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:
parent
baff393675
commit
85c5206895
1 changed files with 654 additions and 20 deletions
|
|
@ -1022,6 +1022,36 @@ export function createRemoteApp(
|
|||
// Auth errors may reject the wait via onerror while send() also throws.
|
||||
void responseWait.catch(() => {});
|
||||
}
|
||||
|
||||
// #2140: this route is held open for the whole call, so the browser
|
||||
// aborting its `POST /api/mcp/send` mid-flight *is* the client's
|
||||
// cancellation signal on a per-request-stream upstream. Forward it as
|
||||
// `requestSignal` so the upstream StreamableHTTP transport closes that
|
||||
// request's SSE response stream, which is what the 2026-07-28 spec makes
|
||||
// the cancellation signal. A transport with no per-request stream (stdio,
|
||||
// SSE) ignores the option, and a *cancel* never reaches it as an abort
|
||||
// anyway: `RemoteClientTransport` withholds `hasPerRequestStream` there, so
|
||||
// the SDK keeps sending `notifications/cancelled`. Forwarding the
|
||||
// disconnect unconditionally is still right — a browser that went away
|
||||
// mid-call is not waiting for the answer whatever the transport is.
|
||||
const upstreamAbort = new AbortController();
|
||||
const clientSignal = c.req.raw.signal;
|
||||
const onClientDisconnect = () => {
|
||||
upstreamAbort.abort(clientSignal.reason);
|
||||
// The response will never arrive now; release the wait so this handler
|
||||
// unwinds instead of sitting on a promise nothing can settle.
|
||||
if (requestId !== undefined) {
|
||||
session.cancelRequestWait(requestId);
|
||||
}
|
||||
};
|
||||
if (clientSignal.aborted) {
|
||||
onClientDisconnect();
|
||||
} else {
|
||||
clientSignal.addEventListener("abort", onClientDisconnect, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// SEP-2243: apply the client's mirrored headers to the upstream request,
|
||||
// restricted to the `Mcp-Param-` prefix so a client can't inject other
|
||||
|
|
@ -1031,6 +1061,7 @@ export function createRemoteApp(
|
|||
await session.transport.send(message, {
|
||||
relatedRequestId: relatedRequestId as string | number | undefined,
|
||||
...(paramHeaders && { headers: paramHeaders }),
|
||||
requestSignal: upstreamAbort.signal,
|
||||
});
|
||||
if (responseWait) {
|
||||
await responseWait;
|
||||
|
|
@ -1051,6 +1082,7 @@ export function createRemoteApp(
|
|||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return c.json({ ok: false, kind: "transport_error", error: msg });
|
||||
} finally {
|
||||
clientSignal.removeEventListener("abort", onClientDisconnect);
|
||||
session.endSend();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue