Skip to content

fix: handle cancellation for request ID 0 - #2654

Open
pshah19 wants to merge 3 commits into
modelcontextprotocol:mainfrom
pshah19:fix/cancellation-request-id-zero
Open

fix: handle cancellation for request ID 0#2654
pshah19 wants to merge 3 commits into
modelcontextprotocol:mainfrom
pshah19:fix/cancellation-request-id-zero

Conversation

@pshah19

@pshah19 pshah19 commented Aug 12, 2026

Copy link
Copy Markdown

Summary

  • Handle requestId: 0 as a valid JSON-RPC request ID in cancellation notifications
  • Add regression coverage confirming that request handlers with ID 0 are aborted correctly

Root cause

The cancellation handler used a truthiness check for requestId, causing the valid numeric ID 0 to be treated as missing.

Testing

  • pnpm --filter @modelcontextprotocol/core-internal exec vitest run test/shared/protocol.test.ts
  • pnpm --filter @modelcontextprotocol/core-internal check
  • pnpm lint:all
  • pnpm test:all — 1 transient SSE timeout; the failed E2E scenario passed when rerun independently

Fixes #2283

Maintainer edit:
Fixes #2115, fixes #2117, fixes #2283

Supersedes #2116, #2135, #2141, #2479, #2496, #2498, #2502, #2504, #2529, #2658

@pshah19
pshah19 requested a review from a team as a code owner August 12, 2026 17:34
@changeset-bot

changeset-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 26bc4a2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/core-internal Patch
@modelcontextprotocol/client Patch
@modelcontextprotocol/server Patch
@modelcontextprotocol/core Patch
@modelcontextprotocol/server-legacy Patch
@modelcontextprotocol/codemod Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2654

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2654

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2654

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2654

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2654

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2654

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2654

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2654

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2654

commit: 26bc4a2

@KKonstantinov KKonstantinov self-assigned this Aug 13, 2026
… test

Parameterize the notifications/cancelled test over 0, 123, '' and 'req-1' so
the ordinary non-zero path keeps its own coverage rather than being traded for
the zero case, and treat a params-less cancel notification as a no-op instead
of a TypeError.

Add the missing changeset.
@KKonstantinov

Copy link
Copy Markdown
Contributor

@claude review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM — the one-token fix is correct and the parameterized regression tests pin all four id shapes (0, 123, '', 'req-1'); the two inline comments are nits (a pre-existing sibling truthiness check and a comment-wording inaccuracy) that don't block the fix itself.

Extended reasoning...

Overview

This PR changes a single guard in Protocol._oncancel (packages/core-internal/src/shared/protocol.ts) from a truthiness check on notification.params.requestId to an explicit === undefined comparison, so cancellation notifications carrying the legal JSON-RPC ids 0 and '' actually abort the in-flight handler. It also parameterizes the existing cancellation test over four id shapes and adds a changeset. Three files, ~60 changed lines, no public API surface change.

Security risks

None. The change makes cancellation more effective (previously a request with id 0 could not be cancelled and would run to completion); there is no new input parsing, no auth-adjacent code, and no new trust boundary. I verified the params?. optional chain cannot be reached with absent params — both era schemas (rev2025-11-25/buildSchemas.ts:165-168, rev2026-07-28/buildSchemas.ts:1239-1242) require params and validation runs before _oncancel — so the defensive chaining is harmless dead code, not a validation bypass.

Level of scrutiny

This is protocol-core code, but the behavioral delta is a one-token predicate change whose semantics are pinned by new test.each coverage including the previously-broken ids. The abort-controller map (_requestHandlerAbortControllers) is keyed by RequestId and already handles 0/'' correctly via Map.get, so the fix composes cleanly with the surrounding code. The two inline findings are both nits: (1) the same truthiness pattern survives in the debounce guard in _notificationViaCodec — a pre-existing line, only triggerable under opt-in debouncedNotificationMethods config, worth completing but not blocking; (2) the new comment's claim that cancelled notifications are not schema-validated before dispatch is inaccurate — I verified the spec-form handler wrapper runs codec.validateNotification first and both era schemas require params — but this is prose accuracy only, the === undefined comparison itself is the correct fix.

Other factors

