@acid-cat/cli 0.8.0 → 0.10.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.
@@ -0,0 +1,332 @@
1
+ import { isObject, memberOf } from "../json.js";
2
+ import { Checks, abortable, expect, expectEqual, until } from "./check.js";
3
+ import { META, checkEvents, checkTools, subscriptionOf } from "./mcp.js";
4
+ import { checkProperties, clientInvoke, heldKeys } from "./properties.js";
5
+ import { spawn } from "node:child_process";
6
+ import { randomUUID } from "node:crypto";
7
+ import { Client } from "@modelcontextprotocol/client";
8
+ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
9
+ //#region src/conform/mcp-stdio.ts
10
+ /**
11
+ * Conformance checks for the MCP binding through standard input and standard output (spec/bindings/mcp.md#stdio). Each
12
+ * check starts the server as a process. The protocol checks use the official MCP client, once with revision 2026-07-28
13
+ * and once with the `initialize` handshake of revision 2025-11-25. The other checks write lines that a client never
14
+ * writes, or end standard input during a call.
15
+ */
16
+ const RULE = "spec/bindings/mcp.md#stdio";
17
+ /** The two ways that a client opens the session: revision 2026-07-28, or the handshake of revision 2025-11-25. */
18
+ const ERAS = [{
19
+ revision: "2026-07-28",
20
+ connects: "an MCP client connects with revision 2026-07-28",
21
+ options: { versionNegotiation: { mode: { pin: "2026-07-28" } } }
22
+ }, {
23
+ revision: "2025-11-25",
24
+ connects: "an MCP client connects with the initialize handshake of revision 2025-11-25",
25
+ options: {}
26
+ }];
27
+ /** The environment of the process: the environment of `conform`, without the variables that have no value. */
28
+ function environment() {
29
+ return Object.fromEntries(Object.entries(process.env).flatMap(([name, value]) => value === void 0 ? [] : [[name, value]]));
30
+ }
31
+ /** Start the server and connect an MCP client. `changes` counts each `notifications/tools/list_changed`. */
32
+ async function connect(command, options, changes, signal) {
33
+ const [program, ...args] = command;
34
+ if (program === void 0) throw new Error("the command that starts the MCP server is empty");
35
+ const client = new Client({
36
+ name: "acid-cat-conform",
37
+ version: "0.1.0"
38
+ }, {
39
+ ...options,
40
+ listChanged: { tools: {
41
+ autoRefresh: false,
42
+ debounceMs: 0,
43
+ onChanged: () => void (changes.count += 1)
44
+ } }
45
+ });
46
+ const transport = new StdioClientTransport({
47
+ command: program,
48
+ args,
49
+ env: environment(),
50
+ stderr: "ignore"
51
+ });
52
+ await client.connect(transport, { signal });
53
+ return client;
54
+ }
55
+ async function checkMcpStdio(command, options) {
56
+ const all = [];
57
+ for (const era of ERAS) {
58
+ const checks = new Checks(`mcp-stdio ${era.revision}`, options.deadline);
59
+ all.push(checks);
60
+ const changes = { count: 0 };
61
+ let client;
62
+ await checks.run(era.connects, RULE, async (signal) => {
63
+ client = await connect(command, era.options, changes, signal);
64
+ expectEqual(client.getNegotiatedProtocolVersion(), era.revision, "the revision of the session");
65
+ });
66
+ if (client === void 0) continue;
67
+ const mcp = client;
68
+ try {
69
+ await checkTools(checks, mcp);
70
+ await checkChanges(checks, mcp, changes);
71
+ await checkCancellation(checks, mcp);
72
+ all.push(await checkProperties(`mcp-stdio ${era.revision} properties`, clientInvoke(mcp), options.deadline));
73
+ } finally {
74
+ await mcp.close();
75
+ }
76
+ }
77
+ all.push(await checkLines(command, options));
78
+ all.push(await checkStdioEvents(command, options));
79
+ return all;
80
+ }
81
+ /**
82
+ * The checks of the events extension through one server process. The requests have revision 2026-07-28 in `_meta`, with
83
+ * no handshake. `cancel` sends `notifications/cancelled` with the id of the `events/stream` request.
84
+ */
85
+ async function checkStdioEvents(command, options) {
86
+ const checks = new Checks("mcp-stdio events", options.deadline);
87
+ const controller = new AbortController();
88
+ const server = startRaw(command, controller.signal);
89
+ try {
90
+ await checkEvents(checks, stdioChannel(server), {
91
+ transport: "stdio",
92
+ poll: options.poll,
93
+ webhook: false
94
+ });
95
+ } finally {
96
+ controller.abort();
97
+ }
98
+ return checks;
99
+ }
100
+ /** A channel to a server process. Each wait stops when the signal of the check aborts. */
101
+ function stdioChannel(server) {
102
+ let next = 0;
103
+ const send = (id, method, params) => server.write(JSON.stringify({
104
+ jsonrpc: "2.0",
105
+ id,
106
+ method,
107
+ params: {
108
+ ...params,
109
+ _meta: META
110
+ }
111
+ }));
112
+ return {
113
+ async request(method, params, signal) {
114
+ const id = `conform-request-${next += 1}`;
115
+ send(id, method, params);
116
+ return until(`the response to ${method}`, async () => server.messages.find((message) => memberOf(message, "id") === id), isObject, signal);
117
+ },
118
+ async stream(id, params, signal) {
119
+ send(id, "events/stream", params);
120
+ const belongs = (message) => isObject(message) && (message.id === id || subscriptionOf(message) === id);
121
+ let position = 0;
122
+ let ended = false;
123
+ return {
124
+ async next() {
125
+ if (ended) return void 0;
126
+ const found = await until("the next message of the subscription", async () => server.messages.slice(position).findIndex(belongs), (index) => index >= 0, signal);
127
+ const message = server.messages[position + found];
128
+ position += found + 1;
129
+ if (!belongs(message)) throw new Error("the message of the subscription is not a JSON-RPC message");
130
+ if (message.id === id) ended = true;
131
+ return message;
132
+ },
133
+ async cancel() {
134
+ if (ended) return;
135
+ ended = true;
136
+ server.write(JSON.stringify({
137
+ jsonrpc: "2.0",
138
+ method: "notifications/cancelled",
139
+ params: { requestId: id }
140
+ }));
141
+ }
142
+ };
143
+ }
144
+ };
145
+ }
146
+ /** The server tells the client each change of the set of available tools. */
147
+ async function checkChanges(checks, mcp, changes) {
148
+ const set = (on, signal) => mcp.callTool({
149
+ name: "fixture.switch.set",
150
+ arguments: { on }
151
+ }, { signal });
152
+ const listsToggle = async (signal) => (await mcp.listTools(void 0, { signal })).tools.some((tool) => tool.name === "fixture.toggle");
153
+ await checks.run("notifications/tools/list_changed comes when a capability becomes unavailable or available", RULE, async (signal) => {
154
+ try {
155
+ const before = changes.count;
156
+ await set(false, signal);
157
+ await until("notifications/tools/list_changed comes", async () => changes.count, (count) => count > before, signal);
158
+ expect(!await listsToggle(signal), "fixture.toggle must not be listed while it is unavailable");
159
+ const off = changes.count;
160
+ await set(true, signal);
161
+ await until("notifications/tools/list_changed comes again", async () => changes.count, (count) => count > off, signal);
162
+ expect(await listsToggle(signal), "fixture.toggle must be listed when it is available again");
163
+ } finally {
164
+ await set(true, AbortSignal.timeout(checks.deadline));
165
+ }
166
+ });
167
+ }
168
+ /** `notifications/cancelled` cancels a call. The official client sends it when the signal of a call aborts. */
169
+ async function checkCancellation(checks, mcp) {
170
+ const invoke = clientInvoke(mcp);
171
+ await checks.run("notifications/cancelled cancels the call", RULE, async (signal) => {
172
+ const key = randomUUID();
173
+ const controller = new AbortController();
174
+ const call = invoke("fixture.hold", { key }, AbortSignal.any([controller.signal, signal])).catch(() => void 0);
175
+ try {
176
+ await until("fixture.held gives the key of the call", () => heldKeys(invoke, signal), (held) => held.includes(key), signal);
177
+ controller.abort();
178
+ await call;
179
+ await until("the cancelled call stops holding", () => heldKeys(invoke, signal), (held) => !held.includes(key), signal);
180
+ } finally {
181
+ await invoke("fixture.release", { key }, AbortSignal.timeout(checks.deadline)).catch(() => void 0);
182
+ }
183
+ });
184
+ }
185
+ /**
186
+ * A server process with its standard input and output, one JSON-RPC message on each line. `lines` holds each line of
187
+ * standard output. When the signal aborts, the process is killed.
188
+ */
189
+ function startRaw(command, signal) {
190
+ const [program, ...args] = command;
191
+ if (program === void 0) throw new Error("the command that starts the MCP server is empty");
192
+ const child = spawn(program, args, {
193
+ stdio: [
194
+ "pipe",
195
+ "pipe",
196
+ "ignore"
197
+ ],
198
+ signal,
199
+ killSignal: "SIGKILL"
200
+ });
201
+ const lines = [];
202
+ const messages = [];
203
+ let rest = "";
204
+ child.stdout.on("data", (chunk) => {
205
+ const parts = (rest + chunk.toString()).split("\n");
206
+ rest = parts.pop() ?? "";
207
+ for (const line of parts) {
208
+ lines.push(line);
209
+ try {
210
+ messages.push(JSON.parse(line));
211
+ } catch {
212
+ messages.push(void 0);
213
+ }
214
+ }
215
+ });
216
+ child.stdin.on("error", () => void 0);
217
+ const exited = new Promise((resolve, reject) => {
218
+ child.on("error", (error) => reject(signal.aborted ? /* @__PURE__ */ new Error("waiting until the MCP server exits") : error));
219
+ child.on("close", (code, exitSignal) => resolve({
220
+ code,
221
+ signal: exitSignal
222
+ }));
223
+ });
224
+ exited.catch(() => void 0);
225
+ let next = 0;
226
+ const write = (line) => child.stdin.write(`${line}\n`);
227
+ /** Send a request and wait for its response. */
228
+ const request = async (method, params = {}) => {
229
+ const id = `conform-${next += 1}`;
230
+ write(JSON.stringify({
231
+ jsonrpc: "2.0",
232
+ id,
233
+ method,
234
+ params
235
+ }));
236
+ return until(`the response to ${method}`, async () => messages.find((message) => memberOf(message, "id") === id), isObject, signal);
237
+ };
238
+ return {
239
+ child,
240
+ lines,
241
+ messages,
242
+ write,
243
+ request,
244
+ exited
245
+ };
246
+ }
247
+ /** The `initialize` handshake of revision 2025-11-25, through a raw process. */
248
+ async function handshake(server, revision = "2025-11-25") {
249
+ const response = await server.request("initialize", {
250
+ protocolVersion: revision,
251
+ capabilities: {},
252
+ clientInfo: {
253
+ name: "acid-cat-conform",
254
+ version: "0.1.0"
255
+ }
256
+ });
257
+ server.write(JSON.stringify({
258
+ jsonrpc: "2.0",
259
+ method: "notifications/initialized"
260
+ }));
261
+ return response;
262
+ }
263
+ async function checkLines(command, options) {
264
+ const checks = new Checks("mcp-stdio", options.deadline);
265
+ /** Run a body with a new server process, and kill the process at the end. */
266
+ const withServer = (body) => async (signal) => {
267
+ const controller = new AbortController();
268
+ const server = startRaw(command, AbortSignal.any([controller.signal, signal]));
269
+ try {
270
+ await body(server, signal);
271
+ } finally {
272
+ controller.abort();
273
+ }
274
+ };
275
+ await checks.run("initialize with a revision that the server does not serve gives a revision that it serves", RULE, withServer(async (server) => {
276
+ const response = await handshake(server, "1999-01-01");
277
+ const version = memberOf(memberOf(response, "result"), "protocolVersion");
278
+ expect(typeof version === "string" && version !== "1999-01-01", `expected another revision, got ${JSON.stringify(response)}`);
279
+ }));
280
+ await checks.run("a line that is not JSON gets the error -32700, and the session stays open", RULE, withServer(async (server, signal) => {
281
+ await handshake(server);
282
+ server.write("{");
283
+ const parseError = await until("the error -32700", async () => server.messages.find((message) => memberOf(memberOf(message, "error"), "code") === -32700), isObject, signal);
284
+ expectEqual(parseError["id"], null, "the id of the error");
285
+ const echo = await server.request("tools/call", {
286
+ name: "fixture.echo",
287
+ arguments: { text: "after" }
288
+ });
289
+ expectEqual(memberOf(memberOf(echo, "result"), "structuredContent"), { text: "after" }, "structuredContent after the error");
290
+ }));
291
+ await checks.run("standard output has only JSON-RPC messages, also after a defect", RULE, withServer(async (server) => {
292
+ await handshake(server);
293
+ await server.request("tools/list");
294
+ await server.request("tools/call", {
295
+ name: "fixture.crash",
296
+ arguments: {}
297
+ });
298
+ await server.request("tools/call", {
299
+ name: "fixture.echo",
300
+ arguments: { text: "last" }
301
+ });
302
+ const other = server.messages.findIndex((message) => memberOf(message, "jsonrpc") !== "2.0");
303
+ expect(other === -1, `standard output must have only JSON-RPC messages, got the line ${JSON.stringify(server.lines[other])}`);
304
+ }));
305
+ await checks.run("the server exits when standard input ends, also during a call", RULE, withServer(async (server, signal) => {
306
+ await handshake(server);
307
+ const key = randomUUID();
308
+ server.write(JSON.stringify({
309
+ jsonrpc: "2.0",
310
+ id: "conform-hold",
311
+ method: "tools/call",
312
+ params: {
313
+ name: "fixture.hold",
314
+ arguments: { key }
315
+ }
316
+ }));
317
+ const held = async () => {
318
+ const response = await server.request("tools/call", {
319
+ name: "fixture.held",
320
+ arguments: {}
321
+ });
322
+ return memberOf(memberOf(memberOf(response, "result"), "structuredContent"), "keys");
323
+ };
324
+ await until("fixture.held gives the key of the call", held, (keys) => Array.isArray(keys) && keys.includes(key), signal);
325
+ server.child.stdin.end();
326
+ const exit = await abortable(server.exited, signal);
327
+ expect(exit.signal === null, `the process must exit, not end by the signal ${exit.signal}`);
328
+ }));
329
+ return checks;
330
+ }
331
+ //#endregion
332
+ export { checkMcpStdio };
@@ -1,12 +1,73 @@
1
- /**
2
- * Conformance checks for the MCP binding (spec/bindings/mcp.md). The protocol checks use the official MCP client,
3
- * with revision 2026-07-28. The rejection checks send requests that a client never sends.
4
- */
5
1
  import { Client } from "@modelcontextprotocol/client";
