Skip to content

Commit d9f77bc

Browse files
committed
Document the streamable HTTP transport change in the migration guide
Records the removal of StreamableHTTPServerTransport.connect() (the transport is now driven per request by the session manager) and the behaviours clarified alongside it, and refreshes the docstrings that still described the old serve_loop wiring.
1 parent fcf3dae commit d9f77bc

3 files changed

Lines changed: 42 additions & 5 deletions

File tree

docs/migration.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,43 @@ When serving streamable HTTP (stateful or `stateless_http=True`), the server's `
721721

722722
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).
723723

724+
### Streamable HTTP: `StreamableHTTPServerTransport` is driven per request, not per stream
725+
726+
`StreamableHTTPServerTransport` no longer exposes a `connect()` context manager yielding a
727+
`(read_stream, write_stream)` pair for you to run a server loop over. Each HTTP request is now
728+
dispatched to the server's handlers directly: a request's outbound messages ride that request's
729+
own response stream (backed by the optional `EventStore` for `Last-Event-ID` resumability), the
730+
client's POSTed answers to server-initiated requests are correlated back by request id, and the
731+
standalone GET stream is a further per-connection channel. The transport is the per-session
732+
core; `StreamableHTTPSessionManager` binds one to the `Server` for each session (via the new
733+
keyword-only `app` / `lifespan_state` constructor arguments) and routes requests to it.
734+
735+
Nothing changes if you serve through `streamable_http_app()` / `run(transport="streamable-http")`
736+
or mount `StreamableHTTPSessionManager` — the wire behaviour (session ids, GET stream, event
737+
store, `ctx.close_sse_stream()`, `related_request_id` routing) is unchanged. Only code that
738+
constructed a transport and consumed `transport.connect()` by hand needs to move to the session
739+
manager:
740+
741+
```python
742+
# Before (v1)
743+
transport = StreamableHTTPServerTransport(mcp_session_id=session_id, ...)
744+
async with transport.connect() as (read_stream, write_stream):
745+
await server.run(read_stream, write_stream, server.create_initialization_options())
746+
747+
# After (v2): let the manager own transports (mount it or use streamable_http_app())
748+
session_manager = StreamableHTTPSessionManager(app=server, event_store=..., json_response=...)
749+
```
750+
751+
Behaviour clarified in the same change:
752+
753+
- In JSON-response mode a server-to-client request (sampling, elicitation) now raises
754+
`NoBackChannelError` — a JSON response has no stream to carry the request, and previously the
755+
call would hang waiting for an answer that could never be delivered.
756+
- Stateful streamable-HTTP handlers see `TransportContext(kind="streamable-http")` carrying the
757+
request `headers`; the stream-pair path previously reported `kind="jsonrpc"` with no headers.
758+
- A GET carrying `Last-Event-ID` on a server without an `EventStore` opens the standalone stream
759+
as a plain GET would, since there is nothing to replay.
760+
724761
### `MCPServer.get_context()` removed
725762

726763
`MCPServer.get_context()` has been removed. Context is now injected by the framework and passed explicitly — there is no ambient ContextVar to read from.

src/mcp/server/lowlevel/server.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -704,8 +704,9 @@ async def run(
704704
Thin wrapper over `serve_dual_era_loop`: enters the server lifespan,
705705
then drives the loop, serving the legacy handshake era and the modern
706706
per-request-envelope era (the client's first request decides which).
707-
Transports with their own lifespan owner (the streamable-HTTP manager)
708-
call `serve_loop` directly instead.
707+
Transports with their own lifespan owner call `serve_loop` directly
708+
instead (or, without a stream pair - the streamable-HTTP manager -
709+
dispatch each request themselves).
709710
"""
710711
async with self.lifespan(self) as lifespan_context:
711712
await serve_dual_era_loop(

tests/interaction/README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -279,9 +279,8 @@ this hits any test that must run statements after a `ClientSession`/`streamable_
279279
but still inside an outer `async with`, and no restructure can avoid it.
280280

281281
A handful of `# pragma: lax no cover` markers in `src/` cover teardown exception handlers whose
282-
execution is timing-dependent under the in-process HTTP bridge — the POST-stream and
283-
stateless-session `except Exception` handlers in `server/streamable_http*.py` and the
284-
`_terminated` check in `message_router`. `strict-no-cover` does not check `lax` lines; do not
282+
execution is timing-dependent under the in-process HTTP bridge — the SSE-writer and replay
283+
`except` handlers in `server/streamable_http.py`. `strict-no-cover` does not check `lax` lines; do not
285284
promote them to strict `no cover` without first making the teardown ordering deterministic. The
286285
suite also relies on a one-line `src/mcp/server/sse.py` fix (`sse_stream_reader.aclose()`) that
287286
closes a stream the SSE leg would otherwise leak.

0 commit comments

Comments
 (0)