Repository navigation
Description
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:this._transport?.send( this._envelopeOutbound({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: messageId, reason: String(reason) } }), { relatedRequestId, resumptionToken, onresumptiontoken } );
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-ID is live) and then times out or is aborted:
- The
notifications/cancelledPOST — the only spec cancellation signal on that era — is silently swallowed; the server keeps computing. - A GET with the old
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 (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
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. - Let the request time out (or abort its signal).
- Observe the wire: no
notifications/cancelledPOST; instead a GET withLast-Event-IDfor the cancelled request.
Two fix options
- (a)
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. - (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/[email protected] / core-internal 2.0.0.
Area
Client / Transports, core-internal
Generated by Claude Code
Activity
LizunovSergey commentedon Aug 11, 2026
I'd like to take this. I reproduced the legacy cancellation path against current main with a focused regression test. The minimal fix keeps relatedRequestId on notifications/cancelled while dropping the original request's resumptionToken and onresumptiontoken options. I'll open a small PR shortly.
Ben-Home commentedon Aug 16, 2026