-
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 7 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 |
|---|---|---|
|
|
@@ -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. | ||
| - 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
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. | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.