Skip to content

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
mcp-2026/mrtrfrom
mcp-2026/subscriptions-listen
Open

MCP 2026-07-28 (6/11): subscriptions/listen — long-lived notification streams replace resources/subscribe and the GET stream#229
simonx1 wants to merge 15 commits into
mcp-2026/mrtrfrom
mcp-2026/subscriptions-listen

Conversation

@simonx1

@simonx1 simonx1 commented Sep 1, 2026

Copy link
Copy Markdown
Owner

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 (:reconnecting on stdio between processes), acknowledged/unsupported, closed_gracefully?, error, per-subscription listeners, close.
  • SubscriptionSupport (shared) — registry keyed by the listen request id; route_notification handles notifications/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 (so Client cache invalidation keeps working), treats a server notifications/cancelled naming the listen id as a teardown, and a response to the listen request as graceful closure. subscribe_resource/unsubscribe_resource map to one listen stream per URI on modern servers (still gated on resources.subscribe).
  • stdio — subscriptions share the channel; close sends notifications/cancelled with the listen id; on cleanup (or an unexpected process exit, which now marks the session for restart per stdio "Unexpected Termination") open subscriptions are marked :reconnecting and re-sent with a new id once the process is re-established.
  • Streamable HTTP / HTTP — the listen POST streams its SSE response on a dedicated thread (MCP-Protocol-Version, Mcp-Method, Accept both 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; close kills the stream (closing the SSE response is the cancellation signal, no notifications/cancelled); cleanup ends every stream.
  • ClientClient#listen(notifications:, server:); notifications/subscriptions/acknowledged handled in process_notification.
  • Legacy sessions refuse listen with CapabilityError and keep resources/subscribe.

Test plan

  • bundle exec rspec — 1998 examples, 0 failures (21 new in subscriptions_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

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

simonx1 and others added 15 commits September 5, 2026 14:52
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
simonx1 force-pushed the mcp-2026/subscriptions-listen branch from fdea97c to 320b798 Compare September 5, 2026 13:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant