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
15 changed files with 654 additions and 20 deletions
36
README.md
36
README.md
|
|
@ -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-{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) |
|
| `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) |
|
| `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
|
#### MCP Apps
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ class FakeTransport implements Transport {
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
// Optional on the SDK Transport interface; stdio omits it, HTTP defines it.
|
// Optional on the SDK Transport interface; stdio omits it, HTTP defines it.
|
||||||
setProtocolVersion?: (version: string) => void;
|
setProtocolVersion?: (version: string) => void;
|
||||||
|
// Optional too: Streamable HTTP advertises it, stdio and SSE say nothing.
|
||||||
|
hasPerRequestStream?: boolean;
|
||||||
async start(): Promise<void> {}
|
async start(): Promise<void> {}
|
||||||
async send(message: JSONRPCMessage): Promise<void> {
|
async send(message: JSONRPCMessage): Promise<void> {
|
||||||
this.sent.push(message);
|
this.sent.push(message);
|
||||||
|
|
@ -272,4 +274,19 @@ describe("MessageTrackingTransport.setProtocolVersion", () => {
|
||||||
expect(() => tracked.setProtocolVersion("2025-06-18")).not.toThrow();
|
expect(() => tracked.setProtocolVersion("2025-06-18")).not.toThrow();
|
||||||
expect(tracked.protocolVersion).toBe("2025-06-18");
|
expect(tracked.protocolVersion).toBe("2025-06-18");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #2140: the SDK reads `hasPerRequestStream` off the transport it was handed
|
||||||
|
// — always this wrapper — to choose between aborting a request's own stream
|
||||||
|
// and POSTing `notifications/cancelled`. A wrapper that answers for the base
|
||||||
|
// transport instead of forwarding its answer breaks Cancel on every client.
|
||||||
|
it("forwards the base transport's per-request-stream capability", () => {
|
||||||
|
const { tracked, base } = makeTracked();
|
||||||
|
expect(tracked.hasPerRequestStream).toBeUndefined();
|
||||||
|
|
||||||
|
base.hasPerRequestStream = true;
|
||||||
|
expect(tracked.hasPerRequestStream).toBe(true);
|
||||||
|
|
||||||
|
base.hasPerRequestStream = false;
|
||||||
|
expect(tracked.hasPerRequestStream).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -569,4 +569,123 @@ describe("RemoteClientTransport", () => {
|
||||||
|
|
||||||
await transport.close();
|
await transport.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #2140: the Cancel button's wire signal is chosen by the SDK from the
|
||||||
|
// transport's `hasPerRequestStream`. The real upstream transport lives on the
|
||||||
|
// backend, so this transport answers for it — and must answer per server
|
||||||
|
// type, since only Streamable HTTP gives a request a stream of its own to
|
||||||
|
// close.
|
||||||
|
describe("per-request-stream cancellation (#2140)", () => {
|
||||||
|
it("advertises hasPerRequestStream only for streamable-http", () => {
|
||||||
|
const forType = (c: MCPServerConfig) =>
|
||||||
|
new RemoteClientTransport(
|
||||||
|
{ baseUrl, fetchFn: vi.fn() as unknown as typeof fetch },
|
||||||
|
c,
|
||||||
|
).hasPerRequestStream;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
forType({ type: "streamable-http", url: "http://server.example/mcp" }),
|
||||||
|
).toBe(true);
|
||||||
|
// stdio and SSE multiplex every request over one shared channel, so
|
||||||
|
// aborting anything would cancel the whole session rather than the call.
|
||||||
|
// The SDK must keep sending `notifications/cancelled` for them.
|
||||||
|
expect(forType(config)).toBe(false);
|
||||||
|
expect(forType({ type: "sse", url: "http://server.example/sse" })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the SDK's requestSignal to the browser-to-backend send fetch", async () => {
|
||||||
|
const seenInits: (RequestInit | undefined)[] = [];
|
||||||
|
let sse = createPushableSseStream();
|
||||||
|
const fetchFn = vi
|
||||||
|
.fn<typeof fetch>()
|
||||||
|
.mockImplementation(async (input, init) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.endsWith("/api/mcp/connect")) {
|
||||||
|
return new Response(JSON.stringify({ sessionId: "abc" }), {
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes("/api/mcp/events")) {
|
||||||
|
sse = createPushableSseStream();
|
||||||
|
return sse.response;
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/mcp/send")) {
|
||||||
|
seenInits.push(init);
|
||||||
|
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const transport = new RemoteClientTransport({ baseUrl, fetchFn }, config);
|
||||||
|
await transport.start();
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
await transport.send(
|
||||||
|
{ jsonrpc: "2.0", method: "notifications/initialized" },
|
||||||
|
{ requestSignal: controller.signal },
|
||||||
|
);
|
||||||
|
expect(seenInits[0]?.signal).toBe(controller.signal);
|
||||||
|
|
||||||
|
// No signal supplied (stdio/SSE, where the SDK never creates one) must
|
||||||
|
// leave the fetch unaborted rather than passing `undefined` through as a
|
||||||
|
// deliberate value.
|
||||||
|
await transport.send({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "notifications/roots/list_changed",
|
||||||
|
});
|
||||||
|
expect(seenInits[1]).not.toHaveProperty("signal");
|
||||||
|
|
||||||
|
await transport.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborting the requestSignal rejects the in-flight send and releases its response wait", async () => {
|
||||||
|
let sse = createPushableSseStream();
|
||||||
|
const fetchFn = vi
|
||||||
|
.fn<typeof fetch>()
|
||||||
|
.mockImplementation(async (input, init) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.endsWith("/api/mcp/connect")) {
|
||||||
|
return new Response(JSON.stringify({ sessionId: "abc" }), {
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes("/api/mcp/events")) {
|
||||||
|
sse = createPushableSseStream();
|
||||||
|
return sse.response;
|
||||||
|
}
|
||||||
|
if (url.endsWith("/api/mcp/send")) {
|
||||||
|
// The backend holds this route open for the whole call, so model it
|
||||||
|
// as a fetch that only settles when the caller aborts.
|
||||||
|
return new Promise<Response>((_resolve, reject) => {
|
||||||
|
init?.signal?.addEventListener("abort", () =>
|
||||||
|
reject(new DOMException("Aborted", "AbortError")),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
const transport = new RemoteClientTransport({ baseUrl, fetchFn }, config);
|
||||||
|
await transport.start();
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const sent = transport.send(
|
||||||
|
{ jsonrpc: "2.0", id: 7, method: "tools/call" },
|
||||||
|
{ requestSignal: controller.signal },
|
||||||
|
);
|
||||||
|
const settled = vi.fn();
|
||||||
|
void sent.then(settled, settled);
|
||||||
|
|
||||||
|
controller.abort();
|
||||||
|
await expect(sent).rejects.toThrow(/Aborted/);
|
||||||
|
// The response wait registered for id 7 must have been cancelled too —
|
||||||
|
// otherwise this send would hang past the abort waiting for a JSON-RPC
|
||||||
|
// response the aborted request can never produce.
|
||||||
|
expect(settled).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await transport.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -437,6 +437,63 @@ describe("modern-era negotiation (2026-07-28)", () => {
|
||||||
expect(result.result!.isError).toBeFalsy();
|
expect(result.result!.isError).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #2140: on the 2026-07-28 era, closing the request's own response stream IS
|
||||||
|
// the cancellation signal for Streamable HTTP; `notifications/cancelled` is
|
||||||
|
// the stdio mechanism and the spec says it is neither required nor expected
|
||||||
|
// here. The SDK implements that fork off `transport.hasPerRequestStream` —
|
||||||
|
// but every Inspector connection is wrapped in `MessageTrackingTransport`,
|
||||||
|
// which was not forwarding it, so all three clients POSTed the notification
|
||||||
|
// and a spec-compliant server acknowledged it `202` and kept running.
|
||||||
|
//
|
||||||
|
// This asserts at the far end, on the server's own request signal: nothing on
|
||||||
|
// the client side distinguishes a cancel that reached the server from one
|
||||||
|
// that was dropped, which is exactly how this shipped.
|
||||||
|
it("cancels by aborting the request's stream, which the server observes", async () => {
|
||||||
|
let sawAbort!: (value: boolean) => void;
|
||||||
|
const aborted = new Promise<boolean>((resolve) => {
|
||||||
|
sawAbort = resolve;
|
||||||
|
});
|
||||||
|
let started!: () => void;
|
||||||
|
const running = new Promise<void>((resolve) => {
|
||||||
|
started = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const slowServer = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo("cancel-era-test", "1.0.0"),
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
name: "slow_task",
|
||||||
|
description: "Runs until the client cancels it",
|
||||||
|
handler: async (_params, _context, extra) => {
|
||||||
|
extra?.signal?.addEventListener("abort", () => sawAbort(true), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
started();
|
||||||
|
// Never settles on its own, so a regression is a timeout rather
|
||||||
|
// than a pass.
|
||||||
|
return new Promise(() => {});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
modern: {},
|
||||||
|
});
|
||||||
|
await slowServer.start();
|
||||||
|
server = slowServer;
|
||||||
|
|
||||||
|
const connected = await connectWithEra(slowServer.url, "modern");
|
||||||
|
const { tools } = await connected.listTools();
|
||||||
|
const tool = tools.find((t) => t.name === "slow_task");
|
||||||
|
|
||||||
|
// Hold the rejection immediately so it is never seen as unhandled while we
|
||||||
|
// wait on the server side.
|
||||||
|
const settled = connected.callTool(tool!, {}).catch((err: unknown) => err);
|
||||||
|
await running;
|
||||||
|
expect(connected.cancelToolCall()).toBe(true);
|
||||||
|
|
||||||
|
await expect(settled).resolves.toBeInstanceOf(ToolCallCancelledError);
|
||||||
|
await expect(aborted).resolves.toBe(true);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
it("cancels an in-flight MRTR call while its embedded request is pending", async () => {
|
it("cancels an in-flight MRTR call while its embedded request is pending", async () => {
|
||||||
const started = await startMrtrServer(createMrtrTool());
|
const started = await startMrtrServer(createMrtrTool());
|
||||||
const connected = await connectWithEra(started.url, "modern");
|
const connected = await connectWithEra(started.url, "modern");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
/**
|
||||||
|
* #2140 — the Cancel button must send the transport's cancellation signal.
|
||||||
|
*
|
||||||
|
* On a 2026-07-28 Streamable HTTP connection the spec makes *closing the
|
||||||
|
* request's SSE response stream* the cancellation signal, and says a
|
||||||
|
* `notifications/cancelled` is neither required nor expected. The SDK
|
||||||
|
* implements that fork itself: `Protocol.request` aborts a per-request
|
||||||
|
* `AbortController` — handed to the transport as
|
||||||
|
* `TransportSendOptions.requestSignal` — when the connection is modern **and**
|
||||||
|
* the transport advertises `hasPerRequestStream`; otherwise it POSTs the
|
||||||
|
* notification.
|
||||||
|
*
|
||||||
|
* The web client lost it at the proxy boundary. The real upstream transport
|
||||||
|
* lives on the Node backend, and the browser's `RemoteClientTransport` neither
|
||||||
|
* advertised the flag nor applied a `requestSignal` to its
|
||||||
|
* `POST /api/mcp/send` — so every web cancel took the stdio branch, the server
|
||||||
|
* answered the notification `202 Accepted` and dropped it, and the tool kept
|
||||||
|
* running to completion. Only a manual disconnect actually cancelled anything.
|
||||||
|
*
|
||||||
|
* This drives the whole chain the browser drives — InspectorClient ->
|
||||||
|
* RemoteClientTransport -> the Hono backend -> a real upstream Streamable HTTP
|
||||||
|
* transport -> a modern test server — and asserts at the far end, on the
|
||||||
|
* server's own request abort signal. Nothing shallower reaches it: each of the
|
||||||
|
* three hops can drop the signal on its own, and a unit test of any one of them
|
||||||
|
* passes while the chain stays broken.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import { serve } from "@hono/node-server";
|
||||||
|
import type { ServerType } from "@hono/node-server";
|
||||||
|
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||||
|
import { createRemoteTransport } from "@inspector/core/mcp/remote/createRemoteTransport.js";
|
||||||
|
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
|
||||||
|
import { ToolCallCancelledError } from "@inspector/core/mcp/toolCallCancelledError.js";
|
||||||
|
import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js";
|
||||||
|
import {
|
||||||
|
createTestServerHttp,
|
||||||
|
createTestServerInfo,
|
||||||
|
type TestServerHttp,
|
||||||
|
type ToolDefinition,
|
||||||
|
} from "@modelcontextprotocol/inspector-test-server";
|
||||||
|
|
||||||
|
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tool that never returns on its own. It reports when it started, and whether
|
||||||
|
* the server-side request signal — which the SDK aborts when the client closes
|
||||||
|
* this request's response stream — ever fired.
|
||||||
|
*/
|
||||||
|
function createSlowTool(): {
|
||||||
|
tool: ToolDefinition;
|
||||||
|
started: Promise<void>;
|
||||||
|
aborted: Promise<boolean>;
|
||||||
|
} {
|
||||||
|
const started = deferred<void>();
|
||||||
|
const aborted = deferred<boolean>();
|
||||||
|
const tool: ToolDefinition = {
|
||||||
|
name: "slow_task",
|
||||||
|
description: "Runs until the client cancels it",
|
||||||
|
handler: async (_params, _context, extra) => {
|
||||||
|
extra?.signal?.addEventListener("abort", () => aborted.resolve(true), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
started.resolve();
|
||||||
|
// Never settles: this call ends by cancellation or not at all, so a
|
||||||
|
// regression shows up as the test's own timeout rather than as a pass.
|
||||||
|
return new Promise(() => {});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { tool, started: started.promise, aborted: aborted.promise };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRemoteBackend(): Promise<{
|
||||||
|
baseUrl: string;
|
||||||
|
server: ServerType;
|
||||||
|
authToken: string;
|
||||||
|
}> {
|
||||||
|
const { app, authToken } = createRemoteApp({
|
||||||
|
initialConfig: { defaultEnvironment: {} },
|
||||||
|
});
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = serve(
|
||||||
|
{ fetch: app.fetch, port: 0, hostname: "127.0.0.1" },
|
||||||
|
(info) => {
|
||||||
|
const port =
|
||||||
|
info && typeof info === "object" && "port" in info
|
||||||
|
? (info as { port: number }).port
|
||||||
|
: 0;
|
||||||
|
resolve({ baseUrl: `http://127.0.0.1:${port}`, server, authToken });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
server.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("web-client cancellation over a per-request stream (#2140)", () => {
|
||||||
|
let client: InspectorClient | null = null;
|
||||||
|
let backend: ServerType | null = null;
|
||||||
|
let upstream: TestServerHttp | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (client) {
|
||||||
|
try {
|
||||||
|
await client.disconnect();
|
||||||
|
} catch {
|
||||||
|
// Ignore disconnect errors.
|
||||||
|
}
|
||||||
|
client = null;
|
||||||
|
}
|
||||||
|
if (backend) {
|
||||||
|
const server = backend;
|
||||||
|
backend = null;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.close((err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (upstream) {
|
||||||
|
try {
|
||||||
|
await upstream.stop();
|
||||||
|
} catch {
|
||||||
|
// Ignore stop errors.
|
||||||
|
}
|
||||||
|
upstream = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts the upstream request stream, which the server observes as cancellation", async () => {
|
||||||
|
const slow = createSlowTool();
|
||||||
|
upstream = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo("cancel-test", "1.0.0"),
|
||||||
|
tools: [slow.tool],
|
||||||
|
modern: {},
|
||||||
|
});
|
||||||
|
await upstream.start();
|
||||||
|
|
||||||
|
const { baseUrl, server, authToken } = await startRemoteBackend();
|
||||||
|
backend = server;
|
||||||
|
|
||||||
|
const connected = new InspectorClient(
|
||||||
|
{ type: "streamable-http", url: upstream.url },
|
||||||
|
{
|
||||||
|
environment: {
|
||||||
|
transport: createRemoteTransport({ baseUrl, authToken }),
|
||||||
|
},
|
||||||
|
versionNegotiation: eraToVersionNegotiation("modern"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await connected.connect();
|
||||||
|
client = connected;
|
||||||
|
expect(connected.getProtocolEra()).toBe("modern");
|
||||||
|
|
||||||
|
const { tools } = await connected.listTools();
|
||||||
|
const tool = tools.find((t) => t.name === "slow_task");
|
||||||
|
expect(tool).toBeDefined();
|
||||||
|
|
||||||
|
// Hold the rejection from the first turn so the runner never sees it as
|
||||||
|
// unhandled while we wait on the server side.
|
||||||
|
const settled = connected.callTool(tool!, {}).catch((err: unknown) => err);
|
||||||
|
|
||||||
|
await slow.started;
|
||||||
|
expect(connected.cancelToolCall()).toBe(true);
|
||||||
|
|
||||||
|
// The user-facing contract is unchanged: a deliberate cancel still surfaces
|
||||||
|
// as ToolCallCancelledError, whichever wire signal carried it.
|
||||||
|
await expect(settled).resolves.toBeInstanceOf(ToolCallCancelledError);
|
||||||
|
|
||||||
|
// The assertion that matters. Before the fix the server saw only a
|
||||||
|
// `notifications/cancelled` it was free to ignore (and did), its request
|
||||||
|
// signal never fired, and this promise never resolved.
|
||||||
|
await expect(slow.aborted).resolves.toBe(true);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it("advertises the per-request stream only for streamable-http", () => {
|
||||||
|
// The flag is what the SDK forks on, and the factory is where a server's
|
||||||
|
// configured type reaches the transport. stdio and SSE multiplex every
|
||||||
|
// request over one shared channel: there is no per-request stream to close,
|
||||||
|
// and aborting anything would take the whole session down — so they must
|
||||||
|
// keep the `notifications/cancelled` mechanism.
|
||||||
|
const create = createRemoteTransport({ baseUrl: "http://unused.example" });
|
||||||
|
expect(
|
||||||
|
create({ type: "stdio", command: "echo", args: [] }, {}).transport
|
||||||
|
.hasPerRequestStream,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
create({ type: "sse", url: "http://unused.example/sse" }, {}).transport
|
||||||
|
.hasPerRequestStream,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
create({ type: "streamable-http", url: "http://unused.example/mcp" }, {})
|
||||||
|
.transport.hasPerRequestStream,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -648,9 +648,12 @@ export class InspectorClient extends InspectorClientEventTarget {
|
||||||
>();
|
>();
|
||||||
private rawWireRequestCounter = 0;
|
private rawWireRequestCounter = 0;
|
||||||
// Abort controller for the in-flight ordinary (non-task) tool call. Aborting
|
// Abort controller for the in-flight ordinary (non-task) tool call. Aborting
|
||||||
// it makes the SDK send a `notifications/cancelled` for that request (the MCP
|
// it hands the SDK the MCP cancellation flow for that request and rejects the
|
||||||
// cancellation flow) and reject the pending call, which `callTool` surfaces as
|
// pending call, which `callTool` surfaces as a `ToolCallCancelledError`. Which
|
||||||
// a `ToolCallCancelledError`. Undefined when no ordinary call is in flight.
|
// signal reaches the server is the transport's business, not ours: a
|
||||||
|
// per-request-stream transport on a 2026-era connection has that request's
|
||||||
|
// stream aborted, and everything else gets `notifications/cancelled` (#2140).
|
||||||
|
// Undefined when no ordinary call is in flight.
|
||||||
// Task-augmented calls have a server-side task and are cancelled via
|
// Task-augmented calls have a server-side task and are cancelled via
|
||||||
// `cancelRequestorTask` instead, so they don't use this (#1458).
|
// `cancelRequestorTask` instead, so they don't use this (#1458).
|
||||||
private activeToolCallAbortController?: AbortController;
|
private activeToolCallAbortController?: AbortController;
|
||||||
|
|
@ -1169,8 +1172,10 @@ export class InspectorClient extends InspectorClientEventTarget {
|
||||||
if (this.requestTimeout !== undefined) {
|
if (this.requestTimeout !== undefined) {
|
||||||
opts.timeout = this.requestTimeout;
|
opts.timeout = this.requestTimeout;
|
||||||
}
|
}
|
||||||
// When provided, aborting this signal makes the SDK send a
|
// When provided, aborting this signal cancels the request and rejects it
|
||||||
// `notifications/cancelled` for the request and reject it (#1458).
|
// (#1458). The SDK picks the wire signal from the transport: an aborted
|
||||||
|
// per-request SSE stream on a 2026-era Streamable HTTP connection, and
|
||||||
|
// `notifications/cancelled` everywhere else (#2140).
|
||||||
if (signal) {
|
if (signal) {
|
||||||
opts.signal = signal;
|
opts.signal = signal;
|
||||||
}
|
}
|
||||||
|
|
@ -2976,10 +2981,13 @@ export class InspectorClient extends InspectorClientEventTarget {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancel the in-flight ordinary (non-task) tool call started by
|
* Cancel the in-flight ordinary (non-task) tool call started by
|
||||||
* {@link callTool}. Aborting its request makes the SDK send a
|
* {@link callTool}. Aborting its request runs the MCP cancellation flow and
|
||||||
* `notifications/cancelled` to the server (the MCP cancellation flow) and
|
* rejects the pending call, which `callTool` surfaces as a
|
||||||
* reject the pending call, which `callTool` surfaces as a
|
* {@link ToolCallCancelledError}. The SDK chooses the wire signal from the
|
||||||
* {@link ToolCallCancelledError}.
|
* transport: on a 2026-era Streamable HTTP connection it aborts that
|
||||||
|
* request's own SSE response stream — the spec's cancellation signal —
|
||||||
|
* rather than sending `notifications/cancelled`, which is the stdio
|
||||||
|
* mechanism and remains in use there (#2140).
|
||||||
*
|
*
|
||||||
* Task-augmented calls have a server-side task and are cancelled via
|
* Task-augmented calls have a server-side task and are cancelled via
|
||||||
* {@link cancelRequestorTask} instead — this is a no-op for them (and whenever
|
* {@link cancelRequestorTask} instead — this is a no-op for them (and whenever
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,30 @@ export class MessageTrackingTransport implements Transport {
|
||||||
return this.baseTransport.sessionId;
|
return this.baseTransport.sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward the base transport's per-request-stream capability (#2140).
|
||||||
|
*
|
||||||
|
* The SDK's `Protocol.request` reads this off the transport it was handed —
|
||||||
|
* which is always this wrapper — to decide how a cancelled request is
|
||||||
|
* signalled: abort that request's own stream (the 2026-07-28 signal for
|
||||||
|
* Streamable HTTP) or POST a `notifications/cancelled` (the stdio one).
|
||||||
|
*
|
||||||
|
* Not forwarding it made the wrapper *answer* the question, `undefined`, for
|
||||||
|
* every client. So the Cancel button POSTed a notification even on a modern
|
||||||
|
* Streamable HTTP connection, where a spec-compliant server acknowledges it
|
||||||
|
* `202` and drops it — the tool kept running and only a disconnect really
|
||||||
|
* cancelled anything. That hit the CLI and TUI as much as the web client:
|
||||||
|
* their real `StreamableHTTPClientTransport` advertises the flag correctly
|
||||||
|
* and this wrapper hid it.
|
||||||
|
*
|
||||||
|
* A base transport that says nothing stays `undefined` rather than becoming
|
||||||
|
* `false`, since the SDK's check is `=== true` either way and inventing a
|
||||||
|
* value here would misreport what the transport claimed.
|
||||||
|
*/
|
||||||
|
get hasPerRequestStream(): boolean | undefined {
|
||||||
|
return this.baseTransport.hasPerRequestStream;
|
||||||
|
}
|
||||||
|
|
||||||
// Implemented as a concrete method (rather than delegating the base
|
// Implemented as a concrete method (rather than delegating the base
|
||||||
// transport's optional `setProtocolVersion`) so the SDK Client always
|
// transport's optional `setProtocolVersion`) so the SDK Client always
|
||||||
// invokes it after the initialize handshake — including for stdio, whose
|
// invokes it after the initialize handshake — including for stdio, whose
|
||||||
|
|
|
||||||
|
|
@ -1022,6 +1022,36 @@ export function createRemoteApp(
|
||||||
// Auth errors may reject the wait via onerror while send() also throws.
|
// Auth errors may reject the wait via onerror while send() also throws.
|
||||||
void responseWait.catch(() => {});
|
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 {
|
try {
|
||||||
// SEP-2243: apply the client's mirrored headers to the upstream request,
|
// 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
|
// 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, {
|
await session.transport.send(message, {
|
||||||
relatedRequestId: relatedRequestId as string | number | undefined,
|
relatedRequestId: relatedRequestId as string | number | undefined,
|
||||||
...(paramHeaders && { headers: paramHeaders }),
|
...(paramHeaders && { headers: paramHeaders }),
|
||||||
|
requestSignal: upstreamAbort.signal,
|
||||||
});
|
});
|
||||||
if (responseWait) {
|
if (responseWait) {
|
||||||
await responseWait;
|
await responseWait;
|
||||||
|
|
@ -1051,6 +1082,7 @@ export function createRemoteApp(
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
return c.json({ ok: false, kind: "transport_error", error: msg });
|
return c.json({ ok: false, kind: "transport_error", error: msg });
|
||||||
} finally {
|
} finally {
|
||||||
|
clientSignal.removeEventListener("abort", onClientDisconnect);
|
||||||
session.endSend();
|
session.endSend();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -261,6 +261,25 @@ export class RemoteClientTransport implements Transport {
|
||||||
private readonly options: RemoteTransportOptions;
|
private readonly options: RemoteTransportOptions;
|
||||||
private readonly config: import("../types.js").MCPServerConfig;
|
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
|
* 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
|
* 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.options = options;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
this.hasPerRequestStream = config.type === "streamable-http";
|
||||||
}
|
}
|
||||||
|
|
||||||
setAuthRecovery(handlers: AuthRecoveryHandlers | undefined): void {
|
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`, {
|
const res = await this.fetchFn(`${this.baseUrl}/api/mcp/send`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: this.headers,
|
headers: this.headers,
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
...(options?.requestSignal && { signal: options.requestSignal }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
|
||||||
|
|
@ -390,7 +390,11 @@ When a tool execution triggers sampling or elicitation requests, they appear inl
|
||||||
- Step description if provided
|
- Step description if provided
|
||||||
- Elapsed time display
|
- Elapsed time display
|
||||||
- Execute button with loading state
|
- Execute button with loading state
|
||||||
- **Cancel button** sends `notifications/cancelled`
|
- **Cancel button** sends the transport-appropriate cancellation signal (#2140):
|
||||||
|
on a 2026-07-28 Streamable HTTP connection it aborts the request's own SSE
|
||||||
|
response stream, which the spec makes the cancellation signal; on stdio (and
|
||||||
|
on any pre-2026 connection, where the per-stream mechanism does not exist) it
|
||||||
|
sends `notifications/cancelled`
|
||||||
- **Inline Client Request Queue** - When tool triggers sampling/elicitation:
|
- **Inline Client Request Queue** - When tool triggers sampling/elicitation:
|
||||||
- Pending requests shown inline (not as separate modal)
|
- Pending requests shown inline (not as separate modal)
|
||||||
- Queue counter shows total pending requests
|
- Queue counter shows total pending requests
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,11 @@ When a tool execution triggers sampling or elicitation requests, they appear inl
|
||||||
- Step description if provided
|
- Step description if provided
|
||||||
- Elapsed time display
|
- Elapsed time display
|
||||||
- Execute button with loading state
|
- Execute button with loading state
|
||||||
- **Cancel button** sends `notifications/cancelled`
|
- **Cancel button** sends the transport-appropriate cancellation signal (#2140):
|
||||||
|
on a 2026-07-28 Streamable HTTP connection it aborts the request's own SSE
|
||||||
|
response stream, which the spec makes the cancellation signal; on stdio (and
|
||||||
|
on any pre-2026 connection, where the per-stream mechanism does not exist) it
|
||||||
|
sends `notifications/cancelled`
|
||||||
- **Inline Client Request Queue** - When tool triggers sampling/elicitation:
|
- **Inline Client Request Queue** - When tool triggers sampling/elicitation:
|
||||||
- Pending requests shown inline (not as separate modal)
|
- Pending requests shown inline (not as separate modal)
|
||||||
- Queue counter shows total pending requests
|
- Queue counter shows total pending requests
|
||||||
|
|
|
||||||
12
test-servers/configs/cancellation-modern-http.json
Normal file
12
test-servers/configs/cancellation-modern-http.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"serverInfo": {
|
||||||
|
"name": "cancellation-showcase",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"tools": [{ "preset": "slow_task" }, { "preset": "echo" }],
|
||||||
|
"transport": {
|
||||||
|
"type": "streamable-http",
|
||||||
|
"port": 6606,
|
||||||
|
"modern": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,7 @@ import {
|
||||||
createCollectUrlElicitationTool,
|
createCollectUrlElicitationTool,
|
||||||
createUrlElicitationFormTool,
|
createUrlElicitationFormTool,
|
||||||
createSendNotificationTool,
|
createSendNotificationTool,
|
||||||
|
createSlowTaskTool,
|
||||||
createGetAnnotatedMessageTool,
|
createGetAnnotatedMessageTool,
|
||||||
createGetTempTool,
|
createGetTempTool,
|
||||||
createGetTempExtraTool,
|
createGetTempExtraTool,
|
||||||
|
|
@ -165,6 +166,8 @@ function resolveToolPreset(
|
||||||
return createUrlElicitationFormTool();
|
return createUrlElicitationFormTool();
|
||||||
case "send_notification":
|
case "send_notification":
|
||||||
return createSendNotificationTool();
|
return createSendNotificationTool();
|
||||||
|
case "slow_task":
|
||||||
|
return createSlowTaskTool();
|
||||||
case "get_annotated_message":
|
case "get_annotated_message":
|
||||||
return createGetAnnotatedMessageTool();
|
return createGetAnnotatedMessageTool();
|
||||||
case "get_temp":
|
case "get_temp":
|
||||||
|
|
|
||||||
|
|
@ -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
|
* Create a "send_notification" tool that sends a notification message from the server
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,21 @@ import {
|
||||||
* SDK v2's {@link WebStandardStreamableHTTPServerTransport} speaks the Fetch API
|
* SDK v2's {@link WebStandardStreamableHTTPServerTransport} speaks the Fetch API
|
||||||
* (`Request`/`Response`) rather than Node `req`/`res`. The JSON body is passed
|
* (`Request`/`Response`) rather than Node `req`/`res`. The JSON body is passed
|
||||||
* to `handleRequest` via `parsedBody` (Express already parsed it), so the Web
|
* 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();
|
const headers = new Headers();
|
||||||
for (const [key, value] of Object.entries(req.headers)) {
|
for (const [key, value] of Object.entries(req.headers)) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
|
|
@ -50,7 +62,17 @@ function toWebRequest(req: Request): globalThis.Request {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const url = `http://localhost${req.originalUrl || req.url}`;
|
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);
|
this.currentRequestHeaders = extractHeaders(req);
|
||||||
try {
|
try {
|
||||||
const webResponse = await handler.fetch(toWebRequest(req), {
|
const webResponse = await handler.fetch(toWebRequest(req, res), {
|
||||||
parsedBody: req.body,
|
parsedBody: req.body,
|
||||||
});
|
});
|
||||||
await writeWebResponse(res, webResponse);
|
await writeWebResponse(res, webResponse);
|
||||||
|
|
@ -540,9 +562,12 @@ export class TestServerHttp {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const webResponse = await transport.handleRequest(toWebRequest(req), {
|
const webResponse = await transport.handleRequest(
|
||||||
parsedBody: req.body,
|
toWebRequest(req, res),
|
||||||
});
|
{
|
||||||
|
parsedBody: req.body,
|
||||||
|
},
|
||||||
|
);
|
||||||
await writeWebResponse(res, webResponse);
|
await writeWebResponse(res, webResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If response already sent (e.g., by OAuth middleware), don't send another
|
// If response already sent (e.g., by OAuth middleware), don't send another
|
||||||
|
|
@ -577,7 +602,7 @@ export class TestServerHttp {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const webResponse = await newTransport.handleRequest(
|
const webResponse = await newTransport.handleRequest(
|
||||||
toWebRequest(req),
|
toWebRequest(req, res),
|
||||||
{ parsedBody: req.body },
|
{ parsedBody: req.body },
|
||||||
);
|
);
|
||||||
await writeWebResponse(res, webResponse);
|
await writeWebResponse(res, webResponse);
|
||||||
|
|
@ -615,7 +640,9 @@ export class TestServerHttp {
|
||||||
// Let the transport handle the GET request
|
// Let the transport handle the GET request
|
||||||
this.currentRequestHeaders = extractHeaders(req);
|
this.currentRequestHeaders = extractHeaders(req);
|
||||||
try {
|
try {
|
||||||
const webResponse = await transport.handleRequest(toWebRequest(req));
|
const webResponse = await transport.handleRequest(
|
||||||
|
toWebRequest(req, res),
|
||||||
|
);
|
||||||
await writeWebResponse(res, webResponse);
|
await writeWebResponse(res, webResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue