mcp-puzzlemaster
README.md
# mcp-puzzlemaster
A small [MCP](https://modelcontextprotocol.io) server that solves word puzzles —
Spelling Bee, crosswords, Wordle — against the full 172,823-word ENABLE1
dictionary, and writes an audit row to Postgres for **every** call it serves.
It exists to make one argument concrete: **once several agents share a single MCP
server, you get things you cannot get from tools scattered across three
codebases.** One dictionary, loaded once, for every caller. One seam that sees
every invocation. One table you can graph and export.
Built with [FastMCP](https://gofastmcp.com) and deployed to
[Prefect Horizon](https://docs.horizon.prefect.io) (formerly FastMCP Cloud).
---
## Tools
Every tool takes an explicit `agent_name`. See [Agent identity](#agent-identity)
for why that is honest telemetry and not authentication.
| Tool | Arguments | Returns |
|---|---|---|
| `solve_spelling_bee` | `agent_name`, `letters` (7 letters), `center` | every valid word with its score, the total, and any pangrams |
| `solve_crossword_pattern` | `agent_name`, `pattern` (e.g. `C_O__W_RD`) | every dictionary word matching the pattern |
| `solve_wordle` | `agent_name`, `guesses`, `feedback`, `length=5` | the remaining candidate words |
| `usage_graph` | `agent_name`, `group_by` (`"agent"` or `"tool"`) | an ASCII bar chart of logged usage |
| `export_results` | `agent_name`, `limit` (1–1000, default 200) | the audit log as CSV **text** |
### Puzzle rules, as implemented
- **Spelling Bee.** Words are ≥ 4 letters, must contain the center letter, and may
use only the 7 allowed letters — but may reuse them freely, so the check is
`set(word) <= allowed`, not a multiset one. A 4-letter word scores **1 point
flat** (not 4 — this is the classic trap); 5+ letters score 1 point per letter;
a pangram earns **+7** on top.
Worked example: `VALIDTY` with center `V` yields **34 words / 171 points**, and
the pangram `VALIDITY`. The New York Times' curated answer for the same puzzle
was 21 words / 119 points — a public word list over-generates against editorial
curation, which is a real and useful lesson about deterministic tools versus
curated ground truth.
- **Crossword.** `_` means unknown. The word length is derived from the pattern,
so there is no separate length argument. `C_O__W_RD` → `["CROSSWORD"]`.
- **Wordle.** Feedback is one character per letter: `g` right letter and right
spot, `y` right letter wrong spot, `b` absent. Rather than hand-coding
constraint rules — which is exactly where duplicate letters go wrong — a
candidate survives only if replaying each guess against it reproduces the
feedback that was actually seen. `CRANE` / `gybbb` leaves 34 candidates.
### `export_results` returns CSV content, not a file path
The obvious design writes a CSV to disk and returns the path. That is wrong for a
server you reach over HTTPS: the file lands on the server's filesystem, which the
caller cannot read and which is wiped on the next deploy. So the tool returns the
CSV **itself** — header row plus one row per invocation — which the caller can
save, paste into a spreadsheet, or read directly.
Two caps keep that safe:
- at most **1000 rows** per call (`limit`, default 200, most recent first, then
returned oldest-first), and
- a **4 MB** response ceiling, below the 6 MB hard cap on hosted MCP responses,
after which the CSV ends with a `# truncated at N of M rows` comment line.
### Audit cells are capped at 1000 characters
Each of the `inputs` and `outputs` columns holds at most **1000 characters** of
JSON. Anything larger collapses to a still-valid JSON object recording the
original size and a short preview:
```json
{"truncated_chars": 41297, "preview": "{\"candidates\": [\"CABLE\", ..."}
```
This matters more than it sounds. A single Wordle result serialized whole runs to
roughly 9 KB, and a one-letter crossword pattern matches thousands of words — a
handful of those rows makes the exported CSV unreadable in any spreadsheet. The
cap is a promise about the *stored* value: the preview is shrunk until the
finished JSON object fits in 1000 characters, escaping included.
The audit trail is an audit trail **and** a ready-made eval dataset: every row is
an input/output pair that nobody had to write by hand. That only exists because
the calls funnel through one place.
---
## Environment variables
| Name | Required | Purpose |
|---|---|---|
| `DATABASE_URL` | for the audit log | Postgres connection string, e.g. `postgresql://user:password@host:5432/dbname`. The role needs `CREATE` on the database (to create the `mcp_training` schema on first run) and read/write on that schema thereafter. |
There are no other configuration variables, and no secrets are committed to this
repository. If `DATABASE_URL` is unset or the database is unreachable:
- **the solvers still work.** A logging failure must never break a tool call, so
a failed audit write is reported to stderr and swallowed. Repeats are collapsed
so a standing misconfiguration cannot flood the logs.
- `usage_graph` and `export_results` fail with a `ToolError` naming
`DATABASE_URL` and what to set it to — they have nothing to report without it.
### The schema
Everything lives in — and is qualified to — a schema of its own,
**`mcp_training`**. Point `DATABASE_URL` at an empty database and the server
creates it on startup; point it at a database that already has it and the
startup DDL is a no-op.
```sql
CREATE SCHEMA IF NOT EXISTS mcp_training;
CREATE TABLE IF NOT EXISTS mcp_training.invocations (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
agent_name TEXT NOT NULL,
tool TEXT NOT NULL,
inputs JSONB NOT NULL,
outputs JSONB NOT NULL,
duration_ms INTEGER NOT NULL,
ok BOOLEAN NOT NULL
);
CREATE INDEX IF NOT EXISTS invocations_ts_idx ON mcp_training.invocations (ts DESC);
CREATE INDEX IF NOT EXISTS invocations_agent_idx ON mcp_training.invocations (agent_name);
```
Every statement the server issues names `mcp_training.invocations` in full.
Nothing depends on `search_path`, and the server never touches an object outside
its own schema — it is expected to share a database instance with unrelated
applications, where a bare table name is a genuine hazard rather than a style
preference.
A connection that fails at startup is retried at most once every 30 seconds
rather than being written off for the life of the process, and connections come
from a small pool (1–4), not one per call.
Postgres rather than SQLite because the deployment target has an **ephemeral
filesystem**: a local database file disappears on redeploy, and "monitor usage
over time" is meaningless if the counter resets.
---
## Run it locally
Python 3.12 (3.11–3.14 all work).
```bash
git clone https://github.com/overclock-accelerator/mcp-puzzlemaster
cd mcp-puzzlemaster
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
```
**Verify it — no database and no API key needed:**
```bash
python test_server.py
```
This drives the server with FastMCP's in-memory `Client(mcp)`, which speaks the
real MCP protocol to the server object in the same process: no subprocess, no
port, no network. Without `DATABASE_URL` it checks the solvers and asserts that
the reporting tools fail with a clear message; with `DATABASE_URL` set it also
round-trips the audit log through Postgres — including a check, run with
`search_path` deliberately set to `pg_catalog`, that rows really do land in
`mcp_training.invocations` and not wherever the connection's search path
happened to point.
**Run it over stdio** (what a local MCP client speaks):
```bash
python server.py
```
**Inspect what a client will see** — this is the same inspection the deployment
build runs, so if it fails here it will fail there:
```bash
fastmcp inspect server.py:mcp
```
**Point Claude Code at your local copy:**
```bash
claude mcp add puzzlemaster -- python /absolute/path/to/server.py
```
---
## Deploying to Prefect Horizon
Horizon builds from a Git repository and redeploys on every push to the default
branch.
1. Push this repository to GitHub and sign in at
[horizon.prefect.io](https://horizon.prefect.io).
2. Connect the Horizon GitHub App and grant it access to the repository. For a
repository owned by a GitHub organization, the person completing that popup
must be an admin of that organization.
3. Create a server, and set the **entrypoint** explicitly to:
```
server.py:mcp
```
The build system has no default entrypoint — this is the most common reason a
first build never goes live.
4. Add `DATABASE_URL` under **Settings → Environment Variables**. Environment
variables are injected at build time as well as runtime, so **changing one
requires a new build and deploy**; editing the value does not touch the
running server.
5. The server goes live at `https://<name>.fastmcp.app/mcp` over Streamable HTTP.
What this repository does to satisfy the platform:
- **`.python-version` pins `3.12`.** Horizon supports 3.11–3.14. It does **not**
support 3.10.
- **`requirements.txt` is discovered automatically** and pins `fastmcp` exactly.
`fastmcp` must be a dependency even though the platform runs the server for
you — it uses the `fastmcp` CLI to install, inspect, and launch it.
- **`data/enable1.txt` is committed** (~1.7 MB) and resolved as
`Path(__file__).parent / "data" / "enable1.txt"`, never from the working
directory. Committed files ship inside the build artifact; the ephemeral-
filesystem warning applies to files the server *writes* at runtime.
- **The word list loads at import time**, which is a deliberate trade: it adds to
cold start once instead of adding latency to every request.
- **Nothing is ever printed to stdout.** Under stdio transport stdout *is* the
JSON-RPC channel and a single stray `print` corrupts the stream. All
diagnostics go to stderr, which the platform surfaces as server logs.
- **`if __name__ == "__main__": mcp.run()` is kept** for local stdio testing. The
deployed object is the module-level `mcp`; the platform serves it over HTTPS
itself.
Connect a client (Horizon authentication is on by default, so callers present a
bearer key or sign in interactively):
```bash
claude mcp add --transport http puzzlemaster https://<name>.fastmcp.app/mcp
```
---
## Agent identity
`agent_name` is an ordinary tool parameter, which means **the model fills it in.**
It is honest attribution for telemetry, not authentication — a model can get it
wrong, and a hostile one can simply lie. It is the right baseline for a server
that also runs over stdio, where there is no header channel at all.
Over HTTP the production answer is different: inject the caller's identity from
the authenticated request with FastMCP's `Depends()`, which excludes the
parameter from the generated schema entirely so the model can neither see nor
set it. Horizon's gateway also records the acting user or service account for
every request before it reaches this code, independently of what the model
claims.
---
## Layout
```
mcp-puzzlemaster/
├── server.py # the whole server — entrypoint is server.py:mcp
├── test_server.py # in-memory protocol tests, no key and no DB required
├── requirements.txt # pinned
├── .python-version # 3.12
└── data/enable1.txt # ENABLE1, 172,823 words, public domain
```
The solver function bodies in `server.py` are byte-identical to their reference
implementations and are meant to stay that way. The `@mcp.tool` wrappers around
them are the seam: they add agent identity, hand in the shared word list, and
translate `ValueError` into `ToolError`. Add a sixth tool tomorrow and it is
audited for free, because the audit lives in middleware at `on_call_tool` rather
than inside any solver.
## Credits
`data/enable1.txt` is the ENABLE1 word list, which is in the public domain.
## License
MIT — see [LICENSE](LICENSE). Copyright (c) 2026 Overclock Accelerator.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues