-
Notifications
You must be signed in to change notification settings - Fork 2.1k
fix: handle cancellation for request ID 0 #2654
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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". |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
| } | ||
| // Handle request cancellation | ||
|
Check notice on line 732 in packages/core-internal/src/shared/protocol.ts
|
||
|
Comment on lines
724
to
732
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Extended reasoning...The bug. The vendored spec is explicit on The code path that triggers it — no misbehaving user code required. Step-by-step proof. (1) Client connects over the plain legacy path; 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 How to fix (follow-up, not this PR). Guard the send side: in the
Comment on lines
724
to
732
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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): 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 // _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, The code path. Why id 0 is not a corner case is this PR's own argument: Step-by-step proof. (1) Client connects; Why this is pre-existing and non-blocking. The PR does not touch How to fix. Coerce only non-empty numeric strings, so const messageId =
typeof response.id === 'number'
? response.id
: response.id.trim() !== '' && Number.isInteger(Number(response.id))
? Number(response.id)
: NaN;
|
||
| const controller = this._requestHandlerAbortControllers.get(notification.params.requestId); | ||
| controller?.abort(notification.params.reason); | ||
| } | ||
|
|
@@ -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. | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.