diff --git a/docs/client/caching.md b/docs/client/caching.md index 5e0976fb5f..dc4ae97acc 100644 --- a/docs/client/caching.md +++ b/docs/client/caching.md @@ -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"`. diff --git a/docs/client/index.md b/docs/client/index.md index 01287da054..ae47508359 100644 --- a/docs/client/index.md +++ b/docs/client/index.md @@ -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 diff --git a/docs/client/subscriptions.md b/docs/client/subscriptions.md new file mode 100644 index 0000000000..fc7d01308d --- /dev/null +++ b/docs/client/subscriptions.md @@ -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. diff --git a/docs/handlers/subscriptions.md b/docs/handlers/subscriptions.md index 6ff85dd86a..85b9632786 100644 --- a/docs/handlers/subscriptions.md +++ b/docs/handlers/subscriptions.md @@ -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. -!!! 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. +`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 more things the stream is *not*: +Two 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. +* **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 + 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. + +## The client end + +Here is a client on the other side of that stream, following the board: -## One process is the default. More takes a bus +```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*. -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. +## 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 + +bus = InMemorySubscriptionBus() +mcp = MCPServer("Sprint Board", subscriptions=bus) -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. + +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)**. diff --git a/docs/migration.md b/docs/migration.md index 811fa17d99..8822d449d0 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -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. diff --git a/docs/run/deploy.md b/docs/run/deploy.md index cad564c421..7cec58163b 100644 --- a/docs/run/deploy.md +++ b/docs/run/deploy.md @@ -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. diff --git a/docs/whats-new.md b/docs/whats-new.md index d197833db8..ff9cfdeeb2 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -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 diff --git a/docs_src/subscriptions/tutorial001.py b/docs_src/subscriptions/tutorial001.py index 5063fceed4..45c858f494 100644 --- a/docs_src/subscriptions/tutorial001.py +++ b/docs_src/subscriptions/tutorial001.py @@ -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" diff --git a/docs_src/subscriptions/tutorial002.py b/docs_src/subscriptions/tutorial002.py index c0b04f64db..39e42dcc04 100644 --- a/docs_src/subscriptions/tutorial002.py +++ b/docs_src/subscriptions/tutorial002.py @@ -7,34 +7,43 @@ 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, ) diff --git a/docs_src/subscriptions/tutorial003.py b/docs_src/subscriptions/tutorial003.py new file mode 100644 index 0000000000..811f6944bd --- /dev/null +++ b/docs_src/subscriptions/tutorial003.py @@ -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) diff --git a/docs_src/subscriptions/tutorial004_anyio.py b/docs_src/subscriptions/tutorial004_anyio.py new file mode 100644 index 0000000000..1ca499562b --- /dev/null +++ b/docs_src/subscriptions/tutorial004_anyio.py @@ -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) diff --git a/docs_src/subscriptions/tutorial004_asyncio.py b/docs_src/subscriptions/tutorial004_asyncio.py new file mode 100644 index 0000000000..2e4c685117 --- /dev/null +++ b/docs_src/subscriptions/tutorial004_asyncio.py @@ -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()) diff --git a/docs_src/subscriptions/tutorial004_trio.py b/docs_src/subscriptions/tutorial004_trio.py new file mode 100644 index 0000000000..da40715762 --- /dev/null +++ b/docs_src/subscriptions/tutorial004_trio.py @@ -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) diff --git a/docs_src/subscriptions/tutorial005.py b/docs_src/subscriptions/tutorial005.py new file mode 100644 index 0000000000..17387fab17 --- /dev/null +++ b/docs_src/subscriptions/tutorial005.py @@ -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) diff --git a/examples/stories/subscriptions/README.md b/examples/stories/subscriptions/README.md index c7f1a44369..bc309d97ea 100644 --- a/examples/stories/subscriptions/README.md +++ b/examples/stories/subscriptions/README.md @@ -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). diff --git a/examples/stories/subscriptions/client.py b/examples/stories/subscriptions/client.py index 379d69bc65..d2053aaf7c 100644 --- a/examples/stories/subscriptions/client.py +++ b/examples/stories/subscriptions/client.py @@ -4,88 +4,34 @@ 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] - - # The client is done listening: cancel the parked request and let - # the connection teardown below end the stream server-side. - tg.cancel_scope.cancel() + with anyio.fail_after(10): + assert await anext(sub) == ToolsListChanged() - # 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"}) diff --git a/mkdocs.yml b/mkdocs.yml index ae0c57f3ca..f40fcb3726 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index fa78f15ea7..d519106a63 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -6,7 +6,7 @@ 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 @@ 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.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 @@ async def retry(r: InputResponses | None, s: str | None) -> ReadResourceResult | # Driver rounds carry inputResponses, so a terminal result reached through them is never cached (spec MUST). return await self._drive_input_required(first, retry) + 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.""" - return await self.session.subscribe_resource(uri, meta=meta) + """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, diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 5c09304e42..097ade1c91 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -16,6 +16,7 @@ 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 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 @@ 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 @@ def __init__( 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 @@ async def __aenter__(self) -> Self: 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 @@ async def __aexit__( 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 @@ async def read_resource( 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 @@ async def dispatch_input_request( 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 @@ async def _on_notify( 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): diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 09e5048cc7..5c3501d8c4 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -13,6 +13,7 @@ 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 @@ async def _handle_post_request(self, ctx: RequestContext) -> None: ) 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 @@ async def _handle_sse_response( 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 @@ async def _handle_reconnection( 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 @@ async def _handle_reconnection( 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 @@ async def handle_request_async(): 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: diff --git a/src/mcp/client/subscriptions.py b/src/mcp/client/subscriptions.py new file mode 100644 index 0000000000..27283909be --- /dev/null +++ b/src/mcp/client/subscriptions.py @@ -0,0 +1,282 @@ +"""Client-side `subscriptions/listen` driver (2026-07-28, SEP-2575). + +`listen()` opens the stream as an async context manager: entering waits for +the server's acknowledgment, iteration yields typed change events, a graceful +server close ends the loop, and an abrupt drop raises `SubscriptionLost`. +There is no replay and no automatic re-listen: a client that re-opens a +subscription refetches what it depends on. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable, Sequence +from contextlib import asynccontextmanager +from itertools import count +from typing import TYPE_CHECKING, Literal + +import anyio +import mcp_types as types +from mcp_types.version import MODERN_PROTOCOL_VERSIONS + +from mcp.shared.dispatcher import CallOptions +from mcp.shared.exceptions import MCPError +from mcp.shared.subscriptions import ( + PromptsListChanged, + ResourcesListChanged, + ResourceUpdated, + ServerEvent, + ToolsListChanged, + event_matches, +) + +if TYPE_CHECKING: + from mcp.client.session import ClientSession + +__all__ = [ + "ListenNotSupportedError", + "OnEvent", + "PromptsListChanged", + "ResourceUpdated", + "ResourcesListChanged", + "ServerEvent", + "Subscription", + "SubscriptionLost", + "ToolsListChanged", + "listen", +] + +_listen_ids = count(1) +"""Process-wide `listen-N` sequence: string ids can never collide with a dispatcher's minted ints.""" + +_MAX_PENDING_EVENTS = 1024 +"""Backlog backstop: the spec allows sub-resource URIs, so distinct pending +`ResourceUpdated` events are unbounded; overflowing this cap settles the +subscription lost rather than growing client memory.""" + +_SubscriptionEnd = Literal["graceful", "lost", "local"] + + +class ListenNotSupportedError(RuntimeError): + """`subscriptions/listen` requires a 2026-07-28 connection.""" + + def __init__(self, negotiated_version: str | None) -> None: + self.negotiated_version = negotiated_version + super().__init__( + f"subscriptions/listen is not available at protocol version {negotiated_version!r}; it requires " + "2026-07-28. On earlier versions use subscribe_resource() and the change notifications delivered " + "through message_handler." + ) + + +class SubscriptionLost(RuntimeError): + """The stream ended without the server's graceful close; re-listen and refetch.""" + + +class ListenRoute: + """Package-internal demux state for one listen stream, fed synchronously in receive order by the session.""" + + def __init__(self) -> None: + self.honored: types.SubscriptionFilter | None = None + self.acked = anyio.Event() + self.error: MCPError | None = None + self.end: _SubscriptionEnd | None = None + self._honored_uris: frozenset[str] = frozenset() + self._pending: dict[ServerEvent, None] = {} + self._wake = anyio.Event() + + def set_acked(self, honored: types.SubscriptionFilter) -> None: + """Record the acknowledged filter; the first ack wins.""" + if not self.acked.is_set(): + self.honored = honored + self._honored_uris = frozenset(honored.resource_subscriptions or ()) + self.acked.set() + + def deliver(self, event: ServerEvent) -> None: + """Queue an event within the honored filter, deduplicated against the backlog. + + Any `ResourceUpdated` is admitted once URI subscriptions were honored at + all: the spec allows the stamped URI to be a sub-resource of a subscribed one. + """ + if self.end is not None or self.honored is None: + return + if isinstance(event, ResourceUpdated): + admitted = bool(self._honored_uris) + else: + admitted = event_matches(self.honored, self._honored_uris, event) + if not admitted or event in self._pending: + return + if len(self._pending) >= _MAX_PENDING_EVENTS: + self.settle( + "lost", + error=MCPError( + types.INTERNAL_ERROR, + f"subscription backlog exceeded {_MAX_PENDING_EVENTS} unconsumed events; re-listen and refetch", + ), + ) + return + self._pending[event] = None + self._wake.set() + + def settle(self, end: _SubscriptionEnd, error: MCPError | None = None) -> None: + """Record the stream's end; the first reason wins and wakes both waiters.""" + if self.end is None: + self.end = end + self.error = error + self.acked.set() + self._wake.set() + + async def next_event(self) -> ServerEvent | _SubscriptionEnd: + """Peek the next pending event, or the stream's end once the backlog drains. + + A "local" end short-circuits the backlog; the other endings drain it first, + so a graceful close never swallows events that preceded it. + """ + while True: + # Snapshot the wake event before checking state so a deliver landing after the checks cannot be missed. + wake = self._wake + if self.end == "local": + return self.end + if self._pending: + return next(iter(self._pending)) + if self.end is not None: + return self.end + await wake.wait() + self._wake = anyio.Event() + + def consume(self, event: ServerEvent) -> None: + """Remove a peeked event from the backlog.""" + self._pending.pop(event, None) + + +OnEvent = Callable[[ServerEvent], Awaitable[None]] +"""Per-event barrier awaited before a `Subscription` returns each event to its consumer.""" + + +class Subscription: + """One open `subscriptions/listen` stream: an async iterator of typed events. + + Produced by `listen()` / `Client.listen()`, not constructed directly. + """ + + def __init__( + self, + route: ListenRoute, + subscription_id: types.RequestId, + honored: types.SubscriptionFilter, + on_event: OnEvent | None = None, + ): + self._route = route + self._on_event = on_event + self.subscription_id = subscription_id + """The listen request's JSON-RPC id, stamped into every frame's `_meta`.""" + self.honored = honored + """The subset of the requested filter the server agreed to deliver.""" + + def __aiter__(self) -> Subscription: + return self + + async def __anext__(self) -> ServerEvent: + """Yield the next change event; the loop ends when the stream does. + + Raises: + SubscriptionLost: the stream dropped without the server's graceful close. + """ + outcome = await self._route.next_event() + if isinstance(outcome, str): + if outcome == "lost": + raise SubscriptionLost( + f"subscription {self.subscription_id!r} ended without the server's graceful close;" + " re-listen and refetch" + ) from self._route.error + raise StopAsyncIteration + if self._on_event is not None: + # The event stays pending while the barrier runs: a cancellation or a + # raising barrier leaves it for the next anext instead of dropping it. + await self._on_event(outcome) + self._route.consume(outcome) + return outcome + + +@asynccontextmanager +async def listen( + session: ClientSession, + *, + tools_list_changed: bool = False, + prompts_list_changed: bool = False, + resources_list_changed: bool = False, + resource_subscriptions: Sequence[str] = (), + on_event: OnEvent | None = None, +) -> AsyncIterator[Subscription]: + """Open one `subscriptions/listen` stream on `session` (2026-07-28 only). + + Entering sends the request and returns once the server's acknowledgment + arrives; exiting ends the subscription. `on_event` is awaited before each + event is returned - the seam `Client.listen` uses to finish cache eviction + before the consumer can refetch. + + Raises: + ListenNotSupportedError: negotiated version predates 2026-07-28. + MCPError: the server rejected the request, or the connection failed pre-ack. + SubscriptionLost: the stream ended before it was acknowledged. + TimeoutError: the session's read timeout elapsed before the acknowledgment. + """ + if session.protocol_version not in MODERN_PROTOCOL_VERSIONS: + raise ListenNotSupportedError(session.protocol_version) + if isinstance(resource_subscriptions, str): + raise TypeError("resource_subscriptions takes a sequence of URIs, not a bare string") + request = types.SubscriptionsListenRequest( + params=types.SubscriptionsListenRequestParams( + notifications=types.SubscriptionFilter( + tools_list_changed=tools_list_changed or None, + prompts_list_changed=prompts_list_changed or None, + resources_list_changed=resources_list_changed or None, + resource_subscriptions=list(resource_subscriptions) or None, + ) + ) + ) + task_group = session._task_group # pyright: ignore[reportPrivateUsage] + if task_group is None: + raise RuntimeError("listen() requires an entered session") + request_id: types.RequestId = f"listen-{next(_listen_ids)}" + data = request.model_dump(by_alias=True, mode="json", exclude_none=True) + opts: CallOptions = {"request_id": request_id} + session._stamp(data, opts) # pyright: ignore[reportPrivateUsage] + driver_scope = anyio.CancelScope() + + async def drive() -> None: + # Deliberately no result timeout: the response arrives when the stream ends. + with driver_scope: + try: + await session._dispatcher.send_raw_request( # pyright: ignore[reportPrivateUsage] + data["method"], data.get("params"), opts + ) + except MCPError as error: + route.settle("lost", error=error) + return + except ValueError as error: + # A raw request id collided with our minted listen id: fail this subscription + # and release the route in this same slice, so it cannot consume the raw caller's ack. + session._unregister_listen_route(request_id) # pyright: ignore[reportPrivateUsage] + route.settle("lost", error=MCPError(types.INTERNAL_ERROR, str(error))) + return + # A result, whatever its body, is the spec's graceful close; with no prior ack + # it opens the subscription already closed. + route.set_acked(types.SubscriptionFilter()) + route.settle("graceful") + + # Register the demux route before the request is written so the ack cannot race it. + route = session._register_listen_route(request_id) # pyright: ignore[reportPrivateUsage] + try: + task_group.start_soon(drive) + with anyio.fail_after(session._session_read_timeout_seconds): # pyright: ignore[reportPrivateUsage] + await route.acked.wait() + if route.honored is None: + # Only reachable on failure paths: a graceful no-ack result acked an empty filter in drive(). + if route.error is not None: + raise route.error + raise SubscriptionLost(f"subscription {request_id!r} ended before it was acknowledged") + yield Subscription(route, request_id, route.honored, on_event) + finally: + route.settle("local") + driver_scope.cancel() + session._unregister_listen_route(request_id) # pyright: ignore[reportPrivateUsage] diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index 28d06761d3..2b7fdf35ee 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -16,14 +16,14 @@ elicit_with_validation, ) from mcp.server.lowlevel.helper_types import ReadResourceContents -from mcp.server.subscriptions import ( +from mcp.server.subscriptions import SubscriptionBus +from mcp.shared.exceptions import MCPDeprecationWarning +from mcp.shared.subscriptions import ( PromptsListChanged, ResourcesListChanged, ResourceUpdated, - SubscriptionBus, ToolsListChanged, ) -from mcp.shared.exceptions import MCPDeprecationWarning if TYPE_CHECKING: from mcp.server.mcpserver.server import MCPServer diff --git a/src/mcp/server/subscriptions.py b/src/mcp/server/subscriptions.py index d071cfdbf4..6b0b3d49b5 100644 --- a/src/mcp/server/subscriptions.py +++ b/src/mcp/server/subscriptions.py @@ -13,6 +13,8 @@ `MCPServer` registers one automatically; lowlevel `Server` users pass an instance as `on_subscriptions_listen=`. +The event vocabulary lives in `mcp.shared.subscriptions`, shared with the client driver, and is re-exported here. + Per the spec, the handler acknowledges first (the ack is the first frame on the stream), tags every frame with the listen request's JSON-RPC id under `_meta["io.modelcontextprotocol/subscriptionId"]`, and never delivers an @@ -24,7 +26,6 @@ import logging from collections.abc import Callable -from dataclasses import dataclass from typing import Any, Protocol import anyio @@ -33,56 +34,39 @@ from mcp_types import ( INTERNAL_ERROR, INVALID_REQUEST, - NotificationParams, - PromptListChangedNotification, - ResourceListChangedNotification, - ResourceUpdatedNotification, - ResourceUpdatedNotificationParams, - ServerNotification, SubscriptionFilter, SubscriptionsAcknowledgedNotification, SubscriptionsAcknowledgedNotificationParams, SubscriptionsListenRequestParams, SubscriptionsListenResult, - ToolListChangedNotification, ) from mcp.server.context import ServerRequestContext from mcp.shared.exceptions import MCPError +from mcp.shared.subscriptions import ( + SUBSCRIPTION_ID_META_KEY, + PromptsListChanged, + ResourcesListChanged, + ResourceUpdated, + ServerEvent, + ToolsListChanged, + event_matches, + event_to_notification, +) -logger = logging.getLogger(__name__) - -SUBSCRIPTION_ID_META_KEY = "io.modelcontextprotocol/subscriptionId" -"""The `_meta` key carrying the subscription id on every listen-stream frame. - -The value is the `subscriptions/listen` request's JSON-RPC id, verbatim. -""" - - -@dataclass(frozen=True) -class ToolsListChanged: - """The server's tool list changed.""" - - -@dataclass(frozen=True) -class PromptsListChanged: - """The server's prompt list changed.""" - - -@dataclass(frozen=True) -class ResourcesListChanged: - """The server's resource list changed.""" - - -@dataclass(frozen=True) -class ResourceUpdated: - """The resource at `uri` changed and may need to be read again.""" - - uri: str - +__all__ = [ + "SUBSCRIPTION_ID_META_KEY", + "InMemorySubscriptionBus", + "ListenHandler", + "PromptsListChanged", + "ResourceUpdated", + "ResourcesListChanged", + "ServerEvent", + "SubscriptionBus", + "ToolsListChanged", +] -ServerEvent = ToolsListChanged | PromptsListChanged | ResourcesListChanged | ResourceUpdated -"""An event a server publishes for delivery to listen subscribers.""" +logger = logging.getLogger(__name__) class SubscriptionBus(Protocol): @@ -170,32 +154,6 @@ def _honored_subset(requested: SubscriptionFilter) -> SubscriptionFilter: ) -def _event_matches(honored: SubscriptionFilter, uris: frozenset[str], event: ServerEvent) -> bool: - """Whether `event` is within the stream's honored filter. - - `uris` is the honored `resource_subscriptions` as a set: matching runs on - every publish, and the wire filter may name many URIs. - """ - if isinstance(event, ToolsListChanged): - return honored.tools_list_changed is True - if isinstance(event, PromptsListChanged): - return honored.prompts_list_changed is True - if isinstance(event, ResourcesListChanged): - return honored.resources_list_changed is True - return event.uri in uris - - -def _event_to_notification(event: ServerEvent, meta: dict[str, Any]) -> ServerNotification: - """Build the stamped wire notification for `event`.""" - if isinstance(event, ToolsListChanged): - return ToolListChangedNotification(params=NotificationParams(_meta=meta)) - if isinstance(event, PromptsListChanged): - return PromptListChangedNotification(params=NotificationParams(_meta=meta)) - if isinstance(event, ResourcesListChanged): - return ResourceListChangedNotification(params=NotificationParams(_meta=meta)) - return ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri=event.uri, _meta=meta)) - - class ListenHandler: """Serves `subscriptions/listen`: one call is one subscription stream. @@ -244,7 +202,7 @@ async def __call__( send, recv = anyio.create_memory_object_stream[ServerEvent](self._max_buffered_events) def deliver(event: ServerEvent) -> None: - if _event_matches(honored, honored_uris, event): + if event_matches(honored, honored_uris, event): try: send.send_nowait(event) except anyio.ClosedResourceError: @@ -273,7 +231,7 @@ def deliver(event: ServerEvent) -> None: ) async for event in recv: await ctx.session.send_notification( - _event_to_notification(event, meta), related_request_id=subscription_id + event_to_notification(event, meta), related_request_id=subscription_id ) finally: _safe_unsubscribe(unsubscribe) diff --git a/src/mcp/shared/direct_dispatcher.py b/src/mcp/shared/direct_dispatcher.py index 62c74b808e..e17283afa2 100644 --- a/src/mcp/shared/direct_dispatcher.py +++ b/src/mcp/shared/direct_dispatcher.py @@ -28,7 +28,15 @@ from pydantic import ValidationError from mcp.shared._compat import resync_tracer -from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest, ProgressFnT, coerce_request_id +from mcp.shared.dispatcher import ( + CallOptions, + OnNotify, + OnNotifyIntercept, + OnRequest, + ProgressFnT, + coerce_request_id, + run_notify_intercept, +) from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.message import MessageMetadata from mcp.shared.transport_context import TransportContext @@ -106,6 +114,7 @@ def __init__(self, transport_ctx: TransportContext, *, raise_handler_exceptions: self._peer: DirectDispatcher | None = None self._on_request: OnRequest | None = None self._on_notify: OnNotify | None = None + self._on_notify_intercept: OnNotifyIntercept | None = None self._next_id = 0 self._in_flight_ids: set[RequestId] = set() self._ready = anyio.Event() @@ -158,6 +167,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: @@ -169,6 +179,7 @@ async def run( try: self._on_request = on_request self._on_notify = on_notify + self._on_notify_intercept = on_notify_intercept self._running = True self._ready.set() task_status.started() @@ -286,6 +297,8 @@ async def _dispatch_notify(self, method: str, params: Mapping[str, Any] | None) # dropped, not raised back into the sender's call. logger.debug("dropped notification %r to closed DirectDispatcher", method) return + if run_notify_intercept(self._on_notify_intercept, method, params): + return assert self._on_notify is not None dctx = self._make_context() await self._on_notify(dctx, method, params) diff --git a/src/mcp/shared/dispatcher.py b/src/mcp/shared/dispatcher.py index 16360d3142..f109638f2a 100644 --- a/src/mcp/shared/dispatcher.py +++ b/src/mcp/shared/dispatcher.py @@ -16,6 +16,7 @@ embedding a server in-process. """ +import logging from collections.abc import Awaitable, Callable, Mapping from typing import Any, Protocol, TypedDict, TypeVar, runtime_checkable @@ -26,20 +27,32 @@ from mcp.shared.message import MessageMetadata from mcp.shared.transport_context import TransportContext +logger = logging.getLogger(__name__) + __all__ = [ "CallOptions", "DispatchContext", "Dispatcher", "OnNotify", + "OnNotifyIntercept", "OnRequest", "Outbound", "ProgressFnT", + "as_request_id", "coerce_request_id", + "run_notify_intercept", ] TransportT_co = TypeVar("TransportT_co", bound=TransportContext, covariant=True) +def as_request_id(value: object) -> RequestId | None: + """Narrow an untyped wire value to a `RequestId`, or None; rejects bool (True would alias request id 1).""" + if isinstance(value, str | int) and not isinstance(value, bool): + return value + return None + + def coerce_request_id(request_id: RequestId) -> RequestId: """Coerce a stringified int request id back to int so a peer-echoed id still correlates (matches the TS SDK). @@ -211,6 +224,25 @@ async def progress(self, progress: float, total: float | None = None, message: s OnNotify = Callable[[DispatchContext[TransportContext], str, Mapping[str, Any] | None], Awaitable[None]] """Handler for inbound notifications: `(ctx, method, params)`.""" +OnNotifyIntercept = Callable[[str, Mapping[str, Any] | None], bool] +"""Synchronous receive-order intercept for inbound notifications: `(method, params) -> consumed`. + +Runs before `on_notify` is scheduled so correlation state advances in wire order +relative to response resolution (the client's listen demux depends on this). +Returning True consumes the notification. Must not block the receive path. +""" + + +def run_notify_intercept(intercept: OnNotifyIntercept | None, method: str, params: Mapping[str, Any] | None) -> bool: + """Invoke `intercept`, containing a raise to that one notification (never the receive loop).""" + if intercept is None: + return False + try: + return intercept(method, params) + except Exception: + logger.exception("notification intercept raised; passing %r through", method) + return False + class Dispatcher(Outbound, Protocol[TransportT_co]): """A duplex request/notification channel with call-return semantics. @@ -225,6 +257,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: @@ -232,7 +265,9 @@ async def run( Each inbound request is dispatched to `on_request` in its own task; the returned dict (or raised `MCPError`) is sent back as the response. - Inbound notifications go to `on_notify`. + Implementations MUST offer every inbound notification to + `on_notify_intercept` synchronously in receive order (via + `run_notify_intercept`), handing only unconsumed ones to `on_notify`. `task_status.started()` is called once the dispatcher is ready to accept `send_request`/`notify` calls, so callers can use diff --git a/src/mcp/shared/jsonrpc_dispatcher.py b/src/mcp/shared/jsonrpc_dispatcher.py index 793c59bc7b..42798fdc54 100644 --- a/src/mcp/shared/jsonrpc_dispatcher.py +++ b/src/mcp/shared/jsonrpc_dispatcher.py @@ -44,9 +44,12 @@ DispatchContext, Dispatcher, OnNotify, + OnNotifyIntercept, OnRequest, ProgressFnT, + as_request_id, coerce_request_id, + run_notify_intercept, ) from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.message import ( @@ -107,12 +110,8 @@ def progress_token_from_params(params: Mapping[str, Any] | None) -> ProgressToke def cancelled_request_id_from_params(params: Mapping[str, Any] | None) -> RequestId | None: - """Read `params.requestId` from a `notifications/cancelled`; reject bool (True would alias request id 1).""" - match params: - case {"requestId": str() | int() as request_id} if not isinstance(request_id, bool): - return request_id - case _: - return None + """Read `params.requestId` from a `notifications/cancelled` (`as_request_id` shape rules).""" + return as_request_id((params or {}).get("requestId")) @dataclass(slots=True) @@ -297,6 +296,7 @@ def __init__( self._next_id = 0 self._pending: dict[RequestId, _Pending] = {} self._in_flight: dict[RequestId, _InFlight[TransportT]] = {} + self._on_notify_intercept: OnNotifyIntercept | None = None self._tg: anyio.abc.TaskGroup | None = None self._running = False self._closed = False @@ -466,6 +466,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: @@ -474,6 +475,7 @@ async def run( `task_status.started()` fires once `send_raw_request` is usable. Single-shot: once the loop ends the dispatcher stays closed and cannot be restarted. """ + self._on_notify_intercept = on_notify_intercept try: # LIFO exits: the write stream closes only after the task-group join, so teardown writes still land. async with self._write_stream: @@ -603,7 +605,9 @@ def _dispatch_notification( `notifications/cancelled` and `notifications/progress` are intercepted here (they correlate against the `_in_flight`/`_pending` tables this - layer owns) and still teed to `on_notify` afterwards. + layer owns) and still teed to `on_notify` afterwards. The caller's + `on_notify_intercept` then runs in receive order; only unconsumed + notifications reach the spawned `on_notify`. """ if msg.method == "notifications/cancelled": rid = cancelled_request_id_from_params(msg.params) @@ -630,6 +634,8 @@ def _dispatch_notification( ) case _: pass + if run_notify_intercept(self._on_notify_intercept, msg.method, msg.params): + return try: transport_ctx = self._transport_builder(metadata) except Exception: diff --git a/src/mcp/shared/subscriptions.py b/src/mcp/shared/subscriptions.py new file mode 100644 index 0000000000..ba50917fa4 --- /dev/null +++ b/src/mcp/shared/subscriptions.py @@ -0,0 +1,106 @@ +"""Typed event vocabulary for `subscriptions/listen` (2026-07-28, SEP-2575), shared by server and client. + +Every event is a level trigger ("this changed, refetch if you care"), so both sides bound buffers by dedupe. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from mcp_types import ( + NotificationParams, + PromptListChangedNotification, + ResourceListChangedNotification, + ResourceUpdatedNotification, + ResourceUpdatedNotificationParams, + ServerNotification, + SubscriptionFilter, + ToolListChangedNotification, +) + +__all__ = [ + "SUBSCRIPTION_ID_META_KEY", + "PromptsListChanged", + "ResourceUpdated", + "ResourcesListChanged", + "ServerEvent", + "ToolsListChanged", + "event_from_wire", + "event_matches", + "event_to_notification", +] + +SUBSCRIPTION_ID_META_KEY = "io.modelcontextprotocol/subscriptionId" +"""The `_meta` key on every listen-stream frame; the value is the `subscriptions/listen` request's JSON-RPC id.""" + + +@dataclass(frozen=True) +class ToolsListChanged: + """The server's tool list changed.""" + + +@dataclass(frozen=True) +class PromptsListChanged: + """The server's prompt list changed.""" + + +@dataclass(frozen=True) +class ResourcesListChanged: + """The server's resource list changed.""" + + +@dataclass(frozen=True) +class ResourceUpdated: + """The resource at `uri` changed and may need to be read again.""" + + uri: str + + +ServerEvent = ToolsListChanged | PromptsListChanged | ResourcesListChanged | ResourceUpdated +"""An event a server publishes for delivery to listen subscribers.""" + + +def event_to_notification(event: ServerEvent, meta: dict[str, Any]) -> ServerNotification: + """Build the stamped wire notification for `event` (the server's direction).""" + if isinstance(event, ToolsListChanged): + return ToolListChangedNotification(params=NotificationParams(_meta=meta)) + if isinstance(event, PromptsListChanged): + return PromptListChangedNotification(params=NotificationParams(_meta=meta)) + if isinstance(event, ResourcesListChanged): + return ResourceListChangedNotification(params=NotificationParams(_meta=meta)) + return ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri=event.uri, _meta=meta)) + + +_LIST_CHANGED_EVENTS: dict[str, ServerEvent] = { + "notifications/tools/list_changed": ToolsListChanged(), + "notifications/prompts/list_changed": PromptsListChanged(), + "notifications/resources/list_changed": ResourcesListChanged(), +} + + +def event_from_wire(method: str, params: Mapping[str, Any] | None) -> ServerEvent | None: + """The event a raw listen-stream frame announces, or None if it carries none. + + Takes the raw wire dict: the client demultiplexes before the typed notification parse.""" + if (event := _LIST_CHANGED_EVENTS.get(method)) is not None: + return event + if method == "notifications/resources/updated": + uri = (params or {}).get("uri") + if isinstance(uri, str): + return ResourceUpdated(uri=uri) + return None + + +def event_matches(honored: SubscriptionFilter, uris: frozenset[str], event: ServerEvent) -> bool: + """Whether `event` is within the stream's honored filter (`uris`: the honored resource subscriptions as a set). + + The admission predicate both sides share: server delivery and client intake honor only what was acknowledged.""" + if isinstance(event, ToolsListChanged): + return honored.tools_list_changed is True + if isinstance(event, PromptsListChanged): + return honored.prompts_list_changed is True + if isinstance(event, ResourcesListChanged): + return honored.resources_list_changed is True + return event.uri in uris diff --git a/tests/client/test_client.py b/tests/client/test_client.py index f8c02c9734..6c78503b97 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -35,7 +35,7 @@ from mcp_types.version import LATEST_HANDSHAKE_VERSION from pydantic import FileUrl -from mcp import MCPError +from mcp import MCPDeprecationWarning, MCPError from mcp.client._memory import InMemoryTransport from mcp.client._transport import TransportStreams from mcp.client.client import Client @@ -310,13 +310,15 @@ async def handle_progress(ctx: ServerRequestContext, params: types.ProgressNotif async def test_client_subscribe_resource(simple_server: Server): async with Client(simple_server, mode="legacy") as client: - result = await client.subscribe_resource("memory://test") + with pytest.warns(MCPDeprecationWarning, match="use Client.listen"): + result = await client.subscribe_resource("memory://test") # pyright: ignore[reportDeprecated] assert result == snapshot(EmptyResult()) async def test_client_unsubscribe_resource(simple_server: Server): async with Client(simple_server, mode="legacy") as client: - result = await client.unsubscribe_resource("memory://test") + with pytest.warns(MCPDeprecationWarning, match="use Client.listen"): + result = await client.unsubscribe_resource("memory://test") # pyright: ignore[reportDeprecated] assert result == snapshot(EmptyResult()) diff --git a/tests/client/test_send_request_mcp_name.py b/tests/client/test_send_request_mcp_name.py index 4088108148..e22ec4015b 100644 --- a/tests/client/test_send_request_mcp_name.py +++ b/tests/client/test_send_request_mcp_name.py @@ -22,7 +22,7 @@ from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION from mcp.client.session import ClientSession -from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest +from mcp.shared.dispatcher import CallOptions, OnNotify, OnNotifyIntercept, OnRequest from mcp.shared.inbound import MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER, encode_header_value @@ -36,6 +36,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: diff --git a/tests/client/test_session.py b/tests/client/test_session.py index 2a53f67cea..507c8f69e3 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -39,11 +39,13 @@ from mcp.client import ClientRequestContext from mcp.client.client import Client from mcp.client.session import DEFAULT_CLIENT_INFO, ClientSession +from mcp.client.subscriptions import ToolsListChanged, listen from mcp.server import Server, ServerRequestContext from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair -from mcp.shared.dispatcher import CallOptions, DispatchContext, OnNotify, OnRequest +from mcp.shared.dispatcher import CallOptions, DispatchContext, OnNotify, OnNotifyIntercept, OnRequest from mcp.shared.message import SessionMessage from mcp.shared.session import RequestResponder +from mcp.shared.subscriptions import SUBSCRIPTION_ID_META_KEY from mcp.shared.transport_context import TransportContext _SendToClient = anyio.streams.memory.MemoryObjectSendStream[SessionMessage | Exception] @@ -1341,6 +1343,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: @@ -1428,6 +1431,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: @@ -1486,6 +1490,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: @@ -1808,3 +1813,137 @@ async def handler(ctx: ServerRequestContext, params: types.ReadResourceRequestPa result = await client.session.read_resource("memory://r", allow_input_required=True) assert isinstance(result, types.InputRequiredResult) assert result.request_state == "resource-state" + + +@pytest.mark.anyio +async def test_a_late_ack_for_a_closed_driver_listen_reaches_message_handler(): + """Ack consumption is keyed on the live route registry alone: a stray ack for a + closed subscription's id surfaces through message_handler like any other unowned frame.""" + seen: list[object] = [] + follow_up = anyio.Event() + + async def handler(msg: object) -> None: + seen.append(msg) + if len(seen) == 2: + follow_up.set() + + async with raw_client_session(message_handler=handler) as (session, to_client, _): + _set_negotiated_version(session, "2026-07-28") + session._register_listen_route("listen-99") # pyright: ignore[reportPrivateUsage] + session._unregister_listen_route("listen-99") # pyright: ignore[reportPrivateUsage] + await to_client.send( + SessionMessage( + JSONRPCNotification( + jsonrpc="2.0", + method="notifications/subscriptions/acknowledged", + params={ + "notifications": {"toolsListChanged": True}, + "_meta": {SUBSCRIPTION_ID_META_KEY: "listen-99"}, + }, + ) + ) + ) + await to_client.send( + SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/tools/list_changed", params={})) + ) + with anyio.fail_after(5): + await follow_up.wait() + assert [type(message).__name__ for message in seen] == [ + "SubscriptionsAcknowledgedNotification", + "ToolListChangedNotification", + ] + + +@pytest.mark.anyio +async def test_a_graceful_result_does_not_outrun_the_events_that_preceded_it(): + """[ack, event, result] written back-to-back: the event delivers and the wire ack's filter + survives a parked message_handler tee, because routes settle on the dispatcher's receive path in wire order.""" + + async def parked_handler(message: object) -> None: + await anyio.sleep_forever() + + events: list[object] = [] + honored: list[types.SubscriptionFilter] = [] + async with raw_client_session(message_handler=parked_handler) as (session, to_client, from_client): + _set_negotiated_version(session, "2026-07-28") + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: # pragma: no branch + + async def consume() -> None: + async with listen(session, tools_list_changed=True) as sub: # pragma: no branch + honored.append(sub.honored) + events.extend([event async for event in sub]) + + tg.start_soon(consume) + request = await from_client.receive() + assert isinstance(request.message, JSONRPCRequest) + meta = {SUBSCRIPTION_ID_META_KEY: request.message.id} + for message in ( + JSONRPCNotification( + jsonrpc="2.0", + method="notifications/subscriptions/acknowledged", + params={"notifications": {"toolsListChanged": True}, "_meta": meta}, + ), + JSONRPCNotification( + jsonrpc="2.0", method="notifications/tools/list_changed", params={"_meta": meta} + ), + JSONRPCResponse(jsonrpc="2.0", id=request.message.id, result={"_meta": meta}), + ): + await to_client.send(SessionMessage(message)) + assert honored == [types.SubscriptionFilter(tools_list_changed=True)] + assert events == [ToolsListChanged()] + + +def _intercept_only_session() -> ClientSession: + """A never-entered session whose intercept can be driven directly (it is synchronous).""" + dispatcher, _peer = create_direct_dispatcher_pair() + return ClientSession(dispatcher=dispatcher) + + +def test_intercept_settles_only_the_named_listen_route_on_cancelled(): + """SDK demux contract: a server-sent cancel settles exactly the listen route it names and is never consumed.""" + session = _intercept_only_session() + route = session._register_listen_route("listen-1") # pyright: ignore[reportPrivateUsage] + intercept = session._intercept_notification # pyright: ignore[reportPrivateUsage] + assert intercept("notifications/cancelled", {"requestId": "unrelated"}) is False + assert route.end is None + assert intercept("notifications/cancelled", {"requestId": "listen-1"}) is False + assert route.end == "lost" + + +def test_intercept_ignores_frames_without_a_route_or_with_broken_meta(): + """SDK demux contract: frames that correlate to no live route flow through to the normal notification path.""" + session = _intercept_only_session() + intercept = session._intercept_notification # pyright: ignore[reportPrivateUsage] + assert intercept("notifications/tools/list_changed", {"_meta": {SUBSCRIPTION_ID_META_KEY: "listen-1"}}) is False + route = session._register_listen_route("listen-1") # pyright: ignore[reportPrivateUsage] + route.set_acked(types.SubscriptionFilter(tools_list_changed=True)) + assert intercept("notifications/tools/list_changed", None) is False + # A non-mapping `_meta` is constructible on pre-2026 wires. + assert intercept("notifications/tools/list_changed", {"_meta": "oops"}) is False + assert intercept("notifications/tools/list_changed", {"_meta": {SUBSCRIPTION_ID_META_KEY: "other"}}) is False + # A non-string uri is not an event; surface validation owns it. + meta = {"_meta": {SUBSCRIPTION_ID_META_KEY: "listen-1"}} + assert intercept("notifications/resources/updated", {"uri": 7, **meta}) is False + assert route._pending == {} # pyright: ignore[reportPrivateUsage] + + +def test_intercept_consumes_acks_for_live_routes_and_leaves_malformed_ones(): + """SDK demux contract: a well-formed ack for a live route is consumed as driver state; malformed acks pass on.""" + session = _intercept_only_session() + route = session._register_listen_route("listen-1") # pyright: ignore[reportPrivateUsage] + intercept = session._intercept_notification # pyright: ignore[reportPrivateUsage] + meta = {"_meta": {SUBSCRIPTION_ID_META_KEY: "listen-1"}} + assert intercept("notifications/subscriptions/acknowledged", {"notifications": ["nope"], **meta}) is False + assert route.honored is None + # A missing `notifications` field must not be read as an (all-refusing) empty filter. + assert intercept("notifications/subscriptions/acknowledged", dict(meta)) is False + assert route.honored is None + assert ( + intercept("notifications/subscriptions/acknowledged", {"notifications": {"toolsListChanged": True}, **meta}) + is True + ) + assert route.honored == types.SubscriptionFilter(tools_list_changed=True) + # Events deliver but are never consumed - they still tee to message_handler. + assert intercept("notifications/tools/list_changed", meta) is False + assert list(route._pending) == [ToolsListChanged()] # pyright: ignore[reportPrivateUsage] diff --git a/tests/client/test_session_claims.py b/tests/client/test_session_claims.py index 21cf2fa691..94ebd7946e 100644 --- a/tests/client/test_session_claims.py +++ b/tests/client/test_session_claims.py @@ -28,7 +28,7 @@ from mcp.client.extension import ClaimContext, ResultClaim, UnexpectedClaimedResult from mcp.client.session import ClientSession, _CallToolResultAdapter -from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest +from mcp.shared.dispatcher import CallOptions, OnNotify, OnNotifyIntercept, OnRequest _TASKS_EXT = "com.example/tasks" _AD_ONLY_EXT = "com.example/flags" @@ -75,6 +75,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index defda41f85..da17a71c1e 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -18,6 +18,8 @@ from mcp_types import ( CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, + CONNECTION_CLOSED, + INVALID_REQUEST, METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, JSONRPCError, @@ -28,10 +30,16 @@ from mcp_types.version import LATEST_MODERN_VERSION from starlette.types import Receive, Scope, Send -from mcp.client.streamable_http import streamable_http_client +from mcp.client.streamable_http import ( + MAX_RECONNECTION_ATTEMPTS, + RequestContext, + StreamableHTTPTransport, + streamable_http_client, +) from mcp.server import Server from mcp.server._streamable_http_modern import handle_modern_request from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ServerEvent +from mcp.shared._context_streams import ContextSendStream, create_context_streams from mcp.shared.dispatcher import CallOptions, DispatchContext from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_PROTOCOL_VERSION_HEADER, encode_header_value from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher @@ -583,3 +591,160 @@ def handler(request: httpx.Request) -> httpx.Response: ) await parked.closed.wait() assert [body["method"] for body in posted] == ["tools/call", "subscriptions/listen"] + + +class _DyingSSEStream(httpx.AsyncByteStream): + """Emits one id-less comment then breaks - a non-resumable stream dropping.""" + + def __init__(self) -> None: + self.opened = anyio.Event() + + async def __aiter__(self) -> AsyncIterator[bytes]: + self.opened.set() + yield b": hello\n\n" + raise httpx.ReadError("connection reset") + + async def aclose(self) -> None: + pass + + +@pytest.mark.anyio +async def test_a_non_resumable_sse_drop_resolves_the_request_with_an_error() -> None: + """A per-request SSE stream that dies having carried no event ids can never deliver its + response; the transport resolves the waiter with CONNECTION_CLOSED instead of hanging forever.""" + dying = _DyingSSEStream() + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=dying) + + with anyio.fail_after(5): + async with ( + httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, + streamable_http_client("http://test/mcp", http_client=http) as (read, write), + ): + await write.send( + SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={})) + ) + reply = await read.receive() + assert isinstance(reply, SessionMessage) + assert isinstance(reply.message, JSONRPCError) + assert reply.message.id == "listen-1" + assert reply.message.error.code == CONNECTION_CLOSED + + +class _DeliverOnCommandSSEStream(httpx.AsyncByteStream): + """Parks after opening, then delivers one JSON-RPC response when told.""" + + def __init__(self, response_body: dict[str, Any]) -> None: + self._event = f"data: {json.dumps(response_body)}\n\n".encode() + self.opened = anyio.Event() + self.deliver = anyio.Event() + + async def __aiter__(self) -> AsyncIterator[bytes]: + self.opened.set() + await self.deliver.wait() + yield self._event + + async def aclose(self) -> None: + pass + + +@pytest.mark.anyio +async def test_a_superseded_posts_late_real_response_cannot_answer_the_successor() -> None: + """SDK-defined: re-issuing an id severs the superseded POST, so nothing from its + stream (a late real response, or a synthesized error for its death) can resolve + the reused id's waiter; only the successor's own response arrives.""" + stale = _DeliverOnCommandSSEStream({"jsonrpc": "2.0", "id": "dup-1", "result": {"origin": "stale"}}) + succeeding = _DeliverOnCommandSSEStream({"jsonrpc": "2.0", "id": "dup-1", "result": {"origin": "fresh"}}) + streams: list[httpx.AsyncByteStream] = [stale, succeeding] + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=streams.pop(0)) + + with anyio.fail_after(5): + async with ( + httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, + streamable_http_client("http://test/mcp", http_client=http) as (read, write), + ): + await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="tools/call", params={}))) + await stale.opened.wait() + await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="tools/call", params={}))) + await succeeding.opened.wait() + stale.deliver.set() + await anyio.wait_all_tasks_blocked() + succeeding.deliver.set() + reply = await read.receive() + assert isinstance(reply, SessionMessage) + assert isinstance(reply.message, JSONRPCResponse), reply.message + assert reply.message.result == {"origin": "fresh"} + + +@pytest.mark.anyio +async def test_a_202_to_a_request_resolves_the_waiter_with_an_error() -> None: + """SDK-defined: a server that answers a request with 202 Accepted has declared no + response will follow (the spec requires SSE or JSON for requests); the transport + resolves the waiter with INVALID_REQUEST instead of parking the caller forever.""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(202) + + with anyio.fail_after(5): + async with ( + httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, + streamable_http_client("http://test/mcp", http_client=http) as (read, write), + ): + await write.send( + SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={})) + ) + reply = await read.receive() + assert isinstance(reply, SessionMessage) + assert isinstance(reply.message, JSONRPCError) + assert reply.message.id == "listen-1" + assert reply.message.error.code == INVALID_REQUEST + + +def _abandoned_request_context( + http: httpx.AsyncClient, send: ContextSendStream[SessionMessage | Exception] +) -> RequestContext: + return RequestContext( + client=http, + session_id=None, + session_message=SessionMessage( + JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={}) + ), + metadata=None, + read_stream_writer=send, + ) + + +@pytest.mark.anyio +async def test_exhausted_reconnection_attempts_resolve_the_request_with_an_error() -> None: + """An id-bearing stream that exhausts its reconnection budget also resolves the waiter with CONNECTION_CLOSED.""" + transport = StreamableHTTPTransport("http://test/mcp") + send, receive = create_context_streams[SessionMessage | Exception](1) + async with httpx.AsyncClient() as http: + with anyio.fail_after(5): + await transport._handle_reconnection( # pyright: ignore[reportPrivateUsage] + _abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS + ) + reply = await receive.receive() + assert isinstance(reply, SessionMessage) + assert isinstance(reply.message, JSONRPCError) + assert reply.message.id == "listen-1" + assert reply.message.error.code == CONNECTION_CLOSED + send.close() + receive.close() + + +@pytest.mark.anyio +async def test_resolving_an_abandoned_request_after_the_reader_closed_is_contained() -> None: + """Teardown race: a stream dying after the reader closed resolves best-effort and must not crash.""" + transport = StreamableHTTPTransport("http://test/mcp") + send, receive = create_context_streams[SessionMessage | Exception](1) + receive.close() + async with httpx.AsyncClient() as http: + with anyio.fail_after(5): + await transport._handle_reconnection( # pyright: ignore[reportPrivateUsage] + _abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS + ) + send.close() diff --git a/tests/client/test_subscriptions.py b/tests/client/test_subscriptions.py new file mode 100644 index 0000000000..0cc4f133e4 --- /dev/null +++ b/tests/client/test_subscriptions.py @@ -0,0 +1,666 @@ +"""Behavioral tests for the client-side `subscriptions/listen` driver (SDK-defined contract). + +Public API only, against in-process servers; wire-shape assertions live in the interaction suite. +""" + +from itertools import count +from typing import Any + +import anyio +import mcp_types as types +import pytest +from mcp_types import SubscriptionFilter + +import mcp.client.subscriptions as subscriptions_module +from mcp import Client, MCPError +from mcp.client.session import ClientSession +from mcp.client.subscriptions import ( + ListenNotSupportedError, + ListenRoute, + PromptsListChanged, + ResourcesListChanged, + ResourceUpdated, + ServerEvent, + Subscription, + SubscriptionLost, + ToolsListChanged, + listen, +) +from mcp.server import Server, ServerRequestContext +from mcp.server.subscriptions import ( + SUBSCRIPTION_ID_META_KEY, + InMemorySubscriptionBus, + ListenHandler, +) +from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair +from mcp.shared.dispatcher import CallOptions + +pytestmark = pytest.mark.anyio + + +def _bus_server(bus: InMemorySubscriptionBus, *, max_subscriptions: int | None = None) -> Server[Any]: + """A lowlevel server whose only feature is serving listen streams from `bus`.""" + handler = ( + ListenHandler(bus) if max_subscriptions is None else ListenHandler(bus, max_subscriptions=max_subscriptions) + ) + return Server("subs", on_subscriptions_listen=handler) + + +async def _ack(ctx: ServerRequestContext[Any, Any], honored: SubscriptionFilter) -> dict[str, Any]: + """Send a hand-rolled ack for a scripted listen handler; returns the stamped meta.""" + assert ctx.request_id is not None + meta: dict[str, Any] = {SUBSCRIPTION_ID_META_KEY: ctx.request_id} + await ctx.session.send_notification( + types.SubscriptionsAcknowledgedNotification( + params=types.SubscriptionsAcknowledgedNotificationParams(notifications=honored, _meta=meta) + ), + related_request_id=ctx.request_id, + ) + return meta + + +async def test_listen_surfaces_the_honored_filter_and_subscription_id(): + """Entering waits for the server ack and surfaces the honored filter and subscription id.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with client.listen( # pragma: no branch + tools_list_changed=True, resource_subscriptions=["note://todo"] + ) as sub: + assert isinstance(sub, Subscription) + assert sub.honored.tools_list_changed is True + assert sub.honored.resource_subscriptions == ["note://todo"] + assert isinstance(sub.subscription_id, str) + assert sub.subscription_id.startswith("listen-") + + +async def test_listen_delivers_all_four_typed_event_kinds(): + """Bus publishes come back as the same typed event values, in order.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with client.listen( # pragma: no branch + tools_list_changed=True, + prompts_list_changed=True, + resources_list_changed=True, + resource_subscriptions=["note://todo"], + ) as sub: + for event in ( + ToolsListChanged(), + PromptsListChanged(), + ResourcesListChanged(), + ResourceUpdated(uri="note://todo"), + ): + await bus.publish(event) + assert await anext(sub) == event + + +async def test_unconsumed_duplicate_events_coalesce(): + """Events are level triggers: duplicates pending consumption collapse to one.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with client.listen( # pragma: no branch + tools_list_changed=True, resource_subscriptions=["note://todo"] + ) as sub: + for _ in range(3): + await bus.publish(ToolsListChanged()) + await bus.publish(ResourceUpdated(uri="note://todo")) + await anyio.wait_all_tasks_blocked() + assert await anext(sub) == ToolsListChanged() + assert await anext(sub) == ResourceUpdated(uri="note://todo") + + +async def test_graceful_server_close_ends_the_loop_cleanly(): + """The server's deliberate close ends iteration cleanly, after draining prior events.""" + bus = InMemorySubscriptionBus() + handler = ListenHandler(bus) + server = Server("subs", on_subscriptions_listen=handler) + events: list[object] = [] + async with Client(server) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + await bus.publish(ToolsListChanged()) + handler.close() + events.extend([event async for event in sub]) + assert events == [ToolsListChanged()] + + +async def test_abrupt_stream_end_raises_subscription_lost(): + """A stream dying without the graceful result raises `SubscriptionLost` with the cause chained.""" + proceed = anyio.Event() + + async def dropping_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + await _ack(ctx, params.notifications) + await proceed.wait() + raise MCPError(types.INTERNAL_ERROR, "stream torn down") + + server = Server("subs", on_subscriptions_listen=dropping_listen) + async with Client(server) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + proceed.set() + with pytest.raises(SubscriptionLost) as exc_info: # pragma: no branch + await anext(sub) + assert isinstance(exc_info.value.__cause__, MCPError) + assert exc_info.value.__cause__.error.message == "stream torn down" + + +async def test_listen_on_a_legacy_connection_raises_the_typed_steer(): + """On a 2025 connection `listen` fails fast with the typed error steering to the legacy verbs.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus), mode="legacy") as client: + with anyio.fail_after(5): + # Entering is where the guard fires; __aenter__ directly avoids an unreachable with-body. + with pytest.raises(ListenNotSupportedError) as exc_info: # pragma: no branch + await client.listen(tools_list_changed=True).__aenter__() + assert exc_info.value.negotiated_version == "2025-11-25" + assert "subscribe_resource" in str(exc_info.value) + + +async def test_server_rejection_raises_from_enter_not_from_iteration(): + """A server without the listen handler fails the open from entering the context.""" + server = Server("no-listen") + async with Client(server) as client: + with anyio.fail_after(5): + with pytest.raises(MCPError) as exc_info: # pragma: no branch + await client.listen(tools_list_changed=True).__aenter__() + assert exc_info.value.error.code == types.METHOD_NOT_FOUND + + +async def test_immediate_result_without_ack_opens_already_closed(): + """A bare result with no ack yields a subscription already gracefully over: no filter, no events.""" + + async def degenerate_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + return types.SubscriptionsListenResult(_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id}) + + server = Server("subs", on_subscriptions_listen=degenerate_listen) + async with Client(server) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + assert sub.honored == SubscriptionFilter() + with pytest.raises(StopAsyncIteration): # pragma: no branch + await anext(sub) + + +async def test_server_sent_cancelled_for_the_listen_id_raises_subscription_lost(): + """Server-sent notifications/cancelled for the listen id surfaces as a lost subscription.""" + proceed = anyio.Event() + + async def cancelling_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + await _ack(ctx, params.notifications) + await proceed.wait() + await ctx.session.send_notification( + types.CancelledNotification(params=types.CancelledNotificationParams(request_id=ctx.request_id)), + related_request_id=ctx.request_id, + ) + await anyio.sleep_forever() + raise AssertionError("unreachable") # pragma: no cover + + server = Server("subs", on_subscriptions_listen=cancelling_listen) + async with Client(server) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + proceed.set() + with pytest.raises(SubscriptionLost): # pragma: no branch + await anext(sub) + + +async def test_exiting_the_context_frees_the_server_slot(): + """Leaving the block ends the subscription server-side: a one-slot handler admits a second listen.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus, max_subscriptions=1)) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as first: + assert first.honored.tools_list_changed is True + async with client.listen(tools_list_changed=True) as second: # pragma: no branch + assert second.honored.tools_list_changed is True + assert second.subscription_id != first.subscription_id + + +async def test_concurrent_subscriptions_demux_independently(): + """Two open subscriptions each receive only their own filter's events.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with ( # pragma: no branch + client.listen(tools_list_changed=True) as tools_sub, + client.listen(resource_subscriptions=["note://todo"]) as notes_sub, + ): + await bus.publish(ToolsListChanged()) + await bus.publish(ResourceUpdated(uri="note://todo")) + assert await anext(tools_sub) == ToolsListChanged() + assert await anext(notes_sub) == ResourceUpdated(uri="note://todo") + # Neither stream received the other's event. + await bus.publish(ToolsListChanged()) + assert await anext(tools_sub) == ToolsListChanged() + + +async def test_change_notifications_still_reach_message_handler(): + """The demux tees: a delivered event's notification still reaches message_handler; the ack never does.""" + bus = InMemorySubscriptionBus() + seen: list[str] = [] + + async def on_message(message: object) -> None: + assert not isinstance(message, types.SubscriptionsAcknowledgedNotification) + if isinstance(message, types.ToolListChangedNotification): # pragma: no branch + seen.append("tools-changed") + + async with Client(_bus_server(bus), message_handler=on_message) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + await bus.publish(ToolsListChanged()) + assert await anext(sub) == ToolsListChanged() + await anyio.wait_all_tasks_blocked() + assert seen == ["tools-changed"] + + +async def test_enter_times_out_when_the_ack_never_arrives(): + """The ack wait rides the session's read timeout, so a wedged server cannot hang the open.""" + + async def silent_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + await anyio.sleep_forever() + raise AssertionError("unreachable") # pragma: no cover + + server = Server("subs", on_subscriptions_listen=silent_listen) + async with Client(server, read_timeout_seconds=0.05) as client: + with anyio.fail_after(5): + with pytest.raises(TimeoutError): # pragma: no branch + await client.listen(tools_list_changed=True).__aenter__() + + +async def test_an_open_stream_outlives_the_session_read_timeout(): + """The listen request is exempt from the read timeout: the stream delivers after the deadline.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus), read_timeout_seconds=0.05) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + # Real clock on purpose: this pins a timeout feature. + await anyio.sleep(0.2) + await bus.publish(ToolsListChanged()) + assert await anext(sub) == ToolsListChanged() + + +async def test_a_duplicate_ack_does_not_overwrite_the_honored_filter(): + """The first ack wins; a later conflicting ack is a no-op.""" + proceed = anyio.Event() + + async def double_acking_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + await _ack(ctx, params.notifications) + await _ack(ctx, SubscriptionFilter()) + await proceed.wait() + return types.SubscriptionsListenResult(_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id}) + + server = Server("subs", on_subscriptions_listen=double_acking_listen) + async with Client(server) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + assert sub.honored.tools_list_changed is True + proceed.set() + + +async def test_a_non_event_frame_with_the_subscription_id_is_teed_not_delivered(): + """A stamped non-event notification never surfaces as an event; it flows to message_handler.""" + proceed = anyio.Event() + + async def logging_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + meta = await _ack(ctx, params.notifications) + await ctx.session.send_notification( + types.LoggingMessageNotification( + params=types.LoggingMessageNotificationParams(level="info", data="not an event", _meta=meta) + ), + related_request_id=ctx.request_id, + ) + await proceed.wait() + return types.SubscriptionsListenResult(_meta=meta) + + logged: list[str] = [] + + async def on_message(message: object) -> None: + if isinstance(message, types.LoggingMessageNotification): # pragma: no branch + logged.append(str(message.params.data)) + + server = Server("subs", on_subscriptions_listen=logging_listen) + async with Client(server, message_handler=on_message) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + await anyio.wait_all_tasks_blocked() + proceed.set() + with pytest.raises(StopAsyncIteration): # pragma: no branch + await anext(sub) + assert logged == ["not an event"] + + +async def test_session_teardown_unblocks_a_sibling_consumer_with_subscription_lost(): + """Session teardown settles every open route as lost, unblocking parked consumers.""" + bus = InMemorySubscriptionBus() + outcome: list[str] = [] + entered = anyio.Event() + + async def consume(client: Client) -> None: + with pytest.raises(SubscriptionLost): + async with client.listen(tools_list_changed=True) as sub: + entered.set() + await anext(sub) + outcome.append("lost") + + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + async with Client(_bus_server(bus)) as client: # pragma: no branch + tg.start_soon(consume, client) + await entered.wait() + assert outcome == ["lost"] + + +async def test_server_cancel_before_the_ack_raises_subscription_lost_from_enter(): + """A stream torn down before it was ever acknowledged is a failed open: enter raises.""" + + async def cancel_first_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + await ctx.session.send_notification( + types.CancelledNotification(params=types.CancelledNotificationParams(request_id=ctx.request_id)), + related_request_id=ctx.request_id, + ) + await anyio.sleep_forever() + raise AssertionError("unreachable") # pragma: no cover + + server = Server("subs", on_subscriptions_listen=cancel_first_listen) + async with Client(server) as client: + with anyio.fail_after(5): + with pytest.raises(SubscriptionLost, match="before it was acknowledged"): # pragma: no branch + await client.listen(tools_list_changed=True).__aenter__() + + +async def test_listen_on_an_exited_session_raises_and_leaks_no_route(): + """Opening on an exited session fails loudly and leaves no demux registration behind.""" + bus = InMemorySubscriptionBus() + client = Client(_bus_server(bus)) + async with client: + session = client.session + with pytest.raises(RuntimeError): + await listen(session, tools_list_changed=True).__aenter__() + assert session._listen_routes == {} # pyright: ignore[reportPrivateUsage] + + +async def test_listen_on_a_never_entered_session_raises_runtime_error(): + """An adopted-but-never-entered session has no task group to drive the stream.""" + dispatcher, _peer = create_direct_dispatcher_pair() + session = ClientSession(dispatcher=dispatcher) + session.adopt( + types.DiscoverResult( + supported_versions=["2026-07-28"], + capabilities=types.ServerCapabilities(), + server_info=types.Implementation(name="stub", version="0"), + ) + ) + with pytest.raises(RuntimeError, match="entered session"): + await listen(session, tools_list_changed=True).__aenter__() + assert session._listen_routes == {} # pyright: ignore[reportPrivateUsage] + + +async def test_a_retained_handle_after_exit_does_not_serve_stale_events(): + """Leaving the block abandons the backlog: a stashed handle must not replay buffered events.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: + await bus.publish(ToolsListChanged()) + await anyio.wait_all_tasks_blocked() + with pytest.raises(StopAsyncIteration): # pragma: no branch + await anext(sub) + + +async def test_a_stray_ack_outside_the_driver_namespace_still_reaches_message_handler(): + """Acks for ids the driver never minted flow to message_handler (the raw-listen escape hatch).""" + proceed = anyio.Event() + + async def stray_acking_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + await _ack(ctx, params.notifications) + await ctx.session.send_notification( + types.SubscriptionsAcknowledgedNotification( + params=types.SubscriptionsAcknowledgedNotificationParams( + notifications=SubscriptionFilter(), _meta={SUBSCRIPTION_ID_META_KEY: 424242} + ) + ), + related_request_id=ctx.request_id, + ) + await proceed.wait() + return types.SubscriptionsListenResult(_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id}) + + handled: list[str] = [] + + async def on_message(message: object) -> None: + handled.append(type(message).__name__) + + server = Server("subs", on_subscriptions_listen=stray_acking_listen) + async with Client(server, message_handler=on_message) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + await anyio.wait_all_tasks_blocked() + proceed.set() + with pytest.raises(StopAsyncIteration): # pragma: no branch + await anext(sub) + assert "SubscriptionsAcknowledgedNotification" in handled + + +async def test_a_bare_string_for_resource_subscriptions_is_rejected(): + """A bare string would explode into per-character URIs; it is rejected before touching the wire.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as client: + with pytest.raises(TypeError, match="sequence of URIs"): + await client.listen(resource_subscriptions="note://todo").__aenter__() # pyright: ignore[reportArgumentType] + + +def test_the_route_admits_only_honored_events_and_only_while_live(): + """Route admission: nothing before the ack, only honored events while live, nothing after the end.""" + route = ListenRoute() + route.deliver(ToolsListChanged()) + assert route._pending == {} # pyright: ignore[reportPrivateUsage] + route.set_acked(SubscriptionFilter(tools_list_changed=True, resource_subscriptions=["note://todo"])) + route.deliver(PromptsListChanged()) # kind not honored + route.deliver(ResourceUpdated(uri="note://todo/draft")) # sub-resource of a subscribed URI: spec says admit + route.deliver(ResourceUpdated(uri="note://todo")) + route.deliver(ToolsListChanged()) + route.deliver(ToolsListChanged()) # duplicate pending consumption collapses + assert list(route._pending) == [ # pyright: ignore[reportPrivateUsage] + ResourceUpdated(uri="note://todo/draft"), + ResourceUpdated(uri="note://todo"), + ToolsListChanged(), + ] + route.settle("graceful") + route.deliver(ResourceUpdated(uri="note://todo")) # post-close noise is refused + assert len(route._pending) == 3 # pyright: ignore[reportPrivateUsage] + + +def test_a_peer_flooding_distinct_uris_costs_the_subscription_not_client_memory(): + """A peer flooding distinct URIs trips the `_MAX_PENDING_EVENTS` backstop: the route + settles lost instead of growing client memory without bound.""" + route = ListenRoute() + route.set_acked(SubscriptionFilter(resource_subscriptions=["note://todo"])) + for n in range(subscriptions_module._MAX_PENDING_EVENTS): # pyright: ignore[reportPrivateUsage] + route.deliver(ResourceUpdated(uri=f"note://todo/{n}")) + assert route.end is None + route.deliver(ResourceUpdated(uri="note://todo/one-too-many")) + assert route.end == "lost" + assert route.error is not None + assert "backlog" in route.error.error.message + # The overflowing event was not queued. + assert len(route._pending) == subscriptions_module._MAX_PENDING_EVENTS # pyright: ignore[reportPrivateUsage] + + +async def test_a_cancelled_on_event_barrier_does_not_lose_the_event(): + """Cancelling `anext` mid-barrier leaves the event queued; the next `anext` re-runs the + idempotent barrier and returns it.""" + bus = InMemorySubscriptionBus() + entered = anyio.Event() + release = anyio.Event() + + async def parked_barrier(event: ServerEvent) -> None: + entered.set() + await release.wait() + + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with listen( + client.session, tools_list_changed=True, on_event=parked_barrier + ) as sub: # pragma: no branch + await bus.publish(ToolsListChanged()) + async with anyio.create_task_group() as tg: + cancel_scope = anyio.CancelScope() + + async def first_attempt() -> None: + with cancel_scope: + await anext(sub) + raise AssertionError("must be cancelled mid-barrier") # pragma: no cover + + tg.start_soon(first_attempt) + await entered.wait() + cancel_scope.cancel() + release.set() + assert await anext(sub) == ToolsListChanged() + + +async def test_events_outside_the_honored_filter_are_never_delivered(): + """A server violating its acknowledged filter cannot reach the consumer or grow the backlog.""" + proceed = anyio.Event() + + async def overreaching_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + meta = await _ack(ctx, params.notifications) # honors exactly what was requested: tools only + await ctx.session.send_notification( + types.ResourceUpdatedNotification( + params=types.ResourceUpdatedNotificationParams(uri="note://uninvited", _meta=meta) + ), + related_request_id=ctx.request_id, + ) + await ctx.session.send_notification( + types.ToolListChangedNotification(params=types.NotificationParams(_meta=meta)), + related_request_id=ctx.request_id, + ) + await proceed.wait() + return types.SubscriptionsListenResult(_meta=meta) + + server = Server("subs", on_subscriptions_listen=overreaching_listen) + async with Client(server) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + assert await anext(sub) == ToolsListChanged() + proceed.set() + with pytest.raises(StopAsyncIteration): # pragma: no branch + await anext(sub) + + +async def test_the_on_event_barrier_completes_before_each_event_is_returned(): + """`on_event` is awaited before the iterator returns each event (the Client wires cache eviction here).""" + bus = InMemorySubscriptionBus() + order: list[str] = [] + + async def barrier(event: ServerEvent) -> None: + order.append(f"barrier:{type(event).__name__}") + + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with listen(client.session, tools_list_changed=True, on_event=barrier) as sub: # pragma: no branch + await bus.publish(ToolsListChanged()) + event = await anext(sub) + order.append(f"returned:{type(event).__name__}") + assert order == ["barrier:ToolsListChanged", "returned:ToolsListChanged"] + + +async def test_client_listen_installs_the_cache_eviction_barrier_exactly_when_a_cache_exists(): + """`Client.listen` wires the response-cache evictor as the barrier only when a cache exists.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as cached_client: + with anyio.fail_after(5): + async with cached_client.listen(tools_list_changed=True) as sub: # pragma: no branch + assert sub._on_event == cached_client._evict_for_listen_event # pyright: ignore[reportPrivateUsage] + async with Client(_bus_server(bus), cache=False) as uncached_client: + with anyio.fail_after(5): + async with uncached_client.listen(tools_list_changed=True) as sub: # pragma: no branch + assert sub._on_event is None # pyright: ignore[reportPrivateUsage] + + +async def test_the_cache_eviction_barrier_maps_events_and_contains_store_faults( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The barrier evicts through the same notification mapping as the message_handler wrapper; + a raising store costs a log line, not the delivery.""" + client = Client(_bus_server(InMemorySubscriptionBus())) + cache = client._response_cache # pyright: ignore[reportPrivateUsage] + assert cache is not None + evicted: list[types.ServerNotification] = [] + + async def record(notification: types.ServerNotification) -> None: + evicted.append(notification) + + monkeypatch.setattr(cache, "evict_for_notification", record) + await client._evict_for_listen_event(ResourceUpdated(uri="note://x")) # pyright: ignore[reportPrivateUsage] + assert isinstance(evicted[0], types.ResourceUpdatedNotification) + assert evicted[0].params.uri == "note://x" + + async def broken(notification: types.ServerNotification) -> None: + raise RuntimeError("store down") + + monkeypatch.setattr(cache, "evict_for_notification", broken) + # Contained: a cache fault must not block delivery. + await client._evict_for_listen_event(ToolsListChanged()) # pyright: ignore[reportPrivateUsage] + + +async def test_a_raw_request_id_collision_fails_the_subscription_not_the_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A raw caller occupying the driver's next minted id fails that one listen from enter; + the session survives and the next listen opens normally.""" + monkeypatch.setattr(subscriptions_module, "_listen_ids", count(7000)) + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: # pragma: no branch + raw_scope = anyio.CancelScope() + + async def raw_listen() -> None: + request = types.SubscriptionsListenRequest( + params=types.SubscriptionsListenRequestParams( + notifications=SubscriptionFilter(tools_list_changed=True) + ) + ) + data = request.model_dump(by_alias=True, mode="json", exclude_none=True) + opts: CallOptions = {"request_id": "listen-7000"} + client.session._stamp(data, opts) # pyright: ignore[reportPrivateUsage] + with raw_scope: + await client.session._dispatcher.send_raw_request( # pyright: ignore[reportPrivateUsage] + data["method"], data.get("params"), opts + ) + + tg.start_soon(raw_listen) + await anyio.wait_all_tasks_blocked() + with pytest.raises(MCPError) as exc_info: + await client.listen(tools_list_changed=True).__aenter__() + assert "already in flight" in exc_info.value.error.message + # The failed open released the colliding id's demux registration. + assert client.session._listen_routes == {} # pyright: ignore[reportPrivateUsage] + raw_scope.cancel() + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + assert sub.subscription_id == "listen-7001" diff --git a/tests/docs_src/test_client.py b/tests/docs_src/test_client.py index af5e692491..3d70371f53 100644 --- a/tests/docs_src/test_client.py +++ b/tests/docs_src/test_client.py @@ -5,7 +5,7 @@ from mcp_types import Prompt, PromptArgument, PromptReference, TextContent, TextResourceContents, Tool from docs_src.client import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006, tutorial007 -from mcp import Client, MCPError +from mcp import Client, MCPDeprecationWarning, MCPError from mcp.shared.metadata_utils import get_display_name # See test_index.py for why this is a per-module mark and not a conftest hook. @@ -128,7 +128,9 @@ async def test_resource_subscriptions_are_listen_based_on_the_modern_wire() -> N assert client.server_capabilities.resources is not None assert client.server_capabilities.resources.subscribe is True with pytest.raises(MCPError) as exc_info: - await client.subscribe_resource("catalog://genres") + # The verb is itself deprecated; the modern wire also rejects it. + with pytest.warns(MCPDeprecationWarning, match="use Client.listen"): + await client.subscribe_resource("catalog://genres") # pyright: ignore[reportDeprecated] assert exc_info.value.error.code == -32601 assert exc_info.value.error.message == "Method not found" diff --git a/tests/docs_src/test_subscriptions.py b/tests/docs_src/test_subscriptions.py index b664afe983..7a7b75157b 100644 --- a/tests/docs_src/test_subscriptions.py +++ b/tests/docs_src/test_subscriptions.py @@ -1,19 +1,40 @@ -"""`docs/handlers/subscriptions.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/{handlers,client}/subscriptions.md`: every claim the two pages make, proved against the real SDK.""" +from collections.abc import Awaitable, Callable from typing import Any import anyio import mcp_types as types import pytest +from trio.testing import MockClock -from docs_src.subscriptions import tutorial001, tutorial002 +from docs_src.subscriptions import ( + tutorial001, + tutorial002, + tutorial003, + tutorial004_anyio, + tutorial004_asyncio, + tutorial004_trio, + tutorial005, +) from mcp import Client -from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, ToolsListChanged +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, ListenHandler, ToolsListChanged + +_ReadResource = Callable[ + [ServerRequestContext[Any], types.ReadResourceRequestParams], Awaitable[types.ReadResourceResult] +] # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] +@pytest.fixture(autouse=True) +def _module_runner_lease() -> None: + """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`.""" + + class _Stream: """Collects listen-stream notifications and lets tests await arrival counts.""" @@ -42,12 +63,56 @@ async def wait_for(self, count: int) -> None: await self._arrival.wait() +class _Reads: + """Counts server-side resource reads so a test can await the Nth refetch.""" + + def __init__(self) -> None: + self.count = 0 + self._bump = anyio.Event() + + def counting(self, handler: _ReadResource) -> _ReadResource: + async def counted( + ctx: ServerRequestContext[Any], params: types.ReadResourceRequestParams + ) -> types.ReadResourceResult: + result = await handler(ctx, params) + self.count += 1 + self._bump.set() + self._bump = anyio.Event() + return result + + return counted + + async def wait_for(self, count: int) -> None: + with anyio.fail_after(5): + while self.count < count: + await self._bump.wait() + + def _listen_request(**fields: Any) -> types.SubscriptionsListenRequest: return types.SubscriptionsListenRequest( params=types.SubscriptionsListenRequestParams(notifications=types.SubscriptionFilter(**fields)) ) +@pytest.fixture(autouse=True) +def _fresh_server_state() -> Any: + """Each test starts from an all-unfinished board and the base tool set. + + The tutorials mutate module state deliberately (that is what publishes events), so the + board contents and the `enable_reports` registration have to be undone between tests. + """ + boards = {name: dict(tasks) for name, tasks in tutorial001.BOARDS.items()} + lowlevel_board = dict(tutorial002.BOARD) + tools = dict(tutorial001.mcp._tool_manager._tools) # pyright: ignore[reportPrivateUsage] + yield + tutorial001.BOARDS.clear() + tutorial001.BOARDS.update(boards) + tutorial002.BOARD.clear() + tutorial002.BOARD.update(lowlevel_board) + tutorial001.mcp._tool_manager._tools.clear() # pyright: ignore[reportPrivateUsage] + tutorial001.mcp._tool_manager._tools.update(tools) # pyright: ignore[reportPrivateUsage] + + async def test_publishes_reach_the_stream_filtered_and_tagged() -> None: """tutorial001: the full arc - ack first, exact-URI filtering, list_changed leading to a refreshed tool list, and client-side close.""" @@ -57,7 +122,7 @@ async def test_publishes_reach_the_stream_filtered_and_tagged() -> None: async def listen() -> None: await client.session.send_request( - _listen_request(tools_list_changed=True, resource_subscriptions=["note://todo"]), + _listen_request(tools_list_changed=True, resource_subscriptions=["board://sprint"]), types.SubscriptionsListenResult, ) @@ -67,21 +132,21 @@ async def listen() -> None: ack = stream.received[0] assert isinstance(ack, types.SubscriptionsAcknowledgedNotification) assert ack.params.notifications == types.SubscriptionFilter( - tools_list_changed=True, resource_subscriptions=["note://todo"] + tools_list_changed=True, resource_subscriptions=["board://sprint"] ) assert ack.params.meta is not None and SUBSCRIPTION_ID_META_KEY in ack.params.meta # An edit to a URI the stream did not subscribe to stays silent... - await client.call_tool("edit_note", {"name": "journal", "text": "day two"}) + await client.call_tool("complete_task", {"board": "backlog", "task": "tidy docs"}) # ...and the subscribed URI delivers, tagged with the same subscription id. - await client.call_tool("edit_note", {"name": "todo", "text": "water plants"}) + await client.call_tool("complete_task", {"board": "sprint", "task": "design"}) await stream.wait_for(2) updated = stream.received[1] assert isinstance(updated, types.ResourceUpdatedNotification) - assert updated.params.uri == "note://todo" + assert updated.params.uri == "board://sprint" assert updated.params.meta == ack.params.meta - await client.call_tool("enable_search", {}) + await client.call_tool("enable_reports", {}) await stream.wait_for(3) assert isinstance(stream.received[2], types.ToolListChangedNotification) @@ -91,16 +156,16 @@ async def listen() -> None: # The list_changed told us to re-fetch: the new tool is there, and the # session outlives the closed stream. tools = await client.list_tools() - assert "search" in {tool.name for tool in tools.tools} - contents = (await client.read_resource("note://todo")).contents[0] + assert "sprint_report" in {tool.name for tool in tools.tools} + contents = (await client.read_resource("board://sprint")).contents[0] assert isinstance(contents, types.TextResourceContents) - assert contents.text == "water plants" + assert contents.text == "[x] design\n[ ] build\n[ ] ship" async def test_publish_with_no_subscribers_is_a_no_op() -> None: """tutorial001: publishing to an idle server does nothing and breaks nothing.""" async with Client(tutorial001.mcp, mode="2026-07-28") as client: - result = await client.call_tool("edit_note", {"name": "todo", "text": "buy milk"}) + result = await client.call_tool("complete_task", {"board": "sprint", "task": "design"}) assert result.is_error is not True @@ -109,30 +174,130 @@ async def test_lowlevel_composition_serves_the_same_stream() -> None: stream = _Stream() async with Client(tutorial002.server, mode="2026-07-28", message_handler=stream.handler) as client: tools = await client.list_tools() - assert [tool.name for tool in tools.tools] == ["edit_note"] + assert [tool.name for tool in tools.tools] == ["complete_task"] async with anyio.create_task_group() as tg: async def listen() -> None: await client.session.send_request( - _listen_request(resource_subscriptions=["note://todo"]), + _listen_request(resource_subscriptions=["board://sprint"]), types.SubscriptionsListenResult, ) tg.start_soon(listen) await stream.wait_for(1) - await client.call_tool("edit_note", {"name": "todo", "text": "water plants"}) + await client.call_tool("complete_task", {"task": "design"}) await stream.wait_for(2) updated = stream.received[1] assert isinstance(updated, types.ResourceUpdatedNotification) - assert updated.params.uri == "note://todo" + assert updated.params.uri == "board://sprint" # The bus you constructed is also the publish surface outside a # request; an unrequested kind never reaches this stream. await tutorial002.bus.publish(ToolsListChanged()) - await client.call_tool("edit_note", {"name": "todo", "text": "done"}) + await client.call_tool("complete_task", {"task": "build"}) await stream.wait_for(3) assert isinstance(stream.received[2], types.ResourceUpdatedNotification) tg.cancel_scope.cancel() + + +async def test_follow_board_prints_the_refetched_board_and_the_new_tool_list( + capsys: pytest.CaptureFixture[str], +) -> None: + """tutorial003: each event drives a refetch - the board reprints, and a tools change reprints the tool names.""" + async with Client(tutorial001.mcp) as client: + async with anyio.create_task_group() as tg: + tg.start_soon(tutorial003.follow_board, client) + # Let the watcher park on its stream (ack complete) before publishing. + await anyio.wait_all_tasks_blocked() + await client.call_tool("complete_task", {"board": "sprint", "task": "design"}) + await anyio.wait_all_tasks_blocked() + await client.call_tool("enable_reports", {}) + await anyio.wait_all_tasks_blocked() + tg.cancel_scope.cancel() + + printed = capsys.readouterr().out + assert "[x] design\n[ ] build\n[ ] ship" in printed + assert "sprint_report" in printed + + +EMPTY_BOARD = "[ ] design\n[ ] build\n[ ] ship" +FINISHED_BOARD = "[x] design\n[x] build\n[x] ship" + + +def _assert_snapshot_then_current_board(printed: str) -> None: + """The snapshot taken inside the open subscription came first, and the watcher ended up current. + + How many times the watcher printed is deliberately not asserted: identical events that pile up + unconsumed coalesce, so a fast main flow can turn three completions into one refetch. What the + stream guarantees is that no change after the acknowledgment is missed. + """ + assert printed.startswith(EMPTY_BOARD), printed + assert printed.strip().endswith(FINISHED_BOARD), printed + + +async def test_the_asyncio_watcher_runs_beside_the_main_flow(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial004 (asyncio tab): run_sprint opens the subscription, snapshots the board, then a watcher + task reprints it while the main flow keeps calling tools. + + The example connects over HTTP; the in-memory client here is the maintainer-side stand-in.""" + async with Client(tutorial001.mcp) as client: + await tutorial004_asyncio.run_sprint(client) + _assert_snapshot_then_current_board(capsys.readouterr().out) + + +@pytest.mark.parametrize("anyio_backend", [pytest.param("trio", id="trio")]) +async def test_the_trio_watcher_runs_beside_the_main_flow(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial004 (trio tab): the same shape as the asyncio tab, with a nursery owning the watcher.""" + async with Client(tutorial001.mcp) as client: + await tutorial004_trio.run_sprint(client) + _assert_snapshot_then_current_board(capsys.readouterr().out) + + +async def test_the_anyio_watcher_runs_beside_the_main_flow(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial004 (anyio tab): the same shape again, with a task group owning the watcher.""" + async with Client(tutorial001.mcp) as client: + await tutorial004_anyio.run_sprint(client) + _assert_snapshot_then_current_board(capsys.readouterr().out) + + +@pytest.mark.parametrize( + "anyio_backend", + [pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")], +) +async def test_the_follower_re_listens_after_the_stream_ends(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial005: a graceful server close ends one stream; the loop backs off, re-listens, and refetches. + + Runs on trio's autojumping MockClock so the loop's backoff sleep takes no wall-clock time. + """ + reads = _Reads() + handler = ListenHandler(tutorial002.bus) + server = Server( + "sprint-board", + on_read_resource=reads.counting(tutorial002.read_resource), + on_list_tools=tutorial002.list_tools, + on_call_tool=tutorial002.call_tool, + on_subscriptions_listen=handler, + ) + + async with Client(server) as client: + async with anyio.create_task_group() as tg: + tg.start_soon(tutorial005.keep_following, client) + # First stream: the entry refetch reads the board, then an event reads it again. + await reads.wait_for(1) + await client.call_tool("complete_task", {"task": "design"}) + await reads.wait_for(2) + + # End that stream gracefully. The loop backs off (the mock clock jumps the + # sleep), re-listens, and refetches on entry: that is the third read. + handler.close() + await reads.wait_for(3) + await client.call_tool("complete_task", {"task": "build"}) + await reads.wait_for(4) + tg.cancel_scope.cancel() + + printed = capsys.readouterr().out + assert "[x] design\n[ ] build" in printed # first stream, after design + assert "[x] design\n[x] build" in printed # second stream, after build diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index b7b7465f03..0ce1239f4e 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -486,12 +486,6 @@ def __post_init__(self) -> None: "id up front is the SDK surface that makes it satisfiable." ), added_in="2026-07-28", - deferred=( - "No public API surface yet: the capability exists at the dispatcher seam " - "(CallOptions['request_id'], unit-tested there), but ClientSession.send_request does not " - "expose it. The public consumer arrives with the client-side listen driver (Client.listen), " - "whose interaction tests will exercise it end to end." - ), ), "protocol:notifications:no-response": Requirement( source=f"{SPEC_BASE_URL}/basic#notifications", @@ -1229,6 +1223,59 @@ def __post_init__(self) -> None: removed_in="2026-07-28", note="removed in 2026-07-28 (SEP-2575); resources/unsubscribe replaced by subscriptions/listen.", ), + "subscriptions:listen:client:honored-surfacing": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#acknowledgment", + behavior=( + "Entering Client.listen() waits for the server's acknowledgment and surfaces the honored " + "filter subset on the handle, so the client can check it against what it requested (spec SHOULD)." + ), + added_in="2026-07-28", + ), + "subscriptions:listen:client:concurrent-demux": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#multiple-concurrent-subscriptions", + behavior=( + "Concurrently open subscriptions each surface their own acknowledgment: with both listen " + "requests in flight before either ack arrives, each handle's honored filter is the subset " + "for its own request, routed by subscription id rather than broadcast to every open route." + ), + added_in="2026-07-28", + ), + "subscriptions:listen:client:iteration": Requirement( + source="sdk", + behavior=( + "An open subscription is an async iterator of typed change events; delivered notifications " + "still tee to message_handler so caching and observers keep working." + ), + added_in="2026-07-28", + ), + "subscriptions:listen:client:graceful-close": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#cancellation", + behavior=( + "The server's empty subscriptions/listen result (its deliberate close) ends iteration cleanly " + "after buffered events drain; no exception is raised." + ), + added_in="2026-07-28", + ), + "subscriptions:listen:client:lost": Requirement( + source="sdk", + behavior=( + "A listen stream that ends without the graceful result raises SubscriptionLost from iteration; " + "there is no automatic re-listen." + ), + added_in="2026-07-28", + ), + "subscriptions:listen:client:era-guard": Requirement( + source="sdk", + behavior=( + "Client.listen() on a pre-2026 connection raises ListenNotSupportedError steering to " + "subscribe_resource/message_handler instead of leaking a wire -32601." + ), + removed_in="2026-07-28", + note=( + "removed_in scopes the matrix to the 2025 cells deliberately: the behavior under test is the " + "guard on connections where the method does not exist." + ), + ), "resources:updated-notification": Requirement( source=f"{SPEC_BASE_URL}/server/resources#subscriptions", behavior=( diff --git a/tests/interaction/lowlevel/test_resources.py b/tests/interaction/lowlevel/test_resources.py index 44ab33e64a..db7d4dfe60 100644 --- a/tests/interaction/lowlevel/test_resources.py +++ b/tests/interaction/lowlevel/test_resources.py @@ -203,6 +203,7 @@ async def list_resource_templates( ) +@pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning") @requirement("resources:subscribe") async def test_subscribe_resource_delivers_uri_to_handler(connect: Connect) -> None: """Subscribing to a resource delivers the URI to the server's subscribe handler and returns an empty result.""" @@ -214,11 +215,12 @@ async def subscribe_resource(ctx: ServerRequestContext, params: types.SubscribeR server = Server("library", on_subscribe_resource=subscribe_resource) async with connect(server) as client: - result = await client.subscribe_resource("file:///watched.txt") + result = await client.subscribe_resource("file:///watched.txt") # pyright: ignore[reportDeprecated] assert result == snapshot(EmptyResult()) +@pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning") @requirement("resources:subscribe:capability-required") async def test_subscribe_without_a_subscribe_handler_is_method_not_found(connect: Connect) -> None: """Subscribing to a server that registered no subscribe handler is rejected with METHOD_NOT_FOUND. @@ -237,13 +239,14 @@ async def list_resources( async with connect(server) as client: with pytest.raises(MCPError) as exc_info: - await client.subscribe_resource("file:///watched.txt") + await client.subscribe_resource("file:///watched.txt") # pyright: ignore[reportDeprecated] assert exc_info.value.error == snapshot( ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="resources/subscribe") ) +@pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning") @requirement("resources:unsubscribe") async def test_unsubscribe_resource_delivers_uri_to_handler(connect: Connect) -> None: """Unsubscribing from a resource delivers the URI to the server's unsubscribe handler.""" @@ -255,7 +258,7 @@ async def unsubscribe_resource(ctx: ServerRequestContext, params: types.Unsubscr server = Server("library", on_unsubscribe_resource=unsubscribe_resource) async with connect(server) as client: - result = await client.unsubscribe_resource("file:///watched.txt") + result = await client.unsubscribe_resource("file:///watched.txt") # pyright: ignore[reportDeprecated] assert result == snapshot(EmptyResult()) diff --git a/tests/interaction/lowlevel/test_subscriptions.py b/tests/interaction/lowlevel/test_subscriptions.py new file mode 100644 index 0000000000..e22590c9e1 --- /dev/null +++ b/tests/interaction/lowlevel/test_subscriptions.py @@ -0,0 +1,142 @@ +"""Client.listen stream endings against lowlevel servers over the connect matrix.""" + +from typing import Any + +import anyio +import mcp_types as types +import pytest + +from mcp import MCPError +from mcp.client.subscriptions import SubscriptionLost, ToolsListChanged +from mcp.server import Server, ServerRequestContext +from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, InMemorySubscriptionBus, ListenHandler +from tests.interaction._connect import Connect +from tests.interaction._requirements import requirement + +pytestmark = pytest.mark.anyio + + +@requirement("subscriptions:listen:client:graceful-close") +async def test_a_graceful_server_close_ends_iteration_after_buffered_events(connect: Connect) -> None: + """`ListenHandler.close()` sends the result last; iteration drains published events, then ends cleanly.""" + bus = InMemorySubscriptionBus() + handler = ListenHandler(bus) + server = Server("subs", on_subscriptions_listen=handler) + events: list[object] = [] + async with connect(server) as client: + with anyio.fail_after(10): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + await bus.publish(ToolsListChanged()) + handler.close() + events.extend([event async for event in sub]) + assert events == [ToolsListChanged()] + + +@requirement("subscriptions:listen:client:lost") +async def test_a_stream_dropped_after_the_ack_raises_subscription_lost(connect: Connect) -> None: + """Erroring the listen request after the ack (abrupt, not graceful) raises SubscriptionLost from iteration.""" + proceed = anyio.Event() + + async def dropping_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + await ctx.session.send_notification( + types.SubscriptionsAcknowledgedNotification( + params=types.SubscriptionsAcknowledgedNotificationParams( + notifications=params.notifications, + _meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id}, + ) + ), + related_request_id=ctx.request_id, + ) + await proceed.wait() + raise MCPError(types.INTERNAL_ERROR, "stream torn down") + + server = Server("subs", on_subscriptions_listen=dropping_listen) + async with connect(server) as client: + with anyio.fail_after(10): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + proceed.set() + with pytest.raises(SubscriptionLost): # pragma: no branch + await anext(sub) + + +@requirement("protocol:request-id:caller-supplied") +async def test_the_subscription_id_is_the_listen_request_id_the_server_saw(connect: Connect) -> None: + """The handle's `subscription_id` is the listen request's own JSON-RPC id, known to the caller + while the request is still in flight - the key the server stamps every frame with for demux. + + The assertion runs inside the open stream: the ack has arrived but the listen request's + response has not, so the id cannot have come from a response. + """ + bus = InMemorySubscriptionBus() + stock = ListenHandler(bus) + seen: list[types.RequestId] = [] + + async def recording_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + seen.append(ctx.request_id) + return await stock(ctx, params) + + server = Server("subs", on_subscriptions_listen=recording_listen) + async with connect(server) as client: + with anyio.fail_after(10): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + assert seen == [sub.subscription_id] + stock.close() + async for _event in sub: + raise NotImplementedError # unreachable: nothing was published + + +@requirement("subscriptions:listen:client:concurrent-demux") +@requirement("protocol:request-id:caller-supplied") +async def test_concurrent_listen_streams_each_receive_their_own_ack(connect: Connect) -> None: + """Two subscriptions opened concurrently each surface the honored filter of their own request: + ack frames route by subscription id, not broadcast to every open route. + + The server gates both acks until both listen requests have arrived, so both client routes are + live and unacknowledged when the first ack lands - a client that broadcast subscription frames + would cross-pollute that ack into both handles. + """ + bus = InMemorySubscriptionBus() + stock = ListenHandler(bus) + arrived: list[types.RequestId] = [] + both_arrived = anyio.Event() + + async def gated_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + arrived.append(ctx.request_id) + if len(arrived) == 2: + both_arrived.set() + with anyio.fail_after(10): + await both_arrived.wait() + return await stock(ctx, params) + + server = Server("subs", on_subscriptions_listen=gated_listen) + honored: dict[str, types.SubscriptionFilter] = {} + + async with connect(server) as client: + + async def open_tools() -> None: + async with client.listen(tools_list_changed=True) as sub: + honored["tools"] = sub.honored + + async def open_prompts() -> None: + async with client.listen(prompts_list_changed=True) as sub: + honored["prompts"] = sub.honored + + with anyio.fail_after(10): + async with anyio.create_task_group() as tg: # pragma: no branch + tg.start_soon(open_tools) + tg.start_soon(open_prompts) + + assert honored == { + "tools": types.SubscriptionFilter(tools_list_changed=True), + "prompts": types.SubscriptionFilter(prompts_list_changed=True), + } + assert len(set(arrived)) == 2 diff --git a/tests/interaction/mcpserver/test_subscriptions.py b/tests/interaction/mcpserver/test_subscriptions.py new file mode 100644 index 0000000000..047b049d9a --- /dev/null +++ b/tests/interaction/mcpserver/test_subscriptions.py @@ -0,0 +1,62 @@ +"""Client.listen against MCPServer over the connect matrix (2026-07-28).""" + +import anyio +import pytest + +from mcp.client.subscriptions import ListenNotSupportedError, ResourceUpdated, ToolsListChanged +from mcp.server.mcpserver import Context, MCPServer +from tests.interaction._connect import Connect +from tests.interaction._requirements import requirement + +pytestmark = pytest.mark.anyio + + +def _notebook() -> MCPServer: + mcp = MCPServer("notebook") + + @mcp.tool() + async def touch_tools(ctx: Context) -> str: + await ctx.notify_tools_changed() + return "ok" + + @mcp.tool() + async def edit_note(name: str, ctx: Context) -> str: + await ctx.notify_resource_updated(f"note://{name}") + return "saved" + + return mcp + + +@requirement("subscriptions:listen:client:honored-surfacing") +@requirement("subscriptions:listen:client:iteration") +async def test_listen_surfaces_the_ack_and_iterates_typed_events(connect: Connect) -> None: + """Entering waits for the ack (honored is set before any event); iteration yields + only the typed event kinds this stream opted in to.""" + mcp = _notebook() + async with connect(mcp) as client: + with anyio.fail_after(10): + async with client.listen( # pragma: no branch + tools_list_changed=True, resource_subscriptions=["note://todo"] + ) as sub: + assert sub.honored.tools_list_changed is True + assert sub.honored.resource_subscriptions == ["note://todo"] + + await client.call_tool("edit_note", {"name": "journal"}) # unsubscribed URI: silent + await client.call_tool("edit_note", {"name": "todo"}) + assert await anext(sub) == ResourceUpdated(uri="note://todo") + + await client.call_tool("touch_tools", {}) + assert await anext(sub) == ToolsListChanged() + + +@requirement("subscriptions:listen:client:era-guard") +async def test_listen_on_a_pre_2026_connection_raises_the_typed_steer(connect: Connect) -> None: + """On 2025-era connections the guard fires before anything touches the wire, steering to the legacy verbs.""" + mcp = _notebook() + async with connect(mcp) as client: + with anyio.fail_after(10): + # Entering is where the guard fires; __aenter__ directly avoids an unreachable with-body. + with pytest.raises(ListenNotSupportedError) as exc_info: + await client.listen(tools_list_changed=True).__aenter__() + assert exc_info.value.negotiated_version == client.session.protocol_version + assert "subscribe_resource" in str(exc_info.value) diff --git a/tests/shared/test_dispatcher.py b/tests/shared/test_dispatcher.py index 03ef27c8db..c6ebb401ff 100644 --- a/tests/shared/test_dispatcher.py +++ b/tests/shared/test_dispatcher.py @@ -25,7 +25,7 @@ from mcp.shared._compat import resync_tracer from mcp.shared.direct_dispatcher import DirectDispatcher, create_direct_dispatcher_pair -from mcp.shared.dispatcher import DispatchContext, Dispatcher, OnNotify, OnRequest, Outbound +from mcp.shared.dispatcher import DispatchContext, Dispatcher, OnNotify, OnNotifyIntercept, OnRequest, Outbound from mcp.shared.exceptions import MCPError from mcp.shared.transport_context import TransportContext @@ -66,6 +66,7 @@ async def running_pair( server_on_notify: OnNotify | None = None, client_on_request: OnRequest | None = None, client_on_notify: OnNotify | None = None, + client_on_notify_intercept: OnNotifyIntercept | None = None, can_send_request: bool = True, ) -> AsyncIterator[tuple[Dispatcher[TransportContext], Dispatcher[TransportContext], Recorder, Recorder]]: """Yield `(client, server, client_recorder, server_recorder)` with both `run()` loops live.""" @@ -75,7 +76,9 @@ async def running_pair( s_req, s_notify = echo_handlers(server_rec) try: async with anyio.create_task_group() as tg: - await tg.start(client.run, client_on_request or c_req, client_on_notify or c_notify) + await tg.start( + client.run, client_on_request or c_req, client_on_notify or c_notify, client_on_notify_intercept + ) await tg.start(server.run, server_on_request or s_req, server_on_notify or s_notify) try: yield client, server, client_rec, server_rec @@ -509,6 +512,67 @@ async def first() -> None: assert await client.send_raw_request("again", None, {"request_id": "7"}) == {} +@pytest.mark.anyio +async def test_notify_intercept_sees_every_notification_and_consumes_on_true(pair_factory: PairFactory): + """The intercept sees every inbound notification; a frame it consumes never reaches `on_notify`, the rest do.""" + intercepted: list[str] = [] + + def intercept(method: str, params: Mapping[str, Any] | None) -> bool: + intercepted.append(method) + return method == "notifications/consumed" + + async with running_pair(pair_factory, client_on_notify_intercept=intercept) as (_client, server, crec, _srec): + with anyio.fail_after(5): + await server.notify("notifications/consumed", None) + await server.notify("notifications/passed", None) + await crec.notified.wait() + assert intercepted == ["notifications/consumed", "notifications/passed"] + assert [method for method, _ in crec.notifications] == ["notifications/passed"] + + +@pytest.mark.anyio +async def test_notify_intercept_completes_before_a_later_response_resolves(pair_factory: PairFactory): + """Notifications written before a response are intercepted before it resolves, whatever spawned handlers do.""" + seen: list[str] = [] + + def intercept(method: str, params: Mapping[str, Any] | None) -> bool: + seen.append(method) + return False + + async def notify_then_answer( + ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> dict[str, Any]: + await ctx.notify("notifications/first", None) + await ctx.notify("notifications/second", None) + return {} + + async with running_pair( + pair_factory, server_on_request=notify_then_answer, client_on_notify_intercept=intercept + ) as (client, *_): + with anyio.fail_after(5): + await client.send_raw_request("burst", None) + assert seen == ["notifications/first", "notifications/second"] + + +@pytest.mark.anyio +async def test_a_raising_notify_intercept_is_contained_and_passes_the_frame_through(pair_factory: PairFactory): + """An intercept exception costs only that interception: the frame still reaches `on_notify`, the loop survives.""" + + def broken_intercept(method: str, params: Mapping[str, Any] | None) -> bool: + raise RuntimeError("intercept exploded") + + async with running_pair(pair_factory, client_on_notify_intercept=broken_intercept) as ( + _client, + server, + crec, + _srec, + ): + with anyio.fail_after(5): + await server.notify("notifications/survives", None) + await crec.notified.wait() + assert [method for method, _ in crec.notifications] == ["notifications/survives"] + + if TYPE_CHECKING: _d: Dispatcher[TransportContext] = DirectDispatcher(TransportContext(kind="direct", can_send_request=True)) _o: Outbound = _d