Skip to content

Commit 9cbbd73

Browse files
committed
Fail fast on request-scoped server requests in JSON-response mode
In stateful JSON-response mode a tool handler calling ctx.elicit() (or any server-to-client request tied to the in-flight call) hung forever: the loop dispatcher reported can_send_request=True, the request was routed into the call's own per-request queue, and the JSON POST drain loop silently discarded it, so the parked waiter never woke. The transport now supplies each message's TransportContext (StreamableHTTPServerTransport.transport_context_for): it owns what a request's response can carry, so can_send_request is False in JSON-response mode and the request raises NoBackChannelError at once - the same fail-fast the stateless path and the 2026-07-28 entries already have. serve_loop takes the builder as a keyword and the session manager passes it for both the stateful and stateless legs. The session's standalone GET stream is untouched and still carries unrelated messages.
1 parent 45f2a88 commit 9cbbd73

12 files changed

Lines changed: 141 additions & 36 deletions

docs/migration.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -629,7 +629,7 @@ Transport-specific parameters have been moved from the `MCPServer` constructor t
629629
- `host`, `port` - HTTP server binding
630630
- `sse_path`, `message_path` - SSE transport paths
631631
- `streamable_http_path` - StreamableHTTP endpoint path
632-
- `json_response`, `stateless_http` - StreamableHTTP behavior
632+
- `json_response`, `stateless_http` - StreamableHTTP behavior (each also removes a server-to-client channel; mid-request server requests then raise `NoBackChannelError`, see the `Server.run()` entry below)
633633
- `max_request_body_size` - StreamableHTTP request-body limit
634634
- `event_store`, `retry_interval` - StreamableHTTP event handling
635635
- `transport_security` - DNS rebinding protection
@@ -812,8 +812,11 @@ enforce the spec's egress rule: an undeclared capability (form-mode `elicitation
812812
or `tool_choice`) fails the call with a `-32021`
813813
`MISSING_REQUIRED_CLIENT_CAPABILITY` JSON-RPC error instead of sending a
814814
request the client cannot handle. This applies on 2025-11-25 sessions with a
815-
live back-channel too; a session with no back-channel keeps failing with its
816-
no-back-channel error. To migrate, declare the capability: the SDK client
815+
live back-channel too; a pre-`2026-07-28` session with no back-channel
816+
(stateless HTTP, or streamable HTTP with `json_response=True`) keeps failing
817+
with its no-back-channel error. At `2026-07-28` a resolver never uses a
818+
back-channel — it answers with an `InputRequiredResult` — so the `-32021`
819+
check applies there unconditionally. To migrate, declare the capability: the SDK client
817820
declares `elicitation`, `sampling`, and `roots` when the matching callback is
818821
set, and `sampling.tools` needs an explicit
819822
`Client(sampling_capabilities=SamplingCapability(tools=...))`. Direct
@@ -1388,11 +1391,11 @@ The method and the raw inbound params are `ctx.method` and `ctx.params` (`params
13881391

13891392
Previously it also re-raised exceptions yielded by the transport onto the read stream (e.g. JSON parse errors). Those are now debug-logged and dropped regardless of `raise_exceptions`. If you relied on `run()` exiting on a transport-level parse error, that no longer happens.
13901393

1391-
### `Server.run()` no longer takes a `stateless` flag
1394+
### `Server.run()` no longer takes a `stateless` flag; no-back-channel requests raise `NoBackChannelError`
13921395

13931396
The `stateless: bool` parameter on the lowlevel `Server.run()` has been removed. Stateless serving is now a property of how the connection is constructed (the streamable-HTTP manager builds a born-ready `Connection` per request), not a flag the loop driver inspects.
13941397

1395-
Server-initiated requests that have no channel to travel on now raise `NoBackChannelError` (an `MCPError` subclass) — the same exception regardless of why the channel is absent. In v1 there was no dedicated exception for this case: the transport silently dropped the outbound message and the awaiting call stalled.
1398+
Server-initiated requests that have no channel to travel on now raise `NoBackChannelError` (an `MCPError` subclass) — the same exception regardless of why the channel is absent. The request-scoped channel of a stateful streamable-HTTP session running with `json_response=True` is one of those cases: a JSON body carries exactly one response, so a mid-request `ctx.elicit()` (or any server-to-client request tied to the in-flight call) fails fast with `NoBackChannelError` — an `INVALID_REQUEST` protocol error at the client — instead of stalling the `POST`; request-scoped notifications are dropped as before, and unrelated notifications still ride the standalone `GET` stream. In v1 there was no dedicated exception for this case: the transport silently dropped the outbound message and the awaiting call stalled.
13961399

13971400
### Lowlevel `Server`: `request_context` property removed
13981401

docs/run/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ Each transport has its own keyword arguments, all on `run()`:
6565

6666
* `host` / `port`: where to listen. Defaults `127.0.0.1` and `8000`.
6767
* `streamable_http_path`: where the MCP endpoint lives. Default `/mcp`.
68-
* `json_response=True`: answer with plain JSON instead of an SSE stream.
68+
* `json_response=True`: answer each POST with a single JSON body instead of an SSE stream. That body has room for the response and nothing else, so a tool that calls back into the client mid-request (`ctx.elicit()`, sampling) raises `NoBackChannelError` on this leg, and notifications tied to the in-flight call (progress from `ctx.report_progress()`, per-call log messages) are dropped; the standalone `GET` stream still carries unrelated ones.
6969
* `stateless_http=True`: a fresh transport per request, no session tracking.
7070
* `max_request_body_size`: largest accepted POST body in bytes. Defaults to 4 MiB; larger requests
7171
receive HTTP 413 before parsing or session creation. Raise it only when legitimate MCP messages

docs/run/legacy-clients.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ Two things about it matter more than what it does.
7272

7373
**It costs both server-to-client channels on that leg.** A session that lives for one `POST` has no stream for the server to push a request down and no standalone stream for it to push notifications down. Every server-initiated request raises `NoBackChannelError`: `ctx.elicit()`, the retired sampling and roots calls (**[Deprecated features](../deprecated.md)**), and, yes, `Resolve` asking a *legacy* client its question. Notifications don't even get an error; they are silently dropped.
7474

75+
!!! note
76+
`json_response=True` is not that knob, but it takes half the same cost on *every* legacy
77+
session: a `POST` answered with one JSON body has no stream for the request-scoped channel,
78+
so a mid-request `ctx.elicit()` raises the same `NoBackChannelError` and notifications tied to
79+
the request are dropped. The session's standalone stream is untouched: unrelated notifications
80+
still arrive.
81+
7582
!!! check
7683
Do the wrong thing. `reserve` is the exact tool that just served both clients. Deploy it with
7784
`stateless_http=True`, connect the same two clients over HTTP, and call it from each.

docs/troubleshooting.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ You see this one from `ctx.elicit()` on a legacy connection, and on any connecti
305305

306306
## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.`
307307

308-
Your handler tried to reach the client mid-request, on a connection where nothing can carry a request from the server. There are exactly two ways to be on one.
308+
Your handler tried to reach the client mid-request, on a connection whose call has no channel that can carry a request from the server. There are three server configurations that put a call there.
309309

310310
**A `2026-07-28` connection: any transport, always.** The modern protocol has no server-initiated requests at all, so the server refuses before anything is sent. `ctx.elicit()` inside a tool is the classic way to meet this (on the very first in-memory test, since `Client(server)` negotiates `2026-07-28` without being asked), and passing `elicitation_callback=` changes nothing, because no request ever reaches the client for it to answer:
311311

@@ -329,20 +329,23 @@ mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport
329329
--8<-- "docs_src/troubleshooting/tutorial008.py"
330330
```
331331

332+
**A legacy connection on a `json_response=True` server.** The `POST` is answered with one JSON body, and one body carries only the response, so the request-scoped stream a mid-request `ctx.elicit()` needs does not exist here either. The session, its `Mcp-Session-Id`, and its standalone stream are all still there; only the request-scoped channel is gone.
333+
332334
The message names the method it could not send. `NoBackChannelError` is the class the server raises, but the wire carries only the base `MCPError`, so the sentence above is your traceback's last line, not the class name.
333335

334-
The fix is the same for both: don't reach back mid-call. Move the question into a **resolver** (or return an `InputRequiredResult` yourself) and it becomes part of the *response*, which every connection can carry:
336+
For a `2026-07-28` client the fix is the same on all three: don't reach back mid-call. Move the question into a **resolver** (or return an `InputRequiredResult` yourself) and it becomes part of the *response*, which every connection can carry:
335337

336338
```python title="server.py" hl_lines="15-17 21"
337339
--8<-- "docs_src/troubleshooting/tutorial007.py"
338340
```
339341

340-
Same question, same `elicitation_callback` on the client. The difference is under the hood: a resolver lets the server *return* the question from the call instead of pushing it, so nothing ever flows server-to-client. **[Elicitation](handlers/elicitation.md)** covers resolvers; **[Multi-round-trip requests](handlers/multi-round-trip.md)** covers what happens on the wire.
342+
Same question, same `elicitation_callback` on the client. The difference is under the hood: a resolver lets the server *return* the question from the call instead of pushing it, so nothing ever flows server-to-client. That rescues every `2026-07-28` client, whichever of the three configurations the server is in. A *legacy* client is not rescued by the rewrite alone: `2025-11-25` has no way to return a question, so on a legacy connection the resolver still sends `elicitation/create` down the request-scoped channel, and still needs a server that keeps it — neither `stateless_http=True` nor `json_response=True`. **[Elicitation](handlers/elicitation.md)** covers resolvers; **[Multi-round-trip requests](handlers/multi-round-trip.md)** covers what happens on the wire.
341343

342344
!!! check
343345
The tool with `ctx.elicit()` is not wrong, it is *pre-2026*. Connect with `mode="legacy"`
344-
(the classic `initialize` handshake, spec `2025-11-25` and earlier) to a server that is not
345-
`stateless_http=True`, and it works, because the server-to-client channel exists there.
346+
(the classic `initialize` handshake, spec `2025-11-25` and earlier) to a server that is neither
347+
`stateless_http=True` nor `json_response=True`, and it works, because the server-to-client
348+
channel exists there.
346349
**[Protocol versions](protocol-versions.md)** is the page on what each version has.
347350

348351
## `MCPError: Invalid or expired requestState`
@@ -407,6 +410,6 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key
407410
* One 421, three spellings: `Server returned an error response` (the python `Client`), `421 Misdirected Request` / `Invalid Host header` (everything else), `Invalid Host header: <host>` (the server log). Fix: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`.
408411
* `Task group is not initialized` -> a mounted app whose host lifespan never entered `mcp.session_manager.run()`.
409412
* `Session not found` -> the server restarted; reconnect.
410-
* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` needs a server-to-client channel: a `2026-07-28` connection never has one, and `stateless_http=True` takes away the legacy one. Use a resolver. Its neighbour `Method not found` is a request for a method the other side's protocol revision doesn't have.
413+
* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` needs a server-to-client channel: a `2026-07-28` connection never has one, `stateless_http=True` takes away the legacy one, and `json_response=True` takes away the request-scoped one. Use a resolver (a legacy client also needs a server that keeps the channel). Its neighbour `Method not found` is a request for a method the other side's protocol revision doesn't have.
411414
* `Client did not declare the form elicitation capability ...` and `Elicitation not supported` -> the client is missing `elicitation_callback=`.
412415
* `Invalid or expired requestState` never says why on the wire. The server log does; `unknown key` means share `RequestStateSecurity(keys=[...])` across workers.

src/mcp/server/runner.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
import contextvars
1717
import logging
18-
from collections.abc import AsyncIterator, Awaitable, Mapping
18+
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
1919
from contextlib import asynccontextmanager
2020
from dataclasses import KW_ONLY, dataclass, replace
2121
from functools import cached_property, partial
@@ -473,6 +473,7 @@ async def serve_loop(
473473
session_id: str | None = None,
474474
init_options: InitializationOptions | None = None,
475475
raise_exceptions: bool = False,
476+
transport_builder: Callable[[MessageMetadata], TransportContext] | None = None,
476477
) -> None:
477478
"""Drive ``server`` in handshake-only loop mode over a stream pair until the channel closes.
478479
@@ -482,10 +483,20 @@ async def serve_loop(
482483
this; `Server.run` drives `serve_dual_era_loop`, which extends the same
483484
dispatcher recipe (notably the `inline_methods={"initialize"}` rule) with
484485
era routing.
486+
487+
Args:
488+
transport_builder: Builds each inbound message's `TransportContext` from
489+
its `SessionMessage.metadata`. Omitted, every message reads as a full
490+
duplex pipe (`can_send_request=True`) - the truth for a plain stream
491+
pair; a transport whose response cannot carry a server-initiated
492+
request (streamable HTTP in JSON-response mode) passes its own
493+
`StreamableHTTPServerTransport.transport_context_for` so the
494+
request-scoped channel refuses instead of stalling.
485495
"""
486496
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
487497
read_stream,
488498
write_stream,
499+
transport_builder=transport_builder,
489500
raise_handler_exceptions=raise_exceptions,
490501
# Handle `initialize` inline so a client that pipelines it with the
491502
# next request (spec: SHOULD NOT, not MUST NOT) sees the initialized

src/mcp/server/streamable_http.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@
4444
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
4545
from mcp.shared._stream_protocols import ReadStream, WriteStream
4646
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
47-
from mcp.shared.message import ServerMessageMetadata, SessionMessage
47+
from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage
48+
from mcp.shared.transport_context import TransportContext
4849

4950
logger = logging.getLogger(__name__)
5051

@@ -166,8 +167,13 @@ def __init__(
166167
Args:
167168
mcp_session_id: Optional session identifier for this connection.
168169
Must contain only visible ASCII characters (0x21-0x7E).
169-
is_json_response_enabled: If True, return JSON responses for requests
170-
instead of SSE streams. Default is False.
170+
is_json_response_enabled: If True, answer each request POST with a single
171+
JSON body instead of an SSE stream. The body carries
172+
only the response, so `transport_context_for` reports
173+
`can_send_request=False` for it: a server-initiated
174+
request on the request-scoped channel raises
175+
`NoBackChannelError` and request-scoped notifications
176+
are dropped. Default is False.
171177
event_store: Event store for resumability support. If provided,
172178
resumability will be enabled, allowing clients to
173179
reconnect and resume messages.
@@ -205,6 +211,25 @@ def is_terminated(self) -> bool:
205211
"""Check if this transport has been explicitly terminated."""
206212
return self._terminated
207213

214+
def transport_context_for(self, metadata: MessageMetadata) -> TransportContext:
215+
"""Build the `TransportContext` for an inbound message this transport delivered.
216+
217+
The transport owns what a request's response can carry, so it supplies
218+
the loop dispatcher's `transport_builder`. A JSON body holds exactly one
219+
JSON-RPC response, so in JSON-response mode the request-scoped channel has
220+
no room for a server-initiated request and `can_send_request` is `False`:
221+
`ctx.elicit()` raises `NoBackChannelError` at once instead of parking a
222+
waiter no reply can reach. An SSE response streams whatever the handler
223+
emits. The connection's standalone GET stream is a separate channel and
224+
is not described here.
225+
"""
226+
request = metadata.request_context if isinstance(metadata, ServerMessageMetadata) else None
227+
return TransportContext(
228+
kind="streamable-http",
229+
can_send_request=not self.is_json_response_enabled,
230+
headers=request.headers if isinstance(request, Request) else None,
231+
)
232+
208233
def close_sse_stream(self, request_id: RequestId) -> None:
209234
"""Close SSE connection for a specific request without terminating the stream.
210235

src/mcp/server/streamable_http_manager.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import logging
77
from collections import deque
88
from collections.abc import AsyncIterator
9+
from dataclasses import replace
910
from typing import TYPE_CHECKING, Any, Final
1011
from uuid import uuid4
1112

@@ -214,12 +215,14 @@ async def run_stateless_server(*, task_status: TaskStatus[None] = anyio.TASK_STA
214215
read_stream,
215216
write_stream,
216217
inline_methods=frozenset({"initialize"}),
217-
# No session ID means a server-to-client request can be
218-
# written to this POST's response stream, but the client's
219-
# reply has nowhere to land — `can_send_request=False`
220-
# makes the per-request channel raise `NoBackChannelError`
221-
# for requests while still allowing notifications.
222-
transport_builder=lambda _md: TransportContext(kind="streamable-http", can_send_request=False),
218+
# The transport says what its response stream can carry; on
219+
# top of that, no session ID means the client's reply to a
220+
# server request has nowhere to land, so the request-scoped
221+
# channel refuses (`NoBackChannelError`) even in SSE mode
222+
# while still forwarding notifications.
223+
transport_builder=lambda md: replace(
224+
http_transport.transport_context_for(md), can_send_request=False
225+
),
223226
)
224227
# Born-ready, no standalone channel: the legacy stateless path
225228
# never opens a GET stream and need not see `initialize`. The
@@ -320,13 +323,15 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
320323
with idle_scope:
321324
# Drive via `serve_loop` (not `Server.run()`) so the
322325
# manager's already-entered lifespan is reused
323-
# rather than re-entered per session.
326+
# rather than re-entered per session; the transport
327+
# rules on what each request's response can carry.
324328
await serve_loop(
325329
self.app,
326330
read_stream,
327331
write_stream,
328332
lifespan_state=self._lifespan_state,
329333
session_id=http_transport.mcp_session_id,
334+
transport_builder=http_transport.transport_context_for,
330335
)
331336

332337
if idle_scope.cancelled_caught:

0 commit comments

Comments
 (0)