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

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

View file

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

View file

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