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
9 changes: 9 additions & 0 deletions .changeset/no-cancel-notification-for-initialize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@modelcontextprotocol/core-internal': patch
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/server': patch
---

Stop sending `notifications/cancelled` for the `initialize` handshake. The spec is explicit that a client MUST NOT attempt to cancel its `initialize` request, but the outbound cancel path fired for any in-flight request: aborting the `AbortSignal` passed to `connect()`, or letting the handshake hit its timeout, put a forbidden cancellation on the wire naming the initialize request id.

The local behaviour is unchanged — the caller's promise still rejects with the same abort/timeout error, and `connect()` still tears the connection down. Only the wire notification is suppressed. Every other method keeps the existing cancellation path.
36 changes: 23 additions & 13 deletions packages/core-internal/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1453,19 +1453,29 @@ export abstract class Protocol<ContextT extends BaseContext> {
this._progressHandlers.delete(messageId);

if (requestAbort === undefined) {
this._transport
?.send(
this._envelopeOutbound({
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}`)));
// "A client MUST NOT attempt to cancel its `initialize`
// request" (spec basic/lifecycle, mirrored on
// `CancelledNotification`). The handshake is the one request
// whose cancellation is forbidden outright, so an abort or
// timeout on it settles purely locally: the promise still
// rejects below, but nothing goes on the wire. Only the
// legacy era can reach this — `initialize` is absent from the
// modern registry, which negotiates via `server/discover`.
if (request.method !== 'initialize') {
this._transport
?.send(
this._envelopeOutbound({
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}`)));
}
Comment on lines +1454 to +1476

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [quality] Migration guide now contradicts the new initialize-cancel exemption: docs/migration/upgrade-to-v2.md (lines 1504-1509, "The cancelled-on-timeout signal is unchanged on legacy-era connections and on stdio/in-memory at any era") still promises v1 parity, but this diff suppresses notifications/cancelled for a timed-out/aborted initialize on exactly those connections, and no guide entry documents the exemption.

Extended reasoning...

Concrete cost per the repo review checklist ("Bugfix or behavior change: check whether docs/**/*.md describes the old behavior and needs updating; flag prose that now contradicts the implementation"): a migrator whose v1 test suite asserts the cancelled-on-timeout wire signal for the initialize handshake reads the upgrade-to-v2.md 'Error-shape changes' bullet, concludes the signal is unchanged on legacy-era connections, and then sees the assertion fail with no migration-guide explanation — the guide's parity claim is now false for the one exempted request. Fix is a one-line doc update noting the initialize exemption next to that bullet (or in the same section).

