Skip to main content
Glama
dvip1
by dvip1
README.md
# mcp-chess

An MCP (Model Context Protocol) server that exposes **Stockfish** (via
`python-chess`) to LLM chat clients. Built for analyzing your own games and
understanding why the engine's move is better than yours.

> "Why is X the best move and not the one I played? It looks just as good to me."

Runs on the host as a systemd user service, serving both MCP transports
(Streamable HTTP + SSE) on a single port. Tested with **Open WebUI** (podman
container) and **opencode**.

---

## Table of contents

1. [What it does](#what-it-does)
2. [Tools reference](#tools-reference)
   - [analyze_position](#analyze_position)
   - [compare_moves](#compare_moves)
   - [explain_choice](#explain_choice)
   - [analyze_game](#analyze_game)
3. [Architecture](#architecture)
4. [Installation](#installation)
5. [Running it](#running-it)
6. [Integrating with Open WebUI](#integrating-with-open-webui)
7. [Integrating with opencode](#integrating-with-opencode)
8. [Verifying it works](#verifying-it-works)
9. [Configuration](#configuration)
10. [Troubleshooting](#troubleshooting)
11. [Example prompts](#example-prompts)
12. [About Maia](#about-maia)

---

## What it does

The server wraps Stockfish via the `python-chess` library and exposes four MCP
tools that an LLM can call during chat. The tools are designed around the kinds
of questions a chess player asks when reviewing their games:

- "What's the best move here?" → `analyze_position`
- "I played X, was Y better?" → `compare_moves`
- "Why is the engine's move better than mine?" → `explain_choice`
- "Review this whole game" → `analyze_game`

All scores are from the perspective of the side to move (positive = good for the
mover). Centipawn loss (CPL) is clamped to 0 to suppress negative search noise
between multipv and single-pv evals.

---

## Tools reference

### `analyze_position`

Get Stockfish's top N candidate moves for a position.

**Parameters:**

| Name            | Type   | Default | Description |
|-----------------|--------|---------|-------------|
| `fen_or_moves`  | string | —       | FEN, PGN, or space-separated SAN |
| `depth`         | int    | 22      | Stockfish search depth |
| `multipv`       | int    | 5       | Number of candidate lines to return |

**Returns:** The position FEN, side to move, legal move count, check status,
and a list of the top `multipv` lines — each with SAN, UCI, score (cp or mate),
depth, and the principal variation in SAN.

**Example call:**
```python
await analyze_position(fen_or_moves="1.e4 e5 2.Nf3 Nc6 3.Bb5", depth=18, multipv=3)
```

**Example output (abbreviated):**
```json
{
  "fen": "r1bqkbnr/pppp1ppp/2n5/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R b KQkq - 3 3",
  "turn": "black",
  "legal_move_count": 29,
  "in_check": false,
  "lines": [
    {
      "move_san": "Bc5",
      "move_uci": "f8c5",
      "score_cp": -22,
      "score_mate": null,
      "depth": 18,
      "pv_san": ["Bc5", "O-O", "Nf6", "d3", "a6", "c3", ...]
    },
    ...
  ]
}
```

---

### `compare_moves`

Evaluate a set of candidate moves at the same position. For each move, plays it
and evaluates the resulting position, then reports centipawn loss vs the engine's
best.

**Parameters:**

| Name            | Type     | Default | Description |
|-----------------|----------|---------|-------------|
| `fen_or_moves`  | string   | —       | FEN, PGN, or SAN |
| `moves`         | string[] | —       | Candidate moves (SAN or UCI) |
| `depth`         | int      | 22      | Stockfish depth |

**Returns:** The engine's best move (SAN/UCI/eval/PV) plus per-move results:
SAN, UCI, resulting FEN, eval (cp/mate) from mover's POV, centipawn loss vs
best, and a short PV.

**Example:**
```python
await compare_moves(
    fen_or_moves="rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
    moves=["e4", "d4", "Nf3", "a3"],
    depth=15
)
```

---

### `explain_choice`

The primary tool for "why is X best and not the move I played?". Returns the
engine's best move(s) with eval and PV, plus the player's move with its eval
after being played, centipawn loss vs best, and side-by-side PV prefixes so the
LLM can describe the resulting plans.

**Parameters:**

| Name            | Type   | Default | Description |
|-----------------|--------|---------|-------------|
| `fen_or_moves`  | string | —       | FEN, PGN, or SAN |
| `player_move`   | string | —       | The move played (SAN or UCI) |
| `depth`         | int    | 22      | Stockfish depth |
| `multipv`       | int    | 4       | Top engine alternatives to surface |

**Returns:** Best move + alternatives + player move eval + CPL + PVs.

**Example:**
```python
await explain_choice(
    fen_or_moves="1.e4 e5 2.Nf3 Nc6 3.Bb5",
    player_move="a6",
    depth=18
)
```

---

### `analyze_game`

Review a full chess game move-by-move with Stockfish. Designed for "analyze this
game" requests. Plays through every move, evaluates each position before and
after, computes centipawn loss for the played move vs the engine's best, and
classifies each move.

Only surfaces the *critical* moves (CPL ≥ threshold) in full detail, plus a
compact summary of the whole game. This keeps the response small enough for the
chat model to reason over while still flagging the moments that decided the game.

**Parameters:**

| Name                       | Type   | Default | Description |
|----------------------------|--------|---------|-------------|
| `pgn`                      | string | —       | Full PGN string (with or without headers) |
| `depth`                    | int    | 16      | Stockfish depth per move |
| `classification_threshold` | int    | 75      | Only moves with CPL ≥ this appear in `critical_moves` |

**Move classifications (Lichess-style thresholds):**

| Label        | CPL range          |
|--------------|--------------------|
| `best`       | 0                  |
| `good`       | 1–24               |
| `inaccuracy` | 25–74              |
| `mistake`    | 75–149             |
| `blunder`    | ≥ 150              |

**Returns:**

- `headers` — PGN headers (White, Black, Result, Opening, etc.) if present
- `result` — Game result string (`"1-0"`, `"0-1"`, `"1/2-1/2"`)
- `move_count` — Total plies in the game
- `summary` — Per-side: `{blunders, mistakes, inaccuracies, good_moves, best_moves, total_cpl, avg_cpl, moves}`
- `critical_moves` — Moves where CPL ≥ threshold, each with full detail (best PV, played PV, FEN after, eval before/after)
- `all_moves` — Compact list of every move (number, side, SAN, best SAN, CPL, classification)

**Example:**
```python
await analyze_game(
    pgn='[Event "rated rapid game"] [White "khalednafea"] [Black "dvip"] [Result "0-1"] 1. e4 f5 2. exf5 Nf6 3. Bc4 d5 4. Bb3 h6 ... 0-1',
    depth=16,
    classification_threshold=75
)
```

**Example output (abbreviated):**
```json
{
  "headers": {"White": "khalednafea", "Black": "dvip", "Result": "0-1", "Opening": "Duras Gambit"},
  "result": "0-1",
  "move_count": 50,
  "summary": {
    "white": {"blunders": 4, "mistakes": 4, "inaccuracies": 7, "avg_cpl": 96.1, "moves": 25},
    "black": {"blunders": 4, "mistakes": 5, "inaccuracies": 3, "avg_cpl": 69.8, "moves": 25}
  },
  "critical_moves": [
    {
      "move_number": 1,
      "side": "black",
      "played_san": "f5",
      "best_san": "e6",
      "centipawn_loss": 147,
      "classification": "mistake",
      "best_pv_san": ["e6", "d4", "d5", "Nc3", "Nf6"],
      "played_pv_san": ["f5", "exf5", "Nf6", "Be2", "Rg8"]
    },
    ...
  ],
  "all_moves": [
    {"move_number": 1, "side": "white", "played_san": "e4", "best_san": "e4", "centipawn_loss": 0, "classification": "best"},
    {"move_number": 1, "side": "black", "played_san": "f5", "best_san": "e6", "centipawn_loss": 147, "classification": "mistake"},
    ...
  ]
}
```

---

## Architecture

```
┌──────────────────────────────────────────────────────────────┐
│ Host (Fedora)                                                │
│                                                              │
│  systemd user service (mcp-chess.service)                    │
│  ┌────────────────────────────────────────────────────────┐  │
│  │ uv run mcp-chess                                       │  │
│  │  ┌─────────────┐    ┌──────────────────────────────┐   │  │
│  │  │ FastMCP app │───▶│ python-chess → Stockfish 18  │   │  │
│  │  │ (uvicorn)   │    │ (asyncio.Lock serialized)    │   │  │
│  │  └──────┬──────┘    └──────────────────────────────┘   │  │
│  │         │                                               │  │
│  │    SSE: /sse       Streamable HTTP: /mcp                │  │
│  │    127.0.0.1:8765                                        │  │
│  └─────────┬───────────────────────────────────────────────┘  │
│            │                                                  │
│  ┌─────────┴──────────┐    ┌──────────────────────────────┐   │
│  │ Open WebUI         │    │ opencode                     │   │
│  │ (podman, net=host) │    │ (native, reads opencode.json)│   │
│  │ POST /mcp          │    │ GET /sse                     │   │
│  └────────────────────┘    └──────────────────────────────┘   │
└──────────────────────────────────────────────────────────────┘
```

Key design points:

- **Both transports on one process.** The server mounts the SSE app's routes
  (`/sse`, `/messages`) into the Streamable HTTP app (`/mcp`) so a single
  uvicorn process serves both. This matters because Open WebUI and opencode
  prefer different transports.

- **Engine access is serialized** with an `asyncio.Lock`. Concurrent tool calls
  (the LLM often fires several in parallel) would otherwise interleave
  `position`/`go` UCI commands on the single Stockfish process, corrupting its
  state and crashing it (NNUE assertion failures).

- **`Threads=1`** in Stockfish. Multipv search uses SF's internal threading;
  setting `Threads=1` avoids resource contention and the crash above.

- **Lingering enabled** (`loginctl enable-linger`), so the service starts at
  boot and survives logout.

---

## Installation

### Prerequisites

- **Stockfish** installed system-wide:
  ```sh
  sudo dnf install stockfish   # Fedora
  # Verify:
  stockfish    # should print "Stockfish 18 by the Stockfish developers"
  ```

- **uv** (Python package manager):
  ```sh
  curl -LsSf https://astral.sh/uv/install.sh | sh
  ```

### Steps

The project is already set up at `/home/dvip/Documents/chess_stuff/mcp-chess/`.
To recreate from scratch:

```sh
mkdir -p mcp-chess/src/mcp_chess
cd mcp-chess
uv init --no-readme --no-workspace --bare
```

Create `pyproject.toml`:
```toml
[project]
name = "mcp-chess"
version = "0.1.0"
description = "MCP server exposing Stockfish analysis to Open WebUI"
requires-python = ">=3.11"
dependencies = [
    "mcp[cli]>=1.2.0",
    "python-chess>=1.999",
    "uvicorn>=0.30",
]

[project.scripts]
mcp-chess = "mcp_chess.server:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/mcp_chess"]

[tool.uv]
package = true
```

Create `src/mcp_chess/__init__.py` and `src/mcp_chess/server.py` (the latter is
the full server implementation — see the existing file).

Install dependencies:
```sh
uv sync
```

---

## Running it

### As a systemd user service (recommended)

Create `~/.config/systemd/user/mcp-chess.service`:
```ini
[Unit]
Description=Chess MCP server (Stockfish via python-chess) for Open WebUI
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
WorkingDirectory=/home/dvip/Documents/chess_stuff/mcp-chess
Environment=STOCKFISH_PATH=/usr/bin/stockfish
Environment=STOCKFISH_THREADS=1
Environment=STOCKFISH_HASH_MB=128
Environment=STOCKFISH_DEFAULT_DEPTH=22
Environment=STOCKFISH_DEFAULT_MULTI_PV=5
Environment=MCP_HOST=127.0.0.1
Environment=MCP_PORT=8765
Environment=LOGLEVEL=INFO
ExecStart=/home/dvip/.local/bin/uv run mcp-chess
Restart=on-failure
RestartSec=3

[Install]
WantedBy=default.target
```

Enable and start it:
```sh
systemctl --user daemon-reload
systemctl --user enable --now mcp-chess.service
loginctl enable-linger $USER   # survive logout, start at boot
```

Check status:
```sh
systemctl --user status mcp-chess
journalctl --user -u mcp-chess -f
```

### Manually (for development)

```sh
cd /home/dvip/Documents/chess_stuff/mcp-chess
uv run mcp-chess
# Or with custom env:
STOCKFISH_DEFAULT_DEPTH=18 MCP_PORT=9000 uv run mcp-chess
```

---

## Integrating with Open WebUI

The Open WebUI container runs with `--network=host`, so `127.0.0.1` is
reachable from inside it.

### Step 1: Add the tool server

1. Open **http://localhost:8080** as admin.
2. Go to **Admin Panel → Workspace → External Tool Servers → + New**.
3. Fill in:
   - **Name:** `chess`
   - **Transport / Connection Type:** `Streamable HTTP`
   - **URL:** `http://127.0.0.1:8765/mcp`
4. Save. After a few seconds the status dot should turn green.

> If your Open WebUI only offers `SSE` as a transport, use URL
> `http://127.0.0.1:8765/sse` instead.

### Step 2: Fix the auth type (important!)

Open WebUI's `build_tool_server_headers` always emits
`Authorization: Bearer <key>` when `auth_type == "bearer"`, even if `key` is
empty — producing `Bearer ` (trailing space), which `httpx` rejects as an
illegal header value. This causes "Failed to connect to MCP server" errors at
chat time, even though the verify step succeeds.

**Fix:** Set the connection's **Auth Type** to `session` (not `bearer`), or put
any non-empty string in the **Key** field if you must use bearer.

If you need to fix this in the DB directly:
```sh
podman exec open-webui python -c "
import sqlite3, json
db = sqlite3.connect('/app/backend/data/webui.db')
rows = db.execute(\"SELECT key, value FROM config WHERE key='tool_server.connections'\").fetchall()
k, v = rows[0]
conns = json.loads(v)
for c in conns:
    if c.get('info', {}).get('id') == 'chess':
        c['auth_type'] = 'session'
db.execute('UPDATE config SET value=? WHERE key=?', (json.dumps(conns), k))
db.commit()
print('done')
"
podman restart open-webui
```

### Step 3: Enable tools in chat

1. Start a new conversation.
2. Click the **tools** button (next to the model selector).
3. Enable the `chess` server.

### Step 4: Verify

Watch the logs while sending a message:
```sh
journalctl --user -u mcp-chess -f
```

You should see `POST /mcp HTTP/1.1 200 OK` and `Processing request of type
CallToolRequest`. If you see `405 Method Not Allowed`, you're using the wrong
URL/transport combination (see [Troubleshooting](#troubleshooting)).

---

## Integrating with opencode

opencode reads MCP server config from `~/.config/opencode/opencode.json`.

Create or edit that file:
```json
{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "chess": {
      "type": "remote",
      "url": "http://127.0.0.1:8765/sse",
      "enabled": true
    }
  }
}
```

> opencode's `remote` type uses SSE transport, so point at `/sse`. If you prefer
> Streamable HTTP, check the opencode docs for the current `type` value.

Verify:
```sh
opencode mcp list
# Should show: chess ✓ connected
```

The tools are now available to all opencode agents. No restart needed — opencode
loads config on each invocation.

Config reference: <https://opencode.ai/config.json>

---

## Verifying it works

### Quick curl checks

```sh
# SSE handshake (should return an endpoint event)
curl -sN http://127.0.0.1:8765/sse | head -2

# Streamable HTTP initialize (should return MCP init response)
curl -s -X POST http://127.0.0.1:8765/mcp \
  -H "Accept: application/json, text/event-stream" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}'
```

### End-to-end MCP client test

```sh
cd /home/dvip/Documents/chess_stuff/mcp-chess
uv run python - <<'PY'
import asyncio
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client

async def main():
    async with streamablehttp_client("http://127.0.0.1:8765/mcp") as (r, w, _):
        async with ClientSession(r, w) as s:
            await s.initialize()
            tools = await s.list_tools()
            print("TOOLS:", [t.name for t in tools.tools])
            # Try a real call:
            result = await s.call_tool("explain_choice", {
                "fen_or_moves": "1.e4 e5 2.Nf3 Nc6 3.Bb5",
                "player_move": "a6",
                "depth": 14
            })
            for c in result.content:
                print(c.text[:300])
asyncio.run(main())
PY
```

Expected output:
```
TOOLS: ['analyze_position', 'compare_moves', 'explain_choice', 'analyze_game']
{"fen": "r1bqkbnr/pppp1ppp/2n5/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R b KQkq - 3 3", ...
```

### From inside the Open WebUI container

```sh
podman exec open-webui curl -s -X POST http://127.0.0.1:8765/mcp \
  -H "Accept: application/json, text/event-stream" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}'
```

---

## Configuration

All configuration is via environment variables in the systemd service file
(`~/.config/systemd/user/mcp-chess.service`). Edit and restart with
`systemctl --user restart mcp-chess`.

| Variable                       | Default              | Description |
|--------------------------------|----------------------|-------------|
| `STOCKFISH_PATH`               | `/usr/bin/stockfish` | Path to the Stockfish binary |
| `STOCKFISH_THREADS`            | `1`                  | Stockfish `Threads` UCI option. Keep at 1 — multipv uses internal threads and higher values can cause crashes under concurrent access. |
| `STOCKFISH_HASH_MB`            | `128`                | Stockfish `Hash` UCI option (MB) |
| `STOCKFISH_DEFAULT_DEPTH`      | `22`                 | Default depth for `analyze_position`, `compare_moves`, `explain_choice` |
| `STOCKFISH_DEFAULT_MULTI_PV`   | `5`                  | Default multipv for `analyze_position` |
| `MCP_HOST`                     | `127.0.0.1`          | Bind address |
| `MCP_PORT`                     | `8765`               | Listen port |
| `LOGLEVEL`                     | `INFO`               | Python log level |

### Tuning depth vs. speed

| Depth | Use case | Speed |
|-------|----------|-------|
| 12–14 | Quick checks, full-game review | Fast (~0.5s/position) |
| 16–18 | Default for `analyze_game` | Moderate (~1s/position) |
| 20–22 | Default for single-position tools | Slower (~2–4s/position) |
| 26+   | Deep analysis | Slow (~10s+/position) |

For `analyze_game` on a 40-move game at depth 16, expect ~60–90 seconds total.

---

## Troubleshooting

### "405 Method Not Allowed" in logs

```
HTTP Request: POST http://127.0.0.1:8765/sse "HTTP/1.1 405 Method Not Allowed"
```

**Cause:** Transport/URL mismatch. The client is POSTing to `/sse` (SSE only
accepts GET), or GETing `/mcp` (Streamable HTTP expects POST).

**Fix:** Use `/mcp` with Streamable HTTP transport, `/sse` with SSE transport.

---

### "Failed to connect to MCP server" in Open WebUI chat

The verify step succeeds (logs show `POST /mcp 200 OK`), but chat-time
connection fails.

**Cause:** Open WebUI sends `Authorization: Bearer ` (empty token) when
`auth_type == "bearer"` with empty key. `httpx` rejects the malformed header.

**Fix:** Set the connection's auth type to `session`, or put a non-empty key.
See [Integrating with Open WebUI → Step 2](#step-2-fix-the-auth-type-important).

---

### Stockfish crashes (coredump, NNUE assertion)

```
Assertion '__n < this->size()' failed.
Process (stockfish) dumped core.
```

**Cause:** Concurrent `analyse()` calls interleave UCI commands on the single
engine, corrupting its state.

**Fix:** Already fixed — the server serializes all engine access with an
`asyncio.Lock` and uses `Threads=1`. If you still see this, check that you
haven't set `STOCKFISH_THREADS` higher than 1, and that the service is running
the latest code:
```sh
systemctl --user restart mcp-chess
```

---

### "invalid san: '1.e4'" or PGN parse errors

**Cause:** Older versions of the PGN parser didn't handle move numbers glued to
moves (`1.e4`) or PGN header tags (`[Event "..."]`).

**Fix:** Already fixed — the parser now strips `[...]` headers,
`\d+\.+` move numbers, `{...}` comments, and `$N` NAGs. Verify:
```sh
cd /home/dvip/Documents/chess_stuff/mcp-chess
uv run python -c "
from mcp_chess.server import _normalize_fen_or_moves
b = _normalize_fen_or_moves('1.e4 e5 2.Nf3 Nc6 3.Bb5')
print(b.fen())
"
```

---

### Service won't start

```sh
# Check logs
journalctl --user -u mcp-chess -n 30 --no-pager

# Common issues:
# - uv not found: ensure /home/dvip/.local/bin/uv exists
# - stockfish not found: ensure /usr/bin/stockfish exists
# - port in use: change MCP_PORT
```

---

## Example prompts

### In Open WebUI or opencode

**Single position:**
> Analyze `r1bqkbnr/pppp1ppp/2n5/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R b KQkq - 3 3`
> and tell me the top 3 moves.

**Why not my move:**
> In the Italian opening after 1.e4 e5 2.Nf3 Nc6 3.Bb5, I played 3...a6 — was
> that the best move? If not, why is the engine's choice better?

**Compare candidates:**
> Compare `Nf3`, `d4`, and `a3` from the starting position at depth 20.

**Full game review:**
> [Paste full PGN with headers]
> Can you analyze this game? Tell me where I went wrong and what I should have
> played instead.

**Specific game moment:**
> In this game: 1.e4 f5 2.exf5 Nf6 3.Bc4 d5 4.Bb3 h6 5.Qf3 Nc6 6.c3 d4
> 7.cxd4 Nxd4 — was 7.cxd4 a blunder? What should white have played?

---

## About Maia

The `maia-1500.pb.gz` file in the parent directory (`/home/dvip/Documents/chess_stuff/`)
is a Leela/Lc0 weights file for human-like move prediction at a ~1500 Elo level.

It is **not** wired into this server because running it requires `lc0` (Leela
Chess Zero), not Stockfish. If you want human-likely-move suggestions added as a
tool:

1. Install Lc0:
   ```sh
   sudo dnf install lczero
   ```
2. A `maia_suggest(fen, rating)` tool can be added that shells out to `lc0`
   with the appropriate Maia weights file. The rating parameter selects from
   Maia's 1100–1900 rating models.

Stockfish tells you what's *objectively* best. Maia tells you what a *human at a
given rating* would most likely play — useful for understanding practical
chances and preparing opening repertoire against typical opponents.

---

## File layout

```
/home/dvip/Documents/chess_stuff/
├── maia-1500.pb.gz                 # Leela weights (not used by this server)
└── mcp-chess/
    ├── pyproject.toml              # uv project definition
    ├── uv.lock                     # pinned dependencies
    ├── README.md                   # this file
    └── src/mcp_chess/
        ├── __init__.py
        └── server.py               # the MCP server (FastMCP + python-chess)

~/.config/systemd/user/
└── mcp-chess.service               # systemd user service

~/.config/opencode/
└── opencode.json                   # opencode MCP config (remote → /sse)
```

---

## Tech stack

- **Stockfish 18** — the engine (system package)
- **python-chess** — Python chess library (board, move parsing, UCI client)
- **MCP Python SDK** (`mcp[cli]`) — FastMCP server framework
- **uvicorn** — ASGI server
- **uv** — Python package manager
- **systemd** — user service management