Skip to content
53 changes: 53 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,59 @@ When serving streamable HTTP (stateful or `stateless_http=True`), the server's `

Lifespans that set up process-wide state (connection pools, caches, background tasks) are unaffected — they now run once instead of per session/request. If your lifespan was acquiring per-connection resources, move that acquisition into the handler body; per-connection cleanup belongs on the connection's `exit_stack` (a public way to reach it from high-level `@mcp.tool()` handlers is planned).

### Streamable HTTP: `StreamableHTTPServerTransport` is driven per request, not per stream

`StreamableHTTPServerTransport` no longer exposes a `connect()` context manager yielding a
`(read_stream, write_stream)` pair for you to run a server loop over. Each HTTP request is now
dispatched to the server's handlers directly: a request's outbound messages ride that request's
own response stream (backed by the optional `EventStore` for `Last-Event-ID` resumability), the
client's POSTed answers to server-initiated requests are correlated back by request id, and the
standalone GET stream is a further per-connection channel. The transport is the per-session
core; `StreamableHTTPSessionManager` binds one to the `Server` for each session (via the new
keyword-only `app` / `lifespan_state` constructor arguments) and routes requests to it.

Nothing changes if you serve through `streamable_http_app()` / `run(transport="streamable-http")`
or mount `StreamableHTTPSessionManager` — the wire behaviour (session ids, GET stream, event
store, `ctx.close_sse_stream()`, `related_request_id` routing) is unchanged. Only code that
constructed a transport and consumed `transport.connect()` by hand needs to move to the session
manager:

```python
# Before (v1)
transport = StreamableHTTPServerTransport(mcp_session_id=session_id, ...)
async with transport.connect() as (read_stream, write_stream):
await server.run(read_stream, write_stream, server.create_initialization_options())

# After (v2): serve the app the SDK builds ...
app = server.streamable_http_app(event_store=..., json_response=...)
```

... or, when composing your own Starlette/FastAPI app, mount a `StreamableHTTPSessionManager` and
enter `session_manager.run()` in the lifespan — see [Mounting the ASGI app](run/asgi.md) for the
full wiring.

Behaviour clarified in the same change:

- In JSON-response mode a *request-scoped* server-to-client request (`ctx.elicit()`, or any
`ctx.session` call carrying `related_request_id`) now raises `NoBackChannelError` — the POST's
single JSON body has no stream to carry the nested request, and previously the call would hang
waiting for an answer that could never be delivered. Connection-scoped sends (calls without
`related_request_id`) are unchanged and still ride the standalone GET stream.
- A GET carrying `Last-Event-ID` on a server without an `EventStore` opens the standalone stream
as a plain GET would, since there is nothing to replay.
Comment thread
claude[bot] marked this conversation as resolved.
- Two concurrent POSTs that share a JSON-RPC request id each keep their own response stream; the
second no longer silently takes over the first's queue.
- Stream ids handed to your `EventStore` are minted by the transport in its own session-scoped
namespace (previously the raw `str(request_id)` and a single global GET-stream key), so two
sessions sharing one store no longer collide, and a `Last-Event-ID` replay only releases frames
of the requesting session's own streams. Treat the ids as opaque.
- A failing `EventStore.store_event` degrades resumability for that message rather than taking
the stream down: the message is still delivered live (with no event id to resume from) and the
store's exception is logged, never sent to the client.
- A server-to-client request that can reach no client at all (no attached stream and nothing
storing it, or a request-scoped one in JSON-response mode) fails the calling handler with
`CONNECTION_CLOSED` instead of parking it for an answer that cannot arrive.
Comment on lines +923 to +926

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 last bullet of the "Behaviour clarified in the same change" list attributes CONNECTION_CLOSED to "a request-scoped [server-to-client request] in JSON-response mode", but that case never reaches the CONNECTION_CLOSED path — it raises NoBackChannelError (serialized as INVALID_REQUEST), exactly as the first bullet of the same list already documents. Deleting the ", or a request-scoped one in JSON-response mode" parenthetical resolves the self-contradiction; CONNECTION_CLOSED is accurate only for the no-attached-stream-and-nothing-storing-it case.

Extended reasoning...

