llm-chess-mcp
llm-chess-mcp
An MCP chess runtime that lets LLMs play, analyze, and adapt their strength without outsourcing every decision to an engine.
Rather than returning a single best move, it exposes objective strength (Stockfish and Lc0), human move likelihood (Maia3), and real-game statistics (Lichess) so the LLM can choose how it wants to play. The LLM does the strategy and judgment; the MCP server handles all the computation.
Engines
Engine | Role | Runtime |
Stockfish 18 (WASM) | Objective evaluation, best moves, multipv | In-process (npm |
Lc0 (native) | Independent neural-network search and candidate ranking | Bundled child process, CPU by default |
Maia3 5M (ONNX) | Human-like move probabilities conditioned on Elo | Dedicated Node child processes ( |
Lichess explorer | Real human game statistics | HTTP (needs token) |
No separately installed engine executable or Python runtime is required for the bundled CPU engines on supported platforms. Stockfish runs in the server process; Lc0 and Maia inference run in dedicated child processes. Lc0 CPU bundles target Linux x64 (glibc >= 2.35) and Windows x64. The published package bundles the Maia3 5M model; other export variants are not runtime options unless their ONNX files are provided separately.
Analysis modes
Analysis defaults to both. Set ENGINE_MODE=stockfish or ENGINE_MODE=lc0
for a server-wide default, or pass engine_mode to analysis, move evaluation,
and candidate tools. A request overrides the environment, which overrides the
packaged default. Single-engine requests never initialize or check the other
engine and never silently switch engines on failure.
Results identify each engine as ok, error, or not_requested.
When one engine fails in both mode, the successful result is returned with
partial: true. Both failing is an error. Cancellation stops the whole request.
Scores, WDL, principal variations, and move classifications remain engine-local;
centipawn values from different engines are never averaged. Move classification
uses the existing CP-loss heuristic within each engine, not a calibrated
cross-engine measure of move quality.
Candidate consensus uses equal-weight reciprocal rank fusion:
sum(1 / (60 + rank)) / successfulEngineCount. An unranked move contributes
zero without being labeled bad. Within each engine, tied intent scores retain
the engine's original ranking. Consensus ties prefer more supporting engines, then UCI
order. This is a ranking score, not a probability. natural remains Maia-only;
ease_off and give_chance require every successful engine to approve the
candidate using available WDL data.
Stockfish retains depth-based limits. Lc0 uses movetime_ms, with default
fast/normal/deep budgets of 1000/3000/10000 ms. Reported depths and node
counts are not comparable between engines. Full game history is passed when
available; FEN-only games have no inferred real history.
Build from source
The published runtime supports Node.js 20.3 and newer. Repository maintenance uses Node.js 22.13 or newer because pnpm 11 and the coverage gate require it.
pnpm install
pnpm build
pnpm testpnpm test:unit runs the unit suite. pnpm test:e2e builds first, then runs
the MCP transport tests. pnpm check runs the full local gate; use
pnpm release:check before publishing.
Transports
stdio remains the default transport and requires no flags. To expose a local Streamable HTTP endpoint instead:
pnpm build
node dist/index.js --transport httpThe server listens on http://127.0.0.1:3000/mcp and supports Streamable HTTP
sessions, JSON responses, and SSE. The equivalent development command is
pnpm dev:http.
HTTP options:
--host <host> Bind host (default: 127.0.0.1)
--port <port> Listen port (default: 3000)
--path <path> Endpoint path (default: /mcp)
--allowed-host <host> Allowed Host/Origin hostname; repeat as neededThe package also exposes a typed ESM API:
import { serveHttp } from "llm-chess-mcp";
const server = await serveHttp({ port: 3000, bodyTimeoutMs: 15_000 });
await server.close();The root API also exports buildServer, GameStore, ChessError,
ExplorerError, the service/domain types needed to provide custom
AppServices, and safe chess helpers including parseImportedPgn, pgnOf,
and snapshotChess. The package root is the supported public API. Deep imports
under dist/ are intentionally not exported and will fail with
ERR_PACKAGE_PATH_NOT_EXPORTED; use named root exports instead.
This removes the previous dist/* compatibility exports and is a breaking
change for integrations that imported internal modules.
bodyTimeoutMs limits HTTP body upload time; it is not a whole-tool deadline.
The deprecated requestTimeoutMs alias remains supported when bodyTimeoutMs
is omitted.
Binding to 0.0.0.0 or :: requires at least one --allowed-host. HTTP mode
does not provide authentication or TLS; use a trusted network or an
authenticated reverse proxy when exposing it beyond localhost. Origin values
are validated when present, but the server does not emit browser CORS headers.
Lichess token (optional)
The opening explorer now requires authentication. Generate a personal access token
at https://lichess.org/account/oauth/token/create and set it in .env:
cp .env.example .env
# set LICHESS_TOKEN=...Without a token, opening_explorer returns a disabled notice; all other tools work.
Explorer filters are strict. Speeds are ultraBullet, bullet, blitz,
rapid, classical, and correspondence; rating buckets are 0, 1000,
1200, 1400, 1600, 1800, 2000, 2200, and 2500. masters accepts
neither filter. Invalid filters fail locally. Transient failures (network,
timeout, 429, and 5xx) are retried once within a 12-second total budget;
invalid requests and other 4xx responses are not retried. Responses must be
valid UTF-8 JSON and are limited to 1 MiB, 256 moves, and 256 characters per
move or opening string.
Configure in your MCP client
opencode
Add to opencode.json (project) or ~/.config/opencode/opencode.json (global):
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"llm-chess-mcp": {
"type": "local",
"command": ["npx", "-y", "llm-chess-mcp"],
"enabled": true,
"environment": {
"LICHESS_TOKEN": "your-token"
}
}
}
}Claude Code
Add to .mcp.json (project) or ~/.claude.json (global), or run:
claude mcp add llm-chess-mcp -- npx -y llm-chess-mcp{
"mcpServers": {
"llm-chess-mcp": {
"command": "npx",
"args": ["-y", "llm-chess-mcp"],
"env": {
"LICHESS_TOKEN": "your-token"
}
}
}
}Codex CLI
Add to ~/.codex/config.toml:
[mcp_servers.llm-chess-mcp]
command = "npx"
args = ["-y", "llm-chess-mcp"]
[mcp_servers.llm-chess-mcp.env]
LICHESS_TOKEN = "your-token"Or via the CLI:
codex mcp add llm-chess-mcp --command npx --args -y llm-chess-mcp --env LICHESS_TOKEN=your-tokenTools
Tool | Description |
| Create a game (optionally from a FEN), returns |
| Delete a process-shared game and free game capacity |
| Authoritative state: FEN, turn, revision, check/mate/draw flags, history, last move, castling (optional ASCII) |
| Play a move (SAN or UCI) — the only mutating tool, with stale-position guard |
| All legal moves with metadata |
| Export the game as PGN |
| Import a PGN into a new game |
| Per-engine MultiPV lines (cp/mate/WDL + UCI/SAN PV), consensus ranking, and |
| Maia3 human-move probabilities at a target Elo |
| Score one or more moves + cpLoss + classification |
| Primary tool: unified candidates (objective + human + opening) |
| Convenience layer: candidates ranked for a strategic intent |
| Lichess human game statistics |
Result format
structuredContent is the canonical successful result. Handler-level failures
set isError and provide structuredContent.error. Input-schema failures are
generated by the MCP SDK before the handler and use its standard isError text
result without structuredContent. Otherwise, content is only a short
human-readable summary and must not be parsed as data.
Score conventions
Engine analysis scores are side-to-move perspective: positive cp = side to move is better;
mate N= side to move mates in N.wdlis[win, draw, loss]in permille for the side to move.move_candidatesgives per-engineobjective.byEnginevalues withmoverCp(the mover's perspective — higher is better for the player choosing the move) andwhiteCp(fixed white perspective) so the sign never flips on you.move_evaluatereports the score from the mover's perspective, pluscpLoss(centipawns lost vs the best move) and a classification:best / excellent / good / inaccuracy / mistake / blunder.maia3Probis a human-likelihood, not move quality. A high-probability move can still be objectively bad.Successful analysis continuations return corresponding
pvandpvSanarrays of equal length in UCI and SAN. An invalid engine continuation is rejected at the internal tool boundary instead of returning a truncatedpvSan.
Candidate structure
move_candidates returns each candidate with three independent facets:
{
"uci": "g1f3",
"san": "Nf3",
"objective": {
"byEngine": {
"stockfish": { "rank": 1, "moverCp": 55, "whiteCp": 55, "cpLoss": 0, "moverMate": null, "whiteMate": null, "wdl": [153, 844, 3] },
"lc0": { "rank": 1, "moverCp": 45, "whiteCp": 45, "cpLoss": 0, "moverMate": null, "whiteMate": null, "wdl": [200, 750, 50] }
}
},
"consensusRank": 1,
"consensusScore": 0.01639344262295082,
"support": 2,
"human": { "maia3Prob": 0.62, "selfElo": 1500, "opponentElo": 1500 },
"opening": { "status": "available", "games": 18421, "frequency": 0.31, "white": 9000, "draws": 3000, "black": 6421, "averageRating": 1800 }
}objective.byEngine— independent Stockfish and Lc0 evaluations; an engine's entry isnullwhen it did not evaluate that candidate.moverCpis from the mover's perspective (higher = better for the chooser).human— Maia3 conditional probability at a target Elo.opening— Lichess empirical frequency (a different signal from Maia3).
opening.status is available, no_data (API ok but no games in this
position), unavailable (timeout/429/401), or disabled (no token).
Explorer failure does not discard successful engine or Maia3 results. The
selected engine mode controls which engines run. In both mode, one engine
failure yields partial: true; both failing produces a tool error. Top-level
engines records each outcome and enginesUsed lists successful engines.
move_candidates also returns moveSensitivity, describing how sharply the
evaluation changes across the top engine lines:
{
"moveSensitivity": {
"stockfish": { "level": "high", "topMoveSpreadCp": 245 },
"lc0": { "level": "medium", "topMoveSpreadCp": 120 }
}
}level is low (<80cp spread), medium (80–200cp), or high (≥200cp). High
sensitivity means choosing among plausible alternatives can materially change
the evaluation — useful for deciding whether to ease off or play precisely.
An unavailable or unrequested engine has null sensitivity. The two engines'
centipawn scales are independent and should not be compared directly.
Analysis levels
Position and candidate tools accept an analysis_level preset:
Level | Stockfish depth | MultiPV | Lc0 time (ms) |
| 8 | 5 | 1000 |
| 15 | 8 | 3000 |
| 22 | 10 | 10000 |
Position analysis accepts depth/multipv overrides; candidate tools use
sf_depth/sf_multipv. movetime_ms overrides the Lc0 budget in either tool.
move_evaluate defaults to depth 15 and 3000 ms and accepts explicit overrides.
Stale-position guard
Every state read returns a revision. game_play_move requires
expected_revision; if the game has advanced since your last read, the move is
rejected:
{ "error": { "code": "STALE_POSITION", "message": "position changed: expected revision 2, current 3" } }Runtime limits
Up to 1,000 games are retained per process; idle games expire after one hour.
move_evaluateaccepts at most 10 moves per call.Imported and exported PGNs are limited to 1 MiB, 256 headers, and 4,096 plies; stored snapshots enforce the same byte, header, token, and ply resource bounds. Imports also cap the mainline and variations together at 32,768 structural elements and 16 KiB per lexical token. Every variation is legality-checked; game state retains the mainline. UTF-8 BOMs and standard escaped header values are supported.
Custom FENs reject inconsistent castling/en-passant metadata and impossible pawn or promotion material.
Stockfish and Lc0 each accept up to 32 active or queued analyses. Maia runs at most two inferences concurrently and queues up to 32 more.
Lichess Explorer requests run one at a time and share 429 cooldowns.
HTTP retains at most 64 MCP sessions; sessions with no active request expire after 30 minutes. An open GET/SSE stream keeps its session active.
HTTP accepts bodies up to 2 MiB under normal body-parser capacity. Once those parsers are full, an overflow request receives only a small, up-to-8 KiB probe; only a complete MCP cancellation notification can proceed, and no accepted parser is preempted. The listener's connection limit bounds overflow probes. After body parsing, it permits 16 concurrent POST dispatches and downstream compute/network jobs process-wide, with two of each per session. A separate bounded control lane prioritizes MCP cancellation when normal dispatch capacity is full. If an existing-session POST response closes before it finishes, its session is closed and its work is aborted; an uncooperative downstream operation still holds capacity until it settles. HTTP also caps connections at 128 and applies a 15-second body upload deadline plus bounded header, socket, and keep-alive timeouts.
Programmatic users can override the HTTP limits through HttpServerOptions.
These safeguards do not replace public-edge quotas: a public deployment must
still enforce request, connection, and authentication limits at the reverse
proxy.
MCP cancellation notifications, session deletion, and server shutdown propagate to body uploads and Stockfish, Lc0, Maia, and Lichess work. Stockfish stops safely at its UCI queue boundary, drains queued work during shutdown, and rejects new analysis until teardown completes. Lc0 rejects active and queued work on shutdown and waits for its process to exit, escalating termination when necessary. Lichess fetch and retry waits abort immediately. Maia runs native inference in dedicated child processes; cancelling active work terminates its child, while queued cancellation is immediate. A raw response disconnect for an existing-session POST closes that session and aborts its work. Reconnect with a new session, then re-read the process-shared game state before retrying a move.
Intents
move_candidates_by_intent ranks candidates for a chosen intent. It is a
convenience layer over move_candidates; the fixed thresholds below are
heuristic defaults, not the source of truth:
Intent | Meaning |
| Strongest engine move |
| Engine-strong but human-plausible |
| Most human-typical at the target Elo |
| Blend of strength and human-likeness |
| Human-plausible moves that modestly reduce advantage without changing the expected result |
| Human-plausible inaccuracies that meaningfully improve the opponent's chances |
This tool ranks candidates but does not choose a move. Use the returned signals and conversation context to make the final decision — do not map user skill mechanically to an intent.
Example flow
The normal play loop is three calls:
create_game→game_idmove_candidates→ pick a movegame_play_move(withexpected_revision) → commit it
Go deeper only when you need to:
position_analyze— objective best lineshuman_move_distribution— what a human of a given Elo would playopening_explorer— real-game statisticsmove_evaluate— score a specific move (or compare several)
Export Maia3 to ONNX
The publisher chooses the model in model.config.json. The export step needs
Python + PyTorch once; it downloads the pinned checkpoint, verifies the
reimplementation against the original, and writes the verified ONNX bundle to
models/.
uv venv .venv-maia3 --python 3.13
uv pip install --python .venv-maia3/bin/python -r scripts/requirements.txt
uv pip install --python .venv-maia3/bin/python "maia3 @ git+https://github.com/CSSLab/maia3.git@1e13597c42d4858b7cfd7cfdae01e297263364b2"
.venv-maia3/bin/python scripts/export_maia3.py --device cpuThe default --config is the repository's model.config.json; pass another
config path to export a different supported Maia3 variant. The generated
models/manifest.json records the source, checkpoint digest, model filename,
and artifact digests. Run pnpm model:check before packaging to verify that
the manifest still matches the config and files.
The default config selects the current pinned 5M checkpoint:
{
"schemaVersion": 3,
"analysis": { "mode": "both" },
"maia3": {
"model": "5m",
"source": {
"type": "huggingface",
"repoId": "UofTCSSLab/Maia3-5M",
"filename": "maia3-5m.pt",
"revision": "b6559de2398d7140b985f28fd2c19fb5e47ddabe"
}
},
"stockfish": {
"version": "18.0.8",
"flavor": "lite-single"
},
"lc0": {
"version": "0.32.1",
"weights": {
"url": "https://storage.lczero.org/files/networks-contrib/t1-256x10-distilled-swa-2432500.pb.gz",
"sha256": "bc27a6cae8ad36f2b9a80a6ad9dabb0d6fda25b1e7f481a79bc359e14f563406"
},
"backend": "cpu",
"platforms": ["linux-x64", "win32-x64"]
}
}Supported architectures are 3m, 5m, 23m, and 79m; the source checkpoint
must match the selected architecture. Hugging Face revisions must be full
lowercase commit SHAs. For local weights, replace maia3.source with
{"type": "local", "path": "weights/checkpoint.pt"}. Relative checkpoint
paths resolve against the config file, not the working directory.
Absolute local checkpoint paths are also accepted; prefer relative paths for
portable configs.
--cache-dir optionally controls the Hugging Face download cache.
Model/source selection now uses the config file instead of the old --model
and --checkpoint flags. Export always verifies before replacing the bundle;
there is no --skip-verify or custom --out. pnpm export:maia3 is equivalent
when the required Python environment is active.
The workflow is: edit the root config, export, run pnpm check, then run
pnpm test:package. Exporting with another config does not change the root
config; make them agree before packaging. Any unlisted files left over after
switching models must be removed or moved out of models/ explicitly; checks
report them and never delete them automatically.
Normal pnpm build only compiles TypeScript. npm includes the generated
models/ alongside dist/, not the Python scripts, source checkpoint, or build
config. Consumers do not download weights from Hugging Face at install or
runtime. With MAIA3_MODEL unset, the bundled manifest chooses the default;
an explicit supported key retains package-then-working-directory model lookup.
Exporter regression tests run separately from the Python-free Node checks:
.venv-maia3/bin/python -m unittest discover -s scripts -p 'test_model_*.py'Maia3 ONNX verification
The exported ONNX model is regression-tested against the upstream Maia3 implementation across fixed positions and Elo pairs:
.venv-maia3/bin/python scripts/verify_maia3.py --config model.config.jsonUse --onnx path/to/model.onnx to verify a specific ONNX artifact. Without it,
verification reads the model filename from the generated manifest.
It checks top-1/top-k move agreement and max probability error to detect
export/runtime regressions. The bundled maia3-5m.onnx passes with 100% top-1
and top-5 agreement and max probability error < 1e-4.
Configure Stockfish
The same model.config.json selects the exact npm stockfish version and
default engine flavor. 18.0.8 is the npm package version; it contains the
Stockfish 18 engine. Version ranges, tags, and prereleases are not accepted.
Supported flavors are full, single, lite, lite-single, single-lite
(an alias), and asm.
After editing the stockfish section:
pnpm stockfish:prepare
pnpm check
pnpm test:packagePreparation uses pnpm to pin and install the exact dependency and update the
lockfile, compiles TypeScript, then checks initialization, UCI readiness,
analysis, and shutdown using the configured flavor. Only after successful
verification is the default flavor recorded in package.json. An incompatible
version fails preparation; older loader APIs are not automatically adapted.
If preparation fails, dependency files may already have changed. Correct the
configuration or compatibility error and rerun it; Git changes are never
automatically reverted.
Runtime selection is an explicit engine option, then STOCKFISH_FLAVOR, then
the packaged default. The real loader rejects an installed package version
that differs from the pinned dependency. Consumers receive Stockfish as an
exact npm dependency; the running server never installs or switches versions.
pnpm model:check checks both engines without downloading or installing
anything. Stockfish-only changes do not require Maia export: its manifest
continues to record only normalized Maia settings. Schema version 1 build
configs must be updated to the unified format above. Ordinary builds do
not install engines. External NNUE replacement and flavor-specific package
size optimization are not provided.
Package verification
Lc0 engines and weights are prepared by the publisher with pnpm lc0:prepare.
Preparation runs on Linux with Docker and Wine available. The Linux CPU build
uses Ubuntu 22.04 and DNNL; the runtime backend is named blas even when DNNL
provides its matrix operations. If Docker requires sudo, explicitly set
LC0_DOCKER_SUDO=1. The Linux engine source archive and third-party notices are
retained with the prepared artifacts. A prebuilt Linux artifact directory may instead be
supplied through LC0_LINUX_BUNDLE.
The staged bundle is checked before it replaces a previous working bundle.
Preparation includes every platform selected in the config; partial-platform
replacement is rejected. If the root config changes during preparation, the
existing bundle is preserved and preparation must be rerun.
bundle/lc0/manifest.json records platform executables, required libraries,
backend, network identity, and SHA-256 digests. The package contains artifacts
for both supported platforms and a shared pinned weight file; it does not
download models or install GPU software when the server starts.
On Windows 10/11 x64, install the official
Microsoft Visual C++ v14 x64 Redistributable
before using Lc0. The Lc0/DNNL binaries require MSVCP140.dll, VCOMP140.dll,
VCRUNTIME140.dll, and VCRUNTIME140_1.dll; Microsoft runtime DLLs are not
redistributed in this package. Stockfish-only mode does not require Lc0 or
its native runtime prerequisites.
CPU is the default. CUDA is a build-time option requiring a compatible NVIDIA
environment and a successful preparation probe. A missing GPU/backend is an
explicit engine failure, not an implicit switch to CPU. Windows validation via
Wine is supplementary and must not be reported as a native Windows test.
CUDA preparation takes a matching Linux artifact directory in
LC0_LINUX_BUNDLE and a Windows archive in LC0_WINDOWS_ARCHIVE, with its
SHA-256 in LC0_WINDOWS_ARCHIVE_SHA256. It does not install GPU drivers.
Package artifacts are verified locally; this project intentionally has no hosted CI workflow.
Run pnpm check for the deterministic offline gate. Use pnpm test:package to
pack the project, install the tarball in a clean temporary directory, and run
the installed llm-chess-mcp binary against the real Stockfish, Lc0, and Maia
runtimes. pnpm release:check runs both checks plus the production dependency
audit and package manifest dry run.
Package verification uses the OS temporary directory by default. If it exceeds its disk quota or free space, select a larger writable location:
PACKAGE_SMOKE_TMPDIR=/path/on/larger/disk pnpm test:packageThe same environment variable applies to pnpm release:check and publishing.
Temporary installs are removed after success or failure. On failure, a bounded
diagnostic report (including available npm log excerpts) is saved separately in
.package-smoke-failures/; PACKAGE_SMOKE_LOGDIR overrides that location.
Keep diagnostic logs private and review them before sharing. They are not
included in the npm package.
License & attribution
This project is licensed under the AGPL-3.0 (see LICENSE).
It bundles and depends on third-party components:
Component | License | Source |
Maia3 (Chessformer) | AGPL-3.0 | UofT CSSLab — Monroe et al., Chessformer: A Unified Architecture for Chess Modeling (ICLR 2026) |
Stockfish (via npm | GPL-3.0 | The Stockfish developers |
GPL-3.0 | The Leela Chess Zero developers; bundled library notices accompany each platform artifact | |
MIT | Microsoft | |
BSD-2-Clause | Jeff Hlywa |
The bundled Maia3 model (models/maia3-5m.onnx) is derived from
UofTCSSLab/Maia3-5M at b6559de2398d7140b985f28fd2c19fb5e47ddabe.
The ONNX export is a build-time step (scripts/export_maia3.py); the runtime
does not execute any Maia3 Python code.