modelcontextprotocol / modelcontextprotocol/typescript-sdk
[v2] Legacy-era cancel of a resumed request: notifications/cancelled inherits the request's resumptionToken, so the transport swallows the cancellation and opens a rogue resumed GET
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 13.4k
- Forks
- 2.2k
- Avg merge
- 3d 15h
- Merged PRs (30d)
- 4
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/client@2.0.0 / core-internal 2.0.0.
Area
Client / Transports, core-internal
Generated by Claude Code
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/core-internal/src/shared/protocol.ts at the cancel closure around line 1454, then inspect _send and its resume short-circuit in packages/client/src/client/streamableHttp.ts. Verify that cancelling a resumed legacy-era request sends notifications/cancelled as a POST instead of opening a resumed GET, while normal request resumption remains unchanged.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- networking
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 88/100