Verification: nit — the claimed contradiction is factually true. The diff at /home/claude/typescript-sdk/packages/core-internal/src/shared/protocol.ts:1462 adds if (request.method !== 'initialize') around the notifications/cancelled send inside the requestAbort === undefined branch — the branch the code's own comment (lines 1453-1461) says is reachable only on legacy-era connections ("Only the legacy era

} else {
// Modern-era per-request-stream transport: aborting the
// request's underlying stream IS the spec cancel signal.
Expand Down
68 changes: 68 additions & 0 deletions packages/core-internal/test/shared/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,74 @@ describe('protocol tests', () => {
expect(tx.lastRequestSignal?.aborted).toBe(true);
expect(cancelledSent(tx.sent)).toHaveLength(0);
});

// "A client MUST NOT attempt to cancel its `initialize` request." The
// handshake is exempt from the POST path above on every transport: an
// abort or timeout rejects the caller locally and sends nothing. Both
// triggers are covered because they reach cancel() by different routes
// (the caller's signal vs the timeout handler).
describe('the initialize handshake is never cancelled on the wire', () => {
test('aborting an in-flight initialize sends NO notifications/cancelled', async () => {
// ARRANGE
const sent: JSONRPCMessage[] = [];
const tx = new MockTransport();
tx.send = async (m: JSONRPCMessage, _opts?: TransportSendOptions) => {
sent.push(m);
};
const proto = createTestProtocol();
await proto.connect(tx);
setNegotiatedProtocolVersion(proto, '2025-11-25');
Comment on lines +922 to +930

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [quality] Identical 8-line arrange block (recording MockTransport + createTestProtocol + connect + setNegotiatedProtocolVersion('2025-11-25')) is copy-pasted verbatim in all three new tests, and duplicates the sibling test at lines 866-873.

Extended reasoning...

Concrete cost: ~24 lines of duplicated setup within one describe block (lines 923-930, 944-951, 963-970, mirroring 866-873). Any future change to the arrange shape (e.g. a MockTransport API change or a different negotiated-version helper) must be edited in four places, and the tests' distinct intent (abort vs timeout vs regression guard) is buried under repeated boilerplate. A small local helper, e.g. const makeRecordingProto = async () => { ... return { proto, sent }; } next to the existing cancelledSent helper at line 840, does the same job in one place. Nit severity.

Verification: nit — the duplication is real: the arrange block const sent: JSONRPCMessage[] = []; const tx = new MockTransport(); tx.send = async (m, _opts) => { sent.push(m); }; const proto = createTestProtocol(); await proto.connect(tx); setNegotiatedProtocolVersion(proto, '2025-11-25'); is copy-pasted verbatim in all three new tests at packages/core-internal/test/shared/protocol.test.ts:923-930, 944-951, a


// ACT
const ac = new AbortController();
const pending = testRequest(proto, { method: 'initialize', params: {} }, z.object({}), { signal: ac.signal });
ac.abort('user cancel');

// ASSERT — rejects locally, wire stays clean
await expect(pending).rejects.toThrow();
expect(cancelledSent(sent)).toHaveLength(0);
});

test('timing out an in-flight initialize sends NO notifications/cancelled', async () => {
// ARRANGE
const sent: JSONRPCMessage[] = [];
const tx = new MockTransport();
tx.send = async (m: JSONRPCMessage, _opts?: TransportSendOptions) => {
sent.push(m);
};
const proto = createTestProtocol();
await proto.connect(tx);
setNegotiatedProtocolVersion(proto, '2025-11-25');

// ACT
const pending = testRequest(proto, { method: 'initialize', params: {} }, z.object({}), { timeout: 0 });

// ASSERT
await expect(pending).rejects.toThrow();
expect(cancelledSent(sent)).toHaveLength(0);
});

test('every other method still POSTs notifications/cancelled (regression guard)', async () => {
// ARRANGE
const sent: JSONRPCMessage[] = [];
const tx = new MockTransport();
tx.send = async (m: JSONRPCMessage, _opts?: TransportSendOptions) => {
sent.push(m);
};
const proto = createTestProtocol();
await proto.connect(tx);
setNegotiatedProtocolVersion(proto, '2025-11-25');

// ACT
const ac = new AbortController();
const pending = testRequest(proto, { method: 'example', params: {} }, z.object({}), { signal: ac.signal });
ac.abort('user cancel');

// ASSERT
await expect(pending).rejects.toThrow();
expect(cancelledSent(sent)).toHaveLength(1);
});
});
});
});

Expand Down
7 changes: 1 addition & 6 deletions test/e2e/requirements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,7 @@ export const REQUIREMENTS: Record<string, Requirement> = {
transports: STATEFUL_TRANSPORTS,
source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation#behavior-requirements',
behavior: 'The client never sends notifications/cancelled for the initialize request.',
note: 'Stateless hosting creates a fresh server per request and has no standalone GET stream, so there is no server→client channel to deliver/observe these.',
knownFailures: [
{
note: 'SDK sends notifications/cancelled for initialize when connect() is aborted; spec says initialize MUST NOT be cancelled.'
}
]
note: 'Stateless hosting creates a fresh server per request and has no standalone GET stream, so there is no server→client channel to deliver/observe these.'
},
'protocol:cancel:late-response-ignored': {
source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation#timing-considerations',
Expand Down
Loading