Skip to content
Open
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
12 changes: 12 additions & 0 deletions .changeset/cancelled-request-id-zero.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@modelcontextprotocol/core-internal': patch
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/server': patch
---

Treat request id `0` as a real id. Two guards tested a `RequestId` for truthiness, so the legal JSON-RPC ids `0` and `''` were read as absent. Id `0` is not a corner case: the outbound request counter is zero-based, so it is the first id every peer assigns, which on the server→client leg is the first `sampling/createMessage`, `elicitation/create`, or `roots/list` a server sends.

- `notifications/cancelled` carrying id `0` was ignored, and the in-flight handler ran to completion with its `AbortSignal` never fired.
- A notification sent with `relatedRequestId: 0` wrongly passed the debounce gate (for methods opted into `debouncedNotificationMethods`). Because the pending set is keyed by method alone, a second such notification in the same tick was silently dropped rather than sent.

Absent is now the only value that means "no id".
10 changes: 8 additions & 2 deletions packages/core-internal/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -721,13 +721,15 @@
*/
protected _getRequestHandler(method: string): ((request: JSONRPCRequest, ctx: ContextT) => Promise<Result>) | undefined {
return this._requestHandlers.get(method);
}

private async _oncancel(notification: CancelledNotification): Promise<void> {
if (!notification.params.requestId) {
// `requestId` is optional on the 2025-era wire schema. Absent is the
// only thing that means "no id": `0` and `''` are legal request ids.
if (notification.params.requestId === undefined) {
return;
}
Comment thread
claude[bot] marked this conversation as resolved.
// Handle request cancellation

Check notice on line 732 in packages/core-internal/src/shared/protocol.ts

View check run for this annotation

Claude / Claude Code Review

Client cancels its own initialize request (spec MUST NOT), now acted on by SDK servers after the id-0 fix

Pre-existing spec-conformance issue this PR makes newly observable SDK-to-SDK: the outbound cancel closure in `_requestWithSchemaViaCodec` sends `notifications/cancelled` unconditionally for any in-flight request — including `initialize`, which the spec forbids cancelling ("A client MUST NOT attempt to cancel its initialize request", spec.types.2025-11-25.ts:243). Since initialize is id 0 on the plain legacy connect, the old truthiness guard accidentally swallowed the forbidden cancel on SDK ser

Check notice on line 732 in packages/core-internal/src/shared/protocol.ts

View check run for this annotation

Claude / Claude Code Review

Number() id coercion conflates string id with numeric id 0 in _onresponse/_onprogress

Pre-existing (untouched by this PR, but it completes the invariant this PR's changeset states): `_onresponse` and `_onprogress` correlate inbound messages via `Number(response.id)` / `Number(progressToken)`, and `Number('') === 0` — so a response or progress notification carrying the legal string id `''` passes schema validation and silently settles (or resets the timeout / fires `onprogress` of) the pending request with numeric id `0`, which is the first request every peer sends. Fix by matchin
Comment on lines 724 to 732

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟣 Pre-existing spec-conformance issue this PR makes newly observable SDK-to-SDK: the outbound cancel closure in _requestWithSchemaViaCodec sends notifications/cancelled unconditionally for any in-flight request — including initialize, which the spec forbids cancelling ("A client MUST NOT attempt to cancel its initialize request", spec.types.2025-11-25.ts:243). Since initialize is id 0 on the plain legacy connect, the old truthiness guard accidentally swallowed the forbidden cancel on SDK servers; with this (correct, spec-required) fix, an SDK server now aborts the in-flight initialize handler and suppresses its response. Follow-up fix belongs in the untouched send path: skip sending notifications/cancelled when the originating request method is initialize (still rejecting locally with the timeout/abort error).

Extended reasoning...

The bug. The vendored spec is explicit on CancelledNotification (packages/core-internal/src/types/spec.types.2025-11-25.ts:243, also wire/rev2025-11-25/buildSchemas.ts:163): "A client MUST NOT attempt to cancel its initialize request." But the cancel() closure inside _requestWithSchemaViaCodec (packages/core-internal/src/shared/protocol.ts:1449-1470 area) POSTs notifications/cancelled { requestId: messageId, reason } unconditionally on the legacy/single-channel path — there is no request.method === 'initialize' guard anywhere in the request funnel or its abort/timeout paths.

The code path that triggers it — no misbehaving user code required. Client._legacyHandshake (packages/client/src/client/client.ts:1048-1058) issues initialize through this.request(...), the standard funnel, with DEFAULT_REQUEST_TIMEOUT_MSEC (60s). During the handshake no era is negotiated yet, so streamCloseCancels is false, requestAbort is undefined, and the notifications/cancelled POST branch runs. On a fresh client the zero-based _requestMessageId counter makes initialize id 0.

Step-by-step proof. (1) Client connects over the plain legacy path; _legacyHandshake sends initialize with id: 0. (2) The server is slow or hung; after 60s the timeoutHandler fires cancel(new SdkError(RequestTimeout, ...)) (or a caller-threaded abort signal fires cancel() earlier). (3) requestAbort === undefined, so the client POSTs notifications/cancelled { requestId: 0, reason: 'Request timed out' } — a wire-level MUST NOT violation, visible to any conformance-checking server. (4) Before this PR, an SDK server's _oncancel hit !notification.params.requestId with requestId = 0 and returned — the truthiness bug accidentally shielded exactly the one client→server request the spec forbids cancelling. (5) After this PR, _oncancel looks up the AbortController registered for id 0 in _onrequest and aborts it; the completion path sees abortController.signal.aborted and suppresses the InitializeResult (and any error response), leaving the initialize exchange unanswered.

Why this is pre-existing, and why the PR's change is still right. The offending code — the unconditional cancel send — is untouched by this diff and violated the spec on the wire before this PR too (any non-SDK or conformance-checking server always saw it; only SDK-to-SDK traffic was accidentally shielded). The receive-side === undefined fix here is itself correct and spec-required: every other id-0 request (the first sampling/createMessage, elicitation/create, or roots/list a server sends, per the changeset) must be cancellable, and a server honoring a received cancel is not a spec violation — the MUST NOT binds the client. Practical harm from the newly-live consequence is also minimal: the client has already rejected the handshake locally and abandoned the connection, so the suppressed response goes to a peer that stopped listening.

How to fix (follow-up, not this PR). Guard the send side: in the cancel() closure (or where the request enters the funnel), skip emitting notifications/cancelled when request.method === 'initialize', while still rejecting the local promise with the timeout/abort SdkError so caller behavior is unchanged. Alternatively (or additionally), the receive side could decline to abort an in-flight initialize handler. Either single change resolves both the wire-level MUST NOT violation and the newly-observable server-side abort, and a small vitest case in protocol.test.ts asserting no notifications/cancelled is sent for a timed-out initialize would pin it.

Comment on lines 724 to 732

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟣 Pre-existing (untouched by this PR, but it completes the invariant this PR's changeset states): _onresponse and _onprogress correlate inbound messages via Number(response.id) / Number(progressToken), and Number('') === 0 — so a response or progress notification carrying the legal string id '' passes schema validation and silently settles (or resets the timeout / fires onprogress of) the pending request with numeric id 0, which is the first request every peer sends. Fix by matching ids strictly: coerce only non-empty numeric strings, letting '' fall through to the unknown-message-ID onerror path.

Extended reasoning...

The bug. This PR fixes two truthiness guards and its changeset states the invariant plainly: "0 and '' are legal request ids; absent is the only value that means 'no id'". But the same file still conflates '' with 0 at the response/progress correlation seam, via Number() coercion:

// _onresponse (protocol.ts ~1200)
const messageId = Number(response.id);
const handler = this._responseHandlers.get(messageId);

// _onprogress (protocol.ts ~1166)
const messageId = Number(progressToken);

In JavaScript, Number('') === 0 (also Number(' ') === 0 and Number('0x0') === 0). So the empty-string id — one of the two ids this PR's changeset singles out as legal-but-mistreated — maps onto numeric id 0 at exactly the seam that decides which pending request a response belongs to.

The code path. RequestIdSchema = z.union([z.string(), z.number().int()]) (packages/core/src/schemas.ts:131) accepts '', and both response schemas key id on it, so {jsonrpc:'2.0', id:'', result:{...}} passes the isJSONRPCResultResponse/isJSONRPCErrorResponse guards in Protocol.connect and reaches _onresponse. Nothing between the guard and the Number() coercion does a strict-type match on the id. On the client, the _onresponse override (client.ts ~2224) only consumes string ids found in its listen state and its own comment documents the expectation: the base _responseHandlers map is "keyed by NUMBER", so string-id responses are supposed to fall through to super and surface via onerror as an unknown message ID. '' is the one string id that silently doesn't.

Why id 0 is not a corner case is this PR's own argument: _requestMessageId is zero-based, so 0 is the first id every peer assigns — on the client leg that is initialize itself; on the server→client leg it is the first sampling/createMessage / elicitation/create / roots/list.

Step-by-step proof. (1) Client connects; request() assigns messageId = 0 to initialize and registers _responseHandlers.set(0, ...). (2) A buggy or nonconforming peer sends {jsonrpc:'2.0', id:'', result:{...}} while request 0 is in flight. (3) The message passes isJSONRPCResultResponse (RequestIdSchema admits ''). (4) _onresponse computes Number('') === 0, finds the handler for request 0, deletes it, and settles the initialize promise with the foreign payload — or, if the payload fails the result schema, poisons request 0 with an InvalidResult rejection. Per JSON-RPC, id '' was never issued and should have surfaced via onerror as Received a response for an unknown message ID. The same walk applies to _onprogress with progressToken: '': it resets request 0's timeout and/or fires its onprogress callback.

Why this is pre-existing and non-blocking. The PR does not touch _onresponse or _onprogress, and the trigger requires a misbehaving or malicious peer (a conforming peer echoes the numeric id verbatim). It is flagged here because it is the remaining sibling site of the exact pattern this PR eradicates — the repo's Completeness convention ("partial migrations leave sibling code paths with the very bug the PR claims to fix") — and because it leaves the changeset's "absent is the only value that means 'no id'" claim incomplete at the correlation seam. JSON-RPC response correlation should be exact-match at a trust boundary.

How to fix. Coerce only non-empty numeric strings, so '' (and whitespace/hex strings) fall through as unknown, in both sites:

const messageId =
    typeof response.id === 'number'
        ? response.id
        : response.id.trim() !== '' && Number.isInteger(Number(response.id))
          ? Number(response.id)
          : NaN;

NaN never matches a map key, so an id-'' response then correctly reaches the Received a response for an unknown message ID onerror path, and a progressToken: '' progress notification reaches the unknown-token path.

const controller = this._requestHandlerAbortControllers.get(notification.params.requestId);
controller?.abort(notification.params.reason);
}
Expand Down Expand Up @@ -1611,7 +1613,11 @@
const debouncedMethods = this._options?.debouncedNotificationMethods ?? [];
// A notification can only be debounced if it's in the list AND it's "simple"
// (i.e., has no parameters and no related request ID that could be lost).
const canDebounce = debouncedMethods.includes(notification.method) && !notification.params && !options?.relatedRequestId;
// Absent is the only thing that means "no id" here too: `0` and `''` are
// legal request ids, and the pending set is keyed by method alone, so
// treating them as absent lets a related notification be coalesced away.
const canDebounce =
debouncedMethods.includes(notification.method) && !notification.params && options?.relatedRequestId === undefined;

if (canDebounce) {
// If a notification of this type is already scheduled, do nothing.
Expand Down
104 changes: 65 additions & 39 deletions packages/core-internal/test/shared/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,27 @@ describe('protocol tests', () => {
expect(sendSpy).toHaveBeenCalledWith(expect.any(Object), { relatedRequestId: 'req-2' });
});

// Same-tick coverage for every legal request id: the pending set is keyed
// by method alone, so a related notification that wrongly passes the
// debounce gate is coalesced away entirely. Awaiting between the two
// sends would flush the microtask and hide that, so they are fired in one
// tick. `0` and `''` are the ids a truthiness guard swallows.
test.each([0, 123, '', 'req-1'])('should NOT coalesce same-tick notifications related to requestId %j', async relatedRequestId => {
// ARRANGE
protocol = new TestProtocolImpl({ debouncedNotificationMethods: ['test/debounced_with_options'] });
await protocol.connect(transport);

// ACT — two related notifications in the same tick, no await between
void protocol.notification({ method: 'test/debounced_with_options' }, { relatedRequestId });
void protocol.notification({ method: 'test/debounced_with_options' }, { relatedRequestId });
await flushMicrotasks();

// ASSERT — both go out, each still carrying its related request id
expect(sendSpy).toHaveBeenCalledTimes(2);
expect(sendSpy).toHaveBeenNthCalledWith(1, expect.any(Object), { relatedRequestId });
expect(sendSpy).toHaveBeenNthCalledWith(2, expect.any(Object), { relatedRequestId });
});

it('should clear pending debounced notifications on connection close', async () => {
// ARRANGE
protocol = new TestProtocolImpl({ debouncedNotificationMethods: ['test/debounced'] });
Expand Down Expand Up @@ -775,50 +796,55 @@ describe('protocol tests', () => {
});

describe('notifications/cancelled behavior', () => {
test('should abort request handler when notifications/cancelled is received', async () => {
await protocol.connect(transport);

// Set up a request handler that checks if it was aborted
let wasAborted = false;
protocol.setRequestHandler('ping', async (_request, ctx) => {
// Simulate a long-running operation
await new Promise(resolve => setTimeout(resolve, 100));
wasAborted = ctx.mcpReq.signal.aborted;
return {};
});

// Simulate an incoming request
const requestId = 123;
if (transport.onmessage) {
transport.onmessage({
jsonrpc: '2.0',
id: requestId,
method: 'ping',
params: {}
// Every legal JSON-RPC request id must cancel, including the ones a
// truthiness guard swallows: `0` (the first id every peer assigns,
// since the counter is zero-based) and the empty string.
test.each([0, 123, '', 'req-1'])(
'should abort request handler when notifications/cancelled carries requestId %j',
async requestId => {
await protocol.connect(transport);

// Set up a request handler that checks if it was aborted
let wasAborted = false;
protocol.setRequestHandler('ping', async (_request, ctx) => {
// Simulate a long-running operation
await new Promise(resolve => setTimeout(resolve, 100));
wasAborted = ctx.mcpReq.signal.aborted;
return {};
});
}

// Wait a bit for the handler to start
await new Promise(resolve => setTimeout(resolve, 10));
// Simulate an incoming request
if (transport.onmessage) {
transport.onmessage({
jsonrpc: '2.0',
id: requestId,
method: 'ping',
params: {}
});
}

// Wait a bit for the handler to start
await new Promise(resolve => setTimeout(resolve, 10));

// Send cancellation notification
if (transport.onmessage) {
transport.onmessage({
jsonrpc: '2.0',
method: 'notifications/cancelled',
params: {
requestId: requestId,
reason: 'User cancelled'
}
});
}
// Send cancellation notification
if (transport.onmessage) {
transport.onmessage({
jsonrpc: '2.0',
method: 'notifications/cancelled',
params: {
requestId: requestId,
reason: 'User cancelled'
}
});
}

// Wait for the handler to complete
await new Promise(resolve => setTimeout(resolve, 150));
// Wait for the handler to complete
await new Promise(resolve => setTimeout(resolve, 150));

// Verify the request was aborted
expect(wasAborted).toBe(true);
});
// Verify the request was aborted
expect(wasAborted).toBe(true);
}
);
});

// Spec basic/patterns/cancellation §Transport-Specific (2026-07-28): on a
Expand Down
Loading