You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[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 #2646
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:
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.
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.
Solid write-up, and the two-half split is exactly right. One thing that makes this worse than a plain cancel bug: the rogue resumed GET also re-opens the SSE stream that the cancellation was supposed to close, so the server keeps streaming responses for a request the client already abandoned, and the next resumption starts from a stream the client believes is dead.
The cleanest fix on the Protocol side is to stop forwarding the caller's resumption options onto the cancellation notification entirely. notifications/cancelled identifies the target by requestId alone, and the resumptionToken adds no information for a cancel, so the envelope can be sent with relatedRequestId but without resumptionToken/onresumptiontoken. That immediately prevents the transport from treating the cancel as a resumption and opening the rogue GET, and it keeps the cancel on the existing request context.
On the transport side, a complementary guard is worth adding in StreamableHTTPClientTransport: when a send arrives with relatedRequestId for a request whose stream has already resumed, map the message onto the active stream context instead of falling into the resumption path. That covers any future notification type that legitimately rides the resumed stream.
The regression test that would have caught this: open a legacy-era connection, send a request, let it resume once, cancel it, then assert (a) exactly one GET request was made to the server, and (b) the server received notifications/cancelled. We have a similar cancellation path in the MCP client layer at corpusiq.io (36+ connectors over streamable HTTP) and the requestId-only cancel envelope is what keeps cancellation deterministic across resumptions, so this pattern is proven in production.
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
Solid write-up, and the two-half split is exactly right. One thing that makes this worse than a plain cancel bug: the rogue resumed GET also re-opens the SSE stream that the cancellation was supposed to close, so the server keeps streaming responses for a request the client already abandoned, and the next resumption starts from a stream the client believes is dead.
The cleanest fix on the Protocol side is to stop forwarding the caller's resumption options onto the cancellation notification entirely. notifications/cancelled identifies the target by requestId alone, and the resumptionToken adds no information for a cancel, so the envelope can be sent with relatedRequestId but without resumptionToken/onresumptiontoken. That immediately prevents the transport from treating the cancel as a resumption and opening the rogue GET, and it keeps the cancel on the existing request context.
On the transport side, a complementary guard is worth adding in StreamableHTTPClientTransport: when a send arrives with relatedRequestId for a request whose stream has already resumed, map the message onto the active stream context instead of falling into the resumption path. That covers any future notification type that legitimately rides the resumed stream.
The regression test that would have caught this: open a legacy-era connection, send a request, let it resume once, cancel it, then assert (a) exactly one GET request was made to the server, and (b) the server received notifications/cancelled. We have a similar cancellation path in the MCP client layer at corpusiq.io (36+ connectors over streamable HTTP) and the requestId-only cancel envelope is what keeps cancellation deterministic across resumptions, so this pattern is proven in production.