Skip to content

feat(client): Client.listen() and listChanged auto-open on modern connections#2322

Merged
felixweinberger merged 27 commits into
v2-2026-07-28from
fweinberger/listen-client
Jun 19, 2026
Merged

feat(client): Client.listen() and listChanged auto-open on modern connections#2322
felixweinberger merged 27 commits into
v2-2026-07-28from
fweinberger/listen-client

Conversation

@felixweinberger

Copy link
Copy Markdown
Contributor

Adds the client-side subscriptions/listen driver (protocol revision
2026-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

  • New feature (non-breaking change which adds functionality)

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

  • client.listen(filter) resolves once acknowledged with
    McpSubscription { honoredFilter, close() }. Change notifications dispatch
    to the existing setNotificationHandler registrations.
  • ClientOptions.listChanged auto-opens a listen stream on a modern connection
    (filter derived from which sub-options were set; exposed at
    client.autoOpenedSubscription).
  • close(): Streamable HTTP closes the listen request's SSE stream (per-request
    TransportSendOptions.requestSignal); stdio sends notifications/cancelled.
  • On a 2025-era connection listen() throws a typed steer to resources/subscribe
    • listChanged (no transparent shim).

@felixweinberger felixweinberger requested a review from a team as a code owner June 18, 2026 15:26
@changeset-bot

changeset-bot Bot commented Jun 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: be1694d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@modelcontextprotocol/core Minor
@modelcontextprotocol/client Minor

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

@pkg-pr-new

pkg-pr-new Bot commented Jun 18, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@2322

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/codemod@2322

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@2322

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server-legacy@2322

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@2322

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/fastify@2322

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@2322

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@2322

commit: be1694d

Comment thread packages/client/src/client/client.ts Outdated
Comment thread packages/client/src/client/client.ts
Comment thread packages/server/src/server/serverEventBus.ts
@felixweinberger felixweinberger force-pushed the fweinberger/listen-client branch 2 times, most recently from 6a8a41d to d3b1240 Compare June 18, 2026 16:10
Comment thread packages/client/src/client/client.ts
Comment thread packages/client/src/client/client.ts Outdated
Comment thread packages/client/src/client/client.ts Outdated
@felixweinberger felixweinberger force-pushed the fweinberger/listen-client branch from d3b1240 to 4352068 Compare June 18, 2026 16:25
@felixweinberger felixweinberger force-pushed the fweinberger/listen-server branch from 8760ad9 to 3832503 Compare June 18, 2026 16:25

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(), the close() closure (packages/client/src/client/client.ts:1170-1187) chooses between two teardown strategies based on transportKind = detectProbeTransportKind(this.transport). That helper (packages/client/src/client/versionNegotiation.ts:147-149) classifies a transport as 'stdio' only when the object has both stderr and pid properties — i.e. the child-process StdioClientTransport — and classifies everything else as 'http'. In the 'http' branch, close() only calls requestAbort.abort(). But TransportSendOptions.requestSignal is honored solely by StreamableHTTPClientTransport (the new JSDoc in transport.ts added by this PR explicitly says single-channel transports — stdio, in-memory — ignore it, and InMemoryTransport.send never reads it).\n\nThe code path that triggers it. Connect with any modern-era-capable transport that is neither StreamableHTTPClientTransport nor a child-process stdio transport: InMemoryTransport (the SDK's publicly exported in-process pattern), SSEClientTransport, or a custom Transport implementation — e.g. the client side of a Unix-socket/TCP connection to a serveStdio server, a setup the migration guide explicitly invites ("implement the Transport interface directly", serveStdio's transport option). listen() works fine on such a connection, but detectProbeTransportKind returns 'http' because the transport has no stderr/pid, so close() aborts an AbortController nothing is listening to.\n\nWhy nothing else prevents it. Aborting requestAbort has no effect on a transport that ignores requestSignal; parked.unpark() only removes the local response handler; and _listenFirstLook keeps passing change notifications through to the registered setNotificationHandler registrations (the _listenState entry only gates ack/cancel matching, not change-notification dispatch). The PR's own unit test tacitly acknowledges the gap: the stdio-close case in listen.test.ts has to Object.assign(clientTx, { stderr: null, pid: 1 }) onto InMemoryTransport to make close() do anything at all — a plain InMemoryTransport falls 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: no notifications/cancelled is sent, no stream is torn down, the server-side subscription slot stays consumed (it counts against maxSubscriptions), 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 with createMcpHandler (or serveStdio over a socket) and connect a Client with versionNegotiation: { mode: 'auto' } over InMemoryTransport.createLinkedPair() (or any custom Transport). 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()detectProbeTransportKind sees no stderr/pid'http' branch → requestAbort.abort(). InMemoryTransport.send never 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 emits notifications/tools/list_changed for it, the notification arrives at the client, _listenFirstLook passes it through, and the registered handler fires — after "close".\n5. The slot is never released, so with maxSubscriptions reached (the e2e suite uses 1), subsequent listen() 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 honor requestSignal (a transport capability/marker), or (b) make notifications/cancelled the default teardown for any transport not known to honor per-request abort — the server-side listen routers already handle notifications/cancelled on 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.

