diff --git a/.changeset/fuzzy-birds-cancel.md b/.changeset/fuzzy-birds-cancel.md new file mode 100644 index 0000000000..247fcb98b6 --- /dev/null +++ b/.changeset/fuzzy-birds-cancel.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': patch +--- + +Fix `notifications/cancelled` handling for request ID `0`. Previously the cancellation guard treated `0` as missing and left the first request from a protocol instance uncancellable. diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 361bd6fc7c..c9cdc68ae4 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -409,7 +409,7 @@ export abstract class Protocol { protected abstract buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ContextT; private async _oncancel(notification: CancelledNotification): Promise { - if (!notification.params.requestId) { + if (notification.params.requestId === undefined) { return; } // Handle request cancellation diff --git a/packages/core/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts index 619e09376a..4c70d63824 100644 --- a/packages/core/test/shared/protocol.test.ts +++ b/packages/core/test/shared/protocol.test.ts @@ -2319,6 +2319,43 @@ describe('Request Cancellation vs Task Cancellation', () => { expect(wasAborted).toBe(true); }); + test('should abort request handler when cancelling request ID 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 {}; + }); + + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + id: 0, + method: 'ping', + params: {} + }); + } + + await new Promise(resolve => setTimeout(resolve, 10)); + + if (transport.onmessage) { + transport.onmessage({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { + requestId: 0, + reason: 'User cancelled' + } + }); + } + + await new Promise(resolve => setTimeout(resolve, 150)); + + expect(wasAborted).toBe(true); + }); + test('should NOT automatically cancel associated tasks when notifications/cancelled is received', async () => { await protocol.connect(transport);