Skip to content

Commit 3e90449

Browse files
fix(core): never send notifications/cancelled for the initialize hand… (modelcontextprotocol#2668)
Co-authored-by: Felix Weinberger <fweinberger@anthropic.com> Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
1 parent 75dc7ea commit 3e90449

6 files changed

Lines changed: 109 additions & 33 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@modelcontextprotocol/core-internal': patch
3+
'@modelcontextprotocol/client': patch
4+
'@modelcontextprotocol/server': patch
5+
---
6+
7+
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.
8+
9+
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.

docs/migration/support-2026-07-28.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,9 +236,11 @@ coverage, spawn `serveStdio` as a child process.
236236
On a 2026-07-28 Streamable HTTP connection, aborting an in-flight client request
237237
(`signal` / timeout) closes that request's SSE response stream — the spec cancellation
238238
signal — instead of POSTing `notifications/cancelled`. Nothing to change in calling
239-
code. 2025-era connections and stdio at any era still send `notifications/cancelled`.
240-
Custom `Transport` implementations that open one underlying request per outbound message
241-
and honor `TransportSendOptions.requestSignal` may opt in by declaring
239+
code. 2025-era connections and stdio at any era still send `notifications/cancelled`
240+
(except for the `initialize` handshake, which the spec forbids cancelling — an aborted
241+
or timed-out `connect()` rejects locally and sends nothing). Custom `Transport`
242+
implementations that open one underlying request per outbound message and honor
243+
`TransportSendOptions.requestSignal` may opt in by declaring
242244
`readonly hasPerRequestStream = true`.
243245

244246
### `ctx.mcpReq.log()` and the per-request `logLevel`

docs/migration/upgrade-to-v2.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1506,7 +1506,11 @@ rewrite required unless noted.
15061506
on those survive verbatim. The cancelled-on-timeout signal is unchanged on legacy-era
15071507
connections and on stdio/in-memory at any era; on 2026-era Streamable HTTP the cancel
15081508
signal is the per-request stream close instead of a `notifications/cancelled` POST
1509-
(see [support-2026-07-28.md](./support-2026-07-28.md)).
1509+
(see [support-2026-07-28.md](./support-2026-07-28.md)). The one exemption is the
1510+
`initialize` handshake: an aborted or timed-out `connect()` still rejects locally, but
1511+
no `notifications/cancelled` goes on the wire — the spec forbids cancelling
1512+
`initialize`, and v1 sent one anyway. v1 tests asserting that notification need
1513+
re-baselining.
15101514
- **Also unchanged: SSE reconnection exhaustion.** `StreamableHTTPClientTransport`'s
15111515
standalone GET-stream reconnection behavior and its exhaustion signal carry over from
15121516
v1: when retries run out, the transport emits `onerror` with a plain `Error` whose

packages/core-internal/src/shared/protocol.ts

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1453,19 +1453,29 @@ export abstract class Protocol<ContextT extends BaseContext> {
14531453
this._progressHandlers.delete(messageId);
14541454

14551455
if (requestAbort === undefined) {
1456-
this._transport
1457-
?.send(
1458-
this._envelopeOutbound({
1459-
jsonrpc: '2.0',
1460-
method: 'notifications/cancelled',
1461-
params: {
1462-
requestId: messageId,
1463-
reason: String(reason)
1464-
}
1465-
}),
1466-
{ relatedRequestId, resumptionToken, onresumptiontoken }
1467-
)
1468-
.catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`)));
1456+
// "A client MUST NOT attempt to cancel its `initialize`
1457+
// request" (spec basic/lifecycle, mirrored on
1458+
// `CancelledNotification`). The handshake is the one request
1459+
// whose cancellation is forbidden outright, so an abort or
1460+
// timeout on it settles purely locally: the promise still
1461+
// rejects below, but nothing goes on the wire. Only the
1462+
// legacy era can reach this — `initialize` is absent from the
1463+
// modern registry, which negotiates via `server/discover`.
1464+
if (request.method !== 'initialize') {
1465+
this._transport
1466+
?.send(
1467+
this._envelopeOutbound({
1468+
jsonrpc: '2.0',
1469+
method: 'notifications/cancelled',
1470+
params: {
1471+
requestId: messageId,
1472+
reason: String(reason)
1473+
}
1474+
}),
1475+
{ relatedRequestId, resumptionToken, onresumptiontoken }
1476+
)
1477+
.catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`)));
1478+
}
14691479
} else {
14701480
// Modern-era per-request-stream transport: aborting the
14711481
// request's underlying stream IS the spec cancel signal.

packages/core-internal/test/shared/protocol.test.ts

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -866,6 +866,23 @@ describe('protocol tests', () => {
866866
const cancelledSent = (sent: JSONRPCMessage[]): JSONRPCMessage[] =>
867867
sent.filter(m => 'method' in m && m.method === 'notifications/cancelled');
868868

869+
/**
870+
* Connects a fresh protocol over a single-channel transport (stdio /
871+
* in-memory shape: no `hasPerRequestStream`) at `version`, recording
872+
* every outbound message.
873+
*/
874+
const connectSingleChannel = async (version: string) => {
875+
const sent: JSONRPCMessage[] = [];
876+
const tx = new MockTransport();
877+
tx.send = async (m: JSONRPCMessage) => {
878+
sent.push(m);
879+
};
880+
const proto = createTestProtocol();
881+
await proto.connect(tx);
882+
setNegotiatedProtocolVersion(proto, version);
883+
return { proto, sent };
884+
};
885+
869886
test('modern era + per-request-stream transport: abort closes the stream, NO notifications/cancelled', async () => {
870887
const tx = new PerRequestStreamTransport();
871888
const proto = createTestProtocol();
@@ -888,15 +905,7 @@ describe('protocol tests', () => {
888905
});
889906

890907
test('modern era + single-channel transport (no hasPerRequestStream): POSTs notifications/cancelled', async () => {
891-
// stdio / in-memory shape: hasPerRequestStream is undefined.
892-
const sent: JSONRPCMessage[] = [];
893-
const tx = new MockTransport();
894-
tx.send = async (m: JSONRPCMessage, _opts?: TransportSendOptions) => {
895-
sent.push(m);
896-
};
897-
const proto = createTestProtocol();
898-
await proto.connect(tx);
899-
setNegotiatedProtocolVersion(proto, '2026-07-28');
908+
const { proto, sent } = await connectSingleChannel('2026-07-28');
900909

901910
const ac = new AbortController();
902911
const pending = testRequest(proto, { method: 'example', params: {} }, z.object({}), { signal: ac.signal });
@@ -937,6 +946,53 @@ describe('protocol tests', () => {
937946
expect(tx.lastRequestSignal?.aborted).toBe(true);
938947
expect(cancelledSent(tx.sent)).toHaveLength(0);
939948
});
949+
950+
// "A client MUST NOT attempt to cancel its `initialize` request." The
951+
// handshake is exempt from the POST path above on every transport: an
952+
// abort or timeout rejects the caller locally and sends nothing. Both
953+
// triggers are covered because they reach cancel() by different routes
954+
// (the caller's signal vs the timeout handler).
955+
describe('the initialize handshake is never cancelled on the wire', () => {
956+
test('aborting an in-flight initialize sends NO notifications/cancelled', async () => {
957+
// ARRANGE
958+
const { proto, sent } = await connectSingleChannel('2025-11-25');
959+
960+
// ACT
961+
const ac = new AbortController();
962+
const pending = testRequest(proto, { method: 'initialize', params: {} }, z.object({}), { signal: ac.signal });
963+
ac.abort('user cancel');
964+
965+
// ASSERT — rejects locally, wire stays clean
966+
await expect(pending).rejects.toThrow();
967+
expect(cancelledSent(sent)).toHaveLength(0);
968+
});
969+
970+
test('timing out an in-flight initialize sends NO notifications/cancelled', async () => {
971+
// ARRANGE
972+
const { proto, sent } = await connectSingleChannel('2025-11-25');
973+
974+
// ACT
975+
const pending = testRequest(proto, { method: 'initialize', params: {} }, z.object({}), { timeout: 0 });
976+
977+
// ASSERT
978+
await expect(pending).rejects.toThrow();
979+
expect(cancelledSent(sent)).toHaveLength(0);
980+
});
981+
982+
test('every other method still POSTs notifications/cancelled (regression guard)', async () => {
983+
// ARRANGE
984+
const { proto, sent } = await connectSingleChannel('2025-11-25');
985+
986+
// ACT
987+
const ac = new AbortController();
988+
const pending = testRequest(proto, { method: 'example', params: {} }, z.object({}), { signal: ac.signal });
989+
ac.abort('user cancel');
990+
991+
// ASSERT
992+
await expect(pending).rejects.toThrow();
993+
expect(cancelledSent(sent)).toHaveLength(1);
994+
});
995+
});
940996
});
941997
});
942998

test/e2e/requirements.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -140,15 +140,10 @@ export const REQUIREMENTS: Record<string, Requirement> = {
140140
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.'
141141
},
142142
'protocol:cancel:initialize-not-cancellable': {
143-
transports: STATEFUL_TRANSPORTS,
143+
transports: ['inMemory'],
144144
source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation#behavior-requirements',
145145
behavior: 'The client never sends notifications/cancelled for the initialize request.',
146-
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.',
147-
knownFailures: [
148-
{
149-
note: 'SDK sends notifications/cancelled for initialize when connect() is aborted; spec says initialize MUST NOT be cancelled.'
150-
}
151-
]
146+
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."
152147
},
153148
'protocol:cancel:late-response-ignored': {
154149
source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation#timing-considerations',

0 commit comments

Comments
 (0)