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
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue