@acid-cat/cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,36 +7,36 @@ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/cli
7
7
  */
8
8
  const RULE = "spec/bindings/mcp.md";
9
9
  /** A connected MCP client. */
10
- async function mcpClient(url) {
10
+ async function mcpClient(url, signal) {
11
11
  const client = new Client({
12
12
  name: "acid-cat-conform",
13
13
  version: "0.1.0"
14
14
  }, { versionNegotiation: { mode: { pin: "2026-07-28" } } });
15
- await client.connect(new StreamableHTTPClientTransport(new URL(url)));
15
+ await client.connect(new StreamableHTTPClientTransport(new URL(url)), signal ? { signal } : void 0);
16
16
  return client;
17
17
  }
18
18
  const textOf = (result) => JSON.parse(result.content?.[0]?.text ?? "null");
19
19
  async function checkMcp(url, options) {
20
- const checks = new Checks("mcp");
20
+ const checks = new Checks("mcp", options.deadline);
21
21
  let client;
22
- await checks.run("an MCP client connects with revision 2026-07-28", `${RULE}#server`, async () => {
23
- client = await mcpClient(url);
22
+ await checks.run("an MCP client connects with revision 2026-07-28", `${RULE}#server`, async (signal) => {
23
+ client = await mcpClient(url, signal);
24
24
  });
25
25
  if (client === void 0) return checks;
26
26
  const mcp = client;
27
- const call = (name, args) => mcp.callTool({
27
+ const call = (signal, name, args) => mcp.callTool({
28
28
  name,
29
29
  arguments: args
30
- });
30
+ }, { signal });
31
31
  await checks.run("server/discover gives the description of the catalog as instructions", `${RULE}#server`, () => {
32
32
  expectEqual(mcp.getInstructions(), fixture.description, "instructions");
33
33
  });
34
- await checks.run("tools/list gives each available capability of the view", `${RULE}#tools`, async () => {
35
- const { tools } = await mcp.listTools();
34
+ await checks.run("tools/list gives each available capability of the view", `${RULE}#tools`, async (signal) => {
35
+ const { tools } = await mcp.listTools(void 0, { signal });
36
36
  expectEqual(sorted(tools.map((tool) => tool.name)), idsOf("mcp"), "tools");
37
37
  });
38
- await checks.run("tools have their hints and output schemas", `${RULE}#tools`, async () => {
39
- const { tools } = await mcp.listTools();
38
+ await checks.run("tools have their hints and output schemas", `${RULE}#tools`, async (signal) => {
39
+ const { tools } = await mcp.listTools(void 0, { signal });
40
40
  const echo = tools.find((tool) => tool.name === "fixture.echo");
41
41
  const refuse = tools.find((tool) => tool.name === "fixture.refuse");
42
42
  expect(echo?.annotations?.readOnlyHint === true, "fixture.echo must have readOnlyHint: true");
@@ -45,80 +45,81 @@ async function checkMcp(url, options) {
45
45
  expect(refuse?.outputSchema === void 0, "fixture.refuse has no object output, so it must have no outputSchema");
46
46
  expect((echo?.inputSchema).type === "object", "an inputSchema must be an object");
47
47
  });
48
- await checks.run("a value is a tool result with structured content", `${RULE}#tools`, async () => {
49
- const result = await call("fixture.echo", { text: "hello" });
48
+ await checks.run("a value is a tool result with structured content", `${RULE}#tools`, async (signal) => {
49
+ const result = await call(signal, "fixture.echo", { text: "hello" });
50
50
  expect(result.isError !== true, "a value must not be an error result");
51
51
  expectEqual(result.structuredContent, { text: "hello" }, "structuredContent");
52
52
  expectEqual(textOf(result), { text: "hello" }, "text content");
53
53
  });
54
- await checks.run("a failure is an error result with ok: false", `${RULE}#tools`, async () => {
55
- const result = await call("fixture.refuse", {});
54
+ await checks.run("a failure is an error result with ok: false", `${RULE}#tools`, async (signal) => {
55
+ const result = await call(signal, "fixture.refuse", {});
56
56
  expect(result.isError === true, "a failure must be an error result");
57
57
  const body = textOf(result);
58
58
  expect(body.ok === false && body.error?._tag === "Refused", `expected { ok: false, error: { _tag: "Refused" } }, got ${JSON.stringify(body)}`);
59
59
  });
60
- await checks.run("a defect is an internal call failure with no internal details", `${RULE}#tools`, async () => {
61
- const result = await call("fixture.crash", {});
60
+ await checks.run("a defect is an internal call failure with no internal details", `${RULE}#tools`, async (signal) => {
61
+ const result = await call(signal, "fixture.crash", {});
62
62
  const body = textOf(result);
63
63
  expect(result.isError === true && body.kind === "internal" && !("ok" in body), `expected { kind: "internal" }, got ${JSON.stringify(body)}`);
64
64
  expect(!/\n\s+at /.test(body.message ?? ""), "the message must not contain a stack trace");
65
65
  });
66
- await checks.run("an input that does not agree with its schema is invalid-input", `${RULE}#tools`, async () => {
67
- const result = await call("fixture.echo", { text: 1 });
66
+ await checks.run("an input that does not agree with its schema is invalid-input", `${RULE}#tools`, async (signal) => {
67
+ const result = await call(signal, "fixture.echo", { text: 1 });
68
68
  expectEqual(textOf(result).kind, "invalid-input", "kind");
69
69
  });
70
- await checks.run("a capability outside the view is an unknown tool", `${RULE}#tools`, async () => {
71
- const error = await call("fixture.hidden", {}).then(() => void 0, (caught) => caught);
70
+ await checks.run("a capability outside the view is an unknown tool", `${RULE}#tools`, async (signal) => {
71
+ const error = await call(signal, "fixture.hidden", {}).then(() => void 0, (caught) => caught);
72
72
  expect(error?.code === -32602, `expected the JSON-RPC error -32602, got ${JSON.stringify(error)}`);
73
73
  });
74
- await checks.run("an unavailable capability is not listed", `${RULE}#tools`, async () => {
75
- await call("fixture.switch.set", { on: false });
74
+ await checks.run("an unavailable capability is not listed", `${RULE}#tools`, async (signal) => {
75
+ await call(signal, "fixture.switch.set", { on: false });
76
76
  try {
77
- const { tools } = await mcp.listTools();
77
+ const { tools } = await mcp.listTools(void 0, { signal });
78
78
  expect(!tools.some((tool) => tool.name === "fixture.toggle"), "fixture.toggle must not be listed while it is unavailable");
79
79
  } finally {
80
- await call("fixture.switch.set", { on: true });
80
+ await call(AbortSignal.timeout(options.deadline), "fixture.switch.set", { on: true });
81
81
  }
82
82
  });
83
83
  await mcp.close();
84
- const post = (init) => fetch(url, {
84
+ const post = (signal, init) => fetch(url, {
85
85
  method: "POST",
86
86
  ...init,
87
87
  headers: {
88
88
  "Content-Type": "application/json",
89
89
  ...init.headers
90
- }
90
+ },
91
+ signal
91
92
  });
92
93
  const rejection = async (response, status, code) => {
93
94
  expectEqual(response.status, status, "status");
94
95
  expectEqual(response.headers.get("Cache-Control"), "no-store", "Cache-Control");
95
96
  if (code !== void 0) expectEqual((await response.json()).error?.code, code, "JSON-RPC error code");
96
97
  };
97
- await checks.run("a method other than POST is rejected with 405", `${RULE}#server`, async () => {
98
- const response = await fetch(url);
98
+ await checks.run("a method other than POST is rejected with 405", `${RULE}#server`, async (signal) => {
99
+ const response = await fetch(url, { signal });
99
100
  await rejection(response, 405);
100
101
  expectEqual(response.headers.get("Allow"), "POST", "Allow");
101
102
  });
102
- await checks.run("an origin that is not allowed is rejected with 403", `${RULE}#server`, async () => {
103
- await rejection(await post({
103
+ await checks.run("an origin that is not allowed is rejected with 403", `${RULE}#server`, async (signal) => {
104
+ await rejection(await post(signal, {
104
105
  headers: { Origin: FORBIDDEN_ORIGIN },
105
106
  body: "{}"
106
107
  }), 403);
107
108
  });
108
- await checks.run("a media type other than JSON is rejected with 415", `${RULE}#server`, async () => {
109
- await rejection(await post({
109
+ await checks.run("a media type other than JSON is rejected with 415", `${RULE}#server`, async (signal) => {
110
+ await rejection(await post(signal, {
110
111
  headers: { "Content-Type": "text/plain" },
111
112
  body: "{}"
112
113
  }), 415);
113
114
  });
114
- await checks.run("a body over the limit is rejected with 413 and -32600", `${RULE}#server`, async () => {
115
- await rejection(await post({ body: " ".repeat(options.maximumBytes + 1) }), 413, -32600);
115
+ await checks.run("a body over the limit is rejected with 413 and -32600", `${RULE}#server`, async (signal) => {
116
+ await rejection(await post(signal, { body: " ".repeat(options.maximumBytes + 1) }), 413, -32600);
116
117
  });
117
- await checks.run("a body that is not JSON is rejected with 400 and -32700", `${RULE}#server`, async () => {
118
- await rejection(await post({ body: "{" }), 400, -32700);
118
+ await checks.run("a body that is not JSON is rejected with 400 and -32700", `${RULE}#server`, async (signal) => {
119
+ await rejection(await post(signal, { body: "{" }), 400, -32700);
119
120
  });
120
- await checks.run("a body that is not UTF-8 is rejected with 400", `${RULE}#server`, async () => {
121
- await rejection(await post({ body: new Uint8Array([
121
+ await checks.run("a body that is not UTF-8 is rejected with 400", `${RULE}#server`, async (signal) => {
122
+ await rejection(await post(signal, { body: new Uint8Array([
122
123
  123,
123
124
  255,
124
125
  125
@@ -1,14 +1,10 @@
1
- /**
2
- * Property-based checks for the guarantees of spec/invocation.md#cancellation-and-concurrency, through each remote
3
- * binding. fast-check makes random sets of concurrent calls and cancellations.
4
- */
5
1
  import type { Outcome } from "@acid-cat/spec-ts";
6
2
  import { Checks } from "./check.js";
7
- /** A call through one binding. A cancelled call can reject. */
3
+ /** A call through one binding. When the signal aborts, the client stops the call and rejects with the reason of the signal. */
8
4
  export type Invoke = (id: string, input: object, signal?: AbortSignal) => Promise<Outcome<unknown, unknown>>;
9
5
  export declare function httpInvoke(base: string): Invoke;
10
- export declare function mcpInvoke(url: string): Promise<{
6
+ export declare function mcpInvoke(url: string, signal: AbortSignal): Promise<{
11
7
  readonly invoke: Invoke;
12
8
  close(): Promise<void>;
13
9
  }>;
14
- export declare function checkProperties(binding: string, invoke: Invoke): Promise<Checks>;
10
+ export declare function checkProperties(binding: string, invoke: Invoke, deadline: number): Promise<Checks>;
@@ -1,7 +1,13 @@
1
- import { Checks, expect } from "./check.js";
1
+ import { Checks, expect, until } from "./check.js";
2
2
  import { mcpClient } from "./mcp.js";
3
+ import { SdkError } from "@modelcontextprotocol/client";
4
+ import { randomUUID } from "node:crypto";
3
5
  import fc from "fast-check";
4
6
  //#region src/conform/properties.ts
7
+ /**
8
+ * Property-based checks for the guarantees of spec/invocation.md#cancellation-and-concurrency, through each remote
9
+ * binding. fast-check makes random sets of concurrent calls and cancellations.
10
+ */
5
11
  const RULE = "spec/invocation.md#cancellation-and-concurrency";
6
12
  function httpInvoke(base) {
7
13
  return async (id, input, signal) => {
@@ -13,8 +19,8 @@ function httpInvoke(base) {
13
19
  })).json();
14
20
  };
15
21
  }
16
- async function mcpInvoke(url) {
17
- const client = await mcpClient(url);
22
+ async function mcpInvoke(url, signal) {
23
+ const client = await mcpClient(url, signal);
18
24
  return {
19
25
  invoke: async (id, input, signal) => {
20
26
  try {
@@ -29,6 +35,7 @@ async function mcpInvoke(url) {
29
35
  };
30
36
  return body;
31
37
  } catch (error) {
38
+ if (signal?.aborted && error instanceof SdkError && error.message === String(signal.reason)) throw signal.reason;
32
39
  if (error.code === -32602) return {
33
40
  kind: "unknown-capability",
34
41
  message: String(error)
@@ -52,45 +59,100 @@ function agrees(operation, outcome) {
52
59
  case "invalid": return "kind" in outcome && outcome.kind === "invalid-input";
53
60
  }
54
61
  }
55
- const callFor = (invoke, operation) => {
62
+ const callFor = (invoke, operation, signal) => {
56
63
  switch (operation.kind) {
57
- case "echo": return invoke("fixture.echo", { text: operation.text });
58
- case "refuse": return invoke("fixture.refuse", {});
59
- case "crash": return invoke("fixture.crash", {});
60
- case "invalid": return invoke("fixture.echo", { text: 0 });
64
+ case "echo": return invoke("fixture.echo", { text: operation.text }, signal);
65
+ case "refuse": return invoke("fixture.refuse", {}, signal);
66
+ case "crash": return invoke("fixture.crash", {}, signal);
67
+ case "invalid": return invoke("fixture.echo", { text: 0 }, signal);
61
68
  }
62
69
  };
63
- async function checkProperties(binding, invoke) {
64
- const checks = new Checks(`${binding} properties`);
65
- await checks.run("concurrent calls each get exactly their own outcome", RULE, async () => {
70
+ const settle = (call) => call.then((outcome) => ({ outcome }), (rejected) => ({ rejected }));
71
+ const describe = (settled) => "outcome" in settled ? JSON.stringify(settled.outcome) : `the rejection ${String(settled.rejected)}`;
72
+ /**
73
+ * True if a call ended as a cancellation: with the call failure `cancelled`, or with the rejection that the client gives
74
+ * when it aborts the call, which is the reason of its signal. Any other rejection is not a cancellation.
75
+ */
76
+ const isCancellation = (settled, reason) => "outcome" in settled ? "kind" in settled.outcome && settled.outcome.kind === "cancelled" : settled.rejected === reason;
77
+ /** The keys of the calls that hold now, as fixture.held gives them through the binding. */
78
+ async function heldKeys(invoke, signal) {
79
+ const outcome = await invoke("fixture.held", {}, signal);
80
+ expect("ok" in outcome && outcome.ok, `fixture.held gave ${JSON.stringify(outcome)}`);
81
+ return outcome.value.keys;
82
+ }
83
+ /**
84
+ * Start one call to fixture.hold for each flag, and wait until each call holds. Then cancel each call whose flag is
85
+ * true, and release the others. Each cancelled call must end as a cancellation and stop holding. Each other call must
86
+ * hold until it is released, and then give its own key. At the end, the cleanup releases every key, with a signal that
87
+ * aborts `deadline` milliseconds later.
88
+ */
89
+ async function cancelSome(invoke, flags, signal, deadline) {
90
+ const calls = flags.map((cancelled) => {
91
+ const key = randomUUID();
92
+ const controller = new AbortController();
93
+ const reason = /* @__PURE__ */ new Error(`acid-cat conform cancels the call that holds ${key}`);
94
+ return {
95
+ key,
96
+ cancelled,
97
+ cancel: () => controller.abort(reason),
98
+ reason,
99
+ settled: settle(invoke("fixture.hold", { key }, AbortSignal.any([controller.signal, signal])))
100
+ };
101
+ });
102
+ const cancelled = calls.filter((call) => call.cancelled);
103
+ const kept = calls.filter((call) => !call.cancelled);
104
+ const read = () => heldKeys(invoke, signal);
105
+ try {
106
+ await until("fixture.held gives the key of each call", read, (held) => calls.every((call) => held.includes(call.key)), signal);
107
+ for (const call of cancelled) call.cancel();
108
+ for (const call of cancelled) {
109
+ const settled = await call.settled;
110
+ expect(isCancellation(settled, call.reason), `a cancelled call gave ${describe(settled)}`);
111
+ }
112
+ const held = await until("the cancelled calls stop holding", read, (held) => cancelled.every((call) => !held.includes(call.key)), signal);
113
+ expect(kept.every((call) => held.includes(call.key)), "a call that was not cancelled stopped holding");
114
+ for (const call of kept) {
115
+ await invoke("fixture.release", { key: call.key }, signal);
116
+ const settled = await call.settled;
117
+ const own = {
118
+ ok: true,
119
+ value: { key: call.key }
120
+ };
121
+ expect("outcome" in settled && JSON.stringify(settled.outcome) === JSON.stringify(own), `a call that was not cancelled gave ${describe(settled)}`);
122
+ }
123
+ } finally {
124
+ const cleanup = AbortSignal.timeout(deadline);
125
+ await Promise.all(calls.map((call) => invoke("fixture.release", { key: call.key }, cleanup).catch(() => void 0)));
126
+ }
127
+ }
128
+ /** Two calls or more, at least one of them cancelled and at least one not. */
129
+ const cancellations = fc.array(fc.boolean(), {
130
+ minLength: 2,
131
+ maxLength: 5
132
+ }).filter((flags) => flags.includes(true) && flags.includes(false));
133
+ async function checkProperties(binding, invoke, deadline) {
134
+ const checks = new Checks(`${binding} properties`, deadline);
135
+ await checks.run("concurrent calls each get exactly their own outcome", RULE, async (signal) => {
66
136
  await fc.assert(fc.asyncProperty(fc.array(operation, {
67
137
  minLength: 1,
68
138
  maxLength: 12
69
139
  }), async (operations) => {
70
- const outcomes = await Promise.all(operations.map((op) => callFor(invoke, op)));
140
+ fc.pre(!signal.aborted);
141
+ const outcomes = await Promise.all(operations.map((op) => callFor(invoke, op, signal)));
71
142
  operations.forEach((op, index) => expect(agrees(op, outcomes[index]), `${op.kind} gave ${JSON.stringify(outcomes[index])}`));
72
- }), { numRuns: 25 });
143
+ }), {
144
+ numRuns: 25,
145
+ includeErrorInReport: true
146
+ });
73
147
  });
74
- await checks.run("cancelling a call affects only that call", RULE, async () => {
75
- await fc.assert(fc.asyncProperty(fc.array(fc.boolean(), {
76
- minLength: 2,
77
- maxLength: 5
78
- }), async (cancel) => {
79
- const calls = cancel.map((cancelled) => {
80
- const controller = new AbortController();
81
- const outcome = invoke("fixture.slow", { milliseconds: 200 }, controller.signal).catch(() => "aborted");
82
- if (cancelled) setTimeout(() => controller.abort(), 20);
83
- return {
84
- cancelled,
85
- outcome
86
- };
87
- });
88
- for (const call of calls) {
89
- const outcome = await call.outcome;
90
- if (!call.cancelled) expect(outcome !== "aborted" && "ok" in outcome && outcome.ok, `a call that was not cancelled gave ${JSON.stringify(outcome)}`);
91
- else expect(outcome === "aborted" || "kind" in outcome && outcome.kind === "cancelled", `a cancelled call gave ${JSON.stringify(outcome)}`);
92
- }
93
- }), { numRuns: 5 });
148
+ await checks.run("cancelling a call affects only that call", RULE, async (signal) => {
149
+ await fc.assert(fc.asyncProperty(cancellations, (flags) => {
150
+ fc.pre(!signal.aborted);
151
+ return cancelSome(invoke, flags, signal, deadline);
152
+ }), {
153
+ numRuns: 5,
154
+ includeErrorInReport: true
155
+ });
94
156
  });
95
157
  return checks;
96
158
  }
@@ -25,8 +25,10 @@ export interface TestHost {
25
25
  }): void;
26
26
  };
27
27
  tools(): RecordedTool[];
28
- /** Call a tool. If `cancelAfter` is given, abort the signal of the call after that many milliseconds. */
29
- call(name: string, input: unknown, cancelAfter?: number): Promise<ToolCall>;
28
+ /** Call a tool. With an `id`, `cancel` can abort the signal of the call. */
29
+ call(name: string, input: unknown, id?: string): Promise<ToolCall>;
30
+ /** Abort the signal of the call that has this `id`, if it runs. */
31
+ cancel(id: string): void;
30
32
  }
31
33
  /**
32
34
  * Make a test host. The function uses no imports and no outer variables, because the browser driver sends its
@@ -35,8 +37,15 @@ export interface TestHost {
35
37
  export declare function createTestHost(): TestHost;
36
38
  /** The view from the checks: the tools of the page, and calls to them. */
37
39
  export interface WebMcpDriver {
38
- tools(): Promise<RecordedTool[]>;
39
- call(name: string, input: unknown, cancelAfter?: number): Promise<ToolCall>;
40
+ tools(signal: AbortSignal): Promise<RecordedTool[]>;
41
+ /**
42
+ * Call a tool. When `cancel` aborts, the host aborts the signal of the call, and the driver waits for what the tool
43
+ * gives. When `signal` aborts, the host aborts the signal of the call, and the driver stops waiting.
44
+ */
45
+ call(name: string, input: unknown, options?: {
46
+ readonly signal?: AbortSignal;
47
+ readonly cancel?: AbortSignal;
48
+ }): Promise<ToolCall>;
40
49
  close(): Promise<void>;
41
50
  }
42
51
  /**
@@ -48,4 +57,6 @@ export type WebMcpInstall = (modelContext: TestHost["modelContext"]) => void | (
48
57
  export declare function processDriver(install: WebMcpInstall): Promise<WebMcpDriver>;
49
58
  /** A driver for a page in Chromium, through Playwright. The page loads with the test host as its `modelContext`. */
50
59
  export declare function browserDriver(url: string): Promise<WebMcpDriver>;
51
- export declare function checkWebMcp(driver: WebMcpDriver): Promise<Checks>;
60
+ export declare function checkWebMcp(driver: WebMcpDriver, options: {
61
+ readonly deadline: number;
62
+ }): Promise<Checks>;
@@ -1,4 +1,4 @@
1
- import { Checks, expect, expectEqual, fixture, idsOf, sorted } from "./check.js";
1
+ import { Checks, abortable, expect, expectEqual, fixture, idsOf, sorted, until } from "./check.js";
2
2
  import { REFERENCE, visitObjects } from "@acid-cat/spec-ts";
3
3
  //#region src/conform/webmcp.ts
4
4
  /**
@@ -17,6 +17,7 @@ const TARGET = "app";
17
17
  */
18
18
  function createTestHost() {
19
19
  const registered = /* @__PURE__ */ new Map();
20
+ const calls = /* @__PURE__ */ new Map();
20
21
  return {
21
22
  modelContext: { registerTool(tool, options) {
22
23
  const recorded = tool;
@@ -30,11 +31,11 @@ function createTestHost() {
30
31
  description: tool.description,
31
32
  inputSchema: tool.inputSchema
32
33
  })),
33
- async call(name, input, cancelAfter) {
34
+ async call(name, input, id) {
34
35
  const tool = registered.get(name);
35
36
  if (tool === void 0) return { missing: true };
36
37
  const controller = new AbortController();
37
- if (cancelAfter !== void 0) setTimeout(() => controller.abort(), cancelAfter);
38
+ if (id !== void 0) calls.set(id, controller);
38
39
  try {
39
40
  return { resolved: await tool.execute(input, { signal: controller.signal }) };
40
41
  } catch (error) {
@@ -44,22 +45,46 @@ function createTestHost() {
44
45
  kind,
45
46
  message: String(message)
46
47
  } };
48
+ } finally {
49
+ if (id !== void 0) calls.delete(id);
47
50
  }
51
+ },
52
+ cancel(id) {
53
+ calls.get(id)?.abort();
48
54
  }
49
55
  };
50
56
  }
57
+ let lastCall = 0;
58
+ function driverOf(host) {
59
+ return {
60
+ tools: (signal) => abortable(host.tools(), signal),
61
+ call: (name, input, { signal, cancel } = {}) => {
62
+ const id = String(++lastCall);
63
+ const stop = () => void host.cancel(id).catch(() => void 0);
64
+ signal?.addEventListener("abort", stop, { once: true });
65
+ cancel?.addEventListener("abort", stop, { once: true });
66
+ const call = host.call(name, input, id).finally(() => {
67
+ signal?.removeEventListener("abort", stop);
68
+ cancel?.removeEventListener("abort", stop);
69
+ });
70
+ return signal === void 0 ? call : abortable(call, signal);
71
+ },
72
+ close: () => host.close()
73
+ };
74
+ }
51
75
  /** A driver for an implementation in the same process. Values pass through JSON, as they do from a page. */
52
76
  async function processDriver(install) {
53
77
  const host = createTestHost();
54
78
  const remove = await install(host.modelContext);
55
79
  const copy = (value) => JSON.parse(JSON.stringify(value));
56
- return {
80
+ return driverOf({
57
81
  tools: async () => copy(host.tools()),
58
- call: async (name, input, cancelAfter) => copy(await host.call(name, input, cancelAfter)),
82
+ call: async (name, input, id) => copy(await host.call(name, input, id)),
83
+ cancel: async (id) => host.cancel(id),
59
84
  close: async () => {
60
85
  if (typeof remove === "function") await remove();
61
86
  }
62
- };
87
+ });
63
88
  }
64
89
  /** A driver for a page in Chromium, through Playwright. The page loads with the test host as its `modelContext`. */
65
90
  async function browserDriver(url) {
@@ -80,27 +105,19 @@ async function browserDriver(url) {
80
105
  }
81
106
  `);
82
107
  await page.goto(url);
83
- return {
108
+ return driverOf({
84
109
  tools: async () => {
85
110
  if (errors.length > 0) throw new Error(`The page has errors: ${errors.join("; ")}`);
86
111
  return page.evaluate(() => window.__acidCatConform.tools());
87
112
  },
88
- call: (name, input, cancelAfter) => page.evaluate(([name, input, cancelAfter]) => window.__acidCatConform.call(name, input, cancelAfter), [
113
+ call: (name, input, id) => page.evaluate(([name, input, id]) => window.__acidCatConform.call(name, input, id), [
89
114
  name,
90
115
  input,
91
- cancelAfter
116
+ id
92
117
  ]),
118
+ cancel: (id) => page.evaluate((id) => window.__acidCatConform.cancel(id), id),
93
119
  close: () => browser.close()
94
- };
95
- }
96
- /** Wait until `condition` is true. Give up after `timeout` milliseconds. */
97
- async function eventually(condition, what, timeout = 5e3) {
98
- const end = Date.now() + timeout;
99
- for (;;) {
100
- if (await condition()) return;
101
- if (Date.now() > end) throw new Error(`after ${timeout} ms: ${what}`);
102
- await new Promise((resolve) => setTimeout(resolve, 20));
103
- }
120
+ });
104
121
  }
105
122
  /** True if each `$ref` of a schema refers to a type in its own `$defs` (spec/catalog.md#bundling). */
106
123
  function isBundled(schema) {
@@ -113,16 +130,22 @@ function isBundled(schema) {
113
130
  });
114
131
  return bundled;
115
132
  }
116
- const names = async (driver) => sorted((await driver.tools()).map((tool) => tool.name));
117
- async function checkWebMcp(driver) {
118
- const checks = new Checks("webmcp");
133
+ const names = async (driver, signal) => sorted((await driver.tools(signal)).map((tool) => tool.name));
134
+ /** The keys of the calls that hold now, as fixture.held gives them through the host. */
135
+ async function heldKeys(driver, signal) {
136
+ const call = await driver.call("fixture.held", {}, { signal });
137
+ expect("resolved" in call, `fixture.held gave ${JSON.stringify(call)}`);
138
+ return call.resolved.value.keys;
139
+ }
140
+ async function checkWebMcp(driver, options) {
141
+ const checks = new Checks("webmcp", options.deadline);
119
142
  const view = fixture.capabilities;
120
143
  const expected = idsOf(TARGET);
121
- await checks.run("each available capability of the view is a tool", `${RULE}#tools`, async () => {
122
- await eventually(async () => JSON.stringify(await names(driver)) === JSON.stringify(expected), `the tools must be ${expected.join(", ")}`);
144
+ await checks.run("each available capability of the view is a tool", `${RULE}#tools`, async (signal) => {
145
+ await until(`the tools are ${expected.join(", ")}`, () => names(driver, signal), (tools) => JSON.stringify(tools) === JSON.stringify(expected), signal);
123
146
  });
124
- await checks.run("a tool has the description and the bundled input schema of its capability", `${RULE}#tools`, async () => {
125
- for (const tool of await driver.tools()) {
147
+ await checks.run("a tool has the description and the bundled input schema of its capability", `${RULE}#tools`, async (signal) => {
148
+ for (const tool of await driver.tools(signal)) {
126
149
  expectEqual(tool.description, view[tool.name]?.description, `description of ${tool.name}`);
127
150
  const schema = tool.inputSchema;
128
151
  const input = view[tool.name]?.input ?? {};
@@ -132,14 +155,14 @@ async function checkWebMcp(driver) {
132
155
  expect(isBundled(schema), `the input schema of ${tool.name} must be bundled`);
133
156
  }
134
157
  });
135
- await checks.run("a value resolves as a result", `${RULE}#tools`, async () => {
136
- expectEqual(await driver.call("fixture.echo", { text: "hello" }), { resolved: {
158
+ await checks.run("a value resolves as a result", `${RULE}#tools`, async (signal) => {
159
+ expectEqual(await driver.call("fixture.echo", { text: "hello" }, { signal }), { resolved: {
137
160
  ok: true,
138
161
  value: { text: "hello" }
139
162
  } }, "outcome");
140
163
  });
141
- await checks.run("a failure resolves as a result", `${RULE}#tools`, async () => {
142
- const call = await driver.call("fixture.refuse", {});
164
+ await checks.run("a failure resolves as a result", `${RULE}#tools`, async (signal) => {
165
+ const call = await driver.call("fixture.refuse", {}, { signal });
143
166
  expect("resolved" in call, `the call must resolve, got ${JSON.stringify(call)}`);
144
167
  expectEqual(call.resolved.error?._tag, "Refused", "_tag");
145
168
  });
@@ -155,23 +178,39 @@ async function checkWebMcp(driver) {
155
178
  { text: 1 },
156
179
  "invalid-input",
157
180
  "TypeError"
158
- ]]) await checks.run(name, `${RULE}#tools`, async () => {
159
- const call = await driver.call(capability, input);
181
+ ]]) await checks.run(name, `${RULE}#tools`, async (signal) => {
182
+ const call = await driver.call(capability, input, { signal });
160
183
  expect("rejected" in call, `the call must reject, got ${JSON.stringify(call)}`);
161
184
  expectEqual(call.rejected.kind, kind, "kind");
162
185
  if (errorName !== void 0) expectEqual(call.rejected.name, errorName, "name");
163
186
  });
164
- await checks.run("the signal of the host cancels a call, as an AbortError", `${RULE}#tools`, async () => {
165
- const call = await driver.call("fixture.slow", { milliseconds: 6e4 }, 50);
166
- expect("rejected" in call, `the call must reject, got ${JSON.stringify(call)}`);
167
- expectEqual(call.rejected.kind, "cancelled", "kind");
168
- expectEqual(call.rejected.name, "AbortError", "name");
187
+ await checks.run("the signal of the host cancels a call, as an AbortError", `${RULE}#tools`, async (signal) => {
188
+ const key = "cancel";
189
+ const cancel = new AbortController();
190
+ const call = driver.call("fixture.hold", { key }, {
191
+ signal,
192
+ cancel: cancel.signal
193
+ });
194
+ call.catch(() => void 0);
195
+ const read = () => heldKeys(driver, signal);
196
+ try {
197
+ await until(`fixture.held gives ${key}`, read, (held) => held.includes(key), signal);
198
+ cancel.abort();
199
+ const result = await call;
200
+ expect("rejected" in result, `the call must reject, got ${JSON.stringify(result)}`);
201
+ expectEqual(result.rejected.kind, "cancelled", "kind");
202
+ expectEqual(result.rejected.name, "AbortError", "name");
203
+ await until("the cancelled call stops holding", read, (held) => !held.includes(key), signal);
204
+ } finally {
205
+ await driver.call("fixture.release", { key }, { signal: AbortSignal.timeout(options.deadline) }).catch(() => void 0);
206
+ }
169
207
  });
170
- await checks.run("a tool is removed while its capability is unavailable, and registered again after", `${RULE}#tools`, async () => {
171
- await driver.call("fixture.switch.set", { on: false });
172
- await eventually(async () => !(await names(driver)).includes("fixture.toggle"), "fixture.toggle must be removed");
173
- await driver.call("fixture.switch.set", { on: true });
174
- await eventually(async () => (await names(driver)).includes("fixture.toggle"), "fixture.toggle must be registered again");
208
+ await checks.run("a tool is removed while its capability is unavailable, and registered again after", `${RULE}#tools`, async (signal) => {
209
+ const toggle = (present) => (tools) => tools.includes("fixture.toggle") === present;
210
+ await driver.call("fixture.switch.set", { on: false }, { signal });
211
+ await until("fixture.toggle is removed", () => names(driver, signal), toggle(false), signal);
212
+ await driver.call("fixture.switch.set", { on: true }, { signal });
213
+ await until("fixture.toggle is registered again", () => names(driver, signal), toggle(true), signal);
175
214
  });
176
215
  return checks;
177
216
  }