Skip to content

Commit 435af52

Browse files
fix(client): listChanged config is durable (read fresh per connect, never consumed); align close() prose
1 parent 4d9d0f9 commit 435af52

2 files changed

Lines changed: 15 additions & 12 deletions

File tree

.changeset/subscriptions-listen-client.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
'@modelcontextprotocol/client': minor
44
---
55

6-
`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.
6+
`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.

packages/client/src/client/client.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -267,9 +267,10 @@ export interface McpSubscription {
267267
*/
268268
readonly honoredFilter: SubscriptionFilter;
269269
/**
270-
* Tears the subscription down. Idempotent. On Streamable HTTP this closes
271-
* the listen request's SSE stream; on stdio it sends
272-
* `notifications/cancelled` referencing the listen request id.
270+
* Tears the subscription down. Idempotent. Aborts the listen request's
271+
* stream (where the transport supports it) AND sends
272+
* `notifications/cancelled` referencing the listen request id — both,
273+
* always, so close works on any transport.
273274
*/
274275
close(): Promise<void>;
275276
}
@@ -322,7 +323,11 @@ export class Client extends Protocol<ClientContext> {
322323
private _jsonSchemaValidator: jsonSchemaValidator;
323324
private _cachedToolOutputValidators: Map<string, JsonSchemaValidator<unknown>> = new Map();
324325
private _listChangedDebounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
325-
private _pendingListChangedConfig?: ListChangedHandlers;
326+
/**
327+
* The constructor `listChanged` configuration. Durable across reconnects:
328+
* read fresh on every connect (legacy or modern), never consumed.
329+
*/
330+
private readonly _listChangedConfig?: ListChangedHandlers;
326331
private _enforceStrictCapabilities: boolean;
327332
private _versionNegotiation?: VersionNegotiationOptions;
328333
private _supportedProtocolVersionsOption?: string[];
@@ -372,7 +377,7 @@ export class Client extends Protocol<ClientContext> {
372377

373378
// Store list changed config for setup after connection (when we know server capabilities)
374379
if (options?.listChanged) {
375-
this._pendingListChangedConfig = options.listChanged;
380+
this._listChangedConfig = options.listChanged;
376381
}
377382
}
378383

@@ -784,9 +789,8 @@ export class Client extends Protocol<ClientContext> {
784789
this._negotiatedProtocolVersion = result.protocolVersion;
785790

786791
// Set up list changed handlers now that we know server capabilities
787-
if (this._pendingListChangedConfig) {
788-
this._setupListChangedHandlers(this._pendingListChangedConfig);
789-
this._pendingListChangedConfig = undefined;
792+
if (this._listChangedConfig) {
793+
this._setupListChangedHandlers(this._listChangedConfig);
790794
}
791795
} catch (error) {
792796
// Disconnect if initialization fails.
@@ -860,9 +864,8 @@ export class Client extends Protocol<ClientContext> {
860864
// subscriptions/listen stream (the modern era never delivers change
861865
// notifications unsolicited); on a legacy connection they fire on the
862866
// 2025-era unsolicited notifications, no listen needed.
863-
if (this._pendingListChangedConfig) {
864-
const config = this._pendingListChangedConfig;
865-
this._pendingListChangedConfig = undefined;
867+
if (this._listChangedConfig) {
868+
const config = this._listChangedConfig;
866869
// Compute configured ∩ server-advertised ONCE and use that single
867870
// value for BOTH handler registration and the auto-open filter, so
868871
// a configured-but-not-advertised type is neither subscribed to

0 commit comments

Comments
 (0)