From 52478f726940f833d680e4fc72468171d308d93c Mon Sep 17 00:00:00 2001 From: ameenalkhaldi Date: Sun, 19 Apr 2026 22:19:37 +0300 Subject: [PATCH] fix(core): skip cancellation notification for initialize requests Per the MCP spec, clients must not cancel the `initialize` request. When the caller aborts or the request times out during `connect()`, the promise still rejects locally, but `notifications/cancelled` is no longer emitted on the wire. Fixes #998 --- .changeset/fix-initialize-cancellation.md | 5 ++ packages/core/src/shared/protocol.ts | 30 +++++++----- packages/core/test/shared/protocol.test.ts | 57 ++++++++++++++++++++++ 3 files changed, 79 insertions(+), 13 deletions(-) create mode 100644 .changeset/fix-initialize-cancellation.md diff --git a/.changeset/fix-initialize-cancellation.md b/.changeset/fix-initialize-cancellation.md new file mode 100644 index 0000000000..674aa9c9f6 --- /dev/null +++ b/.changeset/fix-initialize-cancellation.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': patch +--- + +Do not send `notifications/cancelled` for `initialize` requests. Per the MCP specification, clients must not cancel the `initialize` request; when the caller aborts or times out during `connect()`, the promise still rejects locally but the wire notification is no longer emitted. diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 57eab69322..bf8f1ca625 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -846,19 +846,23 @@ export abstract class Protocol { const cancel = (reason: unknown) => { this._progressHandlers.delete(messageId); - this._transport - ?.send( - { - jsonrpc: '2.0', - method: 'notifications/cancelled', - params: { - requestId: messageId, - reason: String(reason) - } - }, - { relatedRequestId, resumptionToken, onresumptiontoken } - ) - .catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`))); + // Per the MCP spec, the `initialize` request MUST NOT be cancelled by clients. + // Abort/timeout still rejects the promise locally; we just skip the wire notification. + if (request.method !== 'initialize') { + this._transport + ?.send( + { + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { + requestId: messageId, + reason: String(reason) + } + }, + { relatedRequestId, resumptionToken, onresumptiontoken } + ) + .catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`))); + } // Wrap the reason in an SdkError if it isn't already const error = reason instanceof SdkError ? reason : new SdkError(SdkErrorCode.RequestTimeout, String(reason)); diff --git a/packages/core/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts index 619e09376a..b5c151310f 100644 --- a/packages/core/test/shared/protocol.test.ts +++ b/packages/core/test/shared/protocol.test.ts @@ -305,6 +305,63 @@ describe('protocol tests', () => { expect(removeSpy).toHaveBeenCalledWith('abort', expect.any(Function)); }); + describe('initialize request cancellation', () => { + test('should not send notifications/cancelled when an initialize request is aborted', async () => { + await protocol.connect(transport); + + const controller = new AbortController(); + const mockSchema = z.object({ result: z.string() }); + const reqPromise = testRequest(protocol, { method: 'initialize', params: {} }, mockSchema, { + signal: controller.signal + }); + + controller.abort('User cancelled'); + await expect(reqPromise).rejects.toThrow(); + + const cancelledSends = sendSpy.mock.calls.filter(([message]) => { + const m = message as Partial; + return m?.method === 'notifications/cancelled'; + }); + expect(cancelledSends).toHaveLength(0); + }); + + test('should not send notifications/cancelled when an initialize request times out', async () => { + await protocol.connect(transport); + + const mockSchema = z.object({ result: z.string() }); + await expect( + testRequest(protocol, { method: 'initialize', params: {} }, mockSchema, { + timeout: 0 + }) + ).rejects.toThrow(); + + const cancelledSends = sendSpy.mock.calls.filter(([message]) => { + const m = message as Partial; + return m?.method === 'notifications/cancelled'; + }); + expect(cancelledSends).toHaveLength(0); + }); + + test('should still send notifications/cancelled for non-initialize requests', async () => { + await protocol.connect(transport); + + const controller = new AbortController(); + const mockSchema = z.object({ result: z.string() }); + const reqPromise = testRequest(protocol, { method: 'example', params: {} }, mockSchema, { + signal: controller.signal + }); + + controller.abort('User cancelled'); + await expect(reqPromise).rejects.toThrow(); + + const cancelledSends = sendSpy.mock.calls.filter(([message]) => { + const m = message as Partial; + return m?.method === 'notifications/cancelled'; + }); + expect(cancelledSends).toHaveLength(1); + }); + }); + test('should not overwrite existing hooks when connecting transports', async () => { const oncloseMock = vi.fn(); const onerrorMock = vi.fn();