diff --git a/.changeset/calm-taxis-cancel.md b/.changeset/calm-taxis-cancel.md new file mode 100644 index 0000000000..f999a81caf --- /dev/null +++ b/.changeset/calm-taxis-cancel.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/core': patch +--- + +Prevent legacy cancellation notifications from inheriting request resumption options. diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 0a19770082..a6c0127a30 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -1461,7 +1461,7 @@ export abstract class Protocol { reason: String(reason) } }), - { relatedRequestId, resumptionToken, onresumptiontoken } + { relatedRequestId } ) .catch(error => this._onerror(new Error(`Failed to send cancellation: ${error}`))); } else { diff --git a/packages/core-internal/test/shared/protocol.test.ts b/packages/core-internal/test/shared/protocol.test.ts index 2ecdc40adc..73e9ff2ea6 100644 --- a/packages/core-internal/test/shared/protocol.test.ts +++ b/packages/core-internal/test/shared/protocol.test.ts @@ -830,9 +830,11 @@ describe('protocol tests', () => { class PerRequestStreamTransport extends MockTransport { readonly hasPerRequestStream = true; sent: JSONRPCMessage[] = []; + sentCalls: { message: JSONRPCMessage; options?: TransportSendOptions }[] = []; lastRequestSignal: AbortSignal | undefined; override async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { this.sent.push(message); + this.sentCalls.push({ message, options }); this.lastRequestSignal = options?.requestSignal; } } @@ -899,6 +901,29 @@ describe('protocol tests', () => { expect(cancelledSent(tx.sent)).toHaveLength(1); }); + test('legacy era: cancellation does not inherit request resumption options', async () => { + const tx = new PerRequestStreamTransport(); + const proto = createTestProtocol(); + await proto.connect(tx); + setNegotiatedProtocolVersion(proto, '2025-11-25'); + + const ac = new AbortController(); + const pending = testRequest(proto, { method: 'example', params: {} }, z.object({}), { + signal: ac.signal, + relatedRequestId: 'parent-request', + resumptionToken: 'resume-token', + onresumptiontoken: vi.fn() + }); + + ac.abort('user cancel'); + await expect(pending).rejects.toThrow(); + + const cancellationCall = tx.sentCalls.find( + ({ message }) => 'method' in message && message.method === 'notifications/cancelled' + ); + expect(cancellationCall?.options).toEqual({ relatedRequestId: 'parent-request' }); + }); + test('modern era + per-request-stream transport: timeout aborts the stream, NO notifications/cancelled', async () => { const tx = new PerRequestStreamTransport(); const proto = createTestProtocol();