Skip to content

Commit 846ff27

Browse files
feat(core,server,client): re-pin spec to f68d864a; wire SubscriptionsListenResult (#2953) (#2371)
1 parent 65848f2 commit 846ff27

26 files changed

Lines changed: 353 additions & 79 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@modelcontextprotocol/core': minor
3+
'@modelcontextprotocol/server': minor
4+
'@modelcontextprotocol/client': minor
5+
---
6+
7+
`subscriptions/listen` graceful close: per spec PR #2953, a server-side graceful close (`createMcpHandler` / `serveStdio` `close()`) now emits the empty `subscriptions/listen` JSON-RPC result (the new `SubscriptionsListenResult``_meta` carries the subscriptionId) before closing the stream, replacing the previous server-originated `notifications/cancelled`. On the client, `McpSubscription.closed` now resolves `'graceful'` for this signal (added alongside `'local'` and `'remote'`); a stream close without a result remains `'remote'` (unexpected disconnect).

docs/migration/support-2026-07-28.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,12 @@ explicitly. `resources/subscribe` is 2025-only — on a 2026-07-28 connection, r
356356
`notifications/resources/updated` via the `resourceSubscriptions` field of the listen
357357
filter instead.
358358

359+
**Graceful close.** When the server closes the listen stream deliberately (entry
360+
`close()`/shutdown), it sends the empty `subscriptions/listen` JSON-RPC result before
361+
closing the stream; `McpSubscription.closed` resolves `'graceful'`. A stream close
362+
without a result resolves `'remote'` and indicates an unexpected disconnect — re-listen
363+
if you still want events.
364+
359365
---
360366

361367
## `Mcp-Param-*` and standard headers (SEP-2243)

packages/client/src/client/client.ts

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -406,10 +406,13 @@ export interface McpSubscription {
406406
*
407407
* - `'local'` — you called {@linkcode close} (or aborted the
408408
* `RequestOptions.signal` you passed to `listen()`).
409-
* - `'remote'` — the server cancelled, the stream ended, or the transport
410-
* dropped. Re-listen if you still want events.
409+
* - `'graceful'` — the server ended the subscription deliberately by
410+
* sending the empty `subscriptions/listen` response (e.g. on shutdown).
411+
* - `'remote'` — the stream ended without a response, or the transport
412+
* dropped — an unexpected disconnect. Re-listen if you still want
413+
* events.
411414
*/
412-
readonly closed: Promise<'local' | 'remote'>;
415+
readonly closed: Promise<'local' | 'graceful' | 'remote'>;
413416
}
414417

415418
/** @internal */
@@ -421,7 +424,7 @@ interface ListenStateEntry {
421424
* failure, ack timeout, caller-signal abort, `_resetConnectionState` —
422425
* routes through it.
423426
*/
424-
settle: (outcome: { ack: SubscriptionFilter } | { cause: 'local' | 'remote'; error?: Error }) => void;
427+
settle: (outcome: { ack: SubscriptionFilter } | { cause: 'local' | 'graceful' | 'remote'; error?: Error }) => void;
425428
}
426429

427430
/**
@@ -1894,12 +1897,12 @@ export class Client extends Protocol<ClientContext> {
18941897
// settle()'s `→ closed` transition; never rejects. When listen()
18951898
// itself rejects (pre-ack) there is no McpSubscription to observe it
18961899
// on — settle() resolves it anyway so nothing dangles.
1897-
let resolveClosed!: (cause: 'local' | 'remote') => void;
1898-
const closed = new Promise<'local' | 'remote'>(resolve => {
1900+
let resolveClosed!: (cause: 'local' | 'graceful' | 'remote') => void;
1901+
const closed = new Promise<'local' | 'graceful' | 'remote'>(resolve => {
18991902
resolveClosed = resolve;
19001903
});
19011904

1902-
const settle = (outcome: { ack: SubscriptionFilter } | { cause: 'local' | 'remote'; error?: Error }): void => {
1905+
const settle = (outcome: { ack: SubscriptionFilter } | { cause: 'local' | 'graceful' | 'remote'; error?: Error }): void => {
19031906
if (state === 'closed') return;
19041907
const wasOpening = state === 'opening';
19051908
if (ackTimer !== undefined) {
@@ -2103,13 +2106,14 @@ export class Client extends Protocol<ClientContext> {
21032106
}
21042107

21052108
/**
2106-
* Transport-level demux for `subscriptions/listen` responses. The spec
2107-
* defines listen as never receiving a JSON-RPC result; a JSON-RPC ERROR
2108-
* for the listen id is the server's pre-ack capacity/params rejection. A
2109-
* string-id response that matches a live `_listenState` entry is consumed
2110-
* here (Protocol's `_responseHandlers` map is keyed by NUMBER and never
2111-
* holds a listen id, so passing a string-id response through would
2112-
* surface as "unknown message ID" via `onerror`).
2109+
* Transport-level demux for `subscriptions/listen` responses. A JSON-RPC
2110+
* ERROR for the listen id is the server's pre-ack capacity/params
2111+
* rejection; a JSON-RPC RESULT for the listen id is the spec's
2112+
* `SubscriptionsListenResult` — the server's GRACEFUL-close signal (sent
2113+
* on shutdown). A string-id response that matches a live `_listenState`
2114+
* entry is consumed here (Protocol's `_responseHandlers` map is keyed by
2115+
* NUMBER and never holds a listen id, so passing a string-id response
2116+
* through would surface as "unknown message ID" via `onerror`).
21132117
*/
21142118
protected override _onresponse(response: JSONRPCResponse): void {
21152119
const id = response.id;
@@ -2121,11 +2125,20 @@ export class Client extends Protocol<ClientContext> {
21212125
error: ProtocolError.fromError(response.error.code, response.error.message, response.error.data)
21222126
});
21232127
} else {
2128+
// The empty `SubscriptionsListenResult` — the server ended
2129+
// the subscription deliberately. Handles both pre-ack and
2130+
// post-ack: while opening, settle rejects the pending listen()
2131+
// promise with a ConnectionClosed (a server that answers
2132+
// before the ack is shutting down before serving); once open,
2133+
// settle transitions to closed and `closed` resolves
2134+
// 'graceful'. Per Q8, the result body itself is not validated
2135+
// — receipt for the listen id IS the signal (foreign servers
2136+
// may omit `_meta`).
21242137
entry.settle({
2125-
cause: 'remote',
2138+
cause: 'graceful',
21262139
error: new SdkError(
2127-
SdkErrorCode.InvalidResult,
2128-
'server answered subscriptions/listen with a result; expected the acknowledged notification'
2140+
SdkErrorCode.ConnectionClosed,
2141+
'subscriptions/listen: server closed the subscription gracefully before acknowledging'
21292142
)
21302143
});
21312144
}