2
+ import type { JsonObject } from "../json.js";
6
3
  import { Checks } from "./check.js";
7
- /** A connected MCP client. */
8
- export declare function mcpClient(url: string, signal?: AbortSignal): Promise<Client>;
9
- export declare function checkMcp(url: string, options: {
4
+ import type { PollSettings } from "./poll.js";
5
+ import type { WebhookSettings } from "./webhook.js";
6
+ /** A connected MCP client. With `handshake`, the client opens the session with `initialize`. */
7
+ export declare function mcpClient(url: string, signal?: AbortSignal, handshake?: boolean): Promise<Client>;
8
+ export interface McpCheckOptions {
10
9
  readonly maximumBytes: number;
11
10
  readonly deadline: number;
12
- }): Promise<Checks>;
11
+ readonly handshake: boolean;
12
+ /** The poll settings of the deployment, for the checks of the buffer and of the lease time. */
13
+ readonly poll: PollSettings;
14
+ /** The settings of the checks of webhook delivery. With none, conform does not test webhook delivery. */
15
+ readonly webhook: WebhookSettings | undefined;
16
+ }
17
+ export declare function checkMcp(url: string, options: McpCheckOptions): Promise<Checks>;
18
+ /**
19
+ * The checks of the tools and their outcomes, through a connected client (spec/bindings/mcp.md#tools). Each transport
20
+ * runs them.
21
+ */
22
+ export declare function checkTools(checks: Checks, mcp: Client): Promise<void>;
23
+ /** The revision of the protocol that serves the events extension. */
24
+ export declare const EVENTS_REVISION = "2026-07-28";
25
+ /** The `_meta` of each request of revision 2026-07-28: the revision, and the capabilities of the client, which has none. */
26
+ export declare const META: {
27
+ "io.modelcontextprotocol/protocolVersion": string;
28
+ "io.modelcontextprotocol/clientCapabilities": {};
29
+ };
30
+ /** A JSON-RPC message from the server. */
31
+ export type Message = JsonObject;
32
+ /**
33
+ * The messages of one `events/stream` request: the notifications of the subscription and its response. `next` gives
34
+ * the next message, or `undefined` at the end. `cancel` cancels the subscription, as the transport does.
35
+ */
36
+ export interface EventStream {
37
+ next(): Promise<Message | undefined>;
38
+ cancel(): Promise<void>;
39
+ }
40
+ /** A way to send requests of revision 2026-07-28 to a server: through HTTP, or through standard input and output. */
41
+ export interface Channel {
42
+ request(method: string, params: JsonObject, signal: AbortSignal): Promise<Message>;
43
+ /** Start `events/stream` with this request id. */
44
+ stream(id: string, params: JsonObject, signal: AbortSignal): Promise<EventStream>;
45
+ }
46
+ /** A channel to a server through HTTP. Each request has the `headers` too, for example the credentials of a principal. */
47
+ export declare function httpChannel(url: string, headers?: Readonly<Record<string, string>>): Channel;
48
+ /** The subscription id in the `_meta` of the params of a notification, or of a result. */
49
+ export declare const subscriptionOf: (message: Message) => unknown;
50
+ /** How a server serves the events extension, as `checkEvents` tests it. */
51
+ export interface EventsOptions {
52
+ /** The transport of the channel. Through stdio, the server never serves webhook delivery. */
53
+ readonly transport: "http" | "stdio";
54
+ /** The poll settings of the deployment. */
55
+ readonly poll: PollSettings;
56
+ /** `true` if conform tests webhook delivery through this server, so the server must serve it. */
57
+ readonly webhook: boolean;
58
+ }
59
+ /** The requests that the checks of events send through a channel, and the capabilities of the fixture that they call. */
60
+ export declare function eventRequests(channel: Channel): {
61
+ resultOf: (method: string, params: JsonObject, signal: AbortSignal) => Promise<JsonObject>;
62
+ errorOf: (message: Message | undefined) => JsonObject;
63
+ tool: (name: string, args: JsonObject, signal: AbortSignal) => Promise<unknown>;
64
+ watching: (signal: AbortSignal) => Promise<readonly string[]>;
65
+ signalKey: (key: string, value: string, signal: AbortSignal) => Promise<unknown>;
66
+ };
67
+ /** The number of times that a key is in a list of keys. */
68
+ export declare const count: (keys: readonly string[], key: string) => number;
69
+ /**
70
+ * The checks of the events extension (spec/bindings/mcp.md#events), through a channel. Each transport runs them. Through
71
+ * stdio, `cancel` sends `notifications/cancelled`; through HTTP, it closes the request.
72
+ */
73
+ export declare function checkEvents(checks: Checks, channel: Channel, options: EventsOptions): Promise<void>;