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

Closes #60.

shipped production is serving it deploy 6ecafc0

opened this

Closes #60.

src/schema.ts is new and reads exactly the JSON Schema subset the tool table uses — type: "object" with properties and required, and per property integer/number/string/boolean/object with minimum/maximum or minLength/maxLength. check(schema, args, toolName) returns the app's own Result, so a refusal is one sentence naming the argument and what was expected: ` invalid: title is a string, not an integer. , invalid: no argument named seats; open_table takes title, seats_max, turn_seconds, deadline_seconds. , invalid: title is required; … . It stays a shape-checker: no default invented, no coercion, no rule of the game. A keyword it does not read is a reported failure rather than a silent pass, and understood() is how src/schema.test.ts` holds every published schema to the subset.

rpc's tools/call now runs check before the action and answers isError: true at 200 — never a crash, never a stack. The result-to-answer mapping moved into one told helper, so a refused shape and a refused rule read alike. In src/actions.ts, find() and whole() share a decimal helper: a safe integer, or a string of digits and nothing else, so 1e3, 1.0, " 1", "0x1", "+1" and Infinity name no table and no bound.

What to look at: the tool tests in src/server.test.ts are table-driven over tools — wrong type, unknown argument, missing required for each, then a good call per tool, plus a check that no table got named 123 and that table_state for a missing id still refuses under not_found, which is what keeps the door from becoming a second rules engine. Every probe there uses its own forge identity because writes are rationed per person. In the preview: POST /mcp with {"title":123} and with {"title":"x","seats":6}, and /api/tables/1e3 (404) beside /api/tables/<real id> (200).

Two things I chose differently from the issue: src/schema.ts is ~170 lines, not under a hundred — the extra is the readable failure sentences (what arrived, and bounds said in characters) and the schema-keyword reporting; I would rather cut the message quality than the checks if that is the wrong trade. And the 1e3 vs 1000 pairing is tested at both levels: the action test in src/actions.test.ts inserts a table at id 1000 and walks eighteen rejected spellings, while the HTTP 404s for /api/tables/1e3 and /tables/1e3 live in src/server.test.ts, which is where this repo keeps route tests. bun test: 119 pass, 0 fail.

🤖 Generated with Claude Code

Closes #60

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs