Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,51 @@ 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-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) |
| `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 stops early if it is cancelled, printing how
far it got to **the server's terminal**. Connect with **Protocol Era = Modern**.

Watch the terminal you started the server in, run `slow_task` from the Tools
tab, and click **Cancel** after a few seconds. The progress must stop
immediately, the Inspector must report the call cancelled, and the server must
print `[slow_task] cancelled after Ns`. On the broken build the progress kept
arriving until the tool completed all 60 seconds and the server printed
`completed all 60s without being cancelled`, because the Inspector was sending
the wrong cancellation signal
([#2140](https://github.com/modelcontextprotocol/inspector/issues/2140)).

The server's terminal is the place to watch, not the Inspector's result panel:
cancellation closes the very stream the tool's result would travel on, so on a
successful cancel the tool's return value is undeliverable by construction and
the Inspector shows a cancelled call rather than a result. Which is the point —
what has to stop is the *work*, and only the server can report that.

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

Expand Down
17 changes: 17 additions & 0 deletions clients/web/src/test/core/mcp/messageTrackingTransport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ class FakeTransport implements Transport {
sessionId?: string;
// Optional on the SDK Transport interface; stdio omits it, HTTP defines it.
setProtocolVersion?: (version: string) => void;
// Optional too: Streamable HTTP advertises it, stdio and SSE say nothing.
hasPerRequestStream?: boolean;
async start(): Promise<void> {}
async send(message: JSONRPCMessage): Promise<void> {
this.sent.push(message);
Expand Down Expand Up @@ -272,4 +274,19 @@ describe("MessageTrackingTransport.setProtocolVersion", () => {
expect(() => tracked.setProtocolVersion("2025-06-18")).not.toThrow();
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);
});
});
117 changes: 117 additions & 0 deletions clients/web/src/test/core/mcp/remote/remoteClientTransport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,4 +569,121 @@ describe("RemoteClientTransport", () => {

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<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", 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 },
);

controller.abort();
// The backend never answers an aborted send, so the rejection has to come
// from the fetch itself. `postSend` already cancels the SSE response wait
// it registered for this id on that path (its catch does, unchanged by
// #2140) — so the send surfaces the abort rather than the wait's timeout.
await expect(sent).rejects.toThrow(/Aborted/);

await transport.close();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
createMrtrEdgeCaseTool,
loadConfig,
resolveConfig,
type ToolDefinition,
} from "@modelcontextprotocol/inspector-test-server";
import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server";
import type {
Expand Down Expand Up @@ -437,6 +438,146 @@ describe("modern-era negotiation (2026-07-28)", () => {
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);

// The test above builds its own never-returning tool, which is the sharpest
// way to assert on the server's abort signal but says nothing about the
// artifacts a human actually uses. This one drives the documented showcase
// through the SAME path they would — the checked-in config, resolved through
// the preset registry — so a misspelt preset name, a config naming a dead
// preset, or a regression in the `slow_task` handler's own abort loop fails
// here rather than only when someone runs the repro by hand.
//
// It observes the handler's own return value, not the client's view. Counting
// progress notifications would be a false negative: the SDK drops a cancelled
// request's progress handler locally, so the client stops seeing ticks the
// moment it cancels whether or not the server ever stopped working — the very
// silence this bug hid behind.
it("stops the showcase tool's work on cancel (slow_task, via the showcase config)", async () => {
const config = loadConfig(
join(repoRoot, "test-servers/configs/cancellation-modern-http.json"),
);
expect(config.tools).toContainEqual({ preset: "slow_task" });

const resolved = resolveConfig(config);
expect(resolved.tools?.map((tool) => tool.name)).toEqual([
"slow_task",
"echo",
]);

// Wrap the resolved preset rather than replacing it, so what runs is the
// real registered handler and only its outcome is observed. `tools` is a
// union with the task-tool shape, whose handler has a different signature,
// so narrow to the plain one this preset actually is.
const preset = resolved.tools!.find(
(tool): tool is ToolDefinition => tool.name === "slow_task",
)!;
let reportOutcome!: (text: string) => void;
const outcome = new Promise<string>((resolve) => {
reportOutcome = resolve;
});
let firstTick!: () => void;
const running = new Promise<void>((resolve) => {
firstTick = resolve;
});

const started = createTestServerHttp({
...resolved,
tools: resolved.tools!.map((tool) =>
tool.name === "slow_task"
? {
...preset,
handler: async (params, context, extra) => {
extra?.signal?.addEventListener("abort", () => firstTick(), {
once: true,
});
const result = await preset.handler(params, context, extra);
reportOutcome(JSON.stringify(result));
return result;
},
}
: tool,
),
});
await started.start();
server = started;
const connected = await connectWithEra(started.url, "modern");

const { tools } = await connected.listTools();
const tool = tools.find((t) => t.name === "slow_task");
expect(tool).toBeDefined();

// Hold the rejection immediately so it is never seen as unhandled.
const settled = connected
.callTool(tool!, { seconds: 30 })
.catch((err: unknown) => err);

// Let it get past its first tick, then cancel.
await new Promise((resolve) => setTimeout(resolve, 1500));
expect(connected.cancelToolCall()).toBe(true);
await expect(settled).resolves.toBeInstanceOf(ToolCallCancelledError);
await running;

// The handler must return promptly, saying it was cancelled — not run on to
// its 30th second, which is what it did before the fix.
await expect(outcome).resolves.toContain("cancelled after");
}, 30_000);

it("cancels an in-flight MRTR call while its embedded request is pending", async () => {
const started = await startMrtrServer(createMrtrTool());
const connected = await connectWithEra(started.url, "modern");
Expand Down
Loading