@acid-cat/cli 0.4.0 → 0.5.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 CHANGED
@@ -54,5 +54,6 @@ The command line is an [Effect](https://effect.website) program that uses `effec
54
54
  | `src/conform/` | The conformance checks, and `serve`, which serves web handlers on a local port |
55
55
  | `conformance/` | The conformance fixture, in Pkl and in JSON |
56
56
  | `test/` | Tests. Run them with `moon run cli:test` |
57
+ | `test/fixtures/` | The catalog of the tests: a bakery, in canonical form |
57
58
 
58
59
  The canonical writer comes from [`@acid-cat/catalog`](../catalog/typescript).
package/dist/catalog.d.ts CHANGED
@@ -7,5 +7,10 @@ export interface Report {
7
7
  readonly errors: readonly string[];
8
8
  readonly warnings: readonly string[];
9
9
  }
10
+ /**
11
+ * True if the value has the members and the member types of a catalog. It does not check the catalog rules: `check`
12
+ * does that.
13
+ */
14
+ export declare const isCatalog: (value: unknown) => value is Catalog;
10
15
  /** Check a catalog. When `text` is given, the file must also be in canonical form. */
11
16
  export declare function check(document: unknown, text?: string): Report;
package/dist/catalog.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import catalog_schema_default from "./json/catalog.schema.json.js";
2
+ import { isObject, isRecordOf, isStringArray } from "./json.js";
2
3
  import { canonicalText } from "@acid-cat/catalog";
3
4
  import { REFERENCE, bundle, schemasOf, viewOf as viewOf$1, visitObjects } from "@acid-cat/spec-ts";
4
5
  import { Result, Schema, SchemaIssue, SchemaRepresentation } from "effect";
@@ -34,13 +35,24 @@ const KNOWN_MEMBERS = {
34
35
  /** The catalog schema (schemas/catalog.schema.json), imported as an Effect schema. */
35
36
  const decodeCatalog = (() => {
36
37
  const { $defs, $schema: _schema, $id: _id, ...root } = catalog_schema_default;
38
+ const definitions = $defs;
37
39
  const schema = SchemaRepresentation.fromJsonSchemaDocument({
38
40
  dialect: "draft-2020-12",
39
41
  schema: root,
40
- definitions: $defs ?? {}
42
+ definitions
41
43
  }, { patterns: "apply" });
42
- return Schema.decodeUnknownResult(schema);
44
+ return Schema.decodeUnknownResult(Schema.toType(schema));
43
45
  })();
46
+ /** True if `key` is absent from the object, or if `isMember` is true for its value. */
47
+ const optional = (object, key, isMember) => !(key in object) || isMember(object[key]);
48
+ const isString = (value) => typeof value === "string";
49
+ const isTarget = (value) => isObject(value) && isStringArray(value.bindings);
50
+ const isCapability = (value) => isObject(value) && typeof value.description === "string" && optional(value, "audience", isStringArray) && optional(value, "input", isObject) && optional(value, "output", isObject) && optional(value, "state", isObject) && optional(value, "failures", (failures) => isRecordOf(failures, isObject)) && optional(value, "readOnly", (readOnly) => typeof readOnly === "boolean");
51
+ /**
52
+ * True if the value has the members and the member types of a catalog. It does not check the catalog rules: `check`
53
+ * does that.
54
+ */
55
+ const isCatalog = (value) => isObject(value) && typeof value.acidcat === "string" && optional(value, "title", isString) && optional(value, "description", isString) && isRecordOf(value.targets, isTarget) && optional(value, "$defs", (types) => isRecordOf(types, isObject)) && isRecordOf(value.capabilities, isCapability);
44
56
  const formatIssues = SchemaIssue.makeFormatterStandardSchemaV1();
45
57
  /** Check a catalog. When `text` is given, the file must also be in canonical form. */
46
58
  function check(document, text) {
@@ -49,6 +61,10 @@ function check(document, text) {
49
61
  errors: formatIssues(decoded.failure.issue).issues.map((issue) => `${(issue.path ?? []).map((key) => `[${JSON.stringify(key)}]`).join("") || "the catalog"}: ${issue.message}`),
50
62
  warnings: []
51
63
  };
64
+ if (!isCatalog(document)) return {
65
+ errors: ["the catalog: the members do not have the types of a catalog"],
66
+ warnings: []
67
+ };
52
68
  const catalog = document;
53
69
  const errors = ruleErrors(catalog);
54
70
  const warnings = [...unknownMembers(catalog), ...errors.length === 0 ? schemaWarnings(catalog) : []];
@@ -103,9 +119,12 @@ function referenceErrors(schema, types) {
103
119
  if ("$defs" in node) errors.push("has its own $defs; put shared types in the catalog's $defs");
104
120
  if (node.$ref === void 0) return;
105
121
  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`);
122
+ if (name === void 0 || !(name in types)) {
123
+ const reference = typeof node.$ref === "string" ? node.$ref : JSON.stringify(node.$ref);
124
+ errors.push(`references ${reference}, which is not a type`);
125
+ }
107
126
  });
108
127
  return errors;
109
128
  }
110
129
  //#endregion
111
- export { bundle, check, viewOf$1 as viewOf };
130
+ export { bundle, check, isCatalog, viewOf$1 as viewOf };
@@ -1,4 +1,4 @@
1
- import type { CatalogDocument } from "@acid-cat/spec-ts";
1
+ import type { CatalogDocument, Outcome } from "@acid-cat/spec-ts";
2
2
  export interface Check {
3
3
  readonly binding: string;
4
4
  readonly name: string;
@@ -9,6 +9,11 @@ export interface Check {
9
9
  }
10
10
  /** The conformance fixture: the catalog that each implementation serves. */
11
11
  export declare const fixture: CatalogDocument;
12
+ /**
13
+ * True if the value is an outcome (spec/invocation.md): `{ ok: true, value }`, `{ ok: false, error }`, or a call
14
+ * failure `{ kind, message }` with no `ok`.
15
+ */
16
+ export declare const isOutcome: (value: unknown) => value is Outcome<unknown, unknown>;
12
17
  /** The ids of the capabilities in the view of a target of the fixture. */
13
18
  export declare const idsOf: (target: string) => string[];
14
19
  /** The time, in milliseconds, that each check has to settle when `conform` gets no other time. */
@@ -1,10 +1,21 @@
1
+ import { isObject } from "../json.js";
1
2
  import fixture_acidcat_default from "../json/fixture.acidcat.json.js";
2
- import { viewOf } from "@acid-cat/spec-ts";
3
+ import { CALL_FAILURE_KINDS, viewOf } from "@acid-cat/spec-ts";
3
4
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
4
5
  //#region src/conform/check.ts
5
6
  /** The result of one conformance check, and helpers to write checks. */
6
7
  /** The conformance fixture: the catalog that each implementation serves. */
7
8
  const fixture = fixture_acidcat_default;
9
+ /**
10
+ * True if the value is an outcome (spec/invocation.md): `{ ok: true, value }`, `{ ok: false, error }`, or a call
11
+ * failure `{ kind, message }` with no `ok`.
12
+ */
13
+ const isOutcome = (value) => {
14
+ if (!isObject(value)) return false;
15
+ if (value.ok === true) return "value" in value;
16
+ if (value.ok === false) return "error" in value;
17
+ return !("ok" in value) && CALL_FAILURE_KINDS.some((kind) => kind === value.kind) && typeof value.message === "string";
18
+ };
8
19
  /** The ids of the capabilities in the view of a target of the fixture. */
9
20
  const idsOf = (target) => Object.keys(viewOf(fixture, target).capabilities).sort();
10
21
  /** The time, in milliseconds, that each check has to settle when `conform` gets no other time. */
@@ -88,4 +99,4 @@ const sorted = (values) => [...values].sort();
88
99
  /** An origin that no deployment allows. */
89
100
  const FORBIDDEN_ORIGIN = "https://not-allowed.acid-cat.invalid";
90
101
  //#endregion
91
- export { Checks, DEADLINE, FORBIDDEN_ORIGIN, abortable, expect, expectEqual, fixture, idsOf, sorted, until };
102
+ export { Checks, DEADLINE, FORBIDDEN_ORIGIN, abortable, expect, expectEqual, fixture, idsOf, isOutcome, sorted, until };
@@ -1,3 +1,4 @@
1
+ import { isArrayOf, isObject, memberOf, parseJson } from "../json.js";
1
2
  import { Checks, expect, expectEqual, idsOf, sorted, until } from "./check.js";
2
3
  import { spawn } from "node:child_process";
3
4
  import { existsSync } from "node:fs";
@@ -19,13 +20,22 @@ const USAGES = {
19
20
  "fixture.switch.set": "fixture switch set --on",
20
21
  "fixture.toggle": "fixture toggle"
21
22
  };
23
+ const isEntry = (value) => isObject(value) && typeof value.id === "string";
24
+ /** The list that the `list` command writes to standard output. The check fails when it is not a list of entries. */
25
+ function entriesOf(stdout) {
26
+ const list = parseJson(stdout);
27
+ if (!isArrayOf(list, isEntry)) throw new Error(`list must give a list of objects with an id, got ${JSON.stringify(list)}`);
28
+ return list;
29
+ }
22
30
  /**
23
31
  * Start the command line. When the signal aborts, the process is killed, and its run rejects. The rejection is observed
24
32
  * here too, so that a check that stops waiting first, for example at the deadline, records a failure and does not end
25
33
  * the process with an unhandled rejection.
26
34
  */
27
35
  function start(command, args, signal) {
28
- const child = spawn(command[0], [...command.slice(1), ...args], {
36
+ const [program, ...programArgs] = command;
37
+ if (program === void 0) throw new Error("the command that starts the command line is empty");
38
+ const child = spawn(program, [...programArgs, ...args], {
29
39
  stdio: [
30
40
  "ignore",
31
41
  "pipe",
@@ -59,12 +69,12 @@ async function checkCli(command, options) {
59
69
  await checks.run("a value goes to standard output with exit code 0", `${RULE}#outcomes`, async (signal) => {
60
70
  const result = await run(signal, "fixture", "echo", "--text", "hello");
61
71
  expectEqual(result.code, 0, "exit code");
62
- expectEqual(JSON.parse(result.stdout), { text: "hello" }, "standard output");
72
+ expectEqual(parseJson(result.stdout), { text: "hello" }, "standard output");
63
73
  });
64
74
  await checks.run("a failure goes to standard output with exit code 1", `${RULE}#outcomes`, async (signal) => {
65
75
  const result = await run(signal, "fixture", "refuse");
66
76
  expectEqual(result.code, 1, "exit code");
67
- expectEqual(JSON.parse(result.stdout)._tag, "Refused", "_tag");
77
+ expectEqual(memberOf(parseJson(result.stdout), "_tag"), "Refused", "_tag");
68
78
  });
69
79
  await checks.run("a defect goes to standard error with exit code 5", `${RULE}#outcomes`, async (signal) => {
70
80
  const result = await run(signal, "fixture", "crash");
@@ -141,14 +151,14 @@ async function checkCli(command, options) {
141
151
  await checks.run("call gives the value of a capability", `${RULE}#commands`, async (signal) => {
142
152
  const result = await run(signal, "call", "fixture.echo", "{\"text\":\"hello\"}");
143
153
  expectEqual(result.code, 0, "exit code");
144
- expectEqual(JSON.parse(result.stdout), { text: "hello" }, "standard output");
154
+ expectEqual(parseJson(result.stdout), { text: "hello" }, "standard output");
145
155
  });
146
156
  await checks.run("list gives the view", `${RULE}#commands`, async (signal) => {
147
157
  const result = await run(signal, "list");
148
- expectEqual(sorted(JSON.parse(result.stdout).map((entry) => entry.id)), idsOf("cli"), "ids");
158
+ expectEqual(sorted(entriesOf(result.stdout).map((entry) => entry.id)), idsOf("cli"), "ids");
149
159
  });
150
160
  await checks.run("list gives the usage of each capability, with its flags", `${RULE}#commands`, async (signal) => {
151
- const entries = JSON.parse((await run(signal, "list")).stdout);
161
+ const entries = entriesOf((await run(signal, "list")).stdout);
152
162
  for (const { id, usage } of entries) {
153
163
  expect(typeof usage === "string", `${id} must have a usage`);
154
164
  const [program, ...rest] = usage.split(" ");
@@ -1,4 +1,3 @@
1
- /** Conformance checks for the HTTP binding (spec/bindings/http.md). */
2
1
  import { Checks } from "./check.js";
3
2
  export declare function checkHttp(base: string, options: {
4
3
  readonly maximumBytes: number;
@@ -1,7 +1,15 @@
1
+ import { isArrayOf, isObject, memberOf, readJson } from "../json.js";
1
2
  import { Checks, FORBIDDEN_ORIGIN, expect, expectEqual, idsOf, sorted } from "./check.js";
2
3
  //#region src/conform/http.ts
3
4
  /** Conformance checks for the HTTP binding (spec/bindings/http.md). */
4
5
  const RULE = "spec/bindings/http.md";
6
+ const isEntry = (value) => isObject(value) && typeof value.id === "string";
7
+ /** The list that `GET /capabilities` gives. The check fails when it is not a list of entries. */
8
+ async function entriesOf(response) {
9
+ const list = await readJson(response);
10
+ if (!isArrayOf(list, isEntry)) throw new Error(`GET /capabilities must give a list of objects with an id, got ${JSON.stringify(list)}`);
11
+ return list;
12
+ }
5
13
  async function checkHttp(base, options) {
6
14
  const checks = new Checks("http", options.deadline);
7
15
  const post = (signal, id, body, headers = {}) => fetch(`${base}/capabilities/${id}`, {
@@ -10,28 +18,28 @@ async function checkHttp(base, options) {
10
18
  "Content-Type": "application/json",
11
19
  ...headers
12
20
  },
13
- body: typeof body === "string" ? body : body instanceof Uint8Array ? new Blob([body]) : JSON.stringify(body),
21
+ body: typeof body === "string" ? body : body instanceof Uint8Array ? new Blob([new Uint8Array(body)]) : JSON.stringify(body),
14
22
  signal
15
23
  });
16
- const json = async (response) => await response.json();
24
+ const kindOf = async (response) => memberOf(await readJson(response), "kind");
17
25
  await checks.run("GET /capabilities lists the view with availability", `${RULE}#requests`, async (signal) => {
18
- const list = await (await fetch(`${base}/capabilities`, { signal })).json();
26
+ const list = await entriesOf(await fetch(`${base}/capabilities`, { signal }));
19
27
  expectEqual(sorted(list.map((entry) => entry.id)), idsOf("api"), "ids");
20
28
  expect(list.every((entry) => typeof entry.available === "boolean"), "each entry must have available");
21
29
  });
22
30
  await checks.run("a value has the status 200", `${RULE}#requests`, async (signal) => {
23
31
  const response = await post(signal, "fixture.echo", { text: "hello" });
24
32
  expectEqual(response.status, 200, "status");
25
- expectEqual(await json(response), {
33
+ expectEqual(await readJson(response), {
26
34
  ok: true,
27
35
  value: { text: "hello" }
28
36
  }, "body");
29
37
  });
30
38
  await checks.run("a failure has the status 200 and ok: false", `${RULE}#requests`, async (signal) => {
31
39
  const response = await post(signal, "fixture.refuse", {});
32
- const body = await json(response);
40
+ const body = await readJson(response);
33
41
  expectEqual(response.status, 200, "status");
34
- expect(body.ok === false && body.error?._tag === "Refused", `got ${JSON.stringify(body)}`);
42
+ expect(memberOf(body, "ok") === false && memberOf(memberOf(body, "error"), "_tag") === "Refused", `got ${JSON.stringify(body)}`);
35
43
  });
36
44
  for (const [name, id, input, status, kind] of [
37
45
  [
@@ -58,16 +66,16 @@ async function checkHttp(base, options) {
58
66
  ]) await checks.run(name, `${RULE}#requests`, async (signal) => {
59
67
  const response = await post(signal, id, input);
60
68
  expectEqual(response.status, status, "status");
61
- expectEqual((await json(response)).kind, kind, "kind");
69
+ expectEqual(await kindOf(response), kind, "kind");
62
70
  });
63
71
  await checks.run("an unavailable capability has the status 503", `${RULE}#requests`, async (signal) => {
64
72
  await post(signal, "fixture.switch.set", { on: false });
65
73
  try {
66
- const toggle = (await (await fetch(`${base}/capabilities`, { signal })).json()).find((entry) => entry.id === "fixture.toggle");
74
+ const toggle = (await entriesOf(await fetch(`${base}/capabilities`, { signal }))).find((entry) => entry.id === "fixture.toggle");
67
75
  expect(toggle?.available === false && typeof toggle.reason === "string", "fixture.toggle must be unavailable, with a reason");
68
76
  const response = await post(signal, "fixture.toggle", {});
69
77
  expectEqual(response.status, 503, "status");
70
- expectEqual((await json(response)).kind, "unavailable", "kind");
78
+ expectEqual(await kindOf(response), "unavailable", "kind");
71
79
  } finally {
72
80
  await post(AbortSignal.timeout(options.deadline), "fixture.switch.set", { on: true });
73
81
  }
@@ -1,3 +1,4 @@
1
+ import { isObject, memberOf, parseJson, readJson } from "../json.js";
1
2
  import { Checks, FORBIDDEN_ORIGIN, expect, expectEqual, fixture, idsOf, sorted } from "./check.js";
2
3
  import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
3
4
  //#region src/conform/mcp.ts
@@ -15,7 +16,11 @@ async function mcpClient(url, signal) {
15
16
  await client.connect(new StreamableHTTPClientTransport(new URL(url)), signal ? { signal } : void 0);
16
17
  return client;
17
18
  }
18
- const textOf = (result) => JSON.parse(result.content?.[0]?.text ?? "null");
19
+ /** The JSON value in the first text content of a tool result, or `null` when there is no text content. */
20
+ const textOf = (result) => {
21
+ const first = result.content[0];
22
+ return parseJson(first?.type === "text" ? first.text : "null");
23
+ };
19
24
  async function checkMcp(url, options) {
20
25
  const checks = new Checks("mcp", options.deadline);
21
26
  let client;
@@ -41,9 +46,9 @@ async function checkMcp(url, options) {
41
46
  const refuse = tools.find((tool) => tool.name === "fixture.refuse");
42
47
  expect(echo?.annotations?.readOnlyHint === true, "fixture.echo must have readOnlyHint: true");
43
48
  expect(refuse?.annotations?.readOnlyHint !== true, "fixture.refuse must not have readOnlyHint: true");
44
- expect((echo?.outputSchema)?.type === "object", "fixture.echo must have an object outputSchema");
49
+ expect(echo?.outputSchema?.type === "object", "fixture.echo must have an object outputSchema");
45
50
  expect(refuse?.outputSchema === void 0, "fixture.refuse has no object output, so it must have no outputSchema");
46
- expect((echo?.inputSchema).type === "object", "an inputSchema must be an object");
51
+ expect(echo?.inputSchema.type === "object", "an inputSchema must be an object");
47
52
  });
48
53
  await checks.run("a value is a tool result with structured content", `${RULE}#tools`, async (signal) => {
49
54
  const result = await call(signal, "fixture.echo", { text: "hello" });
@@ -55,21 +60,23 @@ async function checkMcp(url, options) {
55
60
  const result = await call(signal, "fixture.refuse", {});
56
61
  expect(result.isError === true, "a failure must be an error result");
57
62
  const body = textOf(result);
58
- expect(body.ok === false && body.error?._tag === "Refused", `expected { ok: false, error: { _tag: "Refused" } }, got ${JSON.stringify(body)}`);
63
+ expect(memberOf(body, "ok") === false && memberOf(memberOf(body, "error"), "_tag") === "Refused", `expected { ok: false, error: { _tag: "Refused" } }, got ${JSON.stringify(body)}`);
59
64
  });
60
65
  await checks.run("a defect is an internal call failure with no internal details", `${RULE}#tools`, async (signal) => {
61
66
  const result = await call(signal, "fixture.crash", {});
62
67
  const body = textOf(result);
68
+ expect(isObject(body), `expected an object, got ${JSON.stringify(body)}`);
63
69
  expect(result.isError === true && body.kind === "internal" && !("ok" in body), `expected { kind: "internal" }, got ${JSON.stringify(body)}`);
64
- expect(!/\n\s+at /.test(body.message ?? ""), "the message must not contain a stack trace");
70
+ const message = typeof body.message === "string" ? body.message : "";
71
+ expect(!/\n\s+at /.test(message), "the message must not contain a stack trace");
65
72
  });
66
73
  await checks.run("an input that does not agree with its schema is invalid-input", `${RULE}#tools`, async (signal) => {
67
74
  const result = await call(signal, "fixture.echo", { text: 1 });
68
- expectEqual(textOf(result).kind, "invalid-input", "kind");
75
+ expectEqual(memberOf(textOf(result), "kind"), "invalid-input", "kind");
69
76
  });
70
77
  await checks.run("a capability outside the view is an unknown tool", `${RULE}#tools`, async (signal) => {
71
78
  const error = await call(signal, "fixture.hidden", {}).then(() => void 0, (caught) => caught);
72
- expect(error?.code === -32602, `expected the JSON-RPC error -32602, got ${JSON.stringify(error)}`);
79
+ expect(memberOf(error, "code") === -32602, `expected the JSON-RPC error -32602, got ${JSON.stringify(error)}`);
73
80
  });
74
81
  await checks.run("an unavailable capability is not listed", `${RULE}#tools`, async (signal) => {
75
82
  await call(signal, "fixture.switch.set", { on: false });
@@ -81,19 +88,19 @@ async function checkMcp(url, options) {
81
88
  }
82
89
  });
83
90
  await mcp.close();
84
- const post = (signal, init) => fetch(url, {
91
+ const post = (signal, { headers = {}, body }) => fetch(url, {
85
92
  method: "POST",
86
- ...init,
93
+ body,
87
94
  headers: {
88
95
  "Content-Type": "application/json",
89
- ...init.headers
96
+ ...headers
90
97
  },
91
98
  signal
92
99
  });
93
100
  const rejection = async (response, status, code) => {
94
101
  expectEqual(response.status, status, "status");
95
102
  expectEqual(response.headers.get("Cache-Control"), "no-store", "Cache-Control");
96
- if (code !== void 0) expectEqual((await response.json()).error?.code, code, "JSON-RPC error code");
103
+ if (code !== void 0) expectEqual(memberOf(memberOf(await readJson(response), "error"), "code"), code, "JSON-RPC error code");
97
104
  };
98
105
  await checks.run("a method other than POST is rejected with 405", `${RULE}#server`, async (signal) => {
99
106
  const response = await fetch(url, { signal });
@@ -1,7 +1,8 @@
1
1
  import type { Outcome } from "@acid-cat/spec-ts";
2
+ import type { JsonObject } from "../json.js";
2
3
  import { Checks } from "./check.js";
3
4
  /** A call through one binding. When the signal aborts, the client stops the call and rejects with the reason of the signal. */
4
- export type Invoke = (id: string, input: object, signal?: AbortSignal) => Promise<Outcome<unknown, unknown>>;
5
+ export type Invoke = (id: string, input: JsonObject, signal?: AbortSignal) => Promise<Outcome<unknown, unknown>>;
5
6
  export declare function httpInvoke(base: string): Invoke;
6
7
  export declare function mcpInvoke(url: string, signal: AbortSignal): Promise<{
7
8
  readonly invoke: Invoke;
@@ -1,4 +1,5 @@
1
- import { Checks, expect, until } from "./check.js";
1
+ import { isStringArray, memberOf, parseJson, readJson } from "../json.js";
2
+ import { Checks, expect, isOutcome, until } from "./check.js";
2
3
  import { mcpClient } from "./mcp.js";
3
4
  import { SdkError } from "@modelcontextprotocol/client";
4
5
  import { randomUUID } from "node:crypto";
@@ -11,12 +12,15 @@ import fc from "fast-check";
11
12
  const RULE = "spec/invocation.md#cancellation-and-concurrency";
12
13
  function httpInvoke(base) {
13
14
  return async (id, input, signal) => {
14
- return await (await fetch(`${base}/capabilities/${id}`, {
15
+ const response = await fetch(`${base}/capabilities/${id}`, {
15
16
  method: "POST",
16
17
  headers: { "Content-Type": "application/json" },
17
18
  body: JSON.stringify(input),
18
19
  ...signal ? { signal } : {}
19
- })).json();
20
+ });
21
+ const outcome = await readJson(response);
22
+ if (!isOutcome(outcome)) throw new Error(`POST /capabilities/${id} gave ${JSON.stringify(outcome)}, which is not an outcome`);
23
+ return outcome;
20
24
  };
21
25
  }
22
26
  async function mcpInvoke(url, signal) {
@@ -28,15 +32,17 @@ async function mcpInvoke(url, signal) {
28
32
  name: id,
29
33
  arguments: input
30
34
  }, signal ? { signal } : void 0);
31
- const body = JSON.parse(result.content[0]?.text ?? "null");
35
+ const first = result.content[0];
36
+ const body = parseJson(first?.type === "text" ? first.text : "null");
32
37
  if (result.isError !== true) return {
33
38
  ok: true,
34
39
  value: result.structuredContent ?? body
35
40
  };
41
+ if (!isOutcome(body)) throw new Error(`the error result of ${id} gave ${JSON.stringify(body)}, which is not an outcome`);
36
42
  return body;
37
43
  } catch (error) {
38
44
  if (signal?.aborted && error instanceof SdkError && error.message === String(signal.reason)) throw signal.reason;
39
- if (error.code === -32602) return {
45
+ if (memberOf(error, "code") === -32602) return {
40
46
  kind: "unknown-capability",
41
47
  message: String(error)
42
48
  };
@@ -54,7 +60,7 @@ const operation = fc.oneof(fc.string({ maxLength: 40 }).map((text) => ({
54
60
  function agrees(operation, outcome) {
55
61
  switch (operation.kind) {
56
62
  case "echo": return "ok" in outcome && outcome.ok && JSON.stringify(outcome.value) === JSON.stringify({ text: operation.text });
57
- case "refuse": return "ok" in outcome && !outcome.ok && outcome.error._tag === "Refused";
63
+ case "refuse": return "ok" in outcome && !outcome.ok && memberOf(outcome.error, "_tag") === "Refused";
58
64
  case "crash": return "kind" in outcome && outcome.kind === "internal";
59
65
  case "invalid": return "kind" in outcome && outcome.kind === "invalid-input";
60
66
  }
@@ -78,7 +84,9 @@ const isCancellation = (settled, reason) => "outcome" in settled ? "kind" in set
78
84
  async function heldKeys(invoke, signal) {
79
85
  const outcome = await invoke("fixture.held", {}, signal);
80
86
  expect("ok" in outcome && outcome.ok, `fixture.held gave ${JSON.stringify(outcome)}`);
81
- return outcome.value.keys;
87
+ const keys = memberOf(outcome.value, "keys");
88
+ expect(isStringArray(keys), `fixture.held gave ${JSON.stringify(outcome)}, with no list of keys`);
89
+ return keys;
82
90
  }
83
91
  /**
84
92
  * Start one call to fixture.hold for each flag, and wait until each call holds. Then cancel each call whose flag is
@@ -138,8 +146,10 @@ async function checkProperties(binding, invoke, deadline) {
138
146
  maxLength: 12
139
147
  }), async (operations) => {
140
148
  fc.pre(!signal.aborted);
141
- const outcomes = await Promise.all(operations.map((op) => callFor(invoke, op, signal)));
142
- operations.forEach((op, index) => expect(agrees(op, outcomes[index]), `${op.kind} gave ${JSON.stringify(outcomes[index])}`));
149
+ (await Promise.all(operations.map(async (op) => ({
150
+ op,
151
+ outcome: await callFor(invoke, op, signal)
152
+ })))).forEach(({ op, outcome }) => expect(agrees(op, outcome), `${op.kind} gave ${JSON.stringify(outcome)}`));
143
153
  }), {
144
154
  numRuns: 25,
145
155
  includeErrorInReport: true
@@ -23,7 +23,8 @@ function serve(routes) {
23
23
  async function answer(incoming, outgoing, prefixes, routes) {
24
24
  const url = new URL(incoming.url ?? "/", `http://${incoming.headers.host ?? "127.0.0.1"}`);
25
25
  const prefix = prefixes.find((candidate) => url.pathname === candidate || url.pathname.startsWith(`${candidate}/`));
26
- if (prefix === void 0) {
26
+ const handler = prefix === void 0 ? void 0 : routes[prefix];
27
+ if (handler === void 0) {
27
28
  outgoing.writeHead(404).end();
28
29
  return;
29
30
  }
@@ -35,17 +36,19 @@ async function answer(incoming, outgoing, prefixes, routes) {
35
36
  for (const [name, value] of Object.entries(incoming.headers)) if (Array.isArray(value)) value.forEach((item) => headers.append(name, item));
36
37
  else if (value !== void 0) headers.set(name, value);
37
38
  const hasBody = incoming.method !== "GET" && incoming.method !== "HEAD";
38
- const request = new Request(url, {
39
+ const init = {
39
40
  method: incoming.method ?? "GET",
40
41
  headers,
41
- signal: abort.signal,
42
- ...hasBody ? {
43
- body: incoming,
44
- duplex: "half"
45
- } : {}
42
+ signal: abort.signal
43
+ };
44
+ const withBody = () => ({
45
+ ...init,
46
+ body: bodyOf(incoming),
47
+ duplex: "half"
46
48
  });
49
+ const request = new Request(url, hasBody ? withBody() : init);
47
50
  try {
48
- const response = await routes[prefix](request);
51
+ const response = await handler(request);
49
52
  outgoing.writeHead(response.status, Object.fromEntries(response.headers));
50
53
  if (response.body === null) {
51
54
  outgoing.end();
@@ -58,5 +61,39 @@ async function answer(incoming, outgoing, prefixes, routes) {
58
61
  outgoing.end();
59
62
  }
60
63
  }
64
+ /**
65
+ * The body of an incoming request, as a web stream. The stream reads the request only when the handler reads the body,
66
+ * and the request pauses after each chunk until the handler reads again. A request that the handler does not read stays
67
+ * unread, so that Node.js discards it after the response. When the handler cancels the stream, the request discards the
68
+ * rest of its body.
69
+ */
70
+ function bodyOf(incoming) {
71
+ let open = true;
72
+ let reading = false;
73
+ const close = (end) => {
74
+ if (open) end();
75
+ open = false;
76
+ };
77
+ return new ReadableStream({
78
+ pull(controller) {
79
+ if (!reading) {
80
+ reading = true;
81
+ incoming.on("data", (chunk) => {
82
+ if (!open) return;
83
+ if (!(chunk instanceof Uint8Array)) return close(() => controller.error(/* @__PURE__ */ new TypeError("a chunk of the request body is not bytes")));
84
+ controller.enqueue(chunk);
85
+ incoming.pause();
86
+ });
87
+ incoming.on("end", () => close(() => controller.close()));
88
+ incoming.on("error", (error) => close(() => controller.error(error)));
89
+ }
90
+ incoming.resume();
91
+ },
92
+ cancel() {
93
+ open = false;
94
+ incoming.resume();
95
+ }
96
+ }, { highWaterMark: 0 });
97
+ }
61
98
  //#endregion
62
99
  export { serve };
@@ -5,6 +5,12 @@ export interface RecordedTool {
5
5
  readonly description: string;
6
6
  readonly inputSchema: unknown;
7
7
  }
8
+ /** A tool, as a page gives it to `registerTool`. */
9
+ export interface HostTool extends RecordedTool {
10
+ execute(input: unknown, options: {
11
+ readonly signal: AbortSignal;
12
+ }): Promise<unknown>;
13
+ }
8
14
  /** What a call to a tool gives: the value that `execute` resolves with, or the error that it rejects with. */
9
15
  export type ToolCall = {
10
16
  readonly resolved: unknown;
@@ -20,7 +26,7 @@ export type ToolCall = {
20
26
  /** The host that the checks give to the page. */
21
27
  export interface TestHost {
22
28
  readonly modelContext: {
23
- registerTool(tool: object, options: {
29
+ registerTool(tool: HostTool, options: {
24
30
  readonly signal: AbortSignal;
25
31
  }): void;
26
32
  };
@@ -1,3 +1,4 @@
1
+ import { isArrayOf, isObject, isStringArray, memberOf, parseJson } from "../json.js";
1
2
  import { Checks, abortable, expect, expectEqual, fixture, idsOf, sorted, until } from "./check.js";
2
3
  import { REFERENCE, visitObjects } from "@acid-cat/spec-ts";
3
4
  //#region src/conform/webmcp.ts
@@ -20,10 +21,9 @@ function createTestHost() {
20
21
  const calls = /* @__PURE__ */ new Map();
21
22
  return {
22
23
  modelContext: { registerTool(tool, options) {
23
- const recorded = tool;
24
- registered.set(recorded.name, recorded);
24
+ registered.set(tool.name, tool);
25
25
  options.signal.addEventListener("abort", () => {
26
- if (registered.get(recorded.name) === recorded) registered.delete(recorded.name);
26
+ if (registered.get(tool.name) === tool) registered.delete(tool.name);
27
27
  });
28
28
  } },
29
29
  tools: () => [...registered.values()].map((tool) => ({
@@ -39,7 +39,10 @@ function createTestHost() {
39
39
  try {
40
40
  return { resolved: await tool.execute(input, { signal: controller.signal }) };
41
41
  } catch (error) {
42
- const { name: errorName, kind, message } = error;
42
+ const hasMembers = typeof error === "object" && error !== null;
43
+ const errorName = hasMembers && "name" in error ? error.name : void 0;
44
+ const kind = hasMembers && "kind" in error ? error.kind : void 0;
45
+ const message = hasMembers && "message" in error ? error.message : void 0;
43
46
  return { rejected: {
44
47
  name: String(errorName),
45
48
  kind,
@@ -54,6 +57,30 @@ function createTestHost() {
54
57
  }
55
58
  };
56
59
  }
60
+ /** The tools that a host gives, after they pass through JSON. The check fails when the value is not a list of tools. */
61
+ function toolsOf(value) {
62
+ const isTool = (tool) => isObject(tool) && typeof tool.name === "string" && typeof tool.description === "string";
63
+ if (!isArrayOf(value, isTool)) throw new Error(`the host must give a list of tools, got ${JSON.stringify(value)}`);
64
+ return value.map((tool) => ({
65
+ name: tool.name,
66
+ description: tool.description,
67
+ inputSchema: tool.inputSchema
68
+ }));
69
+ }
70
+ /** A tool call that a host gives, after it passes through JSON. The check fails when the value is not a tool call. */
71
+ function toolCallOf(value) {
72
+ if (isObject(value)) {
73
+ if (value.missing === true) return { missing: true };
74
+ if ("resolved" in value) return { resolved: value.resolved };
75
+ const rejected = value.rejected;
76
+ if (isObject(rejected) && typeof rejected.name === "string" && typeof rejected.message === "string") return { rejected: {
77
+ name: rejected.name,
78
+ kind: rejected.kind,
79
+ message: rejected.message
80
+ } };
81
+ }
82
+ throw new Error(`the host must give a tool call, got ${JSON.stringify(value)}`);
83
+ }
57
84
  let lastCall = 0;
58
85
  function driverOf(host) {
59
86
  return {
@@ -76,10 +103,10 @@ function driverOf(host) {
76
103
  async function processDriver(install) {
77
104
  const host = createTestHost();
78
105
  const remove = await install(host.modelContext);
79
- const copy = (value) => JSON.parse(JSON.stringify(value));
106
+ const copy = (value) => parseJson(JSON.stringify(value));
80
107
  return driverOf({
81
- tools: async () => copy(host.tools()),
82
- call: async (name, input, id) => copy(await host.call(name, input, id)),
108
+ tools: async () => toolsOf(copy(host.tools())),
109
+ call: async (name, input, id) => toolCallOf(copy(await host.call(name, input, id))),
83
110
  cancel: async (id) => host.cancel(id),
84
111
  close: async () => {
85
112
  if (typeof remove === "function") await remove();
@@ -105,23 +132,22 @@ async function browserDriver(url) {
105
132
  }
106
133
  `);
107
134
  await page.goto(url);
135
+ const host = (method, ...args) => page.evaluate(`window.__acidCatConform.${method}(${args.map((arg) => JSON.stringify(arg) ?? "undefined").join(", ")})`);
108
136
  return driverOf({
109
137
  tools: async () => {
110
138
  if (errors.length > 0) throw new Error(`The page has errors: ${errors.join("; ")}`);
111
- return page.evaluate(() => window.__acidCatConform.tools());
139
+ return toolsOf(await host("tools"));
140
+ },
141
+ call: async (name, input, id) => toolCallOf(await host("call", name, input, id)),
142
+ cancel: async (id) => {
143
+ await host("cancel", id);
112
144
  },
113
- call: (name, input, id) => page.evaluate(([name, input, id]) => window.__acidCatConform.call(name, input, id), [
114
- name,
115
- input,
116
- id
117
- ]),
118
- cancel: (id) => page.evaluate((id) => window.__acidCatConform.cancel(id), id),
119
145
  close: () => browser.close()
120
146
  });
121
147
  }
122
148
  /** True if each `$ref` of a schema refers to a type in its own `$defs` (spec/catalog.md#bundling). */
123
149
  function isBundled(schema) {
124
- const types = schema.$defs ?? {};
150
+ const types = isObject(schema.$defs) ? schema.$defs : {};
125
151
  let bundled = true;
126
152
  visitObjects(schema, (node) => {
127
153
  if (typeof node.$ref !== "string") return;
@@ -135,7 +161,9 @@ const names = async (driver, signal) => sorted((await driver.tools(signal)).map(
135
161
  async function heldKeys(driver, signal) {
136
162
  const call = await driver.call("fixture.held", {}, { signal });
137
163
  expect("resolved" in call, `fixture.held gave ${JSON.stringify(call)}`);
138
- return call.resolved.value.keys;
164
+ const keys = memberOf(memberOf(call.resolved, "value"), "keys");
165
+ expect(isStringArray(keys), `fixture.held gave ${JSON.stringify(call)}, with no list of keys`);
166
+ return keys;
139
167
  }
140
168
  async function checkWebMcp(driver, options) {
141
169
  const checks = new Checks("webmcp", options.deadline);
@@ -148,10 +176,12 @@ async function checkWebMcp(driver, options) {
148
176
  for (const tool of await driver.tools(signal)) {
149
177
  expectEqual(tool.description, view[tool.name]?.description, `description of ${tool.name}`);
150
178
  const schema = tool.inputSchema;
179
+ expect(isObject(schema), `the input schema of ${tool.name} must be an object`);
151
180
  const input = view[tool.name]?.input ?? {};
181
+ const required = (object) => isStringArray(object.required) ? object.required : [];
152
182
  expectEqual(schema.type, "object", `type of the input schema of ${tool.name}`);
153
183
  expectEqual(sorted(Object.keys(schema.properties ?? {})), sorted(Object.keys(input.properties ?? {})), `properties of ${tool.name}`);
154
- expectEqual(sorted(schema.required ?? []), sorted(input.required ?? []), `required properties of ${tool.name}`);
184
+ expectEqual(sorted(required(schema)), sorted(required(input)), `required properties of ${tool.name}`);
155
185
  expect(isBundled(schema), `the input schema of ${tool.name} must be bundled`);
156
186
  }
157
187
  });
@@ -164,7 +194,7 @@ async function checkWebMcp(driver, options) {
164
194
  await checks.run("a failure resolves as a result", `${RULE}#tools`, async (signal) => {
165
195
  const call = await driver.call("fixture.refuse", {}, { signal });
166
196
  expect("resolved" in call, `the call must resolve, got ${JSON.stringify(call)}`);
167
- expectEqual(call.resolved.error?._tag, "Refused", "_tag");
197
+ expectEqual(memberOf(memberOf(call.resolved, "error"), "_tag"), "Refused", "_tag");
168
198
  });
169
199
  for (const [name, capability, input, kind, errorName] of [[
170
200
  "a defect rejects with the kind internal",
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.4.0";
2
+ var version = "0.5.0";
3
3
  //#endregion
4
4
  export { version };
package/dist/json.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Checks of JSON values at run time. A value from a file, a process or a network is not trusted. These checks give it a
3
+ * type only after they examine it.
4
+ */
5
+ import type { JsonObject } from "@acid-cat/spec-ts";
6
+ export type { JsonObject };
7
+ /** True if the value is a JSON object: not `null`, and not an array. */
8
+ export declare const isObject: (value: unknown) => value is JsonObject;
9
+ /** True if the value is an array of strings. */
10
+ export declare const isStringArray: (value: unknown) => value is readonly string[];
11
+ /** True if the value is a JSON object, and `isMember` is true for each of its member values. */
12
+ export declare const isRecordOf: <T>(value: unknown, isMember: (member: unknown) => member is T) => value is {
13
+ readonly [key: string]: T;
14
+ };
15
+ /** The member `key` of a value, or `undefined` when the value is not an object. */
16
+ export declare const memberOf: (value: unknown, key: string) => unknown;
17
+ /** Parse JSON text. The value is not trusted, so its type is `unknown`. */
18
+ export declare function parseJson(text: string): unknown;
19
+ /** Read the body of a response as JSON. The value is not trusted, so its type is `unknown`. */
20
+ export declare function readJson(response: Response): Promise<unknown>;
21
+ /** True if the value is an array, and `isItem` is true for each of its items. */
22
+ export declare const isArrayOf: <T>(value: unknown, isItem: (item: unknown) => item is T) => value is readonly T[];
package/dist/json.js ADDED
@@ -0,0 +1,21 @@
1
+ //#region src/json.ts
2
+ /** True if the value is a JSON object: not `null`, and not an array. */
3
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
4
+ /** True if the value is an array of strings. */
5
+ const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
6
+ /** True if the value is a JSON object, and `isMember` is true for each of its member values. */
7
+ const isRecordOf = (value, isMember) => isObject(value) && Object.values(value).every(isMember);
8
+ /** The member `key` of a value, or `undefined` when the value is not an object. */
9
+ const memberOf = (value, key) => isObject(value) ? value[key] : void 0;
10
+ /** Parse JSON text. The value is not trusted, so its type is `unknown`. */
11
+ function parseJson(text) {
12
+ return JSON.parse(text);
13
+ }
14
+ /** Read the body of a response as JSON. The value is not trusted, so its type is `unknown`. */
15
+ async function readJson(response) {
16
+ return await response.json();
17
+ }
18
+ /** True if the value is an array, and `isItem` is true for each of its items. */
19
+ const isArrayOf = (value, isItem) => Array.isArray(value) && value.every(isItem);
20
+ //#endregion
21
+ export { isArrayOf, isObject, isRecordOf, isStringArray, memberOf, parseJson, readJson };
package/dist/main.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { check, viewOf } from "./catalog.js";
2
+ import { parseJson } from "./json.js";
3
+ import { check, isCatalog, viewOf } from "./catalog.js";
3
4
  import { DEADLINE } from "./conform/check.js";
4
5
  import { conform } from "./conform/index.js";
5
6
  import { version } from "./json/package.json.js";
@@ -17,7 +18,7 @@ const readCatalog = Effect.fn("readCatalog")(function* (file) {
17
18
  const text = yield* (yield* FileSystem.FileSystem).readFileString(file);
18
19
  return {
19
20
  text,
20
- document: JSON.parse(text)
21
+ document: parseJson(text)
21
22
  };
22
23
  });
23
24
  const fail = Effect.sync(() => {
@@ -55,8 +56,11 @@ const viewCommand = Command.make("view", {
55
56
  file: Argument.String("catalog").pipe(Argument.withDescription("A catalog file")),
56
57
  target: Argument.String("target").pipe(Argument.withDescription("The name of a target in the catalog"))
57
58
  }, Effect.fn(function* ({ file, target }) {
58
- const { document } = yield* readCatalog(file);
59
- const catalog = document;
59
+ const { document: catalog } = yield* readCatalog(file);
60
+ if (!isCatalog(catalog)) {
61
+ yield* Console.error(`${file}: error: the file is not a catalog; run \`acid-cat check ${file}\``);
62
+ return yield* fail;
63
+ }
60
64
  if (!(target in catalog.targets)) {
61
65
  yield* Console.error(`${file}: error: there is no target ${target}`);
62
66
  return yield* fail;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acid-cat/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "The AcidCat command line: check, format and view catalogs, and test implementations.",
5
5
  "keywords": [
6
6
  "acidcat",
@@ -37,12 +37,12 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "@acid-cat/catalog": "0.4.0",
41
- "@acid-cat/spec-ts": "0.4.0",
42
- "@effect/platform-node": "4.0.5",
43
- "@effect/platform-node-shared": "4.0.5",
40
+ "@acid-cat/catalog": "0.5.0",
41
+ "@acid-cat/spec-ts": "0.5.0",
42
+ "@effect/platform-node": "4.0.6",
43
+ "@effect/platform-node-shared": "4.0.6",
44
44
  "@modelcontextprotocol/client": "2.3.1",
45
- "effect": "4.0.5",
45
+ "effect": "4.0.6",
46
46
  "fast-check": "4.10.2"
47
47
  },
48
48
  "peerDependencies": {