-
Notifications
You must be signed in to change notification settings - Fork 2.1k
fix(core): never send notifications/cancelled for the initialize hand… #2668
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. Verification: nit — the duplication is real: the arrange block |
||
|
|
||
| // 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); | ||
| }); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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 thenotifications/cancelledsend inside therequestAbort === undefinedbranch — the branch the code's own comment (lines 1453-1461) says is reachable only on legacy-era connections ("Only the legacy era