@acid-cat/cli 0.0.0-stage → 0.1.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,1018 @@
1
+ // src/catalog.ts
2
+ import { canonicalText } from "@acid-cat/catalog";
3
+ import { bundle, REFERENCE, schemasOf, viewOf, visitObjects } from "@acid-cat/spec-ts";
4
+ import { Result, Schema, SchemaIssue, SchemaRepresentation } from "effect";
5
+
6
+ // ../../schemas/catalog.schema.json
7
+ var catalog_schema_default = {
8
+ $schema: "https://json-schema.org/draft/2020-12/schema",
9
+ $id: "https://acid-cat.dev/schemas/0.1/catalog.schema.json",
10
+ title: "AcidCat catalog",
11
+ description: "One document that states what an application can do and which targets see it. See spec/catalog.md.",
12
+ type: "object",
13
+ required: [
14
+ "acidcat",
15
+ "targets",
16
+ "capabilities"
17
+ ],
18
+ properties: {
19
+ acidcat: {
20
+ description: "The version of the AcidCat specification this catalog follows.",
21
+ const: "0.1"
22
+ },
23
+ title: {
24
+ type: "string"
25
+ },
26
+ description: {
27
+ type: "string"
28
+ },
29
+ targets: {
30
+ description: "The targets, keyed by name.",
31
+ type: "object",
32
+ propertyNames: {
33
+ $ref: "#/$defs/targetName"
34
+ },
35
+ additionalProperties: {
36
+ $ref: "#/$defs/target"
37
+ }
38
+ },
39
+ $defs: {
40
+ description: "Shared types, keyed by name. Capability schemas reference them as #/$defs/<Name>.",
41
+ type: "object",
42
+ propertyNames: {
43
+ $ref: "#/$defs/typeName"
44
+ },
45
+ additionalProperties: {
46
+ $ref: "#/$defs/schema"
47
+ }
48
+ },
49
+ capabilities: {
50
+ description: "The capabilities, keyed by id.",
51
+ type: "object",
52
+ propertyNames: {
53
+ $ref: "#/$defs/id"
54
+ },
55
+ additionalProperties: {
56
+ $ref: "#/$defs/capability"
57
+ }
58
+ }
59
+ },
60
+ $defs: {
61
+ id: {
62
+ type: "string",
63
+ maxLength: 128,
64
+ pattern: "^[a-z][a-z0-9]*(-[a-z0-9]+)*(\\.[a-z][a-z0-9]*(-[a-z0-9]+)*)+$"
65
+ },
66
+ targetName: {
67
+ type: "string",
68
+ pattern: "^[a-z][a-z0-9-]*(:[a-z][a-z0-9-]*)?$"
69
+ },
70
+ bindingName: {
71
+ type: "string",
72
+ pattern: "^[a-z][a-z0-9-]*(:[a-z][a-z0-9-]*)?$"
73
+ },
74
+ typeName: {
75
+ type: "string",
76
+ pattern: "^[A-Za-z][A-Za-z0-9_]*$"
77
+ },
78
+ target: {
79
+ type: "object",
80
+ required: [
81
+ "bindings"
82
+ ],
83
+ properties: {
84
+ bindings: {
85
+ type: "array",
86
+ items: {
87
+ $ref: "#/$defs/bindingName"
88
+ },
89
+ minItems: 1,
90
+ uniqueItems: true
91
+ },
92
+ description: {
93
+ type: "string"
94
+ },
95
+ options: {
96
+ type: "object"
97
+ }
98
+ }
99
+ },
100
+ schema: {
101
+ description: "A JSON Schema 2020-12 schema. Its $refs point into the catalog's $defs.",
102
+ type: [
103
+ "object",
104
+ "boolean"
105
+ ]
106
+ },
107
+ objectSchema: {
108
+ description: "An input schema: it states that the input is an object, or it references a type.",
109
+ type: "object",
110
+ anyOf: [
111
+ {
112
+ required: [
113
+ "type"
114
+ ],
115
+ properties: {
116
+ type: {
117
+ const: "object"
118
+ }
119
+ }
120
+ },
121
+ {
122
+ required: [
123
+ "$ref"
124
+ ],
125
+ properties: {
126
+ $ref: {
127
+ type: "string"
128
+ }
129
+ }
130
+ }
131
+ ]
132
+ },
133
+ capability: {
134
+ type: "object",
135
+ required: [
136
+ "description"
137
+ ],
138
+ properties: {
139
+ description: {
140
+ type: "string",
141
+ minLength: 1,
142
+ pattern: "\\S"
143
+ },
144
+ audience: {
145
+ type: "array",
146
+ items: {
147
+ $ref: "#/$defs/targetName"
148
+ },
149
+ uniqueItems: true
150
+ },
151
+ input: {
152
+ $ref: "#/$defs/objectSchema"
153
+ },
154
+ output: {
155
+ $ref: "#/$defs/schema"
156
+ },
157
+ failures: {
158
+ type: "object",
159
+ propertyNames: {
160
+ type: "string",
161
+ minLength: 1
162
+ },
163
+ additionalProperties: {
164
+ $ref: "#/$defs/schema"
165
+ }
166
+ },
167
+ readOnly: {
168
+ type: "boolean"
169
+ },
170
+ state: {
171
+ $ref: "#/$defs/schema"
172
+ }
173
+ }
174
+ }
175
+ }
176
+ };
177
+
178
+ // src/catalog.ts
179
+ var KNOWN_MEMBERS = {
180
+ catalog: ["acidcat", "title", "description", "targets", "$defs", "capabilities"],
181
+ target: ["bindings", "description", "options"],
182
+ capability: ["description", "audience", "input", "output", "failures", "readOnly", "state"]
183
+ };
184
+ var decodeCatalog = (() => {
185
+ const { $defs, $schema: _schema, $id: _id, ...root } = catalog_schema_default;
186
+ const schema = SchemaRepresentation.fromJsonSchemaDocument(
187
+ { dialect: "draft-2020-12", schema: root, definitions: $defs ?? {} },
188
+ { patterns: "apply" }
189
+ );
190
+ return Schema.decodeUnknownResult(schema);
191
+ })();
192
+ var formatIssues = SchemaIssue.makeFormatterStandardSchemaV1();
193
+ function check(document, text) {
194
+ const decoded = decodeCatalog(document);
195
+ if (Result.isFailure(decoded)) {
196
+ const errors2 = formatIssues(decoded.failure.issue).issues.map(
197
+ (issue) => `${(issue.path ?? []).map((key) => `[${JSON.stringify(key)}]`).join("") || "the catalog"}: ${issue.message}`
198
+ );
199
+ return { errors: errors2, warnings: [] };
200
+ }
201
+ const catalog = document;
202
+ const errors = ruleErrors(catalog);
203
+ const warnings = [...unknownMembers(catalog), ...errors.length === 0 ? schemaWarnings(catalog) : []];
204
+ if (text !== void 0 && text !== canonicalText(catalog)) {
205
+ warnings.push("the file is not in canonical form: run `acid-cat format --write`");
206
+ }
207
+ return { errors, warnings };
208
+ }
209
+ function ruleErrors(catalog) {
210
+ const errors = [];
211
+ const types = catalog.$defs ?? {};
212
+ for (const [name, type] of Object.entries(types)) {
213
+ errors.push(...referenceErrors(type, types).map((error) => `type ${name} ${error}`));
214
+ }
215
+ for (const [id, capability] of Object.entries(catalog.capabilities)) {
216
+ for (const target of capability.audience ?? []) {
217
+ if (!(target in catalog.targets)) errors.push(`${id}: the audience names ${target}, which is not a target`);
218
+ }
219
+ for (const [member, schema] of schemasOf(capability)) {
220
+ const references = referenceErrors(schema, types);
221
+ errors.push(...references.map((error) => `${id} ${member} ${error}`));
222
+ if (member === "input" && references.length === 0 && bundle(schema, types).type !== "object") {
223
+ errors.push(`${id}: the input is not an object`);
224
+ }
225
+ }
226
+ }
227
+ return errors;
228
+ }
229
+ function unknownMembers(catalog) {
230
+ const unknown = (where, value, known) => Object.keys(value).filter((key) => !known.includes(key) && !key.startsWith("x-")).map((key) => `${where}: unknown member ${key}; give extensions an x- prefix`);
231
+ return [
232
+ ...unknown("the catalog", catalog, KNOWN_MEMBERS.catalog),
233
+ ...Object.entries(catalog.targets).flatMap(
234
+ ([name, target]) => unknown(`target ${name}`, target, KNOWN_MEMBERS.target)
235
+ ),
236
+ ...Object.entries(catalog.capabilities).flatMap(
237
+ ([id, capability]) => unknown(id, capability, KNOWN_MEMBERS.capability)
238
+ )
239
+ ];
240
+ }
241
+ function schemaWarnings(catalog) {
242
+ const definitions = catalog.$defs ?? {};
243
+ const warnings = [];
244
+ for (const [id, capability] of Object.entries(catalog.capabilities)) {
245
+ for (const [member, schema] of schemasOf(capability)) {
246
+ try {
247
+ SchemaRepresentation.fromJsonSchemaMultiDocument(
248
+ { dialect: "draft-2020-12", schemas: [schema], definitions },
249
+ { patterns: "ignore" }
250
+ );
251
+ } catch (cause) {
252
+ warnings.push(`${id} ${member}: cannot check this schema: ${cause instanceof Error ? cause.message : String(cause)}`);
253
+ }
254
+ }
255
+ }
256
+ return warnings;
257
+ }
258
+ function referenceErrors(schema, types) {
259
+ const errors = [];
260
+ visitObjects(schema, (node) => {
261
+ if ("$defs" in node) errors.push("has its own $defs; put shared types in the catalog's $defs");
262
+ if (node.$ref === void 0) return;
263
+ const name = typeof node.$ref === "string" ? REFERENCE.exec(node.$ref)?.[1] : void 0;
264
+ if (name === void 0 || !(name in types)) errors.push(`references ${String(node.$ref)}, which is not a type`);
265
+ });
266
+ return errors;
267
+ }
268
+
269
+ // conformance/fixture.acidcat.json
270
+ var fixture_acidcat_default = {
271
+ acidcat: "0.1",
272
+ title: "AcidCat conformance fixture",
273
+ targets: {
274
+ api: {
275
+ bindings: [
276
+ "http"
277
+ ]
278
+ },
279
+ app: {
280
+ bindings: [
281
+ "typescript",
282
+ "webmcp"
283
+ ]
284
+ },
285
+ cli: {
286
+ bindings: [
287
+ "cli"
288
+ ]
289
+ },
290
+ mcp: {
291
+ bindings: [
292
+ "mcp"
293
+ ]
294
+ }
295
+ },
296
+ capabilities: {
297
+ "fixture.crash": {
298
+ description: "Always fail with a defect.",
299
+ audience: [
300
+ "app",
301
+ "mcp",
302
+ "cli",
303
+ "api"
304
+ ]
305
+ },
306
+ "fixture.echo": {
307
+ description: "Return the input text.",
308
+ audience: [
309
+ "app",
310
+ "mcp",
311
+ "cli",
312
+ "api"
313
+ ],
314
+ input: {
315
+ properties: {
316
+ text: {
317
+ type: "string"
318
+ }
319
+ },
320
+ required: [
321
+ "text"
322
+ ],
323
+ type: "object"
324
+ },
325
+ output: {
326
+ properties: {
327
+ text: {
328
+ type: "string"
329
+ }
330
+ },
331
+ required: [
332
+ "text"
333
+ ],
334
+ type: "object"
335
+ },
336
+ readOnly: true
337
+ },
338
+ "fixture.hidden": {
339
+ description: "Only the app target can use this capability.",
340
+ audience: [
341
+ "app"
342
+ ]
343
+ },
344
+ "fixture.refuse": {
345
+ description: "Always return the failure Refused.",
346
+ audience: [
347
+ "app",
348
+ "mcp",
349
+ "cli",
350
+ "api"
351
+ ],
352
+ failures: {
353
+ Refused: {
354
+ properties: {
355
+ _tag: {
356
+ const: "Refused",
357
+ type: "string"
358
+ },
359
+ reason: {
360
+ type: "string"
361
+ }
362
+ },
363
+ required: [
364
+ "_tag",
365
+ "reason"
366
+ ],
367
+ type: "object"
368
+ }
369
+ }
370
+ },
371
+ "fixture.slow": {
372
+ description: "Wait for some milliseconds, then return null.",
373
+ audience: [
374
+ "app",
375
+ "mcp",
376
+ "cli",
377
+ "api"
378
+ ],
379
+ input: {
380
+ properties: {
381
+ milliseconds: {
382
+ type: "number"
383
+ }
384
+ },
385
+ required: [
386
+ "milliseconds"
387
+ ],
388
+ type: "object"
389
+ }
390
+ },
391
+ "fixture.switch.set": {
392
+ description: "Turn the switch on or off. The switch controls the availability of fixture.toggle.",
393
+ audience: [
394
+ "app",
395
+ "mcp",
396
+ "cli",
397
+ "api"
398
+ ],
399
+ input: {
400
+ properties: {
401
+ on: {
402
+ type: "boolean"
403
+ }
404
+ },
405
+ required: [
406
+ "on"
407
+ ],
408
+ type: "object"
409
+ }
410
+ },
411
+ "fixture.toggle": {
412
+ description: "Available only while the switch is on.",
413
+ audience: [
414
+ "app",
415
+ "mcp",
416
+ "cli",
417
+ "api"
418
+ ]
419
+ }
420
+ }
421
+ };
422
+
423
+ // src/conform/check.ts
424
+ import { viewOf as viewOf2 } from "@acid-cat/spec-ts";
425
+ var fixture = fixture_acidcat_default;
426
+ var idsOf = (target) => Object.keys(viewOf2(fixture, target).capabilities).sort();
427
+ var Checks = class {
428
+ results = [];
429
+ binding;
430
+ constructor(binding) {
431
+ this.binding = binding;
432
+ }
433
+ async run(name, rule, body) {
434
+ try {
435
+ await body();
436
+ this.results.push({ binding: this.binding, name, rule, passed: true });
437
+ } catch (error) {
438
+ this.results.push({ binding: this.binding, name, rule, passed: false, detail: error instanceof Error ? error.message : String(error) });
439
+ }
440
+ }
441
+ };
442
+ function expect(condition, message) {
443
+ if (!condition) throw new Error(message);
444
+ }
445
+ function expectEqual(actual, expected, what) {
446
+ const a = JSON.stringify(actual);
447
+ const b = JSON.stringify(expected);
448
+ if (a !== b) throw new Error(`${what}: expected ${b}, got ${a}`);
449
+ }
450
+ var sorted = (values) => [...values].sort();
451
+ var FORBIDDEN_ORIGIN = "https://not-allowed.acid-cat.invalid";
452
+
453
+ // src/conform/serve.ts
454
+ import { createServer } from "node:http";
455
+ function serve(routes) {
456
+ const prefixes = Object.keys(routes).sort((a, b) => b.length - a.length);
457
+ const server = createServer((incoming, outgoing) => {
458
+ void answer(incoming, outgoing, prefixes, routes);
459
+ });
460
+ return new Promise((resolve) => {
461
+ server.listen(0, "127.0.0.1", () => {
462
+ const address = server.address();
463
+ const port = typeof address === "object" && address !== null ? address.port : 0;
464
+ resolve({
465
+ url: `http://127.0.0.1:${port}`,
466
+ close: () => new Promise((done) => server.close(() => done()))
467
+ });
468
+ });
469
+ });
470
+ }
471
+ async function answer(incoming, outgoing, prefixes, routes) {
472
+ const url = new URL(incoming.url ?? "/", `http://${incoming.headers.host ?? "127.0.0.1"}`);
473
+ const prefix = prefixes.find((candidate) => url.pathname === candidate || url.pathname.startsWith(`${candidate}/`));
474
+ if (prefix === void 0) {
475
+ outgoing.writeHead(404).end();
476
+ return;
477
+ }
478
+ const abort = new AbortController();
479
+ outgoing.on("close", () => {
480
+ if (!outgoing.writableFinished) abort.abort();
481
+ });
482
+ const headers = new Headers();
483
+ for (const [name, value] of Object.entries(incoming.headers)) {
484
+ if (Array.isArray(value)) value.forEach((item) => headers.append(name, item));
485
+ else if (value !== void 0) headers.set(name, value);
486
+ }
487
+ const hasBody = incoming.method !== "GET" && incoming.method !== "HEAD";
488
+ const request = new Request(url, {
489
+ method: incoming.method ?? "GET",
490
+ headers,
491
+ signal: abort.signal,
492
+ ...hasBody ? { body: incoming, duplex: "half" } : {}
493
+ });
494
+ try {
495
+ const response = await routes[prefix](request);
496
+ outgoing.writeHead(response.status, Object.fromEntries(response.headers));
497
+ if (response.body === null) {
498
+ outgoing.end();
499
+ return;
500
+ }
501
+ for await (const chunk of response.body) outgoing.write(chunk);
502
+ outgoing.end();
503
+ } catch {
504
+ if (!outgoing.headersSent) outgoing.writeHead(500);
505
+ outgoing.end();
506
+ }
507
+ }
508
+
509
+ // src/conform/cli.ts
510
+ import { spawn } from "node:child_process";
511
+ var RULE = "spec/bindings/cli.md";
512
+ var INTERRUPT_AFTER = 2e3;
513
+ function run(command, args, interruptAfter) {
514
+ return new Promise((resolve, reject) => {
515
+ const child = spawn(command[0], [...command.slice(1), ...args], { stdio: ["ignore", "pipe", "pipe"] });
516
+ const timer = interruptAfter === void 0 ? void 0 : setTimeout(() => child.kill("SIGINT"), interruptAfter);
517
+ let stdout = "";
518
+ let stderr = "";
519
+ child.stdout.on("data", (chunk) => stdout += chunk.toString());
520
+ child.stderr.on("data", (chunk) => stderr += chunk.toString());
521
+ child.on("error", reject);
522
+ child.on("close", (code, signal) => {
523
+ clearTimeout(timer);
524
+ resolve({ code, signal, stdout, stderr });
525
+ });
526
+ });
527
+ }
528
+ async function checkCli(command) {
529
+ const checks = new Checks("cli");
530
+ const cli = (...args) => run(command, args);
531
+ await checks.run("a value goes to standard output with exit code 0", `${RULE}#outcomes`, async () => {
532
+ const result = await cli("fixture", "echo", "--text", "hello");
533
+ expectEqual(result.code, 0, "exit code");
534
+ expectEqual(JSON.parse(result.stdout), { text: "hello" }, "standard output");
535
+ });
536
+ await checks.run("a failure goes to standard output with exit code 1", `${RULE}#outcomes`, async () => {
537
+ const result = await cli("fixture", "refuse");
538
+ expectEqual(result.code, 1, "exit code");
539
+ expectEqual(JSON.parse(result.stdout)._tag, "Refused", "_tag");
540
+ });
541
+ await checks.run("a defect goes to standard error with exit code 5", `${RULE}#outcomes`, async () => {
542
+ const result = await cli("fixture", "crash");
543
+ expectEqual(result.code, 5, "exit code");
544
+ expect(/"kind":"internal"/.test(result.stderr), "standard error must contain the call failure");
545
+ });
546
+ for (const [name, args, code] of [
547
+ ["an unknown command has exit code 2", ["fixture", "nothing"], 2],
548
+ ["a capability outside the view has exit code 2", ["call", "fixture.hidden"], 2],
549
+ ["a missing required flag has exit code 3", ["fixture", "echo"], 3],
550
+ ["a flag that is not valid has exit code 3", ["fixture", "slow", "--milliseconds", "soon"], 3],
551
+ ["invalid input through call has exit code 3", ["call", "fixture.echo", '{"text":1}'], 3]
552
+ ]) {
553
+ await checks.run(name, `${RULE}#outcomes`, async () => {
554
+ expectEqual((await cli(...args)).code, code, "exit code");
555
+ });
556
+ }
557
+ await checks.run("an interrupt cancels the call with exit code 130", `${RULE}#commands`, async () => {
558
+ const result = await run(command, ["fixture", "slow", "--milliseconds", "60000"], INTERRUPT_AFTER);
559
+ expect(result.signal === null, `the process must exit, not end by the signal ${result.signal}`);
560
+ expectEqual(result.code, 130, "exit code");
561
+ expectEqual(result.stdout, "", "standard output");
562
+ });
563
+ await checks.run("call gives the value of a capability", `${RULE}#commands`, async () => {
564
+ const result = await cli("call", "fixture.echo", '{"text":"hello"}');
565
+ expectEqual(result.code, 0, "exit code");
566
+ expectEqual(JSON.parse(result.stdout), { text: "hello" }, "standard output");
567
+ });
568
+ await checks.run("list gives the view", `${RULE}#commands`, async () => {
569
+ const result = await cli("list");
570
+ expectEqual(sorted(JSON.parse(result.stdout).map((entry) => entry.id)), idsOf("cli"), "ids");
571
+ });
572
+ return checks;
573
+ }
574
+
575
+ // src/conform/http.ts
576
+ var RULE2 = "spec/bindings/http.md";
577
+ async function checkHttp(base, options) {
578
+ const checks = new Checks("http");
579
+ const post = (id, body, headers = {}) => fetch(`${base}/capabilities/${id}`, {
580
+ method: "POST",
581
+ headers: { "Content-Type": "application/json", ...headers },
582
+ body: typeof body === "string" ? body : body instanceof Uint8Array ? new Blob([body]) : JSON.stringify(body)
583
+ });
584
+ const json = async (response) => await response.json();
585
+ await checks.run("GET /capabilities lists the view with availability", `${RULE2}#requests`, async () => {
586
+ const list = await (await fetch(`${base}/capabilities`)).json();
587
+ expectEqual(sorted(list.map((entry) => entry.id)), idsOf("api"), "ids");
588
+ expect(list.every((entry) => typeof entry.available === "boolean"), "each entry must have available");
589
+ });
590
+ await checks.run("a value has the status 200", `${RULE2}#requests`, async () => {
591
+ const response = await post("fixture.echo", { text: "hello" });
592
+ expectEqual(response.status, 200, "status");
593
+ expectEqual(await json(response), { ok: true, value: { text: "hello" } }, "body");
594
+ });
595
+ await checks.run("a failure has the status 200 and ok: false", `${RULE2}#requests`, async () => {
596
+ const response = await post("fixture.refuse", {});
597
+ const body = await json(response);
598
+ expectEqual(response.status, 200, "status");
599
+ expect(body.ok === false && body.error?._tag === "Refused", `got ${JSON.stringify(body)}`);
600
+ });
601
+ for (const [name, id, input, status2, kind] of [
602
+ ["invalid input has the status 422", "fixture.echo", { text: 1 }, 422, "invalid-input"],
603
+ ["a capability outside the view has the status 404", "fixture.hidden", {}, 404, "unknown-capability"],
604
+ ["a defect has the status 500", "fixture.crash", {}, 500, "internal"]
605
+ ]) {
606
+ await checks.run(name, `${RULE2}#requests`, async () => {
607
+ const response = await post(id, input);
608
+ expectEqual(response.status, status2, "status");
609
+ expectEqual((await json(response)).kind, kind, "kind");
610
+ });
611
+ }
612
+ await checks.run("an unavailable capability has the status 503", `${RULE2}#requests`, async () => {
613
+ await post("fixture.switch.set", { on: false });
614
+ try {
615
+ const list = await (await fetch(`${base}/capabilities`)).json();
616
+ const toggle = list.find((entry) => entry.id === "fixture.toggle");
617
+ expect(toggle?.available === false && typeof toggle.reason === "string", "fixture.toggle must be unavailable, with a reason");
618
+ const response = await post("fixture.toggle", {});
619
+ expectEqual(response.status, 503, "status");
620
+ expectEqual((await json(response)).kind, "unavailable", "kind");
621
+ } finally {
622
+ await post("fixture.switch.set", { on: true });
623
+ }
624
+ });
625
+ const status = async (response, expected) => expectEqual(response.status, expected, "status");
626
+ await checks.run("a method that is not accepted is rejected with 405", `${RULE2}#requests`, async () => {
627
+ await status(await fetch(`${base}/capabilities`, { method: "PUT" }), 405);
628
+ });
629
+ await checks.run("an origin that is not allowed is rejected with 403", `${RULE2}#requests`, async () => {
630
+ await status(await post("fixture.echo", { text: "x" }, { Origin: FORBIDDEN_ORIGIN }), 403);
631
+ });
632
+ await checks.run("a media type other than JSON is rejected with 415", `${RULE2}#requests`, async () => {
633
+ await status(await post("fixture.echo", { text: "x" }, { "Content-Type": "text/plain" }), 415);
634
+ });
635
+ await checks.run("a body over the limit is rejected with 413", `${RULE2}#requests`, async () => {
636
+ await status(await post("fixture.echo", " ".repeat(options.maximumBytes + 1)), 413);
637
+ });
638
+ await checks.run("a body that is not UTF-8 is rejected with 400", `${RULE2}#requests`, async () => {
639
+ await status(await post("fixture.echo", new Uint8Array([123, 255, 125])), 400);
640
+ });
641
+ return checks;
642
+ }
643
+
644
+ // src/conform/mcp.ts
645
+ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
646
+ var RULE3 = "spec/bindings/mcp.md";
647
+ async function mcpClient(url) {
648
+ const client = new Client({ name: "acid-cat-conform", version: "0.1.0" }, { versionNegotiation: { mode: { pin: "2026-07-28" } } });
649
+ await client.connect(new StreamableHTTPClientTransport(new URL(url)));
650
+ return client;
651
+ }
652
+ var textOf = (result) => JSON.parse(result.content?.[0]?.text ?? "null");
653
+ async function checkMcp(url, options) {
654
+ const checks = new Checks("mcp");
655
+ let client;
656
+ await checks.run("an MCP client connects with revision 2026-07-28", `${RULE3}#server`, async () => {
657
+ client = await mcpClient(url);
658
+ });
659
+ if (client === void 0) return checks;
660
+ const mcp = client;
661
+ const call = (name, args) => mcp.callTool({ name, arguments: args });
662
+ await checks.run("tools/list gives each available capability of the view", `${RULE3}#tools`, async () => {
663
+ const { tools } = await mcp.listTools();
664
+ expectEqual(sorted(tools.map((tool) => tool.name)), idsOf("mcp"), "tools");
665
+ });
666
+ await checks.run("tools have their hints and output schemas", `${RULE3}#tools`, async () => {
667
+ const { tools } = await mcp.listTools();
668
+ const echo = tools.find((tool) => tool.name === "fixture.echo");
669
+ const refuse = tools.find((tool) => tool.name === "fixture.refuse");
670
+ expect(echo?.annotations?.readOnlyHint === true, "fixture.echo must have readOnlyHint: true");
671
+ expect(refuse?.annotations?.readOnlyHint !== true, "fixture.refuse must not have readOnlyHint: true");
672
+ expect(echo?.outputSchema?.type === "object", "fixture.echo must have an object outputSchema");
673
+ expect(refuse?.outputSchema === void 0, "fixture.refuse has no object output, so it must have no outputSchema");
674
+ expect((echo?.inputSchema).type === "object", "an inputSchema must be an object");
675
+ });
676
+ await checks.run("a value is a tool result with structured content", `${RULE3}#tools`, async () => {
677
+ const result = await call("fixture.echo", { text: "hello" });
678
+ expect(result.isError !== true, "a value must not be an error result");
679
+ expectEqual(result.structuredContent, { text: "hello" }, "structuredContent");
680
+ expectEqual(textOf(result), { text: "hello" }, "text content");
681
+ });
682
+ await checks.run("a failure is an error result with ok: false", `${RULE3}#tools`, async () => {
683
+ const result = await call("fixture.refuse", {});
684
+ expect(result.isError === true, "a failure must be an error result");
685
+ const body = textOf(result);
686
+ expect(body.ok === false && body.error?._tag === "Refused", `expected { ok: false, error: { _tag: "Refused" } }, got ${JSON.stringify(body)}`);
687
+ });
688
+ await checks.run("a defect is an internal call failure with no internal details", `${RULE3}#tools`, async () => {
689
+ const result = await call("fixture.crash", {});
690
+ const body = textOf(result);
691
+ expect(result.isError === true && body.kind === "internal" && !("ok" in body), `expected { kind: "internal" }, got ${JSON.stringify(body)}`);
692
+ expect(!/\n\s+at /.test(body.message ?? ""), "the message must not contain a stack trace");
693
+ });
694
+ await checks.run("an input that does not agree with its schema is invalid-input", `${RULE3}#tools`, async () => {
695
+ const result = await call("fixture.echo", { text: 1 });
696
+ expectEqual(textOf(result).kind, "invalid-input", "kind");
697
+ });
698
+ await checks.run("a capability outside the view is an unknown tool", `${RULE3}#tools`, async () => {
699
+ const error = await call("fixture.hidden", {}).then(
700
+ () => void 0,
701
+ (caught) => caught
702
+ );
703
+ expect(error?.code === -32602, `expected the JSON-RPC error -32602, got ${JSON.stringify(error)}`);
704
+ });
705
+ await checks.run("an unavailable capability is not listed", `${RULE3}#tools`, async () => {
706
+ await call("fixture.switch.set", { on: false });
707
+ try {
708
+ const { tools } = await mcp.listTools();
709
+ expect(!tools.some((tool) => tool.name === "fixture.toggle"), "fixture.toggle must not be listed while it is unavailable");
710
+ } finally {
711
+ await call("fixture.switch.set", { on: true });
712
+ }
713
+ });
714
+ await mcp.close();
715
+ const post = (init) => fetch(url, { method: "POST", ...init, headers: { "Content-Type": "application/json", ...init.headers } });
716
+ const rejection = async (response, status, code) => {
717
+ expectEqual(response.status, status, "status");
718
+ expectEqual(response.headers.get("Cache-Control"), "no-store", "Cache-Control");
719
+ if (code !== void 0) expectEqual((await response.json()).error?.code, code, "JSON-RPC error code");
720
+ };
721
+ await checks.run("a method other than POST is rejected with 405", `${RULE3}#server`, async () => {
722
+ const response = await fetch(url);
723
+ await rejection(response, 405);
724
+ expectEqual(response.headers.get("Allow"), "POST", "Allow");
725
+ });
726
+ await checks.run("an origin that is not allowed is rejected with 403", `${RULE3}#server`, async () => {
727
+ await rejection(await post({ headers: { Origin: FORBIDDEN_ORIGIN }, body: "{}" }), 403);
728
+ });
729
+ await checks.run("a media type other than JSON is rejected with 415", `${RULE3}#server`, async () => {
730
+ await rejection(await post({ headers: { "Content-Type": "text/plain" }, body: "{}" }), 415);
731
+ });
732
+ await checks.run("a body over the limit is rejected with 413 and -32600", `${RULE3}#server`, async () => {
733
+ await rejection(await post({ body: " ".repeat(options.maximumBytes + 1) }), 413, -32600);
734
+ });
735
+ await checks.run("a body that is not JSON is rejected with 400 and -32700", `${RULE3}#server`, async () => {
736
+ await rejection(await post({ body: "{" }), 400, -32700);
737
+ });
738
+ await checks.run("a body that is not UTF-8 is rejected with 400", `${RULE3}#server`, async () => {
739
+ await rejection(await post({ body: new Uint8Array([123, 255, 125]) }), 400);
740
+ });
741
+ return checks;
742
+ }
743
+
744
+ // src/conform/properties.ts
745
+ import fc from "fast-check";
746
+ var RULE4 = "spec/invocation.md#cancellation-and-concurrency";
747
+ function httpInvoke(base) {
748
+ return async (id, input, signal) => {
749
+ const response = await fetch(`${base}/capabilities/${id}`, {
750
+ method: "POST",
751
+ headers: { "Content-Type": "application/json" },
752
+ body: JSON.stringify(input),
753
+ ...signal ? { signal } : {}
754
+ });
755
+ return await response.json();
756
+ };
757
+ }
758
+ async function mcpInvoke(url) {
759
+ const client = await mcpClient(url);
760
+ return {
761
+ invoke: async (id, input, signal) => {
762
+ try {
763
+ const result = await client.callTool({ name: id, arguments: input }, signal ? { signal } : void 0);
764
+ const body = JSON.parse(result.content[0]?.text ?? "null");
765
+ if (result.isError !== true) return { ok: true, value: result.structuredContent ?? body };
766
+ return body;
767
+ } catch (error) {
768
+ if (error.code === -32602) return { kind: "unknown-capability", message: String(error) };
769
+ throw error;
770
+ }
771
+ },
772
+ close: () => client.close()
773
+ };
774
+ }
775
+ var operation = fc.oneof(
776
+ fc.string({ maxLength: 40 }).map((text) => ({ kind: "echo", text })),
777
+ fc.constant({ kind: "refuse" }),
778
+ fc.constant({ kind: "crash" }),
779
+ fc.constant({ kind: "invalid" })
780
+ );
781
+ function agrees(operation2, outcome) {
782
+ switch (operation2.kind) {
783
+ case "echo":
784
+ return "ok" in outcome && outcome.ok && JSON.stringify(outcome.value) === JSON.stringify({ text: operation2.text });
785
+ case "refuse":
786
+ return "ok" in outcome && !outcome.ok && outcome.error._tag === "Refused";
787
+ case "crash":
788
+ return "kind" in outcome && outcome.kind === "internal";
789
+ case "invalid":
790
+ return "kind" in outcome && outcome.kind === "invalid-input";
791
+ }
792
+ }
793
+ var callFor = (invoke, operation2) => {
794
+ switch (operation2.kind) {
795
+ case "echo":
796
+ return invoke("fixture.echo", { text: operation2.text });
797
+ case "refuse":
798
+ return invoke("fixture.refuse", {});
799
+ case "crash":
800
+ return invoke("fixture.crash", {});
801
+ case "invalid":
802
+ return invoke("fixture.echo", { text: 0 });
803
+ }
804
+ };
805
+ async function checkProperties(binding, invoke) {
806
+ const checks = new Checks(`${binding} properties`);
807
+ await checks.run("concurrent calls each get exactly their own outcome", RULE4, async () => {
808
+ await fc.assert(
809
+ fc.asyncProperty(fc.array(operation, { minLength: 1, maxLength: 12 }), async (operations) => {
810
+ const outcomes = await Promise.all(operations.map((op) => callFor(invoke, op)));
811
+ operations.forEach((op, index) => expect(agrees(op, outcomes[index]), `${op.kind} gave ${JSON.stringify(outcomes[index])}`));
812
+ }),
813
+ { numRuns: 25 }
814
+ );
815
+ });
816
+ await checks.run("cancelling a call affects only that call", RULE4, async () => {
817
+ await fc.assert(
818
+ fc.asyncProperty(fc.array(fc.boolean(), { minLength: 2, maxLength: 5 }), async (cancel) => {
819
+ const calls = cancel.map((cancelled) => {
820
+ const controller = new AbortController();
821
+ const outcome = invoke("fixture.slow", { milliseconds: 200 }, controller.signal).catch(() => "aborted");
822
+ if (cancelled) setTimeout(() => controller.abort(), 20);
823
+ return { cancelled, outcome };
824
+ });
825
+ for (const call of calls) {
826
+ const outcome = await call.outcome;
827
+ if (!call.cancelled) expect(outcome !== "aborted" && "ok" in outcome && outcome.ok, `a call that was not cancelled gave ${JSON.stringify(outcome)}`);
828
+ else expect(outcome === "aborted" || "kind" in outcome && outcome.kind === "cancelled", `a cancelled call gave ${JSON.stringify(outcome)}`);
829
+ }
830
+ }),
831
+ { numRuns: 5 }
832
+ );
833
+ });
834
+ return checks;
835
+ }
836
+
837
+ // src/conform/webmcp.ts
838
+ import { REFERENCE as REFERENCE2, visitObjects as visitObjects2 } from "@acid-cat/spec-ts";
839
+ var RULE5 = "spec/bindings/webmcp.md";
840
+ var TARGET = "app";
841
+ function createTestHost() {
842
+ const registered = /* @__PURE__ */ new Map();
843
+ return {
844
+ modelContext: {
845
+ registerTool(tool, options) {
846
+ const recorded = tool;
847
+ registered.set(recorded.name, recorded);
848
+ options.signal.addEventListener("abort", () => {
849
+ if (registered.get(recorded.name) === recorded) registered.delete(recorded.name);
850
+ });
851
+ }
852
+ },
853
+ tools: () => [...registered.values()].map((tool) => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema })),
854
+ async call(name, input, cancelAfter) {
855
+ const tool = registered.get(name);
856
+ if (tool === void 0) return { missing: true };
857
+ const controller = new AbortController();
858
+ if (cancelAfter !== void 0) setTimeout(() => controller.abort(), cancelAfter);
859
+ try {
860
+ return { resolved: await tool.execute(input, { signal: controller.signal }) };
861
+ } catch (error) {
862
+ const { name: errorName, kind, message } = error;
863
+ return { rejected: { name: String(errorName), kind, message: String(message) } };
864
+ }
865
+ }
866
+ };
867
+ }
868
+ async function processDriver(install) {
869
+ const host = createTestHost();
870
+ const remove = await install(host.modelContext);
871
+ const copy = (value) => JSON.parse(JSON.stringify(value));
872
+ return {
873
+ tools: async () => copy(host.tools()),
874
+ call: async (name, input, cancelAfter) => copy(await host.call(name, input, cancelAfter)),
875
+ close: async () => {
876
+ if (typeof remove === "function") await remove();
877
+ }
878
+ };
879
+ }
880
+ async function browserDriver(url) {
881
+ let playwright;
882
+ try {
883
+ playwright = await import("playwright");
884
+ } catch {
885
+ throw new Error(
886
+ "acid-cat conform --webmcp needs Playwright and Chromium: `pnpm add -D playwright`, then `pnpm exec playwright install chromium`."
887
+ );
888
+ }
889
+ const browser = await playwright.chromium.launch();
890
+ const page = await browser.newPage();
891
+ const errors = [];
892
+ page.on("pageerror", (error) => errors.push(error.message));
893
+ await page.addInitScript(`
894
+ window.__acidCatConform = (${createTestHost.toString()})();
895
+ for (const scope of [document, navigator]) {
896
+ Object.defineProperty(scope, "modelContext", { value: window.__acidCatConform.modelContext, configurable: true });
897
+ }
898
+ `);
899
+ await page.goto(url);
900
+ return {
901
+ tools: async () => {
902
+ if (errors.length > 0) throw new Error(`The page has errors: ${errors.join("; ")}`);
903
+ return page.evaluate(() => window.__acidCatConform.tools());
904
+ },
905
+ call: (name, input, cancelAfter) => page.evaluate(
906
+ ([name2, input2, cancelAfter2]) => window.__acidCatConform.call(name2, input2, cancelAfter2),
907
+ [name, input, cancelAfter]
908
+ ),
909
+ close: () => browser.close()
910
+ };
911
+ }
912
+ async function eventually(condition, what, timeout = 5e3) {
913
+ const end = Date.now() + timeout;
914
+ for (; ; ) {
915
+ if (await condition()) return;
916
+ if (Date.now() > end) throw new Error(`after ${timeout} ms: ${what}`);
917
+ await new Promise((resolve) => setTimeout(resolve, 20));
918
+ }
919
+ }
920
+ function isBundled(schema) {
921
+ const types = schema.$defs ?? {};
922
+ let bundled = true;
923
+ visitObjects2(schema, (node) => {
924
+ if (typeof node.$ref !== "string") return;
925
+ const name = REFERENCE2.exec(node.$ref)?.[1];
926
+ if (name === void 0 || !(name in types)) bundled = false;
927
+ });
928
+ return bundled;
929
+ }
930
+ var names = async (driver) => sorted((await driver.tools()).map((tool) => tool.name));
931
+ async function checkWebMcp(driver) {
932
+ const checks = new Checks("webmcp");
933
+ const view = fixture.capabilities;
934
+ const expected = idsOf(TARGET);
935
+ await checks.run("each available capability of the view is a tool", `${RULE5}#tools`, async () => {
936
+ await eventually(async () => JSON.stringify(await names(driver)) === JSON.stringify(expected), `the tools must be ${expected.join(", ")}`);
937
+ });
938
+ await checks.run("a tool has the description and the bundled input schema of its capability", `${RULE5}#tools`, async () => {
939
+ for (const tool of await driver.tools()) {
940
+ expectEqual(tool.description, view[tool.name]?.description, `description of ${tool.name}`);
941
+ const schema = tool.inputSchema;
942
+ const input = view[tool.name]?.input ?? {};
943
+ expectEqual(schema.type, "object", `type of the input schema of ${tool.name}`);
944
+ expectEqual(sorted(Object.keys(schema.properties ?? {})), sorted(Object.keys(input.properties ?? {})), `properties of ${tool.name}`);
945
+ expectEqual(sorted(schema.required ?? []), sorted(input.required ?? []), `required properties of ${tool.name}`);
946
+ expect(isBundled(schema), `the input schema of ${tool.name} must be bundled`);
947
+ }
948
+ });
949
+ await checks.run("a value resolves as a result", `${RULE5}#tools`, async () => {
950
+ expectEqual(await driver.call("fixture.echo", { text: "hello" }), { resolved: { ok: true, value: { text: "hello" } } }, "outcome");
951
+ });
952
+ await checks.run("a failure resolves as a result", `${RULE5}#tools`, async () => {
953
+ const call = await driver.call("fixture.refuse", {});
954
+ expect("resolved" in call, `the call must resolve, got ${JSON.stringify(call)}`);
955
+ expectEqual(call.resolved.error?._tag, "Refused", "_tag");
956
+ });
957
+ for (const [name, capability, input, kind, errorName] of [
958
+ ["a defect rejects with the kind internal", "fixture.crash", {}, "internal", void 0],
959
+ ["invalid input rejects with the kind invalid-input, as a TypeError", "fixture.echo", { text: 1 }, "invalid-input", "TypeError"]
960
+ ]) {
961
+ await checks.run(name, `${RULE5}#tools`, async () => {
962
+ const call = await driver.call(capability, input);
963
+ expect("rejected" in call, `the call must reject, got ${JSON.stringify(call)}`);
964
+ expectEqual(call.rejected.kind, kind, "kind");
965
+ if (errorName !== void 0) expectEqual(call.rejected.name, errorName, "name");
966
+ });
967
+ }
968
+ await checks.run("the signal of the host cancels a call, as an AbortError", `${RULE5}#tools`, async () => {
969
+ const call = await driver.call("fixture.slow", { milliseconds: 6e4 }, 50);
970
+ expect("rejected" in call, `the call must reject, got ${JSON.stringify(call)}`);
971
+ expectEqual(call.rejected.kind, "cancelled", "kind");
972
+ expectEqual(call.rejected.name, "AbortError", "name");
973
+ });
974
+ await checks.run("a tool is removed while its capability is unavailable, and registered again after", `${RULE5}#tools`, async () => {
975
+ await driver.call("fixture.switch.set", { on: false });
976
+ await eventually(async () => !(await names(driver)).includes("fixture.toggle"), "fixture.toggle must be removed");
977
+ await driver.call("fixture.switch.set", { on: true });
978
+ await eventually(async () => (await names(driver)).includes("fixture.toggle"), "fixture.toggle must be registered again");
979
+ });
980
+ return checks;
981
+ }
982
+
983
+ // src/conform/index.ts
984
+ async function conform(options) {
985
+ const maximumBytes = options.maximumBytes ?? 1048576;
986
+ const results = [];
987
+ if (options.mcp !== void 0) {
988
+ results.push(...(await checkMcp(options.mcp, { maximumBytes })).results);
989
+ const mcp = await mcpInvoke(options.mcp).catch(() => void 0);
990
+ if (mcp !== void 0) {
991
+ results.push(...(await checkProperties("mcp", mcp.invoke)).results);
992
+ await mcp.close();
993
+ }
994
+ }
995
+ if (options.http !== void 0) {
996
+ results.push(...(await checkHttp(options.http, { maximumBytes })).results);
997
+ results.push(...(await checkProperties("http", httpInvoke(options.http))).results);
998
+ }
999
+ if (options.cli !== void 0) results.push(...(await checkCli(options.cli)).results);
1000
+ if (options.webmcp !== void 0) {
1001
+ const driver = typeof options.webmcp === "string" ? await browserDriver(options.webmcp) : await processDriver(options.webmcp);
1002
+ try {
1003
+ results.push(...(await checkWebMcp(driver)).results);
1004
+ } finally {
1005
+ await driver.close();
1006
+ }
1007
+ }
1008
+ return results;
1009
+ }
1010
+
1011
+ export {
1012
+ bundle,
1013
+ viewOf,
1014
+ check,
1015
+ fixture,
1016
+ serve,
1017
+ conform
1018
+ };