Skip to content

Commit ffcc057

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 b7c9a91 commit ffcc057

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
@@ -749,7 +749,7 @@ Transport-specific parameters have been moved off the `MCPServer` constructor an
749749
- `host`, `port` - HTTP server binding, on `run()` only. The app factories have no `port` (`streamable_http_app(port=...)` raises `TypeError`; a mounted app binds wherever the outer ASGI server does) but do take `host` (default `"127.0.0.1"`), used only to decide whether DNS rebinding protection auto-enables (see the note below)
750750
- `sse_path`, `message_path` - SSE transport paths, on `run(transport="sse", ...)` and `sse_app()`
751751
- `streamable_http_path` - StreamableHTTP endpoint path, on `run(transport="streamable-http", ...)` and `streamable_http_app()`
752-
- `json_response`, `stateless_http` - StreamableHTTP behavior, same two places
752+
- `json_response`, `stateless_http` - StreamableHTTP behavior, same two places; each also removes a server-to-client channel, see [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror)
753753
- `max_request_body_size` - StreamableHTTP request-body limit, same two places
754754
- `event_store`, `retry_interval` - StreamableHTTP event handling, same two places
755755
- `transport_security` - DNS rebinding protection, on `run()` for both HTTP transports and on both app methods
@@ -963,8 +963,11 @@ enforce the spec's egress rule: an undeclared capability (form-mode `elicitation
963963
or `tool_choice`) fails the call with a `-32021`
964964
`MISSING_REQUIRED_CLIENT_CAPABILITY` JSON-RPC error instead of sending a
965965
request the client cannot handle. This applies on 2025-11-25 sessions with a
966-
live back-channel too; a session with no back-channel keeps failing with its
967-
no-back-channel error. To migrate, declare the capability: the SDK client
966+
live back-channel too; a pre-`2026-07-28` session with no back-channel
967+
(stateless HTTP, or streamable HTTP with `json_response=True`) keeps failing
968+
with its no-back-channel error. At `2026-07-28` a resolver never uses a
969+
back-channel — it answers with an `InputRequiredResult` — so the `-32021`
970+
check applies there unconditionally. To migrate, declare the capability: the SDK client
968971
declares `elicitation`, `sampling`, and `roots` when the matching callback is
969972
set, and `sampling.tools` needs an explicit
970973
`Client(sampling_capabilities=SamplingCapability(tools=...))`. Direct
@@ -1617,7 +1620,7 @@ Nothing changes for callers of the built-in client: abandoning a call (cancellin
16171620

16181621
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.
16191622

1620-
Server-initiated requests that have no channel to travel on — a legacy session against a `stateless_http=True` server, or any connection negotiated at 2026-07-28 — now raise `NoBackChannelError` instead of stalling as they did in v1 (the transport silently dropped the outbound message), so a stateless-HTTP `ctx.elicit()` that used to hang now fails fast; see [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror) for the exception and the migration paths.
1623+
Server-initiated requests that have no channel to travel on — a legacy session against a `stateless_http=True` server, the request-scoped channel of a stateful legacy session against a `json_response=True` server, or any connection negotiated at 2026-07-28 — now raise `NoBackChannelError` instead of stalling as they did in v1 (the transport silently dropped the outbound message), so a stateless-HTTP or JSON-mode `ctx.elicit()` that used to hang now fails fast; see [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror) for the exception and the migration paths.
16211624

16221625
### Lowlevel `Server`: `request_context` property removed
16231626

@@ -2801,7 +2804,7 @@ rest of this guide stays focused on the v1-to-v2 upgrade itself.
28012804

28022805
The 2026-07-28 protocol has no server-initiated requests, so a handler that reaches back to the client mid-request — `ctx.elicit()`, `ctx.elicit_url()`, `ctx.session.create_message()`, `ctx.session.list_roots()`, or any other `ServerSession` request helper — raises `NoBackChannelError` on such a connection instead of sending. An in-process `Client(server)` negotiates 2026-07-28 by default (see [`Client` defaults to `mode='auto'`](#client-defaults-to-modeauto)), so the first smoke test of an unchanged v1 sampling or elicitation tool fails, and setting `sampling_callback=` / `elicitation_callback=` on the client changes nothing because no request ever reaches the client.
28032806

2804-
`NoBackChannelError` lives in `mcp.shared.exceptions` and subclasses `MCPError` (code `-32600`, message `Cannot send '<method>': this transport context has no back-channel for server-initiated requests.`). Raised inside an `@mcp.tool()` it reaches the client as a top-level JSON-RPC error, not `CallToolResult(is_error=True)` — see [`MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error](#mcperror-raised-from-an-mcptool-handler-now-surfaces-as-a-json-rpc-error) — and the [Troubleshooting](troubleshooting.md) page walks through the client-side traceback. The same exception is raised on a legacy session against a `stateless_http=True` server, where v1 dropped the message and stalled ([`Server.run()` no longer takes a `stateless` flag](#serverrun-no-longer-takes-a-stateless-flag)). Notifications never raise it: `send_log_message()`, `send_tool_list_changed()`, and the other notification helpers are dropped with a debug log where no channel exists, and `UrlElicitationRequiredError` from a tool is unaffected (it is an error response, not a request).
2807+
`NoBackChannelError` lives in `mcp.shared.exceptions` and subclasses `MCPError` (code `-32600`, message `Cannot send '<method>': this transport context has no back-channel for server-initiated requests.`). Raised inside an `@mcp.tool()` it reaches the client as a top-level JSON-RPC error, not `CallToolResult(is_error=True)` — see [`MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error](#mcperror-raised-from-an-mcptool-handler-now-surfaces-as-a-json-rpc-error) — and the [Troubleshooting](troubleshooting.md) page walks through the client-side traceback. The same exception is raised on a legacy session against a `stateless_http=True` server, and on the request-scoped channel of a stateful legacy session against a `json_response=True` server (a JSON body carries exactly one response, so a mid-request `ctx.elicit()` cannot ride it; the session's standalone `GET` stream still carries unrelated messages) — both places v1 dropped the message and stalled ([`Server.run()` no longer takes a `stateless` flag](#serverrun-no-longer-takes-a-stateless-flag)). Notifications never raise it: `send_log_message()`, `send_tool_list_changed()`, and the other notification helpers are dropped with a debug log where no channel exists, and `UrlElicitationRequiredError` from a tool is unaffected (it is an error response, not a request).
28052808

28062809
Two ways to migrate:
28072810

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

@@ -173,8 +174,13 @@ def __init__(
173174
Args:
174175
mcp_session_id: Optional session identifier for this connection.
175176
Must contain only visible ASCII characters (0x21-0x7E).
176-
is_json_response_enabled: If True, return JSON responses for requests
177-
instead of SSE streams. Default is False.
177+
is_json_response_enabled: If True, answer each request POST with a single
178+
JSON body instead of an SSE stream. The body carries
179+
only the response, so `transport_context_for` reports
180+
`can_send_request=False` for it: a server-initiated
181+
request on the request-scoped channel raises
182+
`NoBackChannelError` and request-scoped notifications
183+
are dropped. Default is False.
178184
event_store: Event store for resumability support. If provided,
179185
resumability will be enabled, allowing clients to
180186
reconnect and resume messages.
@@ -212,6 +218,25 @@ def is_terminated(self) -> bool:
212218
"""Check if this transport has been explicitly terminated."""
213219
return self._terminated
214220

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

0 commit comments

Comments
 (0)