-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Rebuild the streamable HTTP server transport around per-request dispatch #3192
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
920f8a8
ef159e7
fcf3dae
d9f77bc
edb0abd
1e3be94
47e156d
5b1275b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -863,7 +863,7 @@ | |
|
|
||
| ### Streamable HTTP: session manager, `EventStore`, and stateless mode unchanged | ||
|
|
||
| Beyond the constructor parameters that moved to `run()`/`streamable_http_app()` and the lifespan change above, the server-side Streamable HTTP machinery is as in v1: | ||
| Beyond the constructor parameters that moved to `run()`/`streamable_http_app()`, the lifespan change above, and the transport rework in the next section (which does not touch the public surface), the server-side Streamable HTTP API is as in v1: | ||
|
Check warning on line 866 in docs/migration.md
|
||
|
|
||
| - `mcp.server.streamable_http` still exports the `EventStore` ABC (`store_event()`, `replay_events_after()`), `EventMessage`, `EventCallback`, `EventId`, and `StreamId` with unchanged signatures; a custom `EventStore` keeps importing `JSONRPCMessage` from `mcp.types`, unchanged. | ||
| - `StreamableHTTPSessionManager` keeps its constructor and its `run()` / `handle_request()` methods (see [Lowlevel `Server`: what did not change](#lowlevel-server-what-did-not-change)); its `stateless=` parameter is unrelated to the removed [`Server.run(stateless=)` flag](#serverrun-no-longer-takes-a-stateless-flag). | ||
|
|
@@ -872,6 +872,59 @@ | |
|
|
||
| Only private attributes moved: `mcp._mcp_server` is now `mcp._lowlevel_server` (see [Registering lowlevel handlers from `MCPServer`](#registering-lowlevel-handlers-from-mcpserver)), and `_session_manager` now lives on that lowlevel `Server`. Prefer the public `mcp.session_manager` property to either. | ||
|
|
||
| ### 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. | ||
|
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. | ||
|
Check warning on line 926 in docs/migration.md
|
||
|
Comment on lines
+923
to
+926
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Extended reasoning...What the bug is. The new "Behaviour clarified in the same change" list in |
||
|
|
||
| ### `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. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 The parenthetical at docs/migration.md:866 says the transport rework "does not touch the public surface", but the very next section (added by this same PR) documents that the rework removes the public
StreamableHTTPServerTransport.connect()context manager and adds keyword-onlyapp/lifespan_stateconstructor arguments — the PR description itself callsconnect()removal "the one API removal". Consider rewording to something like "(whose only public-surface change is the removal oftransport.connect(), covered there)" so a reader skimming the "unchanged" section doesn't conclude no migration is needed.Extended reasoning...
What the issue is. This PR edits the sentence at
docs/migration.md:866(in the "Streamable HTTP: session manager,EventStore, and stateless mode unchanged" section) to read: "Beyond the constructor parameters that moved torun()/streamable_http_app(), the lifespan change above, and the transport rework in the next section (which does not touch the public surface), the server-side Streamable HTTP API is as in v1". The parenthetical claims the transport rework has no public-surface impact.Why it's contradicted three paragraphs later. The very next section this PR adds — "Streamable HTTP:
StreamableHTTPServerTransportis driven per request, not per stream" (starting at line 875) — opens with: "StreamableHTTPServerTransportno longer exposes aconnect()context manager…" and documents the new keyword-onlyapp/lifespan_stateconstructor arguments, complete with a Before/After migration snippet for users who calledtransport.connect()by hand. The PR's own description calls theconnect()removal "the one API removal" and checks the Breaking-change box, and per AGENTS.md the change is (correctly) documented indocs/migration.md— so by the PR's own accounting the rework does touch the public surface.Step-by-step walk-through of the contradiction. (1) A v1 user who hand-constructed a transport and drove it via
async with transport.connect()reads the migration guide top to bottom. (2) They reach the "unchanged" section, which tells them the transport rework in the next section "does not touch the public surface" — signalling the next section is internal detail they can skip. (3) They skip it and miss the one migration they actually need: moving fromtransport.connect()toStreamableHTTPSessionManager(orstreamable_http_app()). (4) On upgrade, their code fails withAttributeError: 'StreamableHTTPServerTransport' object has no attribute 'connect', and the guide's own framing told them nothing applied to them.The defensible readings don't hold up. One could argue "public surface" means the module-level exports the bulleted list enumerates (
EventStore,EventMessage,EventCallback, etc.), which are indeed unchanged — andStreamableHTTPServerTransportis not inmcp.__init__'s__all__. But the sentence structure has the parenthetical directly modifying "the transport rework", not the export list; and the doc's own next section treatsconnect()as an exposed public API being removed (it provides a migration snippet, which only makes sense for public API). If the rework genuinely didn't touch the public surface, it wouldn't need to be listed as an exception to "as in v1" at all.Impact. No code behaviour is affected — this is purely a documentation-accuracy issue. The breaking change is fully documented in the adjacent section, so a careful reader still finds it; the risk is limited to a skimming reader taking the "unchanged" section's parenthetical at face value and skipping the section that applies to them.
How to fix. Drop or reword the parenthetical, e.g.: "…and the transport rework in the next section (whose only public-surface change is the removal of
transport.connect(), covered there), the server-side Streamable HTTP API is as in v1". A one-line edit; no other text needs to change.