test: drive the cancellation showcase through its checked-in config
The two cancellation tests each built their own `slow_task`, so the documented showcase — the config file, its preset registration, and the fixture's own abort loop — was covered by nothing. A misspelt preset name or a regression in that handler would have surfaced only when someone ran the repro by hand. Resolve `cancellation-modern-http.json` through `loadConfig`/`resolveConfig` and drive the real preset, following the pattern the nullable-fields and MRTR showcase tests already use. It asserts on the handler's own return value rather than on progress notifications. Counting ticks is a false negative here: 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 — verified, that version passed with the fix reverted. 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
56d3c04643
commit
cd5dde839c
1 changed files with 84 additions and 0 deletions
|
|
@ -20,6 +20,7 @@ import {
|
||||||
createMrtrEdgeCaseTool,
|
createMrtrEdgeCaseTool,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
resolveConfig,
|
resolveConfig,
|
||||||
|
type ToolDefinition,
|
||||||
} from "@modelcontextprotocol/inspector-test-server";
|
} from "@modelcontextprotocol/inspector-test-server";
|
||||||
import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server";
|
import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -494,6 +495,89 @@ describe("modern-era negotiation (2026-07-28)", () => {
|
||||||
await expect(aborted).resolves.toBe(true);
|
await expect(aborted).resolves.toBe(true);
|
||||||
}, 30_000);
|
}, 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 () => {
|
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");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue