Skip to content

Commit 98bd125

Browse files
committed
Serve the 2026-07-28 protocol over stdio by deciding the era from the opening message
The 2026-07-28 protocol did not work over stdio: subscriptions/listen was hard-refused, a legacy initialize arriving during an in-flight modern request was accepted and re-locked the connection, and a peer cancel produced a trailing "Request cancelled" frame. All three share one root cause: the connection's era was derived from which requests had completed instead of being decided once, in wire order, from how the client opened the connection. Replace serve_dual_era_loop and serve_loop with a single serve_stream driver that decides the era synchronously in the dispatcher's read loop, before the request body is spawned. initialize (or any envelope-less request) opens the legacy era, an enveloped request opens the modern era, server/discover is answered without pinning, and a stray leading notification opens nothing. A conflicting era claim on a committed connection is refused (-32022 or -32600) rather than silently switching. Cancel silence is structural: each request answers through a one-shot channel whose write target becomes a powerless void on a peer cancel, so there is no cancelled-check at any write site. The generic JSON-RPC dispatcher loses the code-0 cancel frame, the code-0 str(exc) catch-all, and the inline_methods knob, and documents that handlers are invoked synchronously in receive order with the returned awaitable as the body. Add a Posture enum (DUAL default, LEGACY_ONLY, MODERN_ONLY) on the Server and MCPServer constructors, honoured by the stream driver and the streamable-HTTP manager alike. Server.run(read, write) now stands alone, Server.lifespan() is a bound context manager, and serve_listener / newline_json_transport / close_subscriptions() give a straightforward path for custom transports. See docs/migration.md for the full list of observable changes.
1 parent 837ef90 commit 98bd125

65 files changed

Lines changed: 4284 additions & 1744 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/advanced/low-level-server.md

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,51 @@ Each of these is one idea you now have the vocabulary for; each has its own page
187187
* `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)**).
188188
* `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.
189189
* `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.
190-
* `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.
190+
* `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.
191+
192+
## Serving over your own transport
193+
194+
`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.
195+
196+
**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.
197+
198+
**Many connections from a byte-stream listener: `serve_listener(server, listener)`.** A whole Unix-socket server is:
199+
200+
```python
201+
import anyio
202+
203+
from mcp.server import serve_listener
204+
205+
206+
async def main() -> None:
207+
listener = await anyio.create_unix_listener("/tmp/bookshop.sock")
208+
await serve_listener(server, listener)
209+
```
210+
211+
`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.
212+
213+
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.
214+
215+
**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.
216+
217+
**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:
218+
219+
```python
220+
from mcp.server import serve_stream
221+
222+
async with server.lifespan() as state, anyio.create_task_group() as tg:
223+
async for read_stream, write_stream in your_connections():
224+
tg.start_soon(lambda r=read_stream, w=write_stream: serve_stream(server, r, w, lifespan_state=state))
225+
```
226+
227+
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).
228+
229+
Two things about the shared-`Server` shape that the single-connection rungs never surface:
230+
231+
* Which protocol eras a server offers is `Server(posture=...)` (`Posture.DUAL` by default, `Posture.MODERN_ONLY`, `Posture.LEGACY_ONLY`); it rides along on the server object, so none of these drivers takes a posture argument you could forget.
232+
* 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.
233+
234+
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.
191235

192236
## Recap
193237

docs/client/subscriptions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ The order is the point. Nothing is replayed, so an event published before your s
5757

5858
Requests run freely beside an open stream, from the watcher task or any other, on the same client. Because *duplicate* unconsumed events coalesce, a busy main flow may produce one refetch rather than three. Events that differ do not coalesce: a filter naming many URIs queues one pending event per URI.
5959

60-
To stop watching, leave the block: there is no `unsubscribe` call. Cancelling the task that owns the block does that for you, and the SDK cancels the listen request the way the transport expects: over streamable HTTP, by closing that request's stream. A watcher that runs for the life of your app never returns on its own, so cancel it, or its task group's scope, at shutdown.
60+
To stop watching, leave the block: there is no `unsubscribe` call. Cancelling the task that owns the block does that for you, and the SDK cancels the listen request the way the transport expects: over streamable HTTP by closing that request's stream, and over stdio (or any duplex stream) by sending `notifications/cancelled` for the listen request's id, after which a server built on this SDK writes nothing further for it. A watcher that runs for the life of your app never returns on its own, so cancel it, or its task group's scope, at shutdown.
6161

6262
## Streams end
6363

docs/handlers/subscriptions.md

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,19 @@ Two things the stream is *not*:
5252
handler on the low-level `Server` and acknowledge a smaller filter than the client asked
5353
for; the acknowledgment is how the client learns what it actually got.
5454

55-
!!! warning "Streamable HTTP only, for now"
56-
`subscriptions/listen` needs a transport that can stream a request's response, which today
57-
means streamable HTTP. Over stdio a 2026-07-28 connection rejects the method with
58-
METHOD_NOT_FOUND, even though `server/discover` advertises the subscription capabilities
59-
there. Serving it over stdio is planned; the open-stream semantics for that transport are
60-
not built yet.
55+
!!! note "Same handler, every transport"
56+
A subscription is a request that stays in flight, so `subscriptions/listen` is served the
57+
same way over stdio (or any duplex stream) as over streamable HTTP: the acknowledgment is
58+
the first frame, events follow, and closing the stream sends the empty result. On stdio a
59+
client ends a subscription by sending `notifications/cancelled` for the listen request
60+
id, and the server sends nothing further for that id.
61+
62+
That silence is this SDK's reading, and not every SDK reads it the same way: the Go and C#
63+
listen handlers return the empty result once the client cancels, so a server built on
64+
them may write the listen request's result as a final frame after your cancel. A client
65+
written against this SDK never notices, because a result arriving for a request it
66+
already ended is a late response and is dropped. If you hand-roll a client, expect that
67+
trailing result from those servers and ignore it.
6168

6269
## The client end
6370

@@ -109,19 +116,20 @@ mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis))
109116

110117
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.
111118

112-
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.
119+
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`:
113120

114121
```python
115-
from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged
122+
from mcp.server.subscriptions import ToolsListChanged
116123

117-
bus = InMemorySubscriptionBus()
118-
mcp = MCPServer("Sprint Board", subscriptions=bus)
124+
mcp = MCPServer("Sprint Board")
119125

120126

121127
async def tools_reloaded() -> None:
122-
await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere
128+
await mcp.subscriptions.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere
123129
```
124130

131+
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.
132+
125133
## The low-level composition
126134

127135
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
132140

133141
* 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.
134142
* `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.
135-
* `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.
143+
* `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.
136144

137145
## Recap
138146

0 commit comments

Comments
 (0)