llm-chess-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # llm-chess-mcp
2
+
3
+ An MCP server for truly enjoying chess games with your LLM. It adds a feature that, rather than simply making the best move, allows it to think and judge on its own and make moves. the MCP server handles all the computation (Stockfish evaluation, Maia3 human-move prediction, Lichess opening statistics).
4
+
5
+ ## Engines
6
+
7
+ | Engine | Role | Runtime |
8
+ |---|---|---|
9
+ | **Stockfish 18** (WASM) | Objective evaluation, best moves, multipv | In-process (npm `stockfish`) |
10
+ | **Maia3** (ONNX) | Human-like move probabilities conditioned on Elo | In-process (`onnxruntime-node`) |
11
+ | **Lichess explorer** | Real human game statistics | HTTP (needs token) |
12
+
13
+ Everything runs inside the Node process — no separate engine binaries or Python
14
+ runtime needed at deploy time. Maia3 is exported to ONNX once at build time.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pnpm install
20
+ pnpm build
21
+ ```
22
+
23
+ ### Export Maia3 to ONNX (build-time only)
24
+
25
+ This step needs Python + PyTorch once. It downloads the Maia3 checkpoint, verifies
26
+ the reimplementation against the original, and exports `models/maia3-5m.onnx`.
27
+
28
+ ```bash
29
+ uv venv .venv-maia3 --python 3.13
30
+ uv pip install --python .venv-maia3/bin/python -r scripts/requirements.txt
31
+ uv pip install --python .venv-maia3/bin/python "maia3 @ git+https://github.com/CSSLab/maia3"
32
+ pnpm export:maia3 # -> models/maia3-5m.onnx
33
+ ```
34
+
35
+ The resulting `.onnx` is committed/bundled; end users never need Python or torch.
36
+
37
+ ### Lichess token (optional)
38
+
39
+ The opening explorer now requires authentication. Generate a personal access token
40
+ at <https://lichess.org/account/oauth/token/create> and set it in `.env`:
41
+
42
+ ```bash
43
+ cp .env.example .env
44
+ # set LICHESS_TOKEN=...
45
+ ```
46
+
47
+ Without a token, `opening_explorer` returns a disabled notice; all other tools work.
48
+
49
+ ## Configure in your MCP client
50
+
51
+ ### opencode
52
+
53
+ Add to `opencode.json` (project) or `~/.config/opencode/opencode.json` (global):
54
+
55
+ ```json
56
+ {
57
+ "$schema": "https://opencode.ai/config.json",
58
+ "mcp": {
59
+ "llm-chess-mcp": {
60
+ "type": "local",
61
+ "command": ["node", "/path/to/llm-chess-mcp/dist/index.js"],
62
+ "enabled": true,
63
+ "environment": {
64
+ "LICHESS_TOKEN": "your-token",
65
+ "MAIA3_MODEL": "5m"
66
+ }
67
+ }
68
+ }
69
+ }
70
+ ```
71
+
72
+ ### Claude Code
73
+
74
+ Add to `.mcp.json` (project) or `~/.claude.json` (global), or run:
75
+
76
+ ```bash
77
+ claude mcp add llm-chess-mcp -- node /path/to/llm-chess-mcp/dist/index.js
78
+ ```
79
+
80
+ ```json
81
+ {
82
+ "mcpServers": {
83
+ "llm-chess-mcp": {
84
+ "command": "node",
85
+ "args": ["/path/to/llm-chess-mcp/dist/index.js"],
86
+ "env": {
87
+ "LICHESS_TOKEN": "your-token",
88
+ "MAIA3_MODEL": "5m"
89
+ }
90
+ }
91
+ }
92
+ }
93
+ ```
94
+
95
+ ### Codex CLI
96
+
97
+ Add to `~/.codex/config.toml`:
98
+
99
+ ```toml
100
+ [mcp_servers.llm-chess-mcp]
101
+ command = "node"
102
+ args = ["/path/to/llm-chess-mcp/dist/index.js"]
103
+
104
+ [mcp_servers.llm-chess-mcp.env]
105
+ LICHESS_TOKEN = "your-token"
106
+ MAIA3_MODEL = "5m"
107
+ ```
108
+
109
+ Or via the CLI:
110
+
111
+ ```bash
112
+ codex mcp add llm-chess-mcp --command node --args /path/to/llm-chess-mcp/dist/index.js --env LICHESS_TOKEN=your-token
113
+ ```
114
+
115
+ ## Tools
116
+
117
+ | Tool | Description |
118
+ |---|---|
119
+ | `create_game` | Create a game (optionally from a FEN), returns `game_id` |
120
+ | `delete_game` | Delete a game and free its session |
121
+ | `game_state` | Authoritative state: FEN, turn, check/mate/draw flags, history, last move, castling |
122
+ | `game_state_ascii` | ASCII board diagram |
123
+ | `game_play_move` | Play a move (SAN or UCI) — the only mutating tool |
124
+ | `game_legal_moves` | All legal moves with metadata |
125
+ | `game_pgn` | Export the game as PGN |
126
+ | `game_import_pgn` | Import a PGN into a new game |
127
+ | `position_analyze` | Stockfish multipv lines (cp/mate + PV) |
128
+ | `human_move_distribution` | Maia3 human-move probabilities at a target Elo |
129
+ | `move_evaluate` | Score a move + cpLoss + classification |
130
+ | `move_candidates` | Unified candidates (SF eval + cpLoss + Maia3 prob + Lichess stats) |
131
+ | `move_candidates_by_intent` | Candidates ranked for a strategic intent |
132
+ | `opening_explorer` | Lichess human game statistics |
133
+
134
+ ## Score conventions
135
+
136
+ - Stockfish scores are **side-to-move perspective**: positive cp = side to move is
137
+ better; `mate N` = side to move mates in N.
138
+ - `move_evaluate` reports the score **from the mover's perspective** (negated after
139
+ the move), plus `cpLoss` (centipawns lost vs the best move) and a classification:
140
+ `best / excellent / good / inaccuracy / mistake / blunder`.
141
+ - `maia3Prob` is a **human-likelihood**, not move quality. A high-probability move
142
+ can still be objectively bad.
143
+
144
+ ## Intents
145
+
146
+ `move_candidates_by_intent` ranks candidates for a chosen intent:
147
+
148
+ | Intent | Meaning |
149
+ |---|---|
150
+ | `best` | Strongest engine move |
151
+ | `strong` | Engine-strong but human-plausible |
152
+ | `natural` | Most human-typical at the target Elo |
153
+ | `balanced` | Blend of strength and human-likeness |
154
+ | `ease_off` | Slightly weaker (−30..−80cp) but human |
155
+ | `give_chance` | Clearly weaker (−80..−150cp), gives the opponent chances |
156
+
157
+ ## Example flow
158
+
159
+ 1. `create_game` → `game_id`
160
+ 2. `position_analyze` to see the objective best lines
161
+ 3. `human_move_distribution` to see what a human of a given Elo would play
162
+ 4. `move_candidates_by_intent` with the intent that fits the situation
163
+ 5. `game_play_move` to commit the chosen move
164
+
165
+ ## License & attribution
166
+
167
+ This project is licensed under the **AGPL-3.0** (see `LICENSE`).
168
+
169
+ It bundles and depends on third-party components:
170
+
171
+ | Component | License | Source |
172
+ |---|---|---|
173
+ | [Maia3](https://github.com/CSSLab/maia3) (Chessformer) | AGPL-3.0 | UofT CSSLab — Monroe et al., *Chessformer: A Unified Architecture for Chess Modeling* (ICLR 2026) |
174
+ | [Stockfish](https://github.com/official-stockfish/Stockfish) (via npm `stockfish`) | GPL-3.0 | The Stockfish developers |
175
+ | [onnxruntime-node](https://github.com/microsoft/onnxruntime) | MIT | Microsoft |
176
+ | [chess.js](https://github.com/jhlywa/chess.js) | BSD-2-Clause | Jeff Hlywa |
177
+
178
+ The Maia3 model weights (`models/maia3-5m.onnx`) are derived from the
179
+ `UofTCSSLab/Maia3-5M` checkpoint. The ONNX export is a build-time step
180
+ (`scripts/export_maia3.py`); the runtime does not execute any Maia3 Python code.
@@ -0,0 +1,125 @@
1
+ import { createRequire } from "node:module";
2
+ const require = createRequire(import.meta.url);
3
+ function flavor() {
4
+ return process.env.STOCKFISH_FLAVOR || "lite-single";
5
+ }
6
+ function parseScore(token) {
7
+ if (token.startsWith("cp"))
8
+ return { cp: Number(token.slice(2)), mate: null };
9
+ if (token.startsWith("mate"))
10
+ return { cp: null, mate: Number(token.slice(4)) };
11
+ return { cp: null, mate: null };
12
+ }
13
+ export class Stockfish {
14
+ engine = null;
15
+ ready = null;
16
+ queue = Promise.resolve();
17
+ init() {
18
+ if (this.ready)
19
+ return this.ready;
20
+ this.ready = new Promise((resolve, reject) => {
21
+ const init = require("stockfish");
22
+ const engine = init(flavor(), (err) => {
23
+ if (err)
24
+ return reject(err);
25
+ this.handshake().then(resolve, reject);
26
+ });
27
+ this.engine = engine;
28
+ engine.listener = () => { };
29
+ });
30
+ return this.ready;
31
+ }
32
+ handshake() {
33
+ return new Promise((resolve, reject) => {
34
+ const engine = this.engine;
35
+ let stage = 0;
36
+ const timer = setTimeout(() => reject(new Error("stockfish handshake timeout")), 15000);
37
+ engine.listener = (line) => {
38
+ if (stage === 0 && line === "uciok") {
39
+ stage = 1;
40
+ engine.sendCommand("isready");
41
+ }
42
+ else if (stage === 1 && line === "readyok") {
43
+ clearTimeout(timer);
44
+ engine.listener = null;
45
+ resolve();
46
+ }
47
+ };
48
+ engine.sendCommand("uci");
49
+ });
50
+ }
51
+ enqueue(fn) {
52
+ const run = this.queue.then(fn, fn);
53
+ this.queue = run.catch(() => { });
54
+ return run;
55
+ }
56
+ async analyze(fen, depth, multipv) {
57
+ await this.init();
58
+ return this.enqueue(() => this.doAnalyze(fen, depth, multipv));
59
+ }
60
+ doAnalyze(fen, depth, multipv) {
61
+ return new Promise((resolve, reject) => {
62
+ const engine = this.engine;
63
+ const byPv = new Map();
64
+ let stopTimer = null;
65
+ let failTimer = null;
66
+ const cleanup = () => {
67
+ if (stopTimer)
68
+ clearTimeout(stopTimer);
69
+ if (failTimer)
70
+ clearTimeout(failTimer);
71
+ stopTimer = null;
72
+ failTimer = null;
73
+ };
74
+ engine.listener = (line) => {
75
+ if (line.startsWith("info") && line.includes(" multipv ")) {
76
+ const m = line.match(/multipv (\d+)/);
77
+ const s = line.match(/ score (cp -?\d+|mate -?\d+)/);
78
+ const pv = line.match(/ pv (.+)$/);
79
+ if (!m)
80
+ return;
81
+ const n = Number(m[1]);
82
+ const score = s ? parseScore(s[1]) : { cp: null, mate: null };
83
+ byPv.set(n, {
84
+ multipv: n,
85
+ scoreCp: score.cp,
86
+ scoreMate: score.mate,
87
+ pv: pv ? pv[1].split(" ") : [],
88
+ });
89
+ }
90
+ else if (line.startsWith("bestmove")) {
91
+ cleanup();
92
+ engine.listener = null;
93
+ resolve([...byPv.values()].sort((a, b) => a.multipv - b.multipv));
94
+ }
95
+ };
96
+ engine.sendCommand("position fen " + fen);
97
+ engine.sendCommand(`setoption name MultiPV value ${multipv}`);
98
+ engine.sendCommand(`go depth ${depth}`);
99
+ stopTimer = setTimeout(() => {
100
+ engine.sendCommand("stop");
101
+ failTimer = setTimeout(() => {
102
+ if (engine.listener) {
103
+ engine.listener = null;
104
+ this.reset();
105
+ reject(new Error("stockfish analyze timeout"));
106
+ }
107
+ }, 2000);
108
+ }, 30000);
109
+ });
110
+ }
111
+ reset() {
112
+ if (this.engine) {
113
+ try {
114
+ this.engine.terminate();
115
+ }
116
+ catch { }
117
+ }
118
+ this.engine = null;
119
+ this.ready = null;
120
+ }
121
+ async quit() {
122
+ this.reset();
123
+ }
124
+ }
125
+ export const stockfish = new Stockfish();
package/dist/env.js ADDED
@@ -0,0 +1,24 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ export function loadEnv(path = ".env") {
4
+ let text;
5
+ try {
6
+ text = readFileSync(resolve(process.cwd(), path), "utf8");
7
+ }
8
+ catch {
9
+ return;
10
+ }
11
+ for (const line of text.split("\n")) {
12
+ const trimmed = line.trim();
13
+ if (!trimmed || trimmed.startsWith("#"))
14
+ continue;
15
+ const eq = trimmed.indexOf("=");
16
+ if (eq === -1)
17
+ continue;
18
+ const key = trimmed.slice(0, eq).trim();
19
+ const value = trimmed.slice(eq + 1).trim();
20
+ if (key && process.env[key] === undefined) {
21
+ process.env[key] = value.replace(/^["']|["']$/g, "");
22
+ }
23
+ }
24
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,11 @@
1
+ export class ChessError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(`${code}: ${message}`);
5
+ this.code = code;
6
+ this.name = "ChessError";
7
+ }
8
+ }
9
+ export function fail(code, message) {
10
+ throw new ChessError(code, message);
11
+ }
package/dist/eval.js ADDED
@@ -0,0 +1,27 @@
1
+ export function scoreToCp(line) {
2
+ if (line.scoreMate !== null) {
3
+ const sign = line.scoreMate >= 0 ? 1 : -1;
4
+ return sign * (10000 - Math.abs(line.scoreMate) * 100);
5
+ }
6
+ return line.scoreCp ?? 0;
7
+ }
8
+ export const CLASSIFICATION = {
9
+ best: 0,
10
+ excellent: 30,
11
+ good: 80,
12
+ inaccuracy: 150,
13
+ mistake: 300,
14
+ };
15
+ export function classifyCpLoss(cpLoss) {
16
+ if (cpLoss <= CLASSIFICATION.best)
17
+ return "best";
18
+ if (cpLoss < CLASSIFICATION.excellent)
19
+ return "excellent";
20
+ if (cpLoss < CLASSIFICATION.good)
21
+ return "good";
22
+ if (cpLoss < CLASSIFICATION.inaccuracy)
23
+ return "inaccuracy";
24
+ if (cpLoss < CLASSIFICATION.mistake)
25
+ return "mistake";
26
+ return "blunder";
27
+ }
@@ -0,0 +1,38 @@
1
+ const BASE = "https://explorer.lichess.org";
2
+ export function explorerEnabled() {
3
+ return (process.env.LICHESS_TOKEN || "").length > 0;
4
+ }
5
+ export async function openingExplorer(chess, db, speeds, ratings) {
6
+ if (!explorerEnabled()) {
7
+ throw new Error("LICHESS_TOKEN not set; opening explorer is disabled");
8
+ }
9
+ const params = new URLSearchParams();
10
+ params.set("fen", chess.fen());
11
+ if (speeds.length)
12
+ params.set("speeds", speeds.join(","));
13
+ if (ratings.length)
14
+ params.set("ratings", ratings.join(","));
15
+ const res = await fetch(`${BASE}/${db}?${params}`, {
16
+ headers: { Authorization: `Bearer ${process.env.LICHESS_TOKEN}` },
17
+ });
18
+ if (!res.ok) {
19
+ throw new Error(`lichess explorer ${db} failed: HTTP ${res.status}`);
20
+ }
21
+ const data = (await res.json());
22
+ return {
23
+ db,
24
+ white: data.white,
25
+ draws: data.draws,
26
+ black: data.black,
27
+ moves: data.moves.map((m) => ({
28
+ uci: m.uci,
29
+ san: m.san,
30
+ white: m.white,
31
+ draws: m.draws,
32
+ black: m.black,
33
+ count: m.white + m.draws + m.black,
34
+ averageRating: m.averageRating ?? null,
35
+ })),
36
+ opening: data.opening ?? null,
37
+ };
38
+ }
package/dist/games.js ADDED
@@ -0,0 +1,30 @@
1
+ import { Chess } from "chess.js";
2
+ import { randomUUID } from "node:crypto";
3
+ import { ChessError } from "./errors.js";
4
+ const games = new Map();
5
+ export function createGame(fen) {
6
+ const id = randomUUID();
7
+ const chess = fen ? new Chess(fen) : new Chess();
8
+ games.set(id, { chess, createdAt: Date.now() });
9
+ return id;
10
+ }
11
+ export function createGameFromChess(chess) {
12
+ const id = randomUUID();
13
+ games.set(id, { chess, createdAt: Date.now() });
14
+ return id;
15
+ }
16
+ export function getGame(id) {
17
+ const g = games.get(id);
18
+ if (!g)
19
+ throw new ChessError("GAME_NOT_FOUND", `game not found: ${id}`);
20
+ return g;
21
+ }
22
+ export function deleteGame(id) {
23
+ return games.delete(id);
24
+ }
25
+ export function listGames() {
26
+ return [...games.keys()];
27
+ }
28
+ export function gameCount() {
29
+ return games.size;
30
+ }