Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/subscriptions-listen-result.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@modelcontextprotocol/core': minor
'@modelcontextprotocol/server': minor
'@modelcontextprotocol/client': minor
---

`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).

Check warning on line 7 in .changeset/subscriptions-listen-result.md

View check run for this annotation

Claude / Claude Code Review

Changeset prose misdescribes the prior notifications/cancelled teardown (HTTP never sent one; stale sibling changeset still describes it)

The changeset prose misdescribes the prior teardown behavior in two places: (1) this PR's changeset says the new graceful-close result is "replacing the previous server-originated `notifications/cancelled`" for both `createMcpHandler` and `serveStdio` `close()`, but the HTTP path never sent one — its previous teardown was a bare stream close, so that clause should be scoped to the stdio entry; (2) the still-pending sibling changeset `.changeset/subscriptions-listen-server.md` still describes tea
Comment thread
felixweinberger marked this conversation as resolved.
6 changes: 6 additions & 0 deletions docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,12 @@ explicitly. `resources/subscribe` is 2025-only — on a 2026-07-28 connection, r
`notifications/resources/updated` via the `resourceSubscriptions` field of the listen
filter instead.

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

---

## `Mcp-Param-*` and standard headers (SEP-2243)
Expand Down
47 changes: 30 additions & 17 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,13 +403,16 @@
/**
* 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.
* - `'graceful'` — the server ended the subscription deliberately by
* sending the empty `subscriptions/listen` response (e.g. on shutdown).
* - `'remote'` — the stream ended without a response, or the transport
* dropped — an unexpected disconnect. Re-listen if you still want
* events.
*/
readonly closed: Promise<'local' | 'remote'>;
readonly closed: Promise<'local' | 'graceful' | 'remote'>;

Check warning on line 415 in packages/client/src/client/client.ts

View check run for this annotation

Claude / Claude Code Review

McpSubscription.closed JSDoc for 'remote' drops the still-existing server-cancel (notifications/cancelled) cause

The rewritten JSDoc for `McpSubscription.closed` describes `'remote'` only as "the stream ended without a response, or the transport dropped — an unexpected disconnect", but a server-sent `notifications/cancelled` referencing the listen id still settles the subscription with `{ cause: 'remote' }` in `_onnotification` (line ~2101). A deliberate server-side cancel is therefore no longer covered by any documented bullet and is mischaracterized as an unexpected disconnect — the `'remote'` bullet sho
Comment thread
felixweinberger marked this conversation as resolved.
}

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

/**
Expand Down Expand Up @@ -1894,12 +1897,12 @@
// 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 => {
let resolveClosed!: (cause: 'local' | 'graceful' | 'remote') => void;
const closed = new Promise<'local' | 'graceful' | 'remote'>(resolve => {
resolveClosed = resolve;
});

const settle = (outcome: { ack: SubscriptionFilter } | { cause: 'local' | 'remote'; error?: Error }): void => {
const settle = (outcome: { ack: SubscriptionFilter } | { cause: 'local' | 'graceful' | 'remote'; error?: Error }): void => {
if (state === 'closed') return;
const wasOpening = state === 'opening';
if (ackTimer !== undefined) {
Expand Down Expand Up @@ -2103,13 +2106,14 @@
}

/**
* 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`).
* Transport-level demux for `subscriptions/listen` responses. A JSON-RPC
* ERROR for the listen id is the server's pre-ack capacity/params
* rejection; a JSON-RPC RESULT for the listen id is the spec's
* `SubscriptionsListenResult` — the server's GRACEFUL-close signal (sent
* on shutdown). 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;
Expand All @@ -2121,11 +2125,20 @@
error: ProtocolError.fromError(response.error.code, response.error.message, response.error.data)
});
} else {
// The empty `SubscriptionsListenResult` — the server ended
// the subscription deliberately. Handles both pre-ack and
// post-ack: while opening, settle rejects the pending listen()
// promise with a ConnectionClosed (a server that answers
// before the ack is shutting down before serving); once open,
// settle transitions to closed and `closed` resolves
// 'graceful'. Per Q8, the result body itself is not validated
// — receipt for the listen id IS the signal (foreign servers
// may omit `_meta`).
entry.settle({
cause: 'remote',
cause: 'graceful',
error: new SdkError(
SdkErrorCode.InvalidResult,
'server answered subscriptions/listen with a result; expected the acknowledged notification'
SdkErrorCode.ConnectionClosed,
'subscriptions/listen: server closed the subscription gracefully before acknowledging'
)
});
}
Expand Down
34 changes: 28 additions & 6 deletions packages/client/test/client/listen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,7 +667,7 @@ describe('Client.listen()', () => {
await client.close();
});

