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
3 changed files with 654 additions and 20 deletions

View file

@ -33,6 +33,7 @@ import {
createCollectUrlElicitationTool,
createUrlElicitationFormTool,
createSendNotificationTool,
createSlowTaskTool,
createGetAnnotatedMessageTool,
createGetTempTool,
createGetTempExtraTool,
@ -165,6 +166,8 @@ function resolveToolPreset(
return createUrlElicitationFormTool();
case "send_notification":
return createSendNotificationTool();
case "slow_task":
return createSlowTaskTool();
case "get_annotated_message":
return createGetAnnotatedMessageTool();
case "get_temp":

View file

@ -1219,6 +1219,71 @@ export function createCollectUrlElicitationTool(): ToolDefinition {
};
}
/**
* Create a "slow_task" tool that runs until the client cancels it (#2140).
*
* It emits a progress notification every second up to `seconds` (default 60)
* and completes only if it is never cancelled, so there is a long, visible
* window in which to click Cancel.
*
* The point is what it does on the way out. On the 2026-07-28 era a client
* cancels a Streamable HTTP request by closing that request's response stream,
* and the SDK surfaces the disconnect to this handler as `extra.signal`. So the
* tool reports which signal it actually received: cancelled through the stream,
* the progress stops immediately; cancelled by a `notifications/cancelled` the
* server is free to ignore, the progress keeps arriving until the tool
* completes on its own — the exact symptom reported.
*/
export function createSlowTaskTool(): ToolDefinition {
return {
name: "slow_task",
description:
"Run for up to `seconds`, reporting progress each second, until cancelled",
inputSchema: {
seconds: z
.number()
.int()
.min(1)
.max(600)
.optional()
.describe("How long to run before completing on its own (default 60)"),
},
handler: async (
params: Record<string, unknown>,
_context?: TestServerContext,
extra?: HandlerExtra,
) => {
const total = typeof params.seconds === "number" ? params.seconds : 60;
const progressToken = extra?._meta?.progressToken;
for (let step = 1; step <= total; step++) {
const slept = await new Promise<boolean>((resolve) => {
const timer = setTimeout(() => resolve(true), 1000);
extra?.signal?.addEventListener(
"abort",
() => {
clearTimeout(timer);
resolve(false);
},
{ once: true },
);
});
if (!slept || extra?.signal?.aborted) {
// Cancelled. Return rather than keep working — the whole point of
// the demo is that the work actually stops.
return toToolResult(`Cancelled after ${step - 1}s`);
}
if (progressToken !== undefined) {
await extra?.sendNotification?.({
method: "notifications/progress",
params: { progressToken, progress: step, total },
});
}
}
return toToolResult(`Completed all ${total}s without being cancelled`);
},
};
}
/**
* Create a "send_notification" tool that sends a notification message from the server
*/

View file

@ -38,9 +38,21 @@ import {
* SDK v2's {@link WebStandardStreamableHTTPServerTransport} speaks the Fetch API
* (`Request`/`Response`) rather than Node `req`/`res`. The JSON body is passed
* to `handleRequest` via `parsedBody` (Express already parsed it), so the Web
* request carries only method, URL, and headers.
* request carries only method, URL, headers — and a `signal`.
*
* The signal is load-bearing, not decoration (#2140). On the 2026-07-28 era a
* client cancels a request by closing that request's response stream, and the
* SDK server surfaces the disconnect to a tool handler as `ctx.mcpReq.signal`
* — reading it off this Request. Building one without a signal therefore makes
* every fixture server deaf to cancellation, which is indistinguishable from a
* client that never sent the signal: a test asserting that a cancel reaches the
* server passes only if this is wired.
*
* `res`'s `close` is the disconnect, but only when the response had not already
* finished — Express emits it on every completed response too, and aborting
* then would cancel handlers that had already succeeded.
*/
function toWebRequest(req: Request): globalThis.Request {
function toWebRequest(req: Request, res: Response): globalThis.Request {
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (Array.isArray(value)) {
@ -50,7 +62,17 @@ function toWebRequest(req: Request): globalThis.Request {
}
}
const url = `http://localhost${req.originalUrl || req.url}`;
return new globalThis.Request(url, { method: req.method, headers });
const controller = new AbortController();
res.on("close", () => {
if (!res.writableEnded) {
controller.abort();
}
});
return new globalThis.Request(url, {
method: req.method,
headers,
signal: controller.signal,
});
}
/**
@ -436,7 +458,7 @@ export class TestServerHttp {
}
this.currentRequestHeaders = extractHeaders(req);
try {
const webResponse = await handler.fetch(toWebRequest(req), {
const webResponse = await handler.fetch(toWebRequest(req, res), {
parsedBody: req.body,
});
await writeWebResponse(res, webResponse);
@ -540,9 +562,12 @@ export class TestServerHttp {
}
try {
const webResponse = await transport.handleRequest(toWebRequest(req), {
parsedBody: req.body,
});
const webResponse = await transport.handleRequest(
toWebRequest(req, res),
{
parsedBody: req.body,
},
);
await writeWebResponse(res, webResponse);
} catch (error) {
// If response already sent (e.g., by OAuth middleware), don't send another
@ -577,7 +602,7 @@ export class TestServerHttp {
try {
const webResponse = await newTransport.handleRequest(
toWebRequest(req),
toWebRequest(req, res),
{ parsedBody: req.body },
);
await writeWebResponse(res, webResponse);
@ -615,7 +640,9 @@ export class TestServerHttp {
// Let the transport handle the GET request
this.currentRequestHeaders = extractHeaders(req);
try {
const webResponse = await transport.handleRequest(toWebRequest(req));
const webResponse = await transport.handleRequest(
toWebRequest(req, res),
);
await writeWebResponse(res, webResponse);
} catch (error) {
if (!res.headersSent) {