From 586de5024d9d631b36c26488a3abfe4c948e87a1 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 14:13:00 +0000 Subject: [PATCH 01/27] feat(core): in-Protocol park primitive for the listen driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/core/src/shared/protocol.ts | 110 +++++++++++++++++- packages/core/src/types/types.ts | 2 +- .../core/src/wire/rev2026-07-28/registry.ts | 4 +- 3 files changed, 108 insertions(+), 8 deletions(-) diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 873aa6f92a..d04f82b63b 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -609,12 +609,14 @@ export abstract class Protocol { * resolve. The base default surfaces it as a typed * {@linkcode SdkErrorCode.UnsupportedResultType} error (no retry). * - * Intended consumers (named so the seam stays accountable): - * - the `Client`'s multi-round-trip auto-fulfilment engine, which fulfils - * `'input_required'` results through the registered - * elicitation/sampling/roots handlers and retries via `flow.retry`; - * - a future client-side terminal-result handler for - * `subscriptions/listen`, when the spec defines one. + * Intended consumer (named so the seam stays accountable): the `Client`'s + * multi-round-trip auto-fulfilment engine, which fulfils + * `'input_required'` results through the registered + * elicitation/sampling/roots handlers and retries via `flow.retry`. + * + * `subscriptions/listen` does NOT use this seam — it never receives a + * JSON-RPC result; the listen driver uses {@linkcode _parkRequest} and + * {@linkcode _onParkedNotification} instead. * * `Server` instances never receive `input_required` responses on their * outbound legs and leave the base behavior in place. @@ -641,6 +643,97 @@ export abstract class Protocol { return this._requestHandlers.get(method); } + /** + * First-look hook for inbound notifications, consulted before any + * decoding, era gating, or handler dispatch. When set and returning + * `'consumed'`, the notification goes no further; when unset or returning + * `undefined`, dispatch proceeds unchanged. + * + * Intended consumer (named so the seam stays accountable): the + * `Client`-side `subscriptions/listen` driver, which routes the leading + * `notifications/subscriptions/acknowledged` (and the inbound + * `notifications/cancelled` that terminates a parked listen on stdio) to + * the per-subscription state rather than the generic notification map. + * The hook receives the raw wire shape — `_meta` (including the + * subscription-id key) is intact. + * + * Inert when unset. Do NOT widen this into a general-purpose notification + * tap; anything beyond the listen driver belongs in + * {@linkcode setNotificationHandler}. + */ + protected _onParkedNotification?: (raw: JSONRPCNotification) => 'consumed' | undefined; + + /** + * Low-level no-timeout request primitive: sends a JSON-RPC request and + * registers a response handler WITHOUT arming `_setupTimeout`. The + * registration stays in `_responseHandlers` until either a JSON-RPC + * response/error arrives for the id, the transport closes, or the caller + * invokes the returned `unpark()` — whichever comes first. + * + * Intended consumer (named so the seam stays accountable): the + * `Client.listen()` driver. `subscriptions/listen` is a long-lived + * request that the spec defines as never receiving a JSON-RPC result — + * termination is stream close (HTTP) or an inbound + * `notifications/cancelled` (stdio). The standard `request()` funnel is + * deliberately untouched; this primitive does not pass through + * `_resolveNonCompleteResult`, `decodeResult`, or any per-request timeout. + * + * Returns the allocated message id (the spec's subscription id is this id + * verbatim) and an `unpark()` that idempotently removes the registration. + * `terminated` resolves when the registration is consumed by a response, + * error, or transport close; it never rejects. + */ + protected _parkRequest( + request: { method: string; params?: { [key: string]: unknown; _meta?: { [key: string]: unknown } } }, + options?: TransportSendOptions, + /** + * Called synchronously after the message id is allocated and the + * response handler is registered, BEFORE the request is sent — so + * the caller can register per-id state without racing a + * synchronously-delivered response on an in-process transport. + */ + onBeforeSend?: (messageId: number) => void + ): { + messageId: number; + sent: Promise; + terminated: Promise<{ reason: 'response' | 'error' | 'unparked'; error?: Error }>; + unpark: () => void; + } { + if (!this._transport) { + throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); + } + const messageId = this._requestMessageId++; + const jsonrpcRequest: JSONRPCRequest = { ...request, jsonrpc: '2.0', id: messageId }; + let settle!: (value: { reason: 'response' | 'error' | 'unparked'; error?: Error }) => void; + const terminated = new Promise<{ reason: 'response' | 'error' | 'unparked'; error?: Error }>(resolve => { + settle = resolve; + }); + let live = true; + this._responseHandlers.set(messageId, response => { + if (!live) return; + live = false; + settle(response instanceof Error ? { reason: 'error', error: response } : { reason: 'response' }); + }); + const unpark = () => { + if (!live) return; + live = false; + this._responseHandlers.delete(messageId); + settle({ reason: 'unparked' }); + }; + onBeforeSend?.(messageId); + let sent: Promise; + try { + sent = this._transport.send(jsonrpcRequest, options); + } catch (error) { + // Unregister before rethrowing so a synchronous send failure does + // not leak a permanent _responseHandlers entry for an id that + // never went out on the wire. + unpark(); + throw error; + } + return { messageId, sent, terminated, unpark }; + } + private async _oncancel(notification: CancelledNotification): Promise { if (!notification.params.requestId) { return; @@ -771,6 +864,11 @@ export abstract class Protocol { } private _onnotification(rawNotification: JSONRPCNotification, extra?: MessageExtraInfo): void { + // First-look hook (inert when unset): the `subscriptions/listen` + // driver gets first refusal on the raw notification — `_meta` intact. + if (this._onParkedNotification?.(rawNotification) === 'consumed') { + return; + } // Hide wire-only material from notification handlers too — but ONLY // the reserved envelope `_meta` keys (the retry params names are // reserved on requests, not notifications). There is no diff --git a/packages/core/src/types/types.ts b/packages/core/src/types/types.ts index 3d4bd9940e..4f072fd748 100644 --- a/packages/core/src/types/types.ts +++ b/packages/core/src/types/types.ts @@ -566,7 +566,7 @@ export type ResultTypeMap = { // `subscriptions/listen` never receives a JSON-RPC result on the wire: // termination is stream close (HTTP) or `notifications/cancelled` (stdio). // The `EmptyResult` entry exists only to keep the mapped types total — - // see the serving entries' listen routers. + // see `Client.listen()` and the serving entries' listen routers. 'subscriptions/listen': EmptyResult; 'tools/call': CallToolResult; 'tools/list': ListToolsResult; diff --git a/packages/core/src/wire/rev2026-07-28/registry.ts b/packages/core/src/wire/rev2026-07-28/registry.ts index 49df49429f..4abe136fe8 100644 --- a/packages/core/src/wire/rev2026-07-28/registry.ts +++ b/packages/core/src/wire/rev2026-07-28/registry.ts @@ -23,7 +23,9 @@ * (SEP-1865): 2026-only vocabulary, present here as registry shells. * Dispatch never reaches a registered handler — the serving entries * (`createMcpHandler`, `serveStdio`) recognize listen at the entry layer - * and own ack/filter/stamp/teardown themselves. + * and own ack/filter/stamp/teardown themselves; on the client side + * `Client.listen()` sends via the in-Protocol park primitive rather than + * `request()`. */ import type * as z from 'zod/v4'; From 5e65543b834eb45d7c0772613ee3e2615ea72f7b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 14:15:47 +0000 Subject: [PATCH 02/27] feat(client): Client.listen() + McpSubscription + listChanged auto-open on modern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .changeset/subscriptions-listen-client.md | 6 + packages/client/src/client/client.ts | 206 ++++++++++++++++++- packages/client/src/client/streamableHttp.ts | 15 +- packages/client/src/index.ts | 2 +- packages/client/test/client/listen.test.ts | 181 ++++++++++++++++ packages/core/src/shared/transport.ts | 10 + 6 files changed, 413 insertions(+), 7 deletions(-) create mode 100644 .changeset/subscriptions-listen-client.md create mode 100644 packages/client/test/client/listen.test.ts diff --git a/.changeset/subscriptions-listen-client.md b/.changeset/subscriptions-listen-client.md new file mode 100644 index 0000000000..29555a9943 --- /dev/null +++ b/.changeset/subscriptions-listen-client.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +--- + +`Client.listen(filter)` opens a `subscriptions/listen` stream on a 2026-07-28-era connection, resolving once the server's acknowledged notification arrives with an `McpSubscription { honoredFilter, close() }`. Change notifications delivered on the stream dispatch to the existing `setNotificationHandler` registrations — the same handlers the 2025-era unsolicited notifications fire on a legacy connection — so `listen()` is 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` steering to `resources/subscribe` and `ClientOptions.listChanged`. `ClientOptions.listChanged` now auto-opens a listen stream on a modern connection (filter derived from which sub-options were set; the auto-opened subscription is exposed at `client.autoOpenedSubscription`). `TransportSendOptions` gains `requestSignal` for per-request abort on the Streamable HTTP transport. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 4eef2f2ec8..033c243d57 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -45,6 +45,7 @@ import type { ServerCapabilities, StandardSchemaV1, SubscribeRequest, + SubscriptionFilter, Tool, Transport, UnsubscribeRequest @@ -63,6 +64,7 @@ import { ListChangedOptionsBaseSchema, mergeCapabilities, parseSchema, + PROTOCOL_VERSION_META_KEY, Protocol, PROTOCOL_VERSION_META_KEY, ProtocolError, @@ -70,7 +72,9 @@ import { resolveInputRequiredDriverConfig, runInputRequiredFlow, SdkError, - SdkErrorCode + SdkErrorCode, + SUBSCRIPTION_ID_META_KEY, + SubscriptionFilterSchema } from '@modelcontextprotocol/core'; import type { ResolvedVersionNegotiation, VersionNegotiationOptions } from './versionNegotiation.js'; @@ -252,6 +256,31 @@ export type ClientOptions = ProtocolOptions & { listChanged?: ListChangedHandlers; }; +/** + * A handle to an open `subscriptions/listen` stream (protocol revision + * 2026-07-28). Change notifications delivered on the stream dispatch to the + * existing {@linkcode Client.setNotificationHandler} registrations. + */ +export interface McpSubscription { + /** + * The subset of the requested filter the server agreed to honor (from + * `notifications/subscriptions/acknowledged`). + */ + readonly honoredFilter: SubscriptionFilter; + /** + * Tears the subscription down. Idempotent. On Streamable HTTP this closes + * the listen request's SSE stream; on stdio it sends + * `notifications/cancelled` referencing the listen request id. + */ + close(): Promise; +} + +/** @internal */ +interface ListenStateEntry { + onAck: ((honored: SubscriptionFilter) => void) | undefined; + onServerCancel: () => void; +} + /** * An MCP client on top of a pluggable transport. * @@ -299,6 +328,10 @@ export class Client extends Protocol { private _versionNegotiation?: VersionNegotiationOptions; private _supportedProtocolVersionsOption?: string[]; private _inputRequiredDriverConfig: ResolvedInputRequiredDriverConfig; + /** Active subscriptions/listen state, keyed by subscription id (= the listen request's JSON-RPC id). */ + private _listenState = new Map(); + /** The auto-opened subscription backing ClientOptions.listChanged on a modern connection. */ + private _autoOpenedSubscription?: McpSubscription; /** * Initializes this client with the given name and version information. @@ -802,10 +835,23 @@ export class Client extends Protocol { transport.setProtocolVersion(result.version); } // The modern era has no notifications/initialized; list-changed handlers - // are configured straight from the advertised capabilities. + // are configured straight from the advertised capabilities. On a modern + // connection the configured handlers are fed by an auto-opened + // subscriptions/listen stream (the modern era never delivers change + // notifications unsolicited); on a legacy connection they fire on the + // 2025-era unsolicited notifications, no listen needed. if (this._pendingListChangedConfig) { - this._setupListChangedHandlers(this._pendingListChangedConfig); + const config = this._pendingListChangedConfig; + this._setupListChangedHandlers(config); this._pendingListChangedConfig = undefined; + const filter: SubscriptionFilter = { + ...(config.tools && { toolsListChanged: true as const }), + ...(config.prompts && { promptsListChanged: true as const }), + ...(config.resources && { resourcesListChanged: true as const }) + }; + if (Object.keys(filter).length > 0) { + this._autoOpenedSubscription = await this.listen(filter, options); + } } } @@ -1134,6 +1180,160 @@ export class Client extends Protocol { return this.request({ method: 'resources/unsubscribe', params }, options); } + /** + * Opens a `subscriptions/listen` stream (protocol revision 2026-07-28). + * + * Resolves once the server's `notifications/subscriptions/acknowledged` + * arrives (the standard request timeout applies to this ack phase). Change + * notifications delivered on the stream are dispatched to the existing + * {@linkcode setNotificationHandler} registrations — the same handlers the + * 2025-era unsolicited notifications fire on a legacy connection — so + * `listen()` is era-transparent for consumers that already register those. + * + * `close()` tears the subscription down: on Streamable HTTP it closes the + * listen request's SSE stream; on stdio it sends `notifications/cancelled` + * referencing the listen request id. No automatic re-listen — call + * `listen()` again to re-establish. + * + * On a 2025-era connection this throws a typed + * {@linkcode SdkErrorCode.MethodNotSupportedByProtocolVersion} steering to + * `resources/subscribe` and `ClientOptions.listChanged` (the legacy + * unsolicited delivery model still applies there); no transparent shim. + */ + async listen(filter: SubscriptionFilter, options?: RequestOptions): Promise { + const negotiated = this._negotiatedProtocolVersion; + if (negotiated === undefined || !isModernProtocolVersion(negotiated)) { + throw new SdkError( + SdkErrorCode.MethodNotSupportedByProtocolVersion, + `subscriptions/listen requires a 2026-07-28-era connection (negotiated: ${negotiated ?? 'none'}). ` + + 'On a 2025-era connection, change notifications are delivered unsolicited: use ClientOptions.listChanged ' + + 'and resources/subscribe instead.', + { method: 'subscriptions/listen', protocolVersion: negotiated } + ); + } + + if (this._onParkedNotification === undefined) { + this._onParkedNotification = raw => this._listenFirstLook(raw); + } + + const requestAbort = new AbortController(); + const transportKind = this.transport !== undefined ? detectProbeTransportKind(this.transport) : 'http'; + + let closed = false; + let parked!: ReturnType; + const close = async (): Promise => { + if (closed) return; + closed = true; + this._listenState.delete(parked.messageId); + // Per-transport teardown: HTTP closes the request's SSE stream; + // stdio sends notifications/cancelled referencing the listen id. + if (transportKind === 'stdio') { + await this.transport + ?.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: parked.messageId } }) + .catch(() => {}); + } else { + requestAbort.abort(); + } + parked.unpark(); + }; + + const honored = await new Promise((resolve, reject) => { + const ackTimeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; + const timer = setTimeout(() => { + void close(); + reject(new SdkError(SdkErrorCode.RequestTimeout, 'subscriptions/listen ack timed out', { timeout: ackTimeout })); + }, ackTimeout); + // The per-subscription state is registered BEFORE the request is + // sent (`onBeforeSend`) so a synchronously-delivered ack (an + // in-process transport) cannot race the registration. + parked = this._parkRequest( + { + method: 'subscriptions/listen', + params: { + _meta: { + [PROTOCOL_VERSION_META_KEY]: negotiated, + [CLIENT_INFO_META_KEY]: this._clientInfo, + [CLIENT_CAPABILITIES_META_KEY]: this._capabilities + }, + notifications: filter + } + }, + { requestSignal: requestAbort.signal }, + messageId => { + this._listenState.set(messageId, { + onAck: honored => { + clearTimeout(timer); + resolve(honored); + }, + onServerCancel: () => void close() + }); + } + ); + // Pre-ack capacity / params rejection arrives as a JSON-RPC error + // for the listen id — surfaced via terminated. + void parked.terminated.then(({ reason, error }) => { + if (reason === 'error') { + clearTimeout(timer); + this._listenState.delete(parked.messageId); + closed = true; + reject(error ?? new Error('subscriptions/listen failed')); + } + }); + parked.sent.catch(err => { + clearTimeout(timer); + void close(); + reject(err instanceof Error ? err : new Error(String(err))); + }); + }); + + return { honoredFilter: honored, close }; + } + + /** + * The subscription auto-opened by `ClientOptions.listChanged` on a modern + * connection (the listen filter derived from which sub-options were set), + * or `undefined` on a legacy connection or before connect. Exposed so the + * consumer can `close()` it. + */ + get autoOpenedSubscription(): McpSubscription | undefined { + return this._autoOpenedSubscription; + } + + /** + * The first-look notification hook installed by `listen()`. Consumes the + * leading `notifications/subscriptions/acknowledged` (resolves the ack + * waiter) and an inbound `notifications/cancelled` referencing a parked + * listen id (stdio server-side teardown). Change notifications carrying a + * subscription id pass through to the existing registered handlers. + */ + private _listenFirstLook(raw: JSONRPCNotification): 'consumed' | undefined { + if (raw.method === 'notifications/subscriptions/acknowledged') { + const params = raw.params as { _meta?: Record; notifications?: unknown } | undefined; + const subscriptionId = params?._meta?.[SUBSCRIPTION_ID_META_KEY]; + // Tolerant read: subscription id may be string or number; match by + // String() coercion against this connection's parked listen ids. + for (const [id, entry] of this._listenState) { + if (String(id) === String(subscriptionId) && entry.onAck !== undefined) { + const honored = SubscriptionFilterSchema.safeParse(params?.notifications ?? {}); + entry.onAck(honored.success ? honored.data : {}); + entry.onAck = undefined; + return 'consumed'; + } + } + return 'consumed'; + } + if (raw.method === 'notifications/cancelled') { + const cancelledId = (raw.params as { requestId?: unknown } | undefined)?.requestId; + for (const [id, entry] of this._listenState) { + if (String(id) === String(cancelledId)) { + entry.onServerCancel(); + return 'consumed'; + } + } + } + return undefined; + } + /** * Calls a tool on the connected server and returns the result. Automatically validates structured output * if the tool has an `outputSchema`. diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 5dea9a7cc5..d0c98c4615 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -541,14 +541,14 @@ export class StreamableHTTPClientTransport implements Transport { async send( message: JSONRPCMessage | JSONRPCMessage[], - options?: { resumptionToken?: string; onresumptiontoken?: (token: string) => void } + options?: { resumptionToken?: string; onresumptiontoken?: (token: string) => void; requestSignal?: AbortSignal } ): Promise { return this._send(message, options, false); } private async _send( message: JSONRPCMessage | JSONRPCMessage[], - options: { resumptionToken?: string; onresumptiontoken?: (token: string) => void } | undefined, + options: { resumptionToken?: string; onresumptiontoken?: (token: string) => void; requestSignal?: AbortSignal } | undefined, isAuthRetry: boolean ): Promise { try { @@ -569,12 +569,21 @@ export class StreamableHTTPClientTransport implements Transport { const types = [...(userAccept?.split(',').map(s => s.trim().toLowerCase()) ?? []), 'application/json', 'text/event-stream']; headers.set('accept', [...new Set(types)].join(', ')); + // Per-request abort: when the caller supplies a request-scoped + // signal (the `subscriptions/listen` driver), aborting it cancels + // this POST and its SSE response stream without closing the + // transport. AbortSignal.any is the standard combinator. + const transportSignal = this._abortController?.signal; + const signal = + options?.requestSignal !== undefined && transportSignal !== undefined + ? AbortSignal.any([transportSignal, options.requestSignal]) + : (options?.requestSignal ?? transportSignal); const init = { ...this._requestInit, method: 'POST', headers, body: JSON.stringify(message), - signal: this._abortController?.signal + signal }; const response = await (this._fetch ?? fetch)(this._url, init); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 678bb4d45d..7fe7acb958 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -52,7 +52,7 @@ export { PrivateKeyJwtProvider, StaticPrivateKeyJwtProvider } from './client/authExtensions.js'; -export type { ClientOptions } from './client/client.js'; +export type { ClientOptions, McpSubscription } from './client/client.js'; export { Client } from './client/client.js'; export { getSupportedElicitationModes } from './client/client.js'; export type { DiscoverAndRequestJwtAuthGrantOptions, JwtAuthGrantResult, RequestJwtAuthGrantOptions } from './client/crossAppAccess.js'; diff --git a/packages/client/test/client/listen.test.ts b/packages/client/test/client/listen.test.ts new file mode 100644 index 0000000000..865ffacf9d --- /dev/null +++ b/packages/client/test/client/listen.test.ts @@ -0,0 +1,181 @@ +/** + * `Client.listen()` — the `subscriptions/listen` driver (protocol revision + * 2026-07-28). Covers ack-resolved-promise, change-notification dispatch to + * existing setNotificationHandler registrations, the F-12 legacy-era steer, + * stdio-style close (sends notifications/cancelled), inbound server-side + * cancel, and ClientOptions.listChanged auto-open on a modern connection. + */ +import type { JSONRPCMessage, JSONRPCNotification } from '@modelcontextprotocol/core'; +import { InMemoryTransport, LATEST_PROTOCOL_VERSION, SdkError, SdkErrorCode, SUBSCRIPTION_ID_META_KEY } from '@modelcontextprotocol/core'; +import { describe, expect, it } from 'vitest'; + +import { Client } from '../../src/client/client.js'; + +const MODERN = '2026-07-28'; +const flush = () => new Promise(r => setTimeout(r, 10)); + +async function scriptedModern(onListen?: (id: number | string, filter: unknown, send: (m: JSONRPCMessage) => void) => void) { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + serverTx.onmessage = message => { + written.push(message); + const req = message as { id?: number | string; method?: string; params?: { notifications?: unknown } }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: { listChanged: true }, prompts: { listChanged: true } }, + serverInfo: { name: 'scripted', version: '1' } + } + }); + } + if (req.method === 'subscriptions/listen' && req.id !== undefined) { + const filter = req.params?.notifications ?? {}; + const ack: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: req.id }, notifications: filter } + }; + void serverTx.send(ack); + onListen?.(req.id, filter, m => void serverTx.send(m)); + } + }; + await serverTx.start(); + return { clientTx, serverTx, written }; +} + +describe('Client.listen()', () => { + it('throws a typed steer on a legacy-era connection (no wire write)', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + serverTx.onmessage = m => { + written.push(m); + const req = m as { id?: number | string; method?: string }; + if (req.method === 'initialize' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, serverInfo: { name: 's', version: '1' } } + }); + } + }; + await serverTx.start(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'legacy' } }); + await client.connect(clientTx); + written.length = 0; + + const error = await client.listen({ toolsListChanged: true }).catch(e => e as SdkError); + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); + expect((error as SdkError).message).toContain('resources/subscribe'); + expect((error as SdkError).message).toContain('listChanged'); + // The steer fires before any wire write. + expect(written.some(m => (m as { method?: string }).method === 'subscriptions/listen')).toBe(false); + await client.close(); + }); + + it('resolves on ack with the honored filter; change notifications reach setNotificationHandler', async () => { + let send!: (m: JSONRPCMessage) => void; + const { clientTx } = await scriptedModern((_id, _f, s) => { + send = s; + }); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + const seen: string[] = []; + client.setNotificationHandler('notifications/tools/list_changed', () => { + seen.push('tools'); + }); + await client.connect(clientTx); + + const sub = await client.listen({ toolsListChanged: true }); + expect(sub.honoredFilter).toEqual({ toolsListChanged: true }); + + send({ + jsonrpc: '2.0', + method: 'notifications/tools/list_changed', + params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: 0 } } + }); + await flush(); + expect(seen).toEqual(['tools']); + await sub.close(); + await client.close(); + }); + + it('close() on a stdio-style transport sends notifications/cancelled referencing the listen id', async () => { + const { clientTx, written } = await scriptedModern(); + // Make the in-memory transport quack like a stdio child-process transport. + Object.assign(clientTx, { stderr: null, pid: 1 }); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + const listenId = ( + written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { id: number | string } + ).id; + written.length = 0; + await sub.close(); + expect(written).toEqual([{ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } }]); + // Idempotent. + await sub.close(); + expect(written).toHaveLength(1); + await client.close(); + }); + + it('inbound notifications/cancelled referencing the listen id tears the subscription down', async () => { + let listenId!: number | string; + let send!: (m: JSONRPCMessage) => void; + const { clientTx } = await scriptedModern((id, _f, s) => { + listenId = id; + send = s; + }); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification); + await flush(); + // close() after server-cancel is idempotent. + await sub.close(); + await client.close(); + }); + + it('rejects with the typed pre-ack error when the server answers -32603', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = m => { + const req = m as { id?: number | string; method?: string }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { resultType: 'complete', supportedVersions: [MODERN], capabilities: {}, serverInfo: { name: 's', version: '1' } } + }); + } + if (req.method === 'subscriptions/listen' && req.id !== undefined) { + void serverTx.send({ jsonrpc: '2.0', id: req.id, error: { code: -32_603, message: 'Subscription limit reached' } }); + } + }; + await serverTx.start(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error); + expect(error).toBeInstanceOf(Error); + expect((error as { code?: number }).code).toBe(-32_603); + await client.close(); + }); + + it('ClientOptions.listChanged auto-opens a listen stream on a modern connection (filter derived from sub-options)', async () => { + const filters: unknown[] = []; + const { clientTx } = await scriptedModern((_id, filter) => filters.push(filter)); + const onChanged = () => {}; + const client = new Client( + { name: 'c', version: '1' }, + { versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged }, prompts: { onChanged } } } + ); + await client.connect(clientTx); + expect(filters).toEqual([{ toolsListChanged: true, promptsListChanged: true }]); + expect(client.autoOpenedSubscription).toBeDefined(); + expect(client.autoOpenedSubscription!.honoredFilter).toEqual({ toolsListChanged: true, promptsListChanged: true }); + await client.autoOpenedSubscription!.close(); + await client.close(); + }); +}); diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index c606e2e3b5..bd2abd678c 100644 --- a/packages/core/src/shared/transport.ts +++ b/packages/core/src/shared/transport.ts @@ -67,6 +67,16 @@ export type TransportSendOptions = { * This allows clients to persist the latest token for potential reconnection. */ onresumptiontoken?: ((token: string) => void) | undefined; + + /** + * An abort signal for THIS outbound message's underlying request, when the + * transport sends one outbound message per underlying request (the + * Streamable HTTP transport's POST-per-request model). Aborting it cancels + * the underlying request (and its SSE response stream) without closing the + * transport. Transports that share a single channel (stdio, in-memory) + * ignore it. + */ + requestSignal?: AbortSignal | undefined; }; /** * Describes the minimal contract for an MCP transport that a client or server can communicate over. From bf5dbbf02a3747600a6a20ac04e155a68c574b94 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 14:18:04 +0000 Subject: [PATCH 03/27] docs(examples): runnable subscriptions/listen example pair 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. --- examples/client/README.md | 1 + .../client/src/subscriptionsListenClient.ts | 75 +++++++++++++++++++ examples/server/README.md | 1 + examples/server/src/subscriptionsListen.ts | 63 ++++++++++++++++ 4 files changed, 140 insertions(+) create mode 100644 examples/client/src/subscriptionsListenClient.ts create mode 100644 examples/server/src/subscriptionsListen.ts diff --git a/examples/client/README.md b/examples/client/README.md index 0879b3b6c0..56c5881cdf 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -36,6 +36,7 @@ Most clients expect a server to be running. Start one from [`../server/README.md | Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) | | URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | | Multi-round-trip client (2026-07-28) | Calls a write-once tool twice: default auto-fulfilment, then manual mode. | [`src/multiRoundTripClient.ts`](src/multiRoundTripClient.ts) | +| Subscriptions/listen client (2026-07-28) | `listChanged` auto-open then manual `client.listen()`; closes after a few changes. | [`src/subscriptionsListenClient.ts`](src/subscriptionsListenClient.ts) | ## URL elicitation example (server + client) diff --git a/examples/client/src/subscriptionsListenClient.ts b/examples/client/src/subscriptionsListenClient.ts new file mode 100644 index 0000000000..039b5a0154 --- /dev/null +++ b/examples/client/src/subscriptionsListenClient.ts @@ -0,0 +1,75 @@ +/** + * Drives the `subscriptions/listen` server example + * (`examples/server/src/subscriptionsListen.ts`) two ways on a 2026-07-28 + * connection: + * + * 1. **auto-open via `ClientOptions.listChanged`** — the same option a + * 2025-era client sets; on a modern connection the SDK auto-opens a + * listen stream with the filter derived from which sub-options were set, + * so the configured `onChanged` handlers fire on every published change; + * 2. **manual `client.listen()`** — opens a stream explicitly, registers a + * `notifications/tools/list_changed` handler the stream feeds, and closes + * after a few notifications. + * + * Start the server first, then: + * + * tsx examples/client/src/subscriptionsListenClient.ts + */ +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const URL = process.env.MCP_SERVER_URL ?? 'http://localhost:3000/'; +const CLIENT_INFO = { name: 'subscriptions-listen-example-client', version: '1.0.0' }; + +async function autoOpenLeg(): Promise { + console.log('--- auto-open via ClientOptions.listChanged ---'); + let count = 0; + let done!: () => void; + const finished = new Promise(resolve => { + done = resolve; + }); + const client = new Client(CLIENT_INFO, { + versionNegotiation: { mode: 'auto' }, + listChanged: { + tools: { + // autoRefresh: false — automatic per-request envelope emission + // is a client-side follow-up; until then a refreshing + // listTools() on a 2026 connection needs the envelope attached + // explicitly (see the multi-round-trip example). + autoRefresh: false, + onChanged: () => { + console.log('[client] (auto) tools/list_changed received'); + if (++count >= 2) done(); + } + } + } + }); + await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); + console.log(`[client] (auto) connected (${client.getNegotiatedProtocolVersion()}); auto-opened filter:`, client.autoOpenedSubscription?.honoredFilter); + await finished; + await client.autoOpenedSubscription?.close(); + await client.close(); +} + +async function manualLeg(): Promise { + console.log('--- manual client.listen() ---'); + const client = new Client(CLIENT_INFO, { versionNegotiation: { mode: 'auto' } }); + let count = 0; + let done!: () => void; + const finished = new Promise(resolve => { + done = resolve; + }); + client.setNotificationHandler('notifications/tools/list_changed', () => { + console.log('[client] (manual) tools/list_changed received'); + if (++count >= 2) done(); + }); + await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); + const sub = await client.listen({ toolsListChanged: true }); + console.log('[client] (manual) listening; honored filter:', sub.honoredFilter); + await finished; + await sub.close(); + await client.close(); +} + +await autoOpenLeg(); +await manualLeg(); +console.log('done.'); diff --git a/examples/server/README.md b/examples/server/README.md index bce265104a..1b6cf90158 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -39,6 +39,7 @@ pnpm tsx src/simpleStreamableHttp.ts | Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) | | SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) | | Multi-round-trip server (2026-07-28) | Write-once tool that returns `inputRequired(...)` (form + URL elicitation, requestState echo) via `createMcpHandler`. | [`src/multiRoundTrip.ts`](src/multiRoundTrip.ts) | +| Subscriptions/listen server (2026-07-28) | Publishes `tools/list_changed` to open `subscriptions/listen` streams via `handler.notify.toolsChanged()`. | [`src/subscriptionsListen.ts`](src/subscriptionsListen.ts) | ## OAuth demo flags (Streamable HTTP server) diff --git a/examples/server/src/subscriptionsListen.ts b/examples/server/src/subscriptionsListen.ts new file mode 100644 index 0000000000..2559a9ee20 --- /dev/null +++ b/examples/server/src/subscriptionsListen.ts @@ -0,0 +1,63 @@ +/** + * `subscriptions/listen` change notifications served via `createMcpHandler` + * (protocol revision 2026-07-28). + * + * The handler exposes `.notify` typed publish sugar over its + * `subscriptions/listen` bus: this example calls + * `handler.notify.toolsChanged()` whenever a tool is added or removed, and + * every open `subscriptions/listen` stream that opted in to + * `toolsListChanged` receives a stamped `notifications/tools/list_changed`. + * + * Run with: + * + * tsx examples/server/src/subscriptionsListen.ts + * + * and point the paired client example at it: + * + * tsx examples/client/src/subscriptionsListenClient.ts + */ +import { createServer } from 'node:http'; + +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import * as z from 'zod/v4'; + +let extraToolEnabled = false; + +function buildServer(): McpServer { + const server = new McpServer({ name: 'subscriptions-listen-example', version: '1.0.0' }); + + server.registerTool( + 'greet', + { description: 'Returns a greeting', inputSchema: z.object({ name: z.string() }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `hello, ${name}` }] }) + ); + if (extraToolEnabled) { + server.registerTool( + 'farewell', + { description: 'Returns a farewell', inputSchema: z.object({ name: z.string() }) }, + async ({ name }) => ({ content: [{ type: 'text', text: `goodbye, ${name}` }] }) + ); + } + + return server; +} + +// Host with the per-request HTTP entry on its default posture (2026-07-28 +// served per request; 2025-era traffic served stateless from the same +// factory). The handler creates an in-process bus by default; supply your +// own `bus` for multi-process deployments. +const handler = createMcpHandler(() => buildServer()); +const port = Number(process.env.PORT ?? '3000'); + +createServer((req, res) => void handler.node(req, res)).listen(port, () => { + console.error(`subscriptions/listen example server listening on http://localhost:${port}/`); +}); + +// Mutate the tool set every two seconds and publish the change to every open +// subscription stream that opted in to toolsListChanged. Safe to call when no +// subscription is open (no-op). +setInterval(() => { + extraToolEnabled = !extraToolEnabled; + console.error(`tools changed: farewell ${extraToolEnabled ? 'added' : 'removed'}`); + handler.notify.toolsChanged(); +}, 2000); From 358c318bf106271fd675754edfe6b9d4ebc9549d Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 14:19:53 +0000 Subject: [PATCH 04/27] test(e2e): subscriptions/listen cells 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. --- test/e2e/requirements.ts | 39 +++++++ test/e2e/scenarios/subscriptions.test.ts | 141 +++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 test/e2e/scenarios/subscriptions.test.ts diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 48c715ab66..a60ea5e59d 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2667,6 +2667,45 @@ export const REQUIREMENTS: Record = { 'The SDK provides a server-side legacy HTTP+SSE transport so existing SSE deployments can be hosted on SDK components alone.', transports: ['sse'], note: 'This asserts the availability of the server half of the legacy SSE transport (SSEServerTransport from @modelcontextprotocol/server-legacy/sse); the matrix transport arg is ignored, so it runs as a single sse-labelled cell.' + }, + 'subscriptions:listen:ack-first-stamped': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/subscriptions#acknowledgment', + behavior: + "notifications/subscriptions/acknowledged is the first message on a subscriptions/listen stream and carries the listen request's JSON-RPC id verbatim under the io.modelcontextprotocol/subscriptionId _meta key, plus the honored subset of the requested filter.", + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Hosted by the test body via createMcpHandler so it can publish via handler.notify.' + }, + 'subscriptions:listen:per-stream-filter': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/subscriptions#notification-filter', + behavior: + 'A subscriptions/listen stream receives only the notification types its filter explicitly requested; an un-requested type is provably never delivered. Change notifications dispatch to the existing setNotificationHandler registrations.', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Hosted by the test body via createMcpHandler so it can publish via handler.notify.' + }, + 'subscriptions:listen:capacity-guard': { + source: 'sdk', + behavior: + "A subscriptions/listen request is refused with -32603 'Subscription limit reached' (in-band on HTTP 200, before the ack) when the configured maxSubscriptions is reached.", + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Hosted by the test body via createMcpHandler with maxSubscriptions: 1.' + }, + 'typescript:subscriptions:listChanged-auto-open-modern': { + source: 'sdk', + behavior: + 'ClientOptions.listChanged auto-opens a subscriptions/listen stream on a modern connection (filter derived from which sub-options were set), so the configured handlers fire on every published change. The auto-opened subscription is exposed for close.', + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Hosted by the test body via createMcpHandler so it can publish via handler.notify.' + }, + 'typescript:subscriptions:listen:legacy-era-steer': { + source: 'sdk', + behavior: + 'On a 2025-era connection, Client.listen() throws a typed MethodNotSupportedByProtocolVersion error steering to resources/subscribe and ClientOptions.listChanged before any wire write (no transparent shim).', + removedInSpecVersion: '2026-07-28', + note: 'Runs on the 2025-era arms; the entryModern arm is bound out by the removedInSpecVersion.' } } satisfies Record; diff --git a/test/e2e/scenarios/subscriptions.test.ts b/test/e2e/scenarios/subscriptions.test.ts new file mode 100644 index 0000000000..70bd205e35 --- /dev/null +++ b/test/e2e/scenarios/subscriptions.test.ts @@ -0,0 +1,141 @@ +/** + * `subscriptions/listen` (SEP-1865, protocol revision 2026-07-28) through the + * public surface: ack-first, subscription-id stamping, per-stream filtering, + * the listChanged auto-open bridge, and the F-12 legacy steer. + * + * The 2026-era cells host `createMcpHandler` themselves (the test publishes + * via `handler.notify.*`); the legacy cell runs on the standard arms. + */ +import { Client, SdkError, SdkErrorCode, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { createMcpHandler, McpServer, SUBSCRIPTION_ID_META_KEY } from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; +import { z } from 'zod/v4'; + +import { modernEnvelopeMeta, wire } from '../helpers/index.js'; +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +function makeServer() { + const server = new McpServer({ name: 'subs-e2e', version: '1' }); + server.registerTool('greet', { inputSchema: z.object({}) }, async () => ({ content: [] })); + return server; +} + +async function hostListen() { + const handler = createMcpHandler(() => makeServer(), { legacy: 'reject', keepAliveMs: 0 }); + const url = new URL('http://in-process/mcp'); + const fetch = (u: URL | string, init?: RequestInit) => handler.fetch(new Request(u, init)); + const client = new Client({ name: 'subs-e2e-client', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(url, { fetch })); + expect(client.getNegotiatedProtocolVersion()).toBe('2026-07-28'); + return { + client, + handler, + fetch, + url, + [Symbol.asyncDispose]: () => Promise.all([client.close(), handler.close()]).then(() => {}) + }; +} + +verifies('subscriptions:listen:ack-first-stamped', async () => { + const handler = createMcpHandler(() => makeServer(), { legacy: 'reject', keepAliveMs: 0 }); + const response = await handler.fetch( + new Request('http://in-process/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 'sub-1', + method: 'subscriptions/listen', + params: { _meta: modernEnvelopeMeta(), notifications: { toolsListChanged: true } } + }) + }) + ); + expect(response.headers.get('Content-Type')).toBe('text/event-stream'); + const reader = response.body!.getReader(); + const { value } = await reader.read(); + const frame = new TextDecoder().decode(value); + const ack = JSON.parse(frame.slice(frame.indexOf('data: ') + 6, frame.indexOf('\n\n'))) as { + method: string; + params: { _meta: Record; notifications: unknown }; + }; + expect(ack.method).toBe('notifications/subscriptions/acknowledged'); + expect(ack.params._meta[SUBSCRIPTION_ID_META_KEY]).toBe('sub-1'); + expect(ack.params.notifications).toEqual({ toolsListChanged: true }); + await reader.cancel(); + await handler.close(); +}); + +verifies('subscriptions:listen:per-stream-filter', async () => { + await using h = await hostListen(); + const seen: string[] = []; + h.client.setNotificationHandler('notifications/tools/list_changed', () => void seen.push('tools')); + h.client.setNotificationHandler('notifications/prompts/list_changed', () => void seen.push('prompts')); + const sub = await h.client.listen({ toolsListChanged: true }); + h.handler.notify.promptsChanged(); + h.handler.notify.toolsChanged(); + await new Promise(r => setTimeout(r, 30)); + // The un-requested type was provably never delivered. + expect(seen).toEqual(['tools']); + await sub.close(); +}); + +verifies('typescript:subscriptions:listChanged-auto-open-modern', async () => { + const handler = createMcpHandler(() => makeServer(), { legacy: 'reject', keepAliveMs: 0 }); + const fetch = (u: URL | string, init?: RequestInit) => handler.fetch(new Request(u, init)); + let count = 0; + let done!: () => void; + const finished = new Promise(r => { + done = r; + }); + const client = new Client( + { name: 'subs-e2e-client', version: '1' }, + { + versionNegotiation: { mode: 'auto' }, + listChanged: { tools: { autoRefresh: false, onChanged: () => (++count >= 1 ? done() : undefined) } } + } + ); + await client.connect(new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch })); + expect(client.autoOpenedSubscription?.honoredFilter).toEqual({ toolsListChanged: true }); + handler.notify.toolsChanged(); + await finished; + expect(count).toBe(1); + await client.autoOpenedSubscription!.close(); + await client.close(); + await handler.close(); +}); + +verifies('typescript:subscriptions:listen:legacy-era-steer', async ({ transport }: TestArgs) => { + const client = new Client({ name: 'c', version: '0' }); + await using _ = await wire(transport, makeServer, client); + const error = await client.listen({ toolsListChanged: true }).catch(e => e as SdkError); + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); + expect((error as SdkError).message).toContain('resources/subscribe'); +}); + +verifies('subscriptions:listen:capacity-guard', async () => { + const handler = createMcpHandler(() => makeServer(), { legacy: 'reject', keepAliveMs: 0, maxSubscriptions: 1 }); + const post = (id: number) => + handler.fetch( + new Request('http://in-process/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id, + method: 'subscriptions/listen', + params: { _meta: modernEnvelopeMeta(), notifications: {} } + }) + }) + ); + const first = await post(1); + expect(first.headers.get('Content-Type')).toBe('text/event-stream'); + const second = await post(2); + expect(second.headers.get('Content-Type')).toContain('application/json'); + const body = (await second.json()) as { error: { code: number; message: string } }; + expect(body.error.code).toBe(-32_603); + expect(body.error.message).toBe('Subscription limit reached'); + await first.body!.cancel(); + await handler.close(); +}); From d0b46e4c3aa2c992f04da2649c838e9b92f97527 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 14:31:20 +0000 Subject: [PATCH 05/27] chore: lint/format fixes; update integration discoverRoundtrip for A11 discharge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration discoverRoundtrip test asserted the OLD A11-rider behavior (advertisement excludes listChanged); updated to assert the discharged behavior (a designed existing-test modification — the rider's own acceptance criterion, same mandate as discover.test.ts). --- examples/client/src/subscriptionsListenClient.ts | 5 ++++- examples/server/src/subscriptionsListen.ts | 8 +++----- packages/client/src/client/client.ts | 7 +++---- packages/client/test/client/listen.test.ts | 11 +++++++---- test/e2e/scenarios/subscriptions.test.ts | 2 +- 5 files changed, 18 insertions(+), 15 deletions(-) diff --git a/examples/client/src/subscriptionsListenClient.ts b/examples/client/src/subscriptionsListenClient.ts index 039b5a0154..4925e3a7e5 100644 --- a/examples/client/src/subscriptionsListenClient.ts +++ b/examples/client/src/subscriptionsListenClient.ts @@ -44,7 +44,10 @@ async function autoOpenLeg(): Promise { } }); await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); - console.log(`[client] (auto) connected (${client.getNegotiatedProtocolVersion()}); auto-opened filter:`, client.autoOpenedSubscription?.honoredFilter); + console.log( + `[client] (auto) connected (${client.getNegotiatedProtocolVersion()}); auto-opened filter:`, + client.autoOpenedSubscription?.honoredFilter + ); await finished; await client.autoOpenedSubscription?.close(); await client.close(); diff --git a/examples/server/src/subscriptionsListen.ts b/examples/server/src/subscriptionsListen.ts index 2559a9ee20..b56006b794 100644 --- a/examples/server/src/subscriptionsListen.ts +++ b/examples/server/src/subscriptionsListen.ts @@ -26,11 +26,9 @@ let extraToolEnabled = false; function buildServer(): McpServer { const server = new McpServer({ name: 'subscriptions-listen-example', version: '1.0.0' }); - server.registerTool( - 'greet', - { description: 'Returns a greeting', inputSchema: z.object({ name: z.string() }) }, - async ({ name }) => ({ content: [{ type: 'text', text: `hello, ${name}` }] }) - ); + server.registerTool('greet', { description: 'Returns a greeting', inputSchema: z.object({ name: z.string() }) }, async ({ name }) => ({ + content: [{ type: 'text', text: `hello, ${name}` }] + })); if (extraToolEnabled) { server.registerTool( 'farewell', diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 033c243d57..8dbde2816e 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -64,7 +64,6 @@ import { ListChangedOptionsBaseSchema, mergeCapabilities, parseSchema, - PROTOCOL_VERSION_META_KEY, Protocol, PROTOCOL_VERSION_META_KEY, ProtocolError, @@ -1217,7 +1216,7 @@ export class Client extends Protocol { } const requestAbort = new AbortController(); - const transportKind = this.transport !== undefined ? detectProbeTransportKind(this.transport) : 'http'; + const transportKind = this.transport === undefined ? 'http' : detectProbeTransportKind(this.transport); let closed = false; let parked!: ReturnType; @@ -1279,10 +1278,10 @@ export class Client extends Protocol { reject(error ?? new Error('subscriptions/listen failed')); } }); - parked.sent.catch(err => { + parked.sent.catch(error => { clearTimeout(timer); void close(); - reject(err instanceof Error ? err : new Error(String(err))); + reject(error instanceof Error ? error : new Error(String(error))); }); }); diff --git a/packages/client/test/client/listen.test.ts b/packages/client/test/client/listen.test.ts index 865ffacf9d..1ea01d3f74 100644 --- a/packages/client/test/client/listen.test.ts +++ b/packages/client/test/client/listen.test.ts @@ -110,9 +110,7 @@ describe('Client.listen()', () => { const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(clientTx); const sub = await client.listen({ toolsListChanged: true }); - const listenId = ( - written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { id: number | string } - ).id; + const listenId = (written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { id: number | string }).id; written.length = 0; await sub.close(); expect(written).toEqual([{ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } }]); @@ -147,7 +145,12 @@ describe('Client.listen()', () => { void serverTx.send({ jsonrpc: '2.0', id: req.id, - result: { resultType: 'complete', supportedVersions: [MODERN], capabilities: {}, serverInfo: { name: 's', version: '1' } } + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: {}, + serverInfo: { name: 's', version: '1' } + } }); } if (req.method === 'subscriptions/listen' && req.id !== undefined) { diff --git a/test/e2e/scenarios/subscriptions.test.ts b/test/e2e/scenarios/subscriptions.test.ts index 70bd205e35..e4cd1bc8d3 100644 --- a/test/e2e/scenarios/subscriptions.test.ts +++ b/test/e2e/scenarios/subscriptions.test.ts @@ -108,7 +108,7 @@ verifies('typescript:subscriptions:listChanged-auto-open-modern', async () => { verifies('typescript:subscriptions:listen:legacy-era-steer', async ({ transport }: TestArgs) => { const client = new Client({ name: 'c', version: '0' }); await using _ = await wire(transport, makeServer, client); - const error = await client.listen({ toolsListChanged: true }).catch(e => e as SdkError); + const error = await client.listen({ toolsListChanged: true }).catch(error_ => error_ as SdkError); expect(error).toBeInstanceOf(SdkError); expect((error as SdkError).code).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); expect((error as SdkError).message).toContain('resources/subscribe'); From 3c198ca1de5b29ddc6a4cbdfbfa76134ec42097e Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 14:32:26 +0000 Subject: [PATCH 06/27] docs: migration.md entry for subscriptions/listen --- docs/migration.md | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 445304b986..7f6f7e74df 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1159,6 +1159,23 @@ Resolution is per field, most specific author first: for each of `ttlMs` and `ca per-resource hint that sets only one field never suppresses the other field configured at the operation level. Configured hints are validated when they are configured — an invalid `ttlMs` (negative or non-integer) or `cacheScope` throws a `RangeError`. Responses on 2025-era connections never carry these fields, with or without configuration. +### `subscriptions/listen` (2026-07-28): change-notification streams replace unsolicited delivery + +The 2026-07-28 revision delivers `tools/prompts/resources` `list_changed` and `resources/updated` only on a `subscriptions/listen` stream the client opened — the server never sends an un-requested notification type. Both halves ship: + +**Server side.** Nothing to register: the serving entries handle `subscriptions/listen` themselves. `createMcpHandler` returns `.notify.{toolsChanged, promptsChanged, resourcesChanged, resourceUpdated(uri)}` typed publish sugar over an in-process bus (supply your own +`ServerEventBus` for multi-process deployments). On stdio, `serveStdio` routes the pinned instance's existing `send*ListChanged()` calls onto the active subscriptions automatically. The 2025-era unsolicited delivery model is unchanged on legacy connections. + +```typescript +const handler = createMcpHandler(() => buildServer()); +// after a tool registration changes: +handler.notify.toolsChanged(); +``` + +**Client side.** `ClientOptions.listChanged` keeps working: on a 2026-07-28 connection the SDK auto-opens a `subscriptions/listen` stream with the filter derived from which sub-options were set, so the same handlers fire on every published change (the auto-opened subscription is +exposed at `client.autoOpenedSubscription` for `close()`). `client.listen(filter)` opens a stream explicitly and resolves once the server's acknowledged notification arrives with `{ honoredFilter, close() }`; change notifications dispatch to the existing `setNotificationHandler` +registrations. `resources/subscribe` is 2025-only — on a 2026-07-28 connection, request `notifications/resources/updated` via the `resourceSubscriptions` field of the listen filter instead. + ### Multi round-trip requests (2026-07-28): write-once handlers and the client auto-fulfilment driver The 2026-07-28 revision removes the server→client JSON-RPC request channel: servers obtain client input (elicitation, sampling, roots) **in-band**, by answering `tools/call`, `prompts/get`, or `resources/read` with an `input_required` result that embeds the requests, and the @@ -1192,9 +1209,9 @@ has: only `tools/call` has a catch-all that wraps handler failures into `isError **`requestState` is untrusted input — protect it yourself.** `inputRequired({ requestState })` lets a server round-trip opaque state through the client instead of holding it in memory. The SDK treats it as an opaque string end to end: the client echoes it back byte-exact and never parses it, and the server sees the echoed value raw at `ctx.mcpReq.requestState`. The specification's requirement is the consumer's obligation: the value comes back as **attacker-controlled input**, so if it influences authorization, resource access, or business logic you -MUST integrity-protect it when minting it (for example HMAC or AEAD over the payload, bound to the principal, the originating method/parameters, and an expiry) and MUST reject state that fails verification on re-entry. The SDK does not provide or apply any sealing of its own, -but it does provide the place to put your verification: configure `ServerOptions.requestState.verify`, and the seam runs it before the handler whenever `requestState` is present — a thrown rejection answers the client with a frozen `-32602` (above the tool funnel, so it is a -real JSON-RPC error rather than an `isError` result). See `examples/server/src/multiRoundTrip.ts` for a worked HMAC example. +MUST integrity-protect it when minting it (for example HMAC or AEAD over the payload, bound to the principal, the originating method/parameters, and an expiry) and MUST reject state that fails verification on re-entry. The SDK does not provide or apply any sealing of its own, but +it does provide the place to put your verification: configure `ServerOptions.requestState.verify`, and the seam runs it before the handler whenever `requestState` is present — a thrown rejection answers the client with a frozen `-32602` (above the tool funnel, so it is a real +JSON-RPC error rather than an `isError` result). See `examples/server/src/multiRoundTrip.ts` for a worked HMAC example. **Client side — auto-fulfilment by default.** When a call to `tools/call`, `prompts/get`, or `resources/read` on a 2026-07-28 connection answers `input_required`, the client fulfils the embedded requests through the same handlers registered with `setRequestHandler('elicitation/create' | 'sampling/createMessage' | 'roots/list', …)` and retries the original request (fresh request id, `inputResponses`, byte-exact `requestState` echo) up to `inputRequired.maxRounds` rounds (default 10). `client.callTool()` and its siblings From 48c562d81594218a1916befdbcef66d311297407 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 15:54:20 +0000 Subject: [PATCH 07/27] fix(client): guard listen() connectivity before park; auto-open failure surfaces via onerror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related hardening fixes: - listen() now checks this.transport before any setup. _parkRequest throws NotConnected synchronously, and it was called inside the ack-promise executor AFTER the ack timer was armed — so a not-connected listen() leaked a timer whose callback then dereferenced an unassigned park handle. The guard returns the rejection as the listen() promise with no setup started. - ClientOptions.listChanged auto-open during _connectNegotiated no longer fails connect when listen() rejects (server refuses on capacity, does not support listen, etc.). The modern connection is fully usable without a listen stream; the failure surfaces via onerror and autoOpenedSubscription stays undefined. Tests added for both. --- packages/client/src/client/client.ts | 19 +++++++- packages/client/test/client/listen.test.ts | 50 ++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 8dbde2816e..3015c52eb7 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -849,7 +849,15 @@ export class Client extends Protocol { ...(config.resources && { resourcesListChanged: true as const }) }; if (Object.keys(filter).length > 0) { - this._autoOpenedSubscription = await this.listen(filter, options); + // A failed auto-open MUST NOT fail connect: the modern + // connection is fully usable without a listen stream (the + // server may not support it, or refuse on capacity). Surface + // via onerror; the consumer can call listen() later. + try { + this._autoOpenedSubscription = await this.listen(filter, options); + } catch (error) { + this.onerror?.(error instanceof Error ? error : new Error(String(error))); + } } } } @@ -1210,13 +1218,20 @@ export class Client extends Protocol { { method: 'subscriptions/listen', protocolVersion: negotiated } ); } + // Connectivity is checked here so the rejection is delivered as the + // returned promise (no setup or ack timer is started) — `_parkRequest` + // would otherwise throw NotConnected from inside the executor below + // after the timer is armed. + if (this.transport === undefined) { + throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); + } if (this._onParkedNotification === undefined) { this._onParkedNotification = raw => this._listenFirstLook(raw); } const requestAbort = new AbortController(); - const transportKind = this.transport === undefined ? 'http' : detectProbeTransportKind(this.transport); + const transportKind = detectProbeTransportKind(this.transport); let closed = false; let parked!: ReturnType; diff --git a/packages/client/test/client/listen.test.ts b/packages/client/test/client/listen.test.ts index 1ea01d3f74..41242465df 100644 --- a/packages/client/test/client/listen.test.ts +++ b/packages/client/test/client/listen.test.ts @@ -166,6 +166,19 @@ describe('Client.listen()', () => { await client.close(); }); + it('rejects with NotConnected (as a rejected promise, no setup) when no transport is connected', async () => { + const { clientTx } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + await client.close(); + // listen() is async, so a pre-send guard throw is delivered as the + // returned promise's rejection (no ack timer started, no park state). + const pending = client.listen({ toolsListChanged: true }); + const error = await pending.catch(e => e as SdkError); + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.NotConnected); + }); + it('ClientOptions.listChanged auto-opens a listen stream on a modern connection (filter derived from sub-options)', async () => { const filters: unknown[] = []; const { clientTx } = await scriptedModern((_id, filter) => filters.push(filter)); @@ -181,4 +194,41 @@ describe('Client.listen()', () => { await client.autoOpenedSubscription!.close(); await client.close(); }); + + it('a failed auto-open surfaces via onerror and does NOT fail connect', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = m => { + const req = m as { id?: number | string; method?: string }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 's', version: '1' } + } + }); + } + if (req.method === 'subscriptions/listen' && req.id !== undefined) { + // Server refuses listen (capacity guard / not supported). + void serverTx.send({ jsonrpc: '2.0', id: req.id, error: { code: -32_603, message: 'Subscription limit reached' } }); + } + }; + await serverTx.start(); + const onChanged = () => {}; + const client = new Client( + { name: 'c', version: '1' }, + { versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged } } } + ); + const errors: Error[] = []; + client.onerror = e => errors.push(e); + // connect MUST resolve: the modern connection is usable without listen. + await client.connect(clientTx); + expect(client.autoOpenedSubscription).toBeUndefined(); + expect(errors).toHaveLength(1); + expect((errors[0] as { code?: number }).code).toBe(-32_603); + await client.close(); + }); }); From 3c470a440460802bebc14ec1361e268a3ac74f67 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 16:34:42 +0000 Subject: [PATCH 08/27] refactor(client): centralize per-connection state reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/client/src/client/client.ts | 41 ++++++++++++++++------ packages/client/test/client/listen.test.ts | 16 +++++++++ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 3015c52eb7..6901135728 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -332,6 +332,27 @@ export class Client extends Protocol { /** The auto-opened subscription backing ClientOptions.listChanged on a modern connection. */ private _autoOpenedSubscription?: McpSubscription; + /** + * Clears every per-connection field in one place. Called at the start of + * each fresh (non-resuming) connect and from `close()`, so a stale + * negotiated era / server identity / auto-opened subscription cannot + * survive a reconnect. + */ + private _resetConnectionState(): void { + this._negotiatedProtocolVersion = undefined; + this._serverCapabilities = undefined; + this._serverVersion = undefined; + this._instructions = undefined; + this._autoOpenedSubscription = undefined; + this._listenState.clear(); + this._cachedToolOutputValidators.clear(); + } + + override async close(): Promise { + await super.close(); + this._resetConnectionState(); + } + /** * Initializes this client with the given name and version information. */ @@ -689,15 +710,15 @@ export class Client extends Protocol { } return; } - // Fresh connect: the negotiated protocol version is connection state — - // a value left over from a previous connection must not survive into a - // new handshake. Clearing it puts the instance back in the - // pre-negotiation phase, so the initialize exchange below rides the - // bootstrap method pins (legacy era) instead of a dead session's era. - // Without this, an instance that once negotiated a modern era could - // never re-run a fresh handshake: `initialize` is physically absent - // from the modern registry. (The resume branch above keeps it instead.) - this._negotiatedProtocolVersion = undefined; + // Fresh connect: per-connection state left over from a previous + // connection must not survive into a new handshake. Clearing it puts + // the instance back in the pre-negotiation phase, so the initialize + // exchange below rides the bootstrap method pins (legacy era) instead + // of a dead session's era. Without this, an instance that once + // negotiated a modern era could never re-run a fresh handshake: + // `initialize` is physically absent from the modern registry. (The + // resume branch above keeps it instead.) + this._resetConnectionState(); await this._legacyHandshake(transport, options); } @@ -797,7 +818,7 @@ export class Client extends Protocol { // Fresh connect: stale connection state must not survive into a new // negotiation — every fresh negotiated connect re-runs the probe. - this._negotiatedProtocolVersion = undefined; + this._resetConnectionState(); let result: Awaited>; try { diff --git a/packages/client/test/client/listen.test.ts b/packages/client/test/client/listen.test.ts index 41242465df..3877d41b2b 100644 --- a/packages/client/test/client/listen.test.ts +++ b/packages/client/test/client/listen.test.ts @@ -195,6 +195,22 @@ describe('Client.listen()', () => { await client.close(); }); + it('autoOpenedSubscription is cleared on close() and on a fresh reconnect', async () => { + const onChanged = () => {}; + const client = new Client( + { name: 'c', version: '1' }, + { versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged } } } + ); + const { clientTx } = await scriptedModern(); + await client.connect(clientTx); + expect(client.autoOpenedSubscription).toBeDefined(); + await client.close(); + // close() clears every per-connection field. + expect(client.autoOpenedSubscription).toBeUndefined(); + expect(client.getServerCapabilities()).toBeUndefined(); + expect(client.getNegotiatedProtocolVersion()).toBeUndefined(); + }); + it('a failed auto-open surfaces via onerror and does NOT fail connect', async () => { const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); serverTx.onmessage = m => { From 94010ae9c44c0f2aa89441252dd9842602f877c5 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 16:37:52 +0000 Subject: [PATCH 09/27] =?UTF-8?q?refactor(client):=20listen()=20driver=20a?= =?UTF-8?q?s=20an=20explicit=20opening=E2=86=92open=E2=86=92closed=20state?= =?UTF-8?q?=20machine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- packages/client/src/client/client.ts | 133 ++++++++++++++------- packages/client/test/client/listen.test.ts | 77 ++++++++++++ 2 files changed, 167 insertions(+), 43 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 6901135728..09444f4bbd 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -276,7 +276,7 @@ export interface McpSubscription { /** @internal */ interface ListenStateEntry { - onAck: ((honored: SubscriptionFilter) => void) | undefined; + onAck: (honored: SubscriptionFilter) => void; onServerCancel: () => void; } @@ -1229,6 +1229,14 @@ export class Client extends Protocol { * unsolicited delivery model still applies there); no transparent shim. */ async listen(filter: SubscriptionFilter, options?: RequestOptions): Promise { + // Connectivity is checked first so a closed instance rejects with + // NotConnected (no setup or ack timer is started); after close(), + // `_resetConnectionState` has also cleared the negotiated era, so the + // era guard alone would surface a misleading + // MethodNotSupportedByProtocolVersion. + if (this.transport === undefined) { + throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); + } const negotiated = this._negotiatedProtocolVersion; if (negotiated === undefined || !isModernProtocolVersion(negotiated)) { throw new SdkError( @@ -1239,13 +1247,6 @@ export class Client extends Protocol { { method: 'subscriptions/listen', protocolVersion: negotiated } ); } - // Connectivity is checked here so the rejection is delivered as the - // returned promise (no setup or ack timer is started) — `_parkRequest` - // would otherwise throw NotConnected from inside the executor below - // after the timer is armed. - if (this.transport === undefined) { - throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); - } if (this._onParkedNotification === undefined) { this._onParkedNotification = raw => this._listenFirstLook(raw); @@ -1254,30 +1255,78 @@ export class Client extends Protocol { const requestAbort = new AbortController(); const transportKind = detectProbeTransportKind(this.transport); - let closed = false; - let parked!: ReturnType; - const close = async (): Promise => { - if (closed) return; - closed = true; - this._listenState.delete(parked.messageId); - // Per-transport teardown: HTTP closes the request's SSE stream; - // stdio sends notifications/cancelled referencing the listen id. - if (transportKind === 'stdio') { + // Explicit `opening → open → closed` state machine. Every termination + // path — ack-arrives, ack-timeout, server-cancelled, user-close, + // transport-close, send-failure — funnels through the single `settle` + // below, which clears the ack timer, unparks, transitions state, and + // resolves/rejects the opening promise exactly once. The cancelled- + // before-ack / close-before-ack hangs are impossible by construction. + let state: 'opening' | 'open' | 'closed' = 'opening'; + let parked: ReturnType | undefined; + let ackTimer: ReturnType | undefined; + let resolveOpening!: (honored: SubscriptionFilter) => void; + let rejectOpening!: (error: Error) => void; + const opening = new Promise((resolve, reject) => { + resolveOpening = resolve; + rejectOpening = reject; + }); + + const settle = (outcome: { ack: SubscriptionFilter } | { error: Error } | 'closed'): void => { + if (state === 'closed') return; + const wasOpening = state === 'opening'; + if (ackTimer !== undefined) { + clearTimeout(ackTimer); + ackTimer = undefined; + } + if (outcome !== 'closed' && 'ack' in outcome) { + // The single `opening → open` transition; an ack after close + // hits the `closed` guard above and is a no-op. + state = 'open'; + resolveOpening(outcome.ack); + return; + } + state = 'closed'; + if (parked !== undefined) { + this._listenState.delete(parked.messageId); + parked.unpark(); + } + if (wasOpening) { + rejectOpening( + outcome === 'closed' + ? new SdkError(SdkErrorCode.ConnectionClosed, 'subscriptions/listen closed before the server acknowledged') + : outcome.error + ); + } + }; + + // Wire-level teardown for a locally-initiated close (user close or ack + // timeout): HTTP closes the request's SSE stream; stdio sends + // notifications/cancelled referencing the listen id. Not called when + // the server already terminated (error / server-cancelled). + const wireTeardown = async (): Promise => { + const id = parked?.messageId; + if (transportKind === 'stdio' && id !== undefined) { await this.transport - ?.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: parked.messageId } }) + ?.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: id } }) .catch(() => {}); } else { requestAbort.abort(); } - parked.unpark(); }; - const honored = await new Promise((resolve, reject) => { - const ackTimeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; - const timer = setTimeout(() => { - void close(); - reject(new SdkError(SdkErrorCode.RequestTimeout, 'subscriptions/listen ack timed out', { timeout: ackTimeout })); - }, ackTimeout); + const close = async (): Promise => { + if (state === 'closed') return; + settle('closed'); + await wireTeardown(); + }; + + const ackTimeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; + ackTimer = setTimeout(() => { + settle({ error: new SdkError(SdkErrorCode.RequestTimeout, 'subscriptions/listen ack timed out', { timeout: ackTimeout }) }); + void wireTeardown().catch(() => {}); + }, ackTimeout); + + try { // The per-subscription state is registered BEFORE the request is // sent (`onBeforeSend`) so a synchronously-delivered ack (an // in-process transport) cannot race the registration. @@ -1296,31 +1345,30 @@ export class Client extends Protocol { { requestSignal: requestAbort.signal }, messageId => { this._listenState.set(messageId, { - onAck: honored => { - clearTimeout(timer); - resolve(honored); - }, - onServerCancel: () => void close() + onAck: honored => settle({ ack: honored }), + onServerCancel: () => { + settle({ error: new Error('subscriptions/listen: server cancelled before acknowledging') }); + } }); } ); + // A synchronously-delivered termination during `send()` (an + // in-process transport) ran `settle()` before `parked` was + // assigned — unpark now so the handler does not leak. + if (state === 'closed') parked.unpark(); // Pre-ack capacity / params rejection arrives as a JSON-RPC error - // for the listen id — surfaced via terminated. + // for the listen id; transport close is delivered the same way. void parked.terminated.then(({ reason, error }) => { - if (reason === 'error') { - clearTimeout(timer); - this._listenState.delete(parked.messageId); - closed = true; - reject(error ?? new Error('subscriptions/listen failed')); - } + if (reason === 'error') settle({ error: error ?? new Error('subscriptions/listen failed') }); }); parked.sent.catch(error => { - clearTimeout(timer); - void close(); - reject(error instanceof Error ? error : new Error(String(error))); + settle({ error: error instanceof Error ? error : new Error(String(error)) }); }); - }); + } catch (error) { + settle({ error: error instanceof Error ? error : new Error(String(error)) }); + } + const honored = await opening; return { honoredFilter: honored, close }; } @@ -1348,10 +1396,9 @@ export class Client extends Protocol { // Tolerant read: subscription id may be string or number; match by // String() coercion against this connection's parked listen ids. for (const [id, entry] of this._listenState) { - if (String(id) === String(subscriptionId) && entry.onAck !== undefined) { + if (String(id) === String(subscriptionId)) { const honored = SubscriptionFilterSchema.safeParse(params?.notifications ?? {}); entry.onAck(honored.success ? honored.data : {}); - entry.onAck = undefined; return 'consumed'; } } diff --git a/packages/client/test/client/listen.test.ts b/packages/client/test/client/listen.test.ts index 3877d41b2b..dfbfc24ce1 100644 --- a/packages/client/test/client/listen.test.ts +++ b/packages/client/test/client/listen.test.ts @@ -166,6 +166,83 @@ describe('Client.listen()', () => { await client.close(); }); + it('server cancels BEFORE the ack: listen() rejects immediately, no 60s hang', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = m => { + const req = m as { id?: number | string; method?: string }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: {}, + serverInfo: { name: 's', version: '1' } + } + }); + } + if (req.method === 'subscriptions/listen' && req.id !== undefined) { + // Server cancels the listen id BEFORE sending the ack. + void serverTx.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: req.id } }); + } + }; + await serverTx.start(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const t0 = Date.now(); + const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('server cancelled before acknowledging'); + // Rejected promptly (well under the 60s ack timeout). + expect(Date.now() - t0).toBeLessThan(1000); + // No leaked _responseHandlers entry for the listen id. + expect((client as unknown as { _responseHandlers: Map })._responseHandlers.size).toBe(0); + await client.close(); + }); + + it('an ack arriving AFTER the subscription was server-cancelled is a no-op', async () => { + let listenId!: number | string; + let send!: (m: JSONRPCMessage) => void; + const { clientTx } = await scriptedModern((id, _f, s) => { + listenId = id; + send = s; + }); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + // Server tears the open subscription down. + send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification); + await flush(); + // A late duplicate ack must not throw or resurrect state. + send({ + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: listenId }, notifications: {} } + }); + await flush(); + await sub.close(); + await client.close(); + }); + + it('a synchronous transport.send throw does not leak a _responseHandlers entry', async () => { + const { clientTx } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const handlers = (client as unknown as { _responseHandlers: Map })._responseHandlers; + const before = handlers.size; + const realSend = clientTx.send.bind(clientTx); + clientTx.send = () => { + throw new Error('send blew up'); + }; + const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error); + expect((error as Error).message).toContain('send blew up'); + // The park primitive unregistered before rethrowing — no leak. + expect(handlers.size).toBe(before); + clientTx.send = realSend; + await client.close(); + }); + it('rejects with NotConnected (as a rejected promise, no setup) when no transport is connected', async () => { const { clientTx } = await scriptedModern(); const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); From f654626fc390e57e1085bf3c74e34059b2ae4766 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 16:38:36 +0000 Subject: [PATCH 10/27] =?UTF-8?q?fix(client):=20derive=20auto-open=20liste?= =?UTF-8?q?n=20filter=20from=20configured=20=E2=88=A9=20server-advertised?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_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.) --- packages/client/src/client/client.ts | 23 ++++++++++++----- packages/client/test/client/listen.test.ts | 29 ++++++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 09444f4bbd..9be4376fe6 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -862,12 +862,22 @@ export class Client extends Protocol { // 2025-era unsolicited notifications, no listen needed. if (this._pendingListChangedConfig) { const config = this._pendingListChangedConfig; - this._setupListChangedHandlers(config); this._pendingListChangedConfig = undefined; + // Compute configured ∩ server-advertised ONCE and use that single + // value for BOTH handler registration and the auto-open filter, so + // a configured-but-not-advertised type is neither subscribed to + // nor handled (the two stay in lockstep). + const advertised = this._serverCapabilities; + const effective: ListChangedHandlers = { + ...(config.tools && advertised?.tools?.listChanged && { tools: config.tools }), + ...(config.prompts && advertised?.prompts?.listChanged && { prompts: config.prompts }), + ...(config.resources && advertised?.resources?.listChanged && { resources: config.resources }) + }; + this._setupListChangedHandlers(effective); const filter: SubscriptionFilter = { - ...(config.tools && { toolsListChanged: true as const }), - ...(config.prompts && { promptsListChanged: true as const }), - ...(config.resources && { resourcesListChanged: true as const }) + ...(effective.tools && { toolsListChanged: true as const }), + ...(effective.prompts && { promptsListChanged: true as const }), + ...(effective.resources && { resourcesListChanged: true as const }) }; if (Object.keys(filter).length > 0) { // A failed auto-open MUST NOT fail connect: the modern @@ -1354,8 +1364,9 @@ export class Client extends Protocol { ); // A synchronously-delivered termination during `send()` (an // in-process transport) ran `settle()` before `parked` was - // assigned — unpark now so the handler does not leak. - if (state === 'closed') parked.unpark(); + // assigned — unpark now so the handler does not leak. (Cast: TS + // control-flow narrowing does not track closure mutation.) + if ((state as 'opening' | 'open' | 'closed') === 'closed') parked.unpark(); // Pre-ack capacity / params rejection arrives as a JSON-RPC error // for the listen id; transport close is delivered the same way. void parked.terminated.then(({ reason, error }) => { diff --git a/packages/client/test/client/listen.test.ts b/packages/client/test/client/listen.test.ts index dfbfc24ce1..be697681e0 100644 --- a/packages/client/test/client/listen.test.ts +++ b/packages/client/test/client/listen.test.ts @@ -288,6 +288,35 @@ describe('Client.listen()', () => { expect(client.getNegotiatedProtocolVersion()).toBeUndefined(); }); + it('auto-open filter is configured ∩ server-advertised; empty intersection skips auto-open', async () => { + const filters: unknown[] = []; + // scriptedModern advertises tools.listChanged + prompts.listChanged but NOT resources. + const { clientTx } = await scriptedModern((_id, filter) => filters.push(filter)); + const onChanged = () => {}; + const client = new Client( + { name: 'c', version: '1' }, + // Configures tools + resources; server advertises tools + prompts. + { versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged }, resources: { onChanged } } } + ); + await client.connect(clientTx); + // Intersection = tools only. + expect(filters).toEqual([{ toolsListChanged: true }]); + expect(client.autoOpenedSubscription?.honoredFilter).toEqual({ toolsListChanged: true }); + await client.close(); + + // Empty intersection: configures resources only; server advertises tools+prompts. + const filters2: unknown[] = []; + const { clientTx: clientTx2 } = await scriptedModern((_id, filter) => filters2.push(filter)); + const client2 = new Client( + { name: 'c', version: '1' }, + { versionNegotiation: { mode: 'auto' }, listChanged: { resources: { onChanged } } } + ); + await client2.connect(clientTx2); + expect(filters2).toEqual([]); + expect(client2.autoOpenedSubscription).toBeUndefined(); + await client2.close(); + }); + it('a failed auto-open surfaces via onerror and does NOT fail connect', async () => { const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); serverTx.onmessage = m => { From e40c89fc6a345a98ed1630d80093cd8f071c4477 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 17:03:20 +0000 Subject: [PATCH 11/27] =?UTF-8?q?fix(client):=20McpSubscription.close()=20?= =?UTF-8?q?is=20transport-agnostic=20=E2=80=94=20always=20abort=20+=20send?= =?UTF-8?q?=20notifications/cancelled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/client/src/client/client.ts | 27 +++++++++++++--------- packages/client/test/client/listen.test.ts | 13 +++++++---- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 9be4376fe6..e349cb50f4 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -1228,10 +1228,12 @@ export class Client extends Protocol { * 2025-era unsolicited notifications fire on a legacy connection — so * `listen()` is era-transparent for consumers that already register those. * - * `close()` tears the subscription down: on Streamable HTTP it closes the - * listen request's SSE stream; on stdio it sends `notifications/cancelled` - * referencing the listen request id. No automatic re-listen — call - * `listen()` again to re-establish. + * `close()` tears the subscription down by aborting the listen request's + * `requestSignal` (closes the SSE stream where the transport honors it) + * AND sending `notifications/cancelled` referencing the listen request id + * — both, unconditionally, so any spec-compliant server on any transport + * sees the cancel. No automatic re-listen — call `listen()` again to + * re-establish. * * On a 2025-era connection this throws a typed * {@linkcode SdkErrorCode.MethodNotSupportedByProtocolVersion} steering to @@ -1263,7 +1265,6 @@ export class Client extends Protocol { } const requestAbort = new AbortController(); - const transportKind = detectProbeTransportKind(this.transport); // Explicit `opening → open → closed` state machine. Every termination // path — ack-arrives, ack-timeout, server-cancelled, user-close, @@ -1310,17 +1311,21 @@ export class Client extends Protocol { }; // Wire-level teardown for a locally-initiated close (user close or ack - // timeout): HTTP closes the request's SSE stream; stdio sends - // notifications/cancelled referencing the listen id. Not called when - // the server already terminated (error / server-cancelled). + // timeout). Transport-agnostic: ALWAYS abort the request signal (closes + // the SSE stream where the transport honors `requestSignal` — HTTP does, + // stdio does not) AND send `notifications/cancelled` referencing the + // listen id (which the stdio listen router and any spec-compliant + // server honor). Idempotent over HTTP — the cancelled notification is + // a no-op once the stream is gone; correct on every other transport. + // Not called when the server already terminated (error / server- + // cancelled). const wireTeardown = async (): Promise => { + requestAbort.abort(); const id = parked?.messageId; - if (transportKind === 'stdio' && id !== undefined) { + if (id !== undefined) { await this.transport ?.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: id } }) .catch(() => {}); - } else { - requestAbort.abort(); } }; diff --git a/packages/client/test/client/listen.test.ts b/packages/client/test/client/listen.test.ts index be697681e0..110c1d8ab3 100644 --- a/packages/client/test/client/listen.test.ts +++ b/packages/client/test/client/listen.test.ts @@ -2,8 +2,9 @@ * `Client.listen()` — the `subscriptions/listen` driver (protocol revision * 2026-07-28). Covers ack-resolved-promise, change-notification dispatch to * existing setNotificationHandler registrations, the F-12 legacy-era steer, - * stdio-style close (sends notifications/cancelled), inbound server-side - * cancel, and ClientOptions.listChanged auto-open on a modern connection. + * transport-agnostic close (always sends notifications/cancelled), inbound + * server-side cancel, and ClientOptions.listChanged auto-open on a modern + * connection. */ import type { JSONRPCMessage, JSONRPCNotification } from '@modelcontextprotocol/core'; import { InMemoryTransport, LATEST_PROTOCOL_VERSION, SdkError, SdkErrorCode, SUBSCRIPTION_ID_META_KEY } from '@modelcontextprotocol/core'; @@ -103,10 +104,12 @@ describe('Client.listen()', () => { await client.close(); }); - it('close() on a stdio-style transport sends notifications/cancelled referencing the listen id', async () => { + it('close() sends notifications/cancelled referencing the listen id on any transport', async () => { + // Plain InMemoryTransport (neither child-process nor SSE-stream + // semantics): close() must NOT depend on transport-kind detection — + // it always sends notifications/cancelled, so a spec-compliant server + // on InMemory / SSE / a custom transport tears the subscription down. const { clientTx, written } = await scriptedModern(); - // Make the in-memory transport quack like a stdio child-process transport. - Object.assign(clientTx, { stderr: null, pid: 1 }); const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(clientTx); const sub = await client.listen({ toolsListChanged: true }); From ed05d010405f2a5e12f0c0af576839aad5fedf9f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 17:21:55 +0000 Subject: [PATCH 12/27] =?UTF-8?q?fix(client):=20per-request=20abort=20in?= =?UTF-8?q?=20StreamableHTTP=20is=20a=20clean=20shutdown=20=E2=80=94=20no?= =?UTF-8?q?=20onerror,=20no=20reconnect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- packages/client/src/client/streamableHttp.ts | 28 ++++++++-- .../client/test/client/streamableHttp.test.ts | 53 +++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index d0c98c4615..cb82620355 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -50,6 +50,15 @@ export interface StartSSEOptions { * so that the response can be associated with the new resumed request. */ replayMessageId?: string | number; + + /** + * The per-request abort signal supplied by the caller via + * `TransportSendOptions.requestSignal`. When this signal is aborted the + * originating POST and its SSE response stream are torn down + * intentionally — `_handleSseStream` treats it exactly like the + * transport-level abort: no `onerror`, no reconnect. + */ + requestSignal?: AbortSignal; } /** @@ -391,7 +400,13 @@ export class StreamableHTTPClientTransport implements Transport { if (!stream) { return; } - const { onresumptiontoken, replayMessageId } = options; + const { onresumptiontoken, replayMessageId, requestSignal } = options; + // An intentional abort — transport-wide close OR a per-request abort + // (McpSubscription.close() aborting its `requestSignal`) — must read as + // a clean shutdown: no misleading "SSE stream disconnected" onerror, + // and no GET+Last-Event-ID reconnect that would resurrect a stream the + // caller just tore down. + const isIntentionalAbort = (): boolean => this._abortController?.signal.aborted === true || requestSignal?.aborted === true; let lastEventId: string | undefined; // Track whether we've received a priming event (event with ID) @@ -460,7 +475,7 @@ export class StreamableHTTPClientTransport implements Transport { // BUT don't reconnect if we already received a response - the request is complete const canResume = isReconnectable || hasPrimingEvent; const needsReconnect = canResume && !receivedResponse; - if (needsReconnect && this._abortController && !this._abortController.signal.aborted) { + if (needsReconnect && this._abortController && !isIntentionalAbort()) { this._scheduleReconnection( { resumptionToken: lastEventId, @@ -471,6 +486,11 @@ export class StreamableHTTPClientTransport implements Transport { ); } } catch (error) { + if (isIntentionalAbort()) { + // The reader threw because we aborted it. Not an error; do + // not surface onerror, do not reconnect. + return; + } // Handle stream errors - likely a network disconnect this.onerror?.(new Error(`SSE stream disconnected: ${error}`)); @@ -479,7 +499,7 @@ export class StreamableHTTPClientTransport implements Transport { // BUT don't reconnect if we already received a response - the request is complete const canResume = isReconnectable || hasPrimingEvent; const needsReconnect = canResume && !receivedResponse; - if (needsReconnect && this._abortController && !this._abortController.signal.aborted) { + if (needsReconnect && this._abortController && !isIntentionalAbort()) { // Use the exponential backoff reconnection strategy try { this._scheduleReconnection( @@ -699,7 +719,7 @@ export class StreamableHTTPClientTransport implements Transport { // Handle SSE stream responses for requests // We use the same handler as standalone streams, which now supports // reconnection with the last event ID - this._handleSseStream(response.body, { onresumptiontoken }, false); + this._handleSseStream(response.body, { onresumptiontoken, requestSignal: options?.requestSignal }, false); } else if (contentType?.includes('application/json')) { // For non-streaming servers, we might get direct JSON responses const data = await response.json(); diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 6542302c9d..ea20dfb113 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -1102,6 +1102,59 @@ describe('StreamableHTTPClientTransport', () => { expect(fetchMock.mock.calls[0]![1]?.method).toBe('POST'); }); + it('per-request requestSignal abort: no onerror, no reconnect (McpSubscription.close())', async () => { + // ARRANGE — a POST stream that has been primed with an SSE event id + // (server-side resumability), so without the per-request abort + // guard the transport WOULD schedule a GET+Last-Event-ID reconnect. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 10, + maxRetries: 1, + maxReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1 + } + }); + const errorSpy = vi.fn(); + transport.onerror = errorSpy; + + let streamController!: ReadableStreamDefaultController; + const primedStream = new ReadableStream({ + start(controller) { + streamController = controller; + // Priming event with an id — would arm POST-stream resumability. + controller.enqueue(new TextEncoder().encode('id: ev-1\ndata: \n\n')); + } + }); + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockImplementationOnce((_url, init: RequestInit) => { + // Propagate abort to the stream the way fetch does. + init.signal?.addEventListener('abort', () => streamController.error(init.signal?.reason), { once: true }); + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: primedStream + }); + }); + + const requestAbort = new AbortController(); + await transport.start(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen-1', params: {} }, + { requestSignal: requestAbort.signal } + ); + await vi.advanceTimersByTimeAsync(5); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // ACT — McpSubscription.close() aborts the per-request signal. + requestAbort.abort(); + await vi.advanceTimersByTimeAsync(50); + + // ASSERT — intentional per-request abort: no onerror, no reconnect. + expect(errorSpy).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it('should NOT reconnect a POST stream when error response was received', async () => { // ARRANGE transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { From 65d1298ee839c968f847259e5f4565da8b6253eb Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 17:23:08 +0000 Subject: [PATCH 13/27] =?UTF-8?q?fix(client):=20feature-detect=20AbortSign?= =?UTF-8?q?al.any=20(Node=20>=3D20=20floor=20includes=2020.0=E2=80=9320.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/client/src/client/streamableHttp.ts | 25 ++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index cb82620355..05fecb1ff5 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -173,6 +173,27 @@ export type StreamableHTTPClientTransportOptions = { protocolVersion?: string; }; +/** + * `AbortSignal.any` with a manual fallback. `AbortSignal.any` landed in + * Node 20.3; this package's `engines` floor is `>=20`, so 20.0–20.2 must be + * served by the fallback combinator (a controller that aborts on the first + * of `a` or `b`). The native path is preferred because it propagates the + * originating signal's `reason` and participates in GC the way the spec + * defines. + */ +function anySignal(a: AbortSignal, b: AbortSignal): AbortSignal { + if (typeof AbortSignal.any === 'function') { + return AbortSignal.any([a, b]); + } + const controller = new AbortController(); + if (a.aborted) return (controller.abort(a.reason), controller.signal); + if (b.aborted) return (controller.abort(b.reason), controller.signal); + const forward = (source: AbortSignal) => () => controller.abort(source.reason); + a.addEventListener('abort', forward(a), { once: true }); + b.addEventListener('abort', forward(b), { once: true }); + return controller.signal; +} + /** * Client transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification. * It will connect to a server using HTTP `POST` for sending messages and HTTP `GET` with Server-Sent Events @@ -592,11 +613,11 @@ export class StreamableHTTPClientTransport implements Transport { // Per-request abort: when the caller supplies a request-scoped // signal (the `subscriptions/listen` driver), aborting it cancels // this POST and its SSE response stream without closing the - // transport. AbortSignal.any is the standard combinator. + // transport. const transportSignal = this._abortController?.signal; const signal = options?.requestSignal !== undefined && transportSignal !== undefined - ? AbortSignal.any([transportSignal, options.requestSignal]) + ? anySignal(transportSignal, options.requestSignal) : (options?.requestSignal ?? transportSignal); const init = { ...this._requestInit, From 9f5e28ec30ffc7aac2ab86490957bc4a8182dc5b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 17:23:56 +0000 Subject: [PATCH 14/27] fix(client): settle() drops _listenState by the captured id, not via parked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/client/src/client/client.ts | 21 ++++++++---- packages/client/test/client/listen.test.ts | 39 ++++++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index e349cb50f4..70ced5836e 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -1274,6 +1274,12 @@ export class Client extends Protocol { // before-ack / close-before-ack hangs are impossible by construction. let state: 'opening' | 'open' | 'closed' = 'opening'; let parked: ReturnType | undefined; + // The listen request id, captured by `onBeforeSend` BEFORE the request + // goes out. `settle()` deletes `_listenState` by this id (not via + // `parked.messageId`), so a synchronously-delivered termination during + // `_parkRequest`'s send — when `parked` is still unassigned — does not + // leak the entry. + let listenMessageId: number | undefined; let ackTimer: ReturnType | undefined; let resolveOpening!: (honored: SubscriptionFilter) => void; let rejectOpening!: (error: Error) => void; @@ -1297,10 +1303,10 @@ export class Client extends Protocol { return; } state = 'closed'; - if (parked !== undefined) { - this._listenState.delete(parked.messageId); - parked.unpark(); + if (listenMessageId !== undefined) { + this._listenState.delete(listenMessageId); } + parked?.unpark(); if (wasOpening) { rejectOpening( outcome === 'closed' @@ -1321,7 +1327,7 @@ export class Client extends Protocol { // cancelled). const wireTeardown = async (): Promise => { requestAbort.abort(); - const id = parked?.messageId; + const id = listenMessageId; if (id !== undefined) { await this.transport ?.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: id } }) @@ -1359,6 +1365,7 @@ export class Client extends Protocol { }, { requestSignal: requestAbort.signal }, messageId => { + listenMessageId = messageId; this._listenState.set(messageId, { onAck: honored => settle({ ack: honored }), onServerCancel: () => { @@ -1369,8 +1376,10 @@ export class Client extends Protocol { ); // A synchronously-delivered termination during `send()` (an // in-process transport) ran `settle()` before `parked` was - // assigned — unpark now so the handler does not leak. (Cast: TS - // control-flow narrowing does not track closure mutation.) + // assigned; `settle()` already cleared `_listenState` via + // `listenMessageId` — unpark now so the response handler does not + // leak either. (Cast: TS control-flow narrowing does not track + // closure mutation.) if ((state as 'opening' | 'open' | 'closed') === 'closed') parked.unpark(); // Pre-ack capacity / params rejection arrives as a JSON-RPC error // for the listen id; transport close is delivered the same way. diff --git a/packages/client/test/client/listen.test.ts b/packages/client/test/client/listen.test.ts index 110c1d8ab3..831a589e7d 100644 --- a/packages/client/test/client/listen.test.ts +++ b/packages/client/test/client/listen.test.ts @@ -228,6 +228,42 @@ describe('Client.listen()', () => { await client.close(); }); + it('a synchronously-delivered server-cancel during send does not leak a _listenState entry', async () => { + // In-process delivery: the server's notifications/cancelled arrives + // inside `_parkRequest`'s send (before `parked` is assigned). settle() + // must still drop the `_listenState` entry it registered via + // onBeforeSend. + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = m => { + const req = m as { id?: number | string; method?: string }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: {}, + serverInfo: { name: 's', version: '1' } + } + }); + } + if (req.method === 'subscriptions/listen' && req.id !== undefined) { + void serverTx.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: req.id } }); + } + }; + await serverTx.start(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const listenState = (client as unknown as { _listenState: Map })._listenState; + const before = listenState.size; + const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error); + expect((error as Error).message).toContain('server cancelled before acknowledging'); + // No leaked _listenState entry for the listen id. + expect(listenState.size).toBe(before); + await client.close(); + }); + it('a synchronous transport.send throw does not leak a _responseHandlers entry', async () => { const { clientTx } = await scriptedModern(); const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); @@ -242,6 +278,9 @@ describe('Client.listen()', () => { expect((error as Error).message).toContain('send blew up'); // The park primitive unregistered before rethrowing — no leak. expect(handlers.size).toBe(before); + // settle() in the catch path also dropped the _listenState entry that + // onBeforeSend registered before send threw. + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); clientTx.send = realSend; await client.close(); }); From 178d325284e95e133222b395984f2e4f235281aa Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 17:24:45 +0000 Subject: [PATCH 15/27] fix(client): listen() honors RequestOptions.signal 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. --- packages/client/src/client/client.ts | 24 +++++++++ packages/client/test/client/listen.test.ts | 60 ++++++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 70ced5836e..3f567da3a1 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -1260,6 +1260,10 @@ export class Client extends Protocol { ); } + // Honor RequestOptions.signal exactly as request() does: an + // already-aborted signal rejects synchronously before any setup. + options?.signal?.throwIfAborted(); + if (this._onParkedNotification === undefined) { this._onParkedNotification = raw => this._listenFirstLook(raw); } @@ -1281,6 +1285,7 @@ export class Client extends Protocol { // leak the entry. let listenMessageId: number | undefined; let ackTimer: ReturnType | undefined; + let onCallerAbort: (() => void) | undefined; let resolveOpening!: (honored: SubscriptionFilter) => void; let rejectOpening!: (error: Error) => void; const opening = new Promise((resolve, reject) => { @@ -1303,6 +1308,9 @@ export class Client extends Protocol { return; } state = 'closed'; + if (onCallerAbort !== undefined) { + options?.signal?.removeEventListener('abort', onCallerAbort); + } if (listenMessageId !== undefined) { this._listenState.delete(listenMessageId); } @@ -1347,6 +1355,22 @@ export class Client extends Protocol { void wireTeardown().catch(() => {}); }, ackTimeout); + // RequestOptions.signal aborts the subscription at any point in its + // lifecycle (mirrors request()'s cancel path). While `opening`, settle + // rejects the pending listen() promise with the signal's reason; while + // `open`, it transitions to `closed` and tears the wire down. The + // listener is removed by `settle()` once the subscription has closed. + if (options?.signal) { + const callerSignal = options.signal; + onCallerAbort = () => { + if (state === 'closed') return; + const reason = callerSignal.reason; + settle({ error: reason instanceof Error ? reason : new Error(String(reason ?? 'Aborted')) }); + void wireTeardown().catch(() => {}); + }; + callerSignal.addEventListener('abort', onCallerAbort, { once: true }); + } + try { // The per-subscription state is registered BEFORE the request is // sent (`onBeforeSend`) so a synchronously-delivered ack (an diff --git a/packages/client/test/client/listen.test.ts b/packages/client/test/client/listen.test.ts index 831a589e7d..502280b6bd 100644 --- a/packages/client/test/client/listen.test.ts +++ b/packages/client/test/client/listen.test.ts @@ -285,6 +285,66 @@ describe('Client.listen()', () => { await client.close(); }); + it('options.signal aborted while opening: listen() rejects fast with the signal reason', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + serverTx.onmessage = m => { + written.push(m); + const req = m as { id?: number | string; method?: string }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: {}, + serverInfo: { name: 's', version: '1' } + } + }); + } + // No ack for subscriptions/listen — stays in `opening`. + }; + await serverTx.start(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const ac = new AbortController(); + const t0 = Date.now(); + const pending = client.listen({ toolsListChanged: true }, { signal: ac.signal }); + ac.abort(new Error('caller-abort')); + const error = await pending.catch(e => e as Error); + expect((error as Error).message).toBe('caller-abort'); + expect(Date.now() - t0).toBeLessThan(1000); + // wireTeardown sent notifications/cancelled referencing the listen id. + await flush(); + const listenId = (written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { id: number | string }).id; + const cancelled = written.find(m => (m as { method?: string }).method === 'notifications/cancelled') as + | { params: { requestId: unknown } } + | undefined; + expect(cancelled?.params.requestId).toBe(listenId); + // No leaked state. + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + expect((client as unknown as { _responseHandlers: Map })._responseHandlers.size).toBe(0); + await client.close(); + }); + + it('options.signal aborted while open: closes the subscription (notifications/cancelled sent)', async () => { + const { clientTx, written } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const ac = new AbortController(); + const sub = await client.listen({ toolsListChanged: true }, { signal: ac.signal }); + const listenId = (written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { id: number | string }).id; + written.length = 0; + ac.abort(); + await flush(); + expect(written).toEqual([{ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } }]); + // close() after signal-abort is idempotent. + await sub.close(); + expect(written).toHaveLength(1); + await client.close(); + }); + it('rejects with NotConnected (as a rejected promise, no setup) when no transport is connected', async () => { const { clientTx } = await scriptedModern(); const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); From 1b9b64ad3d595559c0299a10fae7aef761fad963 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 17:25:29 +0000 Subject: [PATCH 16/27] =?UTF-8?q?docs:=20auto-open=20listen=20filter=20is?= =?UTF-8?q?=20configured=20=E2=88=A9=20server-advertised=20everywhere?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The behavior landed in ce9251cbc 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. --- .changeset/subscriptions-listen-client.md | 2 +- docs/migration.md | 4 ++-- examples/client/src/subscriptionsListenClient.ts | 6 ++++-- packages/client/src/client/client.ts | 8 +++++--- packages/client/test/client/listen.test.ts | 2 +- test/e2e/requirements.ts | 2 +- 6 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.changeset/subscriptions-listen-client.md b/.changeset/subscriptions-listen-client.md index 29555a9943..bcc62ff65b 100644 --- a/.changeset/subscriptions-listen-client.md +++ b/.changeset/subscriptions-listen-client.md @@ -3,4 +3,4 @@ '@modelcontextprotocol/client': minor --- -`Client.listen(filter)` opens a `subscriptions/listen` stream on a 2026-07-28-era connection, resolving once the server's acknowledged notification arrives with an `McpSubscription { honoredFilter, close() }`. Change notifications delivered on the stream dispatch to the existing `setNotificationHandler` registrations — the same handlers the 2025-era unsolicited notifications fire on a legacy connection — so `listen()` is 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` steering to `resources/subscribe` and `ClientOptions.listChanged`. `ClientOptions.listChanged` now auto-opens a listen stream on a modern connection (filter derived from which sub-options were set; the auto-opened subscription is exposed at `client.autoOpenedSubscription`). `TransportSendOptions` gains `requestSignal` for per-request abort on the Streamable HTTP transport. +`Client.listen(filter)` opens a `subscriptions/listen` stream on a 2026-07-28-era connection, resolving once the server's acknowledged notification arrives with an `McpSubscription { honoredFilter, close() }`. Change notifications delivered on the stream dispatch to the existing `setNotificationHandler` registrations — the same handlers the 2025-era unsolicited notifications fire on a legacy connection — so `listen()` is 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` steering to `resources/subscribe` and `ClientOptions.listChanged`. `ClientOptions.listChanged` now auto-opens a listen stream on a modern connection — the filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities; auto-open is skipped (`client.autoOpenedSubscription` stays `undefined`) when that intersection is empty; otherwise the auto-opened subscription is exposed at `client.autoOpenedSubscription`. `TransportSendOptions` gains `requestSignal` for per-request abort on the Streamable HTTP transport. diff --git a/docs/migration.md b/docs/migration.md index 7f6f7e74df..fa0447cce7 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1172,8 +1172,8 @@ const handler = createMcpHandler(() => buildServer()); handler.notify.toolsChanged(); ``` -**Client side.** `ClientOptions.listChanged` keeps working: on a 2026-07-28 connection the SDK auto-opens a `subscriptions/listen` stream with the filter derived from which sub-options were set, so the same handlers fire on every published change (the auto-opened subscription is -exposed at `client.autoOpenedSubscription` for `close()`). `client.listen(filter)` opens a stream explicitly and resolves once the server's acknowledged notification arrives with `{ honoredFilter, close() }`; change notifications dispatch to the existing `setNotificationHandler` +**Client side.** `ClientOptions.listChanged` keeps working: on a 2026-07-28 connection the SDK auto-opens a `subscriptions/listen` stream whose filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities, so the same handlers +fire on every published change (the auto-opened subscription is exposed at `client.autoOpenedSubscription` for `close()`; when the intersection is empty auto-open is skipped and `autoOpenedSubscription` stays `undefined`). `client.listen(filter)` opens a stream explicitly and resolves once the server's acknowledged notification arrives with `{ honoredFilter, close() }`; change notifications dispatch to the existing `setNotificationHandler` registrations. `resources/subscribe` is 2025-only — on a 2026-07-28 connection, request `notifications/resources/updated` via the `resourceSubscriptions` field of the listen filter instead. ### Multi round-trip requests (2026-07-28): write-once handlers and the client auto-fulfilment driver diff --git a/examples/client/src/subscriptionsListenClient.ts b/examples/client/src/subscriptionsListenClient.ts index 4925e3a7e5..f92dce780f 100644 --- a/examples/client/src/subscriptionsListenClient.ts +++ b/examples/client/src/subscriptionsListenClient.ts @@ -5,8 +5,10 @@ * * 1. **auto-open via `ClientOptions.listChanged`** — the same option a * 2025-era client sets; on a modern connection the SDK auto-opens a - * listen stream with the filter derived from which sub-options were set, - * so the configured `onChanged` handlers fire on every published change; + * listen stream whose filter is the intersection of the configured + * sub-options and the server-advertised `listChanged` capabilities + * (auto-open is skipped when the intersection is empty), so the + * configured `onChanged` handlers fire on every published change; * 2. **manual `client.listen()`** — opens a stream explicitly, registers a * `notifications/tools/list_changed` handler the stream feeds, and closes * after a few notifications. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 3f567da3a1..6c3abdcd78 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -1423,9 +1423,11 @@ export class Client extends Protocol { /** * The subscription auto-opened by `ClientOptions.listChanged` on a modern - * connection (the listen filter derived from which sub-options were set), - * or `undefined` on a legacy connection or before connect. Exposed so the - * consumer can `close()` it. + * connection — the listen filter is the intersection of the configured + * sub-options and the server-advertised `listChanged` capabilities. + * `undefined` on a legacy connection, before connect, or when that + * intersection is empty (auto-open skipped). Exposed so the consumer can + * `close()` it. */ get autoOpenedSubscription(): McpSubscription | undefined { return this._autoOpenedSubscription; diff --git a/packages/client/test/client/listen.test.ts b/packages/client/test/client/listen.test.ts index 502280b6bd..61b526c507 100644 --- a/packages/client/test/client/listen.test.ts +++ b/packages/client/test/client/listen.test.ts @@ -358,7 +358,7 @@ describe('Client.listen()', () => { expect((error as SdkError).code).toBe(SdkErrorCode.NotConnected); }); - it('ClientOptions.listChanged auto-opens a listen stream on a modern connection (filter derived from sub-options)', async () => { + it('ClientOptions.listChanged auto-opens a listen stream on a modern connection (filter = configured ∩ server-advertised)', async () => { const filters: unknown[] = []; const { clientTx } = await scriptedModern((_id, filter) => filters.push(filter)); const onChanged = () => {}; diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index a60ea5e59d..f2a40b86c1 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2695,7 +2695,7 @@ export const REQUIREMENTS: Record = { 'typescript:subscriptions:listChanged-auto-open-modern': { source: 'sdk', behavior: - 'ClientOptions.listChanged auto-opens a subscriptions/listen stream on a modern connection (filter derived from which sub-options were set), so the configured handlers fire on every published change. The auto-opened subscription is exposed for close.', + 'ClientOptions.listChanged auto-opens a subscriptions/listen stream on a modern connection — the filter is the intersection of the configured sub-options and the server-advertised listChanged capabilities (auto-open is skipped and autoOpenedSubscription stays undefined when the intersection is empty) — so the configured handlers fire on every published change. The auto-opened subscription is exposed for close.', addedInSpecVersion: '2026-07-28', transports: ['entryModern'], note: 'Hosted by the test body via createMcpHandler so it can publish via handler.notify.' From dceef7ccd3ade79dd7c81d086dcf985126065a7d Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 17:40:35 +0000 Subject: [PATCH 17/27] fix(client): listChanged config is durable (read fresh per connect, never consumed); align close() prose --- .changeset/subscriptions-listen-client.md | 2 +- packages/client/src/client/client.ts | 25 +++++++++++++---------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.changeset/subscriptions-listen-client.md b/.changeset/subscriptions-listen-client.md index bcc62ff65b..a879fab5e0 100644 --- a/.changeset/subscriptions-listen-client.md +++ b/.changeset/subscriptions-listen-client.md @@ -3,4 +3,4 @@ '@modelcontextprotocol/client': minor --- -`Client.listen(filter)` opens a `subscriptions/listen` stream on a 2026-07-28-era connection, resolving once the server's acknowledged notification arrives with an `McpSubscription { honoredFilter, close() }`. Change notifications delivered on the stream dispatch to the existing `setNotificationHandler` registrations — the same handlers the 2025-era unsolicited notifications fire on a legacy connection — so `listen()` is 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` steering to `resources/subscribe` and `ClientOptions.listChanged`. `ClientOptions.listChanged` now auto-opens a listen stream on a modern connection — the filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities; auto-open is skipped (`client.autoOpenedSubscription` stays `undefined`) when that intersection is empty; otherwise the auto-opened subscription is exposed at `client.autoOpenedSubscription`. `TransportSendOptions` gains `requestSignal` for per-request abort on the Streamable HTTP transport. +`Client.listen(filter)` opens a `subscriptions/listen` stream on a 2026-07-28-era connection, resolving once the server's acknowledged notification arrives with an `McpSubscription { honoredFilter, close() }`. Change notifications delivered on the stream dispatch to the existing `setNotificationHandler` registrations — the same handlers the 2025-era unsolicited notifications fire on a legacy connection — so `listen()` is era-transparent for consumers that already register those. `close()` aborts the listen request's stream (where the transport supports it) and sends `notifications/cancelled` referencing the listen id — both, on every transport; no automatic re-listen. On a 2025-era connection `listen()` throws a typed `MethodNotSupportedByProtocolVersion` steering to `resources/subscribe` and `ClientOptions.listChanged`. `ClientOptions.listChanged` now auto-opens a listen stream on a modern connection — the filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities; auto-open is skipped (`client.autoOpenedSubscription` stays `undefined`) when that intersection is empty; otherwise the auto-opened subscription is exposed at `client.autoOpenedSubscription`. `TransportSendOptions` gains `requestSignal` for per-request abort on the Streamable HTTP transport. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 6c3abdcd78..9f7f3e66bc 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -267,9 +267,10 @@ export interface McpSubscription { */ readonly honoredFilter: SubscriptionFilter; /** - * Tears the subscription down. Idempotent. On Streamable HTTP this closes - * the listen request's SSE stream; on stdio it sends - * `notifications/cancelled` referencing the listen request id. + * Tears the subscription down. Idempotent. Aborts the listen request's + * stream (where the transport supports it) AND sends + * `notifications/cancelled` referencing the listen request id — both, + * always, so close works on any transport. */ close(): Promise; } @@ -322,7 +323,11 @@ export class Client extends Protocol { private _jsonSchemaValidator: jsonSchemaValidator; private _cachedToolOutputValidators: Map> = new Map(); private _listChangedDebounceTimers: Map> = new Map(); - private _pendingListChangedConfig?: ListChangedHandlers; + /** + * The constructor `listChanged` configuration. Durable across reconnects: + * read fresh on every connect (legacy or modern), never consumed. + */ + private readonly _listChangedConfig?: ListChangedHandlers; private _enforceStrictCapabilities: boolean; private _versionNegotiation?: VersionNegotiationOptions; private _supportedProtocolVersionsOption?: string[]; @@ -372,7 +377,7 @@ export class Client extends Protocol { // Store list changed config for setup after connection (when we know server capabilities) if (options?.listChanged) { - this._pendingListChangedConfig = options.listChanged; + this._listChangedConfig = options.listChanged; } } @@ -784,9 +789,8 @@ export class Client extends Protocol { this._negotiatedProtocolVersion = result.protocolVersion; // Set up list changed handlers now that we know server capabilities - if (this._pendingListChangedConfig) { - this._setupListChangedHandlers(this._pendingListChangedConfig); - this._pendingListChangedConfig = undefined; + if (this._listChangedConfig) { + this._setupListChangedHandlers(this._listChangedConfig); } } catch (error) { // Disconnect if initialization fails. @@ -860,9 +864,8 @@ export class Client extends Protocol { // subscriptions/listen stream (the modern era never delivers change // notifications unsolicited); on a legacy connection they fire on the // 2025-era unsolicited notifications, no listen needed. - if (this._pendingListChangedConfig) { - const config = this._pendingListChangedConfig; - this._pendingListChangedConfig = undefined; + if (this._listChangedConfig) { + const config = this._listChangedConfig; // Compute configured ∩ server-advertised ONCE and use that single // value for BOTH handler registration and the auto-open filter, so // a configured-but-not-advertised type is neither subscribed to From c1abaff0de783863163f7718cbdeb77d491197e9 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 18:10:44 +0000 Subject: [PATCH 18/27] fix(client): pass through unmatched listen acks; reword server-cancel error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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. --- packages/client/src/client/client.ts | 12 ++++++++++-- packages/client/test/client/listen.test.ts | 4 ++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 9f7f3e66bc..a27cee07a6 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -1396,7 +1396,11 @@ export class Client extends Protocol { this._listenState.set(messageId, { onAck: honored => settle({ ack: honored }), onServerCancel: () => { - settle({ error: new Error('subscriptions/listen: server cancelled before acknowledging') }); + // Handles BOTH the pre-ack and post-ack server-side + // cancel: while opening, settle rejects the pending + // listen() promise; once open, settle just + // transitions to closed and the message is unused. + settle({ error: new Error('subscriptions/listen: server cancelled the subscription') }); } }); } @@ -1456,7 +1460,11 @@ export class Client extends Protocol { return 'consumed'; } } - return 'consumed'; + // An ack referencing no parked listen on this connection is NOT + // consumed: pass it through so a stray/foreign ack reaches + // setNotificationHandler / fallthroughNotificationHandler instead + // of being silently swallowed. + return undefined; } if (raw.method === 'notifications/cancelled') { const cancelledId = (raw.params as { requestId?: unknown } | undefined)?.requestId; diff --git a/packages/client/test/client/listen.test.ts b/packages/client/test/client/listen.test.ts index 61b526c507..ee5fd63212 100644 --- a/packages/client/test/client/listen.test.ts +++ b/packages/client/test/client/listen.test.ts @@ -196,7 +196,7 @@ describe('Client.listen()', () => { const t0 = Date.now(); const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error); expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain('server cancelled before acknowledging'); + expect((error as Error).message).toContain('server cancelled the subscription'); // Rejected promptly (well under the 60s ack timeout). expect(Date.now() - t0).toBeLessThan(1000); // No leaked _responseHandlers entry for the listen id. @@ -258,7 +258,7 @@ describe('Client.listen()', () => { const listenState = (client as unknown as { _listenState: Map })._listenState; const before = listenState.size; const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error); - expect((error as Error).message).toContain('server cancelled before acknowledging'); + expect((error as Error).message).toContain('server cancelled the subscription'); // No leaked _listenState entry for the listen id. expect(listenState.size).toBe(before); await client.close(); From cd882dd690980bce67d3f4c25b0a68a569fac6ac Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 18:11:13 +0000 Subject: [PATCH 19/27] fix(client): settle live listen state on connection reset; reset even when transport close rejects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/client/src/client/client.ts | 29 +++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index a27cee07a6..29c223b758 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -279,6 +279,8 @@ export interface McpSubscription { interface ListenStateEntry { onAck: (honored: SubscriptionFilter) => void; onServerCancel: () => void; + /** Settle the per-listen machine with an explicit error (used by `_resetConnectionState`). */ + onConnectionReset: (error: Error) => void; } /** @@ -349,13 +351,33 @@ export class Client extends Protocol { this._serverVersion = undefined; this._instructions = undefined; this._autoOpenedSubscription = undefined; + // Settle every live per-listen state machine before clearing the map: + // a fresh connect (or close) on a connection whose prior transport + // never fired onclose would otherwise leave an in-flight listen() + // promise hanging forever. Each entry's settle() deletes itself from + // the map, so iterate over a snapshot. + if (this._listenState.size > 0) { + const reason = new SdkError( + SdkErrorCode.ConnectionClosed, + 'subscriptions/listen: client reconnected or closed; subscription state from the previous connection was reset' + ); + for (const entry of [...this._listenState.values()]) { + entry.onConnectionReset(reason); + } + } this._listenState.clear(); this._cachedToolOutputValidators.clear(); } override async close(): Promise { - await super.close(); - this._resetConnectionState(); + try { + await super.close(); + } finally { + // Per-connection state is cleared even when the transport's close + // rejects, so a stale negotiated era / live listen state cannot + // survive a failed close. + this._resetConnectionState(); + } } /** @@ -1401,7 +1423,8 @@ export class Client extends Protocol { // listen() promise; once open, settle just // transitions to closed and the message is unused. settle({ error: new Error('subscriptions/listen: server cancelled the subscription') }); - } + }, + onConnectionReset: error => settle({ error }) }); } ); From 7619694cd5b3d2c0aee4c40ac450df569ad547ec Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 18:13:37 +0000 Subject: [PATCH 20/27] test(client): cover the listen() state machine's termination paths 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. --- packages/client/src/client/client.ts | 10 +- packages/client/test/client/listen.test.ts | 204 ++++++++++++++++++++- 2 files changed, 208 insertions(+), 6 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 29c223b758..8e6eb3b94f 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -354,14 +354,14 @@ export class Client extends Protocol { // Settle every live per-listen state machine before clearing the map: // a fresh connect (or close) on a connection whose prior transport // never fired onclose would otherwise leave an in-flight listen() - // promise hanging forever. Each entry's settle() deletes itself from - // the map, so iterate over a snapshot. + // promise hanging forever. Each entry's settle() deletes only itself + // (Map self-delete during iteration is well-defined). if (this._listenState.size > 0) { const reason = new SdkError( SdkErrorCode.ConnectionClosed, 'subscriptions/listen: client reconnected or closed; subscription state from the previous connection was reset' ); - for (const entry of [...this._listenState.values()]) { + for (const entry of this._listenState.values()) { entry.onConnectionReset(reason); } } @@ -1485,8 +1485,8 @@ export class Client extends Protocol { } // An ack referencing no parked listen on this connection is NOT // consumed: pass it through so a stray/foreign ack reaches - // setNotificationHandler / fallthroughNotificationHandler instead - // of being silently swallowed. + // setNotificationHandler / fallbackNotificationHandler instead of + // being silently swallowed. return undefined; } if (raw.method === 'notifications/cancelled') { diff --git a/packages/client/test/client/listen.test.ts b/packages/client/test/client/listen.test.ts index ee5fd63212..99583895f0 100644 --- a/packages/client/test/client/listen.test.ts +++ b/packages/client/test/client/listen.test.ts @@ -8,7 +8,7 @@ */ import type { JSONRPCMessage, JSONRPCNotification } from '@modelcontextprotocol/core'; import { InMemoryTransport, LATEST_PROTOCOL_VERSION, SdkError, SdkErrorCode, SUBSCRIPTION_ID_META_KEY } from '@modelcontextprotocol/core'; -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Client } from '../../src/client/client.js'; @@ -48,6 +48,33 @@ async function scriptedModern(onListen?: (id: number | string, filter: unknown, return { clientTx, serverTx, written }; } +/** + * Like `scriptedModern` but does NOT auto-ack `subscriptions/listen`: the + * test drives ack / cancel / transport-close itself. + */ +async function scriptedModernNoAck() { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const written: JSONRPCMessage[] = []; + serverTx.onmessage = message => { + written.push(message); + const req = message as { id?: number | string; method?: string }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: { listChanged: true }, prompts: { listChanged: true } }, + serverInfo: { name: 'scripted', version: '1' } + } + }); + } + }; + await serverTx.start(); + return { clientTx, serverTx, written }; +} + describe('Client.listen()', () => { it('throws a typed steer on a legacy-era connection (no wire write)', async () => { const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); @@ -455,4 +482,179 @@ describe('Client.listen()', () => { expect((errors[0] as { code?: number }).code).toBe(-32_603); await client.close(); }); + + it('transport closes BEFORE the ack: listen() rejects fast', async () => { + const { clientTx, serverTx } = await scriptedModernNoAck(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const t0 = Date.now(); + const pending = client.listen({ toolsListChanged: true }); + await flush(); + // Server-side transport closes before ever acking → Protocol._onclose + // errors every parked _responseHandlers entry → settle({error}). + await serverTx.close(); + const error = await pending.catch(e => e as Error); + expect(error).toBeInstanceOf(Error); + expect(Date.now() - t0).toBeLessThan(1000); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + expect((client as unknown as { _responseHandlers: Map })._responseHandlers.size).toBe(0); + }); + + it('transport closes WHILE the subscription is open: subscription transitions to closed; close() is a no-op', async () => { + const { clientTx, serverTx, written } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(1); + await serverTx.close(); + await flush(); + // Transport-close settled the per-listen machine; nothing leaks. + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + // sub.close() after transport-close is a no-op (state already 'closed'): + // no notifications/cancelled lands on a future connection. + written.length = 0; + await sub.close(); + expect(written.some(m => (m as { method?: string }).method === 'notifications/cancelled')).toBe(false); + }); + + it('concurrent listens are independent (each ack resolves its own promise; closing one leaves the other open)', async () => { + const ids: (number | string)[] = []; + const { clientTx, written } = await scriptedModern(id => ids.push(id)); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const [a, b] = await Promise.all([client.listen({ toolsListChanged: true }), client.listen({ promptsListChanged: true })]); + expect(a.honoredFilter).toEqual({ toolsListChanged: true }); + expect(b.honoredFilter).toEqual({ promptsListChanged: true }); + expect(ids).toHaveLength(2); + expect(ids[0]).not.toBe(ids[1]); + const listenState = (client as unknown as { _listenState: Map })._listenState; + expect(listenState.size).toBe(2); + written.length = 0; + await a.close(); + // Only `a`'s id is cancelled; `b` stays open. + expect(written).toEqual([{ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: ids[0] } }]); + expect(listenState.size).toBe(1); + await b.close(); + expect(listenState.size).toBe(0); + await client.close(); + }); + + it('after close(): nothing further dispatched into the per-listen machine; late ack passes through unconsumed', async () => { + let listenId!: number | string; + let send!: (m: JSONRPCMessage) => void; + const { clientTx } = await scriptedModern((id, _f, s) => { + listenId = id; + send = s; + }); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + await sub.close(); + // The per-listen entry is gone; a late server-side ack and a late + // server-side cancel for this id are NOT consumed by the first-look + // hook (no parked entry matches) and reach the fallback handler. + const fallback: string[] = []; + client.fallbackNotificationHandler = async n => { + fallback.push(n.method); + }; + send({ + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: listenId }, notifications: {} } + }); + send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification); + await flush(); + expect(fallback).toContain('notifications/subscriptions/acknowledged'); + // The state machine stayed closed throughout (no leak, no resurrection). + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + await client.close(); + }); + + it('an unmatched ack passes through to fallbackNotificationHandler (not silently swallowed)', async () => { + const { clientTx, serverTx } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + const fallback: string[] = []; + client.fallbackNotificationHandler = async n => { + fallback.push(n.method); + }; + await client.connect(clientTx); + // One listen is active; a stray ack referencing a FOREIGN id must + // reach the fallback handler instead of being silently swallowed. + const sub = await client.listen({ toolsListChanged: true }); + await serverTx.send({ + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { _meta: { [SUBSCRIPTION_ID_META_KEY]: 'foreign-id' }, notifications: {} } + }); + await flush(); + expect(fallback).toEqual(['notifications/subscriptions/acknowledged']); + await sub.close(); + await client.close(); + }); + + it('a fresh connect without an intervening close settles in-flight listen() from the prior connection', async () => { + // Edge: prior transport never fires onclose; consumer calls connect() + // again. The in-flight listen() promise from the old connection must + // reject with a clear "client reconnected/closed" error rather than + // hang on the (now-discarded) ack timer. + const { clientTx } = await scriptedModernNoAck(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const pending = client.listen({ toolsListChanged: true }); + await flush(); + const handlers = (client as unknown as { _responseHandlers: Map })._responseHandlers; + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(1); + // Fresh connect on a new transport — _resetConnectionState runs. + const { clientTx: clientTx2 } = await scriptedModern(); + await client.connect(clientTx2); + const error = await pending.catch(e => e as Error); + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.ConnectionClosed); + expect((error as SdkError).message).toContain('reconnected or closed'); + // No leaked parked handler from the old connection. + expect(handlers.size).toBe(0); + await client.close(); + }); + + it('close() resets per-connection state even when transport.close() rejects', async () => { + const { clientTx } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + clientTx.close = () => Promise.reject(new Error('close blew up')); + await expect(client.close()).rejects.toThrow('close blew up'); + // Per-connection state was cleared regardless. + expect(client.getNegotiatedProtocolVersion()).toBeUndefined(); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + }); +}); + +describe('Client.listen() — ack timeout (fake timers)', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('ack timer firing rejects with RequestTimeout and tears the wire down', async () => { + const { clientTx, written } = await scriptedModernNoAck(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + const connecting = client.connect(clientTx); + await vi.runAllTimersAsync(); + await connecting; + const pending = client.listen({ toolsListChanged: true }, { timeout: 1000 }); + // Capture rejection to avoid an unhandled-rejection on the timer tick. + const settled = pending.catch(e => e as SdkError); + await vi.advanceTimersByTimeAsync(1000); + const error = await settled; + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.RequestTimeout); + // wireTeardown sent notifications/cancelled referencing the listen id. + const listenId = (written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { id: number | string }).id; + const cancelled = written.find(m => (m as JSONRPCNotification).method === 'notifications/cancelled'); + expect(cancelled).toMatchObject({ params: { requestId: listenId } }); + // No leaked state. + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + expect((client as unknown as { _responseHandlers: Map })._responseHandlers.size).toBe(0); + // Restore real timers before close to avoid hanging on transport timers. + vi.useRealTimers(); + await client.close(); + }); }); From 649aff883cb6991d27acc5ea3599578a0df88cbd Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 18:14:12 +0000 Subject: [PATCH 21/27] test(e2e): subscriptions/listen honored filter narrows against advertised 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. --- test/e2e/requirements.ts | 8 ++++++++ test/e2e/scenarios/subscriptions.test.ts | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index f2a40b86c1..5058dcdd5d 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2684,6 +2684,14 @@ export const REQUIREMENTS: Record = { transports: ['entryModern'], note: 'Hosted by the test body via createMcpHandler so it can publish via handler.notify.' }, + 'subscriptions:listen:honored-filter-narrows-to-advertised': { + source: 'https://modelcontextprotocol.io/specification/draft/basic/patterns/subscriptions#acknowledgment', + behavior: + "The acknowledged filter on a subscriptions/listen stream is the requested set narrowed against the server's declared listChanged/subscribe capability bits — a requested type the server does not advertise is dropped from honoredFilter and is never delivered.", + addedInSpecVersion: '2026-07-28', + transports: ['entryModern'], + note: 'Hosted by the test body via createMcpHandler so it can publish via handler.notify. 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.' + }, 'subscriptions:listen:capacity-guard': { source: 'sdk', behavior: diff --git a/test/e2e/scenarios/subscriptions.test.ts b/test/e2e/scenarios/subscriptions.test.ts index e4cd1bc8d3..bbca6105d5 100644 --- a/test/e2e/scenarios/subscriptions.test.ts +++ b/test/e2e/scenarios/subscriptions.test.ts @@ -114,6 +114,26 @@ verifies('typescript:subscriptions:listen:legacy-era-steer', async ({ transport expect((error as SdkError).message).toContain('resources/subscribe'); }); +verifies('subscriptions:listen:honored-filter-narrows-to-advertised', async () => { + // makeServer registers a tool but no prompts/resources: a listen requesting + // toolsListChanged + promptsListChanged + resourcesListChanged must come + // back honored as toolsListChanged only — the ack reflects only what the + // server advertises. + await using h = await hostListen(); + const sub = await h.client.listen({ toolsListChanged: true, promptsListChanged: true, resourcesListChanged: true }); + expect(sub.honoredFilter).toEqual({ toolsListChanged: true }); + // And nothing the server doesn't advertise reaches the stream: the entry + // delivers via the same narrowed filter it acknowledged. + const seen: string[] = []; + h.client.setNotificationHandler('notifications/prompts/list_changed', () => void seen.push('prompts')); + h.client.setNotificationHandler('notifications/tools/list_changed', () => void seen.push('tools')); + h.handler.notify.promptsChanged(); + h.handler.notify.toolsChanged(); + await new Promise(r => setTimeout(r, 30)); + expect(seen).toEqual(['tools']); + await sub.close(); +}); + verifies('subscriptions:listen:capacity-guard', async () => { const handler = createMcpHandler(() => makeServer(), { legacy: 'reject', keepAliveMs: 0, maxSubscriptions: 1 }); const post = (id: number) => From 28ff64bfaf0a5420a67b389cf3c7db01071c75e0 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 18:37:23 +0000 Subject: [PATCH 22/27] fix(client): listen auto-open inherits only ack timeout; thread requestSignal 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. --- packages/client/src/client/client.ts | 13 ++++++- packages/client/src/client/streamableHttp.ts | 28 +++++++++++--- packages/client/test/client/listen.test.ts | 33 +++++++++++++++++ .../client/test/client/streamableHttp.test.ts | 37 +++++++++++++++++++ 4 files changed, 105 insertions(+), 6 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 8e6eb3b94f..b8b76b1f7a 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -909,8 +909,19 @@ export class Client extends Protocol { // connection is fully usable without a listen stream (the // server may not support it, or refuse on capacity). Surface // via onerror; the consumer can call listen() later. + // + // Forward ONLY the ack timeout from connect()'s options. + // listen() binds RequestOptions.signal to the SUBSCRIPTION + // lifetime, so a connect-scoped signal (e.g. + // `AbortSignal.timeout(30_000)` for the handshake) would tear + // the auto-opened stream down the moment it fires after + // connect has already resolved. Connect's signal governs the + // handshake only; the auto-opened subscription outlives it. try { - this._autoOpenedSubscription = await this.listen(filter, options); + this._autoOpenedSubscription = await this.listen( + filter, + options?.timeout === undefined ? undefined : { timeout: options.timeout } + ); } catch (error) { this.onerror?.(error instanceof Error ? error : new Error(String(error))); } diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 05fecb1ff5..6e1579e27b 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -188,9 +188,25 @@ function anySignal(a: AbortSignal, b: AbortSignal): AbortSignal { const controller = new AbortController(); if (a.aborted) return (controller.abort(a.reason), controller.signal); if (b.aborted) return (controller.abort(b.reason), controller.signal); - const forward = (source: AbortSignal) => () => controller.abort(source.reason); - a.addEventListener('abort', forward(a), { once: true }); - b.addEventListener('abort', forward(b), { once: true }); + // Standard polyfill shape: when EITHER input fires, remove the listener + // registered on the OTHER input too. `{once:true}` alone leaks the + // sibling listener — for `_send()`, `a` is the transport-lifetime signal, + // so every request-scoped `b` that aborts would otherwise leave one + // listener + closure pinned on `a` for the life of the transport. + const cleanup = (): void => { + a.removeEventListener('abort', onA); + b.removeEventListener('abort', onB); + }; + function onA(): void { + cleanup(); + controller.abort(a.reason); + } + function onB(): void { + cleanup(); + controller.abort(b.reason); + } + a.addEventListener('abort', onA, { once: true }); + b.addEventListener('abort', onB, { once: true }); return controller.signal; } @@ -501,7 +517,8 @@ export class StreamableHTTPClientTransport implements Transport { { resumptionToken: lastEventId, onresumptiontoken, - replayMessageId + replayMessageId, + requestSignal }, 0 ); @@ -527,7 +544,8 @@ export class StreamableHTTPClientTransport implements Transport { { resumptionToken: lastEventId, onresumptiontoken, - replayMessageId + replayMessageId, + requestSignal }, 0 ); diff --git a/packages/client/test/client/listen.test.ts b/packages/client/test/client/listen.test.ts index 99583895f0..f78a69e85f 100644 --- a/packages/client/test/client/listen.test.ts +++ b/packages/client/test/client/listen.test.ts @@ -483,6 +483,39 @@ describe('Client.listen()', () => { await client.close(); }); + it('connect-scoped signal does NOT bind to the auto-opened subscription lifetime', async () => { + // Regression: forwarding connect()'s full RequestOptions into the + // auto-open listen() call meant a connect-scoped signal — typically + // `AbortSignal.timeout(30_000)` for the handshake — was bound to the + // SUBSCRIPTION lifetime. When it fired after connect resolved, the + // auto-opened stream was silently torn down. + const { clientTx, written } = await scriptedModern(); + const onChanged = () => {}; + const client = new Client( + { name: 'c', version: '1' }, + { versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged } } } + ); + const errors: Error[] = []; + client.onerror = e => errors.push(e); + const connectScoped = new AbortController(); + await client.connect(clientTx, { signal: connectScoped.signal }); + expect(client.autoOpenedSubscription).toBeDefined(); + written.length = 0; + + // The connect-scoped signal fires AFTER connect resolved (as a + // handshake `AbortSignal.timeout` would). + connectScoped.abort(); + await flush(); + + // The auto-opened subscription is still live: no wire teardown + // (`notifications/cancelled`) was sent, and the per-listen state + // entry is still registered. + expect(written.some(m => (m as JSONRPCNotification).method === 'notifications/cancelled')).toBe(false); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(1); + expect(errors).toHaveLength(0); + await client.close(); + }); + it('transport closes BEFORE the ack: listen() rejects fast', async () => { const { clientTx, serverTx } = await scriptedModernNoAck(); const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index ea20dfb113..6717c87a85 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -1155,6 +1155,43 @@ describe('StreamableHTTPClientTransport', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it('anySignal fallback removes the sibling listener (no leak on the transport-lifetime signal)', async () => { + // ARRANGE — force the manual fallback path (Node 20.0–20.2). + const nativeAny = AbortSignal.any; + (AbortSignal as { any?: unknown }).any = undefined; + try { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockResolvedValue({ ok: true, status: 202, headers: new Headers() }); + await transport.start(); + + const transportSignal = (transport as unknown as { _abortController: AbortController })._abortController.signal; + const addSpy = vi.spyOn(transportSignal, 'addEventListener'); + const removeSpy = vi.spyOn(transportSignal, 'removeEventListener'); + + // ACT — N sends each with a fresh request-scoped signal that + // aborts after the send completes (the McpSubscription.close() + // pattern). Each send registers one fallback listener on the + // transport-lifetime signal; aborting the request-scoped + // signal must remove it. + for (let i = 0; i < 5; i++) { + const requestAbort = new AbortController(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: `listen-${i}`, params: {} }, + { requestSignal: requestAbort.signal } + ); + requestAbort.abort(); + } + + // ASSERT — every listener registered on the transport-lifetime + // signal was removed; nothing accrues per send(). + expect(addSpy.mock.calls.length).toBeGreaterThan(0); + expect(removeSpy.mock.calls.length).toBe(addSpy.mock.calls.length); + } finally { + (AbortSignal as { any?: unknown }).any = nativeAny; + } + }); + it('should NOT reconnect a POST stream when error response was received', async () => { // ARRANGE transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { From c72a59aac71ad5d1ead81dd4b7eebd51e169b7c1 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 19:03:32 +0000 Subject: [PATCH 23/27] feat(client): McpSubscription.closed observes termination cause; reset clears debounce timers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .changeset/subscriptions-listen-client.md | 2 +- docs/migration.md | 2 +- packages/client/src/client/client.ts | 75 ++++++++++++---- packages/client/test/client/listen.test.ts | 99 ++++++++++++++++++++-- 4 files changed, 153 insertions(+), 25 deletions(-) diff --git a/.changeset/subscriptions-listen-client.md b/.changeset/subscriptions-listen-client.md index a879fab5e0..d01bd0dd63 100644 --- a/.changeset/subscriptions-listen-client.md +++ b/.changeset/subscriptions-listen-client.md @@ -3,4 +3,4 @@ '@modelcontextprotocol/client': minor --- -`Client.listen(filter)` opens a `subscriptions/listen` stream on a 2026-07-28-era connection, resolving once the server's acknowledged notification arrives with an `McpSubscription { honoredFilter, close() }`. Change notifications delivered on the stream dispatch to the existing `setNotificationHandler` registrations — the same handlers the 2025-era unsolicited notifications fire on a legacy connection — so `listen()` is era-transparent for consumers that already register those. `close()` aborts the listen request's stream (where the transport supports it) and sends `notifications/cancelled` referencing the listen id — both, on every transport; no automatic re-listen. On a 2025-era connection `listen()` throws a typed `MethodNotSupportedByProtocolVersion` steering to `resources/subscribe` and `ClientOptions.listChanged`. `ClientOptions.listChanged` now auto-opens a listen stream on a modern connection — the filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities; auto-open is skipped (`client.autoOpenedSubscription` stays `undefined`) when that intersection is empty; otherwise the auto-opened subscription is exposed at `client.autoOpenedSubscription`. `TransportSendOptions` gains `requestSignal` for per-request abort on the Streamable HTTP transport. +`Client.listen(filter)` opens a `subscriptions/listen` stream on a 2026-07-28-era connection, resolving once the server's acknowledged notification arrives with an `McpSubscription { honoredFilter, close(), closed }`. `closed` is a `Promise<'local' | 'remote'>` that resolves exactly once when the subscription terminates (`'local'` = you called `close()`; `'remote'` = the server cancelled, the stream ended, or the transport dropped — re-listen if you still want events) and never rejects. Change notifications delivered on the stream dispatch to the existing `setNotificationHandler` registrations — the same handlers the 2025-era unsolicited notifications fire on a legacy connection — so `listen()` is era-transparent for consumers that already register those. `close()` aborts the listen request's stream (where the transport supports it) and sends `notifications/cancelled` referencing the listen id — both, on every transport; no automatic re-listen. On a 2025-era connection `listen()` throws a typed `MethodNotSupportedByProtocolVersion` steering to `resources/subscribe` and `ClientOptions.listChanged`. `ClientOptions.listChanged` now auto-opens a listen stream on a modern connection — the filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities; auto-open is skipped (`client.autoOpenedSubscription` stays `undefined`) when that intersection is empty; otherwise the auto-opened subscription is exposed at `client.autoOpenedSubscription`. `TransportSendOptions` gains `requestSignal` for per-request abort on the Streamable HTTP transport. diff --git a/docs/migration.md b/docs/migration.md index fa0447cce7..2e79e8964b 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1173,7 +1173,7 @@ handler.notify.toolsChanged(); ``` **Client side.** `ClientOptions.listChanged` keeps working: on a 2026-07-28 connection the SDK auto-opens a `subscriptions/listen` stream whose filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities, so the same handlers -fire on every published change (the auto-opened subscription is exposed at `client.autoOpenedSubscription` for `close()`; when the intersection is empty auto-open is skipped and `autoOpenedSubscription` stays `undefined`). `client.listen(filter)` opens a stream explicitly and resolves once the server's acknowledged notification arrives with `{ honoredFilter, close() }`; change notifications dispatch to the existing `setNotificationHandler` +fire on every published change (the auto-opened subscription is exposed at `client.autoOpenedSubscription` for `close()`; when the intersection is empty auto-open is skipped and `autoOpenedSubscription` stays `undefined`). `client.listen(filter)` opens a stream explicitly and resolves once the server's acknowledged notification arrives with `{ honoredFilter, close(), closed }` (where `closed` is a `Promise<'local' | 'remote'>` that resolves once on termination — `'remote'` means the server cancelled, the stream ended, or the transport dropped, so re-listen if you still want events); change notifications dispatch to the existing `setNotificationHandler` registrations. `resources/subscribe` is 2025-only — on a 2026-07-28 connection, request `notifications/resources/updated` via the `resourceSubscriptions` field of the listen filter instead. ### Multi round-trip requests (2026-07-28): write-once handlers and the client auto-fulfilment driver diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index b8b76b1f7a..dc90ba572c 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -273,6 +273,16 @@ export interface McpSubscription { * always, so close works on any transport. */ close(): Promise; + /** + * Resolves exactly once when the subscription has terminated. Never + * rejects — this is an observation, not an operation. + * + * - `'local'` — you called {@linkcode close} (or aborted the + * `RequestOptions.signal` you passed to `listen()`). + * - `'remote'` — the server cancelled, the stream ended, or the transport + * dropped. Re-listen if you still want events. + */ + readonly closed: Promise<'local' | 'remote'>; } /** @internal */ @@ -366,6 +376,13 @@ export class Client extends Protocol { } } this._listenState.clear(); + // Debounce timers are connection-scoped: a callback armed on a + // connection that is now gone must not fire onto whatever connection + // (if any) replaces it. + for (const timer of this._listChangedDebounceTimers.values()) { + clearTimeout(timer); + } + this._listChangedDebounceTimers.clear(); this._cachedToolOutputValidators.clear(); } @@ -1328,15 +1345,23 @@ export class Client extends Protocol { resolveOpening = resolve; rejectOpening = reject; }); + // The McpSubscription.closed observation. Resolved exactly once by + // settle()'s `→ closed` transition; never rejects. When listen() + // itself rejects (pre-ack) there is no McpSubscription to observe it + // on — settle() resolves it anyway so nothing dangles. + let resolveClosed!: (cause: 'local' | 'remote') => void; + const closed = new Promise<'local' | 'remote'>(resolve => { + resolveClosed = resolve; + }); - const settle = (outcome: { ack: SubscriptionFilter } | { error: Error } | 'closed'): void => { + const settle = (outcome: { ack: SubscriptionFilter } | { cause: 'local' | 'remote'; error?: Error }): void => { if (state === 'closed') return; const wasOpening = state === 'opening'; if (ackTimer !== undefined) { clearTimeout(ackTimer); ackTimer = undefined; } - if (outcome !== 'closed' && 'ack' in outcome) { + if ('ack' in outcome) { // The single `opening → open` transition; an ack after close // hits the `closed` guard above and is a no-op. state = 'open'; @@ -1351,11 +1376,17 @@ export class Client extends Protocol { this._listenState.delete(listenMessageId); } parked?.unpark(); + // Abort the per-request signal so an HTTP SSE reader stops on a + // remote-initiated close too (server-cancel / stream-end / + // transport-drop). Idempotent; a no-op on transports that ignore + // requestSignal. wireTeardown() also aborts on the local paths — + // harmless redundancy. + requestAbort.abort(); + resolveClosed(outcome.cause); if (wasOpening) { rejectOpening( - outcome === 'closed' - ? new SdkError(SdkErrorCode.ConnectionClosed, 'subscriptions/listen closed before the server acknowledged') - : outcome.error + outcome.error ?? + new SdkError(SdkErrorCode.ConnectionClosed, 'subscriptions/listen closed before the server acknowledged') ); } }; @@ -1381,27 +1412,31 @@ export class Client extends Protocol { const close = async (): Promise => { if (state === 'closed') return; - settle('closed'); + settle({ cause: 'local' }); await wireTeardown(); }; const ackTimeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; ackTimer = setTimeout(() => { - settle({ error: new SdkError(SdkErrorCode.RequestTimeout, 'subscriptions/listen ack timed out', { timeout: ackTimeout }) }); + settle({ + cause: 'remote', + error: new SdkError(SdkErrorCode.RequestTimeout, 'subscriptions/listen ack timed out', { timeout: ackTimeout }) + }); void wireTeardown().catch(() => {}); }, ackTimeout); // RequestOptions.signal aborts the subscription at any point in its // lifecycle (mirrors request()'s cancel path). While `opening`, settle // rejects the pending listen() promise with the signal's reason; while - // `open`, it transitions to `closed` and tears the wire down. The - // listener is removed by `settle()` once the subscription has closed. + // `open`, it transitions to `closed` (`closed` resolves `'local'`) and + // tears the wire down. The listener is removed by `settle()` once the + // subscription has closed. if (options?.signal) { const callerSignal = options.signal; onCallerAbort = () => { if (state === 'closed') return; const reason = callerSignal.reason; - settle({ error: reason instanceof Error ? reason : new Error(String(reason ?? 'Aborted')) }); + settle({ cause: 'local', error: reason instanceof Error ? reason : new Error(String(reason ?? 'Aborted')) }); void wireTeardown().catch(() => {}); }; callerSignal.addEventListener('abort', onCallerAbort, { once: true }); @@ -1431,11 +1466,12 @@ export class Client extends Protocol { onServerCancel: () => { // Handles BOTH the pre-ack and post-ack server-side // cancel: while opening, settle rejects the pending - // listen() promise; once open, settle just - // transitions to closed and the message is unused. - settle({ error: new Error('subscriptions/listen: server cancelled the subscription') }); + // listen() promise; once open, settle transitions + // to closed and `closed` resolves 'remote' so the + // consumer can observe the server-initiated close. + settle({ cause: 'remote', error: new Error('subscriptions/listen: server cancelled the subscription') }); }, - onConnectionReset: error => settle({ error }) + onConnectionReset: error => settle({ cause: 'remote', error }) }); } ); @@ -1448,18 +1484,21 @@ export class Client extends Protocol { if ((state as 'opening' | 'open' | 'closed') === 'closed') parked.unpark(); // Pre-ack capacity / params rejection arrives as a JSON-RPC error // for the listen id; transport close is delivered the same way. + // A 'response' (the spec defines listen as never receiving a + // result) is treated as the server having ended the stream. void parked.terminated.then(({ reason, error }) => { - if (reason === 'error') settle({ error: error ?? new Error('subscriptions/listen failed') }); + if (reason === 'unparked') return; + settle({ cause: 'remote', error: error ?? new Error('subscriptions/listen: stream ended') }); }); parked.sent.catch(error => { - settle({ error: error instanceof Error ? error : new Error(String(error)) }); + settle({ cause: 'remote', error: error instanceof Error ? error : new Error(String(error)) }); }); } catch (error) { - settle({ error: error instanceof Error ? error : new Error(String(error)) }); + settle({ cause: 'remote', error: error instanceof Error ? error : new Error(String(error)) }); } const honored = await opening; - return { honoredFilter: honored, close }; + return { honoredFilter: honored, close, closed }; } /** diff --git a/packages/client/test/client/listen.test.ts b/packages/client/test/client/listen.test.ts index f78a69e85f..045e3f7559 100644 --- a/packages/client/test/client/listen.test.ts +++ b/packages/client/test/client/listen.test.ts @@ -150,7 +150,7 @@ describe('Client.listen()', () => { await client.close(); }); - it('inbound notifications/cancelled referencing the listen id tears the subscription down', async () => { + it("inbound notifications/cancelled post-ack: closed resolves 'remote'; subscription torn down; handlers stop firing", async () => { let listenId!: number | string; let send!: (m: JSONRPCMessage) => void; const { clientTx } = await scriptedModern((id, _f, s) => { @@ -158,15 +158,72 @@ describe('Client.listen()', () => { send = s; }); const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + const seen: string[] = []; + client.setNotificationHandler('notifications/tools/list_changed', () => { + seen.push('tools'); + }); await client.connect(clientTx); const sub = await client.listen({ toolsListChanged: true }); send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification); - await flush(); - // close() after server-cancel is idempotent. + // The spec-defined remote termination signal is now observable on the + // subscription handle; settle() is the funnel and resolves it once. + await expect(sub.closed).resolves.toBe('remote'); + // Per-listen state is gone; the request signal was aborted (so an HTTP + // SSE reader would have stopped). + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + // After a server-side close, the server stops delivering on this stream + // — a notification carrying this subscription id is no longer routed + // through any per-listen entry (the entry is gone). The handler is the + // shared setNotificationHandler registration; assert no later + // dispatch from THIS subscription's stream by asserting no entry exists + // to demux it. + expect((client as unknown as { _listenState: Map })._listenState.has(listenId)).toBe(false); + expect(seen).toEqual([]); + // close() after server-cancel is idempotent and does NOT change the + // already-resolved cause. await sub.close(); + await expect(sub.closed).resolves.toBe('remote'); await client.close(); }); + it("close() resolves closed with 'local' exactly once", async () => { + const { clientTx } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + await sub.close(); + await expect(sub.closed).resolves.toBe('local'); + // A second close() and a later remote signal cannot change it. + await sub.close(); + await expect(sub.closed).resolves.toBe('local'); + await client.close(); + }); + + it('closed resolves exactly once even when multiple termination signals arrive', async () => { + let listenId!: number | string; + let send!: (m: JSONRPCMessage) => void; + const { clientTx, serverTx } = await scriptedModern((id, _f, s) => { + listenId = id; + send = s; + }); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + const resolutions: string[] = []; + void sub.closed.then(cause => resolutions.push(cause)); + // Three signals in quick succession: server-cancel, a duplicate + // server-cancel, then transport close. settle()'s `closed` guard + // means only the first transitions; `closed` resolves once. + send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification); + send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } } as JSONRPCNotification); + await serverTx.close(); + await flush(); + expect(resolutions).toEqual(['remote']); + // sub.close() after the fact is still idempotent and cannot flip it. + await sub.close(); + await expect(sub.closed).resolves.toBe('remote'); + }); + it('rejects with the typed pre-ack error when the server answers -32603', async () => { const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); serverTx.onmessage = m => { @@ -366,6 +423,8 @@ describe('Client.listen()', () => { ac.abort(); await flush(); expect(written).toEqual([{ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } }]); + // Caller-signal abort is consumer-initiated → 'local'. + await expect(sub.closed).resolves.toBe('local'); // close() after signal-abort is idempotent. await sub.close(); expect(written).toHaveLength(1); @@ -533,14 +592,14 @@ describe('Client.listen()', () => { expect((client as unknown as { _responseHandlers: Map })._responseHandlers.size).toBe(0); }); - it('transport closes WHILE the subscription is open: subscription transitions to closed; close() is a no-op', async () => { + it("transport closes WHILE the subscription is open: closed resolves 'remote'; close() is a no-op", async () => { const { clientTx, serverTx, written } = await scriptedModern(); const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(clientTx); const sub = await client.listen({ toolsListChanged: true }); expect((client as unknown as { _listenState: Map })._listenState.size).toBe(1); await serverTx.close(); - await flush(); + await expect(sub.closed).resolves.toBe('remote'); // Transport-close settled the per-listen machine; nothing leaks. expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); // sub.close() after transport-close is a no-op (state already 'closed'): @@ -662,6 +721,36 @@ describe('Client.listen()', () => { }); }); +describe('_resetConnectionState() clears connection-scoped debounce timers (fake timers)', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('a debounced listChanged callback armed on a closed connection never fires', async () => { + const { clientTx, serverTx } = await scriptedModernNoAck(); + const calls: unknown[] = []; + const client = new Client( + { name: 'c', version: '1' }, + { + versionNegotiation: { mode: 'auto' }, + listChanged: { tools: { onChanged: (e, items) => calls.push({ e, items }), autoRefresh: false, debounceMs: 100 } } + } + ); + const connecting = client.connect(clientTx); + await vi.runAllTimersAsync(); + await connecting; + // Arm the debounce timer for `tools` on the current connection. + await serverTx.send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed' }); + await vi.advanceTimersByTimeAsync(0); + expect((client as unknown as { _listChangedDebounceTimers: Map })._listChangedDebounceTimers.size).toBe(1); + // close() → _resetConnectionState() must clear the armed timer so the + // callback for the dead connection never fires. + await client.close(); + expect((client as unknown as { _listChangedDebounceTimers: Map })._listChangedDebounceTimers.size).toBe(0); + await vi.advanceTimersByTimeAsync(200); + expect(calls).toEqual([]); + }); +}); + describe('Client.listen() — ack timeout (fake timers)', () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); From 0826c2dc68b44cf66935832e427c31386396a10e Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 19:25:10 +0000 Subject: [PATCH 24/27] =?UTF-8?q?fix(client):=20listen=20polish=20?= =?UTF-8?q?=E2=80=94=20derived=20ack-wait=20signal;=20envelope=20on=20list?= =?UTF-8?q?en-cancel;=20intentional-abort=20guards=20across=20reconnect=20?= =?UTF-8?q?path;=20typed=20result-during-opening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/client/src/client/client.ts | 62 +++++++++++--- packages/client/src/client/streamableHttp.ts | 34 ++++++-- packages/client/test/client/listen.test.ts | 85 ++++++++++++++++++- .../client/test/client/streamableHttp.test.ts | 34 ++++++++ 4 files changed, 194 insertions(+), 21 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index dc90ba572c..fad4a0cf43 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -927,20 +927,37 @@ export class Client extends Protocol { // server may not support it, or refuse on capacity). Surface // via onerror; the consumer can call listen() later. // - // Forward ONLY the ack timeout from connect()'s options. // listen() binds RequestOptions.signal to the SUBSCRIPTION - // lifetime, so a connect-scoped signal (e.g. - // `AbortSignal.timeout(30_000)` for the handshake) would tear - // the auto-opened stream down the moment it fires after - // connect has already resolved. Connect's signal governs the - // handshake only; the auto-opened subscription outlives it. + // lifetime, so connect()'s signal must NOT be forwarded + // verbatim — a connect-scoped `AbortSignal.timeout(30_000)` + // would silently tear the auto-opened stream down the moment + // it fires after connect has resolved. But connect()'s signal + // MUST still cancel the in-connect ack WAIT (otherwise an + // aborted connect blocks here for the full ack timeout). + // Derived one-shot: bound to connect()'s signal only for the + // duration of the listen() await; the listener is removed in + // `finally` so the auto-opened subscription outlives connect's + // signal. + const ackAbort = new AbortController(); + const onConnectAbort = (): void => ackAbort.abort(options?.signal?.reason); + // Handle the already-aborted case (aborted between the + // discover leg resolving and now): the listener never fires + // for a past event. + if (options?.signal?.aborted) onConnectAbort(); + options?.signal?.addEventListener('abort', onConnectAbort); try { - this._autoOpenedSubscription = await this.listen( - filter, - options?.timeout === undefined ? undefined : { timeout: options.timeout } - ); + this._autoOpenedSubscription = await this.listen(filter, { + timeout: options?.timeout, + signal: ackAbort.signal + }); } catch (error) { + // Connect-signal abort during the ack wait propagates as a + // connect() rejection (caller asked to abort connect); a + // server-side refusal stays a soft onerror. + if (options?.signal?.aborted) throw error; this.onerror?.(error instanceof Error ? error : new Error(String(error))); + } finally { + options?.signal?.removeEventListener('abort', onConnectAbort); } } } @@ -1404,8 +1421,16 @@ export class Client extends Protocol { requestAbort.abort(); const id = listenMessageId; if (id !== undefined) { + // Carry the same modern auto-envelope as every other outbound + // (request()'s cancel and Protocol.notification() both go via + // `_envelopeOutbound`); the listen path was the only outbound + // bypassing it. await this.transport - ?.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: id } }) + ?.send({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { _meta: { ...this._outboundMetaEnvelope() }, requestId: id } + }) .catch(() => {}); } }; @@ -1485,9 +1510,22 @@ export class Client extends Protocol { // Pre-ack capacity / params rejection arrives as a JSON-RPC error // for the listen id; transport close is delivered the same way. // A 'response' (the spec defines listen as never receiving a - // result) is treated as the server having ended the stream. + // result) surfaces as a typed protocol-shape error so a server + // bug — answering listen with a JSON-RPC result instead of the + // acknowledged notification — is diagnosable, not a 60s ack + // timeout. void parked.terminated.then(({ reason, error }) => { if (reason === 'unparked') return; + if (reason === 'response') { + settle({ + cause: 'remote', + error: new SdkError( + SdkErrorCode.InvalidResult, + 'server answered subscriptions/listen with a result; expected the acknowledged notification' + ) + }); + return; + } settle({ cause: 'remote', error: error ?? new Error('subscriptions/listen: stream ended') }); }); parked.sent.catch(error => { diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 6e1579e27b..61eba63005 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -300,7 +300,13 @@ export class StreamableHTTPClientTransport implements Transport { } private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false): Promise { - const { resumptionToken } = options; + const { resumptionToken, requestSignal } = options; + // Same guard as `_handleSseStream`: a resurrected listen stream (the + // POST-SSE → GET reconnect path threads `requestSignal` through + // `StartSSEOptions`) must honour the per-request abort exactly as the + // original POST did — both as a fetch signal and as a "do not surface + // onerror" gate. + const isIntentionalAbort = (): boolean => this._abortController?.signal.aborted === true || requestSignal?.aborted === true; try { // Try to open an initial SSE stream with GET to listen for server messages @@ -315,11 +321,16 @@ export class StreamableHTTPClientTransport implements Transport { headers.set('last-event-id', resumptionToken); } + const transportSignal = this._abortController?.signal; + const signal = + requestSignal !== undefined && transportSignal !== undefined + ? anySignal(transportSignal, requestSignal) + : (requestSignal ?? transportSignal); const response = await (this._fetch ?? fetch)(this._url, { ...this._requestInit, method: 'GET', headers, - signal: this._abortController?.signal + signal }); if (!response.ok) { @@ -366,7 +377,9 @@ export class StreamableHTTPClientTransport implements Transport { this._handleSseStream(response.body, options, true); } catch (error) { - this.onerror?.(error as Error); + if (!isIntentionalAbort()) { + this.onerror?.(error as Error); + } throw error; } } @@ -413,8 +426,12 @@ export class StreamableHTTPClientTransport implements Transport { const reconnect = (): void => { this._cancelReconnection = undefined; - if (this._abortController?.signal.aborted) return; + // Honour BOTH the transport-wide abort and the per-request abort + // (a listen subscription closed during the backoff delay): do not + // resurrect a stream the caller already tore down. + if (this._abortController?.signal.aborted || options.requestSignal?.aborted) return; this._startOrAuthSse(options).catch(error => { + if (this._abortController?.signal.aborted || options.requestSignal?.aborted) return; this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`)); try { this._scheduleReconnection(options, attemptCount + 1); @@ -780,7 +797,14 @@ export class StreamableHTTPClientTransport implements Transport { await response.text?.().catch(() => {}); } } catch (error) { - this.onerror?.(error as Error); + // Intentional per-request abort BEFORE response headers (the + // `subscriptions/listen` driver aborting its `requestSignal`): + // fetch rejects with AbortError. Same guard as + // `_handleSseStream`'s `isIntentionalAbort` — do not surface a + // misleading onerror; still rethrow so `parked.sent` settles. + if (options?.requestSignal?.aborted !== true) { + this.onerror?.(error as Error); + } throw error; } } diff --git a/packages/client/test/client/listen.test.ts b/packages/client/test/client/listen.test.ts index 045e3f7559..4d061a9ef2 100644 --- a/packages/client/test/client/listen.test.ts +++ b/packages/client/test/client/listen.test.ts @@ -7,7 +7,14 @@ * connection. */ import type { JSONRPCMessage, JSONRPCNotification } from '@modelcontextprotocol/core'; -import { InMemoryTransport, LATEST_PROTOCOL_VERSION, SdkError, SdkErrorCode, SUBSCRIPTION_ID_META_KEY } from '@modelcontextprotocol/core'; +import { + InMemoryTransport, + LATEST_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY, + SdkError, + SdkErrorCode, + SUBSCRIPTION_ID_META_KEY +} from '@modelcontextprotocol/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Client } from '../../src/client/client.js'; @@ -143,7 +150,13 @@ describe('Client.listen()', () => { const listenId = (written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { id: number | string }).id; written.length = 0; await sub.close(); - expect(written).toEqual([{ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } }]); + expect(written).toHaveLength(1); + const cancel = written[0] as unknown as { method: string; params: { requestId: unknown; _meta?: Record } }; + expect(cancel.method).toBe('notifications/cancelled'); + expect(cancel.params.requestId).toBe(listenId); + // The listen-path cancel carries the same modern auto-envelope as + // every other outbound (request()'s cancel, Protocol.notification()). + expect(cancel.params._meta?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN); // Idempotent. await sub.close(); expect(written).toHaveLength(1); @@ -422,7 +435,9 @@ describe('Client.listen()', () => { written.length = 0; ac.abort(); await flush(); - expect(written).toEqual([{ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: listenId } }]); + expect(written).toHaveLength(1); + expect((written[0] as JSONRPCNotification).method).toBe('notifications/cancelled'); + expect((written[0] as unknown as { params: { requestId: unknown } }).params.requestId).toBe(listenId); // Caller-signal abort is consumer-initiated → 'local'. await expect(sub.closed).resolves.toBe('local'); // close() after signal-abort is idempotent. @@ -575,6 +590,66 @@ describe('Client.listen()', () => { await client.close(); }); + it('connect-scoped signal aborted DURING the auto-open ack wait: connect rejects fast (no 60s hang)', async () => { + // Regression: forwarding only {timeout} into the auto-open listen() + // meant connect()'s signal could not cancel the in-connect ack wait — + // an aborted connect blocked here for the full ack timeout. + const { clientTx } = await scriptedModernNoAck(); + const onChanged = () => {}; + const client = new Client( + { name: 'c', version: '1' }, + { versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged } } } + ); + const connectScoped = new AbortController(); + const t0 = Date.now(); + const pending = client.connect(clientTx, { signal: connectScoped.signal }); + // discover resolves; connect is now awaiting the auto-open ack. + await flush(); + connectScoped.abort(new Error('connect-abort')); + const error = await pending.catch(e => e as Error); + expect(error).toBeInstanceOf(Error); + expect(Date.now() - t0).toBeLessThan(1000); + // No leaked per-listen state on the aborted connect. + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + await client.close(); + }); + + it('server answers listen with a JSON-RPC RESULT during opening: rejects with a typed InvalidResult (not 60s)', async () => { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + serverTx.onmessage = m => { + const req = m as { id?: number | string; method?: string }; + if (req.method === 'server/discover' && req.id !== undefined) { + void serverTx.send({ + jsonrpc: '2.0', + id: req.id, + result: { + resultType: 'complete', + supportedVersions: [MODERN], + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 's', version: '1' } + } + }); + } + if (req.method === 'subscriptions/listen' && req.id !== undefined) { + // Buggy server: answers with a result instead of the + // acknowledged notification. Spec defines listen as never + // receiving a result. + void serverTx.send({ jsonrpc: '2.0', id: req.id, result: {} }); + } + }; + await serverTx.start(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const t0 = Date.now(); + const error = await client.listen({ toolsListChanged: true }).catch(e => e as SdkError); + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.InvalidResult); + expect((error as SdkError).message).toContain('expected the acknowledged notification'); + expect(Date.now() - t0).toBeLessThan(1000); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + await client.close(); + }); + it('transport closes BEFORE the ack: listen() rejects fast', async () => { const { clientTx, serverTx } = await scriptedModernNoAck(); const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); @@ -624,7 +699,9 @@ describe('Client.listen()', () => { written.length = 0; await a.close(); // Only `a`'s id is cancelled; `b` stays open. - expect(written).toEqual([{ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: ids[0] } }]); + expect(written).toHaveLength(1); + expect((written[0] as JSONRPCNotification).method).toBe('notifications/cancelled'); + expect((written[0] as unknown as { params: { requestId: unknown } }).params.requestId).toBe(ids[0]); expect(listenState.size).toBe(1); await b.close(); expect(listenState.size).toBe(0); diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 6717c87a85..a6630ec814 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -1155,6 +1155,40 @@ describe('StreamableHTTPClientTransport', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it('per-request requestSignal abort BEFORE response headers: no misleading onerror; send() still rejects', async () => { + // ARRANGE — fetch is in flight (pending promise) when the + // requestSignal aborts; fetch rejects with AbortError before the + // SSE stream handler ever runs. _send's catch must apply the same + // intentional-abort guard as _handleSseStream. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + const errorSpy = vi.fn(); + transport.onerror = errorSpy; + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockImplementationOnce( + (_url, init: RequestInit) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }); + }) + ); + + const requestAbort = new AbortController(); + await transport.start(); + const sent = transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen-1', params: {} }, + { requestSignal: requestAbort.signal } + ); + // Let _send reach the in-flight fetch. + await vi.advanceTimersByTimeAsync(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // ACT — abort before headers. + requestAbort.abort(new Error('intentional')); + + // ASSERT — send() rejects (so parked.sent settles), but no onerror. + await expect(sent).rejects.toThrow(); + expect(errorSpy).not.toHaveBeenCalled(); + }); + it('anySignal fallback removes the sibling listener (no leak on the transport-lifetime signal)', async () => { // ARRANGE — force the manual fallback path (Node 20.0–20.2). const nativeAny = AbortSignal.any; From ebf896d155a001a7f7b795fa47e93e6073313741 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 20:25:37 +0000 Subject: [PATCH 25/27] =?UTF-8?q?refactor(client):=20listen()=20driver=20i?= =?UTF-8?q?s=20transport-level=20=E2=80=94=20drop=20the=20in-Protocol=20pa?= =?UTF-8?q?rk=20primitive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- packages/client/src/client/client.ts | 292 ++++++++++-------- packages/client/src/client/streamableHttp.ts | 58 +++- packages/client/test/client/listen.test.ts | 86 ++++-- .../client/test/client/streamableHttp.test.ts | 88 +++++- packages/core/src/shared/protocol.ts | 116 +------ packages/core/src/shared/transport.ts | 10 + .../core/src/wire/rev2026-07-28/registry.ts | 4 +- 7 files changed, 380 insertions(+), 274 deletions(-) diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index fad4a0cf43..f8650f4b28 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -17,6 +17,7 @@ import type { InputRequiredOptions, JSONRPCNotification, JSONRPCRequest, + JSONRPCResponse, JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator, @@ -58,6 +59,7 @@ import { CreateMessageResultWithToolsSchema, DEFAULT_REQUEST_TIMEOUT_MSEC, DiscoverResultSchema, + isJSONRPCErrorResponse, isJSONRPCRequest, isModernProtocolVersion, legacyProtocolVersions, @@ -287,10 +289,14 @@ export interface McpSubscription { /** @internal */ interface ListenStateEntry { - onAck: (honored: SubscriptionFilter) => void; - onServerCancel: () => void; - /** Settle the per-listen machine with an explicit error (used by `_resetConnectionState`). */ - onConnectionReset: (error: Error) => void; + /** + * The single funnel for the per-listen `opening → open → closed` state + * machine. Every transport-level feed source — the `_onnotification` / + * `_onresponse` / `_onclose` overrides, `onRequestStreamEnd`, send + * failure, ack timeout, caller-signal abort, `_resetConnectionState` — + * routes through it. + */ + settle: (outcome: { ack: SubscriptionFilter } | { cause: 'local' | 'remote'; error?: Error }) => void; } /** @@ -344,8 +350,15 @@ export class Client extends Protocol { private _versionNegotiation?: VersionNegotiationOptions; private _supportedProtocolVersionsOption?: string[]; private _inputRequiredDriverConfig: ResolvedInputRequiredDriverConfig; - /** Active subscriptions/listen state, keyed by subscription id (= the listen request's JSON-RPC id). */ - private _listenState = new Map(); + /** + * Active subscriptions/listen state, keyed by subscription id (= the + * listen request's JSON-RPC id verbatim). The id is a STRING from a + * Client-owned counter (`'listen:' + N`) — JSON-RPC permits string ids, + * and Protocol's numeric `_requestMessageId` counter only ever issues + * numbers, so listen ids cannot collide with ordinary request ids. + */ + private _listenState = new Map(); + private _nextListenId = 0; /** The auto-opened subscription backing ClientOptions.listChanged on a modern connection. */ private _autoOpenedSubscription?: McpSubscription; @@ -372,7 +385,7 @@ export class Client extends Protocol { 'subscriptions/listen: client reconnected or closed; subscription state from the previous connection was reset' ); for (const entry of this._listenState.values()) { - entry.onConnectionReset(reason); + entry.settle({ cause: 'remote', error: reason }); } } this._listenState.clear(); @@ -915,7 +928,16 @@ export class Client extends Protocol { ...(config.prompts && advertised?.prompts?.listChanged && { prompts: config.prompts }), ...(config.resources && advertised?.resources?.listChanged && { resources: config.resources }) }; - this._setupListChangedHandlers(effective); + // Handler registration validates the per-type options and can + // throw on misconfiguration; the modern connection IS established + // at this point and is fully usable without listChanged handlers, + // so a misconfiguration surfaces via onerror and connect resolves + // (matching the auto-open soft-fail posture). + try { + this._setupListChangedHandlers(effective); + } catch (error) { + this.onerror?.(error instanceof Error ? error : new Error(String(error))); + } const filter: SubscriptionFilter = { ...(effective.tools && { toolsListChanged: true as const }), ...(effective.prompts && { promptsListChanged: true as const }), @@ -1334,26 +1356,21 @@ export class Client extends Protocol { // already-aborted signal rejects synchronously before any setup. options?.signal?.throwIfAborted(); - if (this._onParkedNotification === undefined) { - this._onParkedNotification = raw => this._listenFirstLook(raw); - } - const requestAbort = new AbortController(); + // The listen request's JSON-RPC id (= the spec's subscription id + // verbatim). A STRING from a Client-owned counter so it cannot + // collide with Protocol's numeric `_requestMessageId` counter — the + // `_onresponse`/`_onnotification` overrides demux by string-id alone. + const listenId = `listen:${this._nextListenId++}`; // Explicit `opening → open → closed` state machine. Every termination // path — ack-arrives, ack-timeout, server-cancelled, user-close, - // transport-close, send-failure — funnels through the single `settle` - // below, which clears the ack timer, unparks, transitions state, and - // resolves/rejects the opening promise exactly once. The cancelled- - // before-ack / close-before-ack hangs are impossible by construction. + // stream-end, transport-close, send-failure — funnels through the + // single `settle` below, which clears the ack timer, transitions + // state, and resolves/rejects the opening promise exactly once. The + // cancelled-before-ack / close-before-ack hangs are impossible by + // construction. let state: 'opening' | 'open' | 'closed' = 'opening'; - let parked: ReturnType | undefined; - // The listen request id, captured by `onBeforeSend` BEFORE the request - // goes out. `settle()` deletes `_listenState` by this id (not via - // `parked.messageId`), so a synchronously-delivered termination during - // `_parkRequest`'s send — when `parked` is still unassigned — does not - // leak the entry. - let listenMessageId: number | undefined; let ackTimer: ReturnType | undefined; let onCallerAbort: (() => void) | undefined; let resolveOpening!: (honored: SubscriptionFilter) => void; @@ -1389,10 +1406,7 @@ export class Client extends Protocol { if (onCallerAbort !== undefined) { options?.signal?.removeEventListener('abort', onCallerAbort); } - if (listenMessageId !== undefined) { - this._listenState.delete(listenMessageId); - } - parked?.unpark(); + this._listenState.delete(listenId); // Abort the per-request signal so an HTTP SSE reader stops on a // remote-initiated close too (server-cancel / stream-end / // transport-drop). Idempotent; a no-op on transports that ignore @@ -1408,31 +1422,19 @@ export class Client extends Protocol { } }; - // Wire-level teardown for a locally-initiated close (user close or ack - // timeout). Transport-agnostic: ALWAYS abort the request signal (closes - // the SSE stream where the transport honors `requestSignal` — HTTP does, - // stdio does not) AND send `notifications/cancelled` referencing the - // listen id (which the stdio listen router and any spec-compliant - // server honor). Idempotent over HTTP — the cancelled notification is - // a no-op once the stream is gone; correct on every other transport. - // Not called when the server already terminated (error / server- - // cancelled). + // Wire-level teardown for a locally-initiated close (user close, ack + // timeout, caller-signal abort). Transport-agnostic: ALWAYS abort the + // request signal (closes the SSE stream where the transport honors + // `requestSignal` — HTTP does, stdio does not) AND send + // `notifications/cancelled` referencing the listen id (which the + // stdio listen router and any spec-compliant server honor). Sent via + // `notification()` so the modern auto-envelope is attached exactly as + // for every other outbound. Idempotent over HTTP — the cancelled + // notification is a no-op once the stream is gone; correct on every + // other transport. Not called when the server already terminated. const wireTeardown = async (): Promise => { requestAbort.abort(); - const id = listenMessageId; - if (id !== undefined) { - // Carry the same modern auto-envelope as every other outbound - // (request()'s cancel and Protocol.notification() both go via - // `_envelopeOutbound`); the listen path was the only outbound - // bypassing it. - await this.transport - ?.send({ - jsonrpc: '2.0', - method: 'notifications/cancelled', - params: { _meta: { ...this._outboundMetaEnvelope() }, requestId: id } - }) - .catch(() => {}); - } + await this.notification({ method: 'notifications/cancelled', params: { requestId: listenId } }).catch(() => {}); }; const close = async (): Promise => { @@ -1441,6 +1443,11 @@ export class Client extends Protocol { await wireTeardown(); }; + // The per-subscription state is registered BEFORE the request is sent + // so a synchronously-delivered ack (an in-process transport) cannot + // race the registration. + this._listenState.set(listenId, { settle }); + const ackTimeout = options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; ackTimer = setTimeout(() => { settle({ @@ -1467,71 +1474,29 @@ export class Client extends Protocol { callerSignal.addEventListener('abort', onCallerAbort, { once: true }); } + // Send the listen request directly on the transport. The `_meta` + // envelope is built via the same `_outboundMetaEnvelope()` seam every + // other outbound uses (so a future envelope key cannot silently + // diverge here). `onRequestStreamEnd` feeds the per-request stream's + // non-deliberate end into the state machine on transports that open + // one (Streamable HTTP); stdio/InMemory ignore it. + const jsonrpcRequest: JSONRPCRequest = { + jsonrpc: '2.0', + id: listenId, + method: 'subscriptions/listen', + params: { _meta: { ...this._outboundMetaEnvelope() }, notifications: filter } + }; try { - // The per-subscription state is registered BEFORE the request is - // sent (`onBeforeSend`) so a synchronously-delivered ack (an - // in-process transport) cannot race the registration. - parked = this._parkRequest( - { - method: 'subscriptions/listen', - params: { - _meta: { - [PROTOCOL_VERSION_META_KEY]: negotiated, - [CLIENT_INFO_META_KEY]: this._clientInfo, - [CLIENT_CAPABILITIES_META_KEY]: this._capabilities - }, - notifications: filter - } - }, - { requestSignal: requestAbort.signal }, - messageId => { - listenMessageId = messageId; - this._listenState.set(messageId, { - onAck: honored => settle({ ack: honored }), - onServerCancel: () => { - // Handles BOTH the pre-ack and post-ack server-side - // cancel: while opening, settle rejects the pending - // listen() promise; once open, settle transitions - // to closed and `closed` resolves 'remote' so the - // consumer can observe the server-initiated close. - settle({ cause: 'remote', error: new Error('subscriptions/listen: server cancelled the subscription') }); - }, - onConnectionReset: error => settle({ cause: 'remote', error }) - }); - } - ); - // A synchronously-delivered termination during `send()` (an - // in-process transport) ran `settle()` before `parked` was - // assigned; `settle()` already cleared `_listenState` via - // `listenMessageId` — unpark now so the response handler does not - // leak either. (Cast: TS control-flow narrowing does not track - // closure mutation.) - if ((state as 'opening' | 'open' | 'closed') === 'closed') parked.unpark(); - // Pre-ack capacity / params rejection arrives as a JSON-RPC error - // for the listen id; transport close is delivered the same way. - // A 'response' (the spec defines listen as never receiving a - // result) surfaces as a typed protocol-shape error so a server - // bug — answering listen with a JSON-RPC result instead of the - // acknowledged notification — is diagnosable, not a 60s ack - // timeout. - void parked.terminated.then(({ reason, error }) => { - if (reason === 'unparked') return; - if (reason === 'response') { - settle({ - cause: 'remote', - error: new SdkError( - SdkErrorCode.InvalidResult, - 'server answered subscriptions/listen with a result; expected the acknowledged notification' - ) - }); - return; - } - settle({ cause: 'remote', error: error ?? new Error('subscriptions/listen: stream ended') }); - }); - parked.sent.catch(error => { - settle({ cause: 'remote', error: error instanceof Error ? error : new Error(String(error)) }); + await this.transport.send(jsonrpcRequest, { + requestSignal: requestAbort.signal, + onRequestStreamEnd: () => settle({ cause: 'remote', error: new Error('subscriptions/listen: stream ended') }) }); } catch (error) { + // Synchronous OR awaited send failure (including a per-request + // abort fired before response headers — `streamableHttp._send` + // rethrows with onerror suppressed). `settle()` is idempotent so + // a locally-aborted send hitting this path after `close()` is a + // no-op. settle({ cause: 'remote', error: error instanceof Error ? error : new Error(String(error)) }); } @@ -1552,41 +1517,94 @@ export class Client extends Protocol { } /** - * The first-look notification hook installed by `listen()`. Consumes the - * leading `notifications/subscriptions/acknowledged` (resolves the ack - * waiter) and an inbound `notifications/cancelled` referencing a parked - * listen id (stdio server-side teardown). Change notifications carrying a - * subscription id pass through to the existing registered handlers. + * Transport-level demux for `subscriptions/listen` notifications, before + * any decoding/era-gating/handler dispatch. Consumes the leading + * `notifications/subscriptions/acknowledged` referencing a live + * subscription id (resolves the ack waiter) and an inbound + * `notifications/cancelled` referencing a live string-typed subscription + * id (server-side teardown on stdio). Change notifications carrying a + * subscription id pass through to the existing registered handlers via + * `super`. An unmatched ack/cancelled is NOT consumed: it reaches + * `setNotificationHandler` / `fallbackNotificationHandler` instead of + * being silently swallowed. */ - private _listenFirstLook(raw: JSONRPCNotification): 'consumed' | undefined { + protected override _onnotification(raw: JSONRPCNotification, extra?: MessageExtraInfo): void { if (raw.method === 'notifications/subscriptions/acknowledged') { const params = raw.params as { _meta?: Record; notifications?: unknown } | undefined; const subscriptionId = params?._meta?.[SUBSCRIPTION_ID_META_KEY]; - // Tolerant read: subscription id may be string or number; match by - // String() coercion against this connection's parked listen ids. - for (const [id, entry] of this._listenState) { - if (String(id) === String(subscriptionId)) { - const honored = SubscriptionFilterSchema.safeParse(params?.notifications ?? {}); - entry.onAck(honored.success ? honored.data : {}); - return 'consumed'; - } + const entry = typeof subscriptionId === 'string' ? this._listenState.get(subscriptionId) : undefined; + if (entry !== undefined) { + const honored = SubscriptionFilterSchema.safeParse(params?.notifications ?? {}); + entry.settle({ ack: honored.success ? honored.data : {} }); + return; } - // An ack referencing no parked listen on this connection is NOT - // consumed: pass it through so a stray/foreign ack reaches - // setNotificationHandler / fallbackNotificationHandler instead of - // being silently swallowed. - return undefined; } if (raw.method === 'notifications/cancelled') { const cancelledId = (raw.params as { requestId?: unknown } | undefined)?.requestId; - for (const [id, entry] of this._listenState) { - if (String(id) === String(cancelledId)) { - entry.onServerCancel(); - return 'consumed'; - } + const entry = typeof cancelledId === 'string' ? this._listenState.get(cancelledId) : undefined; + if (entry !== undefined) { + // Handles BOTH the pre-ack and post-ack server-side cancel: + // while opening, settle rejects the pending listen() promise; + // once open, settle transitions to closed and `closed` resolves + // 'remote' so the consumer can observe the server-initiated + // close. + entry.settle({ cause: 'remote', error: new Error('subscriptions/listen: server cancelled the subscription') }); + return; } } - return undefined; + super._onnotification(raw, extra); + } + + /** + * Transport-level demux for `subscriptions/listen` responses. The spec + * defines listen as never receiving a JSON-RPC result; a JSON-RPC ERROR + * for the listen id is the server's pre-ack capacity/params rejection. A + * string-id response that matches a live `_listenState` entry is consumed + * here (Protocol's `_responseHandlers` map is keyed by NUMBER and never + * holds a listen id, so passing a string-id response through would + * surface as "unknown message ID" via `onerror`). + */ + protected override _onresponse(response: JSONRPCResponse): void { + const id = response.id; + const entry = typeof id === 'string' ? this._listenState.get(id) : undefined; + if (entry !== undefined) { + if (isJSONRPCErrorResponse(response)) { + entry.settle({ + cause: 'remote', + error: ProtocolError.fromError(response.error.code, response.error.message, response.error.data) + }); + } else { + entry.settle({ + cause: 'remote', + error: new SdkError( + SdkErrorCode.InvalidResult, + 'server answered subscriptions/listen with a result; expected the acknowledged notification' + ) + }); + } + return; + } + super._onresponse(response); + } + + /** + * Settle every live per-listen state machine on a transport-initiated + * close (the server dropping the connection on stdio/InMemory) before + * Protocol's `_onclose` tears the transport down. The base + * `_responseHandlers` settlement does not reach `_listenState` (listen + * ids are never registered there), so without this override a remote + * close would leave an in-flight `listen()` / open `McpSubscription` + * hanging. + */ + protected override _onclose(): void { + if (this._listenState.size > 0) { + const reason = new SdkError(SdkErrorCode.ConnectionClosed, 'Connection closed'); + for (const entry of this._listenState.values()) { + entry.settle({ cause: 'remote', error: reason }); + } + this._listenState.clear(); + } + super._onclose(); } /** diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 61eba63005..bd09b123ab 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -59,6 +59,15 @@ export interface StartSSEOptions { * transport-level abort: no `onerror`, no reconnect. */ requestSignal?: AbortSignal; + + /** + * The per-request stream-end callback supplied via + * `TransportSendOptions.onRequestStreamEnd`. Fired when the SSE response + * stream for the originating POST ends or errors for any non-deliberate + * reason (server closed, network dropped, reconnection exhausted) — never + * when `requestSignal` was aborted. + */ + onRequestStreamEnd?: () => void; } /** @@ -418,6 +427,8 @@ export class StreamableHTTPClientTransport implements Transport { // Check if we've exceeded maximum retry attempts if (attemptCount >= maxRetries) { this.onerror?.(new Error(`Maximum reconnection attempts (${maxRetries}) exceeded.`)); + // The per-request stream is now definitively gone. + options.onRequestStreamEnd?.(); return; } @@ -454,7 +465,7 @@ export class StreamableHTTPClientTransport implements Transport { if (!stream) { return; } - const { onresumptiontoken, replayMessageId, requestSignal } = options; + const { onresumptiontoken, replayMessageId, requestSignal, onRequestStreamEnd } = options; // An intentional abort — transport-wide close OR a per-request abort // (McpSubscription.close() aborting its `requestSignal`) — must read as // a clean shutdown: no misleading "SSE stream disconnected" onerror, @@ -535,10 +546,16 @@ export class StreamableHTTPClientTransport implements Transport { resumptionToken: lastEventId, onresumptiontoken, replayMessageId, - requestSignal + requestSignal, + onRequestStreamEnd }, 0 ); + } else if (!isIntentionalAbort()) { + // The per-request stream ended without reconnecting (no + // priming event for a POST stream, or response already + // received). Not a deliberate abort — notify the caller. + onRequestStreamEnd?.(); } } catch (error) { if (isIntentionalAbort()) { @@ -562,13 +579,19 @@ export class StreamableHTTPClientTransport implements Transport { resumptionToken: lastEventId, onresumptiontoken, replayMessageId, - requestSignal + requestSignal, + onRequestStreamEnd }, 0 ); } catch (error) { this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`)); + onRequestStreamEnd?.(); } + } else { + // Non-deliberate stream error without reconnection: the + // per-request stream is gone — notify the caller. + onRequestStreamEnd?.(); } } }; @@ -617,14 +640,26 @@ export class StreamableHTTPClientTransport implements Transport { async send( message: JSONRPCMessage | JSONRPCMessage[], - options?: { resumptionToken?: string; onresumptiontoken?: (token: string) => void; requestSignal?: AbortSignal } + options?: { + resumptionToken?: string; + onresumptiontoken?: (token: string) => void; + requestSignal?: AbortSignal; + onRequestStreamEnd?: () => void; + } ): Promise { return this._send(message, options, false); } private async _send( message: JSONRPCMessage | JSONRPCMessage[], - options: { resumptionToken?: string; onresumptiontoken?: (token: string) => void; requestSignal?: AbortSignal } | undefined, + options: + | { + resumptionToken?: string; + onresumptiontoken?: (token: string) => void; + requestSignal?: AbortSignal; + onRequestStreamEnd?: () => void; + } + | undefined, isAuthRetry: boolean ): Promise { try { @@ -775,7 +810,15 @@ export class StreamableHTTPClientTransport implements Transport { // Handle SSE stream responses for requests // We use the same handler as standalone streams, which now supports // reconnection with the last event ID - this._handleSseStream(response.body, { onresumptiontoken, requestSignal: options?.requestSignal }, false); + this._handleSseStream( + response.body, + { + onresumptiontoken, + requestSignal: options?.requestSignal, + onRequestStreamEnd: options?.onRequestStreamEnd + }, + false + ); } else if (contentType?.includes('application/json')) { // For non-streaming servers, we might get direct JSON responses const data = await response.json(); @@ -801,7 +844,8 @@ export class StreamableHTTPClientTransport implements Transport { // `subscriptions/listen` driver aborting its `requestSignal`): // fetch rejects with AbortError. Same guard as // `_handleSseStream`'s `isIntentionalAbort` — do not surface a - // misleading onerror; still rethrow so `parked.sent` settles. + // misleading onerror; still rethrow so `listen()`'s send-catch + // settles the per-subscription state machine. if (options?.requestSignal?.aborted !== true) { this.onerror?.(error as Error); } diff --git a/packages/client/test/client/listen.test.ts b/packages/client/test/client/listen.test.ts index 4d061a9ef2..b7d805a0b8 100644 --- a/packages/client/test/client/listen.test.ts +++ b/packages/client/test/client/listen.test.ts @@ -296,8 +296,8 @@ describe('Client.listen()', () => { expect((error as Error).message).toContain('server cancelled the subscription'); // Rejected promptly (well under the 60s ack timeout). expect(Date.now() - t0).toBeLessThan(1000); - // No leaked _responseHandlers entry for the listen id. - expect((client as unknown as { _responseHandlers: Map })._responseHandlers.size).toBe(0); + // No leaked per-listen state for the listen id. + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); await client.close(); }); @@ -327,9 +327,8 @@ describe('Client.listen()', () => { it('a synchronously-delivered server-cancel during send does not leak a _listenState entry', async () => { // In-process delivery: the server's notifications/cancelled arrives - // inside `_parkRequest`'s send (before `parked` is assigned). settle() - // must still drop the `_listenState` entry it registered via - // onBeforeSend. + // inside `transport.send()` (before the `await opening`). settle() + // must still drop the `_listenState` entry registered before send. const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); serverTx.onmessage = m => { const req = m as { id?: number | string; method?: string }; @@ -361,23 +360,21 @@ describe('Client.listen()', () => { await client.close(); }); - it('a synchronous transport.send throw does not leak a _responseHandlers entry', async () => { + it('a synchronous transport.send throw does not leak a _listenState entry', async () => { const { clientTx } = await scriptedModern(); const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(clientTx); - const handlers = (client as unknown as { _responseHandlers: Map })._responseHandlers; - const before = handlers.size; const realSend = clientTx.send.bind(clientTx); clientTx.send = () => { throw new Error('send blew up'); }; const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error); expect((error as Error).message).toContain('send blew up'); - // The park primitive unregistered before rethrowing — no leak. - expect(handlers.size).toBe(before); - // settle() in the catch path also dropped the _listenState entry that - // onBeforeSend registered before send threw. + // settle() in the catch path dropped the _listenState entry that was + // registered before send threw; listen() never registers in + // Protocol's `_responseHandlers` so there is nothing to leak there. expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + expect((client as unknown as { _responseHandlers: Map })._responseHandlers.size).toBe(0); clientTx.send = realSend; await client.close(); }); @@ -421,7 +418,6 @@ describe('Client.listen()', () => { expect(cancelled?.params.requestId).toBe(listenId); // No leaked state. expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); - expect((client as unknown as { _responseHandlers: Map })._responseHandlers.size).toBe(0); await client.close(); }); @@ -657,8 +653,8 @@ describe('Client.listen()', () => { const t0 = Date.now(); const pending = client.listen({ toolsListChanged: true }); await flush(); - // Server-side transport closes before ever acking → Protocol._onclose - // errors every parked _responseHandlers entry → settle({error}). + // Server-side transport closes before ever acking → Client's + // `_onclose` override settles every per-listen state machine. await serverTx.close(); const error = await pending.catch(e => e as Error); expect(error).toBeInstanceOf(Error); @@ -720,8 +716,9 @@ describe('Client.listen()', () => { const sub = await client.listen({ toolsListChanged: true }); await sub.close(); // The per-listen entry is gone; a late server-side ack and a late - // server-side cancel for this id are NOT consumed by the first-look - // hook (no parked entry matches) and reach the fallback handler. + // server-side cancel for this id are NOT consumed by the + // `_onnotification` override (no entry matches) and reach the + // fallback handler. const fallback: string[] = []; client.fallbackNotificationHandler = async n => { fallback.push(n.method); @@ -771,7 +768,6 @@ describe('Client.listen()', () => { await client.connect(clientTx); const pending = client.listen({ toolsListChanged: true }); await flush(); - const handlers = (client as unknown as { _responseHandlers: Map })._responseHandlers; expect((client as unknown as { _listenState: Map })._listenState.size).toBe(1); // Fresh connect on a new transport — _resetConnectionState runs. const { clientTx: clientTx2 } = await scriptedModern(); @@ -780,11 +776,61 @@ describe('Client.listen()', () => { expect(error).toBeInstanceOf(SdkError); expect((error as SdkError).code).toBe(SdkErrorCode.ConnectionClosed); expect((error as SdkError).message).toContain('reconnected or closed'); - // No leaked parked handler from the old connection. - expect(handlers.size).toBe(0); + // No leaked per-listen state from the old connection. + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + await client.close(); + }); + + it("the listen request id is a STRING on the wire ('listen:N'); cancel echoes it verbatim", async () => { + const { clientTx, written } = await scriptedModern(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + const wireListen = written.find(m => (m as { method?: string }).method === 'subscriptions/listen') as { + id: unknown; + params: { _meta?: Record }; + }; + // String id from a Client-owned counter — JSON-RPC valid; spec + // subscriptionId is the request id verbatim; zero collision with + // Protocol's numeric counter. + expect(typeof wireListen.id).toBe('string'); + expect(wireListen.id).toMatch(/^listen:\d+$/); + // The auto-envelope is on the wire too. + expect(wireListen.params._meta?.[PROTOCOL_VERSION_META_KEY]).toBe(MODERN); + written.length = 0; + await sub.close(); + const cancel = written[0] as unknown as { method: string; params: { requestId: unknown } }; + expect(cancel.params.requestId).toBe(wireListen.id); await client.close(); }); + it("transport-level per-request stream end (onRequestStreamEnd) → closed resolves 'remote'", async () => { + // Mock a transport that captures the per-request `onRequestStreamEnd` + // callback and fires it after the ack — simulating a Streamable HTTP + // server closing the listen request's SSE stream. + const { clientTx, serverTx } = await scriptedModern(); + let onStreamEnd: (() => void) | undefined; + const realSend = clientTx.send.bind(clientTx); + clientTx.send = (m, opts) => { + if ((m as { method?: string }).method === 'subscriptions/listen') { + onStreamEnd = (opts as { onRequestStreamEnd?: () => void } | undefined)?.onRequestStreamEnd; + } + return realSend(m, opts); + }; + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + const sub = await client.listen({ toolsListChanged: true }); + expect(onStreamEnd).toBeDefined(); + // Transport reports the per-request stream ended (server closed the + // SSE response, network dropped it, reconnection exhausted). + onStreamEnd!(); + await expect(sub.closed).resolves.toBe('remote'); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + // close() after stream-end is a no-op (state already 'closed'). + await sub.close(); + await serverTx.close(); + }); + it('close() resets per-connection state even when transport.close() rejects', async () => { const { clientTx } = await scriptedModern(); const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index a6630ec814..fbd120f9fa 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -1155,6 +1155,92 @@ describe('StreamableHTTPClientTransport', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it('onRequestStreamEnd fires when the per-request POST stream ends gracefully without reconnecting', async () => { + // ARRANGE — a POST stream with NO priming event id (so the + // graceful-close path does NOT schedule a reconnect): the + // per-request stream simply ends. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + let streamController!: ReadableStreamDefaultController; + const unprimedStream = new ReadableStream({ + start(controller) { + streamController = controller; + // An ack frame with no SSE event id — does NOT arm POST-stream resumability. + controller.enqueue( + new TextEncoder().encode( + 'data: {"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{}}\n\n' + ) + ); + } + }); + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: unprimedStream + }) + ); + + const requestAbort = new AbortController(); + const onStreamEnd = vi.fn(); + await transport.start(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} }, + { requestSignal: requestAbort.signal, onRequestStreamEnd: onStreamEnd } + ); + await vi.advanceTimersByTimeAsync(5); + expect(onStreamEnd).not.toHaveBeenCalled(); + + // ACT — server gracefully closes the SSE stream. + streamController.close(); + await vi.advanceTimersByTimeAsync(5); + + // ASSERT — non-deliberate stream end without reconnecting: + // onRequestStreamEnd fired exactly once; no further fetches. + expect(onStreamEnd).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('onRequestStreamEnd does NOT fire on a deliberate per-request abort', async () => { + // Same shape as the no-onerror/no-reconnect test, but assert the + // stream-end callback is NEVER invoked when `requestSignal` was the + // abort source. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + let streamController!: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(controller) { + streamController = controller; + } + }); + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockImplementationOnce((_url, init: RequestInit) => { + init.signal?.addEventListener('abort', () => streamController.error(init.signal?.reason), { once: true }); + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: stream + }); + }); + + const requestAbort = new AbortController(); + const onStreamEnd = vi.fn(); + await transport.start(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} }, + { requestSignal: requestAbort.signal, onRequestStreamEnd: onStreamEnd } + ); + await vi.advanceTimersByTimeAsync(5); + + // ACT — deliberate per-request abort. + requestAbort.abort(); + await vi.advanceTimersByTimeAsync(50); + + // ASSERT — deliberate abort: onRequestStreamEnd never fires. + expect(onStreamEnd).not.toHaveBeenCalled(); + }); + it('per-request requestSignal abort BEFORE response headers: no misleading onerror; send() still rejects', async () => { // ARRANGE — fetch is in flight (pending promise) when the // requestSignal aborts; fetch rejects with AbortError before the @@ -1184,7 +1270,7 @@ describe('StreamableHTTPClientTransport', () => { // ACT — abort before headers. requestAbort.abort(new Error('intentional')); - // ASSERT — send() rejects (so parked.sent settles), but no onerror. + // ASSERT — send() rejects (so listen()'s send-catch settles), but no onerror. await expect(sent).rejects.toThrow(); expect(errorSpy).not.toHaveBeenCalled(); }); diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index d04f82b63b..9291a3cfc3 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -609,14 +609,12 @@ export abstract class Protocol { * resolve. The base default surfaces it as a typed * {@linkcode SdkErrorCode.UnsupportedResultType} error (no retry). * - * Intended consumer (named so the seam stays accountable): the `Client`'s - * multi-round-trip auto-fulfilment engine, which fulfils - * `'input_required'` results through the registered - * elicitation/sampling/roots handlers and retries via `flow.retry`. - * - * `subscriptions/listen` does NOT use this seam — it never receives a - * JSON-RPC result; the listen driver uses {@linkcode _parkRequest} and - * {@linkcode _onParkedNotification} instead. + * Intended consumers (named so the seam stays accountable): + * - the `Client`'s multi-round-trip auto-fulfilment engine, which fulfils + * `'input_required'` results through the registered + * elicitation/sampling/roots handlers and retries via `flow.retry`; + * - a future client-side terminal-result handler for + * `subscriptions/listen`, when the spec defines one. * * `Server` instances never receive `input_required` responses on their * outbound legs and leave the base behavior in place. @@ -643,97 +641,6 @@ export abstract class Protocol { return this._requestHandlers.get(method); } - /** - * First-look hook for inbound notifications, consulted before any - * decoding, era gating, or handler dispatch. When set and returning - * `'consumed'`, the notification goes no further; when unset or returning - * `undefined`, dispatch proceeds unchanged. - * - * Intended consumer (named so the seam stays accountable): the - * `Client`-side `subscriptions/listen` driver, which routes the leading - * `notifications/subscriptions/acknowledged` (and the inbound - * `notifications/cancelled` that terminates a parked listen on stdio) to - * the per-subscription state rather than the generic notification map. - * The hook receives the raw wire shape — `_meta` (including the - * subscription-id key) is intact. - * - * Inert when unset. Do NOT widen this into a general-purpose notification - * tap; anything beyond the listen driver belongs in - * {@linkcode setNotificationHandler}. - */ - protected _onParkedNotification?: (raw: JSONRPCNotification) => 'consumed' | undefined; - - /** - * Low-level no-timeout request primitive: sends a JSON-RPC request and - * registers a response handler WITHOUT arming `_setupTimeout`. The - * registration stays in `_responseHandlers` until either a JSON-RPC - * response/error arrives for the id, the transport closes, or the caller - * invokes the returned `unpark()` — whichever comes first. - * - * Intended consumer (named so the seam stays accountable): the - * `Client.listen()` driver. `subscriptions/listen` is a long-lived - * request that the spec defines as never receiving a JSON-RPC result — - * termination is stream close (HTTP) or an inbound - * `notifications/cancelled` (stdio). The standard `request()` funnel is - * deliberately untouched; this primitive does not pass through - * `_resolveNonCompleteResult`, `decodeResult`, or any per-request timeout. - * - * Returns the allocated message id (the spec's subscription id is this id - * verbatim) and an `unpark()` that idempotently removes the registration. - * `terminated` resolves when the registration is consumed by a response, - * error, or transport close; it never rejects. - */ - protected _parkRequest( - request: { method: string; params?: { [key: string]: unknown; _meta?: { [key: string]: unknown } } }, - options?: TransportSendOptions, - /** - * Called synchronously after the message id is allocated and the - * response handler is registered, BEFORE the request is sent — so - * the caller can register per-id state without racing a - * synchronously-delivered response on an in-process transport. - */ - onBeforeSend?: (messageId: number) => void - ): { - messageId: number; - sent: Promise; - terminated: Promise<{ reason: 'response' | 'error' | 'unparked'; error?: Error }>; - unpark: () => void; - } { - if (!this._transport) { - throw new SdkError(SdkErrorCode.NotConnected, 'Not connected'); - } - const messageId = this._requestMessageId++; - const jsonrpcRequest: JSONRPCRequest = { ...request, jsonrpc: '2.0', id: messageId }; - let settle!: (value: { reason: 'response' | 'error' | 'unparked'; error?: Error }) => void; - const terminated = new Promise<{ reason: 'response' | 'error' | 'unparked'; error?: Error }>(resolve => { - settle = resolve; - }); - let live = true; - this._responseHandlers.set(messageId, response => { - if (!live) return; - live = false; - settle(response instanceof Error ? { reason: 'error', error: response } : { reason: 'response' }); - }); - const unpark = () => { - if (!live) return; - live = false; - this._responseHandlers.delete(messageId); - settle({ reason: 'unparked' }); - }; - onBeforeSend?.(messageId); - let sent: Promise; - try { - sent = this._transport.send(jsonrpcRequest, options); - } catch (error) { - // Unregister before rethrowing so a synchronous send failure does - // not leak a permanent _responseHandlers entry for an id that - // never went out on the wire. - unpark(); - throw error; - } - return { messageId, sent, terminated, unpark }; - } - private async _oncancel(notification: CancelledNotification): Promise { if (!notification.params.requestId) { return; @@ -828,7 +735,7 @@ export abstract class Protocol { await this._transport.start(); } - private _onclose(): void { + protected _onclose(): void { const responseHandlers = this._responseHandlers; this._responseHandlers = new Map(); this._progressHandlers.clear(); @@ -863,12 +770,7 @@ export abstract class Protocol { this.onerror?.(error); } - private _onnotification(rawNotification: JSONRPCNotification, extra?: MessageExtraInfo): void { - // First-look hook (inert when unset): the `subscriptions/listen` - // driver gets first refusal on the raw notification — `_meta` intact. - if (this._onParkedNotification?.(rawNotification) === 'consumed') { - return; - } + protected _onnotification(rawNotification: JSONRPCNotification, extra?: MessageExtraInfo): void { // Hide wire-only material from notification handlers too — but ONLY // the reserved envelope `_meta` keys (the retry params names are // reserved on requests, not notifications). There is no @@ -1184,7 +1086,7 @@ export abstract class Protocol { handler(params); } - private _onresponse(response: JSONRPCResponse | JSONRPCErrorResponse): void { + protected _onresponse(response: JSONRPCResponse | JSONRPCErrorResponse): void { const messageId = Number(response.id); const handler = this._responseHandlers.get(messageId); diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index bd2abd678c..c9be6ee56c 100644 --- a/packages/core/src/shared/transport.ts +++ b/packages/core/src/shared/transport.ts @@ -77,6 +77,16 @@ export type TransportSendOptions = { * ignore it. */ requestSignal?: AbortSignal | undefined; + + /** + * Fired by transports that open a per-request stream (the Streamable HTTP + * transport's POST-per-request SSE response) when that stream ends or + * errors for any reason OTHER than a deliberate `requestSignal` abort — + * i.e. the server closed the stream, the network dropped it, or + * reconnection was exhausted. Transports that share a single channel + * (stdio, in-memory) ignore it. + */ + onRequestStreamEnd?: (() => void) | undefined; }; /** * Describes the minimal contract for an MCP transport that a client or server can communicate over. diff --git a/packages/core/src/wire/rev2026-07-28/registry.ts b/packages/core/src/wire/rev2026-07-28/registry.ts index 4abe136fe8..969c719bbd 100644 --- a/packages/core/src/wire/rev2026-07-28/registry.ts +++ b/packages/core/src/wire/rev2026-07-28/registry.ts @@ -24,8 +24,8 @@ * Dispatch never reaches a registered handler — the serving entries * (`createMcpHandler`, `serveStdio`) recognize listen at the entry layer * and own ack/filter/stamp/teardown themselves; on the client side - * `Client.listen()` sends via the in-Protocol park primitive rather than - * `request()`. + * `Client.listen()` sends directly on the transport (string-typed + * request id, transport-level demux) rather than via `request()`. */ import type * as z from 'zod/v4'; From b142b80eaacdfb66ce00fde5bd67de7f39ed0c32 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 20:31:15 +0000 Subject: [PATCH 26/27] fix(client): close transport when connect-signal aborts during auto-open ack wait; drop old-layout listen examples (live in #2325); e2e CLAUDE.md doc nit --- examples/client/README.md | 1 - .../client/src/subscriptionsListenClient.ts | 80 ------------------- examples/server/README.md | 1 - examples/server/src/subscriptionsListen.ts | 61 -------------- packages/client/src/client/client.ts | 12 ++- test/e2e/CLAUDE.md | 2 +- 6 files changed, 10 insertions(+), 147 deletions(-) delete mode 100644 examples/client/src/subscriptionsListenClient.ts delete mode 100644 examples/server/src/subscriptionsListen.ts diff --git a/examples/client/README.md b/examples/client/README.md index 56c5881cdf..0879b3b6c0 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -36,7 +36,6 @@ Most clients expect a server to be running. Start one from [`../server/README.md | Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) | | URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) | | Multi-round-trip client (2026-07-28) | Calls a write-once tool twice: default auto-fulfilment, then manual mode. | [`src/multiRoundTripClient.ts`](src/multiRoundTripClient.ts) | -| Subscriptions/listen client (2026-07-28) | `listChanged` auto-open then manual `client.listen()`; closes after a few changes. | [`src/subscriptionsListenClient.ts`](src/subscriptionsListenClient.ts) | ## URL elicitation example (server + client) diff --git a/examples/client/src/subscriptionsListenClient.ts b/examples/client/src/subscriptionsListenClient.ts deleted file mode 100644 index f92dce780f..0000000000 --- a/examples/client/src/subscriptionsListenClient.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Drives the `subscriptions/listen` server example - * (`examples/server/src/subscriptionsListen.ts`) two ways on a 2026-07-28 - * connection: - * - * 1. **auto-open via `ClientOptions.listChanged`** — the same option a - * 2025-era client sets; on a modern connection the SDK auto-opens a - * listen stream whose filter is the intersection of the configured - * sub-options and the server-advertised `listChanged` capabilities - * (auto-open is skipped when the intersection is empty), so the - * configured `onChanged` handlers fire on every published change; - * 2. **manual `client.listen()`** — opens a stream explicitly, registers a - * `notifications/tools/list_changed` handler the stream feeds, and closes - * after a few notifications. - * - * Start the server first, then: - * - * tsx examples/client/src/subscriptionsListenClient.ts - */ -import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; - -const URL = process.env.MCP_SERVER_URL ?? 'http://localhost:3000/'; -const CLIENT_INFO = { name: 'subscriptions-listen-example-client', version: '1.0.0' }; - -async function autoOpenLeg(): Promise { - console.log('--- auto-open via ClientOptions.listChanged ---'); - let count = 0; - let done!: () => void; - const finished = new Promise(resolve => { - done = resolve; - }); - const client = new Client(CLIENT_INFO, { - versionNegotiation: { mode: 'auto' }, - listChanged: { - tools: { - // autoRefresh: false — automatic per-request envelope emission - // is a client-side follow-up; until then a refreshing - // listTools() on a 2026 connection needs the envelope attached - // explicitly (see the multi-round-trip example). - autoRefresh: false, - onChanged: () => { - console.log('[client] (auto) tools/list_changed received'); - if (++count >= 2) done(); - } - } - } - }); - await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); - console.log( - `[client] (auto) connected (${client.getNegotiatedProtocolVersion()}); auto-opened filter:`, - client.autoOpenedSubscription?.honoredFilter - ); - await finished; - await client.autoOpenedSubscription?.close(); - await client.close(); -} - -async function manualLeg(): Promise { - console.log('--- manual client.listen() ---'); - const client = new Client(CLIENT_INFO, { versionNegotiation: { mode: 'auto' } }); - let count = 0; - let done!: () => void; - const finished = new Promise(resolve => { - done = resolve; - }); - client.setNotificationHandler('notifications/tools/list_changed', () => { - console.log('[client] (manual) tools/list_changed received'); - if (++count >= 2) done(); - }); - await client.connect(new StreamableHTTPClientTransport(new globalThis.URL(URL))); - const sub = await client.listen({ toolsListChanged: true }); - console.log('[client] (manual) listening; honored filter:', sub.honoredFilter); - await finished; - await sub.close(); - await client.close(); -} - -await autoOpenLeg(); -await manualLeg(); -console.log('done.'); diff --git a/examples/server/README.md b/examples/server/README.md index 1b6cf90158..bce265104a 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -39,7 +39,6 @@ pnpm tsx src/simpleStreamableHttp.ts | Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) | | SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) | | Multi-round-trip server (2026-07-28) | Write-once tool that returns `inputRequired(...)` (form + URL elicitation, requestState echo) via `createMcpHandler`. | [`src/multiRoundTrip.ts`](src/multiRoundTrip.ts) | -| Subscriptions/listen server (2026-07-28) | Publishes `tools/list_changed` to open `subscriptions/listen` streams via `handler.notify.toolsChanged()`. | [`src/subscriptionsListen.ts`](src/subscriptionsListen.ts) | ## OAuth demo flags (Streamable HTTP server) diff --git a/examples/server/src/subscriptionsListen.ts b/examples/server/src/subscriptionsListen.ts deleted file mode 100644 index b56006b794..0000000000 --- a/examples/server/src/subscriptionsListen.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * `subscriptions/listen` change notifications served via `createMcpHandler` - * (protocol revision 2026-07-28). - * - * The handler exposes `.notify` typed publish sugar over its - * `subscriptions/listen` bus: this example calls - * `handler.notify.toolsChanged()` whenever a tool is added or removed, and - * every open `subscriptions/listen` stream that opted in to - * `toolsListChanged` receives a stamped `notifications/tools/list_changed`. - * - * Run with: - * - * tsx examples/server/src/subscriptionsListen.ts - * - * and point the paired client example at it: - * - * tsx examples/client/src/subscriptionsListenClient.ts - */ -import { createServer } from 'node:http'; - -import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; -import * as z from 'zod/v4'; - -let extraToolEnabled = false; - -function buildServer(): McpServer { - const server = new McpServer({ name: 'subscriptions-listen-example', version: '1.0.0' }); - - server.registerTool('greet', { description: 'Returns a greeting', inputSchema: z.object({ name: z.string() }) }, async ({ name }) => ({ - content: [{ type: 'text', text: `hello, ${name}` }] - })); - if (extraToolEnabled) { - server.registerTool( - 'farewell', - { description: 'Returns a farewell', inputSchema: z.object({ name: z.string() }) }, - async ({ name }) => ({ content: [{ type: 'text', text: `goodbye, ${name}` }] }) - ); - } - - return server; -} - -// Host with the per-request HTTP entry on its default posture (2026-07-28 -// served per request; 2025-era traffic served stateless from the same -// factory). The handler creates an in-process bus by default; supply your -// own `bus` for multi-process deployments. -const handler = createMcpHandler(() => buildServer()); -const port = Number(process.env.PORT ?? '3000'); - -createServer((req, res) => void handler.node(req, res)).listen(port, () => { - console.error(`subscriptions/listen example server listening on http://localhost:${port}/`); -}); - -// Mutate the tool set every two seconds and publish the change to every open -// subscription stream that opted in to toolsListChanged. Safe to call when no -// subscription is open (no-op). -setInterval(() => { - extraToolEnabled = !extraToolEnabled; - console.error(`tools changed: farewell ${extraToolEnabled ? 'added' : 'removed'}`); - handler.notify.toolsChanged(); -}, 2000); diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index f8650f4b28..6b8bbafa80 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -974,9 +974,15 @@ export class Client extends Protocol { }); } catch (error) { // Connect-signal abort during the ack wait propagates as a - // connect() rejection (caller asked to abort connect); a - // server-side refusal stays a soft onerror. - if (options?.signal?.aborted) throw error; + // connect() rejection (caller asked to abort connect). The + // transport is already started, so close it before + // rethrowing — a connect() rejection MUST NOT leave a + // half-open connection. A server-side refusal stays a + // soft onerror (connect succeeds, no listen stream). + if (options?.signal?.aborted) { + void this.close(); + throw error; + } this.onerror?.(error instanceof Error ? error : new Error(String(error))); } finally { options?.signal?.removeEventListener('abort', onConnectAbort); diff --git a/test/e2e/CLAUDE.md b/test/e2e/CLAUDE.md index d44ba8a03a..586f390698 100644 --- a/test/e2e/CLAUDE.md +++ b/test/e2e/CLAUDE.md @@ -73,7 +73,7 @@ entryExclusions: [{ arm: 'entryModern', reason: 'method-not-in-modern-registry' ``` Omitting `arm` excludes both arms. The reasons (`EntryExclusionReason` in types.ts) are the acceptance checklist for re-admitting cells when the corresponding entry feature lands; a coverage gate rejects annotations that would never have an effect. Requirement families that the -per-request entry structurally cannot serve at all (server→client requests, sessions/resumability, standalone GET streams, subscriptions) are already expressed through their `transports` restrictions and need no annotation. +per-request entry structurally cannot serve at all (server→client requests, sessions/resumability, standalone GET streams) are already expressed through their `transports` restrictions and need no annotation. Arm-specific helpers: `wire()`'s fourth argument also accepts `entry` (createMcpHandler hosting overrides — e.g. a `responseMode` or a different `legacy` posture), the returned `Wired.httpLog` records every HTTP exchange (request body, status, content-type, a readable response clone) for raw wire assertions, factories may accept the optional per-request context (`EntryServerFactory`), and `modernEnvelopeMeta()` builds the envelope for bodies that POST raw 2026-era requests through `wired.fetch`. Compositions that the entry no longer expresses through From be1694d2488c74dd81fc165e350a925ae82a8dda Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 22:12:20 +0000 Subject: [PATCH 27/27] fix(client): fire onRequestStreamEnd on the GET-resume 405/null-body 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 --- .changeset/subscriptions-listen-client.md | 2 +- packages/client/src/client/client.ts | 21 +- packages/client/src/client/streamableHttp.ts | 14 ++ packages/client/test/client/listen.test.ts | 31 +++ .../client/test/client/streamableHttp.test.ts | 191 ++++++++++++++++++ packages/core/src/shared/protocol.ts | 17 ++ 6 files changed, 268 insertions(+), 8 deletions(-) diff --git a/.changeset/subscriptions-listen-client.md b/.changeset/subscriptions-listen-client.md index d01bd0dd63..3cc5c10757 100644 --- a/.changeset/subscriptions-listen-client.md +++ b/.changeset/subscriptions-listen-client.md @@ -3,4 +3,4 @@ '@modelcontextprotocol/client': minor --- -`Client.listen(filter)` opens a `subscriptions/listen` stream on a 2026-07-28-era connection, resolving once the server's acknowledged notification arrives with an `McpSubscription { honoredFilter, close(), closed }`. `closed` is a `Promise<'local' | 'remote'>` that resolves exactly once when the subscription terminates (`'local'` = you called `close()`; `'remote'` = the server cancelled, the stream ended, or the transport dropped — re-listen if you still want events) and never rejects. Change notifications delivered on the stream dispatch to the existing `setNotificationHandler` registrations — the same handlers the 2025-era unsolicited notifications fire on a legacy connection — so `listen()` is era-transparent for consumers that already register those. `close()` aborts the listen request's stream (where the transport supports it) and sends `notifications/cancelled` referencing the listen id — both, on every transport; no automatic re-listen. On a 2025-era connection `listen()` throws a typed `MethodNotSupportedByProtocolVersion` steering to `resources/subscribe` and `ClientOptions.listChanged`. `ClientOptions.listChanged` now auto-opens a listen stream on a modern connection — the filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities; auto-open is skipped (`client.autoOpenedSubscription` stays `undefined`) when that intersection is empty; otherwise the auto-opened subscription is exposed at `client.autoOpenedSubscription`. `TransportSendOptions` gains `requestSignal` for per-request abort on the Streamable HTTP transport. +`Client.listen(filter)` opens a `subscriptions/listen` stream on a 2026-07-28-era connection, resolving once the server's acknowledged notification arrives with an `McpSubscription { honoredFilter, close(), closed }`. `closed` is a `Promise<'local' | 'remote'>` that resolves exactly once when the subscription terminates (`'local'` = you called `close()`; `'remote'` = the server cancelled, the stream ended, or the transport dropped — re-listen if you still want events) and never rejects. Change notifications delivered on the stream dispatch to the existing `setNotificationHandler` registrations — the same handlers the 2025-era unsolicited notifications fire on a legacy connection — so `listen()` is era-transparent for consumers that already register those. `close()` aborts the listen request's stream (where the transport supports it) and sends `notifications/cancelled` referencing the listen id — both, on every transport; no automatic re-listen. On a 2025-era connection `listen()` throws a typed `MethodNotSupportedByProtocolVersion` steering to `resources/subscribe` and `ClientOptions.listChanged`. `ClientOptions.listChanged` now auto-opens a listen stream on a modern connection — the filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities; auto-open is skipped (`client.autoOpenedSubscription` stays `undefined`) when that intersection is empty; otherwise the auto-opened subscription is exposed at `client.autoOpenedSubscription`. `TransportSendOptions` gains `requestSignal` (per-request abort) and `onRequestStreamEnd` (fires when a per-request response stream ends or errors for any non-deliberate reason) on the Streamable HTTP transport. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 6b8bbafa80..e6ae442893 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -932,17 +932,24 @@ export class Client extends Protocol { // throw on misconfiguration; the modern connection IS established // at this point and is fully usable without listChanged handlers, // so a misconfiguration surfaces via onerror and connect resolves - // (matching the auto-open soft-fail posture). + // (matching the auto-open soft-fail posture). When registration + // fails the auto-open is SKIPPED — opening a listen stream for + // types whose handler never registered would consume a server + // slot to deliver notifications nothing handles. + let handlersRegistered = true; try { this._setupListChangedHandlers(effective); } catch (error) { + handlersRegistered = false; this.onerror?.(error instanceof Error ? error : new Error(String(error))); } - const filter: SubscriptionFilter = { - ...(effective.tools && { toolsListChanged: true as const }), - ...(effective.prompts && { promptsListChanged: true as const }), - ...(effective.resources && { resourcesListChanged: true as const }) - }; + const filter: SubscriptionFilter = handlersRegistered + ? { + ...(effective.tools && { toolsListChanged: true as const }), + ...(effective.prompts && { promptsListChanged: true as const }), + ...(effective.resources && { resourcesListChanged: true as const }) + } + : {}; if (Object.keys(filter).length > 0) { // A failed auto-open MUST NOT fail connect: the modern // connection is fully usable without a listen stream (the @@ -980,7 +987,7 @@ export class Client extends Protocol { // half-open connection. A server-side refusal stays a // soft onerror (connect succeeds, no listen stream). if (options?.signal?.aborted) { - void this.close(); + await this.close().catch(() => {}); throw error; } this.onerror?.(error instanceof Error ? error : new Error(String(error))); diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index bd09b123ab..277e84e0fd 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -375,6 +375,15 @@ export class StreamableHTTPClientTransport implements Transport { // 405 indicates that the server does not offer an SSE stream at GET endpoint // This is an expected case that should not trigger an error if (response.status === 405) { + // A 405 on the standalone-GET path is benign (the caller + // never had a per-request stream). On the POST→GET resume + // path it is a TERMINAL non-resumable outcome for a + // per-request stream the caller is observing — fire the + // stream-end callback so the caller can settle (otherwise + // a resumed listen subscription dead-ends silently). The + // standalone-GET callers never pass `onRequestStreamEnd`, + // so this is a no-op for them. + options.onRequestStreamEnd?.(); return; } @@ -463,6 +472,11 @@ export class StreamableHTTPClientTransport implements Transport { private _handleSseStream(stream: ReadableStream | null, options: StartSSEOptions, isReconnectable: boolean): void { if (!stream) { + // A null body on a per-request stream (or its GET resume) is the + // same terminal non-resumable outcome as a 405 — fire the + // stream-end callback so the caller can settle. No-op for + // standalone-GET callers (they never pass `onRequestStreamEnd`). + options.onRequestStreamEnd?.(); return; } const { onresumptiontoken, replayMessageId, requestSignal, onRequestStreamEnd } = options; diff --git a/packages/client/test/client/listen.test.ts b/packages/client/test/client/listen.test.ts index b7d805a0b8..2476325c23 100644 --- a/packages/client/test/client/listen.test.ts +++ b/packages/client/test/client/listen.test.ts @@ -553,6 +553,31 @@ describe('Client.listen()', () => { await client.close(); }); + it('a misconfigured listChanged handler surfaces via onerror and SKIPS auto-open (no wire write)', async () => { + // Regression: when handler registration threw (the soft-fail catch), + // the auto-open filter was still built from the same `effective`, + // opening a listen stream for types whose handler never registered — + // delivered notifications dropped on the floor while consuming a + // server slot. Now a registration failure skips auto-open entirely. + const { clientTx, written } = await scriptedModernNoAck(); + const onChanged = () => {}; + const client = new Client( + { name: 'c', version: '1' }, + { versionNegotiation: { mode: 'auto' }, listChanged: { tools: { onChanged, debounceMs: -1 } } } + ); + const errors: Error[] = []; + client.onerror = e => errors.push(e); + // connect MUST resolve: the modern connection is usable without listen. + await client.connect(clientTx); + expect(errors).toHaveLength(1); + expect(errors[0]!.message).toContain('Invalid tools listChanged options'); + // Auto-open SKIPPED: no listen request hit the wire, no subscription. + expect(client.autoOpenedSubscription).toBeUndefined(); + expect(written.some(m => (m as { method?: string }).method === 'subscriptions/listen')).toBe(false); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + await client.close(); + }); + it('connect-scoped signal does NOT bind to the auto-opened subscription lifetime', async () => { // Regression: forwarding connect()'s full RequestOptions into the // auto-open listen() call meant a connect-scoped signal — typically @@ -591,6 +616,7 @@ describe('Client.listen()', () => { // meant connect()'s signal could not cancel the in-connect ack wait — // an aborted connect blocked here for the full ack timeout. const { clientTx } = await scriptedModernNoAck(); + const closeSpy = vi.spyOn(clientTx, 'close'); const onChanged = () => {}; const client = new Client( { name: 'c', version: '1' }, @@ -607,6 +633,11 @@ describe('Client.listen()', () => { expect(Date.now() - t0).toBeLessThan(1000); // No leaked per-listen state on the aborted connect. expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + // A connect() rejection MUST NOT leave a half-open connection: the + // transport was closed before rethrowing (b142b80ea regression assertion). + await flush(); + expect(closeSpy).toHaveBeenCalled(); + expect(client.transport).toBeUndefined(); await client.close(); }); diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index fbd120f9fa..04d4615b41 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -1241,6 +1241,197 @@ describe('StreamableHTTPClientTransport', () => { expect(onStreamEnd).not.toHaveBeenCalled(); }); + it('onRequestStreamEnd fires when reconnection attempts are exhausted (maxRetries reached)', async () => { + // ARRANGE — a primed POST stream (so a non-deliberate close + // schedules a GET resume); every GET resume fails; maxRetries 1 + // means the second schedule hits the exhausted branch. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 5, + maxRetries: 1, + maxReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1 + } + }); + const errorSpy = vi.fn(); + transport.onerror = errorSpy; + + let streamController!: ReadableStreamDefaultController; + const primedStream = new ReadableStream({ + start(controller) { + streamController = controller; + controller.enqueue(new TextEncoder().encode('id: ev-1\ndata: \n\n')); + } + }); + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: primedStream + }); + // The GET resume fails with a 5xx → reconnect catch reschedules → exhausted. + fetchMock.mockResolvedValue({ ok: false, status: 503, statusText: 'unavailable', headers: new Headers() }); + + const onStreamEnd = vi.fn(); + await transport.start(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} }, + { requestSignal: new AbortController().signal, onRequestStreamEnd: onStreamEnd } + ); + await vi.advanceTimersByTimeAsync(5); + expect(onStreamEnd).not.toHaveBeenCalled(); + + // ACT — server closes the primed POST stream non-deliberately. + streamController.close(); + await vi.advanceTimersByTimeAsync(100); + + // ASSERT — exhausted: onRequestStreamEnd fired exactly once (the + // max-retries branch); the exhausted onerror surfaced. + expect(onStreamEnd).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining('Maximum reconnection attempts') }) + ); + }); + + it('onRequestStreamEnd fires when the per-request POST stream ERRORS without reconnecting', async () => { + // ARRANGE — a POST stream with NO priming event id; the body + // errors (network drop). The error-branch `else` (no reconnect, + // not intentional-abort) must fire onRequestStreamEnd. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + const failingStream = new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + 'data: {"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{}}\n\n' + ) + ); + queueMicrotask(() => controller.error(new Error('network drop'))); + } + }); + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: failingStream + }); + + const onStreamEnd = vi.fn(); + await transport.start(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} }, + { requestSignal: new AbortController().signal, onRequestStreamEnd: onStreamEnd } + ); + await vi.advanceTimersByTimeAsync(50); + + // ASSERT — error-branch fired exactly once; no reconnection + // attempted (POST stream wasn't primed). + expect(onStreamEnd).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('onRequestStreamEnd does NOT fire on transport.close()', async () => { + // The transport-wide abort is the OTHER deliberate teardown + // (`isIntentionalAbort()` checks both signals): a per-request + // stream-end callback must not fire when close() tore the stream + // down — `_onclose` is the settle path for that. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp')); + let streamController!: ReadableStreamDefaultController; + const stream = new ReadableStream({ + start(controller) { + streamController = controller; + controller.enqueue(new TextEncoder().encode('id: ev-1\ndata: \n\n')); + } + }); + const fetchMock = globalThis.fetch as Mock; + fetchMock.mockImplementationOnce((_url, init: RequestInit) => { + init.signal?.addEventListener('abort', () => streamController.error(init.signal?.reason), { once: true }); + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: stream + }); + }); + + const onStreamEnd = vi.fn(); + await transport.start(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} }, + { requestSignal: new AbortController().signal, onRequestStreamEnd: onStreamEnd } + ); + await vi.advanceTimersByTimeAsync(5); + + // ACT — transport-wide close. + await transport.close(); + await vi.advanceTimersByTimeAsync(50); + + // ASSERT — deliberate transport close: onRequestStreamEnd never fires. + expect(onStreamEnd).not.toHaveBeenCalled(); + }); + + it('onRequestStreamEnd fires when a primed POST→GET resume hits 405 (non-resumable terminal)', async () => { + // R1 regression: against a server that stamps SSE event ids on the + // listen POST stream but returns 405 on the GET resume, + // `_startOrAuthSse` resolved without a stream and nothing fired — + // the subscription dead-ended silently. The 405 is now a terminal + // per-request stream-end. ALSO asserts the GET resume carried the + // per-request `requestSignal` (the close-after-reconnect path). + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 5, + maxRetries: 3, + maxReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1 + } + }); + let streamController!: ReadableStreamDefaultController; + const primedStream = new ReadableStream({ + start(controller) { + streamController = controller; + controller.enqueue(new TextEncoder().encode('id: ev-1\ndata: \n\n')); + } + }); + const fetchMock = globalThis.fetch as Mock; + let getSignal: AbortSignal | null | undefined; + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'text/event-stream' }), + body: primedStream + }); + fetchMock.mockImplementationOnce((_url, init: RequestInit) => { + getSignal = init.signal; + return Promise.resolve({ ok: false, status: 405, headers: new Headers() }); + }); + + const requestAbort = new AbortController(); + const onStreamEnd = vi.fn(); + await transport.start(); + await transport.send( + { jsonrpc: '2.0', method: 'subscriptions/listen', id: 'listen:0', params: {} }, + { requestSignal: requestAbort.signal, onRequestStreamEnd: onStreamEnd } + ); + await vi.advanceTimersByTimeAsync(5); + + // ACT — server closes the primed POST stream → schedules a GET resume → 405. + streamController.close(); + await vi.advanceTimersByTimeAsync(50); + + // ASSERT — onRequestStreamEnd fired exactly once on the 405; the + // resume was a single GET (no further retries — 405 resolves). + expect(onStreamEnd).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1]![1]?.method).toBe('GET'); + // requestSignal threaded through the GET reconnect: aborting the + // per-request signal aborts the resume's fetch signal. + expect(getSignal).toBeDefined(); + expect(getSignal?.aborted).toBe(false); + requestAbort.abort(); + expect(getSignal?.aborted).toBe(true); + }); + it('per-request requestSignal abort BEFORE response headers: no misleading onerror; send() still rejects', async () => { // ARRANGE — fetch is in flight (pending promise) when the // requestSignal aborts; fetch rejects with AbortError before the diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 9291a3cfc3..eaf14d5c67 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -735,6 +735,11 @@ export abstract class Protocol { await this._transport.start(); } + /** + * Transport-close hook. Subclass overrides MUST call `super._onclose()` + * after their own cleanup — base teardown (response-handler settlement, + * timeout clearing, in-flight request abort) does not run otherwise. + */ protected _onclose(): void { const responseHandlers = this._responseHandlers; this._responseHandlers = new Map(); @@ -770,6 +775,12 @@ export abstract class Protocol { this.onerror?.(error); } + /** + * Inbound-notification dispatch. Subclass overrides MUST delegate + * unmatched traffic to `super._onnotification(rawNotification, extra)` — + * an override that consumes only what it owns and falls through to base + * dispatch for everything else. + */ protected _onnotification(rawNotification: JSONRPCNotification, extra?: MessageExtraInfo): void { // Hide wire-only material from notification handlers too — but ONLY // the reserved envelope `_meta` keys (the retry params names are @@ -1086,6 +1097,12 @@ export abstract class Protocol { handler(params); } + /** + * Inbound-response dispatch. Subclass overrides MUST delegate unmatched + * traffic to `super._onresponse(response)` — an override that consumes + * only what it owns and falls through to base dispatch for everything + * else. + */ protected _onresponse(response: JSONRPCResponse | JSONRPCErrorResponse): void { const messageId = Number(response.id);