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
|
|
@ -261,6 +261,25 @@ export class RemoteClientTransport implements Transport {
|
|||
private readonly options: RemoteTransportOptions;
|
||||
private readonly config: import("../types.js").MCPServerConfig;
|
||||
|
||||
/**
|
||||
* Whether the upstream connection gives each request its own response stream
|
||||
* (#2140). The SDK's `Protocol.request` reads this: on a 2026-era connection
|
||||
* a transport advertising it has its per-request stream **aborted** as the
|
||||
* spec's cancellation signal, and no `notifications/cancelled` is sent; a
|
||||
* transport that does not gets the stdio mechanism instead.
|
||||
*
|
||||
* The real upstream transport lives on the backend, so this transport has to
|
||||
* answer for it. Streamable HTTP opens one POST — and one SSE response
|
||||
* stream — per request, so it qualifies; stdio and SSE multiplex every
|
||||
* request over one shared channel and must keep sending the notification.
|
||||
*
|
||||
* Advertising it is only half the fix: `requestSend` applies the SDK's
|
||||
* `requestSignal` to the browser-to-backend fetch, and `/api/mcp/send`
|
||||
* forwards that disconnect to the upstream `transport.send`, which is what
|
||||
* actually closes the stream the server is watching.
|
||||
*/
|
||||
readonly hasPerRequestStream: boolean;
|
||||
|
||||
/**
|
||||
* Intentionally returns undefined. The MCP Client checks transport.sessionId to detect
|
||||
* reconnects and skip initialize. Our _sessionId is the remote server's session ID, not
|
||||
|
|
@ -312,6 +331,7 @@ export class RemoteClientTransport implements Transport {
|
|||
) {
|
||||
this.options = options;
|
||||
this.config = config;
|
||||
this.hasPerRequestStream = config.type === "streamable-http";
|
||||
}
|
||||
|
||||
setAuthRecovery(handlers: AuthRecoveryHandlers | undefined): void {
|
||||
|
|
@ -757,10 +777,17 @@ export class RemoteClientTransport implements Transport {
|
|||
}),
|
||||
};
|
||||
|
||||
// #2140: aborting this fetch is the cancellation signal for a
|
||||
// per-request-stream upstream. The backend holds `/api/mcp/send` open for
|
||||
// the whole call (it awaits the JSON-RPC response), so the disconnect
|
||||
// reaches it mid-flight and it aborts the upstream request's SSE stream.
|
||||
// For stdio/SSE `hasPerRequestStream` is false, so the SDK never supplies
|
||||
// a `requestSignal` and this is undefined.
|
||||
const res = await this.fetchFn(`${this.baseUrl}/api/mcp/send`, {
|
||||
method: "POST",
|
||||
headers: this.headers,
|
||||
body: JSON.stringify(body),
|
||||
...(options?.requestSignal && { signal: options.requestSignal }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue