Skip to main content
Glama
dvip1
by dvip1

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

  2. Tools reference

  3. Architecture

  4. Installation

  5. Running it

  6. Integrating with Open WebUI

  7. Integrating with opencode

  8. Verifying it works

  9. Configuration

  10. Troubleshooting

  11. Example prompts

  12. 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:

await analyze_position(fen_or_moves="1.e4 e5 2.Nf3 Nc6 3.Bb5", depth=18, multipv=3)

Example output (abbreviated):

{
  "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:

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:

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:

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):

{
  "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:

    sudo dnf install stockfish   # Fedora
    # Verify:
    stockfish    # should print "Stockfish 18 by the Stockfish developers"
  • uv (Python package manager):

    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:

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

Create pyproject.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:

uv sync

Running it

Create ~/.config/systemd/user/mcp-chess.service:

[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:

systemctl --user daemon-reload
systemctl --user enable --now mcp-chess.service
loginctl enable-linger $USER   # survive logout, start at boot

Check status:

systemctl --user status mcp-chess
journalctl --user -u mcp-chess -f

Manually (for development)

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:

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:

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).


Integrating with opencode

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

Create or edit that file:

{
  "$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:

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

# 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

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

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.


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:

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:

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

# 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:

    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

-
license - not tested
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/dvip1/chess_mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server