Comment thread packages/client/src/client/streamableHttp.ts
Comment thread packages/client/src/client/streamableHttp.ts
@felixweinberger felixweinberger force-pushed the fweinberger/listen-server branch from 3832503 to a7fcd76 Compare June 18, 2026 16:48
@felixweinberger felixweinberger force-pushed the fweinberger/listen-client branch from 4352068 to 44d0772 Compare June 18, 2026 16:48

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 standard RequestOptions type, but the implementation reads only options?.timeout (used as the ack timeout). RequestOptions.signal is documented in protocol.ts as "Can be used to cancel an in-flight request. This will cause an AbortError to be raised from request()", and every other Client method that takes RequestOptions (the request()-based ones) honors it. In listen() the only AbortController in play is the internally created requestAbort passed to _parkRequest as { requestSignal: requestAbort.signal } — the caller's options.signal is never consulted: no abort listener is attached, nothing calls close(), 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 (onAckresolve), a pre-ack JSON-RPC error (terminated with reason 'error'reject), a failed send (sent.catchreject), and the ack timer (default DEFAULT_REQUEST_TIMEOUT_MSEC, 60s → reject with RequestTimeout). 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 calls const 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 calls ac.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, the requestAbort controller is untouched (so the SSE POST stays open on Streamable HTTP), the ack timer keeps running, and p stays pending.\n4. Up to 60 seconds later (default), the timer fires and p finally rejects with SdkErrorCode.RequestTimeout — not the abort reason — or, if the ack happens to arrive first, p resolves 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. _connectNegotiated forwards connect()'s options object verbatim into this.listen(filter, options) for the ClientOptions.listChanged auto-open. So a consumer who passes an AbortSignal to connect() 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 other RequestOptions members (onprogress, resetTimeoutOnProgress, maxTotalTimeout, relatedRequestId) genuinely don't apply to a parked listen request, so ignoring those is defensible. signal is the substantive gap.\n\nHow to fix. Either (a) wire options.signal into the executor: if already aborted, reject immediately; otherwise add an abort listener that does clearTimeout(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 like Pick<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 forwards connect()'s options here.

