MCP 2026-07-28 (6/11): subscriptions/listen — long-lived notification streams replace resources/subscribe and the GET stream - #229
Open
simonx1 wants to merge 15 commits into
Open
Conversation
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
2 tasks
simonx1
force-pushed
the
mcp-2026/subscriptions-listen
branch
2 times, most recently
from
September 1, 2026 20:30
f317b96 to
875a6c3
Compare
simonx1
force-pushed
the
mcp-2026/mrtr
branch
from
September 5, 2026 00:27
bcece02 to
1220b23
Compare
simonx1
force-pushed
the
mcp-2026/subscriptions-listen
branch
from
September 5, 2026 00:34
4cc387e to
e718c14
Compare
simonx1
force-pushed
the
mcp-2026/mrtr
branch
from
September 5, 2026 12:52
b22a32a to
628df99
Compare
Replace resources/subscribe and the HTTP GET stream on modern servers with subscriptions/listen: MCPClient::Subscription (filter normalization, acknowledged subset, graceful vs abrupt closure), SubscriptionSupport (registry keyed by listen id, routing of tagged notifications, acknowledgment, server-side cancellation, resources/subscribe mapping), a stdio implementation that cancels with notifications/cancelled and re-sends live subscriptions when the process is re-established, and a Streamable HTTP implementation whose long-lived POST stream runs on its own thread, closes to cancel, and re-opens after an abrupt drop. Client#listen exposes it; subscription notifications still drive the client's cache invalidation. Legacy sessions refuse listen. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ed entries, shutdown EOF Codex findings: - Listen streams request Accept-Encoding: identity (the stream is parsed as it arrives; a gzip body could not be). - A rejected listen goes through the regular HTTP error pipeline — typed JSON-RPC errors, and 401/403 through the OAuth challenge handling (insufficient_scope surfaces as InsufficientScopeError) — including when raise_error middleware is configured. - subscribe_resource opens a fresh stream when the previous per-URI subscription was closed by the server. - A shutdown flag keeps the reader's EOF during cleanup from being reported (and handled) as an unexpected process exit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ping, closed subscriptions - A 2xx application/json answer to subscriptions/listen is the closing response (or rejection) instead of a silently dropped stream. - A listen stream only honours responses carrying its own request id. - A subscription the host closed stays closed across reconnect races; the stream reader stops as soon as the host closes it. - Subscription#unacknowledged_resource_uris reports partially acknowledged resource subscriptions. - The stream buffer is scanned incrementally (linear in stream size) while the buffer cap still applies to unterminated events. - resource_subscriptions is guarded by the subscriptions mutex; the client sanitizes subscription ids and cancellation reasons in logs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
subscribe_resource and unsubscribe_resource map onto a subscriptions/listen stream for a modern server and keep resources/subscribe for a 2025-11-25 one. The mapping itself lives in SubscriptionSupport, but the era test is each transport's own, and only stdio's was covered — the two HTTP transports were correct by inspection alone. They now carry the same three examples through a shared group: a modern server gets subscriptions/listen carrying resourceSubscriptions and no resources/subscribe, closing it sends no resources/unsubscribe and drops the registry entry, and a legacy server still gets both plain requests and no listen. Removing the gate from either transport fails its own example. The listen stream opens on its own thread, so the examples wait for the request to be recorded rather than for a fixed margin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…read listeners, stream cancellation subscribe_resource on a modern server started a listen stream and dropped the handle, so it answered true for a stream the server had rejected and the caller could see neither the error nor the URIs the server declined. It now waits for the acknowledgment and checks that it names the URI, raising the server's own error (otherwise ResourceReadError) when it does not. Opening and closing the stream for one URI is serialized, so two threads share one stream that unsubscribe_resource really closes. Listener callbacks move to the subscription's own dispatcher thread. On stdio they used to run on the sole stdout reader, so a listener that re-read the resource that changed waited for a response only the thread it was blocking could deliver — it timed out and stalled all message processing meanwhile. HTTP cancellation closes the SSE response stream (the spec's cancellation signal) and interrupts the re-open backoff instead of killing the reader wherever it happened to be, which lost a notification mid-delivery. Taking a new listen id is atomic with closure on both transports, so a close racing with a re-open either stops it or cancels the id that went out; stdio names that id again if the cancellation raced ahead of the request. close_listen_streams moves both registries under the subscription lock, and no path touches a Subscription's own lock while holding it. SSE events are framed and split on CR, LF or CRLF in any mix, and the incremental scan offset is counted in characters like the offset String#match takes, so a multibyte payload cannot stall the stream. The era gate spec now pins the era decision rather than the branch — an auto-mode transport negotiated down to 2025-11-25 keeps resources/subscribe — and its unsubscribe example pins that the stream is closed, not just deregistered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ssure HTTP cancellation could not reliably stop a listen stream. The closed check ran only inside on_data, so a close that landed after the subscription was registered but before its thread reached the POST still sent it, and a quiet server then held a subscription nothing reads until the 300 s read timeout. The request is refused twice now: before it is built, and again as the connection is about to open its socket — under the same lock the cancellation takes, so a close either stops the request outright or finds the session it has to close. Closing that session is the other half: `finish if started?` silently did nothing for a socket that was still being opened, so the close now reports that case and comes back for it once the join has timed out. After `close` returns, either no listen request went out or its response stream has been closed. Transport shutdown killed the reader threads. A kill lands wherever the thread happens to be — mid-delivery, or holding the subscription's own lock while it takes a new listen id, which is exactly what a later close or listen would then wait on — and the joins around it ran under the transport lock the readers themselves need for that id, so shutdown could stall every later call for the join timeout. It closes every stream cooperatively now and lets the readers unwind on their own; once its subscription is finished and its response stream closed, a reader can send nothing more. The gap that made the kill look necessary is closed with it: a stream between two listen ids is registered under neither registry, so shutdown never finished it and it re-opened onto a transport that was already gone. The threads' own subscriptions are named alongside the registry's now. The dispatcher queue is bounded (MAX_PENDING_NOTIFICATIONS = 1024). Round 3 moved listener callbacks onto a per-subscription dispatcher, which fixed the deadlock and removed the backpressure with it: neither the stdio reader nor the HTTP on_data callback waits for the listeners any more, so a listener that reads a resource on every notifications/resources/updated let a chatty server grow the queue without a cap — the same peer-controlled growth the 32 MB buffer cap bounds one stage upstream. A full queue drops its *oldest* entry, counted in dropped_notifications (pending_notifications reports the depth) and named once per subscription in the log. Why the oldest, of the four choices: blocking the reader — for this subscription or for all of them — puts back exactly the deadlock the dispatcher exists to prevent, because the reader would be waiting on a listener that is waiting for a response only that reader can deliver. Ending the subscription with an error hands a chatty server a way to kill a stream the host still wants. Dropping the newest leaves the host acting on a stale view for good. Every MCP notification is a "look again" signal about state the host re-reads for itself, so the newest one still carries what the dropped ones said: dropping the oldest costs intermediate wake-ups and nothing else. A listen answer is framed by its Content-Type. The server MAY answer with a single JSON object instead of a stream; SSE parsing was applied before the Content-Type was inspected, so a compact body followed by a blank line was consumed as an event with no data lines and the empty buffer left a clean close looking like a dropped stream and a typed rejection generic. Faraday saves the response headers before the first chunk, so on_data decides the framing from them (falling back to the shape of the first bytes) and leaves a JSON answer alone. A closing response goes through validate_result_type! like every other response: a result that is missing, scalar, or carries an unrecognized resultType fails the subscription with an InvalidResultError instead of being reported as a graceful close. A stream that drops after it was acknowledged marks itself reconnecting, so active? stops answering true while no server-side subscription exists. Settling stays one-way: a drop that follows the acknowledgment immediately must not un-answer the question a subscribe_resource is blocked on, or the call would wait out its whole acknowledgment timeout for an acknowledgment it already had. New examples cover an HTTP listen closed at once and closed at each point of the send race, a cancellation that closes an in-flight stream and one whose socket was still opening, the shutdown gap, the queue bound, both JSON framings, the three invalid closing responses, the reconnecting state, and the three confirm_resource_subscription paths round 3 left unpinned: a subscribe_resource whose acknowledgment deadline passes while the request is still in flight, a stream closed before the acknowledgment, and an acknowledgment that never arrives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ntity-keyed backpressure, stdio restart A cancellation could not reach Faraday's connect phase. A session whose socket is still being opened cannot be closed, so `close_listen_session` answered :opening; the cancellation gave up after two joins — 4 seconds against a 10-second open timeout — having already removed the thread from `listen_threads`. The connect could then finish and POST a subscription the host had closed, and nothing was left for a later `cleanup` to close. The session now refuses to send for a closed subscription, on the session itself, between the socket opening and the request going out: the one point that holds however long the connect takes and whether or not anything is still waiting. The cancellation keeps coming back for a session that is still opening instead of trying once, and leaves the thread registered until it removes itself, so a stream it gives up on is still one `cleanup` finds. Once `close` or `cleanup` returns, either no listen request went out or the stream is closed. The queue's drop-oldest policy was wrong for a mixed filter. One stream can watch several resource URIs or task ids, and dropping by arrival order discarded the only queued update for a quiet resource to keep newer ones for a busy one — losing the signal the ceiling exists to preserve, with `dropped_notifications` counting it and the listener never learning. The dispatcher moves to its own class and discards by identity instead: the oldest notification about the same thing (method plus `uri`/`taskId`) as the arriving one, or failing that the oldest of whichever thing has the most queued, so nothing loses its only notice while something else has a spare. Enqueuing still never waits for a listener. On stdio a process that exited on its own only marked the session for restart, which a host that is just waiting for notifications never triggers: a subscription is a standing request it does not repeat, so every subscription stayed :reconnecting for ever. The exit path now re-establishes the process while subscriptions are open and re-sends them — what MCP 2026-07-28 stdio "Unexpected Termination" asks of a client, and what this transport already promises across a restart. A restart that fails, or a process that exits again immediately, closes those subscriptions with the error instead, so the host learns from `closed?`/`error` rather than waiting on a stream that is not coming back; with nothing open the process stays lazily re-established on the next request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…n order, re-acknowledged URIs, crash-loop uptime Bound the notification queue by bytes, not just by count. The params of every queued notification are retained until its listener has run and the peer chooses how big they are, so MAX_PENDING_NOTIFICATIONS alone let a slow or blocked listener sit behind a thousand payloads of up to 32 MiB each on HTTP, and of no bounded size at all on stdio. Overflow now starts at whichever of MAX_PENDING_NOTIFICATIONS and the new MAX_PENDING_NOTIFICATION_BYTES the arriving notification would breach; which entry goes is unchanged — still the identity-keyed choice round 5 introduced. A notification larger than the whole budget is still delivered, alone, so the peer can hold one payload behind a stalled listener rather than a queueful, and no signal is lost for its size. pending_notification_bytes reports the current retention. Invalidate the caches before the notification becomes visible to listeners. A listener runs on the subscription's dispatcher thread, so queuing its delivery publishes the notification at once: a listener reacting to a list_changed notification by calling a cached list method could run before the transport and client caches it invalidates had been dropped, and read the very entry the notification says is stale. Routing drops both caches first and delivers afterwards, which makes the ordering a guarantee rather than a race the scheduler usually happens to win. Recheck the resource-subscription mapping on every acknowledgment, not just the first. A stream re-opened after an HTTP drop or a stdio restart is a new listen request the server holds no state for and MAY acknowledge more narrowly, so an acknowledgment that comes back without the URI now closes the subscription and drops the mapping instead of leaving live_resource_subscription reporting a watch nothing is honouring. Measure the stdio crash-loop bound from readiness rather than from the restart attempt. SUBSCRIPTION_RESTART_MIN_INTERVAL was compared against the moment ensure_initialized was called, so a server whose handshake alone outlasts the interval — an `npx -y …` command fetching its package, say — and which then exits immediately was credited with its whole start-up, read as healthy, and respawned for ever. The session a restart produces is stamped once its handshake is answered and its subscriptions re-sent, and the bound now measures that process's uptime. Round 5's restart examples stubbed connect to return immediately, so the handshake could never exceed the interval and this path was untested; the new examples drive a handshake longer than a stubbed interval and cover both the crash loop and the restart that legitimately follows a process which stayed up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
… copies Eight findings, three of them on round 6's own fixes. Restart lifecycle (stdio): - A listen write that fails only after the child exited and the restart re-opened the same subscription under a new id no longer tears that healthy stream down: the failure cleanup names the id the write went out with, unregisters only that registration, and a superseded failure is logged instead of raised — the subscription the caller asked for is open on the session that replaced this one. - The crash-loop mark is a depth, not a shared flag, and each session carries the answer decided before it was established. The process one restart spawns can exit while that restart is still finishing, so a second begins on the new process's reader thread before the first returns; whichever finished first used to clear the flag and leave the other's session stamped with nothing, and the crash-looping server was respawned for ever. The stamp is also taken as soon as the handshake is answered, since the process can exit while the subscriptions are still going out. - A restarted process that negotiates a pre-2026-07-28 version cannot carry the subscriptions cleanup moved aside: they end with a CapabilityError rather than staying :reconnecting for ever with the host never told. Delivery and state: - Routing keeps round 6's order (transport cache, client cache, listeners) but the host's on_notification callback can now stop neither: an exception escaping it is logged, where it used to drop the notification the subscription's listeners were waiting for and, on stdio, the reader thread with it. Round 6's ordering example now lets the callback raise. - A payload larger than the whole byte budget is not charged against it. Charging it left the queue permanently overflowing, so the next notice of anything else evicted it through identity rule 3 — the only-notice-of-one- thing loss the policy exists to prevent. Only one payload is exempt, so the retained total stays within the budget plus one peer-sized payload. - The acknowledgment a resource subscription is opened on is checked once more with the URI mapped: the round 6 revalidation reads that mapping, and a narrowing re-acknowledgment landing before it was written was stored as a live watch. - A requested filter is detached and frozen, so a caller mutating the array it passed cannot change the request Streamable HTTP builds on the stream's own thread after listen returns, or what a reconnect asks for. - `unsupported` reads the acknowledgment's values rather than its keys: a resourceSubscriptions echoed with none of the requested URIs, or a flag acknowledged as false, is a field the server declined while naming it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ting Two findings had survived three rounds of patching, so both mechanisms are replaced rather than adjusted again. The stdio crash-loop bound is now a record per child process. The invariant: the open subscriptions are re-sent onto a new process unless the process that last received them died less than SUBSCRIPTION_RESTART_MIN_INTERVAL after receiving them. Both facts are stamped on that process's own ChildSession — by the hand-over, and by its teardown — and the question is asked in the one place that re-sends, so it no longer depends on which thread re-established the process: the restart flag and the readiness stamp on the transport left the answer to whichever restart (or racing host request) finished last, and a host re-init that got there first left it permanently "healthy" while a crash-looping server was respawned for ever. The notification queue now charges exactly what it retains, and every eviction removes an entry whose removal relieves the pressure that caused it: the byte budget considers only the entries charged against it, the count ceiling all of them, and the oversized payload sits in a slot of its own, of which there is only ever one. "Which entry goes" and "how many bytes are charged" were decided by rules that disagreed, so the only notice of a resource could be spent on pressure that discarding it did not relieve, leaving the queue over budget and the signal gone. What overflow gives up is still chosen by identity. Also: - subscribe_resource reuses a mapped stream only while the server's word on that URI stands, and waits for the replacement request otherwise. Every non-closed handle used to count as a live watch, and a subscription with no acknowledgment has no unacknowledged URIs either, so the post-mapping recheck read "not missing" as "being watched". - The acknowledged filter is stored as a deeply frozen copy: it arrives in the peer's own hash, which is handed on to the host callback and the listeners. - The subscription a notification belongs to is resolved before the host callback sees the payload, so editing _meta can neither drop nor redirect the delivery. - A listen write that fails while the subscription is already queued for the next process leaves it there instead of closing a stream the restart was about to re-send; a superseded failure is raised when the stream that replaced it has itself failed, rather than handing back a closed handle with no exception. - notify_host sanitizes the peer-controlled method name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
Four findings at the edges of the two mechanisms round 8 rewrote.
A stream that dropped keeps the last acknowledgment on record until the
replacement takes a new listen id after the backoff, and the reuse path
read that record as the current grant — so `subscribe_resource` reported a
watch for the whole of an HTTP re-open backoff or a stdio handshake, on a
stream the server no longer held and whose replacement may reject the URI.
Reuse now asks whether the server is honouring the URI on that stream now
(`await_live_resource_watch`). The subscriber waiting on its own listen
request still gets its answer, since a connection that drops the instant the
acknowledgment lands does not unanswer it; "nothing re-sent yet" is told
from "re-sent and not yet acknowledged" by a flag written at the
acknowledgment and at each new listen id rather than by which of them a
reconnect reaches first.
On stdio, the guard that left a `:reconnecting` subscription alone never
fired on the hand-over itself: taking the new listen id has already moved it
to `:pending` by the time an EPIPE or a nil stdin raises, so the very stream
a restart was re-sending — which the spec says MUST be re-sent — was closed.
The decision is now keyed on the subscription (`reestablishing?`), which
survives that transition, and such a subscription goes back on the queue the
next session drains instead of being stranded off it.
The notification queue retained the method name but charged only the params,
so tagged notifications with multi-megabyte method names and `{}` params
cost two bytes each and a thousand of them slipped the byte ceiling. The
charge now counts everything the entry retains.
The record the crash-loop bound consults outlived the question it answers:
after a refusal had closed the subscriptions the dead child carried, it
still convicted a subscription opened directly on the healthy replacement
when that process later exited. Asking now spends it, while a session handed
nothing spends nothing — the subscriptions are still open on the session it
replaced.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…, discarded watches, 5xx listens Four findings from the round 9 review. The host's on_notification callback now runs last, after the delivery to the subscription's listeners has been queued. It is the only routing step that can block — host code, on whatever thread is routing, which on stdio is the process's sole stdout reader — so a callback making a synchronous request of its own held the queueing behind a response only that reader could deliver: the block round 3 removed for listeners, back one level up. Every guarantee the earlier rounds built survives the move: the caches are still dropped before any listener can run, an exception is still logged and stops nothing, and the callback still cannot drop or redirect a delivery — now because the delivery has already been made rather than because its target was resolved first. The stdio queue of subscriptions waiting for a process is guarded. A cleanup moving the open subscriptions across and a hand-over whose listen write failed putting one back overlap — the second lands in the window the first leaves between the registry snapshot and the write — and concurrent concat/<< on a bare Array is undefined in MRI: the window could lose a stream the spec says MUST be re-sent, or duplicate it and open two. Both paths now go through one lock, membership is by identity, and the cancellation names what is actually outstanding: notifications/cancelled for every listen the client wrote for that subscription on the live process, with the ids of a process that is gone forgotten rather than cancelled on its replacement. A mapped resource subscription that never becomes a live watch is closed, not merely unmapped: it was left reconnectable with nothing pointing at it, free to come back and deliver the same updates beside the replacement subscribe_resource opened, and unreachable to unsubscribe_resource. A listen POST answered with a 5xx re-opens on the usual backoff instead of ending the subscription. The status was already classified transient, but the call site finished the stream and returned closed, which the loop does not retry — so a brief 500 or 503 killed a long-lived subscription while a connection failure or a timeout on the same request reconnected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…sed connections, host caches Five findings the eleven review rounds left, three of them now driven against a real subprocess rather than a scripted one. An exit during initialization is an exit. The reader skipped the unexpected-exit handling outright while @initialized was false, which is exactly the state a replacement that answers the discovery probe and then dies leaves behind: initialization went on to mark the dead connection initialized and re-send the open subscriptions to it, the failed writes were deferred back onto the "wait for the next process" queue, and with that reader already gone nothing was left to establish one — the subscriptions stayed :reconnecting for ever with no error to tell the host why. The reader now waits out an initialization still in flight and then handles the exit. The lock it waits on is that initialization finishing; it can deliver no further responses by then, so whatever the initializing thread is waiting for is already bounded by its own timeout. It also only tears down the process it was started for, never a successor. A listen request goes to the pipe it was recorded against. send_request read the transport's current stdin, so a listen still pending when the process exited was written to the process that replaced it — whose teardown had already forgotten that id, since nothing written to a dead process is outstanding and none of its ids may be cancelled on its successor. The server served a second stream this client could no longer name, and close cancelled only the restart's own listen. The pipe is now pinned before anything is recorded about the attempt, so a write that lands late reaches the process it was opening on and fails into the existing error paths once that pipe is closed. The Streamable HTTP mirror image is refused rather than deferred. A listen paused between readying the connection and sending the request used to register and POST after a cleanup had closed the (then empty) registries, leaving a live stream on a disconnected transport that no later cleanup could find — cleanup returns at once on a transport that is already disconnected. The stream is claimed under the very lock the close takes, so a cleanup either finds it or stops it, and a listen it stops raises ConnectionError instead of handing back a stream that was never opened. Only an exit counts against the crash-loop bound. Every teardown stamps the moment the process ended, but a cleanup the host asked for is not the server crashing: a host that closes the transport and reconnects — which a cleanup/request cycle does, and so does re-authenticating or re-configuring a server — did so well inside SUBSCRIPTION_RESTART_MIN_INTERVAL and had the very subscriptions the reconnect exists to carry across closed for a crash that never happened. The bound still fires on a real exit. This client's caches are dropped ahead of the delivery. Round 10 moved the host callback to the end of the routing order, for good reason — it is the only step that can block, on the very reader the delivery came from — and the client's own cache invalidation rode on that callback, so a listener reacting to a list_changed notification could read the entry the notification says is stale. The invalidation gets a hook of its own, ServerBase#on_cache_invalidation, run at the invalidation step; only the invalidation moved, and everything else the client does with a notification (logging, progress callbacks, task status) stays behind the delivery. Paths that fan a notification out without routing a subscription announce the hook too — the legacy SSE parser and the synthetic tools/list_changed a header-mismatch refresh emits — so no transport is left invalidating on only one of the two, and a transport that does not define the hook keeps both on on_notification with the invalidation first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ancellation order, deadlines Five findings from a second review of the subscriptions/listen work, each driven by an example that fails without the fix. * **[P1] An HTTP listen could open after the transport was closed.** `ensure_session_ready` connects and *then* clears the "streams may still be opened" flag, and those are two operations: a `cleanup` landing between them had its flag reset by the listen that resumed afterwards, which went on to register and POST on a transport the host had already disconnected — and `cleanup` returns at once on a transport that is already disconnected, so nothing could ever close that stream. Claiming a stream now asks whether the connection is still up rather than only whether the flag was cleared. * **[P2] A stdio cancellation could precede the request it named.** The subscription's lock is released before the listen is written, so a `close` landing in that window cancelled the id the open had just taken and put `cancelled(n)` on the wire ahead of `listen(n)` — "the cancelled request MUST have been previously issued". A recorded listen is now cancellable only once its write has finished, and the attempt that wrote it cancels it itself when it finds the subscription closed by then. `cancel_outstanding_listens` no longer names the subscription's current id on top of the ids this client actually wrote, which also stops a close during a stdio restart from cancelling a dead process's id on its successor's pipe. * **[P2] A listen the server never acknowledged stayed pending for ever.** The request is meant to outlive every other one — its response is the server's closing of the stream — so the deadline the lifecycle asks for goes on the acknowledgment: `listen(..., ack_timeout:)` (the transport's read timeout by default, `false` to wait for ever) cancels an unacknowledged listen and closes the handle with a RequestTimeoutError. An acknowledged stream is unbounded as before. The resource-subscribe wrapper, which waits for the acknowledgment itself, opts out. * **[P2] A non-completion result was reported as a graceful closure.** `input_required` is a resultType the client recognizes, so a listen answered with one closed `closed_gracefully?`. It is valid on tools/call, resources/read and prompts/get alone and means the request has *not* finished; it now fails the subscription like any other unusable result. * **[P2] Custom transports silently lost the client's cache invalidation.** The fallback was chosen by whether the transport's class defines `on_cache_invalidation` — and every ServerBase subclass inherits it, so an adapter written against the older interface, emitting only through the notification callback, invalidated nothing. The hook now says per notification whether it ran, and the callback invalidates when it did not. Coverage repairs, each checked by applying the mutation it is meant to catch: * The listen request's reserved `_meta` fields (clientInfo, clientCapabilities) and the HTTP MCP-Protocol-Version header — removing any of them from listen requests left all examples green. * The client-cache example seeded no tool, so the client cache never held an entry and only the transport's invalidation was under test. * The incremental-scan assertion counted matcher calls, which a scan from offset zero also satisfies; it now pins the offsets. * The untagged-notification negative assertion read the listeners the moment routing returned, racing the dispatcher; it now waits for a tagged delivery behind it. * The cache-ordering example depended on a dispatcher thread winning a sleep; it now reads the caches synchronously, at the instant the delivery is queued. * The Streamable HTTP self-join example registered no listen thread, so the branch it names was never reached; and the round 3 cancellation race now forbids a cancellation *before* the listen as well as requiring one after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
The branch below retires a stdio transport lazily when its reader reaches EOF, so the next request re-establishes it. This branch handles the exit where it happens — the process is torn down and restarted for the subscriptions the host still wants — and that teardown moves the transport generation, so the lazy retirement no longer fires on this path. It stays for the one this branch does not cover: a reader that died without reaching EOF. The example therefore captures the live handles before the exit and waits for the reader to finish, which is the signal the exit was handled. Every guarantee it pinned is still asserted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
simonx1
force-pushed
the
mcp-2026/subscriptions-listen
branch
from
September 5, 2026 13:05
fdea97c to
320b798
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Sixth PR of the MCP 2026-07-28 series (stacked on #228). Implements Subscriptions (changelog major #4).
MCPClient::Subscription— filter normalization (toolsListChanged,promptsListChanged,resourcesListChanged,resourceSubscriptions,taskIds; snake_case accepted), states:pending → :active → :closed(:reconnectingon stdio between processes),acknowledged/unsupported,closed_gracefully?,error, per-subscription listeners,close.SubscriptionSupport(shared) — registry keyed by the listen request id;route_notificationhandlesnotifications/subscriptions/acknowledged(first message, records the honoured subset), demultiplexes notifications by_meta["io.modelcontextprotocol/subscriptionId"]to the owning subscription and to the transport's general callback (soClientcache invalidation keeps working), treats a servernotifications/cancellednaming the listen id as a teardown, and a response to the listen request as graceful closure.subscribe_resource/unsubscribe_resourcemap to one listen stream per URI on modern servers (still gated onresources.subscribe).closesendsnotifications/cancelledwith the listen id; oncleanup(or an unexpected process exit, which now marks the session for restart per stdio "Unexpected Termination") open subscriptions are marked:reconnectingand re-sent with a new id once the process is re-established.MCP-Protocol-Version,Mcp-Method,Acceptboth types); keep-alive comments ignored; server-initiated requests dropped; a 4xx yields the typed error; a stream that ends without the closing response is re-opened with a new id (1 s → 30 s backoff) while the subscription is still wanted;closekills the stream (closing the SSE response is the cancellation signal, nonotifications/cancelled);cleanupends every stream.Client#listen(notifications:, server:);notifications/subscriptions/acknowledgedhandled inprocess_notification.listenwithCapabilityErrorand keepresources/subscribe.Test plan
bundle exec rspec— 1998 examples, 0 failures (21 new insubscriptions_listen_2026_spec.rb: stdio, Streamable HTTP with reconnect/close/error, Client cache invalidation, legacy refusal, filter validation)bundle exec rubocop— clean🤖 Generated with Claude Code
https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7