What the bug is. The new "Behaviour clarified in the same change" list in docs/migration.md gives two contradictory error types for the same scenario. Bullet 1 (lines 908–912) correctly says that in JSON-response mode a request-scoped server-to-client request (ctx.elicit(), or any ctx.session call carrying related_request_id) raises NoBackChannelError. The last bullet (lines 923–925) then says a server-to-client request that can reach no client — "(no attached stream and nothing storing it, or a request-scoped one in JSON-response mode)" — fails the handler with CONNECTION_CLOSED. Both cannot be true, and the code says bullet 1 is right.\n\nThe code path. In JSON-response mode, StreamableHTTPServerTransport._can_send_request is False (self.mcp_session_id is not None and not self.is_json_response_enabled — src/mcp/server/streamable_http.py). The request's dispatch context inherits that via TransportContext(can_send_request=...), so _HTTPRequestDispatchContext.send_raw_request hits if not self.can_send_request: raise NoBackChannelError(method) before _call_over_channel is ever invoked. _call_over_channel is the only site where the CONNECTION_CLOSED conversion can fire (channel.write returning Falseanyio.ClosedResourceError → the correlator surfaces MCPError(CONNECTION_CLOSED)). The gate makes that path unreachable for the JSON-mode request-scoped case.\n\nWhy nothing else corrects it. NoBackChannelError (src/mcp/shared/exceptions.py:55–70) is constructed with code=INVALID_REQUEST (-32600) and its docstring says it serializes to an INVALID_REQUEST error response — not CONNECTION_CLOSED (-32000). The PR's own test pins this: test_json_mode_refuses_a_request_scoped_server_to_client_request (tests/server/test_streamable_http_transport.py) asserts exc_info.value.error.code == INVALID_REQUEST. The PR description's version of this very sentence omits the parenthetical ("no attached stream and nothing storing it" only), which supports this being an editing slip introduced in the doc, not an intended claim.\n\nStep-by-step proof. (1) A client opens a stateful session with json_response=True and POSTs tools/call. (2) The handler calls ctx.session.send_request(ElicitRequest(...), metadata=ServerMessageMetadata(related_request_id=ctx.request_id)) — a request-scoped server-to-client request. (3) The transport built the dispatch context with can_send_request = (mcp_session_id is not None and not is_json_response_enabled) = False. (4) send_raw_request raises NoBackChannelError at the gate; _call_over_channel/RequestCorrelator.call never runs, so no CONNECTION_CLOSED can be produced. (5) The handler's failure is serialized as a JSON-RPC error with code INVALID_REQUEST (-32600), matching the PR's test.\n\nImpact. A migrating user who follows the last bullet and keys error handling on CONNECTION_CLOSED for JSON-mode elicitation/sampling failures will never match the actual INVALID_REQUEST error — and the two bullets in the same list telling them different things makes the guide confusing even before they write code. CONNECTION_CLOSED remains accurate for the case the rest of the bullet describes (connection-scoped request over the standalone stream with no attached GET stream and no event store), which is pinned by test_a_server_request_no_client_can_receive_fails_the_call_instead_of_hanging.\n\nHow to fix. Delete ", or a request-scoped one in JSON-response mode" from the last bullet so it reads "...can reach no client at all (no attached stream and nothing storing it) fails the calling handler with CONNECTION_CLOSED...". Bullet 1 already documents the JSON-mode request-scoped case correctly, so no replacement text is needed. Docs-only: nothing breaks at runtime, hence nit severity.


### `MCPServer.get_context()` removed

`MCPServer.get_context()` has been removed. Context is now injected by the framework and passed explicitly — there is no ambient ContextVar to read from.
Expand Down
5 changes: 3 additions & 2 deletions src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -704,8 +704,9 @@ async def run(
Thin wrapper over `serve_dual_era_loop`: enters the server lifespan,
then drives the loop, serving the legacy handshake era and the modern
per-request-envelope era (the client's first request decides which).
Transports with their own lifespan owner (the streamable-HTTP manager)
call `serve_loop` directly instead.
Transports with their own lifespan owner call `serve_loop` directly
instead (or, without a stream pair - the streamable-HTTP manager -
dispatch each request themselves).
"""
async with self.lifespan(self) as lifespan_context:
await serve_dual_era_loop(
Expand Down
13 changes: 9 additions & 4 deletions src/mcp/server/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,12 @@ async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult:
result = _dump_result(await call(ctx))
if method == "initialize":
# Commit only on chain success, so a middleware veto leaves no state.
# Race-free: the read loop is parked until this call returns.
# Race-free for the session's first handshake: the transport runs no
# other request until it returns (a stream driver's read loop is
# parked here; streamable HTTP holds later requests behind the
# in-progress initialize, and the session id only ships with its
# response). A repeated initialize on an established session (a
# recorded divergence) recommits alongside whatever is running.
# TODO: this re-reads the wire `params`, so a middleware that rewrote
# `ctx.params` (or `ctx.method`, or short-circuited without `call_next`)
# can leave `connection.protocol_version` out of step with the
Expand Down Expand Up @@ -477,9 +482,9 @@ async def serve_loop(
"""Drive ``server`` in handshake-only loop mode over a stream pair until the channel closes.

Builds the loop-mode `JSONRPCDispatcher` + `Connection` and hands them to
`serve_connection`. The streamable-HTTP manager (which owns its lifespan
and serves the modern era on the single-exchange entry instead) calls
this; `Server.run` drives `serve_dual_era_loop`, which extends the same
`serve_connection`. For a transport that supplies a duplex message stream
pair but owns its own lifespan (so `Server.run`'s lifespan entry is not
wanted); `Server.run` drives `serve_dual_era_loop`, which extends the same
dispatcher recipe (notably the `inline_methods={"initialize"}` rule) with
era routing.
"""
Expand Down
Loading