chess-mcp
by adityareus
README.md
<h1 align="center">♟️ chess-mcp</h1>
<p align="center">
<strong>Ask an LLM about chess.com. Get answers — not 2 MB of JSON.</strong>
</p>
<p align="center">
<img src="https://img.shields.io/badge/python-3.10%2B-blue" alt="Python 3.10+">
<img src="https://img.shields.io/badge/MCP%20SDK-1.28-8A2BE2" alt="MCP SDK 1.28">
<img src="https://img.shields.io/badge/tests-147%20offline-brightgreen" alt="147 tests">
<img src="https://img.shields.io/badge/API-public%20%C2%B7%20no%20auth-lightgrey" alt="No auth required">
</p>
---
An [MCP](https://modelcontextprotocol.io) server that wraps [chess.com's public data API](https://www.chess.com/news/view/published-data-api) so an LLM can answer questions like:
> *"How did I do last month?"* · *"Have Magnus and Hikaru ever played?"* · *"Show me my last 5 blitz losses."*
## 🎯 The problem, in one number
One month of an active player's games is **2 MB of JSON — 490 games, ~600k tokens.** Return that from a tool and you've destroyed the model's context window on the first question.
So the entire design follows one rule:
> ### **Fetch big. Return small.**
> The server does the filtering and aggregation. The model gets tens of compact rows, never megabytes.
| | |
|---|---|
| One raw chess.com game object | `4,636 bytes` · 15 keys |
| One normalized `GameRow` | `282 bytes` |
| **Reduction** | **16.4×** |
<sub>Measured against `hikaru/games/2026/07` on 2026-07-26. Numbers drift month to month with activity.</sub>
And from a real session, the built-in CLI keeps score:
```
15 HTTP calls, 6 cache hits | 7,635 games scanned -> 25 rows | 23.4 MB fetched -> 11.8 KB returned
```
**23.4 MB fetched. 11.8 KB handed to the model.** That gap is the whole project.
## 🚀 Quick start
```bash
pip install -e ".[dev]"
python -m chess_mcp.cli # try it immediately, no MCP client needed
```
```
chess> summary hikaru 2026-06..2026-07
hikaru 2026-06..2026-07: 1199 games, 984W-146L-69D win_rate=0.821
blitz 717 games 629W-55L-33D rating 3327->3435 (+108)
bullet 482 games 355W-91L-36D rating 3432->3333 (-99)
top opponents: Oleksandr_Bortnyk(49), gurelediz(31), 0gZPanda(30), ...
best win: beat Oleksandr_Bortnyk (3363) on 2026-06-06 16:22
```
```
chess> games hikaru 2026-07 --result loss --limit 5
date color result opponent opp.rtg tc rated
---------------- ----- ------ ----------------- ------- ------ -----
2026-07-25 18:09 white loss gurelediz 3294 blitz y
2026-07-25 17:54 white loss gurelediz 3329 bullet y
2026-07-25 17:51 black loss Oleksandr_Bortnyk 3295 bullet y
2026-07-25 17:50 black loss gurelediz 3332 bullet y
2026-07-25 17:49 white loss GHANDEEVAM2003 3254 bullet y
showing 5 of 64 (truncated)
```
Type `stats` to see the bytes saved, `raw` to see the exact JSON a model would receive.
## 🛠 The five tools
| Tool | Answers |
|---|---|
| `get_player_profile` | *"What's X's rating / title / when did they join?"* |
| `list_game_archives` | *"What months does X have games in?"* — the cheap scoping call |
| `find_games` | *"Show me X's games matching \[filters]"* |
| `head_to_head` | *"Have X and Y played? What's the record?"* |
| `player_summary` | *"How did X do over \[range]?"* |
All months are `"YYYY-MM"` strings. Full parameter docs live in the tool descriptions themselves — they're written for the model that reads them.
## 🧩 Architecture
```mermaid
flowchart LR
A["MCP Client<br/><i>Claude Desktop</i>"] -->|JSON-RPC / stdio| B["<b>server.py</b><br/>5 thin tools"]
B --> C["<b>queries.py</b><br/>filter · tally · truncate"]
C --> D["<b>normalize.py</b><br/>4.6 KB → 282 B"]
C --> E["<b>client.py</b><br/>httpx · cache · retry"]
E -->|HTTPS| F[("chess.com<br/>public API")]
```
Each layer has exactly one job, and the boundaries are enforced: `client.py` never imports the data models, `queries.py` never touches HTTP, and `server.py` contains no logic at all — if it needs a loop or a conditional, that belonged one layer down.
## 💡 Why tools — and not resources or prompts?
MCP offers three primitives. This server uses only **tools**, deliberately.
| Primitive | Shape | Verdict |
|---|---|---|
| **Resources** | Addressable, static-ish content fetched by URI | ❌ No useful finite URI set — the space is *username × ~150 months × 5 filters*, and the client would need to know what it wanted before it could build the URI |
| **Prompts** | User-initiated conversation templates | ❌ Don't fetch data at all |
| **Tools** | Model-initiated, parameterized, with a return contract | ✅ The **only** primitive where the *server* gets to shrink 2 MB down to 7 KB before the model sees it |
That last row isn't a style preference — it's the reason this fits in a context window at all.
<details>
<summary><b>Taking the counterargument seriously →</b></summary>
<br>
`get_player_profile` is *nearly* resource-shaped: stable, addressable by a single username, cheap to fetch. It stayed a tool anyway, for two reasons.
**Uniform surface.** Five tools that all "just get called" is a simpler mental model for the client than four tools plus one resource with a different invocation shape.
**It needs somewhere to put warnings.** chess.com's `/stats` endpoint fails *consistently* for some accounts (`hikaru` is a reliable reproduction). A tool result has a natural place to carry *"this partially succeeded, here's what's missing."* A resource does not.
</details>
## ⚖️ Design decisions
<table>
<tr><td width="30%"><b>Normalize to the player's POV</b></td>
<td>chess.com gives you <code>white</code> and <code>black</code> and leaves you to work out which was "you." Every <code>GameRow</code> already says <code>color</code>, <code>opponent</code>, <code>result</code> from the queried player's side. Left to the model, it flips one occasionally — invisible until someone checks by hand.</td></tr>
<tr><td><b>Explicit result mapping</b></td>
<td><code>resigned</code>, <code>timeout</code>, <code>checkmated</code>, <code>agreed</code>… → <code>win|loss|draw|unknown</code> in one place, every code unit-tested. The raw code survives in <code>termination</code>. An unrecognized code becomes <code>"unknown"</code> — not a crash, not a guess.</td></tr>
<tr><td><b>Bounded envelope</b></td>
<td>Every list tool returns <code>{results, total_matched, truncated}</code>. <code>limit</code> defaults to 25, clamps to 100. Ask for 500 and you get 100, not an error. This single contract is what stops a tool ever returning megabytes.</td></tr>
<tr><td><b>12-month guardrail</b></td>
<td>A longer range errors <i>before</i> any fetching. hikaru has ~150 archive months — without this, one careless question is 150 requests and hundreds of MB.</td></tr>
<tr><td><b>Degrade, don't throw</b></td>
<td>A broken <code>/stats</code> returns <code>ratings: null</code> + a warning, not an exception. Same one level down: a malformed game is skipped, a failed month is named in <code>warnings</code> — neither sinks the response. Partial answers beat errors.</td></tr>
<tr><td><b>Sequential fetching</b></td>
<td>chess.com throttles concurrency; <code>asyncio.gather</code> over 12 months reliably earns 429s. A plain <code>for</code> loop is correct, and a test fails if anyone "optimizes" it back.</td></tr>
<tr><td><b>Immutable-past cache</b></td>
<td>Any month before the current UTC month can't change → cached forever. The current month gets a 5-minute TTL. This turns a 12-month follow-up question from 30 seconds into instant.</td></tr>
</table>
<details>
<summary><b>⚠️ The one place this deviates from spec, on purpose →</b></summary>
<br>
`head_to_head` was specified to default to **"all available history."** That's incompatible with the 12-month guardrail — hikaru alone has 150+ archive months, and a full-history head-to-head is exactly the unbounded fetch the guardrail exists to prevent.
**Resolution:** omitting the range examines the **12 most recent months in which the player has any games** — active months, not calendar months, so a casual player with gaps still gets a meaningful window. The tool then reports the range it actually used:
```
! examined 2025-08..2026-07 (12 most recent month(s) with games); record may be incomplete
```
A head-to-head record that silently omits most of history would be worse than one that admits it.
</details>
## 🔌 Connect it to an MCP client
Add to `%APPDATA%\Claude\claude_desktop_config.json` (Windows) or your client's equivalent:
```json
{
"mcpServers": {
"chess-com": {
"command": "C:\\Path\\To\\python.exe",
"args": ["-m", "chess_mcp.server"]
}
}
}
```
> **Use an absolute interpreter path**, not a bare `python`. An MCP client inherits a different `PATH` than your terminal — and on Windows a Microsoft Store `python.exe` stub may shadow the real one, printing *"Python was not found"* and exiting. Find yours with `(Get-Command python).Source`.
## 🧪 Testing
```bash
pytest -q # 147 tests, fully offline (respx-mocked), ~2s
pytest -m live # hits the real chess.com API, excluded by default
python scripts/mcp_smoke.py # does the server actually speak MCP?
```
<details>
<summary><b>What <code>mcp_smoke.py</code> does →</b></summary>
<br>
Spawns `python -m chess_mcp.server` as a real subprocess over stdio — the same transport a desktop client uses — and drives it with the MCP SDK's own client: lists all five tools, calls each one, then trips the 12-month guardrail and a nonexistent-username lookup to confirm the error text a model would actually see.
Faster than launching the Inspector and fully scriptable, so it's the first thing to run if the server *"connects but doesn't work."*
One wording note it surfaces: FastMCP prefixes every tool error with `Error executing tool <name>: `, so a model actually sees:
```
Error executing tool find_games: range '2025-01' to '2026-06' spans 18 months,
exceeds the 12-month limit; narrow the range
```
</details>
## 🚫 Out of scope, deliberately
| | |
|---|---|
| **No PGN / move parsing** | Reports game *metadata* — who, when, result, ratings — not game *content*. No `python-chess`, no ECO/opening analysis. |
| **No chess engine** | No Stockfish, no accuracy scoring, no blunder detection. |
| **No persistent store** | In-memory cache only, scoped to the server process. |
| **No default username** | Every tool takes an explicit `username` — keeps the surface honest about whose data is being requested. |
| **No authenticated endpoints** | Everything here is public, unauthenticated data. |
Each is a design boundary, not an oversight.
---
<p align="center"><sub>Built as an AI course project — optimized for a legible design and a working demo, not for deployment.</sub></p>
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessUnresponsive