@acid-cat/cli 0.1.0 → 0.2.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,111 @@
1
+ import catalog_schema_default from "./json/catalog.schema.json.js";
2
+ import { canonicalText } from "@acid-cat/catalog";
3
+ import { REFERENCE, bundle, schemasOf, viewOf as viewOf$1, visitObjects } from "@acid-cat/spec-ts";
4
+ import { Result, Schema, SchemaIssue, SchemaRepresentation } from "effect";
5
+ //#region src/catalog.ts
6
+ /**
7
+ * The catalog operations that the specification defines: the catalog rules, views and bundling.
8
+ * See spec/catalog.md and spec/conformance.md.
9
+ */
10
+ const KNOWN_MEMBERS = {
11
+ catalog: [
12
+ "acidcat",
13
+ "title",
14
+ "description",
15
+ "targets",
16
+ "$defs",
17
+ "capabilities"
18
+ ],
19
+ target: [
20
+ "bindings",
21
+ "description",
22
+ "options"
23
+ ],
24
+ capability: [
25
+ "description",
26
+ "audience",
27
+ "input",
28
+ "output",
29
+ "failures",
30
+ "readOnly",
31
+ "state"
32
+ ]
33
+ };
34
+ /** The catalog schema (schemas/catalog.schema.json), imported as an Effect schema. */
35
+ const decodeCatalog = (() => {
36
+ const { $defs, $schema: _schema, $id: _id, ...root } = catalog_schema_default;
37
+ const schema = SchemaRepresentation.fromJsonSchemaDocument({
38
+ dialect: "draft-2020-12",
39
+ schema: root,
40
+ definitions: $defs ?? {}
41
+ }, { patterns: "apply" });
42
+ return Schema.decodeUnknownResult(schema);
43
+ })();
44
+ const formatIssues = SchemaIssue.makeFormatterStandardSchemaV1();
45
+ /** Check a catalog. When `text` is given, the file must also be in canonical form. */
46
+ function check(document, text) {
47
+ const decoded = decodeCatalog(document);
48
+ if (Result.isFailure(decoded)) return {
49
+ errors: formatIssues(decoded.failure.issue).issues.map((issue) => `${(issue.path ?? []).map((key) => `[${JSON.stringify(key)}]`).join("") || "the catalog"}: ${issue.message}`),
50
+ warnings: []
51
+ };
52
+ const catalog = document;
53
+ const errors = ruleErrors(catalog);
54
+ const warnings = [...unknownMembers(catalog), ...errors.length === 0 ? schemaWarnings(catalog) : []];
55
+ if (text !== void 0 && text !== canonicalText(catalog)) warnings.push("the file is not in canonical form: run `acid-cat format --write`");
56
+ return {
57
+ errors,
58
+ warnings
59
+ };
60
+ }
61
+ /** The catalog rules that the catalog schema cannot express. */
62
+ function ruleErrors(catalog) {
63
+ const errors = [];
64
+ const types = catalog.$defs ?? {};
65
+ for (const [name, type] of Object.entries(types)) errors.push(...referenceErrors(type, types).map((error) => `type ${name} ${error}`));
66
+ for (const [id, capability] of Object.entries(catalog.capabilities)) {
67
+ for (const target of capability.audience ?? []) if (!(target in catalog.targets)) errors.push(`${id}: the audience names ${target}, which is not a target`);
68
+ for (const [member, schema] of schemasOf(capability)) {
69
+ const references = referenceErrors(schema, types);
70
+ errors.push(...references.map((error) => `${id} ${member} ${error}`));
71
+ if (member === "input" && references.length === 0 && bundle(schema, types).type !== "object") errors.push(`${id}: the input is not an object`);
72
+ }
73
+ }
74
+ return errors;
75
+ }
76
+ /** Members that the specification does not define and that have no `x-` prefix. */
77
+ function unknownMembers(catalog) {
78
+ 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`);
79
+ return [
80
+ ...unknown("the catalog", catalog, KNOWN_MEMBERS.catalog),
81
+ ...Object.entries(catalog.targets).flatMap(([name, target]) => unknown(`target ${name}`, target, KNOWN_MEMBERS.target)),
82
+ ...Object.entries(catalog.capabilities).flatMap(([id, capability]) => unknown(id, capability, KNOWN_MEMBERS.capability))
83
+ ];
84
+ }
85
+ /** Schemas that cannot be imported as Effect schemas. They can still be valid JSON Schema, so this is a warning. */
86
+ function schemaWarnings(catalog) {
87
+ const definitions = catalog.$defs ?? {};
88
+ const warnings = [];
89
+ for (const [id, capability] of Object.entries(catalog.capabilities)) for (const [member, schema] of schemasOf(capability)) try {
90
+ SchemaRepresentation.fromJsonSchemaMultiDocument({
91
+ dialect: "draft-2020-12",
92
+ schemas: [schema],
93
+ definitions
94
+ }, { patterns: "ignore" });
95
+ } catch (cause) {
96
+ warnings.push(`${id} ${member}: cannot check this schema: ${cause instanceof Error ? cause.message : String(cause)}`);
97
+ }
98
+ return warnings;
99
+ }
100
+ function referenceErrors(schema, types) {
101
+ const errors = [];
102
+ visitObjects(schema, (node) => {
103
+ if ("$defs" in node) errors.push("has its own $defs; put shared types in the catalog's $defs");
104
+ if (node.$ref === void 0) return;
105
+ const name = typeof node.$ref === "string" ? REFERENCE.exec(node.$ref)?.[1] : void 0;
106
+ if (name === void 0 || !(name in types)) errors.push(`references ${String(node.$ref)}, which is not a type`);
107
+ });
108
+ return errors;
109
+ }
110
+ //#endregion
111
+ export { bundle, check, viewOf$1 as viewOf };
@@ -0,0 +1,51 @@
1
+ import fixture_acidcat_default from "../json/fixture.acidcat.json.js";
2
+ import { viewOf } from "@acid-cat/spec-ts";
3
+ //#region src/conform/check.ts
4
+ /** The result of one conformance check, and helpers to write checks. */
5
+ /** The conformance fixture: the catalog that each implementation serves. */
6
+ const fixture = fixture_acidcat_default;
7
+ /** The ids of the capabilities in the view of a target of the fixture. */
8
+ const idsOf = (target) => Object.keys(viewOf(fixture, target).capabilities).sort();
9
+ /** Collects checks for one binding. */
10
+ var Checks = class {
11
+ results = [];
12
+ binding;
13
+ constructor(binding) {
14
+ this.binding = binding;
15
+ }
16
+ async run(name, rule, body) {
17
+ try {
18
+ await body();
19
+ this.results.push({
20
+ binding: this.binding,
21
+ name,
22
+ rule,
23
+ passed: true
24
+ });
25
+ } catch (error) {
26
+ this.results.push({
27
+ binding: this.binding,
28
+ name,
29
+ rule,
30
+ passed: false,
31
+ detail: error instanceof Error ? error.message : String(error)
32
+ });
33
+ }
34
+ }
35
+ };
36
+ /** Fail a check unless `condition` is true. */
37
+ function expect(condition, message) {
38
+ if (!condition) throw new Error(message);
39
+ }
40
+ /** Fail a check unless the two values have the same JSON. */
41
+ function expectEqual(actual, expected, what) {
42
+ const a = JSON.stringify(actual);
43
+ const b = JSON.stringify(expected);
44
+ if (a !== b) throw new Error(`${what}: expected ${b}, got ${a}`);
45
+ }
46
+ /** A sorted copy of a list. */
47
+ const sorted = (values) => [...values].sort();
48
+ /** An origin that no deployment allows. */
49
+ const FORBIDDEN_ORIGIN = "https://not-allowed.acid-cat.invalid";
50
+ //#endregion
51
+ export { Checks, FORBIDDEN_ORIGIN, expect, expectEqual, fixture, idsOf, sorted };
@@ -0,0 +1,112 @@
1
+ import { Checks, expect, expectEqual, idsOf, sorted } from "./check.js";
2
+ import { spawn } from "node:child_process";
3
+ //#region src/conform/cli.ts
4
+ /** Conformance checks for the CLI binding (spec/bindings/cli.md). Each check runs the command line as a process. */
5
+ const RULE = "spec/bindings/cli.md";
6
+ /** The time that a command line has to start before the interrupt, in milliseconds. */
7
+ const INTERRUPT_AFTER = 2e3;
8
+ /** Run the command line. If `interruptAfter` is given, send `SIGINT` after that many milliseconds. */
9
+ function run(command, args, interruptAfter) {
10
+ return new Promise((resolve, reject) => {
11
+ const child = spawn(command[0], [...command.slice(1), ...args], { stdio: [
12
+ "ignore",
13
+ "pipe",
14
+ "pipe"
15
+ ] });
16
+ const timer = interruptAfter === void 0 ? void 0 : setTimeout(() => child.kill("SIGINT"), interruptAfter);
17
+ let stdout = "";
18
+ let stderr = "";
19
+ child.stdout.on("data", (chunk) => stdout += chunk.toString());
20
+ child.stderr.on("data", (chunk) => stderr += chunk.toString());
21
+ child.on("error", reject);
22
+ child.on("close", (code, signal) => {
23
+ clearTimeout(timer);
24
+ resolve({
25
+ code,
26
+ signal,
27
+ stdout,
28
+ stderr
29
+ });
30
+ });
31
+ });
32
+ }
33
+ async function checkCli(command) {
34
+ const checks = new Checks("cli");
35
+ const cli = (...args) => run(command, args);
36
+ await checks.run("a value goes to standard output with exit code 0", `${RULE}#outcomes`, async () => {
37
+ const result = await cli("fixture", "echo", "--text", "hello");
38
+ expectEqual(result.code, 0, "exit code");
39
+ expectEqual(JSON.parse(result.stdout), { text: "hello" }, "standard output");
40
+ });
41
+ await checks.run("a failure goes to standard output with exit code 1", `${RULE}#outcomes`, async () => {
42
+ const result = await cli("fixture", "refuse");
43
+ expectEqual(result.code, 1, "exit code");
44
+ expectEqual(JSON.parse(result.stdout)._tag, "Refused", "_tag");
45
+ });
46
+ await checks.run("a defect goes to standard error with exit code 5", `${RULE}#outcomes`, async () => {
47
+ const result = await cli("fixture", "crash");
48
+ expectEqual(result.code, 5, "exit code");
49
+ expect(/"kind":"internal"/.test(result.stderr), "standard error must contain the call failure");
50
+ });
51
+ for (const [name, args, code] of [
52
+ [
53
+ "an unknown command has exit code 2",
54
+ ["fixture", "nothing"],
55
+ 2
56
+ ],
57
+ [
58
+ "a capability outside the view has exit code 2",
59
+ ["call", "fixture.hidden"],
60
+ 2
61
+ ],
62
+ [
63
+ "a missing required flag has exit code 3",
64
+ ["fixture", "echo"],
65
+ 3
66
+ ],
67
+ [
68
+ "a flag that is not valid has exit code 3",
69
+ [
70
+ "fixture",
71
+ "slow",
72
+ "--milliseconds",
73
+ "soon"
74
+ ],
75
+ 3
76
+ ],
77
+ [
78
+ "invalid input through call has exit code 3",
79
+ [
80
+ "call",
81
+ "fixture.echo",
82
+ "{\"text\":1}"
83
+ ],
84
+ 3
85
+ ]
86
+ ]) await checks.run(name, `${RULE}#outcomes`, async () => {
87
+ expectEqual((await cli(...args)).code, code, "exit code");
88
+ });
89
+ await checks.run("an interrupt cancels the call with exit code 130", `${RULE}#commands`, async () => {
90
+ const result = await run(command, [
91
+ "fixture",
92
+ "slow",
93
+ "--milliseconds",
94
+ "60000"
95
+ ], INTERRUPT_AFTER);
96
+ expect(result.signal === null, `the process must exit, not end by the signal ${result.signal}`);
97
+ expectEqual(result.code, 130, "exit code");
98
+ expectEqual(result.stdout, "", "standard output");
99
+ });
100
+ await checks.run("call gives the value of a capability", `${RULE}#commands`, async () => {
101
+ const result = await cli("call", "fixture.echo", "{\"text\":\"hello\"}");
102
+ expectEqual(result.code, 0, "exit code");
103
+ expectEqual(JSON.parse(result.stdout), { text: "hello" }, "standard output");
104
+ });
105
+ await checks.run("list gives the view", `${RULE}#commands`, async () => {
106
+ const result = await cli("list");
107
+ expectEqual(sorted(JSON.parse(result.stdout).map((entry) => entry.id)), idsOf("cli"), "ids");
108
+ });
109
+ return checks;
110
+ }
111
+ //#endregion
112
+ export { checkCli };
@@ -0,0 +1,97 @@
1
+ import { Checks, FORBIDDEN_ORIGIN, expect, expectEqual, idsOf, sorted } from "./check.js";
2
+ //#region src/conform/http.ts
3
+ /** Conformance checks for the HTTP binding (spec/bindings/http.md). */
4
+ const RULE = "spec/bindings/http.md";
5
+ async function checkHttp(base, options) {
6
+ const checks = new Checks("http");
7
+ const post = (id, body, headers = {}) => fetch(`${base}/capabilities/${id}`, {
8
+ method: "POST",
9
+ headers: {
10
+ "Content-Type": "application/json",
11
+ ...headers
12
+ },
13
+ body: typeof body === "string" ? body : body instanceof Uint8Array ? new Blob([body]) : JSON.stringify(body)
14
+ });
15
+ const json = async (response) => await response.json();
16
+ await checks.run("GET /capabilities lists the view with availability", `${RULE}#requests`, async () => {
17
+ const list = await (await fetch(`${base}/capabilities`)).json();
18
+ expectEqual(sorted(list.map((entry) => entry.id)), idsOf("api"), "ids");
19
+ expect(list.every((entry) => typeof entry.available === "boolean"), "each entry must have available");
20
+ });
21
+ await checks.run("a value has the status 200", `${RULE}#requests`, async () => {
22
+ const response = await post("fixture.echo", { text: "hello" });
23
+ expectEqual(response.status, 200, "status");
24
+ expectEqual(await json(response), {
25
+ ok: true,
26
+ value: { text: "hello" }
27
+ }, "body");
28
+ });
29
+ await checks.run("a failure has the status 200 and ok: false", `${RULE}#requests`, async () => {
30
+ const response = await post("fixture.refuse", {});
31
+ const body = await json(response);
32
+ expectEqual(response.status, 200, "status");
33
+ expect(body.ok === false && body.error?._tag === "Refused", `got ${JSON.stringify(body)}`);
34
+ });
35
+ for (const [name, id, input, status, kind] of [
36
+ [
37
+ "invalid input has the status 422",
38
+ "fixture.echo",
39
+ { text: 1 },
40
+ 422,
41
+ "invalid-input"
42
+ ],
43
+ [
44
+ "a capability outside the view has the status 404",
45
+ "fixture.hidden",
46
+ {},
47
+ 404,
48
+ "unknown-capability"
49
+ ],
50
+ [
51
+ "a defect has the status 500",
52
+ "fixture.crash",
53
+ {},
54
+ 500,
55
+ "internal"
56
+ ]
57
+ ]) await checks.run(name, `${RULE}#requests`, async () => {
58
+ const response = await post(id, input);
59
+ expectEqual(response.status, status, "status");
60
+ expectEqual((await json(response)).kind, kind, "kind");
61
+ });
62
+ await checks.run("an unavailable capability has the status 503", `${RULE}#requests`, async () => {
63
+ await post("fixture.switch.set", { on: false });
64
+ try {
65
+ const toggle = (await (await fetch(`${base}/capabilities`)).json()).find((entry) => entry.id === "fixture.toggle");
66
+ expect(toggle?.available === false && typeof toggle.reason === "string", "fixture.toggle must be unavailable, with a reason");
67
+ const response = await post("fixture.toggle", {});
68
+ expectEqual(response.status, 503, "status");
69
+ expectEqual((await json(response)).kind, "unavailable", "kind");
70
+ } finally {
71
+ await post("fixture.switch.set", { on: true });
72
+ }
73
+ });
74
+ const status = async (response, expected) => expectEqual(response.status, expected, "status");
75
+ await checks.run("a method that is not accepted is rejected with 405", `${RULE}#requests`, async () => {
76
+ await status(await fetch(`${base}/capabilities`, { method: "PUT" }), 405);
77
+ });
78
+ await checks.run("an origin that is not allowed is rejected with 403", `${RULE}#requests`, async () => {
79
+ await status(await post("fixture.echo", { text: "x" }, { Origin: FORBIDDEN_ORIGIN }), 403);
80
+ });
81
+ await checks.run("a media type other than JSON is rejected with 415", `${RULE}#requests`, async () => {
82
+ await status(await post("fixture.echo", { text: "x" }, { "Content-Type": "text/plain" }), 415);
83
+ });
84
+ await checks.run("a body over the limit is rejected with 413", `${RULE}#requests`, async () => {
85
+ await status(await post("fixture.echo", " ".repeat(options.maximumBytes + 1)), 413);
86
+ });
87
+ await checks.run("a body that is not UTF-8 is rejected with 400", `${RULE}#requests`, async () => {
88
+ await status(await post("fixture.echo", new Uint8Array([
89
+ 123,
90
+ 255,
91
+ 125
92
+ ])), 400);
93
+ });
94
+ return checks;
95
+ }
96
+ //#endregion
97
+ export { checkHttp };
@@ -0,0 +1,37 @@
1
+ import { fixture } from "./check.js";
2
+ import { checkCli } from "./cli.js";
3
+ import { checkHttp } from "./http.js";
4
+ import { checkMcp } from "./mcp.js";
5
+ import { checkProperties, httpInvoke, mcpInvoke } from "./properties.js";
6
+ import { browserDriver, checkWebMcp, processDriver } from "./webmcp.js";
7
+ import { serve } from "./serve.js";
8
+ //#region src/conform/index.ts
9
+ /** Run all checks for the bindings that the options give. */
10
+ async function conform(options) {
11
+ const maximumBytes = options.maximumBytes ?? 1048576;
12
+ const results = [];
13
+ if (options.mcp !== void 0) {
14
+ results.push(...(await checkMcp(options.mcp, { maximumBytes })).results);
15
+ const mcp = await mcpInvoke(options.mcp).catch(() => void 0);
16
+ if (mcp !== void 0) {
17
+ results.push(...(await checkProperties("mcp", mcp.invoke)).results);
18
+ await mcp.close();
19
+ }
20
+ }
21
+ if (options.http !== void 0) {
22
+ results.push(...(await checkHttp(options.http, { maximumBytes })).results);
23
+ results.push(...(await checkProperties("http", httpInvoke(options.http))).results);
24
+ }
25
+ if (options.cli !== void 0) results.push(...(await checkCli(options.cli)).results);
26
+ if (options.webmcp !== void 0) {
27
+ const driver = typeof options.webmcp === "string" ? await browserDriver(options.webmcp) : await processDriver(options.webmcp);
28
+ try {
29
+ results.push(...(await checkWebMcp(driver)).results);
30
+ } finally {
31
+ await driver.close();
32
+ }
33
+ }
34
+ return results;
35
+ }
36
+ //#endregion
37
+ export { conform, fixture, serve };
@@ -0,0 +1,127 @@
1
+ import { Checks, FORBIDDEN_ORIGIN, expect, expectEqual, idsOf, sorted } from "./check.js";
2
+ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
3
+ //#region src/conform/mcp.ts
4
+ /**
5
+ * Conformance checks for the MCP binding (spec/bindings/mcp.md). The protocol checks use the official MCP client,
6
+ * with revision 2026-07-28. The rejection checks send requests that a client never sends.
7
+ */
8
+ const RULE = "spec/bindings/mcp.md";
9
+ /** A connected MCP client. */
10
+ async function mcpClient(url) {
11
+ const client = new Client({
12
+ name: "acid-cat-conform",
13
+ version: "0.1.0"
14
+ }, { versionNegotiation: { mode: { pin: "2026-07-28" } } });
15
+ await client.connect(new StreamableHTTPClientTransport(new URL(url)));
16
+ return client;
17
+ }
18
+ const textOf = (result) => JSON.parse(result.content?.[0]?.text ?? "null");
19
+ async function checkMcp(url, options) {
20
+ const checks = new Checks("mcp");
21
+ let client;
22
+ await checks.run("an MCP client connects with revision 2026-07-28", `${RULE}#server`, async () => {
23
+ client = await mcpClient(url);
24
+ });
25
+ if (client === void 0) return checks;
26
+ const mcp = client;
27
+ const call = (name, args) => mcp.callTool({
28
+ name,
29
+ arguments: args
30
+ });
31
+ await checks.run("tools/list gives each available capability of the view", `${RULE}#tools`, async () => {
32
+ const { tools } = await mcp.listTools();
33
+ expectEqual(sorted(tools.map((tool) => tool.name)), idsOf("mcp"), "tools");
34
+ });
35
+ await checks.run("tools have their hints and output schemas", `${RULE}#tools`, async () => {
36
+ const { tools } = await mcp.listTools();
37
+ const echo = tools.find((tool) => tool.name === "fixture.echo");
38
+ const refuse = tools.find((tool) => tool.name === "fixture.refuse");
39
+ expect(echo?.annotations?.readOnlyHint === true, "fixture.echo must have readOnlyHint: true");
40
+ expect(refuse?.annotations?.readOnlyHint !== true, "fixture.refuse must not have readOnlyHint: true");
41
+ expect((echo?.outputSchema)?.type === "object", "fixture.echo must have an object outputSchema");
42
+ expect(refuse?.outputSchema === void 0, "fixture.refuse has no object output, so it must have no outputSchema");
43
+ expect((echo?.inputSchema).type === "object", "an inputSchema must be an object");
44
+ });
45
+ await checks.run("a value is a tool result with structured content", `${RULE}#tools`, async () => {
46
+ const result = await call("fixture.echo", { text: "hello" });
47
+ expect(result.isError !== true, "a value must not be an error result");
48
+ expectEqual(result.structuredContent, { text: "hello" }, "structuredContent");
49
+ expectEqual(textOf(result), { text: "hello" }, "text content");
50
+ });
51
+ await checks.run("a failure is an error result with ok: false", `${RULE}#tools`, async () => {
52
+ const result = await call("fixture.refuse", {});
53
+ expect(result.isError === true, "a failure must be an error result");
54
+ const body = textOf(result);
55
+ expect(body.ok === false && body.error?._tag === "Refused", `expected { ok: false, error: { _tag: "Refused" } }, got ${JSON.stringify(body)}`);
56
+ });
57
+ await checks.run("a defect is an internal call failure with no internal details", `${RULE}#tools`, async () => {
58
+ const result = await call("fixture.crash", {});
59
+ const body = textOf(result);
60
+ expect(result.isError === true && body.kind === "internal" && !("ok" in body), `expected { kind: "internal" }, got ${JSON.stringify(body)}`);
61
+ expect(!/\n\s+at /.test(body.message ?? ""), "the message must not contain a stack trace");
62
+ });
63
+ await checks.run("an input that does not agree with its schema is invalid-input", `${RULE}#tools`, async () => {
64
+ const result = await call("fixture.echo", { text: 1 });
65
+ expectEqual(textOf(result).kind, "invalid-input", "kind");
66
+ });
67
+ await checks.run("a capability outside the view is an unknown tool", `${RULE}#tools`, async () => {
68
+ const error = await call("fixture.hidden", {}).then(() => void 0, (caught) => caught);
69
+ expect(error?.code === -32602, `expected the JSON-RPC error -32602, got ${JSON.stringify(error)}`);
70
+ });
71
+ await checks.run("an unavailable capability is not listed", `${RULE}#tools`, async () => {
72
+ await call("fixture.switch.set", { on: false });
73
+ try {
74
+ const { tools } = await mcp.listTools();
75
+ expect(!tools.some((tool) => tool.name === "fixture.toggle"), "fixture.toggle must not be listed while it is unavailable");
76
+ } finally {
77
+ await call("fixture.switch.set", { on: true });
78
+ }
79
+ });
80
+ await mcp.close();
81
+ const post = (init) => fetch(url, {
82
+ method: "POST",
83
+ ...init,
84
+ headers: {
85
+ "Content-Type": "application/json",
86
+ ...init.headers
87
+ }
88
+ });
89
+ const rejection = async (response, status, code) => {
90
+ expectEqual(response.status, status, "status");
91
+ expectEqual(response.headers.get("Cache-Control"), "no-store", "Cache-Control");
92
+ if (code !== void 0) expectEqual((await response.json()).error?.code, code, "JSON-RPC error code");
93
+ };
94
+ await checks.run("a method other than POST is rejected with 405", `${RULE}#server`, async () => {
95
+ const response = await fetch(url);
96
+ await rejection(response, 405);
97
+ expectEqual(response.headers.get("Allow"), "POST", "Allow");
98
+ });
99
+ await checks.run("an origin that is not allowed is rejected with 403", `${RULE}#server`, async () => {
100
+ await rejection(await post({
101
+ headers: { Origin: FORBIDDEN_ORIGIN },
102
+ body: "{}"
103
+ }), 403);
104
+ });
105
+ await checks.run("a media type other than JSON is rejected with 415", `${RULE}#server`, async () => {
106
+ await rejection(await post({
107
+ headers: { "Content-Type": "text/plain" },
108
+ body: "{}"
109
+ }), 415);
110
+ });
111
+ await checks.run("a body over the limit is rejected with 413 and -32600", `${RULE}#server`, async () => {
112
+ await rejection(await post({ body: " ".repeat(options.maximumBytes + 1) }), 413, -32600);
113
+ });
114
+ await checks.run("a body that is not JSON is rejected with 400 and -32700", `${RULE}#server`, async () => {
115
+ await rejection(await post({ body: "{" }), 400, -32700);
116
+ });
117
+ await checks.run("a body that is not UTF-8 is rejected with 400", `${RULE}#server`, async () => {
118
+ await rejection(await post({ body: new Uint8Array([
119
+ 123,
120
+ 255,
121
+ 125
122
+ ]) }), 400);
123
+ });
124
+ return checks;
125
+ }
126
+ //#endregion
127
+ export { checkMcp, mcpClient };
@@ -0,0 +1,98 @@
1
+ import { Checks, expect } from "./check.js";
2
+ import { mcpClient } from "./mcp.js";
3
+ import fc from "fast-check";
4
+ //#region src/conform/properties.ts
5
+ const RULE = "spec/invocation.md#cancellation-and-concurrency";
6
+ function httpInvoke(base) {
7
+ return async (id, input, signal) => {
8
+ return await (await fetch(`${base}/capabilities/${id}`, {
9
+ method: "POST",
10
+ headers: { "Content-Type": "application/json" },
11
+ body: JSON.stringify(input),
12
+ ...signal ? { signal } : {}
13
+ })).json();
14
+ };
15
+ }
16
+ async function mcpInvoke(url) {
17
+ const client = await mcpClient(url);
18
+ return {
19
+ invoke: async (id, input, signal) => {
20
+ try {
21
+ const result = await client.callTool({
22
+ name: id,
23
+ arguments: input
24
+ }, signal ? { signal } : void 0);
25
+ const body = JSON.parse(result.content[0]?.text ?? "null");
26
+ if (result.isError !== true) return {
27
+ ok: true,
28
+ value: result.structuredContent ?? body
29
+ };
30
+ return body;
31
+ } catch (error) {
32
+ if (error.code === -32602) return {
33
+ kind: "unknown-capability",
34
+ message: String(error)
35
+ };
36
+ throw error;
37
+ }
38
+ },
39
+ close: () => client.close()
40
+ };
41
+ }
42
+ const operation = fc.oneof(fc.string({ maxLength: 40 }).map((text) => ({
43
+ kind: "echo",
44
+ text
45
+ })), fc.constant({ kind: "refuse" }), fc.constant({ kind: "crash" }), fc.constant({ kind: "invalid" }));
46
+ /** The outcome that an operation must have. */
47
+ function agrees(operation, outcome) {
48
+ switch (operation.kind) {
49
+ case "echo": return "ok" in outcome && outcome.ok && JSON.stringify(outcome.value) === JSON.stringify({ text: operation.text });
50
+ case "refuse": return "ok" in outcome && !outcome.ok && outcome.error._tag === "Refused";
51
+ case "crash": return "kind" in outcome && outcome.kind === "internal";
52
+ case "invalid": return "kind" in outcome && outcome.kind === "invalid-input";
53
+ }
54
+ }
55
+ const callFor = (invoke, operation) => {
56
+ switch (operation.kind) {
57
+ case "echo": return invoke("fixture.echo", { text: operation.text });
58
+ case "refuse": return invoke("fixture.refuse", {});
59
+ case "crash": return invoke("fixture.crash", {});
60
+ case "invalid": return invoke("fixture.echo", { text: 0 });
61
+ }
62
+ };
63
+ async function checkProperties(binding, invoke) {
64
+ const checks = new Checks(`${binding} properties`);
65
+ await checks.run("concurrent calls each get exactly their own outcome", RULE, async () => {
66
+ await fc.assert(fc.asyncProperty(fc.array(operation, {
67
+ minLength: 1,
68
+ maxLength: 12
69
+ }), async (operations) => {
70
+ const outcomes = await Promise.all(operations.map((op) => callFor(invoke, op)));
71
+ operations.forEach((op, index) => expect(agrees(op, outcomes[index]), `${op.kind} gave ${JSON.stringify(outcomes[index])}`));
72
+ }), { numRuns: 25 });
73
+ });
74
+ await checks.run("cancelling a call affects only that call", RULE, async () => {
75
+ await fc.assert(fc.asyncProperty(fc.array(fc.boolean(), {
76
+ minLength: 2,
77
+ maxLength: 5
78
+ }), async (cancel) => {
79
+ const calls = cancel.map((cancelled) => {
80
+ const controller = new AbortController();
81
+ const outcome = invoke("fixture.slow", { milliseconds: 200 }, controller.signal).catch(() => "aborted");
82
+ if (cancelled) setTimeout(() => controller.abort(), 20);
83
+ return {
84
+ cancelled,
85
+ outcome
86
+ };
87
+ });
88
+ for (const call of calls) {
89
+ const outcome = await call.outcome;
90
+ if (!call.cancelled) expect(outcome !== "aborted" && "ok" in outcome && outcome.ok, `a call that was not cancelled gave ${JSON.stringify(outcome)}`);
91
+ else expect(outcome === "aborted" || "kind" in outcome && outcome.kind === "cancelled", `a cancelled call gave ${JSON.stringify(outcome)}`);
92
+ }
93
+ }), { numRuns: 5 });
94
+ });
95
+ return checks;
96
+ }
97
+ //#endregion
98
+ export { checkProperties, httpInvoke, mcpInvoke };