@acid-cat/cli 0.9.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.
- package/README.md +21 -8
- package/conformance/fixture.acidcat.json +167 -1
- package/dist/catalog.js +40 -9
- package/dist/conform/check.d.ts +13 -1
- package/dist/conform/check.js +71 -16
- package/dist/conform/cli.js +131 -8
- package/dist/conform/http.js +207 -2
- package/dist/conform/index.d.ts +20 -0
- package/dist/conform/index.js +9 -2
- package/dist/conform/mcp-stdio.d.ts +2 -0
- package/dist/conform/mcp-stdio.js +68 -2
- package/dist/conform/mcp.d.ts +64 -8
- package/dist/conform/mcp.js +468 -7
- package/dist/conform/poll.d.ts +13 -0
- package/dist/conform/poll.js +289 -0
- package/dist/conform/properties.js +1 -1
- package/dist/conform/serve.js +4 -1
- package/dist/conform/sse.d.ts +15 -0
- package/dist/conform/sse.js +57 -0
- package/dist/conform/webhook.d.ts +28 -0
- package/dist/conform/webhook.js +475 -0
- package/dist/conform/webmcp.js +5 -0
- package/dist/index.d.ts +1 -1
- package/dist/json/catalog.schema.json.js +26 -2
- package/dist/json/fixture.acidcat.json.js +117 -1
- package/dist/json/package.json.js +1 -1
- package/dist/main.js +49 -2
- package/package.json +6 -6
package/dist/conform/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isArrayOf, isObject, memberOf, parseJson } from "../json.js";
|
|
2
|
-
import { Checks, expect, expectEqual, idsOf, sorted, until } from "./check.js";
|
|
2
|
+
import { Checks, eventIdsOf, expect, expectEqual, expectOccurrence, idsOf, sorted, until } from "./check.js";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
@@ -19,7 +19,15 @@ const USAGES = {
|
|
|
19
19
|
"fixture.refuse": "fixture refuse",
|
|
20
20
|
"fixture.release": "fixture release --key <string>",
|
|
21
21
|
"fixture.switch.set": "fixture switch set --on",
|
|
22
|
-
"fixture.toggle": "fixture toggle"
|
|
22
|
+
"fixture.toggle": "fixture toggle",
|
|
23
|
+
"fixture.signal": "fixture signal --key <string> --value <string>",
|
|
24
|
+
"fixture.watching": "fixture watching"
|
|
25
|
+
};
|
|
26
|
+
/** The `usage` of each event in the view of the fixture, after the name of the program. */
|
|
27
|
+
const EVENT_USAGES = {
|
|
28
|
+
"fixture.broken": "watch fixture.broken [--cursor <string>] [--max-age-ms <integer>]",
|
|
29
|
+
"fixture.ticks": "watch fixture.ticks --count <integer> [--gap <integer>] [--key <string>] [--cursor <string>] [--max-age-ms <integer>]",
|
|
30
|
+
"fixture.watch": "watch fixture.watch --key <string> [--marker <string>] [--cursor <string>] [--max-age-ms <integer>]"
|
|
23
31
|
};
|
|
24
32
|
const isEntry = (value) => isObject(value) && typeof value.id === "string";
|
|
25
33
|
/** The list that the `list` command writes to standard output. The check fails when it is not a list of entries. */
|
|
@@ -229,20 +237,135 @@ async function checkCli(command, options) {
|
|
|
229
237
|
expectEqual(result.code, 0, "exit code");
|
|
230
238
|
expectEqual(parseJson(result.stdout), { text: "hello" }, "standard output");
|
|
231
239
|
});
|
|
232
|
-
await checks.run("list gives the view", `${RULE}#commands`, async (signal) => {
|
|
233
|
-
const
|
|
234
|
-
|
|
240
|
+
await checks.run("list gives the view: the capabilities, then the events", `${RULE}#commands`, async (signal) => {
|
|
241
|
+
const entries = entriesOf((await run(signal, "list")).stdout);
|
|
242
|
+
const ids = (kind) => entries.filter((entry) => entry.kind === kind).map((entry) => entry.id);
|
|
243
|
+
expectEqual(sorted(ids("capability")), idsOf("cli"), "the ids of the items with kind: capability");
|
|
244
|
+
expectEqual(sorted(ids("event")), eventIdsOf("cli"), "the ids of the items with kind: event");
|
|
245
|
+
expectEqual(entries.length, ids("capability").length + ids("event").length, "the number of items with a kind");
|
|
246
|
+
const firstEvent = entries.findIndex((entry) => entry.kind === "event");
|
|
247
|
+
expect(firstEvent === -1 || entries.slice(firstEvent).every((entry) => entry.kind === "event"), "the events must come after the capabilities");
|
|
235
248
|
});
|
|
236
|
-
await checks.run("list gives the usage of each capability, with its flags", `${RULE}#commands`, async (signal) => {
|
|
249
|
+
await checks.run("list gives the usage of each capability and each event, with its flags", `${RULE}#commands`, async (signal) => {
|
|
237
250
|
const entries = entriesOf((await run(signal, "list")).stdout);
|
|
238
|
-
for (const { id, usage } of entries) {
|
|
251
|
+
for (const { id, kind, usage } of entries) {
|
|
239
252
|
expect(typeof usage === "string", `${id} must have a usage`);
|
|
240
253
|
const [program, ...rest] = usage.split(" ");
|
|
241
254
|
expect(program !== "" && rest.length > 0, `the usage of ${id} must start with the name of the program, got ${JSON.stringify(usage)}`);
|
|
242
|
-
expectEqual(rest.join(" "), USAGES[id], `usage of ${id} after the name of the program`);
|
|
255
|
+
expectEqual(rest.join(" "), kind === "event" ? EVENT_USAGES[id] : USAGES[id], `usage of ${id} after the name of the program`);
|
|
243
256
|
}
|
|
244
257
|
});
|
|
258
|
+
await checkWatch(checks, command);
|
|
245
259
|
return checks;
|
|
246
260
|
}
|
|
261
|
+
const EVENTS = `${RULE}#events`;
|
|
262
|
+
/** The checks of `watch` (spec/bindings/cli.md#events). */
|
|
263
|
+
async function checkWatch(checks, command) {
|
|
264
|
+
const run = (signal, ...args) => start(command, args, signal).run;
|
|
265
|
+
/** The lines of standard output, each one an occurrence of the event. */
|
|
266
|
+
const occurrences = (stdout, id) => stdout.split("\n").filter((line) => line !== "").map((line) => expectOccurrence(parseJson(line), id));
|
|
267
|
+
await checks.run("watch writes each occurrence as one line of JSON, and exits with 0 when the subscription is complete", EVENTS, async (signal) => {
|
|
268
|
+
const result = await run(signal, "watch", "fixture.ticks", "--count", "2");
|
|
269
|
+
expectEqual(result.code, 0, "exit code");
|
|
270
|
+
expectEqual(occurrences(result.stdout, "fixture.ticks").map((occurrence) => occurrence.data), [{ n: 1 }, { n: 2 }], "the data of the occurrences");
|
|
271
|
+
expectEqual(result.stderr, "", "standard error");
|
|
272
|
+
});
|
|
273
|
+
await checks.run("watch with --cursor starts after the cursor", EVENTS, async (signal) => {
|
|
274
|
+
const result = await run(signal, "watch", "fixture.ticks", "--count", "3", "--cursor", "1", "--max-age-ms", "60000");
|
|
275
|
+
expectEqual(result.code, 0, "exit code");
|
|
276
|
+
expectEqual(occurrences(result.stdout, "fixture.ticks").map((occurrence) => [occurrence.data, occurrence.cursor]), [[{ n: 2 }, "2"], [{ n: 3 }, "3"]], "the data and the cursor of the occurrences");
|
|
277
|
+
});
|
|
278
|
+
await checks.run("watch writes an occurrence after a gap with truncated: true", EVENTS, async (signal) => {
|
|
279
|
+
const result = await run(signal, "watch", "fixture.ticks", "--count", "3", "--gap", "2");
|
|
280
|
+
expectEqual(result.code, 0, "exit code");
|
|
281
|
+
expectEqual(occurrences(result.stdout, "fixture.ticks").map((occurrence) => [occurrence.data, occurrence.truncated ?? null]), [
|
|
282
|
+
[{ n: 1 }, null],
|
|
283
|
+
[{ n: 2 }, true],
|
|
284
|
+
[{ n: 3 }, null]
|
|
285
|
+
], "the data and truncated of the occurrences");
|
|
286
|
+
});
|
|
287
|
+
await checks.run("watch writes internal to standard error with exit code 5 after a defect", EVENTS, async (signal) => {
|
|
288
|
+
const result = await run(signal, "watch", "fixture.broken");
|
|
289
|
+
expectEqual(result.code, 5, "exit code");
|
|
290
|
+
expectEqual(occurrences(result.stdout, "fixture.broken").map((occurrence) => occurrence.data), [{ n: 1 }], "the data of the occurrences");
|
|
291
|
+
expect(/"kind":"internal"/.test(result.stderr), `standard error must contain the call failure internal, got ${JSON.stringify(result.stderr)}`);
|
|
292
|
+
});
|
|
293
|
+
for (const [name, args, code] of [
|
|
294
|
+
[
|
|
295
|
+
"watch with invalid input has exit code 3",
|
|
296
|
+
[
|
|
297
|
+
"watch",
|
|
298
|
+
"fixture.ticks",
|
|
299
|
+
"--count",
|
|
300
|
+
"101"
|
|
301
|
+
],
|
|
302
|
+
3
|
|
303
|
+
],
|
|
304
|
+
[
|
|
305
|
+
"watch with a missing required flag has exit code 3",
|
|
306
|
+
["watch", "fixture.ticks"],
|
|
307
|
+
3
|
|
308
|
+
],
|
|
309
|
+
[
|
|
310
|
+
"watch of an event outside the view has exit code 2",
|
|
311
|
+
["watch", "fixture.secret"],
|
|
312
|
+
2
|
|
313
|
+
],
|
|
314
|
+
[
|
|
315
|
+
"watch with a --max-age-ms that is not an integer has exit code 3",
|
|
316
|
+
[
|
|
317
|
+
"watch",
|
|
318
|
+
"fixture.ticks",
|
|
319
|
+
"--count",
|
|
320
|
+
"1",
|
|
321
|
+
"--max-age-ms",
|
|
322
|
+
"soon"
|
|
323
|
+
],
|
|
324
|
+
3
|
|
325
|
+
],
|
|
326
|
+
[
|
|
327
|
+
"watch with a --max-age-ms that is a fraction has exit code 3",
|
|
328
|
+
[
|
|
329
|
+
"watch",
|
|
330
|
+
"fixture.ticks",
|
|
331
|
+
"--count",
|
|
332
|
+
"1",
|
|
333
|
+
"--max-age-ms",
|
|
334
|
+
"1.5"
|
|
335
|
+
],
|
|
336
|
+
3
|
|
337
|
+
]
|
|
338
|
+
]) await checks.run(name, EVENTS, async (signal) => {
|
|
339
|
+
expectEqual((await run(signal, ...args)).code, code, "exit code");
|
|
340
|
+
});
|
|
341
|
+
await checks.run("an interrupt cancels watch with exit code 130", EVENTS, async (signal) => {
|
|
342
|
+
const directory = await mkdtemp(join(tmpdir(), "acid-cat-conform-"));
|
|
343
|
+
const marker = join(directory, "watching");
|
|
344
|
+
try {
|
|
345
|
+
const { child, run: watched } = start(command, [
|
|
346
|
+
"watch",
|
|
347
|
+
"fixture.watch",
|
|
348
|
+
"--key",
|
|
349
|
+
"interrupt",
|
|
350
|
+
"--marker",
|
|
351
|
+
marker
|
|
352
|
+
], signal);
|
|
353
|
+
const ended = () => child.exitCode !== null || child.signalCode !== null;
|
|
354
|
+
await until("the subscription writes its marker file", async () => existsSync(marker) || ended(), (ready) => ready, signal);
|
|
355
|
+
if (!ended()) child.kill("SIGINT");
|
|
356
|
+
const result = await watched;
|
|
357
|
+
expect(existsSync(marker), `the subscription must start before the interrupt, but the command line ended with ${result.code}: ${result.stderr}`);
|
|
358
|
+
expect(result.signal === null, `the process must exit, not end by the signal ${result.signal}`);
|
|
359
|
+
expectEqual(result.code, 130, "exit code");
|
|
360
|
+
expectEqual(result.stdout, "", "standard output");
|
|
361
|
+
expectEqual(result.stderr, "", "standard error");
|
|
362
|
+
} finally {
|
|
363
|
+
await rm(directory, {
|
|
364
|
+
recursive: true,
|
|
365
|
+
force: true
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
}
|
|
247
370
|
//#endregion
|
|
248
371
|
export { checkCli };
|
package/dist/conform/http.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { isArrayOf, isObject, memberOf, readJson } from "../json.js";
|
|
2
|
-
import { Checks, FORBIDDEN_ORIGIN, expect, expectEqual, idsOf, sorted } from "./check.js";
|
|
1
|
+
import { isArrayOf, isObject, isStringArray, memberOf, parseJson, readJson } from "../json.js";
|
|
2
|
+
import { Checks, FORBIDDEN_ORIGIN, eventIdsOf, expect, expectEqual, expectOccurrence, idsOf, sorted, until } from "./check.js";
|
|
3
|
+
import { sseReader } from "./sse.js";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
3
5
|
//#region src/conform/http.ts
|
|
4
6
|
/** Conformance checks for the HTTP binding (spec/bindings/http.md). */
|
|
5
7
|
const RULE = "spec/bindings/http.md";
|
|
@@ -103,7 +105,210 @@ async function checkHttp(base, options) {
|
|
|
103
105
|
125
|
|
104
106
|
])), 400);
|
|
105
107
|
});
|
|
108
|
+
await checkEvents(checks, base);
|
|
106
109
|
return checks;
|
|
107
110
|
}
|
|
111
|
+
const EVENTS = `${RULE}#events`;
|
|
112
|
+
/** The checks of the events (spec/bindings/http.md#events). */
|
|
113
|
+
async function checkEvents(checks, base) {
|
|
114
|
+
const subscribe = (signal, id, input, headers = {}) => fetch(`${base}/events/${id}`, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: {
|
|
117
|
+
"Content-Type": "application/json",
|
|
118
|
+
...headers
|
|
119
|
+
},
|
|
120
|
+
body: JSON.stringify(input),
|
|
121
|
+
signal
|
|
122
|
+
});
|
|
123
|
+
/** Subscribe, and check that the stream starts. `signal` and `close` both close the stream. */
|
|
124
|
+
const open = async (signal, id, input, headers = {}) => {
|
|
125
|
+
const controller = new AbortController();
|
|
126
|
+
const response = await subscribe(AbortSignal.any([signal, controller.signal]), id, input, headers);
|
|
127
|
+
if (response.status !== 200) {
|
|
128
|
+
controller.abort();
|
|
129
|
+
throw new Error(`POST /events/${id} must start a stream with the status 200, got ${response.status}`);
|
|
130
|
+
}
|
|
131
|
+
expect(response.headers.get("Content-Type")?.startsWith("text/event-stream") === true, `the media type must be text/event-stream, got ${response.headers.get("Content-Type")}`);
|
|
132
|
+
expectEqual(response.headers.get("Cache-Control"), "no-store", "Cache-Control");
|
|
133
|
+
return {
|
|
134
|
+
response,
|
|
135
|
+
reader: sseReader(response),
|
|
136
|
+
close: () => controller.abort()
|
|
137
|
+
};
|
|
138
|
+
};
|
|
139
|
+
/** The next message of a stream, which must be an occurrence of the event with the cursor as its SSE id. */
|
|
140
|
+
const nextOccurrence = async (stream, id) => {
|
|
141
|
+
const message = await stream.reader.next();
|
|
142
|
+
expect(message?.event === "next", `expected event: next, got ${JSON.stringify(message)}`);
|
|
143
|
+
const occurrence = expectOccurrence(parseJson(message.data), id);
|
|
144
|
+
expectEqual(message.id, occurrence.cursor, "the SSE id of the message, which is the cursor of the occurrence");
|
|
145
|
+
return occurrence;
|
|
146
|
+
};
|
|
147
|
+
const expectComplete = async (stream) => {
|
|
148
|
+
const message = await stream.reader.next();
|
|
149
|
+
expect(message?.event === "complete", `expected event: complete, got ${JSON.stringify(message)}`);
|
|
150
|
+
expectEqual(message.data, "", "the data of event: complete");
|
|
151
|
+
expectEqual(await stream.reader.next(), void 0, "the stream after event: complete");
|
|
152
|
+
};
|
|
153
|
+
const watching = async (signal) => {
|
|
154
|
+
const response = await fetch(`${base}/capabilities/fixture.watching`, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: { "Content-Type": "application/json" },
|
|
157
|
+
body: "{}",
|
|
158
|
+
signal
|
|
159
|
+
});
|
|
160
|
+
const keys = memberOf(memberOf(await readJson(response), "value"), "keys");
|
|
161
|
+
expect(isStringArray(keys), `fixture.watching must give a list of keys, got ${JSON.stringify(keys)}`);
|
|
162
|
+
return keys;
|
|
163
|
+
};
|
|
164
|
+
const count = (keys, key) => keys.filter((each) => each === key).length;
|
|
165
|
+
const signalKey = async (signal, key, value) => {
|
|
166
|
+
const response = await fetch(`${base}/capabilities/fixture.signal`, {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: { "Content-Type": "application/json" },
|
|
169
|
+
body: JSON.stringify({
|
|
170
|
+
key,
|
|
171
|
+
value
|
|
172
|
+
}),
|
|
173
|
+
signal
|
|
174
|
+
});
|
|
175
|
+
const delivered = memberOf(memberOf(await readJson(response), "value"), "delivered");
|
|
176
|
+
expect(typeof delivered === "number", `fixture.signal must give the number of subscriptions, got ${JSON.stringify(delivered)}`);
|
|
177
|
+
return delivered;
|
|
178
|
+
};
|
|
179
|
+
await checks.run("GET /events lists the events of the view", `${RULE}#requests`, async (signal) => {
|
|
180
|
+
const list = await readJson(await fetch(`${base}/events`, { signal }));
|
|
181
|
+
if (!isArrayOf(list, isEntry)) throw new Error(`GET /events must give a list of objects with an id, got ${JSON.stringify(list)}`);
|
|
182
|
+
expectEqual(sorted(list.map((entry) => entry.id)), eventIdsOf("api"), "ids");
|
|
183
|
+
});
|
|
184
|
+
for (const [name, id, input, status, kind] of [
|
|
185
|
+
[
|
|
186
|
+
"a subscription to an event outside the view has the status 404",
|
|
187
|
+
"fixture.secret",
|
|
188
|
+
{},
|
|
189
|
+
404,
|
|
190
|
+
"unknown-capability"
|
|
191
|
+
],
|
|
192
|
+
[
|
|
193
|
+
"a subscription to the id of a capability has the status 404",
|
|
194
|
+
"fixture.echo",
|
|
195
|
+
{ text: "x" },
|
|
196
|
+
404,
|
|
197
|
+
"unknown-capability"
|
|
198
|
+
],
|
|
199
|
+
[
|
|
200
|
+
"a subscription with invalid input has the status 422",
|
|
201
|
+
"fixture.ticks",
|
|
202
|
+
{ count: 101 },
|
|
203
|
+
422,
|
|
204
|
+
"invalid-input"
|
|
205
|
+
]
|
|
206
|
+
]) await checks.run(name, EVENTS, async (signal) => {
|
|
207
|
+
const response = await subscribe(signal, id, input);
|
|
208
|
+
expectEqual(response.status, status, "status");
|
|
209
|
+
const body = await readJson(response);
|
|
210
|
+
expectEqual(memberOf(body, "kind"), kind, "kind");
|
|
211
|
+
expect(typeof memberOf(body, "message") === "string", `the call failure must have a message, got ${JSON.stringify(body)}`);
|
|
212
|
+
});
|
|
213
|
+
await checks.run("a subscription gives each occurrence as event: next, in order, then event: complete", EVENTS, async (signal) => {
|
|
214
|
+
const stream = await open(signal, "fixture.ticks", { count: 3 });
|
|
215
|
+
try {
|
|
216
|
+
for (const n of [
|
|
217
|
+
1,
|
|
218
|
+
2,
|
|
219
|
+
3
|
|
220
|
+
]) {
|
|
221
|
+
const occurrence = await nextOccurrence(stream, "fixture.ticks");
|
|
222
|
+
expectEqual(occurrence.data, { n }, "data");
|
|
223
|
+
expectEqual([occurrence.eventId, occurrence.cursor], [String(n), String(n)], "the eventId and the cursor");
|
|
224
|
+
}
|
|
225
|
+
await expectComplete(stream);
|
|
226
|
+
} finally {
|
|
227
|
+
stream.close();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
await checks.run("an occurrence after a gap has truncated: true, and the other occurrences have no truncated", EVENTS, async (signal) => {
|
|
231
|
+
const stream = await open(signal, "fixture.ticks", {
|
|
232
|
+
count: 3,
|
|
233
|
+
gap: 2
|
|
234
|
+
});
|
|
235
|
+
try {
|
|
236
|
+
for (const n of [
|
|
237
|
+
1,
|
|
238
|
+
2,
|
|
239
|
+
3
|
|
240
|
+
]) {
|
|
241
|
+
const occurrence = await nextOccurrence(stream, "fixture.ticks");
|
|
242
|
+
expectEqual([occurrence.data, occurrence.truncated], [{ n }, n === 2 ? true : void 0], "the data and truncated");
|
|
243
|
+
}
|
|
244
|
+
await expectComplete(stream);
|
|
245
|
+
} finally {
|
|
246
|
+
stream.close();
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
await checks.run("Last-Event-ID gives the cursor, and the subscription starts after it", EVENTS, async (signal) => {
|
|
250
|
+
const stream = await open(signal, "fixture.ticks", { count: 3 }, { "Last-Event-ID": "1" });
|
|
251
|
+
try {
|
|
252
|
+
for (const n of [2, 3]) expectEqual((await nextOccurrence(stream, "fixture.ticks")).data, { n }, "data");
|
|
253
|
+
await expectComplete(stream);
|
|
254
|
+
} finally {
|
|
255
|
+
stream.close();
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
await checks.run("a defect after an occurrence ends the stream with event: error and internal", EVENTS, async (signal) => {
|
|
259
|
+
const stream = await open(signal, "fixture.broken", {});
|
|
260
|
+
try {
|
|
261
|
+
expectEqual((await nextOccurrence(stream, "fixture.broken")).data, { n: 1 }, "data");
|
|
262
|
+
const message = await stream.reader.next();
|
|
263
|
+
expect(message?.event === "error", `expected event: error, got ${JSON.stringify(message)}`);
|
|
264
|
+
const failure = parseJson(message.data);
|
|
265
|
+
expect(memberOf(failure, "kind") === "internal" && typeof memberOf(failure, "message") === "string", `expected { kind: "internal", message }, got ${message.data}`);
|
|
266
|
+
expect(!/\n\s+at /.test(String(memberOf(failure, "message"))), "the message must not contain a stack trace");
|
|
267
|
+
expectEqual(await stream.reader.next(), void 0, "the stream after event: error");
|
|
268
|
+
} finally {
|
|
269
|
+
stream.close();
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
await checks.run("a subscription gets the things that happen, and closing the stream cancels it", EVENTS, async (signal) => {
|
|
273
|
+
const key = randomUUID();
|
|
274
|
+
const pending = open(signal, "fixture.watch", { key });
|
|
275
|
+
pending.catch(() => void 0);
|
|
276
|
+
let stream;
|
|
277
|
+
try {
|
|
278
|
+
await until("fixture.watching gives the key", () => watching(signal), (keys) => keys.includes(key), signal);
|
|
279
|
+
stream = await pending;
|
|
280
|
+
expectEqual(await signalKey(signal, key, "first"), 1, "the subscriptions that fixture.signal reached");
|
|
281
|
+
expectEqual((await nextOccurrence(stream, "fixture.watch")).data, {
|
|
282
|
+
key,
|
|
283
|
+
value: "first"
|
|
284
|
+
}, "data");
|
|
285
|
+
stream.close();
|
|
286
|
+
await until("the key leaves fixture.watching", () => watching(signal), (keys) => !keys.includes(key), signal);
|
|
287
|
+
} finally {
|
|
288
|
+
stream?.close();
|
|
289
|
+
pending.then((opened) => opened.close(), () => void 0);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
await checks.run("cancelling one subscription leaves the other subscriptions running", EVENTS, async (signal) => {
|
|
293
|
+
const key = randomUUID();
|
|
294
|
+
const first = open(signal, "fixture.watch", { key });
|
|
295
|
+
const second = open(signal, "fixture.watch", { key });
|
|
296
|
+
first.catch(() => void 0);
|
|
297
|
+
second.catch(() => void 0);
|
|
298
|
+
try {
|
|
299
|
+
await until("fixture.watching gives the key of both subscriptions", () => watching(signal), (keys) => count(keys, key) === 2, signal);
|
|
300
|
+
const [cancelled, kept] = [await first, await second];
|
|
301
|
+
cancelled.close();
|
|
302
|
+
await until("the cancelled subscription leaves fixture.watching", () => watching(signal), (keys) => count(keys, key) === 1, signal);
|
|
303
|
+
expectEqual(await signalKey(signal, key, "after"), 1, "the subscriptions that fixture.signal reached");
|
|
304
|
+
expectEqual((await nextOccurrence(kept, "fixture.watch")).data, {
|
|
305
|
+
key,
|
|
306
|
+
value: "after"
|
|
307
|
+
}, "data");
|
|
308
|
+
} finally {
|
|
309
|
+
for (const stream of [first, second]) stream.then((opened) => opened.close(), () => void 0);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
}
|
|
108
313
|
//#endregion
|
|
109
314
|
export { checkHttp };
|
package/dist/conform/index.d.ts
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import type { Check } from "./check.js";
|
|
2
2
|
import type { WebMcpInstall } from "./webmcp.js";
|
|
3
|
+
import type { PollSettings } from "./poll.js";
|
|
4
|
+
import type { WebhookSettings } from "./webhook.js";
|
|
3
5
|
export type { Check } from "./check.js";
|
|
4
6
|
export { DEADLINE, fixture } from "./check.js";
|
|
5
7
|
export { serve } from "./serve.js";
|
|
6
8
|
export type { Routes, Served, WebHandler } from "./serve.js";
|
|
7
9
|
export { createTestHost } from "./webmcp.js";
|
|
8
10
|
export type { TestHost, WebMcpInstall } from "./webmcp.js";
|
|
11
|
+
export type { PollSettings } from "./poll.js";
|
|
12
|
+
export type { WebhookSettings } from "./webhook.js";
|
|
9
13
|
export interface ConformOptions {
|
|
10
14
|
/** The URL of the MCP server, for example `http://localhost:8787/mcp`. */
|
|
11
15
|
readonly mcp?: string | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Check the `initialize` handshake of revision 2025-11-25 through the MCP server too
|
|
18
|
+
* (spec/bindings/mcp.md#earlier-revisions-through-http). The default is `false`.
|
|
19
|
+
*/
|
|
20
|
+
readonly mcpHandshake?: boolean | undefined;
|
|
12
21
|
/**
|
|
13
22
|
* The command that starts the MCP server through standard input and standard output, for example
|
|
14
23
|
* `["node", "fixture-mcp-stdio.ts"]`. Each check starts its own process.
|
|
@@ -23,6 +32,17 @@ export interface ConformOptions {
|
|
|
23
32
|
* on a host in the same process. A page runs in Chromium, through Playwright.
|
|
24
33
|
*/
|
|
25
34
|
readonly webmcp?: string | WebMcpInstall | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* The poll settings of the deployment, for MCP through HTTP and through stdio. With `bufferLimit`, conform tests a
|
|
37
|
+
* cursor older than the buffer. With `leaseMs`, it tests that a lease with no poll ends.
|
|
38
|
+
*/
|
|
39
|
+
readonly poll?: PollSettings | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* Test webhook delivery through `mcp`. conform runs an HTTPS receiver on 127.0.0.1 with this certificate and key. The
|
|
42
|
+
* deployment trusts the certificate, allows deliveries to 127.0.0.1, and gives the principal to each request with the
|
|
43
|
+
* header `Authorization: Bearer <principal>`.
|
|
44
|
+
*/
|
|
45
|
+
readonly webhook?: WebhookSettings | undefined;
|
|
26
46
|
/** The body limit of the remote bindings, in bytes. The default is 1 MiB. */
|
|
27
47
|
readonly maximumBytes?: number | undefined;
|
|
28
48
|
/**
|
package/dist/conform/index.js
CHANGED
|
@@ -15,11 +15,15 @@ import { serve } from "./serve.js";
|
|
|
15
15
|
async function conform(options) {
|
|
16
16
|
const maximumBytes = options.maximumBytes ?? 1048576;
|
|
17
17
|
const deadline = options.deadline ?? 3e4;
|
|
18
|
+
const poll = options.poll ?? {};
|
|
18
19
|
const results = [];
|
|
19
20
|
if (options.mcp !== void 0) {
|
|
20
21
|
results.push(...(await checkMcp(options.mcp, {
|
|
21
22
|
maximumBytes,
|
|
22
|
-
deadline
|
|
23
|
+
deadline,
|
|
24
|
+
handshake: options.mcpHandshake === true,
|
|
25
|
+
poll,
|
|
26
|
+
webhook: options.webhook
|
|
23
27
|
})).results);
|
|
24
28
|
const mcp = await mcpInvoke(options.mcp, AbortSignal.timeout(deadline)).catch(() => void 0);
|
|
25
29
|
if (mcp !== void 0) {
|
|
@@ -27,7 +31,10 @@ async function conform(options) {
|
|
|
27
31
|
await mcp.close();
|
|
28
32
|
}
|
|
29
33
|
}
|
|
30
|
-
if (options.mcpStdio !== void 0) for (const checks of await checkMcpStdio(options.mcpStdio, {
|
|
34
|
+
if (options.mcpStdio !== void 0) for (const checks of await checkMcpStdio(options.mcpStdio, {
|
|
35
|
+
deadline,
|
|
36
|
+
poll
|
|
37
|
+
})) results.push(...checks.results);
|
|
31
38
|
if (options.http !== void 0) {
|
|
32
39
|
results.push(...(await checkHttp(options.http, {
|
|
33
40
|
maximumBytes,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { isObject, memberOf } from "../json.js";
|
|
2
2
|
import { Checks, abortable, expect, expectEqual, until } from "./check.js";
|
|
3
|
-
import { checkTools } from "./mcp.js";
|
|
3
|
+
import { META, checkEvents, checkTools, subscriptionOf } from "./mcp.js";
|
|
4
4
|
import { checkProperties, clientInvoke, heldKeys } from "./properties.js";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
-
import { Client } from "@modelcontextprotocol/client";
|
|
7
6
|
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { Client } from "@modelcontextprotocol/client";
|
|
8
8
|
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
|
9
9
|
//#region src/conform/mcp-stdio.ts
|
|
10
10
|
/**
|
|
@@ -75,8 +75,74 @@ async function checkMcpStdio(command, options) {
|
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
all.push(await checkLines(command, options));
|
|
78
|
+
all.push(await checkStdioEvents(command, options));
|
|
78
79
|
return all;
|
|
79
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
|
+
}
|
|
80
146
|
/** The server tells the client each change of the set of available tools. */
|
|
81
147
|
async function checkChanges(checks, mcp, changes) {
|
|
82
148
|
const set = (on, signal) => mcp.callTool({
|
package/dist/conform/mcp.d.ts
CHANGED
|
@@ -1,17 +1,73 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Conformance checks for the MCP binding through HTTP (spec/bindings/mcp.md). The protocol checks use the official MCP
|
|
3
|
-
* client, 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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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>;
|
|
13
18
|
/**
|
|
14
19
|
* The checks of the tools and their outcomes, through a connected client (spec/bindings/mcp.md#tools). Each transport
|
|
15
20
|
* runs them.
|
|
16
21
|
*/
|
|
17
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>;
|