Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion docs/run/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new json_response=True bullet's parenthetical "(ctx.elicit(), sampling)" over-claims for sampling: the default sampling API (ctx.session.create_message()) sends with related_request_id=None, so it rides the connection's standalone channel — which never consults the can_send_request flag this PR wires up — and still routes to the GET stream (or silently hangs if none is attached, the out-of-scope case the PR description acknowledges) rather than raising NoBackChannelError. Only request-scoped calls (ctx.elicit(), Resolve-driven sampling, or an explicit related_request_id) get the new fail-fast; consider dropping "sampling" or qualifying it as request-scoped sampling.

Extended reasoning...

What the doc claims vs. what the code does

The new bullet in docs/run/index.md says that under json_response=True "a tool that calls back into the client mid-request (ctx.elicit(), sampling) raises NoBackChannelError on this leg". That is accurate for ctx.elicit() / ctx.elicit_url() and for Resolve-driven sampling, because those stamp related_request_id=ctx.request_id and therefore travel the request-scoped DispatchContext, whose can_send_request this PR sets to False in JSON-response mode. It is not accurate for the default sampling API.

The code path

ServerSession.create_message() defaults related_request_id=None, and ServerSession.send_request() selects the channel with channel = self._request_outbound if related is not None else self._connection.outbound (src/mcp/server/session.py:88-89). With no related id, sampling rides the connection's standalone channel. For a stateful loop session, Connection.for_loop installs the JSONRPCDispatcher itself as that outbound, and its send_raw_request never consults can_send_request — only the per-message DispatchContext does (src/mcp/shared/jsonrpc_dispatcher.py:167). The mcpserver Context has no sampling helper that stamps a related id (only elicit/elicit_url do, src/mcp/server/mcpserver/context.py:185,218), so the ordinary way a tool samples — ctx.session.create_message(...) — goes standalone by default.

Why the new guards don't fire on this path

Both new mechanisms in this PR are keyed to the request-scoped leg. The transport_context_for fail-fast is only checked in DispatchContext.send_raw_request, which the standalone path bypasses. And the new message_router drop branch in src/mcp/server/streamable_http.py requires related_request_id is not None before it fires; a sampling request with no related id falls through to GET_STREAM_KEY as before.

Step-by-step proof

  1. Deploy a stateful server with json_response=True and a tool that calls await ctx.session.create_message([...], max_tokens=10) mid-request.
  2. Connect a legacy (2025-11-25) client that has not opened the standalone GET stream, and call the tool.
  3. create_message builds ServerMessageMetadata(related_request_id=None) (session.py:266) → send_request picks self._connection.outbound → the dispatcher writes the request and parks a waiter, with no can_send_request check anywhere on that path.
  4. message_router sees a JSONRPCRequest with no related id → target_request_id stays None → routes to GET_STREAM_KEY. No GET stream is registered, so the message is logged and dropped.
  5. The waiter never wakes; the tool call and the POST stall indefinitely — the exact standalone-channel hang the PR description explicitly lists as out of scope. The developer who trusted the doc line expected an immediate NoBackChannelError.

(If the client has a GET stream attached, the request is delivered there instead — also not the documented NoBackChannelError.)

Why this is a nit

Nothing in the code is wrong — the PR's actual behavior change is correct, tested, and matches its description. The inaccuracy is one word in one new doc bullet; the neighboring legacy-clients.md note and troubleshooting.md entries are correctly scoped to the request-scoped channel. All verifiers who traced this agreed.

Suggested fix

Drop "sampling" from the parenthetical, or qualify it, e.g.: "a tool that calls back into the client on the request-scoped channel (ctx.elicit(), sampling with a related request id)".

* `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
Expand Down
7 changes: 7 additions & 0 deletions docs/run/legacy-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 9 additions & 6 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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`
Expand Down Expand Up @@ -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
Loading