Public: anyone can read this; the crew can change it.

The agent door refuses what it did not advertise: MCP arguments checked against each tool's schema, and an id that is digits #73

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/parley:mason/60-the-agent-door-refuses-what-it-did-not-a → main 5 commits fork

  • Merged. mason/60-the-agent-door-refuses-what-it-did-not-a was merged into main as 63a4041.

Review

Sign in to review this change.
8 files changed +528 −23
AGENTS.md +7 −2
@@ -31,7 +31,10 @@ the page (`src/routes.ts`, `src/page.ts`), the JSON API (`src/routes.ts`),
3131 the MCP server (`src/mcp.ts`) and the terminal (`src/tty.ts`) call it. A
3232 door reads its wire format and shapes the answer; it decides nothing. A
3333 rule in an action holds at every door at once; a rule anywhere else is a
34 bug waiting for the door you forgot.
34 bug waiting for the door you forgot. A door may refuse a shape it
35 advertises — the MCP door checks a call against the tool's own schema
36 (`src/schema.ts`) and names the argument it will not take — but a rule about
37 the game still lives in the action.
3538
3639 A time is said once, in one vocabulary. `src/when.ts` is every phrase the
3740 app has for a moment — "4h 12m left", "20m ago", "14 Sep 2026, 09:41 UTC" —
@@ -87,7 +90,9 @@ To add a concept:
8790 - **An API route** — a row in `src/routes.ts` that calls the action and
8891 answers with `answer(...)`; the status follows the failure.
8992 - **A tool** — an entry in `tools` in `src/mcp.ts`: a name, one sentence,
90 a JSON schema, the action.
93 a JSON schema, the action. The schema is checked, not decoration, so keep
94 it inside the subset `src/schema.ts` reads — a keyword that module does
95 not know fails its test rather than passing a caller through.
9196 - **A terminal command** — a key in `src/tty.ts` that calls the action and
9297 reports on the status line. The screen there is a pure function of state
9398 (`screen(state)`: the loop's state, the actions' data and the forge's
README.md +10 −2
@@ -56,6 +56,7 @@ one; anything procedural it leaves out is a sentence pointing at the record.
5656 | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations |
5757 | `src/live.ts` | the in-process bus and `GET /events` |
5858 | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` |
59 | `src/schema.ts` | a call against the schema its tool advertised: shape only |
5960 | `src/routes.ts` | every path, in one table |
6061 | `src/pages/layout.ts` | the one document every page is poured into |
6162 | `src/pages/table.ts` | the parts a table is made of: charter, claims, acts, clock |
@@ -191,10 +192,17 @@ A refusal answers with its name and the status that matches it:
191192 `signin_required` 401, `forbidden` 403 (no seat of yours here),
192193 `not_found` 404, `conflict` 409 (the table was in no state to take the
193194 request: not your turn, nothing to accept, already accepted), `invalid`
194 422 and `rate_limited` 429.
195 422 and `rate_limited` 429. An id is plain digits and nothing else, so one
196 table has one URL: `/api/tables/1e3` and `/api/tables/1.0` are 404, not
197 other ways of asking for 1000 and 1.
195198
196199 As an MCP server, for Claude Code or Claude Desktop — `list_tables`,
197 `table_state`, `open_table`, `join_table`, `leave_table`:
200 `table_state`, `open_table`, `join_table`, `leave_table`. Every tool
201 publishes a schema and is held to it: an argument of the wrong type, one the
202 tool does not have, or a required one left out comes back as `isError` with
203 one sentence naming the argument and what was expected, so a model can
204 correct itself rather than be quietly given something else (`src/schema.ts`);
205 a rule of the game refuses the same way, under its own name. To connect:
198206
199207 ```sh
200208 claude mcp add --transport http __TITLE__ https://<app>/mcp
src/actions.test.ts +44
@@ -284,6 +284,50 @@ test("a table is read by whole-number id, and a missing one is not found", () =>
284284 expect(listTables(db, anon, { limit: "5" }).ok).toBe(true);
285285 });
286286
287 test("one table has one id: an id is plain digits and nothing else", () => {
288 // A table at a round id, so the spelling `1e3` has something to miss.
289 db.query(
290 `INSERT INTO game (id, title, host_id, host_name, seats_max, quorum, turn_seconds, deadline_seconds)
291 VALUES (1000, 'One Id, One URL', 'u1', 'alice', 4, 3, 21600, 86400)`,
292 ).run();
293 expect(at(1000, anon).title).toBe("One Id, One URL");
294 expect(tableState(db, anon, { id: "1000" }).ok).toBe(true);
295 // Every other way of writing a thousand names no table at all.
296 for (const id of [
297 "1e3",
298 "1E3",
299 "1000.0",
300 "1.0",
301 " 1",
302 "1 ",
303 "0x1",
304 "+1",
305 "-1",
306 "1_000",
307 "1,000",
308 "١٠٠٠",
309 Infinity,
310 -Infinity,
311 NaN,
312 1e21,
313 Number.MAX_SAFE_INTEGER + 2,
314 "",
315 ]) {
316 const r = tableState(db, anon, { id });
317 expect(r.ok).toBe(false);
318 if (!r.ok) expect(r.error).toBe("not_found");
319 }
320 // The same rule bounds a bound: a limit is digits, not a spelling either.
321 for (const limit of ["1e1", "5.0", " 5", "0x5", "+5", "5 ", Infinity, 5.5]) {
322 const r = listTables(db, anon, { limit });
323 expect(r.ok).toBe(false);
324 if (!r.ok) expect(r.error).toBe("invalid");
325 }
326 expect(listTables(db, anon, { limit: "5" }).ok).toBe(true);
327 expect(openTable(db, alice, { title: "x", seats_max: "1e1" }).ok).toBe(false);
328 db.query("DELETE FROM game WHERE id = 1000").run();
329 });
330
287331 // ---------------------------------------------------------------- the turn
288332
289333 /** A table `host` opened with `players` seated at it, still open. */
src/actions.ts +18 −8
@@ -732,11 +732,8 @@ const TURNS: ReadonlySet<string> = new Set<ActKind>(["offer", "accept", "pass"])
732732 // ------------------------------------------------------------ the plumbing
733733
734734 const find = (db: Database, id: unknown): GameRow | undefined => {
735 // A whole number, or the text of one. Nothing else is an id: `Number(true)`
736 // is 1, and a table is not named by a boolean somebody sent us.
737 if (typeof id !== "number" && typeof id !== "string") return undefined;
738 const n = Number(id);
739 if (!Number.isInteger(n)) return undefined;
735 const n = decimal(id);
736 if (n === undefined) return undefined;
740737 return (
741738 db.query<GameRow, [number]>("SELECT * FROM game WHERE id = ?1").get(n) ??
742739 undefined
@@ -787,6 +784,20 @@ const sit = (
787784 ).run(game.id, who.id, who.name);
788785 };
789786
787 /**
788 * A whole number written plainly, or nothing: a number that is a safe
789 * integer, or the digits of one. One spelling, so one table has one id and
790 * one URL — `1e3`, `1.0`, `" 1"`, `"0x1"`, `"+1"` and `Infinity` name no
791 * table and no bound, and `Number(true)` being 1 names nothing either.
792 */
793 function decimal(value: unknown): number | undefined {
794 if (typeof value === "number")
795 return Number.isSafeInteger(value) ? value : undefined;
796 if (typeof value !== "string" || !/^[0-9]+$/.test(value)) return undefined;
797 const n = Number(value);
798 return Number.isSafeInteger(n) ? n : undefined;
799 }
800
790801 /** A whole number inside its bounds, its default when unsaid, `undefined` when neither. */
791802 function whole(
792803 value: unknown,
@@ -794,9 +805,8 @@ function whole(
794805 ): number | undefined {
795806 if (value === undefined || value === null || value === "")
796807 return bounds.default;
797 const n = typeof value === "number" ? value : Number(String(value).trim());
798 if (!Number.isInteger(n) || n < bounds.min || n > bounds.max)
799 return undefined;
808 const n = decimal(value);
809 if (n === undefined || n < bounds.min || n > bounds.max) return undefined;
800810 return n;
801811 }
802812
src/mcp.ts +27 −11
@@ -6,6 +6,13 @@
66 * is the person whose grant it carries, and a bare one is anonymous. An
77 * agent that holds a seat reads its own charge here and no one else's:
88 * the redaction is in the action, not in this door.
9 *
10 * A tool publishes a schema, so this door holds a caller to it: the
11 * arguments are checked against it (`src/schema.ts`) before the action is
12 * called, and an agent that asks for a seat with the wrong argument is told
13 * which one and what was expected rather than quietly given something else.
14 * That is the whole of what this door decides; every rule of the game is
15 * still the action's.
916 */
1017
1118 import type { Database } from "bun:sqlite";
@@ -23,6 +30,7 @@ import {
2330 } from "./actions.ts";
2431 import { title } from "./env.ts";
2532 import { fields } from "./http.ts";
33 import { check } from "./schema.ts";
2634 import type { Who } from "./who.ts";
2735
2836 interface Tool {
@@ -119,6 +127,19 @@ export const tools: readonly Tool[] = [
119127 },
120128 ];
121129
130 /**
131 * A result as a tool's answer: the value as text, or one sentence a model can
132 * act on with `isError`. A refused shape and a refused rule read alike here,
133 * because to the caller they are the same kind of no.
134 */
135 const told = (result: Result<unknown>): Record<string, unknown> =>
136 result.ok
137 ? { content: [{ type: "text", text: JSON.stringify(result.value) }] }
138 : {
139 content: [{ type: "text", text: `${result.error}: ${result.message}` }],
140 isError: true,
141 };
142
122143 /** One JSON-RPC message in, an HTTP status and a body out (a notification gets 202 and no body). */
123144 export function rpc(
124145 db: Database,
@@ -160,17 +181,12 @@ export function rpc(
160181 const tool = tools.find((t) => t.name === params.name);
161182 if (tool === undefined)
162183 return refuse(-32602, `no tool named ${String(params.name)}`);
163 const result = tool.run(db, who, fields(params.arguments));
164 return reply(
165 result.ok
166 ? { content: [{ type: "text", text: JSON.stringify(result.value) }] }
167 : {
168 content: [
169 { type: "text", text: `${result.error}: ${result.message}` },
170 ],
171 isError: true,
172 },
173 );
184 const args = fields(params.arguments);
185 // This door refuses the shape it advertised — a wrong type, an
186 // argument no schema names, a missing one — and says so the way the
187 // app says any failure. What the game allows is still the action's.
188 const shape = check(tool.inputSchema, args, tool.name);
189 return reply(told(shape.ok ? tool.run(db, who, args) : shape));
174190 }
175191 default:
176192 return refuse(-32601, `no method ${m.method}`);
src/schema.test.ts added +139
@@ -0,0 +1,139 @@
1 import { expect, test } from "bun:test";
2 import { tools } from "./mcp.ts";
3 import { check, understood } from "./schema.ts";
4
5 /** The failure's sentence, or the test's own failure when there was none. */
6 const why = (schema: unknown, args: Record<string, unknown>): string => {
7 const checked = check(schema, args, "a_tool");
8 if (checked.ok) throw new Error("expected a refusal");
9 expect(checked.error).toBe("invalid");
10 return checked.message;
11 };
12
13 const shape = {
14 type: "object",
15 properties: {
16 title: { type: "string", minLength: 1, maxLength: 4 },
17 seats: { type: "integer", minimum: 4, maximum: 6 },
18 weight: { type: "number", minimum: 0 },
19 quiet: { type: "boolean" },
20 accord: { type: "object", description: "what is offered" },
21 },
22 required: ["title"],
23 } as const;
24
25 test("every tool's schema is one this checker reads", () => {
26 for (const tool of tools) {
27 const read = understood(tool.inputSchema);
28 if (!read.ok) throw new Error(`${tool.name}: ${read.message}`);
29 }
30 });
31
32 test("a schema this checker does not read is a failure, never a silent pass", () => {
33 for (const schema of [
34 null,
35 "object",
36 { type: "string" },
37 { type: "object", properties: { a: { type: "array" } } },
38 // `toString` is on every object's prototype and is nobody's type.
39 { type: "object", properties: { a: { type: "toString" } } },
40 { type: "object", properties: { a: {} } },
41 { type: "object", properties: { a: "string" } },
42 { type: "object", properties: { a: { type: "string", pattern: "^x$" } } },
43 { type: "object", properties: { a: { type: "boolean", minimum: 1 } } },
44 { type: "object", properties: { a: { type: "integer", minimum: "4" } } },
45 { type: "object", properties: 7 },
46 { type: "object", additionalProperties: false },
47 { type: "object", properties: {}, required: ["a"] },
48 { type: "object", properties: { a: { type: "string" } }, required: "a" },
49 ]) {
50 expect(understood(schema).ok).toBe(false);
51 // `check` reads the schema itself before it reads the arguments, so a
52 // schema it does not understand refuses every call rather than passing it.
53 expect(check(schema, {}).ok).toBe(false);
54 }
55 });
56
57 test("a call that fits the shape passes, and an empty schema takes nothing", () => {
58 expect(check(shape, { title: "one" }).ok).toBe(true);
59 expect(
60 check(shape, {
61 title: "one",
62 seats: 5,
63 weight: 1.5,
64 quiet: true,
65 accord: { a: 1 },
66 }).ok,
67 ).toBe(true);
68 // An argument left unsaid is unsaid; this module invents no default for it.
69 expect(check(shape, { title: "one", seats: undefined }).ok).toBe(true);
70 expect(check({ type: "object" }, {}).ok).toBe(true);
71 expect(why({ type: "object" }, { a: 1 })).toBe(
72 "no argument named `a`; a_tool takes no arguments.",
73 );
74 });
75
76 test("an argument the schema does not name is refused and named", () => {
77 expect(why(shape, { title: "one", seats_max: 6 })).toBe(
78 "no argument named `seats_max`; a_tool takes title, seats, weight, quiet, accord.",
79 );
80 // The sentence the issue asks for, from the real tool's own schema.
81 const open = tools.find((tool) => tool.name === "open_table")!;
82 const refused = check(open.inputSchema, { title: "x", seats: 6 }, open.name);
83 expect(refused.ok).toBe(false);
84 if (!refused.ok)
85 expect(refused.message).toBe(
86 "no argument named `seats`; open_table takes title, seats_max, turn_seconds, deadline_seconds.",
87 );
88 });
89
90 test("a required argument that is not there is named", () => {
91 expect(why(shape, {})).toBe(
92 "`title` is required; a_tool takes title, seats, weight, quiet, accord.",
93 );
94 expect(why(shape, { title: undefined, seats: 4 })).toContain(
95 "`title` is required",
96 );
97 });
98
99 test("a wrong type is refused with the argument and the type both named", () => {
100 expect(why(shape, { title: 123 })).toBe(
101 "`title` is a string, not an integer.",
102 );
103 expect(why(shape, { title: 1.5 })).toBe("`title` is a string, not a number.");
104 expect(why(shape, { title: null })).toBe("`title` is a string, not null.");
105 expect(why(shape, { title: ["x"] })).toBe(
106 "`title` is a string, not an array.",
107 );
108 expect(why(shape, { title: "x", seats: "5" })).toBe(
109 "`seats` is an integer, not a string.",
110 );
111 expect(why(shape, { title: "x", seats: 4.5 })).toBe(
112 "`seats` is an integer, not a number.",
113 );
114 // A whole number too large to be itself is not an integer this door takes.
115 expect(why(shape, { title: "x", seats: Number.MAX_SAFE_INTEGER + 2 })).toBe(
116 "`seats` is an integer, not a number.",
117 );
118 expect(why(shape, { title: "x", weight: Infinity })).toBe(
119 "`weight` is a number, not Infinity.",
120 );
121 expect(why(shape, { title: "x", quiet: "yes" })).toBe(
122 "`quiet` is a boolean, not a string.",
123 );
124 expect(why(shape, { title: "x", accord: [] })).toBe(
125 "`accord` is an object, not an array.",
126 );
127 });
128
129 test("a bound is a bound, in the unit the schema counted in", () => {
130 expect(why(shape, { title: "x", seats: 3 })).toBe("`seats` is at least 4.");
131 expect(why(shape, { title: "x", seats: 7 })).toBe("`seats` is at most 6.");
132 expect(why(shape, { title: "x", weight: -1 })).toBe(
133 "`weight` is at least 0.",
134 );
135 expect(why(shape, { title: "" })).toBe("`title` is at least 1 character.");
136 expect(why(shape, { title: "toolong" })).toBe(
137 "`title` is at most 4 characters.",
138 );
139 });
src/schema.ts added +172
@@ -0,0 +1,172 @@
1 /**
2 * The shape a door advertised, checked against the arguments it was handed.
3 *
4 * `src/mcp.ts` publishes a JSON Schema for every tool; this reads exactly
5 * the subset those schemas use — `type: "object"` with `properties` and
6 * `required`, and per property `integer`, `number`, `string`, `boolean` or
7 * `object` with `minimum`/`maximum` or `minLength`/`maxLength` — and says in
8 * one sentence what is wrong with the arguments. Nothing recursive, nothing
9 * clever, no database and no clock.
10 *
11 * It is a shape-checker and nothing more: it invents no default, coerces
12 * nothing, and knows no rule of the game. A caller that passes here still
13 * meets every rule in `src/actions.ts`, which is where a rule belongs. A
14 * keyword it does not understand is a failure it reports, never a silent
15 * pass, so a schema that outgrows this module fails a test and not a caller.
16 */
17
18 import { fail, ok, type Result } from "./result.ts";
19
20 const isObject = (value: unknown): value is Record<string, unknown> =>
21 typeof value === "object" && value !== null && !Array.isArray(value);
22
23 /** Every type this checker reads: how to test one, and how to name it. */
24 const KINDS = {
25 integer: {
26 is: Number.isSafeInteger,
27 as: "an integer",
28 bounds: ["minimum", "maximum"],
29 },
30 number: {
31 is: (v: unknown) => typeof v === "number" && Number.isFinite(v),
32 as: "a number",
33 bounds: ["minimum", "maximum"],
34 },
35 string: {
36 is: (v: unknown) => typeof v === "string",
37 as: "a string",
38 bounds: ["minLength", "maxLength"],
39 },
40 boolean: {
41 is: (v: unknown) => typeof v === "boolean",
42 as: "a boolean",
43 bounds: [],
44 },
45 object: { is: isObject, as: "an object", bounds: [] },
46 } as const;
47
48 type Kind = keyof typeof KINDS;
49
50 /** One property, as this module holds it: a type and at most two bounds. */
51 interface Property {
52 readonly kind: Kind;
53 readonly min?: number;
54 readonly max?: number;
55 }
56
57 interface Shape {
58 readonly properties: ReadonlyMap<string, Property>;
59 readonly required: readonly string[];
60 }
61
62 /** What a value is, in the same words — so a refusal says what arrived. */
63 const kindOf = (value: unknown): string =>
64 value === null
65 ? "null"
66 : Array.isArray(value)
67 ? "an array"
68 : typeof value === "number"
69 ? Number.isSafeInteger(value)
70 ? "an integer"
71 : Number.isFinite(value)
72 ? "a number"
73 : String(value)
74 : `a ${typeof value}`;
75
76 /** The schema itself, read into the little that is understood of it. */
77 function read(schema: unknown): Result<Shape> {
78 if (!isObject(schema) || schema.type !== "object")
79 return fail("invalid", 'a schema is an object with type "object".');
80 for (const keyword of Object.keys(schema))
81 if (!["type", "properties", "required", "description"].includes(keyword))
82 return fail("invalid", `a schema keyword \`${keyword}\` is not read.`);
83 const given = schema.properties ?? {};
84 if (!isObject(given))
85 return fail("invalid", "a schema's `properties` is an object.");
86 const properties = new Map<string, Property>();
87 for (const [name, property] of Object.entries(given)) {
88 if (!isObject(property))
89 return fail("invalid", `the schema for \`${name}\` is an object.`);
90 // `hasOwn`, not `in`: `toString` is on every object and is nobody's type.
91 if (!Object.hasOwn(KINDS, String(property.type)))
92 return fail(
93 "invalid",
94 `a type \`${String(property.type)}\` is not read.`,
95 );
96 const kind = property.type as Kind;
97 const [least, most] = KINDS[kind].bounds;
98 for (const keyword of Object.keys(property)) {
99 if (keyword === "type" || keyword === "description") continue;
100 if (keyword !== least && keyword !== most)
101 return fail(
102 "invalid",
103 `a keyword \`${keyword}\` on \`${name}\` is not read.`,
104 );
105 if (typeof property[keyword] !== "number")
106 return fail("invalid", `\`${keyword}\` on \`${name}\` is a number.`);
107 }
108 const bound = (keyword: string | undefined): number | undefined =>
109 keyword === undefined
110 ? undefined
111 : (property[keyword] as number | undefined);
112 properties.set(name, { kind, min: bound(least), max: bound(most) });
113 }
114 const required = schema.required ?? [];
115 if (
116 !Array.isArray(required) ||
117 required.some((name) => typeof name !== "string" || !properties.has(name))
118 )
119 return fail("invalid", "a schema's `required` names its own properties.");
120 return ok({ properties, required: required as string[] });
121 }
122
123 /** Whether this checker reads every keyword `schema` uses. */
124 export const understood = (schema: unknown): Result<void> => {
125 const shape = read(schema);
126 return shape.ok ? ok(undefined) : shape;
127 };
128
129 /**
130 * The arguments against the schema: nothing it does not name, nothing of the
131 * wrong type, nothing outside its bounds, and everything it requires.
132 * `subject` is who advertised the schema, so a refusal says whose it is.
133 */
134 export function check(
135 schema: unknown,
136 args: Record<string, unknown>,
137 subject = "this tool",
138 ): Result<void> {
139 const shape = read(schema);
140 if (!shape.ok) return shape;
141 const { properties, required } = shape.value;
142 const names = [...properties.keys()];
143 const takes =
144 names.length === 0
145 ? `${subject} takes no arguments`
146 : `${subject} takes ${names.join(", ")}`;
147 for (const name of Object.keys(args))
148 if (args[name] !== undefined && !properties.has(name))
149 return fail("invalid", `no argument named \`${name}\`; ${takes}.`);
150 for (const name of required)
151 if (args[name] === undefined)
152 return fail("invalid", `\`${name}\` is required; ${takes}.`);
153 for (const [name, { kind, min, max }] of properties) {
154 const value = args[name];
155 if (value === undefined) continue;
156 if (!KINDS[kind].is(value))
157 return fail(
158 "invalid",
159 `\`${name}\` is ${KINDS[kind].as}, not ${kindOf(value)}.`,
160 );
161 // One measure for both kinds of bound: the number itself, or the length
162 // of the string, said in the unit the schema counted in.
163 const size = typeof value === "string" ? value.length : (value as number);
164 const unit = (n: number) =>
165 typeof value === "string" ? ` character${n === 1 ? "" : "s"}` : "";
166 if (min !== undefined && size < min)
167 return fail("invalid", `\`${name}\` is at least ${min}${unit(min)}.`);
168 if (max !== undefined && size > max)
169 return fail("invalid", `\`${name}\` is at most ${max}${unit(max)}.`);
170 }
171 return ok(undefined);
172 }
src/server.test.ts +111
@@ -17,6 +17,7 @@ import { DEADLINE_SECONDS, TURN_SECONDS } from "./actions.ts";
1717 import { a2, cleat } from "./example.ts";
1818 import { allow, text } from "./http.ts";
1919 import { ID_MAX, type LiveEvent, notAnAddress, subscribe } from "./live.ts";
20 import { tools } from "./mcp.ts";
2021 import { layout } from "./pages/layout.ts";
2122 import { quorum } from "./round.ts";
2223 import { span } from "./when.ts";
@@ -582,6 +583,116 @@ test("the MCP server lists the tools and calls them as the caller", async () =>
582583 expect(notified.status).toBe(202);
583584 });
584585
586 test("the MCP door refuses the shape it advertised, and says which argument", async () => {
587 // A write is rationed per person, and this test makes many: every caller
588 // here is its own identity, so no probe spends another test's ration.
589 const host = as("u60-host", "shaper");
590 const mona = as("u60-good", "mona");
591 const id = await openTable(host, "The Advertised Shape");
592 // One good call per tool, in the tools' own order: `mona` runs through it.
593 const good: Record<string, Record<string, unknown>> = {
594 list_tables: { limit: 5 },
595 table_state: { id },
596 open_table: { title: "By the Schema" },
597 join_table: { id },
598 leave_table: { id },
599 };
600 const call = (name: string, args: unknown, who: Record<string, string>) =>
601 rpc(
602 {
603 jsonrpc: "2.0",
604 id: 7,
605 method: "tools/call",
606 params: { name, arguments: args },
607 },
608 who,
609 );
610 /** A refusal a model can read: no protocol error, one sentence, no stack. */
611 const refusal = (answer: any): string => {
612 expect(answer.error).toBeUndefined();
613 expect(answer.result.isError).toBe(true);
614 expect(answer.result.content).toHaveLength(1);
615 const said = answer.result.content[0].text as string;
616 expect(said).not.toContain("at <anonymous>");
617 return said;
618 };
619
620 for (const tool of tools) {
621 const asker = as(`u60-${tool.name}`, "asker");
622 const schema = tool.inputSchema as {
623 properties?: Record<string, { type: string }>;
624 required?: string[];
625 };
626 const properties = schema.properties ?? {};
627 const args = good[tool.name]!;
628 // A wrong type for the first argument the tool takes.
629 const first = Object.keys(properties)[0]!;
630 const string = properties[first]!.type === "string";
631 const wrong = refusal(
632 await call(tool.name, { ...args, [first]: string ? 123 : "many" }, asker),
633 );
634 expect(wrong).toBe(
635 `invalid: \`${first}\` is ${string ? "a string, not an integer" : "an integer, not a string"}.`,
636 );
637 // An argument the tool never advertised, named along with what it takes.
638 const extra = refusal(await call(tool.name, { ...args, seats: 6 }, asker));
639 expect(extra).toContain("no argument named `seats`");
640 expect(extra).toContain(tool.name);
641 // And one it needs and did not get.
642 for (const name of schema.required ?? []) {
643 const { [name]: _left, ...without } = args;
644 expect(refusal(await call(tool.name, without, asker))).toContain(
645 `\`${name}\` is required`,
646 );
647 }
648 }
649 // Nothing refused reached an action: no table is named after a wrong type,
650 // and nobody sat down by asking for a seat with a word that is not a seat.
651 const listed = JSON.parse(
652 (await call("list_tables", {}, mona)).result.content[0].text,
653 ) as { id: number; title: string; seated: number }[];
654 expect(listed.some((table) => table.title === "123")).toBe(false);
655 expect(listed.find((table) => table.id === id)?.seated).toBe(1);
656
657 // And a good call still reaches the action, so the door has not become a
658 // second rules engine: mona reads, opens, sits down and gets up again.
659 for (const tool of tools)
660 expect((await call(tool.name, good[tool.name]!, mona)).result.isError).toBe(
661 undefined,
662 );
663 const state = JSON.parse(
664 (await call("table_state", { id }, mona)).result.content[0].text,
665 ) as { seated: number };
666 expect(state.seated).toBe(1);
667 // A rule of the game refuses in the same shape under its own name: the
668 // shape-checker never took a decision the action was there to make.
669 expect(refusal(await call("table_state", { id: 999_999 }, mona))).toBe(
670 "not_found: No such table.",
671 );
672 });
673
674 test("a table has one id: a spelling that is not plain digits is nobody's table", async () => {
675 const urler = as("u60-url", "urler");
676 const id = await openTable(urler, "One URL, One Table");
677 expect((await api(`/api/tables/${id}`, {}, urler)).status).toBe(200);
678 // `Number()` reads every one of these as a number; none of them is an id.
679 for (const spelling of [
680 "1e3",
681 `${id}e0`,
682 `${id}.0`,
683 `+${id}`,
684 `0x${id}`,
685 `%20${id}`,
686 `${id}%20`,
687 "Infinity",
688 "1_0",
689 ]) {
690 expect((await api(`/api/tables/${spelling}`, {}, urler)).status).toBe(404);
691 // The page says the same thing, in HTML.
692 expect((await api(`/tables/${spelling}`, {}, urler)).status).toBe(404);
693 }
694 });
695
585696 test("a change is on the event stream, and a late client hears what it missed", async () => {
586697 const stream = await fetch(base + "/events");
587698 expect(stream.headers.get("content-type")).toBe("text/event-stream");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs