Add the client-side subscriptions/listen driver
Stacked on #3046 (the request-id and cancellation plumbing) — review this one for the driver only; the base branch carries the transport work.
The client half of subscriptions/listen (2026-07-28, SEP-2575), shaped after the ecosystem's own streaming idioms: one context manager, async for consumption, the acknowledgment as state on the handle rather than an event in the stream.
async with client.listen(tools_list_changed=True, resource_subscriptions=["note://todo"]) as sub:
print(sub.honored) # the subset the server agreed to deliver
async for event in sub:
match event:
case ToolsListChanged():
tools = await client.list_tools()
case ResourceUpdated(uri=uri):
body = await client.read_resource(uri)
Motivation and Context
On the 2026 wire the response to subscriptions/listen is the notification stream. The server half shipped in #3035; until now a client had to hand-roll a message_handler, park a raw send_request in a task, and demultiplex _meta subscription ids by hand (see the old examples/stories/subscriptions/client.py for what that looked like). The driver folds all of it into structured concurrency:
- Entering waits for the acknowledgment (spec: the ack MUST be the stream's first message), so
sub.honored— the filter subset the server agreed to deliver — is always populated, making the spec's "client SHOULD check the acknowledged filter" a plain attribute read. Pre-ack failures raise from enter (server rejection asMCPError, a stream that dies unacknowledged asSubscriptionLost); nothing degrades silently. - Iteration yields typed events — the same four frozen dataclasses the server publishes, now defined once in
mcp/shared/subscriptions.pyand re-exported from the server module (no import breaks). Events are level triggers with no payload beyond identity, so unconsumed duplicates deduplicate and the backlog is bounded by the filter's width by construction — no cap, no overflow policy. - The stream's two endings are control flow, mirroring the spec's clean-end-vs-abrupt-drop distinction: the server's empty result ends the
async for; a drop raisesSubscriptionLost. No replay, no automatic re-listen — a reconnecting client refetches, which the docs show as a four-line loop. - Exiting the context ends the subscription with the transport's own spelling via the base branch's plumbing: the POST's response stream is aborted over streamable HTTP (that wire's cancellation signal),
notifications/cancelledis sent on stream transports. The driver itself is transport-blind. - Multiple subscriptions demultiplex independently by their verbatim subscription ids; tool calls run freely beside open streams — the docs cover the watcher-task pattern explicitly.
Client.listen()on a pre-2026 connection raises a typedListenNotSupportedErrorsteering to the legacy verbs (TypeScript SDK parity), andsubscribe_resource/unsubscribe_resourcenow carry deprecation warnings on bothClientandClientSession, with a migration-guide entry.
Session-level mechanics worth a reviewer's attention: acks are consumed only for ids in the driver's minted namespace, so raw escape-hatch listens (driving send_request directly) still observe their acks through message_handler; delivered events are handed to the consumer after the message_handler tee, so the caching layer's 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 gets SubscriptionLost instead of hanging when the client closes. One transport fix rides along: a per-request SSE stream that drops without ever carrying an event id now resolves its request with CONNECTION_CLOSED instead of leaving the waiter pending for the session's lifetime.
How Has This Been Tested?
- Full suite: 100.00% line+branch coverage, pyright clean.
- 24 driver tests (in-process, public API): ack surfacing, all four event kinds, dedupe, graceful/lost/local endings, era steer, rejection-from-enter, degenerate immediate-result open, server-sent cancel pre- and post-ack, slot release on exit, concurrent demux, tee ordering, ack-timeout, read-timeout exemption, duplicate acks, teardown-unblocks-sibling-consumer, raw-id collision containment, retained-handle staleness, bare-string filter rejection.
- 4 interaction tests across the connect matrix (in-memory + streamable HTTP at 2026; the era guard on the 2025 cells) with requirement-manifest rows.
- Docs: the subscriptions page gained a client section backed by a tested snippet; the subscriptions story is rewritten from ~95 lines of hand-rolled demux to the driver and runs across its matrix.
- Manually against a real uvicorn server through the public
Client: honored filter round-trip, both event kinds, exact-URI filter silence, exit-frees-slot proven by a second listen on the same session, refetch-after-change, and the legacy steer.
Breaking Changes
None. The event types moved to mcp/shared/subscriptions.py but remain importable from mcp.server.subscriptions; the deprecated subscription verbs keep working against 2025-era servers.
Types of changes
- Bug fix (non-breaking change which fixes an issue)
- New feature (non-breaking change which adds functionality)
- Breaking change (fix or feature that would cause existing functionality to change)
- Documentation update
Checklist
- I have read the MCP Documentation
- My code follows the repository's style guidelines
- New and existing tests pass locally
- I have added appropriate error handling
- I have added or updated documentation as needed
Additional context
The conformance suite has no client-role listen scenarios — the upstream SEP traceability file explicitly excludes the two client-side obligations (subscription-id correlation is client-internal demux, not wire-observable; the ack check has no wire-observable definition) and the stdio client harness is tracked separately upstream. Coverage here is therefore the interaction suite plus TypeScript-SDK behavioral parity; flagging per the repo's conformance rule so the exclusion is a deliberate acceptance, not an oversight.
Deliberate omissions, for the record: no callbacks layer (the TypeScript SDK routes listen events into its notification-handler registrations; here message_handler still sees every teed frame, so a handler-style consumer remains constructible), no auto-open-at-connect, no auto-re-listen, and no close() method — the context manager is the lifecycle.
Transport hardening (added during review)
Two behavior notes from the waiter-resolution hardening that rode along with this PR:
- A request POST answered with
202 Acceptednow fails that request immediately with a synthesizedINVALID_REQUESTerror (the spec requires SSE or JSON for requests). Previously the caller waited indefinitely, or until a non-conforming server delivered the response on the standalone GET stream. This is stricter than the TypeScript client, which leaves the request pending. - Re-issuing a request id after abandoning the earlier attempt now severs the superseded POST at registration, so a lingering stream from the old attempt can no longer answer, fail, or resolve the new request's waiter. One residual window remains between the dispatcher registering the new waiter and the transport dequeuing the new request; closing it fully would need dispatcher-side attempt identity, which is out of scope here.