Comment thread packages/client/src/client/client.ts Outdated
@felixweinberger felixweinberger force-pushed the fweinberger/listen-client branch from 44d0772 to bd5b1ba Compare June 18, 2026 17:08
Comment thread packages/client/src/client/client.ts Outdated
Comment thread packages/client/src/client/client.ts
Comment thread .changeset/subscriptions-listen-client.md Outdated
@felixweinberger felixweinberger force-pushed the fweinberger/listen-client branch from bd5b1ba to dcd5b57 Compare June 18, 2026 17:15
@felixweinberger felixweinberger force-pushed the fweinberger/listen-server branch from e324db7 to f306029 Compare June 18, 2026 17:16
@felixweinberger felixweinberger force-pushed the fweinberger/listen-client branch 2 times, most recently from c646a1a to f42fc48 Compare June 18, 2026 17:21
@felixweinberger

Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/client/src/client/client.ts Outdated
Comment thread packages/client/src/client/client.ts
@felixweinberger felixweinberger force-pushed the fweinberger/listen-client branch from f42fc48 to 4d9d0f9 Compare June 18, 2026 17:37
Comment thread packages/client/src/client/client.ts
Comment thread packages/client/src/client/client.ts Outdated
Comment thread packages/client/src/client/streamableHttp.ts
Comment thread packages/client/src/client/streamableHttp.ts
@felixweinberger felixweinberger force-pushed the fweinberger/listen-client branch from 435af52 to 80ba9a0 Compare June 18, 2026 18:24
Base automatically changed from fweinberger/listen-server to v2-2026-07-28 June 18, 2026 18:36
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.
… 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.
@felixweinberger felixweinberger force-pushed the fweinberger/listen-client branch from 80ba9a0 to 28ff64b Compare June 18, 2026 18:41

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 packages/client/src/client/streamableHttp.ts:610-630 — When the per-request requestSignal is aborted while the listen POST fetch is still in flight (before response headers arrive — e.g. the ack timer firing wireTeardown(), or RequestOptions.signal aborted while opening), the fetch rejects with an AbortError that lands in _send's unconditional catch (this.onerror?.(error); throw error;) and is reported to the consumer's onerror as if it were a transport failure. This is the same misleading-onerror class the new isIntentionalAbort() guard suppresses for the post-headers stream phase — consider skipping the onerror call in _send's catch when options?.requestSignal?.aborted is 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 the isIntentionalAbort() 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 consults options?.requestSignal?.aborted. So an intentional, request-scoped abort that lands while the listen POST fetch is still awaiting response headers makes fetch() reject with an AbortError that is surfaced to the consumer's transport onerror (and forwarded to Protocol.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:

    1. The ack timer in Client.listen() fires: settle({ error: RequestTimeout }) then void wireTeardown()requestAbort.abort() while the POST is still pending.
    2. RequestOptions.signal is aborted while the subscription is still 'opening' — exactly the scenario the new 'options.signal aborted while opening' unit test exercises (over InMemoryTransport; over real Streamable HTTP the abort would hit the in-flight fetch). Note the auto-open during connect() forwards connect's RequestOptions into listen(), 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.

    1. Client connects over Streamable HTTP to a 2026-07-28 server and calls client.listen({ toolsListChanged: true }, { timeout: 1000 }). _send builds the combined signal from the transport signal and the per-request requestAbort.signal and awaits fetch().
    2. The server stalls; no response headers arrive within the timeout. The ack timer fires: settle() rejects listen() with the typed RequestTimeout error (correct), then wireTeardown() calls requestAbort.abort().
    3. The combined signal aborts; the pending fetch() rejects with AbortError. Control lands in _send's catch at line 764–767.
    4. this.onerror?.(error) fires — the consumer sees a transport-level error report for an abort the SDK itself initiated as routine teardown. The rethrow rejects parked.sent, but that is harmless: settle() already ran, so the sent.catch handler in listen() is a closed-state no-op.

    Why existing code doesn't prevent it. The PR's own design statement (the StartSSEOptions.requestSignal JSDoc and the _handleSseStream comment: 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 via isIntentionalAbort(). 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 the parked.sent rejection is absorbed. The damage is consumer-visible noise — a misleading onerror on every slow-server abort path — which contradicts the PR's stated intent for intentional per-request aborts and can trip consumers who treat onerror as a signal to reconnect or alert.

    How to fix. A one-line guard in _send's catch, mirroring the isIntentionalAbort() treatment in _handleSseStream: skip (or downgrade) the this.onerror?.() call when options?.requestSignal?.aborted is 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.

Comment thread packages/client/src/client/client.ts Outdated
Comment thread packages/client/src/client/client.ts
…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.
Comment thread packages/client/src/client/client.ts Outdated
Comment thread packages/client/src/client/client.ts
Comment thread packages/client/src/client/streamableHttp.ts
Comment thread packages/client/src/client/client.ts Outdated
…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.
Comment thread packages/client/src/client/client.ts
Comment thread packages/client/src/client/client.ts
Comment thread examples/client/src/subscriptionsListenClient.ts Outdated
Comment thread packages/client/src/client/client.ts
Comment thread test/e2e/requirements.ts
…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).
@felixweinberger felixweinberger force-pushed the fweinberger/listen-client branch from 0e5890a to ebf896d Compare June 18, 2026 20:26
…pen ack wait; drop old-layout listen examples (live in #2325); e2e CLAUDE.md doc nit
Comment thread packages/client/src/client/streamableHttp.ts
Comment thread packages/client/src/client/client.ts
Comment thread packages/client/src/client/client.ts
…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

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@felixweinberger felixweinberger merged commit 699d6a6 into v2-2026-07-28 Jun 19, 2026
16 checks passed
@felixweinberger felixweinberger deleted the fweinberger/listen-client branch June 19, 2026 09:57
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