Skip to content
37 changes: 37 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,43 @@

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): let the manager own transports (mount it or use streamable_http_app())
session_manager = StreamableHTTPSessionManager(app=server, event_store=..., json_response=...)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
```

Behaviour clarified in the same change:

- In JSON-response mode a server-to-client request (sampling, elicitation) now raises
`NoBackChannelError` — a JSON response has no stream to carry the request, and previously the
call would hang waiting for an answer that could never be delivered.
- Stateful streamable-HTTP handlers see `TransportContext(kind="streamable-http")` carrying the
request `headers`; the stream-pair path previously reported `kind="jsonrpc"` with no headers.
- 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.

Check warning on line 759 in docs/migration.md

View check run for this annotation

Claude / Claude Code Review

migration.md omits the fourth clarified behaviour (concurrent POSTs sharing a JSON-RPC id)

The "Behaviour clarified in the same change" list in docs/migration.md has only three bullets, but the PR description enumerates four clarified behaviours and states they are all documented here — the fourth (two concurrent POSTs sharing a JSON-RPC id each keep their own response stream instead of the second silently taking over the first's queue, pinned by `test_concurrent_posts_reusing_a_request_id_each_receive_their_own_response`) is missing. Add the missing bullet so the migration guide matc
Comment thread
claude[bot] marked this conversation as resolved.

### `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
6 changes: 3 additions & 3 deletions src/mcp/server/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -477,9 +477,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
Loading