Skip to content

fix: cancel a Streamable HTTP call by closing its stream (#2140) - #2185

Merged
cliffhall merged 4 commits into
v2/mainfrom
v2/fix/2140-streamable-http-cancellation
Aug 28, 2026
Merged

fix: cancel a Streamable HTTP call by closing its stream (#2140)#2185
cliffhall merged 4 commits into
v2/mainfrom
v2/fix/2140-streamable-http-cancellation

Conversation

@cliffhall

Copy link
Copy Markdown
Member

Closes #2140

The bug

Clicking Cancel POSTed a 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"; stdio, which has no per-request stream to close, keeps it. So a spec-compliant server answered 202 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.request creates a per-request AbortController when the connection is modern and transport.hasPerRequestStream === true, aborts it instead of sending the notification, and hands it to the transport as TransportSendOptions.requestSignal.

Every Inspector connection is wrapped in MessageTrackingTransport — the wrapper that feeds the Protocol and Network tabs — and it did not forward hasPerRequestStream. The SDK therefore read undefined off the wrapper and took the stdio branch on every client, CLI and TUI included, whose real StreamableHTTPClientTransport advertises 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 cancelToolCall asserts 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):

  1. core/mcp/messageTrackingTransport.ts — forward the base transport's hasPerRequestStream. This alone is what fixes the CLI and the TUI.
  2. 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 is streamable-http; and it applies the SDK's requestSignal to its POST /api/mcp/send fetch.
  3. core/mcp/remote/node/server.ts/api/mcp/send is held open for the whole call, so the browser's abort arrives mid-flight. Forward it to the upstream transport.send as requestSignal, and release the pending response wait so the handler unwinds instead of sitting on a promise nothing can settle.
  4. test-servers/src/test-server-http.tstoWebRequest built a Web Request with no signal, 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.

stdio and sse are untouched by design — they multiplex every request over one shared channel, there is no per-request stream to close, and notifications/cancelled remains 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 forwards undefined as undefined rather than inventing false.
  • remoteClientTransport.test.ts — the flag is advertised per server type, requestSignal reaches 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:

node test-servers/build/server-composable.js --config test-servers/configs/cancellation-modern-http.json

Connect with Protocol Era = Modern, run slow_task, and hit Cancel after a few seconds. Progress stops immediately and the result reads Cancelled after Ns; before this, progress kept arriving until the tool finished all 60. The Protocol tab shows no notifications/cancelled frame 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.md and specification/v2_ux.md both still said the Cancel button sends notifications/cancelled, which the issue flagged; both now state the transport-specific behavior. README.md gains the showcase entry and a section explaining the fork.

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>
@cliffhall cliffhall added the v2 Issues and PRs for v2 label Aug 28, 2026
@cliffhall
cliffhall requested a balanced review from Copilot August 28, 2026 04:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 hasPerRequestStream and 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.

Comment thread test-servers/src/test-server-fixtures.ts Outdated
Comment thread README.md Outdated
Comment thread clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts Outdated
Comment thread clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts Outdated
- `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>
@cliffhall

Copy link
Copy Markdown
Member Author

Round 1 addressed in 56d3c04 — all four taken. Mirrored here because the inline threads go outdated once the fix is pushed.

1. slow_task leaked an abort listener per iteration (test-server-fixtures.ts) — right, and right about the second half too: { once: true } detaches on the event firing, not on the waiter losing interest, so a 60s run accumulated 60 listeners and would trip MaxListenersExceededWarning. Extracted sleepUnlessAborted(ms, signal): it removes the listener when the timer wins, clears the timer when the abort wins, and returns early on an already-aborted signal.

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 as unknown as typeof fetch — fixed, vi.fn<typeof fetch>(). Copied from older lines in the same file rather than reasoned about.

4. Floating void sent.then(settled, settled) — removed rather than awaited, because chasing it showed its assertion was false. expect(settled).toHaveBeenCalledTimes(1) was claiming the SSE response wait had been released; postSend rethrows from its catch before it ever awaits sseWait, so the send rejects on abort either way. The test now asserts only the rejection and says what actually holds.

npm run local:gate passes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.

Comment thread test-servers/src/test-server-fixtures.ts
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>
@cliffhall

Copy link
Copy Markdown
Member Author

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.

inspectorClient-modern-era.test.ts now resolves cancellation-modern-http.json through loadConfig/resolveConfig and drives the real preset, following the pattern nullable-fields.test.ts and the MRTR showcase test already use. So the config entry, the slow_task registry case, and the fixture handler are covered rather than just a hand-built lookalike.

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.

npm run local:gate is green apart from scripts/lib/render-smoke.test.mjs > "a paint landing just under the deadline", which is a pre-existing 400ms-deadline flake on v2/main under machine load — this PR touches nothing under scripts/, the test passes in isolation, and #2180 is already fixing it. Coverage, the build gate, the bundle-externals gate, all smokes (Chromium and Firefox) and Storybook were run separately and are green.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated no new comments.

@cliffhall
cliffhall merged commit a5efb32 into v2/main Aug 28, 2026
2 checks passed
@cliffhall
cliffhall deleted the v2/fix/2140-streamable-http-cancellation branch August 28, 2026 12:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cancel button sends notifications/cancelled for Streamable HTTP instead of closing the SSE stream

2 participants