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
5 changes: 5 additions & 0 deletions .changeset/fix-falsy-zero-request-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@modelcontextprotocol/core-internal": patch
---

Fix falsy-zero requestId bugs in debounce guard and cancellation handling
11 changes: 8 additions & 3 deletions packages/core-internal/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,11 +724,14 @@ export abstract class Protocol<ContextT extends BaseContext> {
}

private async _oncancel(notification: CancelledNotification): Promise<void> {
if (!notification.params.requestId) {
// RequestId 0 is legal JSON-RPC (and is the first id Protocol assigns).
// Use an explicit absence check — truthiness would drop id 0 (#2283).
const requestId = notification.params.requestId;
if (requestId === undefined || requestId === null) {
return;
}
// Handle request cancellation
const controller = this._requestHandlerAbortControllers.get(notification.params.requestId);
const controller = this._requestHandlerAbortControllers.get(requestId);
controller?.abort(notification.params.reason);
}

Expand Down Expand Up @@ -1611,7 +1614,9 @@ export abstract class Protocol<ContextT extends BaseContext> {
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;
// relatedRequestId 0 is a valid id — check === undefined, not truthiness (#2117).
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
59 changes: 59 additions & 0 deletions packages/core-internal/test/shared/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,25 @@ describe('protocol tests', () => {
expect(sendSpy).toHaveBeenCalledWith(expect.any(Object), { relatedRequestId: 'req-2' });
});

// Regression for #2117: numeric 0 is a legal JSON-RPC RequestId and must
// not be treated as "absent" by the debounce guard's truthiness check.
// Call synchronously (no await between) so coalescing can be observed —
// awaiting would flush the microtask and hide the bug.
it('should NOT debounce a notification that has relatedRequestId 0', async () => {
// ARRANGE
protocol = new TestProtocolImpl({ debouncedNotificationMethods: ['test/debounced_with_options'] });
await protocol.connect(transport);

// ACT — fire both in the same tick
void protocol.notification({ method: 'test/debounced_with_options' }, { relatedRequestId: 0 });
void protocol.notification({ method: 'test/debounced_with_options' }, { relatedRequestId: 0 });
await flushMicrotasks();

// ASSERT — related notifications must never coalesce (sent immediately, twice)
expect(sendSpy).toHaveBeenCalledTimes(2);
expect(sendSpy).toHaveBeenCalledWith(expect.any(Object), { relatedRequestId: 0 });
});

it('should clear pending debounced notifications on connection close', async () => {
// ARRANGE
protocol = new TestProtocolImpl({ debouncedNotificationMethods: ['test/debounced'] });
Expand Down Expand Up @@ -819,6 +838,46 @@ describe('protocol tests', () => {
// Verify the request was aborted
expect(wasAborted).toBe(true);
});

// Regression for #2283 / #2115: requestId 0 is legal JSON-RPC and is the
// first id assigned by Protocol (_requestMessageId starts at 0).
test('should abort request handler when notifications/cancelled uses requestId 0', async () => {
await protocol.connect(transport);

let wasAborted = false;
protocol.setRequestHandler('ping', async (_request, ctx) => {
await new Promise(resolve => setTimeout(resolve, 100));
wasAborted = ctx.mcpReq.signal.aborted;
return {};
});

const requestId = 0;
if (transport.onmessage) {
transport.onmessage({
jsonrpc: '2.0',
id: requestId,
method: 'ping',
params: {}
});
}

await new Promise(resolve => setTimeout(resolve, 10));

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

await new Promise(resolve => setTimeout(resolve, 150));

expect(wasAborted).toBe(true);
});
});

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