What happened?
On a legacy-era (2025-11-25) Streamable HTTP connection, cancelling a request whose SSE stream has already resumed at least once (so the request carries a resumption token) never sends the cancellation to the server — instead the client opens a rogue resumed GET for the request it just cancelled.
Two halves, one on each side of the transport boundary:
-
Protocol's cancel path forwards the original request's resumption options onto the cancellation notification. In packages/core-internal/src/shared/protocol.ts, the cancel closure (non-streamCloseCancels branch, ~line 1454) sends:
this._transport?.send(
this._envelopeOutbound({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: messageId, reason: String(reason) } }),
{ relatedRequestId, resumptionToken, onresumptiontoken }
);
resumptionToken here is the ORIGINAL request's token (from the caller's RequestOptions), not anything belonging to the notifications/cancelled message.
-
StreamableHTTPClientTransport._send's resume short-circuit treats any send carrying a resumptionToken as a stream resume. Its first branch is if (resumptionToken) { this._startOrAuthSse({ resumptionToken, ... }); return; } — the message body is never POSTed.
Net effect on the legacy era whenever the original request had resumed (its Last-Event-ID is live) and then times out or is aborted:
- The
notifications/cancelled POST — the only spec cancellation signal on that era — is silently swallowed; the server keeps computing.
- A GET with the old
Last-Event-ID opens for a request the client has already settled; if the server later completes it, the replayed response surfaces for an unknown message id.
The modern era is unaffected (streamCloseCancels aborts the per-request stream instead of POSTing). Timeout-triggered cancels of never-resumed requests are also unaffected (resumptionToken undefined).
Repro path
- Legacy-era Streamable HTTP session; send a long-running request with
onresumptiontoken capturing the token, server primes the SSE stream with an ID-bearing event and drops it so the client resumes (its internal resumptionToken is now set), request continues on the resumed stream.
- Let the request time out (or abort its signal).
- Observe the wire: no
notifications/cancelled POST; instead a GET with Last-Event-ID for the cancelled request.
Two fix options
- (a)
protocol.ts (preferred): don't forward resumptionToken/onresumptiontoken on the cancellation send — they describe the original request's stream, not the notification. relatedRequestId can stay. One-line change, wire-visible only in that the cancellation actually goes out.
- (b) transport-side: scope
_send's resume short-circuit to messages that are requests (never notifications/responses), so a notification always POSTs regardless of stray resumption options. More defensive but leaves the odd protocol-side coupling in place.
Related context: the resume short-circuit path was touched by #2644 (observer threading), which is where this was noticed; the fix itself is deliberately not part of that PR since option (a) lives in core-internal.
SDK version
Read from main @ cc4b416 (packages/core-internal/src/shared/protocol.ts, packages/client/src/client/streamableHttp.ts); the same structure ships in @modelcontextprotocol/client@2.0.0 / core-internal 2.0.0.
Area
Client / Transports, core-internal
Generated by Claude Code
What happened?
On a legacy-era (2025-11-25) Streamable HTTP connection, cancelling a request whose SSE stream has already resumed at least once (so the request carries a resumption token) never sends the cancellation to the server — instead the client opens a rogue resumed GET for the request it just cancelled.
Two halves, one on each side of the transport boundary:
Protocol's cancel path forwards the original request's resumption options onto the cancellation notification. Inpackages/core-internal/src/shared/protocol.ts, thecancelclosure (non-streamCloseCancelsbranch, ~line 1454) sends:resumptionTokenhere is the ORIGINAL request's token (from the caller'sRequestOptions), not anything belonging to thenotifications/cancelledmessage.StreamableHTTPClientTransport._send's resume short-circuit treats any send carrying aresumptionTokenas a stream resume. Its first branch isif (resumptionToken) { this._startOrAuthSse({ resumptionToken, ... }); return; }— the message body is never POSTed.Net effect on the legacy era whenever the original request had resumed (its
Last-Event-IDis live) and then times out or is aborted:notifications/cancelledPOST — the only spec cancellation signal on that era — is silently swallowed; the server keeps computing.Last-Event-IDopens for a request the client has already settled; if the server later completes it, the replayed response surfaces for an unknown message id.The modern era is unaffected (
streamCloseCancelsaborts the per-request stream instead of POSTing). Timeout-triggered cancels of never-resumed requests are also unaffected (resumptionTokenundefined).Repro path
onresumptiontokencapturing the token, server primes the SSE stream with an ID-bearing event and drops it so the client resumes (its internalresumptionTokenis now set), request continues on the resumed stream.notifications/cancelledPOST; instead a GET withLast-Event-IDfor the cancelled request.Two fix options
protocol.ts(preferred): don't forwardresumptionToken/onresumptiontokenon the cancellation send — they describe the original request's stream, not the notification.relatedRequestIdcan stay. One-line change, wire-visible only in that the cancellation actually goes out._send's resume short-circuit to messages that are requests (never notifications/responses), so a notification always POSTs regardless of stray resumption options. More defensive but leaves the odd protocol-side coupling in place.Related context: the resume short-circuit path was touched by #2644 (observer threading), which is where this was noticed; the fix itself is deliberately not part of that PR since option (a) lives in
core-internal.SDK version
Read from
main@ cc4b416 (packages/core-internal/src/shared/protocol.ts,packages/client/src/client/streamableHttp.ts); the same structure ships in@modelcontextprotocol/client@2.0.0/core-internal2.0.0.Area
Client / Transports, core-internal
Generated by Claude Code