Skip to content

Commit a1dfbc1

Browse files
committed
Add the client-side subscriptions/listen driver
client.listen() opens one subscription as an async context manager: entering sends the request and waits for the server's acknowledgment (the honored filter subset is on the handle before the first event), iteration yields the same four typed events the server publishes, a graceful server close simply ends the loop, and an abrupt drop raises SubscriptionLost. Exiting the context ends the subscription with the transport's own cancellation spelling (aborting the request's stream over streamable HTTP, notifications/cancelled on stream transports). There is no replay and no automatic re-listen. Pending events deduplicate - every kind is a level trigger - so the backlog is bounded by the filter's width by construction. The event vocabulary moves to mcp/shared/subscriptions.py (re-exported from the server module) so both sides speak the same types. The session demultiplexes stream frames by the _meta subscription id: acks in the driver's id namespace are consumed (raw escape-hatch listens keep observing theirs through message_handler), change events are delivered after the message_handler tee so cache eviction always completes before an event-triggered refetch can run, and session teardown settles every open route so a watcher task in a sibling task group cannot hang when the client closes. A per-request SSE stream that drops without ever carrying an event id now resolves its request with CONNECTION_CLOSED instead of leaving it pending for the session's lifetime. The legacy subscribe_resource/unsubscribe_resource verbs are deprecated on both Client and ClientSession with a pointer at the replacement.
1 parent 469fdb2 commit a1dfbc1

23 files changed

Lines changed: 1477 additions & 147 deletions

docs/advanced/subscriptions.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,42 @@ Down on the low-level `Server` there is no pre-wired anything — and the same p
7979
* `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.
8080
* `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.
8181

82+
## The client side
83+
84+
Consuming a subscription is one context manager:
85+
86+
```python title="client.py" hl_lines="9 10"
87+
--8<-- "docs_src/subscriptions/tutorial003.py"
88+
```
89+
90+
* `client.listen(...)` takes the filter as keyword arguments — they mirror the wire `SubscriptionFilter` field for field. Entering sends the request and returns once the server's acknowledgment arrives, so `sub.honored` (the subset the server agreed to deliver) is always there before the first event.
91+
* Iteration yields the same four typed events the server publishes: `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged`, and `ResourceUpdated(uri=...)`. An event is a cue to refetch — it carries no payload beyond identity, and duplicates pending consumption collapse into one.
92+
* Leaving the block ends the subscription, with the transport's own spelling: over streamable HTTP the request's response stream is closed (that is the 2026 cancellation signal), on stream transports `notifications/cancelled` is sent.
93+
* The stream's two endings are control flow. The server closing gracefully simply ends the `async for`; an abrupt drop raises `SubscriptionLost`. There is no replay and no automatic re-listen — a client that reconnects refetches what it depends on:
94+
95+
```python
96+
async def watch(client: Client, uri: str) -> None:
97+
while True:
98+
try:
99+
async with client.listen(resource_subscriptions=[uri]) as sub:
100+
await client.read_resource(uri) # refetch: no replay across streams
101+
async for _event in sub:
102+
await client.read_resource(uri)
103+
except SubscriptionLost:
104+
continue # transport dropped — re-listen
105+
else:
106+
break # the server ended it deliberately
107+
```
108+
109+
* Checking the acknowledgment (the spec's client SHOULD) is reading `sub.honored` — for example, `if not sub.honored.prompts_list_changed:` the server has no prompts to watch. Multiple subscriptions may be open concurrently; each demultiplexes by its own subscription id.
110+
* Tool calls and other requests run freely beside an open stream — from the same task between events, or from sibling tasks sharing the client. A watcher task that refetches inside its event loop is the intended pattern, not a re-entrancy hazard.
111+
* `listen()` requires a 2026-07-28 connection and raises `ListenNotSupportedError` on older ones, steering to the deprecated `subscribe_resource` and `message_handler` spelling those wires use.
112+
82113
## Recap
83114

84115
* 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.
85116
* You publish: `await ctx.notify_resource_updated(uri)`, `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`. Idle servers make these free.
86117
* Streams receive only what their filter requested; URIs match exactly; nothing is replayed.
87118
* Scaling out means implementing `SubscriptionBus` — two methods — over your own pub/sub, and passing it as `MCPServer(subscriptions=...)`.
88119
* The low-level spelling is the same machinery held in your hands: a bus, `ListenHandler(bus)`, one constructor argument.
120+
* Consuming is `async with client.listen(...)` and `async for event in sub` — typed events, honored filter on the handle, clean end vs `SubscriptionLost`.

docs/migration.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1514,6 +1514,23 @@ Tasks are expected to return as a separate MCP extension in a future release.
15141514

15151515
## Deprecations
15161516

1517+
### Client resource-subscription methods deprecated (SEP-2575)
1518+
1519+
[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`:
1520+
1521+
- `Client.subscribe_resource()` / `Client.unsubscribe_resource()`
1522+
- `ClientSession.subscribe_resource()` / `ClientSession.unsubscribe_resource()`
1523+
1524+
They keep working against 2025-era servers; a 2026-07-28 server answers them with `-32601` (method not found). Migrate to the listen driver:
1525+
1526+
```python
1527+
async with client.listen(resource_subscriptions=["note://todo"]) as sub:
1528+
async for event in sub: # ResourceUpdated(uri="note://todo")
1529+
...
1530+
```
1531+
1532+
See the [Subscriptions](advanced/subscriptions.md) page for the full client-side contract (typed events, the honored filter, clean end vs `SubscriptionLost`).
1533+
15171534
### Roots, Sampling, and Logging methods deprecated (SEP-2577)
15181535

15191536
[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.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from mcp import Client
2+
from mcp.client.subscriptions import ResourceUpdated
3+
4+
from .tutorial001 import mcp
5+
6+
7+
async def watch_todo() -> str:
8+
"""Wait for the todo note to change once, then stop listening."""
9+
async with Client(mcp) as client:
10+
async with client.listen(resource_subscriptions=["note://todo"]) as sub:
11+
async for event in sub:
12+
assert isinstance(event, ResourceUpdated)
13+
return f"changed: {event.uri}"
14+
return "the server closed the stream before any change"

examples/stories/subscriptions/client.py

Lines changed: 13 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -4,88 +4,34 @@
44
import mcp_types as types
55

66
from mcp.client import Client
7+
from mcp.client.subscriptions import ResourceUpdated, ToolsListChanged
78
from stories._harness import Target, run_client
89

9-
SUBSCRIPTION_ID = "io.modelcontextprotocol/subscriptionId"
10-
1110

1211
async def main(target: Target, *, mode: str = "auto") -> None:
13-
# Stream frames arrive as ordinary server notifications; `message_handler`
14-
# is constructor-only on `Client`, so the list it fills exists first.
15-
received: list[types.ServerNotification] = []
16-
arrival = anyio.Event()
17-
18-
async def on_message(message: object) -> None:
19-
nonlocal arrival
20-
if isinstance(
21-
message,
22-
types.SubscriptionsAcknowledgedNotification
23-
| types.ResourceUpdatedNotification
24-
| types.ToolListChangedNotification,
25-
):
26-
received.append(message)
27-
arrival.set()
28-
arrival = anyio.Event()
29-
30-
async def wait_for(count: int) -> None:
31-
with anyio.fail_after(10):
32-
while len(received) < count:
33-
await arrival.wait()
34-
35-
async with Client(target, mode=mode, message_handler=on_message) as client:
12+
async with Client(target, mode=mode) as client:
3613
before = await client.list_tools()
3714
assert "search" not in {tool.name for tool in before.tools}
3815

39-
async with anyio.create_task_group() as tg:
40-
# There is no client-side listen API yet, so the story drops to the
41-
# `client.session` escape hatch. The request parks for the stream's
42-
# lifetime, so it runs as a task; cancelling it releases the local
43-
# awaiting scope. In-memory that also ends the server's stream; over
44-
# HTTP today nothing aborts the POST, so the server-side stream ends
45-
# when the connection closes (the `Client` exit right below).
46-
async def listen() -> None:
47-
request = types.SubscriptionsListenRequest(
48-
params=types.SubscriptionsListenRequestParams(
49-
notifications=types.SubscriptionFilter(
50-
tools_list_changed=True, resource_subscriptions=["note://todo"]
51-
)
52-
)
53-
)
54-
await client.session.send_request(request, types.SubscriptionsListenResult)
55-
56-
tg.start_soon(listen)
57-
58-
# ── the ack is the first frame: it echoes the honored filter, tagged ──
59-
await wait_for(1)
60-
ack = received[0]
61-
assert isinstance(ack, types.SubscriptionsAcknowledgedNotification), ack
62-
assert ack.params.notifications.tools_list_changed is True
63-
assert ack.params.notifications.resource_subscriptions == ["note://todo"]
64-
assert ack.params.meta is not None and SUBSCRIPTION_ID in ack.params.meta
16+
async with client.listen(tools_list_changed=True, resource_subscriptions=["note://todo"]) as sub:
17+
# ── entering waited for the ack: the honored filter is already in hand ──
18+
assert sub.honored.tools_list_changed is True
19+
assert sub.honored.resource_subscriptions == ["note://todo"]
6520

6621
# ── exact-URI filtering: an unsubscribed note edit stays silent ──
6722
await client.call_tool("edit_note", {"name": "journal", "text": "day two"})
68-
# ── the subscribed URI delivers, carrying the same subscription id ──
23+
# ── the subscribed URI delivers ──
6924
await client.call_tool("edit_note", {"name": "todo", "text": "water plants"})
70-
await wait_for(2)
71-
updated = received[1]
72-
assert isinstance(updated, types.ResourceUpdatedNotification), updated
73-
assert updated.params.uri == "note://todo"
74-
assert updated.params.meta is not None
75-
assert updated.params.meta[SUBSCRIPTION_ID] == ack.params.meta[SUBSCRIPTION_ID]
76-
assert len(received) == 2, "the journal edit must not have been delivered"
25+
with anyio.fail_after(10):
26+
event = await anext(sub)
27+
assert event == ResourceUpdated(uri="note://todo"), "the journal edit must not have been delivered"
7728

7829
# ── a runtime tool registration announces itself ──
7930
await client.call_tool("enable_search", {})
80-
await wait_for(3)
81-
assert isinstance(received[2], types.ToolListChangedNotification), received[2]
82-
83-
# The client is done listening: cancel the parked request and let
84-
# the connection teardown below end the stream server-side.
85-
tg.cancel_scope.cancel()
31+
with anyio.fail_after(10):
32+
assert await anext(sub) == ToolsListChanged()
8633

87-
# list_changed told us to re-fetch - the new tool is callable, and the
88-
# session outlives the closed stream.
34+
# ── leaving the block closed the stream; the session lives on ──
8935
tools = await client.list_tools()
9036
assert "search" in {tool.name for tool in tools.tools}
9137
result = await client.call_tool("search", {"query": "water"})

src/mcp/client/client.py

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import logging
77
import uuid
88
from collections.abc import Awaitable, Callable, Mapping, Sequence
9-
from contextlib import AsyncExitStack
9+
from contextlib import AbstractAsyncContextManager, AsyncExitStack
1010
from dataclasses import KW_ONLY, dataclass, field
1111
from typing import Any, Literal, TypeVar, cast
1212

@@ -58,6 +58,8 @@
5858
SamplingFnT,
5959
)
6060
from mcp.client.streamable_http import streamable_http_client
61+
from mcp.client.subscriptions import Subscription
62+
from mcp.client.subscriptions import listen as _listen
6163
from mcp.server import Server
6264
from mcp.server.mcpserver import MCPServer
6365
from mcp.server.runner import modern_on_request
@@ -662,13 +664,61 @@ async def retry(r: InputResponses | None, s: str | None) -> ReadResourceResult |
662664
# Driver rounds carry inputResponses, so a terminal result reached through them is never cached (spec MUST).
663665
return await self._drive_input_required(first, retry)
664666

667+
def listen(
668+
self,
669+
*,
670+
tools_list_changed: bool = False,
671+
prompts_list_changed: bool = False,
672+
resources_list_changed: bool = False,
673+
resource_subscriptions: Sequence[str] = (),
674+
) -> AbstractAsyncContextManager[Subscription]:
675+
"""Open a `subscriptions/listen` stream of typed change events (2026-07-28 only).
676+
677+
The keyword arguments mirror the wire `SubscriptionFilter` field for
678+
field; `resource_subscriptions` names exact resource URIs to watch.
679+
Entering waits for the server's acknowledgment - the subset it agreed
680+
to deliver is `sub.honored` - then the handle yields events:
681+
682+
async with client.listen(tools_list_changed=True) as sub:
683+
async for event in sub:
684+
tools = await client.list_tools() # refetch on change
685+
686+
A graceful server close ends the loop; an abrupt drop raises
687+
`SubscriptionLost` (re-listen and refetch - there is no replay).
688+
Exiting the context ends the subscription. Multiple subscriptions may
689+
be open concurrently.
690+
691+
Raises:
692+
ListenNotSupportedError: The negotiated protocol version predates
693+
2026-07-28 (use `subscribe_resource` and `message_handler` there).
694+
MCPError: The server rejected the request, or the connection
695+
failed before the acknowledgment arrived.
696+
SubscriptionLost: The stream ended before it was acknowledged.
697+
TimeoutError: The session's read timeout elapsed before the acknowledgment.
698+
"""
699+
return _listen(
700+
self.session,
701+
tools_list_changed=tools_list_changed,
702+
prompts_list_changed=prompts_list_changed,
703+
resources_list_changed=resources_list_changed,
704+
resource_subscriptions=resource_subscriptions,
705+
)
706+
707+
@deprecated(
708+
"resources/subscribe is removed as of 2026-07-28; use Client.listen() instead.",
709+
category=MCPDeprecationWarning,
710+
)
665711
async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> EmptyResult:
666-
"""Subscribe to resource updates."""
667-
return await self.session.subscribe_resource(uri, meta=meta)
712+
"""Subscribe to resource updates (2025-era servers only)."""
713+
return await self.session.subscribe_resource(uri, meta=meta) # pyright: ignore[reportDeprecated]
668714

715+
@deprecated(
716+
"resources/unsubscribe is removed as of 2026-07-28; use Client.listen() instead.",
717+
category=MCPDeprecationWarning,
718+
)
669719
async def unsubscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> EmptyResult:
670-
"""Unsubscribe from resource updates."""
671-
return await self.session.unsubscribe_resource(uri, meta=meta)
720+
"""Unsubscribe from resource updates (2025-era servers only)."""
721+
return await self.session.unsubscribe_resource(uri, meta=meta) # pyright: ignore[reportDeprecated]
672722

673723
async def call_tool(
674724
self,

0 commit comments

Comments
 (0)