it('server answers listen with a JSON-RPC RESULT during opening: rejects with a typed InvalidResult (not 60s)', async () => {
it('server answers listen with a JSON-RPC RESULT during opening: rejects ConnectionClosed (graceful pre-ack close, not 60s)', async () => {
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
serverTx.onmessage = m => {
const req = m as { id?: number | string; method?: string };
Expand All @@ -684,9 +684,9 @@ describe('Client.listen()', () => {
});
}
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.
// Server is shutting down: emits the SubscriptionsListenResult
// before ever sending the ack. The client treats receipt of
// any result for the listen id as the graceful-close signal.
void serverTx.send({ jsonrpc: '2.0', id: req.id, result: {} });
}
};
Expand All @@ -696,13 +696,35 @@ describe('Client.listen()', () => {
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((error as SdkError).code).toBe(SdkErrorCode.ConnectionClosed);
expect((error as SdkError).message).toContain('closed the subscription gracefully before acknowledging');
expect(Date.now() - t0).toBeLessThan(1000);
expect((client as unknown as { _listenState: Map<unknown, unknown> })._listenState.size).toBe(0);
await client.close();
});

it("inbound SubscriptionsListenResult post-ack: closed resolves 'graceful'; subscription torn 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 });
// The spec's graceful-close signal: the server emits the empty
// subscriptions/listen response, then closes the stream.
send({
jsonrpc: '2.0',
id: listenId,
result: { resultType: 'complete', _meta: { [SUBSCRIPTION_ID_META_KEY]: listenId } }
} as JSONRPCMessage);
await expect(sub.closed).resolves.toBe('graceful');
expect((client as unknown as { _listenState: Map<unknown, unknown> })._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' } });
Expand Down
2 changes: 2 additions & 0 deletions packages/codemod/src/generated/specSchemaMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet<string> = new Set([
'SubscriptionsAcknowledgedNotificationSchema',
'SubscriptionsListenRequestParamsSchema',
'SubscriptionsListenRequestSchema',
'SubscriptionsListenResultMetaSchema',
'SubscriptionsListenResultSchema',
'TaskAugmentedRequestParamsSchema',
'TaskCreationParamsSchema',
'TaskMetadataSchema',
Expand Down
25 changes: 23 additions & 2 deletions packages/core/src/types/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as z from 'zod/v4';

import { JSONRPC_VERSION, RELATED_TASK_META_KEY } from './constants.js';
import { JSONRPC_VERSION, RELATED_TASK_META_KEY, SUBSCRIPTION_ID_META_KEY } from './constants.js';
import type { JSONArray, JSONObject, JSONValue } from './types.js';

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

/**
* `_meta` for a {@linkcode SubscriptionsListenResult}: the listen request's
* JSON-RPC ID under the canonical subscription-id key (mirroring the same key
* on every notification delivered on the stream).
*/
export const SubscriptionsListenResultMetaSchema = z.looseObject({
[SUBSCRIPTION_ID_META_KEY]: RequestIdSchema
});

/**
* The response to a `subscriptions/listen` request, signalling that the
* subscription has ended gracefully (for example, during server shutdown).
* Because the listen stream is long-lived, this result is sent only when the
* server tears the subscription down; an abrupt transport close carries no
* response. The result body is otherwise empty.
*/
export const SubscriptionsListenResultSchema = ResultSchema.extend({
_meta: SubscriptionsListenResultMetaSchema
});

/**
* Parameters for a {@linkcode ResourceUpdatedNotification | notifications/resources/updated} notification.
*/
Expand Down Expand Up @@ -2394,5 +2414,6 @@ export const ServerResultSchema = z.union([
ListResourceTemplatesResultSchema,
ReadResourceResultSchema,
CallToolResultSchema,
ListToolsResultSchema
ListToolsResultSchema,
SubscriptionsListenResultSchema
]);
37 changes: 36 additions & 1 deletion packages/core/src/types/spec.types.2026-07-28.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* Source: https://github.com/modelcontextprotocol/modelcontextprotocol
* Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/draft/schema.ts
* Last updated from commit: dc105208d6c5737c010ed3b6ff50ca19746317c1
* Last updated from commit: f68d864a813754e188c6df52dcc5772a12f96c63
*
* DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates.
* To update this file, run: pnpm run fetch:spec-types 2026-07-28
Expand Down Expand Up @@ -1283,6 +1283,40 @@ export interface SubscriptionsListenRequest extends JSONRPCRequest {
params: SubscriptionsListenRequestParams;
}

/**
* Extends {@link MetaObject} with the subscription-stream identifier carried by a
* {@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply.
*
* @see {@link MetaObject} for key naming rules and reserved prefixes.
* @category `subscriptions/listen`
*/
export interface SubscriptionsListenResultMeta extends MetaObject {
/**
* Identifies the subscription stream this response closes, so the client can
* correlate it with the originating subscription — mirroring the same key on
* the stream's notifications. The value is the JSON-RPC ID of the
* `subscriptions/listen` request that opened the stream (and equals this
* response's `id`).
*/
'io.modelcontextprotocol/subscriptionId': RequestId;
}

/**
* The response to a {@link SubscriptionsListenRequest | subscriptions/listen}
* request, signalling that the subscription has ended gracefully (for example,
* during server shutdown). Because the listen stream is long-lived, this result
* is sent only when the server tears the subscription down; an abrupt transport
* close carries no response. The result body is otherwise empty.
*
* @example Subscription closed gracefully
* {@includeCode ./examples/SubscriptionsListenResult/listen-closed.json}
*
* @category `subscriptions/listen`
*/
export interface SubscriptionsListenResult extends Result {
_meta: SubscriptionsListenResultMeta;
}

/**
* Parameters for a {@link SubscriptionsAcknowledgedNotification | notifications/subscriptions/acknowledged} notification.
*
Expand Down Expand Up @@ -3086,6 +3120,7 @@ export type ServerResult =
| ListResourceTemplatesResult
| ListResourcesResult
| ReadResourceResult
| SubscriptionsListenResult
| CallToolResult
| ListToolsResult
| InputRequiredResult;
2 changes: 2 additions & 0 deletions packages/core/src/types/specTypeSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ const SPEC_SCHEMA_KEYS = [
'SubscriptionsAcknowledgedNotificationParamsSchema',
'SubscriptionsListenRequestSchema',
'SubscriptionsListenRequestParamsSchema',
'SubscriptionsListenResultSchema',
'SubscriptionsListenResultMetaSchema',
'TaskAugmentedRequestParamsSchema',
'TaskCreationParamsSchema',
'TaskMetadataSchema',
Expand Down
14 changes: 9 additions & 5 deletions packages/core/src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ import type {
SubscriptionsAcknowledgedNotificationSchema,
SubscriptionsListenRequestParamsSchema,
SubscriptionsListenRequestSchema,
SubscriptionsListenResultMetaSchema,
SubscriptionsListenResultSchema,
TaskAugmentedRequestParamsSchema,
TaskCreationParamsSchema,
TaskMetadataSchema,
Expand Down Expand Up @@ -376,6 +378,8 @@ export type SubscriptionsListenRequestParams = Infer<typeof SubscriptionsListenR
export type SubscriptionsListenRequest = Infer<typeof SubscriptionsListenRequestSchema>;
export type SubscriptionsAcknowledgedNotificationParams = Infer<typeof SubscriptionsAcknowledgedNotificationParamsSchema>;
export type SubscriptionsAcknowledgedNotification = Infer<typeof SubscriptionsAcknowledgedNotificationSchema>;
export type SubscriptionsListenResultMeta = Infer<typeof SubscriptionsListenResultMetaSchema>;
export type SubscriptionsListenResult = StripWireOnly<Infer<typeof SubscriptionsListenResultSchema>>;

/* Prompts */
export type PromptArgument = Infer<typeof PromptArgumentSchema>;
Expand Down Expand Up @@ -681,11 +685,11 @@ export type ResultTypeMap = {
'resources/read': ReadResourceResult;
'resources/subscribe': EmptyResult;
'resources/unsubscribe': EmptyResult;
// `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 `Client.listen()` and the serving entries' listen routers.
'subscriptions/listen': EmptyResult;
// `subscriptions/listen` receives a JSON-RPC result only on a server-side
// graceful close (the empty `SubscriptionsListenResult`). Listen requests
// never reach `request()` / the typed result map — `Client.listen()` sends
// directly on the transport and demuxes the response in `_onresponse`.
'subscriptions/listen': SubscriptionsListenResult;
'tools/call': CallToolResult;
'tools/list': ListToolsResult;
'sampling/createMessage': CreateMessageResult | CreateMessageResultWithTools;
Expand Down
26 changes: 22 additions & 4 deletions packages/core/src/wire/rev2026-07-28/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,23 @@ export const SubscriptionFilterSchema = z.object({
const subscriptionsListenParamsShape = { notifications: SubscriptionFilterSchema };
export const SubscriptionsListenRequestSchema = wireRequest('subscriptions/listen', subscriptionsListenParamsShape);

/** Anchor SubscriptionsListenResultMeta — required subscriptionId stamp on the graceful-close result. */
export const SubscriptionsListenResultMetaSchema = z.looseObject({
'io.modelcontextprotocol/subscriptionId': RequestIdSchema
});

/**
* Anchor SubscriptionsListenResult (2026-only). The empty `subscriptions/listen`
* response signalling that the subscription has ended gracefully (server
* shutdown). An abrupt transport close carries no response — the client treats
* stream-close-without-result as a disconnect.
*/
export const SubscriptionsListenResultSchema = z.looseObject({
/** Required `_meta` (the subscriptionId stamp); the result body is otherwise empty. */
_meta: SubscriptionsListenResultMetaSchema,
resultType: ResultTypeSchema.default('complete')
});

/**
* The 2026-era request-method set — the hand-registry seed (see registry.ts
* for the seed decisions). The dispatch maps below are mapped types over this
Expand Down Expand Up @@ -1114,10 +1131,11 @@ export const dispatchResultSchemas: { readonly [M in Rev2026RequestMethod]: z.Zo
serverInfo: ImplementationSchema,
instructions: z.string().optional()
}),
// `subscriptions/listen` never receives a JSON-RPC result: termination is
// stream close (HTTP) or `notifications/cancelled` (stdio). The empty
// entry keeps the mapped type total; the codec's `decodeResult` would
// never be called for this method in practice.
// `subscriptions/listen` receives a JSON-RPC result only on a server-side
// graceful close (the empty `SubscriptionsListenResult` — `_meta` carries
// the subscriptionId stamp). The dispatch result schema stays the lifted
// empty body so the mapped type is total; the listen-response demux is
// entry-layer (`Client._onresponse`) and never reaches `decodeResult`.
'subscriptions/listen': liftedResult({})
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
{
"jsonrpc": "2.0",
"method": "notifications/prompts/list_changed"
"method": "notifications/prompts/list_changed",
"params": {
"_meta": {
"io.modelcontextprotocol/subscriptionId": "listen-1"
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
{
"jsonrpc": "2.0",
"method": "notifications/resources/list_changed"
"method": "notifications/resources/list_changed",
"params": {
"_meta": {
"io.modelcontextprotocol/subscriptionId": "listen-1"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"jsonrpc": "2.0",
"method": "notifications/resources/updated",
"params": {
"_meta": {
"io.modelcontextprotocol/subscriptionId": "listen-1"
},
"uri": "file:///project/src/main.rs"
}
}
Loading
Loading