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/LICENSE +661 -0
- package/README.md +180 -0
- package/dist/engines/stockfish.js +125 -0
- package/dist/env.js +24 -0
- package/dist/errors.js +11 -0
- package/dist/eval.js +27 -0
- package/dist/explorer.js +38 -0
- package/dist/games.js +30 -0
- package/dist/index.js +320 -0
- package/dist/intents.js +104 -0
- package/dist/maia3/inference.js +76 -0
- package/dist/maia3/mirror.js +10 -0
- package/dist/maia3/tokenize.js +55 -0
- package/dist/maia3/vocab.js +32 -0
- package/dist/types.js +1 -0
- package/models/maia3-5m.onnx +21019 -0
- package/models/maia3-5m.onnx.data +0 -0
- package/package.json +34 -0
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
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
|
+
}
|
package/dist/explorer.js
ADDED
|
@@ -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
|
+
}
|