You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
-`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)
@@ -812,8 +812,11 @@ enforce the spec's egress rule: an undeclared capability (form-mode `elicitation
812
812
or `tool_choice`) fails the call with a `-32021`
813
813
`MISSING_REQUIRED_CLIENT_CAPABILITY` JSON-RPC error instead of sending a
814
814
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
817
820
declares `elicitation`, `sampling`, and `roots` when the matching callback is
818
821
set, and `sampling.tools` needs an explicit
819
822
`Client(sampling_capabilities=SamplingCapability(tools=...))`. Direct
@@ -1388,11 +1391,11 @@ The method and the raw inbound params are `ctx.method` and `ctx.params` (`params
1388
1391
1389
1392
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.
1390
1393
1391
-
### `Server.run()` no longer takes a `stateless` flag
1394
+
### `Server.run()` no longer takes a `stateless` flag; no-back-channel requests raise `NoBackChannelError`
1392
1395
1393
1396
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.
1394
1397
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.
Copy file name to clipboardExpand all lines: docs/run/index.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -65,7 +65,7 @@ Each transport has its own keyword arguments, all on `run()`:
65
65
66
66
*`host` / `port`: where to listen. Defaults `127.0.0.1` and `8000`.
67
67
*`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.
69
69
*`stateless_http=True`: a fresh transport per request, no session tracking.
70
70
*`max_request_body_size`: largest accepted POST body in bytes. Defaults to 4 MiB; larger requests
71
71
receive HTTP 413 before parsing or session creation. Raise it only when legitimate MCP messages
Copy file name to clipboardExpand all lines: docs/run/legacy-clients.md
+7Lines changed: 7 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -72,6 +72,13 @@ Two things about it matter more than what it does.
72
72
73
73
**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.
74
74
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
+
75
82
!!! check
76
83
Do the wrong thing. `reserve` is the exact tool that just served both clients. Deploy it with
77
84
`stateless_http=True`, connect the same two clients over HTTP, and call it from each.
Copy file name to clipboardExpand all lines: docs/troubleshooting.md
+9-6Lines changed: 9 additions & 6 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -305,7 +305,7 @@ You see this one from `ctx.elicit()` on a legacy connection, and on any connecti
305
305
306
306
## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.`
307
307
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.
309
309
310
310
**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:
311
311
@@ -329,20 +329,23 @@ mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport
329
329
--8<--"docs_src/troubleshooting/tutorial008.py"
330
330
```
331
331
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
+
332
334
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.
333
335
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:
335
337
336
338
```python title="server.py" hl_lines="15-17 21"
337
339
--8<--"docs_src/troubleshooting/tutorial007.py"
338
340
```
339
341
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.
341
343
342
344
!!! check
343
345
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.
346
349
**[Protocol versions](protocol-versions.md)** is the page on what each version has.
* 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=[...])`.
408
411
*`Task group is not initialized` -> a mounted app whose host lifespan never entered `mcp.session_manager.run()`.
409
412
*`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.
411
414
*`Client did not declare the form elicitation capability ...` and `Elicitation not supported` -> the client is missing `elicitation_callback=`.
412
415
*`Invalid or expired requestState` never says why on the wire. The server log does; `unknown key` means share `RequestStateSecurity(keys=[...])` across workers.
0 commit comments