The changeset accurately describes the fix and its impact (id 0 being the first id every peer assigns). Test coverage is genuine regression coverage: the old truthiness guard would fail the 0 and '' cases. The PR follows the repo's minimalism and completeness conventions apart from the one leftover site already flagged inline. Timeline shows only bot comments and a review request — no unaddressed reviewer feedback. Per the repo's review conventions this is a clean correctness fix with no design questions, so approval with the two inline nits is appropriate.

Comment thread packages/core-internal/src/shared/protocol.ts Outdated
Comment thread packages/core-internal/src/shared/protocol.ts
The debounce gate in _notificationViaCodec tested relatedRequestId for
truthiness, so the legal JSON-RPC ids `0` and `''` read as absent and a
related notification wrongly passed the gate. Because the pending set is
keyed by method alone, a second such notification in the same tick was
silently dropped instead of sent — the exact loss the guard's own comment
says it prevents. This is the sibling site of the cancellation bug fixed
in the previous commit (modelcontextprotocol#2117, alongside modelcontextprotocol#2283).

Also drops the optional chain on notification.params in _oncancel: both
era schemas declare params as required on notifications/cancelled and the
spec-form handler wrapper validates before dispatch, so the chain was
unreachable and its comment described a validation gap that does not
exist. Only requestId can legitimately be absent.

The new test fires both notifications in one tick; awaiting between them
flushes the microtask and hides the coalescing.
@KKonstantinov

Copy link
Copy Markdown
Contributor

@claude review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM — both items from my previous review are addressed: the debounce gate in _notificationViaCodec now uses the same === undefined check (with same-tick parameterized coverage over 0, '', and string ids), and the _oncancel comment was reworded to the accurate rationale. The two inline comments on this run flag pre-existing issues in code this PR doesn't touch; they are follow-up material, not blockers for this fix.

Extended reasoning...

Overview

The PR is a two-token correctness fix in packages/core-internal/src/shared/protocol.ts: _oncancel and the debounce gate in _notificationViaCodec both replaced truthiness checks on a RequestId with === undefined, so the legal JSON-RPC ids 0 and '' are no longer treated as absent. Id 0 is mainstream — the outbound counter is zero-based, so it is the first id every peer assigns. The changeset accurately describes both fixes, and the tests parameterize over every legal id shape (0, 123, '', 'req-1') for both the cancellation-abort path and the same-tick debounce path (the latter deliberately fires two sends in one tick so a wrongly-coalesced notification would be visible).

Prior review resolution

My earlier review on this PR raised exactly two points: the surviving !options?.relatedRequestId truthiness at the debounce gate, and an inline comment claiming a validation gap that doesn't exist. The current revision fixes the gate (line 1620), adds tests pinning it, extends the changeset to cover it, and rewords the _oncancel comment to the accurate rationale (requestId is genuinely optional on the 2025-era schema) with the unreachable params?. optional chain removed. Nothing from the prior round remains outstanding.

Findings this run

The bug hunting system surfaced two issues, both explicitly pre-existing and in code this diff does not touch: the unconditional notifications/cancelled send for a timed-out initialize (spec MUST NOT, client-side send path), and the Number() id coercion in _onresponse/_onprogress conflating string id '' with numeric id 0. Both are legitimate follow-ups; neither is introduced or worsened in a blocking way by this PR (the receive-side fix here is itself spec-required — a server honoring a received cancel is correct; the MUST NOT binds the client's send side). I verified via grep that no other truthiness-on-RequestId guard survives in this file.

Security risks

None. The change makes cancellation and debounce behavior strictly more correct for edge-value ids; it does not widen any input acceptance (both schemas already admitted 0/''), touch auth, or change error surfaces.

Level of scrutiny

protocol.ts is core shared code, so it warrants real scrutiny despite the tiny diff — which it received across two review rounds plus the parameterized regression tests added here. The semantic change is narrow and well-understood: only the values 0 and '' change behavior, in exactly the direction the JSON-RPC spec requires. With the prior feedback fully addressed and the remaining findings being pre-existing follow-ups, this is safe to approve.

Comment on lines 724 to 732
}

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;
}
// Handle request cancellation

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
}

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;
}
// Handle request cancellation

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants