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

Co-authored-by: Felix Weinberger <fweinberger@anthropic.com>
Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
This commit is contained in:
Konstantin Konstantinov 2026-08-17 14:09:11 +03:00 • committed by GitHub
commit 3e90449fd5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 109 additions and 33 deletions

View file

@ -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}`)));
}
} else {
// Modern-era per-request-stream transport: aborting the
// request's underlying stream IS the spec cancel signal.

View file

@ -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();
@ -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 });
@ -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);
});
});
});
});