Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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.
8 changes: 5 additions & 3 deletions docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,9 +236,11 @@ coverage, spawn `serveStdio` as a child process.
On a 2026-07-28 Streamable HTTP connection, aborting an in-flight client request
(`signal` / timeout) closes that request's SSE response stream — the spec cancellation
signal — instead of POSTing `notifications/cancelled`. Nothing to change in calling
code. 2025-era connections and stdio at any era still send `notifications/cancelled`.
Custom `Transport` implementations that open one underlying request per outbound message
and honor `TransportSendOptions.requestSignal` may opt in by declaring
code. 2025-era connections and stdio at any era still send `notifications/cancelled`
(except for the `initialize` handshake, which the spec forbids cancelling — an aborted
or timed-out `connect()` rejects locally and sends nothing). Custom `Transport`
implementations that open one underlying request per outbound message and honor
`TransportSendOptions.requestSignal` may opt in by declaring
`readonly hasPerRequestStream = true`.

### `ctx.mcpReq.log()` and the per-request `logLevel`
Expand Down
6 changes: 5 additions & 1 deletion docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -1506,7 +1506,11 @@ rewrite required unless noted.
on those survive verbatim. The cancelled-on-timeout signal is unchanged on legacy-era
connections and on stdio/in-memory at any era; on 2026-era Streamable HTTP the cancel
signal is the per-request stream close instead of a `notifications/cancelled` POST
(see [support-2026-07-28.md](./support-2026-07-28.md)).
(see [support-2026-07-28.md](./support-2026-07-28.md)). The one exemption is the
`initialize` handshake: an aborted or timed-out `connect()` still rejects locally, but
no `notifications/cancelled` goes on the wire — the spec forbids cancelling
`initialize`, and v1 sent one anyway. v1 tests asserting that notification need
re-baselining.
- **Also unchanged: SSE reconnection exhaustion.** `StreamableHTTPClientTransport`'s
standalone GET-stream reconnection behavior and its exhaustion signal carry over from
v1: when retries run out, the transport emits `onerror` with a plain `Error` whose
Expand Down
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 thread
felixweinberger marked this conversation as resolved.
} else {
// Modern-era per-request-stream transport: aborting the
// request's underlying stream IS the spec cancel signal.
Expand Down
74 changes: 65 additions & 9 deletions packages/core-internal/test/shared/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -866,6 +866,23 @@ describe('protocol tests', () => {
const cancelledSent = (sent: JSONRPCMessage[]): JSONRPCMessage[] =>
sent.filter(m => 'method' in m && m.method === 'notifications/cancelled');

/**
* Connects a fresh protocol over a single-channel transport (stdio /
* in-memory shape: no `hasPerRequestStream`) at `version`, recording
* every outbound message.
*/
const connectSingleChannel = async (version: string) => {
const sent: JSONRPCMessage[] = [];
const tx = new MockTransport();
tx.send = async (m: JSONRPCMessage) => {
sent.push(m);
};
const proto = createTestProtocol();
await proto.connect(tx);
setNegotiatedProtocolVersion(proto, version);
return { proto, sent };
};

test('modern era + per-request-stream transport: abort closes the stream, NO notifications/cancelled', async () => {
const tx = new PerRequestStreamTransport();
const proto = createTestProtocol();
Expand All @@ -888,15 +905,7 @@ describe('protocol tests', () => {
});

test('modern era + single-channel transport (no hasPerRequestStream): POSTs notifications/cancelled', async () => {
// stdio / in-memory shape: hasPerRequestStream is undefined.
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, '2026-07-28');
const { proto, sent } = await connectSingleChannel('2026-07-28');

const ac = new AbortController();
const pending = testRequest(proto, { method: 'example', params: {} }, z.object({}), { signal: ac.signal });
Expand Down Expand Up @@ -937,6 +946,53 @@ 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 { proto, sent } = await connectSingleChannel('2025-11-25');

// 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 { proto, sent } = await connectSingleChannel('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 { proto, sent } = await connectSingleChannel('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
9 changes: 2 additions & 7 deletions test/e2e/requirements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,15 +140,10 @@ export const REQUIREMENTS: Record<string, Requirement> = {
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:initialize-not-cancellable': {
transports: STATEFUL_TRANSPORTS,
transports: ['inMemory'],
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: "The behavior itself is transport-agnostic (shared/protocol.ts), but the test must tap the client's outbound messages before connect() resolves, which only the in-memory wiring supports."
Comment on lines 142 to +146

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 [quality] nit: 'protocol:cancel:initialize-not-cancellable' still registers an [inMemory 2026-07-28] cell although the behavior is legacy-era-only — the entry lacks removedInSpecVersion: '2026-07-28' that sibling entry 'protocol:cancel:abort-signal' (line 132) uses for exactly this reason.

Extended reasoning...

verifies() in test/e2e/helpers/verifies.ts:49-54 crosses the entry's transports (['inMemory']) with ALL_SPEC_VERSIONS, so two cells run. The test body (test/e2e/scenarios/protocol.test.ts:226-261) ignores protocolVersion and builds a default client, which always uses the legacy 'initialize' handshake (DEFAULT_VERSION_NEGOTIATION_MODE = 'legacy', packages/client/src/client/versionNegotiation.ts:112). The [inMemory 2026-07-28] cell is therefore a byte-identical rerun of the 2025-11-25 cell: it wastes a CI run and inflates the coverage matrix by reporting 2026-era coverage for a requirement that is vacuous at that era (a 2026-era client never sends 'initialize'; it negotiates via server/discover). Concrete fix: add removedInSpecVersion: '2026-07-28' to the entry, mirroring 'protocol:cancel:abort-signal' at requirements.ts:132, so only the meaningful legacy-era cell runs.

Verification: nit — the factual claims all check out. (1) The PR-edited entry at /home/claude/typescript-sdk/test/e2e/requirements.ts:142-147 sets transports: ['inMemory'] with no removedInSpecVersion, while the sibling 'protocol:cancel:abort-signal' at line 132 carries removedInSpecVersion: '2026-07-28' to bound out modern cells (the manifest uses this bound exactly for that purpose elsewhere, e.g. requi

},
'protocol:cancel:late-response-ignored': {
source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation#timing-considerations',
Expand Down
Loading