Add the client-side subscriptions/listen driver

Merged
MA
3mo ago
+2587-295

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 as MCPError, a stream that dies unacknowledged as SubscriptionLost); nothing degrades silently.
  • Iteration yields typed events — the same four frozen dataclasses the server publishes, now defined once in mcp/shared/subscriptions.py and 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 raises SubscriptionLost. 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/cancelled is 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 typed ListenNotSupportedError steering to the legacy verbs (TypeScript SDK parity), and subscribe_resource/unsubscribe_resource now carry deprecation warnings on both Client and ClientSession, 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 Accepted now fails that request immediately with a synthesized INVALID_REQUEST error (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.