packages/client/test/client/listen.test.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,7 @@ describe('Client.listen()', () => {
667667
await client.close();
668668
});
669669

670-
it('server answers listen with a JSON-RPC RESULT during opening: rejects with a typed InvalidResult (not 60s)', async () => {
670+
it('server answers listen with a JSON-RPC RESULT during opening: rejects ConnectionClosed (graceful pre-ack close, not 60s)', async () => {
671671
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
672672
serverTx.onmessage = m => {
673673
const req = m as { id?: number | string; method?: string };
@@ -684,9 +684,9 @@ describe('Client.listen()', () => {
684684
});
685685
}
686686
if (req.method === 'subscriptions/listen' && req.id !== undefined) {
687-
// Buggy server: answers with a result instead of the
688-
// acknowledged notification. Spec defines listen as never
689-
// receiving a result.
687+
// Server is shutting down: emits the SubscriptionsListenResult
688+
// before ever sending the ack. The client treats receipt of
689+
// any result for the listen id as the graceful-close signal.
690690
void serverTx.send({ jsonrpc: '2.0', id: req.id, result: {} });
691691
}
692692
};
@@ -696,13 +696,35 @@ describe('Client.listen()', () => {
696696
const t0 = Date.now();
697697
const error = await client.listen({ toolsListChanged: true }).catch(e => e as SdkError);
698698
expect(error).toBeInstanceOf(SdkError);
699-
expect((error as SdkError).code).toBe(SdkErrorCode.InvalidResult);
700-
expect((error as SdkError).message).toContain('expected the acknowledged notification');
699+
expect((error as SdkError).code).toBe(SdkErrorCode.ConnectionClosed);
700+
expect((error as SdkError).message).toContain('closed the subscription gracefully before acknowledging');
701701
expect(Date.now() - t0).toBeLessThan(1000);
702702
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
703703
await client.close();
704704
});
705705

706+
it("inbound SubscriptionsListenResult post-ack: closed resolves 'graceful'; subscription torn down", async () => {
707+
let listenId!: number | string;
708+
let send!: (m: JSONRPCMessage) => void;
709+
const { clientTx } = await scriptedModern((id, _f, s) => {
710+
listenId = id;
711+
send = s;
712+
});
713+
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });
714+
await client.connect(clientTx);
715+
const sub = await client.listen({ toolsListChanged: true });
716+
// The spec's graceful-close signal: the server emits the empty
717+
// subscriptions/listen response, then closes the stream.
718+
send({
719+
jsonrpc: '2.0',
720+
id: listenId,
721+
result: { resultType: 'complete', _meta: { [SUBSCRIPTION_ID_META_KEY]: listenId } }
722+
} as JSONRPCMessage);
723+
await expect(sub.closed).resolves.toBe('graceful');
724+
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
725+
await client.close();
726+
});
727+
706728
it('transport closes BEFORE the ack: listen() rejects fast', async () => {
707729
const { clientTx, serverTx } = await scriptedModernNoAck();
708730
const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } });

