Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion docs/advanced/low-level-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor Author

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


`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.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

**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

Expand Down
2 changes: 1 addition & 1 deletion docs/client/subscriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ The order is the point. Nothing is replayed, so an event published before your s

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.

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

## Streams end

Expand Down
32 changes: 20 additions & 12 deletions docs/handlers/subscriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 _EOF_DRAIN_WINDOW, and the peer may already be unable to read. Describe this shutdown as best-effort so callers do not rely on that final frame.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/handlers/subscriptions.md, line 143:

<comment>Stdio EOF does not guarantee a subscription's final result reaches the peer: draining stops after the two-second `_EOF_DRAIN_WINDOW`, and the peer may already be unable to read. Describe this shutdown as best-effort so callers do not rely on that final frame.</comment>

<file context>
@@ -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.
 
 ## Recap
</file context>
Suggested change
* `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.
* `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 starts closing your open streams inside a short bounded window. Draining is best-effort: the peer may already be gone, and a blocked stream can be stopped before its final result or buffered events are written. Without any close, streams end when the client disconnects.


## Recap

Expand Down
Loading
Loading