fix: cancel a Streamable HTTP call by closing its stream (#2140) - #2185
Conversation
The Cancel button POSTed `notifications/cancelled` on every transport. On a 2026-07-28 Streamable HTTP connection that is the wrong signal — the spec makes closing the request's SSE response stream the cancellation signal there and says the notification is neither required nor expected — so a spec-compliant server acknowledged it 202 and dropped it while the tool ran to completion. The SDK already implements the fork, off `transport.hasPerRequestStream`. Every Inspector connection is wrapped in `MessageTrackingTransport`, which did not forward that flag, so the SDK read `undefined` and took the stdio branch on every client — the CLI and TUI included, whose real `StreamableHTTPClientTransport` advertises it correctly. The web client needed three more links: its browser transport answers for the upstream transport that lives on the backend, so it advertises the flag for `streamable-http` and applies the SDK's `requestSignal` to its `POST /api/mcp/send`; and `/api/mcp/send`, held open for the whole call, forwards the browser's disconnect to the upstream `transport.send`. The test fixture's `toWebRequest` built a Web Request with no `signal`, so no fixture server could observe a disconnect at all — without that, a test asserting the cancel reaches the server passes vacuously. stdio and SSE keep `notifications/cancelled`: they multiplex every request over one shared channel and have no per-request stream to close. So does the legacy era, which predates the mechanism. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev Signed-off-by: cliffhall <cliff@futurescale.com>
There was a problem hiding this comment.
Pull request overview
Fixes cancellation for modern Streamable HTTP calls across Web, CLI, and TUI by preserving and forwarding per-request abort signals.
Changes:
- Propagates
hasPerRequestStreamand abort signals through transport wrappers and the web backend. - Adds end-to-end cancellation coverage and a cancellation showcase server.
- Updates cancellation documentation for transport-specific behavior.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
core/mcp/messageTrackingTransport.ts |
Forwards per-request stream capability. |
core/mcp/remote/remoteClientTransport.ts |
Propagates browser cancellation to the backend. |
core/mcp/remote/node/server.ts |
Aborts upstream requests on browser disconnect. |
core/mcp/inspectorClient.ts |
Clarifies transport-specific cancellation behavior. |
test-servers/src/test-server-http.ts |
Exposes HTTP disconnects through request signals. |
test-servers/src/test-server-fixtures.ts |
Adds the cancellable slow_task fixture. |
test-servers/src/preset-registry.ts |
Registers the new fixture preset. |
test-servers/configs/cancellation-modern-http.json |
Adds a manual cancellation showcase. |
clients/web/src/test/integration/mcp/remote/cancel-per-request-stream.test.ts |
Tests the complete web cancellation chain. |
clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts |
Tests direct modern HTTP cancellation. |
clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts |
Tests capability and abort propagation. |
clients/web/src/test/core/mcp/messageTrackingTransport.test.ts |
Tests capability forwarding. |
README.md |
Documents manual verification and protocol behavior. |
specification/v2_ux.md |
Corrects the cancellation specification. |
specification/v2_ux_features.md |
Corrects the feature documentation. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
- `slow_task` sleeps through a helper that removes both listeners on
whichever outcome wins and returns early on an already-aborted signal.
`{ once: true }` detaches on the event firing, not on the waiter losing
interest, so the old loop left one listener per second attached and a
60s run tripped MaxListenersExceededWarning.
- Report the outcome on stderr. A successful cancel closes the stream the
result would travel on, so the tool's return value is undeliverable by
construction — the README now points at the server's terminal rather
than at a result panel that cannot show it.
- Drop a double cast the file's own next hunk shows is unnecessary, and a
floating observer promise whose assertion was false anyway: `postSend`
rethrows before awaiting the SSE wait, so the send rejects on abort
whether or not that wait was cancelled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
|
Round 1 addressed in 56d3c04 — all four taken. Mirrored here because the inline threads go outdated once the fix is pushed. 1. 2. The README told the reader to look for a result that cannot arrive — correct, and stronger than stated. The result is not merely cleared by the UI: cancellation closes the very stream the tool result would travel on, so on a successful cancel it is undeliverable by construction. The fixture now writes its outcome to stderr, and the README points at the server terminal and tells the reader to expect the Inspector to report the call cancelled. Better demo anyway — what has to stop is the work, and only the server can report that. 3. Unjustified 4. Floating
|
The two cancellation tests each built their own `slow_task`, so the documented showcase — the config file, its preset registration, and the fixture's own abort loop — was covered by nothing. A misspelt preset name or a regression in that handler would have surfaced only when someone ran the repro by hand. Resolve `cancellation-modern-http.json` through `loadConfig`/`resolveConfig` and drive the real preset, following the pattern the nullable-fields and MRTR showcase tests already use. It asserts on the handler's own return value rather than on progress notifications. Counting ticks is a false negative here: the SDK drops a cancelled request's progress handler locally, so the client stops seeing ticks the moment it cancels whether or not the server ever stopped — verified, that version passed with the fix reverted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev Signed-off-by: cliffhall <cliff@futurescale.com>
|
Round 2 addressed in cd5dde8. Mirrored here since inline threads go outdated on push. The showcase is not exercised by the tests — right, and it was the most useful comment of the two rounds, because fixing it found a bad test.
The first attempt did the obvious thing — count progress notifications, cancel, assert the count stops growing — and it passed with the fix reverted. The SDK deletes a cancelled request's progress handler locally, so the client stops seeing ticks the moment it cancels regardless of whether the server ever stopped working. That is the same silence this bug hid behind for two releases, reproduced in a test. The version that landed wraps the resolved preset's handler and asserts on its return value, and does fail with the fix reverted. Every one of the four load-bearing changes is now individually pinned: reverting any one of them alone makes at least one of these tests time out.
|
Closes #2140
The bug
Clicking Cancel POSTed a
notifications/cancelledon every transport. On a 2026-07-28 Streamable HTTP connection that is the wrong signal: the spec makes closing the request's SSE response stream the cancellation signal there and says the notification is "neither required nor expected"; stdio, which has no per-request stream to close, keeps it. So a spec-compliant server answered202 Accepted, dropped the notification, and ran the tool to completion — while the Inspector reported the call cancelled. Only a manual disconnect actually cancelled anything.Root cause — one link above where it was reported
The SDK already implements the fork.
Protocol.requestcreates a per-requestAbortControllerwhen the connection is modern andtransport.hasPerRequestStream === true, aborts it instead of sending the notification, and hands it to the transport asTransportSendOptions.requestSignal.Every Inspector connection is wrapped in
MessageTrackingTransport— the wrapper that feeds the Protocol and Network tabs — and it did not forwardhasPerRequestStream. The SDK therefore readundefinedoff the wrapper and took the stdio branch on every client, CLI and TUI included, whose realStreamableHTTPClientTransportadvertises the flag correctly and had it hidden.The investigation on the issue was accurate about the three links below that, all of them real and all of them fixed here, and it saved a lot of time. What it could not see is why its control test looked clean:
inspectorClient.test.ts -t cancelToolCallasserts that the client rejects, which happens identically on both branches. Nothing in the suite asserted that a cancel reached the server, so the direct Node path was broken too and looked fine.The fix
Four changes, each of which is load-bearing — reverting any one on its own makes the new integration test time out (verified individually):
core/mcp/messageTrackingTransport.ts— forward the base transport'shasPerRequestStream. This alone is what fixes the CLI and the TUI.core/mcp/remote/remoteClientTransport.ts— the browser transport answers for the real upstream transport that lives on the backend, so it advertises the flag when, and only when, the configured server isstreamable-http; and it applies the SDK'srequestSignalto itsPOST /api/mcp/sendfetch.core/mcp/remote/node/server.ts—/api/mcp/sendis held open for the whole call, so the browser's abort arrives mid-flight. Forward it to the upstreamtransport.sendasrequestSignal, and release the pending response wait so the handler unwinds instead of sitting on a promise nothing can settle.test-servers/src/test-server-http.ts—toWebRequestbuilt a WebRequestwith nosignal, so no fixture server could ever observe a client disconnect. Without this the new tests pass vacuously: a server deaf to cancellation is indistinguishable from a client that never sent it.stdioandsseare untouched by design — they multiplex every request over one shared channel, there is no per-request stream to close, andnotifications/cancelledremains correct for them. So does the legacy era on Streamable HTTP: the SDK gates the fork on the modern wire revision, which predates the per-stream mechanism.The user-facing contract is unchanged either way — a deliberate cancel still surfaces as
ToolCallCancelledError.Tests
clients/web/src/test/integration/mcp/remote/cancel-per-request-stream.test.ts(new) — drives the whole web chain (InspectorClient → RemoteClientTransport → the Hono backend → a real upstream Streamable HTTP transport → a modern test server) and asserts at the far end, on the server's request abort signal. Each of the three hops can drop the signal alone, so nothing shallower reaches it.inspectorClient-modern-era.test.ts— the same assertion on the direct Node transport, i.e. the CLI/TUI path.messageTrackingTransport.test.ts— the wrapper forwards the flag, and forwardsundefinedasundefinedrather than inventingfalse.remoteClientTransport.test.ts— the flag is advertised per server type,requestSignalreaches the send fetch (and is absent when not supplied), and an abort rejects the in-flight send and releases its response wait rather than hanging.Verifying it by hand
New showcase config, listed in the README alongside the others:
Connect with Protocol Era = Modern, run
slow_task, and hit Cancel after a few seconds. Progress stops immediately and the result readsCancelled after Ns; before this, progress kept arriving until the tool finished all 60. The Protocol tab shows nonotifications/cancelledframe at all now — switch the same server to Legacy and it comes back, which is correct for that era.No screenshots
Deliberately: this changes no web UI component. The difference is on the wire and in the server's behavior, and the evidence for it is the integration test asserting on the server's own abort signal — a screenshot of the Cancel button would show the same pixels before and after.
Docs
specification/v2_ux_features.mdandspecification/v2_ux.mdboth still said the Cancel button sendsnotifications/cancelled, which the issue flagged; both now state the transport-specific behavior.README.mdgains the showcase entry and a section explaining the fork.