feat(client): Client.listen() and listChanged auto-open on modern connections#2322
Conversation
🦋 Changeset detectedLatest commit: be1694d The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
6a8a41d to
d3b1240
Compare
d3b1240 to
4352068
Compare
8760ad9 to
3832503
Compare
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🔴
packages/client/src/client/client.ts:1170-1187— McpSubscription.close() picks its teardown via detectProbeTransportKind(), which only treats a transport as 'stdio' when it has both stderr and pid properties; every other non-Streamable-HTTP transport (InMemoryTransport, SSE, custom Transport implementations) falls into the 'http' branch where close() just calls requestAbort.abort() — a signal only StreamableHTTPClientTransport honors — so close() resolves successfully while sending nothing on the wire: no notifications/cancelled, the server-side subscription slot stays consumed, and change notifications keep dispatching to the registered handlers. Key the teardown decision off whether the transport actually honors requestSignal, or default to sending notifications/cancelled when it is not known to.Extended reasoning...
What the bug is. In
listen(), theclose()closure (packages/client/src/client/client.ts:1170-1187) chooses between two teardown strategies based ontransportKind = detectProbeTransportKind(this.transport). That helper (packages/client/src/client/versionNegotiation.ts:147-149) classifies a transport as'stdio'only when the object has bothstderrandpidproperties — i.e. the child-processStdioClientTransport— and classifies everything else as'http'. In the'http'branch,close()only callsrequestAbort.abort(). ButTransportSendOptions.requestSignalis honored solely byStreamableHTTPClientTransport(the new JSDoc intransport.tsadded by this PR explicitly says single-channel transports — stdio, in-memory — ignore it, andInMemoryTransport.sendnever reads it).\n\nThe code path that triggers it. Connect with any modern-era-capable transport that is neitherStreamableHTTPClientTransportnor a child-process stdio transport:InMemoryTransport(the SDK's publicly exported in-process pattern),SSEClientTransport, or a customTransportimplementation — e.g. the client side of a Unix-socket/TCP connection to aserveStdioserver, a setup the migration guide explicitly invites ("implement theTransportinterface directly",serveStdio'stransportoption).listen()works fine on such a connection, butdetectProbeTransportKindreturns'http'because the transport has nostderr/pid, soclose()aborts anAbortControllernothing is listening to.\n\nWhy nothing else prevents it. AbortingrequestAborthas no effect on a transport that ignoresrequestSignal;parked.unpark()only removes the local response handler; and_listenFirstLookkeeps passing change notifications through to the registeredsetNotificationHandlerregistrations (the_listenStateentry only gates ack/cancel matching, not change-notification dispatch). The PR's own unit test tacitly acknowledges the gap: the stdio-close case inlisten.test.tshas toObject.assign(clientTx, { stderr: null, pid: 1 })ontoInMemoryTransportto makeclose()do anything at all — a plainInMemoryTransportfalls into the silent no-op branch.\n\nImpact.McpSubscription.close()— a new public API whose JSDoc promises "Tears the subscription down" — resolves successfully while doing nothing on the wire on these transports: nonotifications/cancelledis sent, no stream is torn down, the server-side subscription slot stays consumed (it counts againstmaxSubscriptions), and change notifications keep arriving and dispatching to handlers after the consumer believes the subscription is closed. This is a silent functional failure, the worst kind: no error, no log.\n\nStep-by-step proof.\n1. Build a server withcreateMcpHandler(orserveStdioover a socket) and connect aClientwithversionNegotiation: { mode: 'auto' }overInMemoryTransport.createLinkedPair()(or any customTransport). The connection negotiates 2026-07-28.\n2.const sub = await client.listen({ toolsListChanged: true })— the ack arrives, the server registers the subscription, the slot is consumed.\n3.await sub.close()—detectProbeTransportKindsees nostderr/pid→'http'branch →requestAbort.abort().InMemoryTransport.sendnever received or stored that signal, so nothing happens on the wire.close()resolves.\n4. The server still holds the subscription:handler.notify.toolsChanged()(or the stdio router's publish) still emitsnotifications/tools/list_changedfor it, the notification arrives at the client,_listenFirstLookpasses it through, and the registered handler fires — after "close".\n5. The slot is never released, so withmaxSubscriptionsreached (the e2e suite uses 1), subsequentlisten()calls are refused with -32603 even though the consumer closed everything.\n\nHow to fix. Don't key the teardown on a stderr/pid duck-type. Either (a) key it on whether the transport is known to honorrequestSignal(a transport capability/marker), or (b) makenotifications/cancelledthe default teardown for any transport not known to honor per-request abort — the server-side listen routers already handlenotifications/cancelledon single-channel connections, so the safe fallback already exists; it's just gated behind the wrong heuristic. The two flagship transports (Streamable HTTP, child-process stdio) work today, so this is a normal-severity robustness gap rather than a mainline failure — but it's introduced by this PR and worth fixing here.
3832503 to
a7fcd76
Compare
4352068 to
44d0772
Compare
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
packages/client/src/client/client.ts:1210-1230— listen(filter, options?: RequestOptions) only honors options.timeout (as the ack timeout); options.signal is never wired up, so a caller-supplied AbortSignal does not cancel the parked request, clear the ack timer, or settle the returned promise — the listen() promise stays pending until ack/error/full timeout even after the caller aborted (this also affects the auto-open path, since _connectNegotiated forwards connect()'s options into listen()). Either honor options.signal (abort → close() + reject with the abort reason) or narrow the accepted options type so the signature doesn't promise behavior the implementation ignores.Extended reasoning...
What the bug is.
listen(filter: SubscriptionFilter, options?: RequestOptions)accepts the full standardRequestOptionstype, but the implementation reads onlyoptions?.timeout(used as the ack timeout).RequestOptions.signalis documented inprotocol.tsas "Can be used to cancel an in-flight request. This will cause an AbortError to be raised from request()", and every otherClientmethod that takesRequestOptions(therequest()-based ones) honors it. Inlisten()the onlyAbortControllerin play is the internally createdrequestAbortpassed to_parkRequestas{ requestSignal: requestAbort.signal }— the caller'soptions.signalis never consulted: no abort listener is attached, nothing callsclose(), nothing clears the ack timer, and nothing settles the returned promise.\n\nThe code path. Inside the Promise executor, the only settlement paths are: the ack (onAck→resolve), a pre-ack JSON-RPC error (terminatedwith reason'error'→reject), a failed send (sent.catch→reject), and the ack timer (defaultDEFAULT_REQUEST_TIMEOUT_MSEC, 60s →rejectwithRequestTimeout). A caller's abort triggers none of these — the caller's signal simply isn't in the wiring.\n\nStep-by-step proof.\n1. A consumer callsconst ac = new AbortController(); const p = client.listen({ toolsListChanged: true }, { signal: ac.signal })against a slow or unresponsive 2026-07-28 server.\n2. Before the ack arrives, the consumer callsac.abort()— e.g. because the surrounding operation was cancelled or the UI navigated away.\n3. Nothing happens: the parked listen request stays registered in_listenState, therequestAbortcontroller is untouched (so the SSE POST stays open on Streamable HTTP), the ack timer keeps running, andpstays pending.\n4. Up to 60 seconds later (default), the timer fires andpfinally rejects withSdkErrorCode.RequestTimeout— not the abort reason — or, if the ack happens to arrive first,presolves with a live subscription the caller already abandoned, leaving an orphaned listen stream consuming a server-side subscription slot.\n\nThe auto-open path compounds it._connectNegotiatedforwardsconnect()'soptionsobject verbatim intothis.listen(filter, options)for theClientOptions.listChangedauto-open. So a consumer who passes anAbortSignaltoconnect()and aborts it gets the probe and handshake bounded by the signal, but the auto-open ack wait is not —connect()can stay blocked on the listen ack for up to the full ack timeout after the caller already aborted.\n\nWhy nothing else prevents it. There is no doc caveat (the JSDoc only describes the timeout behavior), no type narrowing, and no defensive code: the public signature simply promises cancellation behavior the implementation silently ignores. Mitigations are real — the promise does eventually settle via the ack timeout, and several otherRequestOptionsmembers (onprogress,resetTimeoutOnProgress,maxTotalTimeout,relatedRequestId) genuinely don't apply to a parked listen request, so ignoring those is defensible.signalis the substantive gap.\n\nHow to fix. Either (a) wireoptions.signalinto the executor: if already aborted, reject immediately; otherwise add anabortlistener that doesclearTimeout(timer); void close(); reject(options.signal.reason ?? new SdkError(...)), mirroring the timer branch (and remove the listener on settlement); or (b) narrow the parameter type to something likePick<RequestOptions, 'timeout'>(or'timeout' | 'signal'with the wiring) so the signature matches the implementation. Option (a) is the more useful fix given the auto-open path forwardsconnect()'s options here.
44d0772 to
bd5b1ba
Compare
bd5b1ba to
dcd5b57
Compare
e324db7 to
f306029
Compare
c646a1a to
f42fc48
Compare
|
@claude review |
f42fc48 to
4d9d0f9
Compare
435af52 to
80ba9a0
Compare
protocol.ts: the no-timeout park primitive (`_parkRequest`) — sends a request and registers a `_responseHandlers` entry without arming `_setupTimeout` — and the inert-when-unset `_onParkedNotification` first-look hook wired into `_onnotification` dispatch. Both are named to their one consumer (the `Client.listen()` driver); `request()`'s public contract is unchanged. `_parkRequest` carries an `onBeforeSend` hook so per-id state registration cannot race a synchronously-delivered ack on an in-process transport, and unregisters its `_responseHandlers` entry before rethrowing if `transport.send` throws synchronously (no leaked handler for an id that never went out on the wire). The `_resolveNonCompleteResult` JSDoc no longer names listen as a future consumer; the registry / ResultTypeMap comments name `Client.listen()` as the client-side consumer of the park primitive.
…en on modern
Client.listen(filter) opens a subscriptions/listen stream on a
2026-07-28-era connection: resolves once the server's acknowledged
notification arrives with McpSubscription { honoredFilter, close() }.
Change notifications dispatch to the existing setNotificationHandler
registrations (era-transparent for consumers that already register
those). close() closes the listen request's SSE stream (Streamable
HTTP) or sends notifications/cancelled referencing the listen id
(stdio); no automatic re-listen.
On a 2025-era connection listen() throws a typed
MethodNotSupportedByProtocolVersion error steering to
resources/subscribe and ClientOptions.listChanged (no transparent
shim).
ClientOptions.listChanged auto-opens a listen stream on a modern
connection with the filter derived from which sub-options were set;
the auto-opened subscription is exposed at client.autoOpenedSubscription.
On a legacy connection the same handlers fire on the 2025-era
unsolicited notifications — no listen needed.
TransportSendOptions gains requestSignal for per-request abort on the
Streamable HTTP transport (used by McpSubscription.close() to close
the listen request's SSE stream without closing the transport).
A server hosted via createMcpHandler that mutates its tool set every two seconds and publishes the change via handler.notify.toolsChanged(); a client that drives it two ways on a 2026-07-28 connection: auto-open via ClientOptions.listChanged (the same option a 2025-era client sets), then manual client.listen() with a setNotificationHandler registration. Both legs verified running from source via tsx; README-indexed.
Five requirement entries (ack-first stamping, per-stream filtering, capacity guard, listChanged auto-open on modern, the legacy-era steer) with self-hosted createMcpHandler bodies so handler.notify is in scope. The 2025-era steer cell runs on every legacy arm; the four 2026 cells run on entryModern only.
A new private `_resetConnectionState()` clears every per-connection field in one place — `_negotiatedProtocolVersion`, server capabilities/identity/ instructions, the auto-opened listen subscription, the listen-state map and the cached output validators. The two ad-hoc fresh-connect resets and `close()` now route through it, so a stale `autoOpenedSubscription` (or any other per-connection field) cannot survive a reconnect or outlive the connection it was opened on.
…state machine Every termination path — ack-arrives, ack-timeout, server-cancelled, user-close, transport-close, send-failure — now funnels through a single `settle()` that clears the ack timer, unparks, transitions state, and resolves/rejects the opening promise exactly once. The cancelled-before-ack hang (server sends notifications/cancelled for the listen id BEFORE the ack → 60s misleading RequestTimeout) is impossible by construction: the pre-ack server-cancel rejects the pending listen() promise immediately with a clear "server cancelled before acknowledging" error. Guard order is swapped (NotConnected before the era guard) so a closed instance rejects with NotConnected rather than a misleading MethodNotSupportedByProtocolVersion now that close() clears the negotiated era. Tests cover: cancelled-before-ack rejects fast and leaves no leaked `_responseHandlers` entry; a late duplicate ack after close is a no-op; a synchronous `transport.send` throw does not leak a `_responseHandlers` entry (the `_parkRequest` send-throw guard introduced with the primitive).
…advertised `_connectNegotiated` now computes `effective = configured ∩ serverAdvertised` ONCE after discover and uses that single value for BOTH `_setupListChangedHandlers` and the auto-open `subscriptions/listen` filter. A configured-but-not-advertised type (e.g. tools configured but the server doesn't advertise tools.listChanged) is neither subscribed to nor handled, so the filter and the registered handlers stay in lockstep. When the intersection is empty, auto-open is skipped entirely. (Squash-carried: TS narrow-cast on the synchronous-termination unpark guard — control-flow narrowing does not track closure mutation.)
…bort + send notifications/cancelled The close path branched on detectProbeTransportKind(), which detects child- process transports, not 'honors requestSignal'. On InMemoryTransport, SSE, or any custom transport, close() resolved without ever telling the server, leaving the subscription open server-side. close() now unconditionally aborts the listen request's requestSignal AND sends notifications/cancelled for the listen id. The abort closes the SSE stream where the transport honors it; the cancelled notification reaches the stdio listen router and any spec-compliant server. Idempotent over HTTP, correct on every other transport. detectProbeTransportKind is no longer in the close path.
…— no onerror, no reconnect McpSubscription.close() aborts the listen POST via requestSignal, but _handleSseStream only knew about the transport-level abort: every close() fired a misleading 'SSE stream disconnected' onerror, and (when the server had stamped SSE event ids for resumability) scheduled a GET+Last-Event-ID reconnect that resurrected the subscription the caller just tore down. Thread the per-request requestSignal into _handleSseStream via StartSSEOptions; when EITHER the transport signal or the per-request signal is aborted, skip onerror and skip reconnect (mirrors the existing transport-level reconnect guard).
… 20.0–20.2) AbortSignal.any landed in Node 20.3; the package engines floor is >=20. Feature-detect and fall back to a small manual combinator (a controller that aborts on the first of the two inputs, propagating reason). The native path stays the default. Engines is intentionally NOT bumped.
…parked When a server-cancel (or any termination) is delivered synchronously inside _parkRequest's send — in-process transports — settle() runs before `parked` is assigned, so the previous `_listenState.delete(parked.messageId)` was skipped and the entry registered by onBeforeSend leaked. The catch-up after _parkRequest only called parked.unpark(), not the delete. Capture the messageId in the onBeforeSend closure as `listenMessageId`; settle() and wireTeardown() key off that, so the entry is dropped on every exit path regardless of whether `parked` has been assigned yet.
listen(filter, options?) is typed RequestOptions but only options.timeout was honored; options.signal was silently ignored. The auto-open path forwards connect's options into listen(), so a connect-time signal was also dropped. Honor options.signal in the state machine: an already-aborted signal rejects synchronously (mirrors request()); an abort while `opening` rejects the pending listen() promise with the signal's reason and tears the wire down; an abort while `open` closes the subscription. The abort listener is removed by settle() once the subscription has closed.
…where The behavior landed in ce9251c but the changeset, migration.md, the autoOpenedSubscription getter JSDoc, the example header comment, and the e2e requirement text still said the filter is 'derived from which sub-options were set'. Sync all five to the implemented behavior (and that auto-open is skipped — autoOpenedSubscription stays undefined — when the intersection is empty). Rebuild examples-client dist.
…ever consumed); align close() prose
… error _listenFirstLook returned 'consumed' for every notifications/subscriptions/acknowledged, even when no parked listen on this connection matched its subscription id — a stray or foreign ack was silently swallowed and never reached setNotificationHandler / onerror. Return undefined when no parked entry matches so the ack passes through. Also reword the onServerCancel error: the same callback handles both pre-ack (rejects the pending listen() promise) and post-ack (transitions to closed) server cancels, so 'before acknowledging' was misleading.
… when transport close rejects A fresh connect (or close()) clears _listenState via _resetConnectionState without settling the per-listen machines: when the prior transport never fired onclose, an in-flight listen() promise from the old connection hangs forever. Settle every live entry with a clear ConnectionClosed error before clearing the map. Also wrap super.close() in try/finally so a rejecting transport close still resets per-connection state — a stale negotiated era / live listen state must not survive a failed close.
Adds direct assertions for the paths the adversarial review traced by reading: transport-close before ack (rejects fast), transport-close while open (subscription closes; later sub.close() is a no-op), concurrent listens (independent ids/state), fake-timer ack timeout (RequestTimeout + wireTeardown), nothing dispatched into the per-listen machine after close() (late ack/cancel pass through unconsumed), unmatched ack reaches fallbackNotificationHandler, fresh-connect-without-close settles in-flight listen() with a clear ConnectionClosed error, and close() resets state even when transport.close() rejects.
…ised capabilities
Adds an e2e cell on entryModern where the listen filter requests
toolsListChanged + promptsListChanged + resourcesListChanged but the
server advertises only tools.listChanged: honoredFilter on the resolved
McpSubscription is { toolsListChanged: true } and only the tools change
reaches the stream.
A stdio e2e of the modern listen path is not yet feasible without harness
changes (the e2e stdio arms wire the standard child-process
StdioServerTransport, not the serveStdio entry); stdio narrowing is
covered at unit level in serveStdioListen.test.ts. Recorded in the
requirement note.
…stSignal through SSE reconnect; anySignal fallback removes sibling listener
- auto-open: forwarding connect()'s full RequestOptions into listen() bound a
connect-scoped signal (e.g. AbortSignal.timeout for the handshake) to the
subscription lifetime, silently tearing the auto-opened stream down when it
fired after connect resolved. Forward only the ack timeout.
- _handleSseStream: the two _scheduleReconnection call sites rebuilt
StartSSEOptions without requestSignal, so after one drop+reconnect of a
listen stream the per-request abort guard stopped working.
- anySignal fallback (Node 20.0-20.2): {once:true} alone leaked the sibling
listener on the transport-lifetime signal per _send() with a requestSignal.
Remove both listeners when either input fires.
80ba9a0 to
28ff64b
Compare
There was a problem hiding this comment.
Additional findings (outside current diff — PR may have been updated during review):
-
🟡
packages/client/src/client/streamableHttp.ts:610-630— When the per-requestrequestSignalis aborted while the listen POST fetch is still in flight (before response headers arrive — e.g. the ack timer firingwireTeardown(), orRequestOptions.signalaborted while opening), the fetch rejects with anAbortErrorthat lands in_send's unconditional catch (this.onerror?.(error); throw error;) and is reported to the consumer'sonerroras if it were a transport failure. This is the same misleading-onerror class the newisIntentionalAbort()guard suppresses for the post-headers stream phase — consider skipping theonerrorcall in_send's catch whenoptions?.requestSignal?.abortedis true, mirroring that treatment.Extended reasoning...
What the bug is. This PR adds the per-request abort signal and combines it into the fetch init via
anySignal(transportSignal, options.requestSignal)(packages/client/src/client/streamableHttp.ts:617-628). It also adds theisIntentionalAbort()guard so that a deliberate per-request abort reads as a clean shutdown — but that guard lives only in_handleSseStream/processStream, i.e. it only covers aborts that happen after the POST response body has started streaming._send's outer catch (streamableHttp.ts:764-767) is unconditional:this.onerror?.(error as Error); throw error;— it never consultsoptions?.requestSignal?.aborted. So an intentional, request-scoped abort that lands while the listen POST fetch is still awaiting response headers makesfetch()reject with anAbortErrorthat is surfaced to the consumer's transportonerror(and forwarded toProtocol.onerror) as if it were a transport failure.The code path that triggers it. Realistic triggers exist within this PR's own paths, against a server that is slow to (or never does) answer the listen POST:
- The ack timer in
Client.listen()fires:settle({ error: RequestTimeout })thenvoid wireTeardown()→requestAbort.abort()while the POST is still pending. RequestOptions.signalis aborted while the subscription is still'opening'— exactly the scenario the new'options.signal aborted while opening'unit test exercises (overInMemoryTransport; over real Streamable HTTP the abort would hit the in-flight fetch). Note the auto-open duringconnect()forwards connect'sRequestOptionsintolisten(), widening this surface.
(
McpSubscription.close()itself can't hit this window, since it requires the ack — and therefore a response — to have arrived.)Step-by-step proof.
- Client connects over Streamable HTTP to a 2026-07-28 server and calls
client.listen({ toolsListChanged: true }, { timeout: 1000 })._sendbuilds the combined signal from the transport signal and the per-requestrequestAbort.signaland awaitsfetch(). - The server stalls; no response headers arrive within the timeout. The ack timer fires:
settle()rejectslisten()with the typedRequestTimeouterror (correct), thenwireTeardown()callsrequestAbort.abort(). - The combined signal aborts; the pending
fetch()rejects withAbortError. Control lands in_send's catch at line 764–767. this.onerror?.(error)fires — the consumer sees a transport-level error report for an abort the SDK itself initiated as routine teardown. The rethrow rejectsparked.sent, but that is harmless:settle()already ran, so thesent.catchhandler inlisten()is a closed-state no-op.
Why existing code doesn't prevent it. The PR's own design statement (the
StartSSEOptions.requestSignalJSDoc and the_handleSseStreamcomment: an intentional per-request abort 'must read as a clean shutdown: no misleading onerror, and no … reconnect') is enforced only for the post-headers phase viaisIntentionalAbort(). The pre-response window — the fetch await inside_send— has no equivalent check, and the new unit test ('per-request requestSignal abort: no onerror, no reconnect') only aborts after the SSE body is already being processed, so the gap is uncovered.Impact. No functional damage:
listen()already rejected with the correct typed error, no state leaks, and theparked.sentrejection is absorbed. The damage is consumer-visible noise — a misleadingonerroron every slow-server abort path — which contradicts the PR's stated intent for intentional per-request aborts and can trip consumers who treatonerroras a signal to reconnect or alert.How to fix. A one-line guard in
_send's catch, mirroring theisIntentionalAbort()treatment in_handleSseStream: skip (or downgrade) thethis.onerror?.()call whenoptions?.requestSignal?.abortedis true, e.g.} catch (error) { if (!options?.requestSignal?.aborted) { this.onerror?.(error as Error); } throw error; }
The rethrow should stay so callers awaiting
send()still observe the abort. - The ack timer in
…t clears debounce timers McpSubscription gains `closed: Promise<'local' | 'remote'>` so the spec-defined termination signals are observable on the handle. settle() is the funnel and now carries the cause: close()/caller-abort resolve 'local'; server-cancel, parked stream end/error, transport close, and connection reset resolve 'remote'. settle()'s closed transition also aborts the per-request signal so an HTTP SSE reader stops on a remote-initiated close. Never rejects; resolves exactly once. _resetConnectionState() now clears _listChangedDebounceTimers — the timers are connection-scoped and a callback armed on a connection that is gone must not fire onto its replacement.
…ten-cancel; intentional-abort guards across reconnect path; typed result-during-opening
connect()'s auto-open now derives a one-shot AbortController bound to
connect's signal only for the listen() ack-wait duration (listener
removed in finally; already-aborted handled), so an aborted connect
rejects fast instead of blocking for the ack timeout, while the
auto-opened subscription still outlives connect's signal.
wireTeardown's notifications/cancelled now spreads
_outboundMetaEnvelope() into params._meta — the listen-path cancel was
the only modern outbound bypassing the auto-envelope.
streamableHttp: the intentional per-request abort guard now also covers
_send()'s catch (abort before headers), _startOrAuthSse()'s GET fetch
signal + catch, and _scheduleReconnection()'s timer-fire / catch — a
closed listen subscription on a flaky network can no longer be
resurrected as a GET stream nor surface a misleading onerror.
parked.terminated 'response' surfaces a typed SdkErrorCode.InvalidResult
('server answered subscriptions/listen with a result; expected the
acknowledged notification') instead of a generic stream-ended message.
…otocol park primitive
The listen() driver no longer registers in Protocol's _responseHandlers
via the bespoke _parkRequest() primitive. Instead it sends directly on
the transport with a STRING request id from a Client-owned counter
('listen:N' — JSON-RPC valid, spec subscriptionId is RequestId verbatim,
zero collision with Protocol's numeric counter), and demuxes the listen
id at the Client layer via three protected overrides:
- _onnotification: consumes a matching ack (resolves opening) and a
matching string-id notifications/cancelled (settles 'remote');
unmatched ack/cancelled pass through to super.
- _onresponse: a string-id response matching a live listen entry is the
server's pre-ack JSON-RPC error (settles with the typed ProtocolError)
or a buggy result (settles InvalidResult); never reaches Protocol's
numeric _responseHandlers map.
- _onclose: settles every live per-listen entry 'remote' before
Protocol._onclose tears the transport down (the prior implementation
got this via _responseHandlers settlement; the redesign no longer
registers there).
TransportSendOptions gains onRequestStreamEnd, fired by transports that
open a per-request stream (Streamable HTTP) when that stream ends or
errors for any non-deliberate reason; threaded through the SSE reconnect
path so a reconnected stream still carries it. stdio/InMemory ignore it.
protocol.ts: _parkRequest, _onParkedNotification, and their dispatch
wiring are gone; _onnotification/_onresponse/_onclose are now protected.
Net diff vs the integration base is three private→protected keyword
changes only.
The opening→open→closed state machine, McpSubscription surface,
ClientOptions.listChanged auto-open, and the per-request requestSignal
mechanism are unchanged; this is internal wiring. Also folds in the bot
finding that _setupListChangedHandlers in the modern connect path was
outside the soft-fail guard (now surfaces via onerror, connect resolves).
0e5890a to
ebf896d
Compare
…pen ack wait; drop old-layout listen examples (live in #2325); e2e CLAUDE.md doc nit
…terminal; skip auto-open when handler registration throws; await transport close on connect-abort during the auto-open ack wait; document the protected-override contract
There was a problem hiding this comment.
Latest commits address all of my prior findings (the connect-abort cleanup now awaits close(), registration failures skip the auto-open, and the 405/null-body resume terminals fire onRequestStreamEnd), and this round surfaced no new issues — but this PR adds a substantial new public API surface (Client.listen(), McpSubscription, protocol _on* overrides, per-request transport abort), so it warrants a maintainer's design review rather than auto-approval.
Extended reasoning...
Overview
This PR adds the client-side subscriptions/listen driver for protocol revision 2026-07-28: a new public Client.listen() method with an explicit opening→open→closed state machine, the McpSubscription handle (honoredFilter, close(), closed), ClientOptions.listChanged auto-open on modern connections, protected _onnotification/_onresponse/_onclose overrides in Protocol, and per-request requestSignal/onRequestStreamEnd plumbing in the Streamable HTTP transport. It touches packages/core (protocol/transport types), packages/client (client + HTTP transport), docs, a changeset, and adds extensive unit and e2e coverage.
Security risks
No direct security-sensitive surface (no auth, crypto, or permission logic). The main risk class is lifecycle/state correctness: half-open transports on failed connects, stale subscription handles, and unhandled rejections — all of which were the subject of prior review rounds and now appear addressed in the latest commits.
Level of scrutiny
High. This is a new public API on the SDK's primary client class plus changes to the shared Protocol base (private→protected dispatch hooks) and the Streamable HTTP transport's reconnect/abort behavior. Per the repo's review conventions, new API surface and protocol-adjacent behavior carry the burden of design justification, which is a maintainer call — not something a bot should shadow-approve regardless of bug count.
Other factors
The bug-hunting system found no new issues in this round, and the most recent commits (b142b80, be1694d) resolve the previously flagged items: the connect-signal-abort branch now awaits close() with a catch before rethrowing, a listChanged registration failure skips the auto-open instead of opening an unhandled stream, and the 405/null-body resume terminals now fire onRequestStreamEnd so a resumed listen subscription cannot dead-end silently. Test coverage is thorough (state-machine edges, fake-timer ack timeout, transport-level stream-end, e2e cells through createMcpHandler). Remaining open questions are design-level (e.g. whether the era-steer vs. transparent-shim posture and the auto-open semantics match cross-SDK direction), which is exactly what human review should weigh in on.
Adds the client-side
subscriptions/listendriver (protocol revision2026-07-28). Stacked on the entry-handled server PR.
Motivation and Context
On a 2026-07-28 connection change notifications arrive only on a
subscriptions/listen stream; this gives consumers the same listChanged
experience they have on 2025-era connections.
How Has This Been Tested?
Unit tests cover ack-resolved-promise, dispatch to existing setNotificationHandler
registrations, the legacy-era steer, stdio close (sends notifications/cancelled),
inbound server-side cancel, and listChanged auto-open. E2E cells cover the same
surface. The example pair runs both auto-open and manual listen() end-to-end.
Breaking Changes
None. ClientOptions.listChanged keeps working on both eras.
Types of changes
Checklist
Additional context
client.listen(filter)resolves once acknowledged withMcpSubscription { honoredFilter, close() }. Change notifications dispatchto the existing setNotificationHandler registrations.
(filter derived from which sub-options were set; exposed at
client.autoOpenedSubscription).TransportSendOptions.requestSignal); stdio sends notifications/cancelled.