Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
Serve the 2026-07-28 protocol over stdio: decide the era from the opening message#3151
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
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -187,7 +187,48 @@ Each of these is one idea you now have the vocabulary for; each has its own page | ||
| * `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](../handlers/multi-round-trip.md)**. True to this tier, nothing is installed for you: where `MCPServer` seals `requestState` by default, here the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` performs (**[Protecting `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**). | ||
| * `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives. | ||
| * `on_subscriptions_listen` serves the 2026-07-28 `subscriptions/listen` stream. Pass a `ListenHandler` built over a `SubscriptionBus` and publish events to the bus from your other handlers; see **[Subscriptions](../handlers/subscriptions.md)** for the full composition. | ||
| * `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story. | ||
| * `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream)` drives one connection over a pair of duplex streams (stdio, a socket you framed, an in-memory pair in a test), and that one line is the whole story. Every other hosting shape is the next section. | ||
| ## Serving over your own transport | ||
| `server.run(read_stream, write_stream)` takes any duplex message-stream pair, so a transport is just a source of those two streams. There are four rungs, from the shortest to the most explicit; take the highest one that fits your channel. | ||
| **One connection over a stream pair: `server.run(read, write)`.** stdio is `stdio_server()`, an in-memory pair in a test is the same call. The lifespan is entered for you: one connection, one call. | ||
| **Many connections from a byte-stream listener: `serve_listener(server, listener)`.** A whole Unix-socket server is: | ||
| ```python | ||
| import anyio | ||
| from mcp.server import serve_listener | ||
| async def main() -> None: | ||
| listener = await anyio.create_unix_listener("/tmp/bookshop.sock") | ||
| await serve_listener(server, listener) | ||
| ``` | ||
| `serve_listener` enters the server's lifespan **once**, then frames and serves every connection the listener accepts, and closes the listener when it is cancelled. That is the difference from calling `server.run()` per accepted connection, which would open the lifespan (your database pool, your caches) again for each socket. | ||
| One shared-server behaviour to know before you copy this: open `subscriptions/listen` streams belong to the server's `ListenHandler`, not to a connection, so when one connection's input ends its listen streams are closed gracefully together with those of **every** connection this server is serving; each affected client receives its listen request's result and re-listens. It never bites on stdio (one connection per process), but behind a listener it does. Until listen streams are owned per connection, a host whose departing peer must not touch its neighbours' subscriptions builds a `Server` per accepted connection, each with its own `ListenHandler(bus)`, all driven by `serve_stream(..., lifespan_state=state)` off one entered state (the last rung below); an `MCPServer`, whose one `ListenHandler` is shared, has no such workaround yet. | ||
| **A byte stream you already hold: frame it, then `run`.** `newline_json_transport(byte_stream)` puts the same newline-delimited JSON-RPC wire over any byte stream and yields the pair `run` wants: `async with mcp.server.stdio.newline_json_transport(byte_stream) as (read, write):`, then `server.run(read, write)`. One connection, so the lifespan is again entered for you. | ||
cubic-dev-ai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| **Connections that are not a byte-stream listener: enter the lifespan once, drive each one.** A WebSocket adapter that already yields messages, a broker, your own test loop: compose the same pieces yourself. The rule that bites here is the one `serve_listener` hides: enter `server.lifespan()` **once**, and hand its state to `serve_stream` for every connection, so a hundred sockets share one database pool instead of opening a hundred: | ||
| ```python | ||
| from mcp.server import serve_stream | ||
| async with server.lifespan() as state, anyio.create_task_group() as tg: | ||
| async for read_stream, write_stream in your_connections(): | ||
| tg.start_soon(lambda r=read_stream, w=write_stream: serve_stream(server, r, w, lifespan_state=state)) | ||
| ``` | ||
| The lifespan is entered **outside** the task group on purpose: on the way out the task group joins the still-running connection tasks first and only then does the lifespan tear down, so the shared pool outlives every connection using it (the other order tears the pool down while connections are still being served). | ||
| One thing about the shared-`Server` shape that the single-connection rungs never surface: the cross-connection listen behaviour called out under `serve_listener` above applies to any shared `Server`, including this rung; the per-connection-`Server` recipe there is how you sidestep it. | ||
| An `MCPServer` reaches every one of these through `mcp.lowlevel_server`, and `mcp.close_subscriptions()` (or `Server.close_subscriptions()` down here) ends the open listen streams gracefully from the server's side. | ||
| ## Recap | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -52,12 +52,19 @@ Two things the stream is *not*: | ||||||
| handler on the low-level `Server` and acknowledge a smaller filter than the client asked | ||||||
| for; the acknowledgment is how the client learns what it actually got. | ||||||
| !!! warning "Streamable HTTP only, for now" | ||||||
| `subscriptions/listen` needs a transport that can stream a request's response, which today | ||||||
| means streamable HTTP. Over stdio a 2026-07-28 connection rejects the method with | ||||||
| METHOD_NOT_FOUND, even though `server/discover` advertises the subscription capabilities | ||||||
| there. Serving it over stdio is planned; the open-stream semantics for that transport are | ||||||
| not built yet. | ||||||
| !!! note "Same handler, every transport" | ||||||
| A subscription is a request that stays in flight, so `subscriptions/listen` is served the | ||||||
| same way over stdio (or any duplex stream) as over streamable HTTP: the acknowledgment is | ||||||
| the first frame, events follow, and closing the stream sends the empty result. On stdio a | ||||||
| client ends a subscription by sending `notifications/cancelled` for the listen request | ||||||
| id, and the server sends nothing further for that id. | ||||||
| That silence is this SDK's reading, and not every SDK reads it the same way: the Go and C# | ||||||
| listen handlers return the empty result once the client cancels, so a server built on | ||||||
| them may write the listen request's result as a final frame after your cancel. A client | ||||||
| written against this SDK never notices, because a result arriving for a request it | ||||||
| already ended is a late response and is dropped. If you hand-roll a client, expect that | ||||||
| trailing result from those servers and ignore it. | ||||||
| ## The client end | ||||||
| @@ -109,19 +116,20 @@ mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis)) | ||||||
| The bus carries typed `ServerEvent` values, four small dataclasses, never JSON-RPC. Stamping, filtering, and stream lifecycles stay in the SDK, so a bus implementation cannot break the protocol. It can only move events between processes. | ||||||
| To publish from outside a request, construct the bus yourself so you hold the reference. `MCPServer` builds one internally when you pass nothing, and does not expose it. | ||||||
| To publish from outside a request, use the bus the server owns. `MCPServer` builds one when you pass nothing, and exposes it as `mcp.subscriptions`: | ||||||
| ```python | ||||||
| from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged | ||||||
| from mcp.server.subscriptions import ToolsListChanged | ||||||
| bus = InMemorySubscriptionBus() | ||||||
| mcp = MCPServer("Sprint Board", subscriptions=bus) | ||||||
| mcp = MCPServer("Sprint Board") | ||||||
| async def tools_reloaded() -> None: | ||||||
| await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere | ||||||
| await mcp.subscriptions.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere | ||||||
| ``` | ||||||
| When you want the streams to end from your side (a clean shutdown, an event source going away), `mcp.close_subscriptions()` closes every open stream gracefully: each one drains what it had buffered and then receives the listen request's result as its final frame, which is the spec's way of saying the server ended the subscription deliberately, and the connection carries on. Without it, streams end when their client cancels them or disconnects. | ||||||
| ## The low-level composition | ||||||
| Down on the low-level `Server` there is no pre-wired anything, and the same parts assemble in three lines: | ||||||
| @@ -132,7 +140,7 @@ Down on the low-level `Server` there is no pre-wired anything, and the same part | ||||||
| * You own the bus, so you publish to it directly: `await bus.publish(ResourceUpdated(uri=...))`. Put it wherever your handlers can reach it: module scope here, the lifespan in a bigger app. | ||||||
| * `ListenHandler(bus)` is the same handler `MCPServer` registers, and `on_subscriptions_listen=` is an ordinary handler slot. Put your own callable in that slot for different semantics, and the spec obligations move to you: acknowledge first, stamp every frame with the subscription id, deliver nothing outside the filter. | ||||||
| * `ListenHandler.close()` ends every open stream gracefully. Each one receives the listen request's result as its final frame, which is the spec's way of saying the server ended the subscription deliberately. It returns before those streams finish flushing, so give them a moment before you tear the transport down. Without it, streams end when the client disconnects. | ||||||
| * `ListenHandler.close()` ends every open stream gracefully, and `Server.close_subscriptions()` is the same verb on the server that registered the handler. Each stream drains its buffered events and then receives the listen request's result as its final frame, which is the spec's way of saying the server ended the subscription deliberately. The call returns before those streams finish flushing, so give them a moment before you tear the transport down yourself. Over stdio you rarely need to: when the client's input ends, the driver closes your open streams for you inside a short bounded window, in which each stream's final result is guaranteed to reach the departing peer and any events it still had buffered flush as time allows. Without any close, streams end when the client disconnects. | ||||||
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. P2: Stdio EOF does not guarantee a subscription's final result reaches the peer: draining stops after the two-second Prompt for AI agents
Suggested change
| ||||||
| ## Recap | ||||||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
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.
why even add this, this isn't needed int his PR