Add the client-side subscriptions/listen driver (#3047)

This commit is contained in:
Max 2026-07-07 14:26:09 +01:00 • committed by GitHub
commit 9bdc03d54e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 2587 additions and 295 deletions

View file

@ -53,7 +53,7 @@ One rule sits above `"use"`: **calls carrying `meta` always reach the server.**
To turn caching off entirely, construct with `Client(server, cache=False)`: every call is a round trip again, and `cache_mode`, while still accepted, does nothing.
Scope is honored automatically too: `"private"` entries are keyed to the cache's *partition* (below), while `"public"` ones may opt into wider sharing. And **notifications beat TTL** for the exact entries they name: a `list_changed` notification evicts the matching cached listing, and `resources/updated` evicts the cached read stored under exactly its URI, however fresh they were.
Scope is honored automatically too: `"private"` entries are keyed to the cache's *partition* (below), while `"public"` ones may opt into wider sharing. And **notifications beat TTL** for the exact entries they name: a `list_changed` notification evicts the matching cached listing, and `resources/updated` evicts the cached read stored under exactly its URI, however fresh they were. On a 2026-07-28 connection those notifications arrive on a `subscriptions/listen` stream you open with `client.listen(...)`, and eviction completes before your watcher sees the event; **[Subscriptions](subscriptions.md)** is that page.
One caveat on `resources/updated`: eviction is exact-URI only. The store contract has no enumerate or scan operation (same as the reference TypeScript implementation), so a notification carrying a *sub*-resource URI does not evict a cached read of its parent. If your server signals sub-resources this way, refetch the parent with `cache_mode="refresh"`.

View file

@ -145,7 +145,7 @@ The resource verbs come in pairs: two ways to list, one way to read.
`read_resource` returns `contents`, a list of `TextResourceContents` or `BlobResourceContents`. Same idea as tool content: narrow with `isinstance`, then read `.text` (or `.blob`).
A client can also be told when a resource changes. On 2025-era connections that is `subscribe_resource(uri)` / `unsubscribe_resource(uri)` - a method pair `MCPServer` doesn't implement, so on the 2026-07-28 wire (where those verbs no longer exist) the request answers `-32601`, *Method not found*. The 2026 replacement is a `subscriptions/listen` stream, which `MCPServer` *does* serve - `server_capabilities.resources.subscribe` is `True` there, and the server side of the story is **[Subscriptions](../handlers/subscriptions.md)**.
A client can also be told when a resource changes. On 2025-era connections that is `subscribe_resource(uri)` / `unsubscribe_resource(uri)` - a method pair `MCPServer` doesn't implement, so on the 2026-07-28 wire (where those verbs no longer exist) the request answers `-32601`, *Method not found*. The 2026 replacement is a `subscriptions/listen` stream, which `MCPServer` *does* serve - `server_capabilities.resources.subscribe` is `True` there - and consuming it with `client.listen(...)` is this section's **[Subscriptions](subscriptions.md)** page.
## Prompts

View file

@ -0,0 +1,86 @@
# Subscriptions
A server's catalog is not fixed. Tools appear at runtime, and the content behind a resource URI changes. A client hears about it through `client.listen(...)`: one `subscriptions/listen` request whose response *is* the stream. It stays open and carries the change notifications the client asked for.
This page is the client end: opening the stream, watching it beside your main flow, and handling its endings. Publishing changes, filtering, and serving the method are the server's side of the story, told in **[Subscriptions](../handlers/subscriptions.md)** under *Inside your handler*. The examples here talk to the sprint-board server built there.
## Watching the stream
A subscription is one context manager. Entering it sends the request, with your keyword arguments as the subscription filter, and waits for the server's acknowledgment, so the stream is live by the time the block starts.
```python title="client.py" hl_lines="16 19 29"
--8<-- "docs_src/subscriptions/tutorial003.py"
```
Iteration yields four typed events: `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged`, and `ResourceUpdated(uri=...)`.
An event says *what* changed, never *how*. That is why `follow_board` calls `read_resource` and `list_tools`: the event is a cue to refetch. Read `event.uri` rather than assuming which resource moved: a filter can name several URIs, and a server may report a change on a sub-resource of one of them.
Duplicate events waiting to be consumed collapse into one, and refetching still gets you the current state. Only identical events collapse: two `ResourceUpdated` for different URIs are two events.
Two more properties of the handle:
* `sub.honored` is the filter the server acknowledged: a `SubscriptionFilter` with the fields you passed, read as attributes (`sub.honored.prompts_list_changed`). `MCPServer` honors every kind you ask for, so it echoes your request back. A server that narrows the filter (see the [filter warning](../handlers/subscriptions.md#only-what-was-asked-for) on the server page) acknowledges less, and an honored kind may still never fire.
* `sub.subscription_id` is the listen request's id, the one stamped on every frame of this stream. Several subscriptions can be open at once, each demultiplexed by its own id.
## Watching without blocking
`follow_board` runs until the server closes the stream, which may be never, so on its own it owns your program. Real clients want the watcher *beside* the main flow: an agent calls tools while a watcher keeps a cache or a UI current.
Open the subscription first, then start the watcher and get on with your work.
=== "asyncio"
```python title="app.py" hl_lines="18 20"
--8<-- "docs_src/subscriptions/tutorial004_asyncio.py"
```
=== "trio"
```python title="app.py" hl_lines="18 21"
--8<-- "docs_src/subscriptions/tutorial004_trio.py"
```
=== "anyio"
```python title="app.py" hl_lines="18 21"
--8<-- "docs_src/subscriptions/tutorial004_anyio.py"
```
!!! note
`app.py` imports `BOARD` and `read_board` from the first example, which this repo stores as
`tutorial003.py`. If you save the rendered files side by side as `client.py` and `app.py`,
write `from client import BOARD, read_board` instead. The `watch.py` example further down
imports `read_board` the same way.
The order is the point. Nothing is replayed, so an event published before your stream existed is missed. Entering `client.listen(...)` waits for the acknowledgment, so every change from that moment on reaches your watcher, and the snapshot you take inside the block cannot miss one.
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.
## Streams end
A stream ends in one of two ways, both ordinary control flow. A graceful server close ends the `async for`; an abrupt drop raises `SubscriptionLost`.
The difference is diagnostic, not a difference in what to do next: the stream is gone, nothing was replayed, and a watcher that still cares re-listens and refetches.
```python title="watch.py" hl_lines="16 20"
--8<-- "docs_src/subscriptions/tutorial005.py"
```
Servers close streams gracefully for their own reasons, including shedding a subscriber whose backlog grew too large, so a clean end is not a signal to stop watching. Back off before re-listening.
`SubscriptionLost` has one local cause too. The client holds at most 1024 unconsumed events, and a consumer that falls that far behind loses the subscription rather than grow without bound. Keep the body of the `async for` short and do slow work elsewhere.
`keep_following` catches only `SubscriptionLost`. Entering `listen()` can also raise `MCPError` (the connection failed, or the server does not serve the method), `TimeoutError` (no acknowledgment arrived), and `ListenNotSupportedError` (a pre-2026 connection). Decide which of those your watcher should retry: the last never heals.
## Recap
* Enter `async with client.listen(...)`; entering waits for the acknowledgment, so nothing published after it is missed.
* Iterate with `async for event in sub`. Events are cues to refetch, never payloads.
* Open the subscription, then run the watcher as a task, and tool calls keep flowing beside it.
* A clean end stops the loop; a drop raises `SubscriptionLost`. Either way: re-listen, refetch, back off first.
* Leaving the block is the unsubscribe.
Publishing these events, narrowing the filter, and scaling past one process are the server's story: **[Subscriptions](../handlers/subscriptions.md)**. These same events also keep a client-side cache honest, and **[Caching](caching.md)** is the next page.

View file

@ -1,94 +1,146 @@
# Subscriptions
A server's catalog is not fixed. Tools get registered at runtime, resources change behind their URIs. The client side of that story is a subscription: on the 2026-07-28 protocol, a client that wants to hear about changes sends one `subscriptions/listen` request, and the response to that request *is* the stream — it stays open, carrying exactly the notification kinds the client asked for.
A server's catalog is not fixed. Tools appear at runtime, and the content behind a resource URI changes.
**Subscriptions** are how a client hears about it. The client sends one `subscriptions/listen` request, and the response to that request *is* the stream: it stays open and carries the change notifications the client asked for.
## Publish it from the tool
Your side of it is one line: publish the change.
```python title="server.py" hl_lines="16 27"
```python title="server.py" hl_lines="20 32"
--8<-- "docs_src/subscriptions/tutorial001.py"
```
* `await ctx.notify_resource_updated("note://todo")` delivers `notifications/resources/updated` to every open listen stream that subscribed to that URI. Not to anyone else.
* `await ctx.notify_tools_changed()` delivers `notifications/tools/list_changed` to every stream that asked for tool-list changes. A client that receives it calls `tools/list` again — and now sees `search`.
* The siblings are `notify_prompts_changed()` and `notify_resources_changed()`, for the other two list-changed kinds.
* No subscribers, no work: publishing to an idle server is a no-op. You don't check whether anyone is listening; you state what changed.
* `await ctx.notify_resource_updated("board://sprint")` reaches every open stream that subscribed to that URI. Nobody else.
* `await ctx.notify_tools_changed()` reaches every stream that asked for tool-list changes. A client that receives it calls `tools/list` again, and now sees `sprint_report`.
* The siblings are `notify_prompts_changed()` and `notify_resources_changed()`.
* No subscribers, no work. Publishing to an idle server is a no-op, so you never check whether anyone is listening. You state what changed.
The SDK serves `subscriptions/listen` for you — `MCPServer` registers the handler at construction, and the wire obligations (the acknowledgment as the first frame, the per-stream filtering, the subscription id tagged onto every frame) are its job, not yours.
`MCPServer` serves `subscriptions/listen` for you. The wire obligations (the acknowledgment as the first frame, per-stream filtering, the subscription id on every frame) are the SDK's job.
!!! check
On the wire, a stream whose filter named `note://todo` looks like this after `edit_note` runs:
On the wire, a stream whose filter named `board://sprint` looks like this after `complete_task` runs:
```json
{"method": "notifications/subscriptions/acknowledged",
"params": {"notifications": {"resourceSubscriptions": ["note://todo"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": 7}}}
"params": {"notifications": {"resourceSubscriptions": ["board://sprint"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}}
{"method": "notifications/resources/updated",
"params": {"uri": "note://todo", "_meta": {"io.modelcontextprotocol/subscriptionId": 7}}}
"params": {"uri": "board://sprint", "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}}
```
The acknowledgment echoes the filter the server agreed to honor, and every frame carries the
listen request's JSON-RPC id under `_meta` — that id *is* the subscription id.
Note what the update does *not* carry: the board. Every frame carries the listen request's JSON-RPC id under `_meta`, and that id is the subscription id. The client mints it: the Python `Client` uses strings like `"listen-1"`; other clients may use integers.
## Only what was asked for
The filter is a contract. A stream that requested tool-list changes and one resource URI receives those two kinds and nothing else — publish a prompt change and that stream stays silent. Resource URIs are matched as exact strings: `note://todo` does not cover `note://todo/draft`.
The filter is a contract. A stream that requested tool-list changes and one resource URI receives those two kinds and nothing else. Publish a prompt change and that stream stays silent.
`MCPServer` matches resource URIs as exact strings, so a stream that named `board://sprint` hears nothing about `board://sprint/tasks/1`. The spec lets a server report a change on a sub-resource of a subscribed URI; `MCPServer` never does, but clients are built to expect it.
Two things the stream is *not*:
* **It is not a replay log.** A dropped stream is gone, and events published while nobody was connected are not queued. Clients re-listen and refetch.
* **It is not the 2025 path.** Clients that called `resources/subscribe` are served by `ctx.session.send_resource_updated(uri)`. The `notify_*` methods reach `subscriptions/listen` streams only.
!!! warning
Filters are honored without per-client authorization: any client may name any URI —
including one it cannot read — and will receive update notifications for it (resource
existence and change timing, never content). On a multi-tenant server, don't publish
sensitive per-user URIs through `notify_resource_updated`, or serve the method with
your own handler on the low-level `Server` and narrow the filter there before acking —
the honored subset exists in the protocol precisely so servers can do this.
Two more things the stream is *not*:
* **It is not a replay log.** A dropped stream is gone; events published while nobody was connected are not queued. The client's contract is to re-listen and re-fetch what it cares about.
* **It is not the 2025 path.** Clients on earlier protocol versions that called `resources/subscribe` are served by `ctx.session.send_resource_updated(uri)` — the `notify_*` methods reach `subscriptions/listen` streams only.
Don't publish sensitive per-user URIs through `notify_resource_updated` on a multi-tenant
server. Any client may name any URI in its filter, and `MCPServer` honors it. The exposure
is narrow but real: a subscriber learns that a URI it can guess changed, and when. It never
learns content, and it cannot probe what exists, because an unknown URI is honored too and
simply never fires. To narrow the filter per client today, serve the method with your own
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` is served on the streamable-HTTP transport. Over stdio (and other
stream-pair transports) a 2026-07-28 connection rejects it with METHOD_NOT_FOUND — the
open-stream semantics haven't been built for that transport yet, even though
`server/discover` still advertises the subscription capabilities there.
`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.
## One process is the default. More takes a bus
## The client end
Publishes travel from your handler to the open streams over a `SubscriptionBus`. The default is in-memory: one process, every stream in it. That is the right answer until you run replicas behind a load balancer — then a client's stream is pinned to one replica, and a publish on another replica has to reach it.
Here is a client on the other side of that stream, following the board:
```python title="client.py" hl_lines="16"
--8<-- "docs_src/subscriptions/tutorial003.py"
```
Entering `client.listen(...)` sends the request and waits for your acknowledgment, so the stream is live when the block starts, and each typed event is a cue to refetch, never a payload. That is the whole contract in one screen. Everything else about the client end lives on its own page: watching beside a main flow, stream endings, and re-listening. See **[Subscriptions](../client/subscriptions.md)** under *Clients*.
## Scaling past one process
Publishes travel from your handler to the open streams over a `SubscriptionBus`. The default is in-memory: one process, every stream in it. That is the right answer until you run replicas behind a load balancer, because then a client's stream is pinned to one replica, and a publish on another replica has to reach it.
That seam is yours to implement: two methods over your pub/sub backend.
```python
from collections.abc import Callable
from redis.asyncio import Redis
from mcp.server.mcpserver import MCPServer
from mcp.server.subscriptions import ServerEvent # SubscriptionBus is a Protocol: no base class
class RedisSubscriptionBus:
def __init__(self, redis: Redis) -> None:
self._redis = redis
self._listeners: dict[object, Callable[[ServerEvent], None]] = {}
async def publish(self, event: ServerEvent) -> None:
await self.redis.publish("mcp-events", encode(event)) # to every replica
await self._redis.publish("mcp-events", encode(event)) # to every replica
def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]:
... # register the local listener; a reader task calls it for arriving events
token = object()
self._listeners[token] = listener
def unsubscribe() -> None:
self._listeners.pop(token, None)
return unsubscribe
mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis))
```
`encode` is yours, and so is the reader task on each replica that decodes arriving messages and calls every registered listener. Listeners are synchronous, must not raise, and run on the server's event loop.
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.
```python
mcp = MCPServer("Notebook", subscriptions=RedisSubscriptionBus(...))
```
from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged
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, keep a reference to the bus you constructed and `await bus.publish(ToolsListChanged())` — the server holds the same instance.
bus = InMemorySubscriptionBus()
mcp = MCPServer("Sprint Board", subscriptions=bus)
async def tools_reloaded() -> None:
await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere
```
## The low-level composition
Down on the low-level `Server` there is no pre-wired anything — and the same parts assemble in three lines:
Down on the low-level `Server` there is no pre-wired anything, and the same parts assemble in three lines:
```python title="server.py" hl_lines="9 31 39"
```python title="server.py" hl_lines="9-10 48"
--8<-- "docs_src/subscriptions/tutorial002.py"
```
* 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; `on_subscriptions_listen=` is an ordinary handler slot. Don't want the SDK's semantics? Write your own handler for the slot — the spec obligations come with it.
* `ListenHandler.close()` gracefully ends every open stream: each one receives the listen request's result as its final frame, the spec's signal that the server ended the subscription deliberately — a clean end, as opposed to the abrupt drop a client may treat as a cue to reconnect. Without it, streams end when the client disconnects.
* 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.
## Recap
* A client opts in with one `subscriptions/listen` request; the response is the stream. There is nothing to configure server-side — serving it is built in.
* You publish: `await ctx.notify_resource_updated(uri)`, `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`. Idle servers make these free.
* Streams receive only what their filter requested; URIs match exactly; nothing is replayed.
* Scaling out means implementing `SubscriptionBus` — two methods — over your own pub/sub, and passing it as `MCPServer(subscriptions=...)`.
* The low-level spelling is the same machinery held in your hands: a bus, `ListenHandler(bus)`, one constructor argument.
* A client opts in with one `subscriptions/listen` request, and the response is the stream. Serving it is built in.
* You publish with `ctx.notify_*`, and the SDK does the stamping, filtering, and lifecycle work.
* Events are cues, not payloads. Both ends refetch.
* The client end is `async with client.listen(...)`: **[Subscriptions](../client/subscriptions.md)** under *Clients* is that story.
* On the low-level `Server` you assemble the same parts yourself: a bus, `ListenHandler(bus)`, the `on_subscriptions_listen` slot.
* Scaling out means implementing `SubscriptionBus`, two methods, and passing it as `MCPServer(subscriptions=...)`.
Running the server that serves all this, behind one replica or twenty, is **[Deploy & scale](../run/deploy.md)**.

View file

@ -2055,6 +2055,23 @@ One behavioral caveat when moving progress-reporting handlers onto `Client(serve
## Deprecations
### Client resource-subscription methods deprecated (SEP-2575)
[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2575) removes `resources/subscribe` and `resources/unsubscribe` from the 2026-07-28 wire; per-URI subscriptions travel in the `subscriptions/listen` filter instead. The client verbs now carry `typing_extensions.deprecated`:
- `Client.subscribe_resource()` / `Client.unsubscribe_resource()`
- `ClientSession.subscribe_resource()` / `ClientSession.unsubscribe_resource()`
They keep working against 2025-era servers; a 2026-07-28 server answers them with `-32601` (method not found). Migrate to the listen driver:
```python
async with client.listen(resource_subscriptions=["board://sprint"]) as sub:
async for event in sub: # ResourceUpdated(uri="board://sprint")
...
```
See the [Subscriptions](client/subscriptions.md#watching-the-stream) page under Clients for the full client-side contract (typed events, the honored filter, clean end vs `SubscriptionLost`).
### Roots, Sampling, and Logging methods deprecated (SEP-2577)
[SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) deprecates the Roots, Sampling, and Logging features as of the 2026-07-28 spec. The deprecation is advisory only: there are no wire-level changes, capability negotiation is unchanged, and every method keeps working for sessions negotiating 2025-11-25 and earlier.

View file

@ -149,7 +149,7 @@ The seam between the two is the `SubscriptionBus`. Whatever bus you give a serve
Nothing about the fan-out cares which server object a stream is attached to. Two servers holding one `InMemorySubscriptionBus` already behave this way: open a listen stream on one, `edit_note` on the other, and the stream hears about it. That in-memory bus only spans server objects inside one process, which makes it the model, not the deployment:
* Across real processes, **the SDK ships no bus that can help you.** `SubscriptionBus` is a two-method `Protocol` (`publish` and `subscribe`) that you implement over your own pub/sub backend (Redis, NATS, whatever you already run) and pass as `MCPServer(subscriptions=...)`. **[Subscriptions](../handlers/subscriptions.md#one-process-is-the-default-more-takes-a-bus)** has the sketch and the contract.
* Across real processes, **the SDK ships no bus that can help you.** `SubscriptionBus` is a two-method `Protocol` (`publish` and `subscribe`) that you implement over your own pub/sub backend (Redis, NATS, whatever you already run) and pass as `MCPServer(subscriptions=...)`. **[Subscriptions](../handlers/subscriptions.md#scaling-past-one-process)** has the sketch and the contract.
* The bus carries four small typed events, never JSON-RPC. Acknowledgment, filtering, and stream lifecycle stay in the SDK, so your bus cannot break the protocol; it can only move events between processes.
* Streams are **not** resumable and events are **not** replayed. Losing a replica drops its streams; the clients re-listen and re-fetch. There is no event store to share and nothing else to configure. This is the one place where scaling out is genuinely just more of the same.

View file

@ -190,9 +190,9 @@ That file is the pitch in one place: one server, one `Resolve`-backed tool, and
### Change notifications become one stream
At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are replaced by `subscriptions/listen`: the client opens one long-lived stream and names the notification kinds it wants. `MCPServer` serves it out of the box; you publish with `await ctx.notify_resource_updated(uri)` (and `notify_tools_changed()`, and so on), and multi-replica deployments plug in a shared `SubscriptionBus`. Two honest caveats as of `2.0.0b1`: the Python `Client` cannot open the listen stream yet (the driver ships in a later pre-release), and over stdio the server does not serve it. The net for a Python *client* on that release is that nothing delivers change notifications on a 2026-07-28 connection; a host that relies on `resources/updated` should connect with `mode="legacy"` until the driver lands.
At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are replaced by `subscriptions/listen`: the client opens one long-lived stream and names the notification kinds it wants. `MCPServer` serves it out of the box; you publish with `await ctx.notify_resource_updated(uri)` (and `notify_tools_changed()`, and so on), and multi-replica deployments plug in a shared `SubscriptionBus`. On the client (since `2.0.0b2`), `async with client.listen(...)` opens the stream: the filter goes in as keyword arguments, typed change events come back, and `sub.honored` is the subset the server agreed to deliver. One honest caveat: over stdio the server does not serve the stream yet.
**[Subscriptions](handlers/subscriptions.md)** on the server, and **[Deploy & scale](run/deploy.md)** for the bus.
**[Subscriptions](handlers/subscriptions.md)** covers publishing and serving, **[its Clients twin](client/subscriptions.md)** the watching end, and **[Deploy & scale](run/deploy.md)** the bus.
### The rest, quickly

View file

@ -1,28 +1,33 @@
from mcp.server.mcpserver import Context, MCPServer
mcp = MCPServer("Notebook")
mcp = MCPServer("Sprint Board")
NOTES = {"todo": "buy milk", "journal": "day one"}
BOARDS = {
"sprint": {"design": False, "build": False, "ship": False},
"backlog": {"tidy docs": False},
}
@mcp.resource("note://{name}")
def note(name: str) -> str:
return NOTES[name]
@mcp.resource("board://{name}")
def board(name: str) -> str:
tasks = BOARDS[name]
return "\n".join(f"[{'x' if done else ' '}] {task}" for task, done in tasks.items())
@mcp.tool()
async def edit_note(name: str, text: str, ctx: Context) -> str:
NOTES[name] = text
await ctx.notify_resource_updated(f"note://{name}")
return "saved"
async def complete_task(board: str, task: str, ctx: Context) -> str:
BOARDS[board][task] = True
await ctx.notify_resource_updated(f"board://{board}")
return f"{task}: done"
def search(query: str) -> list[str]:
return [name for name, text in NOTES.items() if query in text]
def sprint_report() -> str:
done = sum(done for tasks in BOARDS.values() for done in tasks.values())
return f"{done} task(s) done"
@mcp.tool()
async def enable_search(ctx: Context) -> str:
mcp.add_tool(search)
async def enable_reports(ctx: Context) -> str:
mcp.add_tool(sprint_report)
await ctx.notify_tools_changed()
return "search is live"
return "reporting is live"

View file

@ -7,34 +7,43 @@ from mcp.server.lowlevel import Server
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ResourceUpdated
bus = InMemorySubscriptionBus()
listen_handler = ListenHandler(bus)
NOTES = {"todo": "buy milk"}
BOARD = {"design": False, "build": False}
EDIT_NOTE_SCHEMA: dict[str, Any] = {
COMPLETE_TASK_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {"name": {"type": "string"}, "text": {"type": "string"}},
"required": ["name", "text"],
"properties": {"task": {"type": "string"}},
"required": ["task"],
}
async def read_resource(
ctx: ServerRequestContext[Any], params: types.ReadResourceRequestParams
) -> types.ReadResourceResult:
board = "\n".join(f"[{'x' if done else ' '}] {task}" for task, done in BOARD.items())
return types.ReadResourceResult(contents=[types.TextResourceContents(uri=params.uri, text=board)])
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="edit_note", description="Replace a note's text.", input_schema=EDIT_NOTE_SCHEMA)]
tools=[types.Tool(name="complete_task", description="Mark a task done.", input_schema=COMPLETE_TASK_SCHEMA)]
)
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
args = params.arguments or {}
NOTES[args["name"]] = args["text"]
await bus.publish(ResourceUpdated(uri=f"note://{args['name']}"))
return types.CallToolResult(content=[types.TextContent(type="text", text="saved")])
BOARD[args["task"]] = True
await bus.publish(ResourceUpdated(uri="board://sprint"))
return types.CallToolResult(content=[types.TextContent(type="text", text="done")])
server = Server(
"notebook",
"sprint-board",
on_read_resource=read_resource,
on_list_tools=list_tools,
on_call_tool=call_tool,
on_subscriptions_listen=ListenHandler(bus),
on_subscriptions_listen=listen_handler,
)

View file

@ -0,0 +1,30 @@
from mcp_types import TextResourceContents
from mcp import Client
from mcp.client.subscriptions import ResourceUpdated, ToolsListChanged
BOARD = "board://sprint"
async def read_board(client: Client, uri: str = BOARD) -> str:
[contents] = (await client.read_resource(uri)).contents
assert isinstance(contents, TextResourceContents)
return contents.text
async def follow_board(client: Client) -> None:
async with client.listen(tools_list_changed=True, resource_subscriptions=[BOARD]) as sub:
async for event in sub:
match event:
case ResourceUpdated(uri=uri):
print(await read_board(client, uri))
case ToolsListChanged():
tools = await client.list_tools()
print("tools:", [tool.name for tool in tools.tools])
case _:
pass # kinds the filter did not ask for never arrive
async def main() -> None:
async with Client("http://localhost:8000/mcp") as client:
await follow_board(client)

View file

@ -0,0 +1,32 @@
import anyio
from mcp import Client
from mcp.client.subscriptions import Subscription
from .tutorial003 import BOARD, read_board
async def watch(client: Client, sub: Subscription) -> None:
async for _event in sub:
board = await read_board(client)
print(board)
if "[ ]" not in board:
return # sprint finished: the stream closes when run_sprint leaves the block
async def run_sprint(client: Client) -> None:
async with client.listen(resource_subscriptions=[BOARD]) as sub:
print(await read_board(client)) # snapshot: acknowledged, so nothing after this is missed
async with anyio.create_task_group() as tg:
tg.start_soon(watch, client, sub)
for task in ("design", "build", "ship"):
await client.call_tool("complete_task", {"board": "sprint", "task": task})
async def main() -> None:
async with Client("http://localhost:8000/mcp") as client:
await run_sprint(client)
if __name__ == "__main__":
anyio.run(main)

View file

@ -0,0 +1,32 @@
import asyncio
from mcp import Client
from mcp.client.subscriptions import Subscription
from .tutorial003 import BOARD, read_board
async def watch(client: Client, sub: Subscription) -> None:
async for _event in sub:
board = await read_board(client)
print(board)
if "[ ]" not in board:
return # sprint finished: the stream closes when run_sprint leaves the block
async def run_sprint(client: Client) -> None:
async with client.listen(resource_subscriptions=[BOARD]) as sub:
print(await read_board(client)) # snapshot: acknowledged, so nothing after this is missed
watcher = asyncio.create_task(watch(client, sub))
for task in ("design", "build", "ship"):
await client.call_tool("complete_task", {"board": "sprint", "task": task})
await watcher # returns once the watcher has seen the finished board
async def main() -> None:
async with Client("http://localhost:8000/mcp") as client:
await run_sprint(client)
if __name__ == "__main__":
asyncio.run(main())

View file

@ -0,0 +1,32 @@
import trio
from mcp import Client
from mcp.client.subscriptions import Subscription
from .tutorial003 import BOARD, read_board
async def watch(client: Client, sub: Subscription) -> None:
async for _event in sub:
board = await read_board(client)
print(board)
if "[ ]" not in board:
return # sprint finished: the stream closes when run_sprint leaves the block
async def run_sprint(client: Client) -> None:
async with client.listen(resource_subscriptions=[BOARD]) as sub:
print(await read_board(client)) # snapshot: acknowledged, so nothing after this is missed
async with trio.open_nursery() as nursery:
nursery.start_soon(watch, client, sub)
for task in ("design", "build", "ship"):
await client.call_tool("complete_task", {"board": "sprint", "task": task})
async def main() -> None:
async with Client("http://localhost:8000/mcp") as client:
await run_sprint(client)
if __name__ == "__main__":
trio.run(main)

View file

@ -0,0 +1,20 @@
import anyio
from mcp import Client
from mcp.client.subscriptions import SubscriptionLost
from .tutorial003 import read_board
async def keep_following(client: Client) -> None:
while True:
try:
async with client.listen(resource_subscriptions=["board://sprint"]) as sub:
print(await read_board(client)) # refetch: no replay across streams
async for _event in sub:
print(await read_board(client))
except SubscriptionLost:
pass
# Either ending means the stream is gone. Back off before re-listening:
# a graceful close may be the server shedding load.
await anyio.sleep(1)

View file

@ -7,16 +7,16 @@ server publishes with `ctx.notify_resource_updated(uri)` /
per-stream filtering, subscription-id tagging). Replaces the handshake-era
`resources/subscribe` + standalone-GET notification path.
The client edits a note it did not subscribe to (silence), edits the one it
did (a tagged `notifications/resources/updated`), registers a tool at runtime
(`notifications/tools/list_changed`, then re-lists and calls it), and finally
stops listening - cancelling the parked request releases the local task, and
closing the connection ends the stream server-side.
The client opens the stream with `client.listen(...)`, edits a note it did
not subscribe to (silence), edits the one it did (a typed `ResourceUpdated`),
registers a tool at runtime (a typed `ToolsListChanged`, then re-lists and
calls it), and finally leaves the `async with` block, which ends the
subscription while the connection lives on.
## Run it
```bash
# HTTP — the client self-hosts the server on a free port, runs, then tears it
# HTTP: the client self-hosts the server on a free port, runs, then tears it
# down (subscriptions/listen is 2026-era only)
uv run python -m stories.subscriptions.client --http
# same, against the lowlevel-API server variant
@ -25,17 +25,18 @@ uv run python -m stories.subscriptions.client --http --server server_lowlevel
## What to look at
- `client.py` — stream frames arrive as ordinary server notifications via the
constructor-only `message_handler=`. There is no client-side listen API yet,
so opening the stream drops to the `client.session` escape hatch; the request
parks for the stream's lifetime. Cancelling it releases the local task; over
HTTP the server-side stream ends when the connection closes. Every frame's
`_meta["io.modelcontextprotocol/subscriptionId"]` is the listen request's
JSON-RPC id.
- `server.py` — publishing is one `await ctx.notify_*()` line per change; the
- `client.py`: the whole subscription is one context manager,
`async with client.listen(...) as sub`. Entering waits for the server's
acknowledgment, so `sub.honored` is already in hand on the first line of the
block. Events arrive as typed values from `anext(sub)`; the edit to the
unsubscribed note never shows up, because the filter is enforced
server-side. Leaving the block ends the subscription (over HTTP the SDK
closes that request's response stream) and the session carries on, which the
final `search` call proves.
- `server.py`: publishing is one `await ctx.notify_*()` line per change; the
filter, the tagging, and the ack ordering are the SDK's job. Publishing with
no subscribers is a no-op.
- `server_lowlevel.py` — the same machinery held by hand: an
- `server_lowlevel.py`: the same machinery held by hand: an
`InMemorySubscriptionBus`, handlers that `await bus.publish(...)`, and
`ListenHandler(bus)` passed as `on_subscriptions_listen=`. A multi-replica
deployment swaps the bus for one backed by its own pub/sub
@ -51,10 +52,11 @@ uv run python -m stories.subscriptions.client --http --server server_lowlevel
## Spec
[Subscriptions — basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions)
[Subscriptions, basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions)
## See also
`streaming/` (request-scoped notifications), `events/` (the events extension
on top of this channel, deferred), and `docs/handlers/subscriptions.md` (the
narrative version).
on top of this channel, deferred), and the narrative versions:
`docs/handlers/subscriptions.md` (server) and `docs/client/subscriptions.md`
(client).

View file

@ -4,88 +4,34 @@ import anyio
import mcp_types as types
from mcp.client import Client
from mcp.client.subscriptions import ResourceUpdated, ToolsListChanged
from stories._harness import Target, run_client
SUBSCRIPTION_ID = "io.modelcontextprotocol/subscriptionId"
async def main(target: Target, *, mode: str = "auto") -> None:
# Stream frames arrive as ordinary server notifications; `message_handler`
# is constructor-only on `Client`, so the list it fills exists first.
received: list[types.ServerNotification] = []
arrival = anyio.Event()
async def on_message(message: object) -> None:
nonlocal arrival
if isinstance(
message,
types.SubscriptionsAcknowledgedNotification
| types.ResourceUpdatedNotification
| types.ToolListChangedNotification,
):
received.append(message)
arrival.set()
arrival = anyio.Event()
async def wait_for(count: int) -> None:
with anyio.fail_after(10):
while len(received) < count:
await arrival.wait()
async with Client(target, mode=mode, message_handler=on_message) as client:
async with Client(target, mode=mode) as client:
before = await client.list_tools()
assert "search" not in {tool.name for tool in before.tools}
async with anyio.create_task_group() as tg:
# There is no client-side listen API yet, so the story drops to the
# `client.session` escape hatch. The request parks for the stream's
# lifetime, so it runs as a task; cancelling it releases the local
# awaiting scope. In-memory that also ends the server's stream; over
# HTTP today nothing aborts the POST, so the server-side stream ends
# when the connection closes (the `Client` exit right below).
async def listen() -> None:
request = types.SubscriptionsListenRequest(
params=types.SubscriptionsListenRequestParams(
notifications=types.SubscriptionFilter(
tools_list_changed=True, resource_subscriptions=["note://todo"]
)
)
)
await client.session.send_request(request, types.SubscriptionsListenResult)
tg.start_soon(listen)
# ── the ack is the first frame: it echoes the honored filter, tagged ──
await wait_for(1)
ack = received[0]
assert isinstance(ack, types.SubscriptionsAcknowledgedNotification), ack
assert ack.params.notifications.tools_list_changed is True
assert ack.params.notifications.resource_subscriptions == ["note://todo"]
assert ack.params.meta is not None and SUBSCRIPTION_ID in ack.params.meta
async with client.listen(tools_list_changed=True, resource_subscriptions=["note://todo"]) as sub:
# ── entering waited for the ack: the honored filter is already in hand ──
assert sub.honored.tools_list_changed is True
assert sub.honored.resource_subscriptions == ["note://todo"]
# ── exact-URI filtering: an unsubscribed note edit stays silent ──
await client.call_tool("edit_note", {"name": "journal", "text": "day two"})
# ── the subscribed URI delivers, carrying the same subscription id ──
# ── the subscribed URI delivers ──
await client.call_tool("edit_note", {"name": "todo", "text": "water plants"})
await wait_for(2)
updated = received[1]
assert isinstance(updated, types.ResourceUpdatedNotification), updated
assert updated.params.uri == "note://todo"
assert updated.params.meta is not None
assert updated.params.meta[SUBSCRIPTION_ID] == ack.params.meta[SUBSCRIPTION_ID]
assert len(received) == 2, "the journal edit must not have been delivered"
with anyio.fail_after(10):
event = await anext(sub)
assert event == ResourceUpdated(uri="note://todo"), "the journal edit must not have been delivered"
# ── a runtime tool registration announces itself ──
await client.call_tool("enable_search", {})
await wait_for(3)
assert isinstance(received[2], types.ToolListChangedNotification), received[2]
with anyio.fail_after(10):
assert await anext(sub) == ToolsListChanged()
# The client is done listening: cancel the parked request and let
# the connection teardown below end the stream server-side.
tg.cancel_scope.cancel()
# list_changed told us to re-fetch - the new tool is callable, and the
# session outlives the closed stream.
# ── leaving the block closed the stream; the session lives on ──
tools = await client.list_tools()
assert "search" in {tool.name for tool in tools.tools}
result = await client.call_tool("search", {"query": "water"})

View file

@ -54,6 +54,7 @@ nav:
- OAuth: client/oauth-clients.md
- Identity assertion: client/identity-assertion.md
- Multiple servers: client/session-groups.md
- Subscriptions: client/subscriptions.md
- Caching: client/caching.md
- Protocol versions: protocol-versions.md
- Deprecated features: deprecated.md

View file

@ -6,7 +6,7 @@ import hashlib
import logging
import uuid
from collections.abc import Awaitable, Callable, Mapping, Sequence
from contextlib import AsyncExitStack
from contextlib import AbstractAsyncContextManager, AsyncExitStack
from dataclasses import KW_ONLY, dataclass, field
from typing import Any, Literal, TypeVar, cast
@ -58,6 +58,8 @@ from mcp.client.session import (
SamplingFnT,
)
from mcp.client.streamable_http import streamable_http_client
from mcp.client.subscriptions import ServerEvent, Subscription
from mcp.client.subscriptions import listen as _listen
from mcp.server import Server
from mcp.server.mcpserver import MCPServer
from mcp.server.runner import modern_on_request
@ -67,6 +69,7 @@ from mcp.shared.exceptions import MCPDeprecationWarning, MCPError
from mcp.shared.extension import validate_extension_identifier
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.session import RequestResponder
from mcp.shared.subscriptions import event_to_notification
logger = logging.getLogger(__name__)
@ -666,13 +669,68 @@ class Client:
# Driver rounds carry inputResponses, so a terminal result reached through them is never cached (spec MUST).
return await self._drive_input_required(first, retry)
async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> EmptyResult:
"""Subscribe to resource updates."""
return await self.session.subscribe_resource(uri, meta=meta)
def listen(
self,
*,
tools_list_changed: bool = False,
prompts_list_changed: bool = False,
resources_list_changed: bool = False,
resource_subscriptions: Sequence[str] = (),
) -> AbstractAsyncContextManager[Subscription]:
"""Open a `subscriptions/listen` stream of typed change events (2026-07-28 only).
Keyword args mirror the wire `SubscriptionFilter`; entering waits for the ack (honored subset: `sub.honored`):
async with client.listen(tools_list_changed=True) as sub:
async for event in sub:
tools = await client.list_tools() # refetch on change
A graceful close ends the loop; an abrupt drop raises `SubscriptionLost`. No replay: re-listen and refetch.
Raises:
ListenNotSupportedError: The negotiated protocol version predates 2026-07-28.
MCPError: The server rejected the request or the connection failed first.
SubscriptionLost: The stream ended before it was acknowledged.
TimeoutError: The read timeout elapsed before the acknowledgment.
"""
return _listen(
self.session,
tools_list_changed=tools_list_changed,
prompts_list_changed=prompts_list_changed,
resources_list_changed=resources_list_changed,
resource_subscriptions=resource_subscriptions,
on_event=self._evict_for_listen_event if self._response_cache is not None else None,
)
async def _evict_for_listen_event(self, event: ServerEvent) -> None:
"""Finish response-cache eviction before a listen consumer can refetch.
Without it the iterator wakes first and refetches a still-warm entry, with no
corrective wake (events are deduplicated level triggers). The tee path repeats
the eviction; deliberate: idempotent, and it covers non-iterating consumers.
"""
cache = self._response_cache
assert cache is not None # installed as the event barrier only when a cache exists
try:
await cache.evict_for_notification(event_to_notification(event, {}))
except Exception: # boundary: eviction reaches user store code; a cache fault must not block delivery
logger.exception("Response cache eviction failed; the event is still delivered")
@deprecated(
"resources/subscribe is removed as of 2026-07-28; use Client.listen() instead.",
category=MCPDeprecationWarning,
)
async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> EmptyResult:
"""Subscribe to resource updates (2025-era servers only)."""
return await self.session.subscribe_resource(uri, meta=meta) # pyright: ignore[reportDeprecated]
@deprecated(
"resources/unsubscribe is removed as of 2026-07-28; use Client.listen() instead.",
category=MCPDeprecationWarning,
)
async def unsubscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> EmptyResult:
"""Unsubscribe from resource updates."""
return await self.session.unsubscribe_resource(uri, meta=meta)
"""Unsubscribe from resource updates (2025-era servers only)."""
return await self.session.unsubscribe_resource(uri, meta=meta) # pyright: ignore[reportDeprecated]
async def call_tool(
self,

View file

@ -16,6 +16,7 @@ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStre
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
CONNECTION_CLOSED,
INTERNAL_ERROR,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
@ -35,8 +36,9 @@ from typing_extensions import Self, TypeVar, deprecated
from mcp.client._transport import ReadStream, WriteStream
from mcp.client.extension import NotificationBinding, ResultClaim, UnexpectedClaimedResult
from mcp.client.subscriptions import ListenRoute
from mcp.shared._compat import resync_tracer
from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, ProgressFnT
from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, ProgressFnT, as_request_id
from mcp.shared.exceptions import MCPDeprecationWarning, MCPError
from mcp.shared.inbound import (
MCP_METHOD_HEADER,
@ -48,9 +50,10 @@ from mcp.shared.inbound import (
mcp_param_headers,
x_mcp_header_map,
)
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher, cancelled_request_id_from_params
from mcp.shared.message import ClientMessageMetadata, SessionMessage
from mcp.shared.session import RequestResponder
from mcp.shared.subscriptions import SUBSCRIPTION_ID_META_KEY, event_from_wire
from mcp.shared.transport_context import TransportContext
DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0")
@ -360,6 +363,8 @@ class ClientSession:
self._negotiated_version: str | None = None
self._stamp: Callable[[dict[str, Any], CallOptions], None] = _preconnect_stamp
self._task_group: anyio.abc.TaskGroup | None = None
# subscriptions/listen demux routes; membership decides ack consumption (raw listens are never registered)
self._listen_routes: dict[RequestId, ListenRoute] = {}
if dispatcher is not None:
if read_stream is not None or write_stream is not None:
raise ValueError("pass read_stream/write_stream or dispatcher, not both")
@ -388,7 +393,9 @@ class ClientSession:
for binding in self._notification_bindings.values():
send, receive = anyio.create_memory_object_stream[BaseModel](_NOTIFICATION_QUEUE_SIZE)
self._binding_queues[binding.method] = (send, receive)
await self._task_group.start(self._dispatcher.run, self._on_request, self._on_notify)
await self._task_group.start(
self._dispatcher.run, self._on_request, self._on_notify, self._intercept_notification
)
for binding in self._notification_bindings.values():
_, receive = self._binding_queues[binding.method]
self._task_group.start_soon(self._deliver_bound_notifications, binding, receive)
@ -422,6 +429,7 @@ class ClientSession:
result = await self._task_group.__aexit__(exc_type, exc_val, exc_tb)
finally:
self._close_binding_queues()
self._settle_listen_routes_closed()
await resync_tracer()
return result
@ -859,15 +867,23 @@ class ClientSession:
raise _input_required_unexpected("read_resource")
return result
@deprecated(
"resources/subscribe is removed as of 2026-07-28; use Client.listen() instead.",
category=MCPDeprecationWarning,
)
async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult:
"""Send a resources/subscribe request."""
"""Send a resources/subscribe request (2025-era servers only)."""
return await self.send_request(
types.SubscribeRequest(params=types.SubscribeRequestParams(uri=uri, _meta=meta)),
types.EmptyResult,
)
@deprecated(
"resources/unsubscribe is removed as of 2026-07-28; use Client.listen() instead.",
category=MCPDeprecationWarning,
)
async def unsubscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult:
"""Send a resources/unsubscribe request."""
"""Send a resources/unsubscribe request (2025-era servers only)."""
return await self.send_request(
types.UnsubscribeRequest(params=types.UnsubscribeRequestParams(uri=uri, _meta=meta)),
types.EmptyResult,
@ -1225,6 +1241,62 @@ class ClientSession:
case types.ListRootsRequest(): # pragma: no branch
return await self._list_roots_callback(ctx)
def _register_listen_route(self, request_id: RequestId) -> ListenRoute:
"""Create the demux route for a listen request id; the caller registers BEFORE sending."""
route = ListenRoute()
self._listen_routes[request_id] = route
return route
def _unregister_listen_route(self, request_id: RequestId) -> None:
"""Drop a listen route; the handle owns membership, so a missing key is a no-op."""
self._listen_routes.pop(request_id, None)
def _settle_listen_routes_closed(self) -> None:
"""Settle all open listen routes as lost on session exit; cancelled driver tasks cannot."""
closed = MCPError(code=CONNECTION_CLOSED, message="Connection closed")
for route in self._listen_routes.values():
route.settle("lost", error=closed)
self._listen_routes.clear()
def _intercept_notification(self, method: str, params: Mapping[str, Any] | None) -> bool:
"""Wire-order listen demux, run synchronously on the dispatcher's receive path.
Bookkeeping must advance in receive order with the listen result (resolved on
this same path); the spawned `_on_notify` path would race it and drop events.
Returns True to consume the frame: a live route's ack is driver state, never surfaced.
"""
if not self._listen_routes:
return False
if method == "notifications/cancelled":
request_id = cancelled_request_id_from_params(params)
if request_id is not None and (listen_route := self._listen_routes.get(request_id)) is not None:
# a server-sent cancel naming a listen request is that stream's teardown signal
listen_route.settle("lost")
return False # _on_notify swallows every cancelled either way (v1 parity)
if params is None:
return False
meta = params.get("_meta")
if not isinstance(meta, Mapping):
return False
# as_request_id is not a tripwire: raw wire _meta can carry a non-id (even unhashable) value
subscription_id = as_request_id(cast("Mapping[str, Any]", meta).get(SUBSCRIPTION_ID_META_KEY))
if subscription_id is None or (listen_route := self._listen_routes.get(subscription_id)) is None:
return False
if method == "notifications/subscriptions/acknowledged":
raw_filter = params.get("notifications")
if raw_filter is None:
# malformed, not an empty filter: leave it to the spawned path's validation warning
return False
try:
honored = types.SubscriptionFilter.model_validate(raw_filter)
except ValidationError:
return False
listen_route.set_acked(honored)
return True
if (event := event_from_wire(method, params)) is not None:
listen_route.deliver(event)
return False # events (and any other stamped frame) still tee as usual
async def _on_notify(
self, dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
) -> None:
@ -1259,7 +1331,7 @@ class ClientSession:
logger.warning("Failed to validate notification: %s", method, exc_info=True)
return
if isinstance(notification, types.CancelledNotification):
# The dispatcher already applied the cancellation; not surfaced to message_handler.
# Never surfaced (v1 parity): the dispatcher already applied it; listen cancels settled by the intercept.
return
try:
if isinstance(notification, types.LoggingMessageNotification):

View file

@ -13,6 +13,7 @@ import httpx
from anyio.abc import TaskGroup
from httpx_sse import EventSource, ServerSentEvent, aconnect_sse
from mcp_types import (
CONNECTION_CLOSED,
INTERNAL_ERROR,
INVALID_REQUEST,
METHOD_NOT_FOUND,
@ -327,6 +328,15 @@ class StreamableHTTPTransport:
) as response:
if response.status_code == 202:
logger.debug("Received 202 Accepted")
if isinstance(message, JSONRPCRequest):
# A request's response arrives on this POST's body; 202 says
# none will follow. Resolve rather than park the caller forever.
await self._resolve_abandoned_request(
ctx.read_stream_writer,
message.id,
"server answered a request with 202 Accepted",
code=INVALID_REQUEST,
)
return
if response.status_code >= 400:
@ -438,9 +448,29 @@ class StreamableHTTPTransport:
logger.debug("SSE stream ended", exc_info=True) # pragma: lax no cover
# Stream ended without response - reconnect if we received an event with ID
if last_event_id is not None: # pragma: no branch
if last_event_id is not None:
logger.info("SSE stream disconnected, reconnecting...")
await self._handle_reconnection(ctx, last_event_id, retry_interval_ms)
else:
# Not resumable: resolve the waiter, else a listen stream's consumer
# would hang forever instead of learning the subscription is lost.
await self._resolve_abandoned_request(
ctx.read_stream_writer, original_request_id, "SSE stream ended without a response"
)
async def _resolve_abandoned_request(
self, read_stream_writer: StreamWriter, request_id: RequestId, message: str, *, code: int = CONNECTION_CLOSED
) -> None:
"""Resolve a request whose response can never arrive with a synthesized error.
Best-effort: a closed read stream means the session is tearing down.
"""
error_data = ErrorData(code=code, message=message)
error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=request_id, error=error_data))
try:
await read_stream_writer.send(error_msg)
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
logger.debug("read stream closed before request %r could be resolved", request_id)
async def _handle_reconnection(
self,
@ -450,9 +480,17 @@ class StreamableHTTPTransport:
attempt: int = 0,
) -> None:
"""Reconnect with Last-Event-ID to resume stream after server disconnect."""
# Bail if max retries exceeded
if attempt >= MAX_RECONNECTION_ATTEMPTS: # pragma: no cover
# Only requests reconnect: every caller arrives from a request's response stream.
assert isinstance(ctx.session_message.message, JSONRPCRequest)
original_request_id = ctx.session_message.message.id
if attempt >= MAX_RECONNECTION_ATTEMPTS:
# Resolve on give-up: a request with no read timeout (a listen
# stream) would otherwise hang its caller forever.
logger.debug(f"Max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded")
await self._resolve_abandoned_request(
ctx.read_stream_writer, original_request_id, "SSE stream ended and reconnection attempts were exhausted"
)
return
# Always wait - use server value or default
@ -462,11 +500,6 @@ class StreamableHTTPTransport:
headers = self._prepare_headers()
headers[LAST_EVENT_ID] = last_event_id
# Extract original request ID to map responses
original_request_id = None
if isinstance(ctx.session_message.message, JSONRPCRequest): # pragma: no branch
original_request_id = ctx.session_message.message.id
try:
async with aconnect_sse(ctx.client, "GET", self.url, headers=headers) as event_source:
event_source.response.raise_for_status()
@ -564,6 +597,12 @@ class StreamableHTTPTransport:
scope=anyio.CancelScope(),
modern=self._protocol_version_header in MODERN_PROTOCOL_VERSIONS,
)
superseded = self._in_flight_posts.get(message.id)
if superseded is not None:
# A reused id means the waiter belongs to this attempt now:
# sever the old POST so its zombie stream cannot answer,
# fail, or resolve the successor's request.
superseded.scope.cancel()
self._in_flight_posts[message.id] = post
tg.start_soon(self._run_request_post, handle_request_async, post, message.id)
else:

Some files were not shown because too many files have changed in this diff Show more