Skip to content

fix(core): never send notifications/cancelled for the initialize hand… - #2668

Open
KKonstantinov wants to merge 2 commits into
mainfrom
fix/no-cancel-notification-for-initialize
Open

fix(core): never send notifications/cancelled for the initialize hand…#2668
KKonstantinov wants to merge 2 commits into
mainfrom
fix/no-cancel-notification-for-initialize

Conversation

@KKonstantinov

@KKonstantinov KKonstantinov commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

The SDK puts a spec-forbidden notifications/cancelled on the wire for the initialize handshake whenever connect() is aborted or times out. This guards the send; the local abort/reject path is unchanged.

Motivation and Context

A client MUST NOT attempt to cancel its initialize request.

spec, basic/lifecycle, mirrored on CancelledNotification in packages/core-internal/src/types/spec.types.2025-11-25.ts.

The cancel closure in _requestWithSchemaViaCodec fires for any in-flight request, and reaches initialize by both routes: the caller's AbortSignal (Client.connect() passes its RequestOptions straight through to the handshake) and the timeout handler. Either one emits a cancellation naming the initialize request id.

This is a long-standing, independently-recorded deviation:

  • Bug Report: Initialize Request Cancellation Violation #998 — filed 2025-10-02, labelled bug / ready for work / P2 / v2 / fix proposed, with the same root cause identified.
  • test/e2e/requirements.ts — the conformance manifest already carried a knownFailures entry on protocol:cancel:initialize-not-cancellable reading "SDK sends notifications/cancelled for initialize when connect() is aborted; spec says initialize MUST NOT be cancelled."

Blast radius is modest but real. _legacyHandshake closes the connection on any handshake failure, so the peer is torn down regardless — but a server that honours the cancel aborts its in-flight initialize handler and suppresses the response before that. The defect is conformance: the SDK emits a message the spec forbids, and every non-SDK server sees it.

The fix

Guard the notification send on the request method:

if (request.method !== 'initialize') {
    this._transport?.send(/* notifications/cancelled */);
}
  • 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.
  • The guard sits inside the non-stream-close branch, so the modern per-request-stream path is untouched — initialize is legacy-era only, absent from the modern registry, which negotiates via server/discover.
  • Every other method keeps the existing cancellation path.

Removing the knownFailures entry is the required companion change, not incidental cleanup: verifies() runs those cells as test.fails(), so they fail once the SDK is fixed. All 8 transport × era cells now pass as real assertions.

How Has This Been Tested?

Three new unit tests in the existing outbound request cancellation: stream-close vs notifications/cancelled block, covering both triggers plus a regression guard. Verified they actually catch the bug — with the guard removed, the two initialize cases fail and the regression guard still passes.

Suite Result
core-internal 1436 passed
client / server / server-legacy 797 / 468 / 168 passed
test/integration 371 passed
e2e protocol:cancel:initialize-not-cancellable 8/8 passed (previously test.fails)
e2e coverage.test.ts manifest gates 6 passed
pnpm typecheck:all, pnpm lint:all clean

One pre-existing flake, attributed rather than assumed: protocol:timeout:max-total [sse] fails under full-suite load. It passes in isolation, and it also fails on an untouched base with this change reverted — unrelated to this PR.

Breaking Changes

No API surface change; ships as a patch. The observable change is on the wire: a peer no longer receives notifications/cancelled for initialize. Any server depending on that was depending on behaviour the spec forbids, and the handshake still fails and closes exactly as before.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Supersedes #1932. That PR proposed the same guard back in April and never got a human review; it has been CONFLICTING since, because it patches packages/core/src/shared/protocol.ts — a path that no longer exists after the protocol moved to core-internal. This is that fix ported to the current layout, with the e2e manifest update it predates. Credit to @ameenalkhaldi for the original.

Adjacent work on the same closure:

Fixes #998.

…shake

The spec is explicit that a client MUST NOT attempt to cancel its
initialize request, but the outbound cancel closure fired for any
in-flight request: aborting connect()'s AbortSignal, or letting the
handshake time out, put a forbidden cancellation on the wire naming the
initialize request id.

Guard the send on the request method. 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. The guard sits
inside the non-stream-close branch, so the modern per-request-stream path
is unchanged - initialize is legacy-era only, absent from the modern
registry.

The e2e conformance suite already tracked this as a known deviation, so
drop the knownFailures entry on protocol:cancel:initialize-not-cancellable;
its 8 transport x era cells now pass as real assertions.

Ports #1932 onto the core-internal layout. Fixes #998.
@KKonstantinov
KKonstantinov requested a review from a team as a code owner August 16, 2026 09:42
@changeset-bot

changeset-bot Bot commented Aug 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 596f1c6

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/core-internal Patch
@modelcontextprotocol/client Patch
@modelcontextprotocol/server Patch
@modelcontextprotocol/core Patch
@modelcontextprotocol/server-legacy Patch
@modelcontextprotocol/codemod Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Aug 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2668

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2668

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2668

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2668

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2668

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2668

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2668

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2668

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2668

commit: 596f1c6

@KKonstantinov

Copy link
Copy Markdown
Contributor Author

@claude review

Comment on lines +1454 to +1476
// "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}`)));
}

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

Comment on lines +922 to +930
// 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');

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

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code review found no issues

No high-confidence issues detected in this change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug Report: Initialize Request Cancellation Violation

1 participant