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

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>
This commit is contained in:
cliffhall 2026-08-28 00:55:29 -04:00
commit 85c5206895

View file

@ -163,6 +163,42 @@ Each config below is a ready-made server for exercising one feature by hand. Loa
| `subscriptions-{legacy,modern}-http.json` | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) |
| `subscriptions-never-acknowledged-http.json` | A `subscriptions/listen` answered with a bare result | [#2097](https://github.com/modelcontextprotocol/inspector/issues/2097) |
| `tasks-{legacy,modern}-http.json` | Tasks, both eras | [#1631](https://github.com/modelcontextprotocol/inspector/issues/1631) |
| `cancellation-modern-http.json` | Cancelling a call by closing its response stream | [#2140](https://github.com/modelcontextprotocol/inspector/issues/2140) |
#### Cancelling a call
`cancellation-modern-http.json` serves `slow_task`, which reports progress once
a second for up to 60 seconds and returns early — saying how far it got — if it
is cancelled. Connect with **Protocol Era = Modern**.
Run `slow_task` from the Tools tab and click **Cancel** after a few seconds. The
progress must stop immediately, and the result must read `Cancelled after Ns`.
On the broken build the progress kept arriving until the tool completed all 60
seconds, because the Inspector was sending the wrong cancellation signal
([#2140](https://github.com/modelcontextprotocol/inspector/issues/2140)).
The 2026-07-28 spec makes this
[transport-specific](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/cancellation#transport-specific-cancellation):
for Streamable HTTP, **closing the request's SSE response stream is the
cancellation signal**, and a `notifications/cancelled` is "neither required nor
expected"; stdio, which has no per-request stream to close, keeps the
notification. A spec-compliant server therefore answers the notification `202
Accepted` and drops it — which is precisely what the reporter observed, with the
task running on to completion while the Inspector reported it cancelled.
The SDK already implements that fork, off `transport.hasPerRequestStream`. Every
Inspector connection is wrapped in `MessageTrackingTransport` (it feeds the
Protocol and Network tabs), which did not forward the flag — so the SDK saw
`undefined` and took the stdio branch on **every** client, CLI and TUI included.
The web client needed two more links in the chain: its browser-side transport
answers for the real upstream one that lives on the Node backend, and the abort
has to survive the `POST /api/mcp/send` hop to reach it.
Watch it on the wire in the **Protocol** tab: cancelling now emits no
`notifications/cancelled` frame at all, and the `tools/call` entry ends as an
aborted request rather than a completed one. Switch the same server to
**Legacy** and the notification comes back — that era has no per-stream
mechanism, so it is still the correct signal there.
#### MCP Apps