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-initialize-cancellation.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 17 additions & 13 deletions packages/core/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -846,19 +846,23 @@ export abstract class Protocol<ContextT extends BaseContext> {
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));
Expand Down
57 changes: 57 additions & 0 deletions packages/core/test/shared/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<JSONRPCNotification>;
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<JSONRPCNotification>;
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<JSONRPCNotification>;
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();
Expand Down
Loading