Fail fast on server-to-client requests in JSON-response mode instead of hanging (#3195)
This commit is contained in:
parent
27f5cc7a46
commit
528e366558
4 changed files with 259 additions and 93 deletions
|
|
@ -749,7 +749,7 @@ Transport-specific parameters have been moved off the `MCPServer` constructor an
|
|||
- `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)
|
||||
- `sse_path`, `message_path` - SSE transport paths, on `run(transport="sse", ...)` and `sse_app()`
|
||||
- `streamable_http_path` - StreamableHTTP endpoint path, on `run(transport="streamable-http", ...)` and `streamable_http_app()`
|
||||
- `json_response`, `stateless_http` - StreamableHTTP behavior, same two places
|
||||
- `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)
|
||||
- `max_request_body_size` - StreamableHTTP request-body limit, same two places
|
||||
- `event_store`, `retry_interval` - StreamableHTTP event handling, same two places
|
||||
- `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
|
|||
or `tool_choice`) fails the call with a `-32021`
|
||||
`MISSING_REQUIRED_CLIENT_CAPABILITY` JSON-RPC error instead of sending a
|
||||
request the client cannot handle. This applies on 2025-11-25 sessions with a
|
||||
live back-channel too; a session with no back-channel keeps failing with its
|
||||
no-back-channel error. To migrate, declare the capability: the SDK client
|
||||
live back-channel too; a pre-`2026-07-28` session with no back-channel
|
||||
(stateless HTTP, or streamable HTTP with `json_response=True`) keeps failing
|
||||
with its no-back-channel error. At `2026-07-28` a resolver never uses a
|
||||
back-channel — it answers with an `InputRequiredResult` — so the `-32021`
|
||||
check applies there unconditionally. To migrate, declare the capability: the SDK client
|
||||
declares `elicitation`, `sampling`, and `roots` when the matching callback is
|
||||
set, and `sampling.tools` needs an explicit
|
||||
`Client(sampling_capabilities=SamplingCapability(tools=...))`. Direct
|
||||
|
|
@ -1617,7 +1620,7 @@ Nothing changes for callers of the built-in client: abandoning a call (cancellin
|
|||
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Lowlevel `Server`: `request_context` property removed
|
||||
|
||||
|
|
@ -2801,7 +2804,7 @@ rest of this guide stays focused on the v1-to-v2 upgrade itself.
|
|||
|
||||
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.
|
||||
|
||||
`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).
|
||||
`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).
|
||||
|
||||
Two ways to migrate:
|
||||
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ Each transport has its own keyword arguments, all on `run()`:
|
|||
|
||||
* `host` / `port`: where to listen. Defaults `127.0.0.1` and `8000`.
|
||||
* `streamable_http_path`: where the MCP endpoint lives. Default `/mcp`.
|
||||
* `json_response=True`: answer with plain JSON instead of an SSE stream.
|
||||
* `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.
|
||||
* `stateless_http=True`: a fresh transport per request, no session tracking.
|
||||
* `max_request_body_size`: largest accepted POST body in bytes. Defaults to 4 MiB; larger requests
|
||||
receive HTTP 413 before parsing or session creation. Raise it only when legitimate MCP messages
|
||||
|
|
|
|||
|
|
@ -72,6 +72,13 @@ Two things about it matter more than what it does.
|
|||
|
||||
**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.
|
||||
|
||||
!!! note
|
||||
`json_response=True` is not that knob, but it takes half the same cost on *every* legacy
|
||||
session: a `POST` answered with one JSON body has no stream for the request-scoped channel,
|
||||
so a mid-request `ctx.elicit()` raises the same `NoBackChannelError` and notifications tied to
|
||||
the request are dropped. The session's standalone stream is untouched: unrelated notifications
|
||||
still arrive.
|
||||
|
||||
!!! check
|
||||
Do the wrong thing. `reserve` is the exact tool that just served both clients. Deploy it with
|
||||
`stateless_http=True`, connect the same two clients over HTTP, and call it from each.
|
||||
|
|
|
|||
|
|
@ -305,7 +305,7 @@ You see this one from `ctx.elicit()` on a legacy connection, and on any connecti
|
|||
|
||||
## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.`
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**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:
|
||||
|
||||
|
|
@ -329,20 +329,23 @@ mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport
|
|||
--8<-- "docs_src/troubleshooting/tutorial008.py"
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
```python title="server.py" hl_lines="15-17 21"
|
||||
--8<-- "docs_src/troubleshooting/tutorial007.py"
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
!!! check
|
||||
The tool with `ctx.elicit()` is not wrong, it is *pre-2026*. Connect with `mode="legacy"`
|
||||
(the classic `initialize` handshake, spec `2025-11-25` and earlier) to a server that is not
|
||||
`stateless_http=True`, and it works, because the server-to-client channel exists there.
|
||||
(the classic `initialize` handshake, spec `2025-11-25` and earlier) to a server that is neither
|
||||
`stateless_http=True` nor `json_response=True`, and it works, because the server-to-client
|
||||
channel exists there.
|
||||
**[Protocol versions](protocol-versions.md)** is the page on what each version has.
|
||||
|
||||
## `MCPError: Invalid or expired requestState`
|
||||
|
|
@ -407,6 +410,6 @@ mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key
|
|||
* 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=[...])`.
|
||||
* `Task group is not initialized` -> a mounted app whose host lifespan never entered `mcp.session_manager.run()`.
|
||||
* `Session not found` -> the server restarted; reconnect.
|
||||
* `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.
|
||||
* `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.
|
||||
* `Client did not declare the form elicitation capability ...` and `Elicitation not supported` -> the client is missing `elicitation_callback=`.
|
||||
* `Invalid or expired requestState` never says why on the wire. The server log does; `unknown key` means share `RequestStateSecurity(keys=[...])` across workers.
|
||||
|
|
|
|||
Loading…
Reference in a new issue