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
Check · Review · Land · Live done — Landed and live in production.
mason/parley:mason/60-the-agent-door-refuses-what-it-did-not-a → main 5 commits fork
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`), | ||
| 31 | 31 | the MCP server (`src/mcp.ts`) and the terminal (`src/tty.ts`) call it. A |
| 32 | 32 | door reads its wire format and shapes the answer; it decides nothing. A |
| 33 | 33 | 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. | |
| 35 | 38 | |
| 36 | 39 | A time is said once, in one vocabulary. `src/when.ts` is every phrase the |
| 37 | 40 | app has for a moment — "4h 12m left", "20m ago", "14 Sep 2026, 09:41 UTC" — |
| @@ -87,7 +90,9 @@ To add a concept: | ||
| 87 | 90 | - **An API route** — a row in `src/routes.ts` that calls the action and |
| 88 | 91 | answers with `answer(...)`; the status follows the failure. |
| 89 | 92 | - **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. | |
| 91 | 96 | - **A terminal command** — a key in `src/tty.ts` that calls the action and |
| 92 | 97 | reports on the status line. The screen there is a pure function of state |
| 93 | 98 | (`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. | ||
| 56 | 56 | | `src/db.ts` | SQLite under `FORGE_APP_DATA`, WAL, forward-only migrations | |
| 57 | 57 | | `src/live.ts` | the in-process bus and `GET /events` | |
| 58 | 58 | | `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 | | |
| 59 | 60 | | `src/routes.ts` | every path, in one table | |
| 60 | 61 | | `src/pages/layout.ts` | the one document every page is poured into | |
| 61 | 62 | | `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: | ||
| 191 | 192 | `signin_required` 401, `forbidden` 403 (no seat of yours here), |
| 192 | 193 | `not_found` 404, `conflict` 409 (the table was in no state to take the |
| 193 | 194 | 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. | |
| 195 | 198 | |
| 196 | 199 | 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: | |
| 198 | 206 | |
| 199 | 207 | sh |
| 200 | 208 | 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", () => | ||
| 284 | 284 | expect(listTables(db, anon, { limit: "5" }).ok).toBe(true); |
| 285 | 285 | }); |
| 286 | 286 | |
| 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 | ||
| 287 | 331 | // ---------------------------------------------------------------- the turn |
| 288 | 332 | |
| 289 | 333 | /** 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"]) | ||
| 732 | 732 | // ------------------------------------------------------------ the plumbing |
| 733 | 733 | |
| 734 | 734 | 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; | |
| 740 | 737 | return ( |
| 741 | 738 | db.query<GameRow, [number]>("SELECT * FROM game WHERE id = ?1").get(n) ?? |
| 742 | 739 | undefined |
| @@ -787,6 +784,20 @@ const sit = ( | ||
| 787 | 784 | ).run(game.id, who.id, who.name); |
| 788 | 785 | }; |
| 789 | 786 | |
| 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 | ||
| 790 | 801 | /** A whole number inside its bounds, its default when unsaid, `undefined` when neither. */ |
| 791 | 802 | function whole( |
| 792 | 803 | value: unknown, |
| @@ -794,9 +805,8 @@ function whole( | ||
| 794 | 805 | ): number | undefined { |
| 795 | 806 | if (value === undefined || value === null || value === "") |
| 796 | 807 | 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; | |
| 800 | 810 | return n; |
| 801 | 811 | } |
| 802 | 812 |
src/mcp.ts +27 −11
| @@ -6,6 +6,13 @@ | ||
| 6 | 6 | * is the person whose grant it carries, and a bare one is anonymous. An |
| 7 | 7 | * agent that holds a seat reads its own charge here and no one else's: |
| 8 | 8 | * 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. | |
| 9 | 16 | */ |
| 10 | 17 | |
| 11 | 18 | import type { Database } from "bun:sqlite"; |
| @@ -23,6 +30,7 @@ import { | ||
| 23 | 30 | } from "./actions.ts"; |
| 24 | 31 | import { title } from "./env.ts"; |
| 25 | 32 | import { fields } from "./http.ts"; |
| 33 | import { check } from "./schema.ts"; | |
| 26 | 34 | import type { Who } from "./who.ts"; |
| 27 | 35 | |
| 28 | 36 | interface Tool { |
| @@ -119,6 +127,19 @@ export const tools: readonly Tool[] = [ | ||
| 119 | 127 | }, |
| 120 | 128 | ]; |
| 121 | 129 | |
| 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 | ||
| 122 | 143 | /** One JSON-RPC message in, an HTTP status and a body out (a notification gets 202 and no body). */ |
| 123 | 144 | export function rpc( |
| 124 | 145 | db: Database, |
| @@ -160,17 +181,12 @@ export function rpc( | ||
| 160 | 181 | const tool = tools.find((t) => t.name === params.name); |
| 161 | 182 | if (tool === undefined) |
| 162 | 183 | 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)); | |
| 174 | 190 | } |
| 175 | 191 | default: |
| 176 | 192 | 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"; | ||
| 17 | 17 | import { a2, cleat } from "./example.ts"; |
| 18 | 18 | import { allow, text } from "./http.ts"; |
| 19 | 19 | import { ID_MAX, type LiveEvent, notAnAddress, subscribe } from "./live.ts"; |
| 20 | import { tools } from "./mcp.ts"; | |
| 20 | 21 | import { layout } from "./pages/layout.ts"; |
| 21 | 22 | import { quorum } from "./round.ts"; |
| 22 | 23 | import { span } from "./when.ts"; |
| @@ -582,6 +583,116 @@ test("the MCP server lists the tools and calls them as the caller", async () => | ||
| 582 | 583 | expect(notified.status).toBe(202); |
| 583 | 584 | }); |
| 584 | 585 | |
| 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 | ||
| 585 | 696 | test("a change is on the event stream, and a late client hears what it missed", async () => { |
| 586 | 697 | const stream = await fetch(base + "/events"); |
| 587 | 698 | expect(stream.headers.get("content-type")).toBe("text/event-stream"); |
new above
offline