Agents
Six tools, three surfaces
MCP server, Pi extension and OMP extension call the same executors in src/tool-operations.ts. They answer identically. A fix lands once.
| Tool | Does | Arguments |
|---|---|---|
puzzles_stats | Totals, status counts and prize sums | none |
puzzles_collections | Every collection with its author and both counts | none |
puzzles_show | One puzzle's complete record | id |
puzzles_list | Puzzles filtered by collection, status and public key availability | collection, status, withPubkey, limit |
puzzles_verify | A published key against its stored address | id |
puzzles_balance | The live balance of a puzzle address | id, apiKey |
Every tool is annotated read only and idempotent. Five are closed world. puzzles_balance is open world, because it reaches a block explorer. A host that lets a model call it should know that.
MCP
puzzles mcp
claude mcp add puzzles --scope user -- npx -y @agntn/puzzles mcp
Or in a client's config:
{
"mcpServers": {
"puzzles": { "command": "npx", "args": ["-y", "@agntn/puzzles", "mcp"] }
}
}
The server speaks MCP over stdio. Every call is checked against the tool's JSON Schema before an executor sees it. Bringing your own transport? createMcpServer() is exported from @agntn/puzzles/mcp.
The limits live once
id is 1 to 100 characters. collection up to 50. apiKey up to 200. limit an integer from 1 to 500, 50 by default. status one of the five values. Those numbers sit in one facts table next to the tool names and descriptions. The schemas are built from it. The executors check the same numbers themselves. A host that skips schema validation hits the same wall, as InvalidArgumentError. A limit of 501 is rejected, not clamped. A model that asked for 501 should learn the limit, not get 500 back and wonder.
What the text carries
An MCP client sees the text a tool returns and nothing else. So the text carries the whole answer:
b1000/71 unsolved 7.100226 BTC 1PWo3JeB9jrGwfHDNpdGK54CRas7fsVzXU
chain: bitcoin address kind: p2pkh
public key: unknown
private key known: no
explorer: https://blockstream.info/address/1PWo3JeB9jrGwfHDNpdGK54CRas7fsVzXU
source: https://bitcointalk.org/index.php?topic=5218972
key range: 400000000000000000..7fffffffffffffffff (hex)
Absent things say so, public key: unknown. A line that vanishes reads as not shown, and a model fills it from memory. puzzles_list prints 12 of 77 matching puzzles: when the limit cut the list, so the next call knows to raise it. puzzles_collections prints the same rows as puzzles collections. A model learns the keys before it guesses one.
What sets isError
An unverifiable puzzle is an answer. puzzles_verify on b1000/71 returns b1000/71: unverifiable (Puzzle has no private key) with isError unset. Only a thrown error sets it: an unknown id, an argument outside its limits, a provider failure. Error text is sanitized. Every control character, format character and line separator becomes a space, and an echoed tool name is quoted. Those values arrive from the model or from a provider. One raw newline would forge a line that reads as the server's own answer.
Pi and OMP
pi install npm:@agntn/puzzles
Both extensions are declared in package.json. They add details next to the text, the structured object the harness renders: the puzzle record for a show, { matched, returned, ids } for a list, the verify result, the balance in base units as strings. MCP drops details and keeps the text. OMP rebuilds the parameter schemas from its own TypeBox build, labels the tool group Puzzles, and renders a call line like Show puzzle b1000/71 with control characters stripped.
The extensions load the shared executors through literal import() specifiers, src/ in a checkout and dist/ when installed, so a bundling host can see the dependencies. The packed layout is a CI gate. pnpm test:packed unpacks the tarball and runs both extensions and the MCP server from it with no src/ next to them.