packages/codemod/src/generated/specSchemaMap.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet<string> = new Set([
147147
'SubscriptionsAcknowledgedNotificationSchema',
148148
'SubscriptionsListenRequestParamsSchema',
149149
'SubscriptionsListenRequestSchema',
150+
'SubscriptionsListenResultMetaSchema',
151+
'SubscriptionsListenResultSchema',
150152
'TaskAugmentedRequestParamsSchema',
151153
'TaskCreationParamsSchema',
152154
'TaskMetadataSchema',

packages/core/src/types/schemas.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as z from 'zod/v4';
22

3-
import { JSONRPC_VERSION, RELATED_TASK_META_KEY } from './constants.js';
3+
import { JSONRPC_VERSION, RELATED_TASK_META_KEY, SUBSCRIPTION_ID_META_KEY } from './constants.js';
44
import type { JSONArray, JSONObject, JSONValue } from './types.js';
55

66
export const JSONValueSchema: z.ZodType<JSONValue, JSONValue> = z.lazy(() =>
@@ -959,6 +959,26 @@ export const SubscriptionsAcknowledgedNotificationSchema = NotificationSchema.ex
959959
params: SubscriptionsAcknowledgedNotificationParamsSchema
960960
});
961961

962+
/**
963+
* `_meta` for a {@linkcode SubscriptionsListenResult}: the listen request's
964+
* JSON-RPC ID under the canonical subscription-id key (mirroring the same key
965+
* on every notification delivered on the stream).
966+
*/
967+
export const SubscriptionsListenResultMetaSchema = z.looseObject({
968+
[SUBSCRIPTION_ID_META_KEY]: RequestIdSchema
969+
});
970+
971+
/**
972+
* The response to a `subscriptions/listen` request, signalling that the
973+
* subscription has ended gracefully (for example, during server shutdown).
974+
* Because the listen stream is long-lived, this result is sent only when the
975+
* server tears the subscription down; an abrupt transport close carries no
976+
* response. The result body is otherwise empty.
977+
*/
978+
export const SubscriptionsListenResultSchema = ResultSchema.extend({
979+
_meta: SubscriptionsListenResultMetaSchema
980+
});
981+
962982
/**
963983
* Parameters for a {@linkcode ResourceUpdatedNotification | notifications/resources/updated} notification.
964984
*/
@@ -2394,5 +2414,6 @@ export const ServerResultSchema = z.union([
23942414
ListResourceTemplatesResultSchema,
23952415
ReadResourceResultSchema,
23962416
CallToolResultSchema,
2397-
ListToolsResultSchema
2417+
ListToolsResultSchema,
2418+
SubscriptionsListenResultSchema
23982419
]);

packages/core/src/types/spec.types.2026-07-28.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*
44
* Source: https://github.com/modelcontextprotocol/modelcontextprotocol
55
* Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/draft/schema.ts
6-
* Last updated from commit: dc105208d6c5737c010ed3b6ff50ca19746317c1
6+
* Last updated from commit: f68d864a813754e188c6df52dcc5772a12f96c63
77
*
88
* DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates.
99
* To update this file, run: pnpm run fetch:spec-types 2026-07-28
@@ -1283,6 +1283,40 @@ export interface SubscriptionsListenRequest extends JSONRPCRequest {
12831283
params: SubscriptionsListenRequestParams;
12841284
}
12851285

1286+
/**
1287+
* Extends {@link MetaObject} with the subscription-stream identifier carried by a
1288+
* {@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply.
1289+
*
1290+
* @see {@link MetaObject} for key naming rules and reserved prefixes.
1291+
* @category `subscriptions/listen`
1292+
*/
1293+
export interface SubscriptionsListenResultMeta extends MetaObject {
1294+
/**
1295+
* Identifies the subscription stream this response closes, so the client can
1296+
* correlate it with the originating subscription — mirroring the same key on
1297+
* the stream's notifications. The value is the JSON-RPC ID of the
1298+
* `subscriptions/listen` request that opened the stream (and equals this
1299+
* response's `id`).
1300+
*/
1301+
'io.modelcontextprotocol/subscriptionId': RequestId;
1302+
}
1303+
1304+
/**
1305+
* The response to a {@link SubscriptionsListenRequest | subscriptions/listen}
1306+
* request, signalling that the subscription has ended gracefully (for example,
1307+
* during server shutdown). Because the listen stream is long-lived, this result
1308+
* is sent only when the server tears the subscription down; an abrupt transport
1309+
* close carries no response. The result body is otherwise empty.
1310+
*
1311+
* @example Subscription closed gracefully
1312+
* {@includeCode ./examples/SubscriptionsListenResult/listen-closed.json}
1313+
*
1314+
* @category `subscriptions/listen`
1315+
*/
1316+
export interface SubscriptionsListenResult extends Result {
1317+
_meta: SubscriptionsListenResultMeta;
1318+
}
1319+
12861320
/**
12871321
* Parameters for a {@link SubscriptionsAcknowledgedNotification | notifications/subscriptions/acknowledged} notification.
12881322
*
@@ -3086,6 +3120,7 @@ export type ServerResult =
30863120
| ListResourceTemplatesResult
30873121
| ListResourcesResult
30883122
| ReadResourceResult
3123+
| SubscriptionsListenResult
30893124
| CallToolResult
30903125
| ListToolsResult
30913126
| InputRequiredResult;

packages/core/src/types/specTypeSchema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,8 @@ const SPEC_SCHEMA_KEYS = [
167167
'SubscriptionsAcknowledgedNotificationParamsSchema',
168168
'SubscriptionsListenRequestSchema',
169169
'SubscriptionsListenRequestParamsSchema',
170+
'SubscriptionsListenResultSchema',
171+
'SubscriptionsListenResultMetaSchema',
170172
'TaskAugmentedRequestParamsSchema',
171173
'TaskCreationParamsSchema',
172174
'TaskMetadataSchema',

packages/core/src/types/types.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ import type {
149149
SubscriptionsAcknowledgedNotificationSchema,
150150
SubscriptionsListenRequestParamsSchema,
151151
SubscriptionsListenRequestSchema,
152+
SubscriptionsListenResultMetaSchema,
153+
SubscriptionsListenResultSchema,
152154
TaskAugmentedRequestParamsSchema,
153155
TaskCreationParamsSchema,
154156
TaskMetadataSchema,
@@ -376,6 +378,8 @@ export type SubscriptionsListenRequestParams = Infer<typeof SubscriptionsListenR
376378
export type SubscriptionsListenRequest = Infer<typeof SubscriptionsListenRequestSchema>;
377379
export type SubscriptionsAcknowledgedNotificationParams = Infer<typeof SubscriptionsAcknowledgedNotificationParamsSchema>;
378380
export type SubscriptionsAcknowledgedNotification = Infer<typeof SubscriptionsAcknowledgedNotificationSchema>;
381+
export type SubscriptionsListenResultMeta = Infer<typeof SubscriptionsListenResultMetaSchema>;
382+
export type SubscriptionsListenResult = StripWireOnly<Infer<typeof SubscriptionsListenResultSchema>>;
379383

380384
/* Prompts */
381385
export type PromptArgument = Infer<typeof PromptArgumentSchema>;
@@ -681,11 +685,11 @@ export type ResultTypeMap = {
681685
'resources/read': ReadResourceResult;
682686
'resources/subscribe': EmptyResult;
683687
'resources/unsubscribe': EmptyResult;
684-
// `subscriptions/listen` never receives a JSON-RPC result on the wire:
685-
// termination is stream close (HTTP) or `notifications/cancelled` (stdio).
686-
// The `EmptyResult` entry exists only to keep the mapped types total —
687-
// see `Client.listen()` and the serving entries' listen routers.
688-
'subscriptions/listen': EmptyResult;
688+
// `subscriptions/listen` receives a JSON-RPC result only on a server-side
689+
// graceful close (the empty `SubscriptionsListenResult`). Listen requests
690+
// never reach `request()` / the typed result map — `Client.listen()` sends
691+
// directly on the transport and demuxes the response in `_onresponse`.
692+
'subscriptions/listen': SubscriptionsListenResult;
689693
'tools/call': CallToolResult;
690694
'tools/list': ListToolsResult;
691695
'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools;

packages/core/src/wire/rev2026-07-28/schemas.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,6 +1014,23 @@ export const SubscriptionFilterSchema = z.object({
10141014
const subscriptionsListenParamsShape = { notifications: SubscriptionFilterSchema };
10151015
export const SubscriptionsListenRequestSchema = wireRequest('subscriptions/listen', subscriptionsListenParamsShape);
10161016

1017+
/** Anchor SubscriptionsListenResultMeta — required subscriptionId stamp on the graceful-close result. */
1018+
export const SubscriptionsListenResultMetaSchema = z.looseObject({
1019+
'io.modelcontextprotocol/subscriptionId': RequestIdSchema
1020+
});
1021+
1022+
/**
1023+
* Anchor SubscriptionsListenResult (2026-only). The empty `subscriptions/listen`
1024+
* response signalling that the subscription has ended gracefully (server
1025+
* shutdown). An abrupt transport close carries no response — the client treats
1026+
* stream-close-without-result as a disconnect.
1027+
*/
1028+
export const SubscriptionsListenResultSchema = z.looseObject({
1029+
/** Required `_meta` (the subscriptionId stamp); the result body is otherwise empty. */
1030+
_meta: SubscriptionsListenResultMetaSchema,
1031+
resultType: ResultTypeSchema.default('complete')
1032+
});
1033+
10171034
/**
10181035
* The 2026-era request-method set — the hand-registry seed (see registry.ts
10191036
* for the seed decisions). The dispatch maps below are mapped types over this
@@ -1114,10 +1131,11 @@ export const dispatchResultSchemas: { readonly [M in Rev2026RequestMethod]: z.Zo
11141131
serverInfo: ImplementationSchema,
11151132
instructions: z.string().optional()
11161133
}),
1117-
// `subscriptions/listen` never receives a JSON-RPC result: termination is
1118-
// stream close (HTTP) or `notifications/cancelled` (stdio). The empty
1119-
// entry keeps the mapped type total; the codec's `decodeResult` would
1120-
// never be called for this method in practice.
1134+
// `subscriptions/listen` receives a JSON-RPC result only on a server-side
1135+
// graceful close (the empty `SubscriptionsListenResult` — `_meta` carries
1136+
// the subscriptionId stamp). The dispatch result schema stays the lifted
1137+
// empty body so the mapped type is total; the listen-response demux is
1138+
// entry-layer (`Client._onresponse`) and never reaches `decodeResult`.
11211139
'subscriptions/listen': liftedResult({})
11221140
};
11231141

0 commit comments

Comments
 (0)