llm-chess-mcp 0.1.2 → 0.1.3

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/.env.example ADDED
@@ -0,0 +1,9 @@
1
+ # Lichess opening explorer (optional; explorer tools disabled if unset)
2
+ # Generate at https://lichess.org/account/oauth/token/create
3
+ LICHESS_TOKEN=
4
+
5
+ # The published package bundles only Maia3 5m (~20 MB). Leave at 5m.
6
+ MAIA3_MODEL=5m
7
+
8
+ # Stockfish flavor: lite-single (default, ~7MB) | lite | single | full
9
+ STOCKFISH_FLAVOR=lite-single
package/README.md CHANGED
@@ -13,14 +13,18 @@ judgment; the MCP server handles all the computation.
13
13
  | Engine | Role | Runtime |
14
14
  |---|---|---|
15
15
  | **Stockfish 18** (WASM) | Objective evaluation, best moves, multipv | In-process (npm `stockfish`) |
16
- | **Maia3** (ONNX) | Human-like move probabilities conditioned on Elo | In-process (`onnxruntime-node`) |
16
+ | **Maia3 5M** (ONNX) | Human-like move probabilities conditioned on Elo | In-process (`onnxruntime-node`) |
17
17
  | **Lichess explorer** | Real human game statistics | HTTP (needs token) |
18
18
 
19
19
  Everything runs inside the Node process — no external engine process or Python
20
- runtime is required at deploy time. Maia3 is exported to ONNX once at build time.
20
+ runtime is required at deploy time. The published package bundles the Maia3 5M
21
+ model; other export variants are not runtime options unless their ONNX files are
22
+ provided separately.
21
23
 
22
24
  ## Install
23
25
 
26
+ Requires Node.js 20 or newer.
27
+
24
28
  No install needed — run it directly with `npx`:
25
29
 
26
30
  ```bash
@@ -41,6 +45,7 @@ npm install -g llm-chess-mcp
41
45
  ```bash
42
46
  pnpm install
43
47
  pnpm build
48
+ pnpm test
44
49
  ```
45
50
 
46
51
  ### Export Maia3 to ONNX (build-time only)
@@ -51,7 +56,7 @@ the reimplementation against the original, and exports `models/maia3-5m.onnx`.
51
56
  ```bash
52
57
  uv venv .venv-maia3 --python 3.13
53
58
  uv pip install --python .venv-maia3/bin/python -r scripts/requirements.txt
54
- uv pip install --python .venv-maia3/bin/python "maia3 @ git+https://github.com/CSSLab/maia3"
59
+ uv pip install --python .venv-maia3/bin/python "maia3 @ git+https://github.com/CSSLab/maia3.git@1e13597c42d4858b7cfd7cfdae01e297263364b2"
55
60
  pnpm export:maia3 # -> models/maia3-5m.onnx
56
61
  ```
57
62
 
@@ -84,8 +89,7 @@ Add to `opencode.json` (project) or `~/.config/opencode/opencode.json` (global):
84
89
  "command": ["npx", "-y", "llm-chess-mcp"],
85
90
  "enabled": true,
86
91
  "environment": {
87
- "LICHESS_TOKEN": "your-token",
88
- "MAIA3_MODEL": "5m"
92
+ "LICHESS_TOKEN": "your-token"
89
93
  }
90
94
  }
91
95
  }
@@ -107,8 +111,7 @@ claude mcp add llm-chess-mcp -- npx -y llm-chess-mcp
107
111
  "command": "npx",
108
112
  "args": ["-y", "llm-chess-mcp"],
109
113
  "env": {
110
- "LICHESS_TOKEN": "your-token",
111
- "MAIA3_MODEL": "5m"
114
+ "LICHESS_TOKEN": "your-token"
112
115
  }
113
116
  }
114
117
  }
@@ -126,7 +129,6 @@ args = ["-y", "llm-chess-mcp"]
126
129
 
127
130
  [mcp_servers.llm-chess-mcp.env]
128
131
  LICHESS_TOKEN = "your-token"
129
- MAIA3_MODEL = "5m"
130
132
  ```
131
133
 
132
134
  Or via the CLI:
@@ -223,6 +225,13 @@ rejected:
223
225
  { "error": { "code": "STALE_POSITION", "message": "position changed: expected revision 2, current 3" } }
224
226
  ```
225
227
 
228
+ ## Runtime limits
229
+
230
+ - Up to 1,000 game sessions are retained; idle sessions expire after one hour.
231
+ - `move_evaluate` accepts at most 10 moves per call.
232
+ - Imported PGNs are limited to 1 MiB and 4,096 plies.
233
+ - Stockfish accepts up to 32 active or queued analyses.
234
+
226
235
  ## Intents
227
236
 
228
237
  `move_candidates_by_intent` ranks candidates for a chosen intent. It is a
@@ -283,6 +292,7 @@ It bundles and depends on third-party components:
283
292
  | [onnxruntime-node](https://github.com/microsoft/onnxruntime) | MIT | Microsoft |
284
293
  | [chess.js](https://github.com/jhlywa/chess.js) | BSD-2-Clause | Jeff Hlywa |
285
294
 
286
- The Maia3 model weights (`models/maia3-5m.onnx`) are derived from the
287
- `UofTCSSLab/Maia3-5M` checkpoint. The ONNX export is a build-time step
288
- (`scripts/export_maia3.py`); the runtime does not execute any Maia3 Python code.
295
+ The bundled Maia3 model (`models/maia3-5m.onnx`) is derived from
296
+ [`UofTCSSLab/Maia3-5M` at `b6559de2398d7140b985f28fd2c19fb5e47ddabe`](https://huggingface.co/UofTCSSLab/Maia3-5M/tree/b6559de2398d7140b985f28fd2c19fb5e47ddabe).
297
+ The ONNX export is a build-time step (`scripts/export_maia3.py`); the runtime
298
+ does not execute any Maia3 Python code.
@@ -1,7 +1,34 @@
1
1
  import { createRequire } from "node:module";
2
2
  const require = createRequire(import.meta.url);
3
- function flavor() {
4
- return process.env.STOCKFISH_FLAVOR || "lite-single";
3
+ export const STOCKFISH_FLAVORS = [
4
+ "full",
5
+ "lite",
6
+ "single",
7
+ "lite-single",
8
+ "single-lite",
9
+ "asm",
10
+ ];
11
+ const DEFAULT_FLAVOR = "lite-single";
12
+ const FLAVORS = new Set(STOCKFISH_FLAVORS);
13
+ const DEFAULT_TIMEOUTS = {
14
+ init: 15000,
15
+ handshake: 15000,
16
+ analyze: 30000,
17
+ stopGrace: 2000,
18
+ };
19
+ const DEFAULT_MAX_QUEUE = 32;
20
+ export function resolveStockfishFlavor(value) {
21
+ const normalized = (value || DEFAULT_FLAVOR).toLowerCase();
22
+ if (!FLAVORS.has(normalized)) {
23
+ throw new Error(`invalid STOCKFISH_FLAVOR: ${JSON.stringify(value)}; expected one of ${STOCKFISH_FLAVORS.join(", ")}`);
24
+ }
25
+ return normalized;
26
+ }
27
+ function loadStockfish() {
28
+ return require("stockfish");
29
+ }
30
+ function asError(error) {
31
+ return error instanceof Error ? error : new Error(String(error));
5
32
  }
6
33
  function parseScore(token) {
7
34
  if (token.startsWith("cp"))
@@ -17,56 +44,176 @@ function parseWdl(line) {
17
44
  return [Number(m[1]), Number(m[2]), Number(m[3])];
18
45
  }
19
46
  export class Stockfish {
20
- engine = null;
21
- ready = null;
47
+ session = null;
22
48
  queue = Promise.resolve();
49
+ queued = 0;
50
+ quitGeneration = 0;
51
+ terminated = new WeakSet();
52
+ initEngine;
53
+ configuredFlavor;
54
+ maxQueue;
55
+ timeouts;
56
+ constructor(options = {}) {
57
+ this.initEngine = options.init;
58
+ this.configuredFlavor = options.flavor;
59
+ this.maxQueue = options.maxQueue ?? DEFAULT_MAX_QUEUE;
60
+ this.timeouts = { ...DEFAULT_TIMEOUTS, ...options.timeouts };
61
+ if (!Number.isInteger(this.maxQueue) || this.maxQueue < 1) {
62
+ throw new Error("stockfish maxQueue must be a positive integer");
63
+ }
64
+ for (const [name, timeout] of Object.entries(this.timeouts)) {
65
+ if (!Number.isFinite(timeout) || timeout < 1) {
66
+ throw new Error(`stockfish ${name} timeout must be positive`);
67
+ }
68
+ }
69
+ }
23
70
  init() {
24
- if (this.ready)
25
- return this.ready;
26
- this.ready = new Promise((resolve, reject) => {
27
- const init = require("stockfish");
28
- const engine = init(flavor(), (err) => {
29
- if (err)
30
- return reject(err);
31
- this.handshake().then(resolve, reject);
32
- });
33
- this.engine = engine;
34
- engine.listener = () => { };
71
+ if (this.session)
72
+ return this.session.ready;
73
+ let resolve;
74
+ let reject;
75
+ const ready = new Promise((res, rej) => {
76
+ resolve = res;
77
+ reject = rej;
35
78
  });
36
- return this.ready;
79
+ const session = {
80
+ engine: null,
81
+ ready,
82
+ initSettled: false,
83
+ initTimer: null,
84
+ resolve,
85
+ reject,
86
+ aborts: new Set(),
87
+ };
88
+ this.session = session;
89
+ session.initTimer = setTimeout(() => this.failSession(session, new Error("stockfish init timeout")), this.timeouts.init);
90
+ let callbackCalled = false;
91
+ try {
92
+ const selectedFlavor = resolveStockfishFlavor(this.configuredFlavor ?? process.env.STOCKFISH_FLAVOR);
93
+ const engine = (this.initEngine ?? loadStockfish())(selectedFlavor, (error, initializedEngine) => {
94
+ callbackCalled = true;
95
+ if (this.session !== session) {
96
+ this.terminate(initializedEngine);
97
+ return;
98
+ }
99
+ if (session.initSettled) {
100
+ if (initializedEngine !== session.engine)
101
+ this.terminate(initializedEngine);
102
+ return;
103
+ }
104
+ if (session.engine && session.engine !== initializedEngine) {
105
+ this.terminate(session.engine);
106
+ }
107
+ session.engine = initializedEngine;
108
+ if (error) {
109
+ this.failSession(session, asError(error));
110
+ return;
111
+ }
112
+ initializedEngine.listener = () => { };
113
+ if (session.initTimer)
114
+ clearTimeout(session.initTimer);
115
+ session.initTimer = null;
116
+ this.handshake(session).then(() => {
117
+ if (this.session !== session || session.initSettled)
118
+ return;
119
+ session.initSettled = true;
120
+ session.resolve();
121
+ }, (handshakeError) => this.failSession(session, asError(handshakeError)));
122
+ });
123
+ if (!callbackCalled && this.session === session && !session.initSettled) {
124
+ session.engine = engine;
125
+ engine.listener = () => { };
126
+ }
127
+ }
128
+ catch (error) {
129
+ this.failSession(session, asError(error));
130
+ }
131
+ return ready;
37
132
  }
38
- handshake() {
133
+ handshake(session) {
39
134
  return new Promise((resolve, reject) => {
40
- const engine = this.engine;
135
+ const engine = session.engine;
136
+ if (!engine) {
137
+ reject(new Error("stockfish initialized without an engine"));
138
+ return;
139
+ }
41
140
  let stage = 0;
42
- const timer = setTimeout(() => reject(new Error("stockfish handshake timeout")), 15000);
43
- engine.listener = (line) => {
141
+ let settled = false;
142
+ const finish = (error) => {
143
+ if (settled)
144
+ return;
145
+ settled = true;
146
+ clearTimeout(timer);
147
+ session.aborts.delete(abort);
148
+ if (engine.listener === listener)
149
+ engine.listener = null;
150
+ if (error)
151
+ reject(error);
152
+ else
153
+ resolve();
154
+ };
155
+ const abort = (error) => finish(error);
156
+ const listener = (line) => {
44
157
  if (stage === 0 && line === "uciok") {
45
158
  stage = 1;
46
- engine.sendCommand("isready");
159
+ try {
160
+ engine.sendCommand("isready");
161
+ }
162
+ catch (error) {
163
+ finish(asError(error));
164
+ }
47
165
  }
48
166
  else if (stage === 1 && line === "readyok") {
49
- clearTimeout(timer);
50
- engine.listener = null;
51
- resolve();
167
+ finish();
52
168
  }
53
169
  };
54
- engine.sendCommand("uci");
170
+ const timer = setTimeout(() => finish(new Error("stockfish handshake timeout")), this.timeouts.handshake);
171
+ session.aborts.add(abort);
172
+ engine.listener = listener;
173
+ try {
174
+ engine.sendCommand("uci");
175
+ }
176
+ catch (error) {
177
+ finish(asError(error));
178
+ }
55
179
  });
56
180
  }
57
181
  enqueue(fn) {
58
- const run = this.queue.then(fn, fn);
59
- this.queue = run.catch(() => { });
60
- return run;
182
+ if (this.queued >= this.maxQueue) {
183
+ return Promise.reject(new Error("stockfish queue full"));
184
+ }
185
+ this.queued++;
186
+ const run = this.queue.then(fn);
187
+ this.queue = run.then(() => { }, () => { });
188
+ return run.finally(() => {
189
+ this.queued--;
190
+ });
61
191
  }
62
- async analyze(fen, depth, multipv) {
63
- await this.init();
64
- return this.enqueue(() => this.doAnalyze(fen, depth, multipv));
192
+ analyze(fen, depth, multipv) {
193
+ const quitGeneration = this.quitGeneration;
194
+ return this.enqueue(async () => {
195
+ if (quitGeneration !== this.quitGeneration) {
196
+ throw new Error("stockfish request cancelled");
197
+ }
198
+ await this.init();
199
+ if (quitGeneration !== this.quitGeneration) {
200
+ throw new Error("stockfish request cancelled");
201
+ }
202
+ const session = this.session;
203
+ if (!session)
204
+ throw new Error("stockfish unavailable after initialization");
205
+ return this.doAnalyze(session, fen, depth, multipv);
206
+ });
65
207
  }
66
- doAnalyze(fen, depth, multipv) {
208
+ doAnalyze(session, fen, depth, multipv) {
67
209
  return new Promise((resolve, reject) => {
68
- const engine = this.engine;
210
+ const engine = session.engine;
211
+ if (!engine) {
212
+ reject(new Error("stockfish engine unavailable"));
213
+ return;
214
+ }
69
215
  const byPv = new Map();
216
+ let settled = false;
70
217
  let stopTimer = null;
71
218
  let failTimer = null;
72
219
  const cleanup = () => {
@@ -76,8 +223,28 @@ export class Stockfish {
76
223
  clearTimeout(failTimer);
77
224
  stopTimer = null;
78
225
  failTimer = null;
226
+ session.aborts.delete(abort);
227
+ if (engine.listener === listener)
228
+ engine.listener = null;
79
229
  };
80
- engine.listener = (line) => {
230
+ const succeed = () => {
231
+ if (settled)
232
+ return;
233
+ settled = true;
234
+ cleanup();
235
+ resolve([...byPv.values()].sort((a, b) => a.multipv - b.multipv));
236
+ };
237
+ const fail = (error, reset) => {
238
+ if (settled)
239
+ return;
240
+ settled = true;
241
+ cleanup();
242
+ if (reset)
243
+ this.invalidateSession(session, error);
244
+ reject(error);
245
+ };
246
+ const abort = (error) => fail(error, false);
247
+ const listener = (line) => {
81
248
  if (line.startsWith("info") && line.includes(" multipv ")) {
82
249
  const m = line.match(/multipv (\d+)/);
83
250
  const s = line.match(/ score (cp -?\d+|mate -?\d+)/);
@@ -95,39 +262,77 @@ export class Stockfish {
95
262
  });
96
263
  }
97
264
  else if (line.startsWith("bestmove")) {
98
- cleanup();
99
- engine.listener = null;
100
- resolve([...byPv.values()].sort((a, b) => a.multipv - b.multipv));
265
+ succeed();
101
266
  }
102
267
  };
103
- engine.sendCommand("position fen " + fen);
104
- engine.sendCommand(`setoption name MultiPV value ${multipv}`);
105
- engine.sendCommand("setoption name UCI_ShowWDL value true");
106
- engine.sendCommand(`go depth ${depth}`);
268
+ session.aborts.add(abort);
269
+ engine.listener = listener;
107
270
  stopTimer = setTimeout(() => {
108
- engine.sendCommand("stop");
109
- failTimer = setTimeout(() => {
110
- if (engine.listener) {
111
- engine.listener = null;
112
- this.reset();
113
- reject(new Error("stockfish analyze timeout"));
114
- }
115
- }, 2000);
116
- }, 30000);
117
- });
118
- }
119
- reset() {
120
- if (this.engine) {
271
+ failTimer = setTimeout(() => fail(new Error("stockfish analyze timeout"), true), this.timeouts.stopGrace);
272
+ try {
273
+ engine.sendCommand("stop");
274
+ }
275
+ catch (error) {
276
+ fail(asError(error), true);
277
+ }
278
+ }, this.timeouts.analyze);
121
279
  try {
122
- this.engine.terminate();
280
+ engine.sendCommand("position fen " + fen);
281
+ engine.sendCommand(`setoption name MultiPV value ${multipv}`);
282
+ engine.sendCommand("setoption name UCI_ShowWDL value true");
283
+ engine.sendCommand(`go depth ${depth}`);
284
+ }
285
+ catch (error) {
286
+ fail(asError(error), true);
287
+ return;
123
288
  }
124
- catch { }
289
+ });
290
+ }
291
+ failSession(session, error) {
292
+ if (session.initSettled)
293
+ return;
294
+ session.initSettled = true;
295
+ if (session.initTimer)
296
+ clearTimeout(session.initTimer);
297
+ session.initTimer = null;
298
+ this.invalidateSession(session, error);
299
+ session.reject(error);
300
+ }
301
+ invalidateSession(session, error) {
302
+ if (this.session !== session)
303
+ return;
304
+ this.session = null;
305
+ if (session.initTimer)
306
+ clearTimeout(session.initTimer);
307
+ session.initTimer = null;
308
+ if (!session.initSettled) {
309
+ session.initSettled = true;
310
+ session.reject(error);
311
+ }
312
+ const aborts = [...session.aborts];
313
+ session.aborts.clear();
314
+ if (session.engine) {
315
+ session.engine.listener = null;
316
+ this.terminate(session.engine);
317
+ }
318
+ for (const abort of aborts)
319
+ abort(error);
320
+ }
321
+ terminate(engine) {
322
+ if (this.terminated.has(engine))
323
+ return;
324
+ this.terminated.add(engine);
325
+ engine.listener = null;
326
+ try {
327
+ engine.terminate();
125
328
  }
126
- this.engine = null;
127
- this.ready = null;
329
+ catch { }
128
330
  }
129
331
  async quit() {
130
- this.reset();
332
+ this.quitGeneration++;
333
+ const session = this.session;
334
+ if (session)
335
+ this.invalidateSession(session, new Error("stockfish quit"));
131
336
  }
132
337
  }
133
338
  export const stockfish = new Stockfish();
package/dist/env.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
+ const ALLOWED_KEYS = new Set(["LICHESS_TOKEN", "MAIA3_MODEL", "STOCKFISH_FLAVOR"]);
3
4
  export function loadEnv(path = ".env") {
4
5
  let text;
5
6
  try {
@@ -17,7 +18,7 @@ export function loadEnv(path = ".env") {
17
18
  continue;
18
19
  const key = trimmed.slice(0, eq).trim();
19
20
  const value = trimmed.slice(eq + 1).trim();
20
- if (key && process.env[key] === undefined) {
21
+ if (ALLOWED_KEYS.has(key) && process.env[key] === undefined) {
21
22
  process.env[key] = value.replace(/^["']|["']$/g, "");
22
23
  }
23
24
  }
package/dist/games.js CHANGED
@@ -2,36 +2,63 @@ import { Chess } from "chess.js";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { ChessError } from "./errors.js";
4
4
  const games = new Map();
5
- export function createGame(fen) {
5
+ export const MAX_GAMES = 1_000;
6
+ export const GAME_TTL_MS = 60 * 60 * 1_000;
7
+ function isExpired(game, now) {
8
+ return now - game.lastAccessedAt >= GAME_TTL_MS;
9
+ }
10
+ export function cleanupGames(now = Date.now()) {
11
+ let removed = 0;
12
+ for (const [id, game] of games) {
13
+ if (!isExpired(game, now))
14
+ continue;
15
+ games.delete(id);
16
+ removed += 1;
17
+ }
18
+ return removed;
19
+ }
20
+ function storeGame(chess) {
21
+ cleanupGames();
22
+ if (games.size >= MAX_GAMES) {
23
+ throw new ChessError("GAME_LIMIT_REACHED", `game session limit reached: ${MAX_GAMES}`);
24
+ }
6
25
  const id = randomUUID();
7
- const chess = fen ? new Chess(fen) : new Chess();
8
- games.set(id, { chess, createdAt: Date.now(), revision: 0 });
26
+ const now = Date.now();
27
+ games.set(id, { chess, createdAt: now, lastAccessedAt: now, revision: 0 });
9
28
  return id;
10
29
  }
30
+ export function createGame(fen) {
31
+ const chess = fen ? new Chess(fen) : new Chess();
32
+ return storeGame(chess);
33
+ }
11
34
  export function createGameFromChess(chess) {
12
- const id = randomUUID();
13
- games.set(id, { chess, createdAt: Date.now(), revision: 0 });
14
- return id;
35
+ return storeGame(chess);
15
36
  }
16
37
  export function getGame(id) {
17
38
  const g = games.get(id);
18
39
  if (!g)
19
40
  throw new ChessError("GAME_NOT_FOUND", `game not found: ${id}`);
41
+ if (isExpired(g, Date.now())) {
42
+ games.delete(id);
43
+ throw new ChessError("GAME_EXPIRED", `game expired: ${id}`);
44
+ }
45
+ g.lastAccessedAt = Date.now();
20
46
  return g;
21
47
  }
22
48
  export function bumpRevision(id) {
23
- const g = games.get(id);
24
- if (!g)
25
- throw new ChessError("GAME_NOT_FOUND", `game not found: ${id}`);
49
+ const g = getGame(id);
26
50
  g.revision += 1;
27
51
  return g.revision;
28
52
  }
29
53
  export function deleteGame(id) {
54
+ cleanupGames();
30
55
  return games.delete(id);
31
56
  }
32
57
  export function listGames() {
58
+ cleanupGames();
33
59
  return [...games.keys()];
34
60
  }
35
61
  export function gameCount() {
62
+ cleanupGames();
36
63
  return games.size;
37
64
  }
package/dist/index.js CHANGED
@@ -3,6 +3,9 @@ import { McpServer } from "@modelcontextprotocol/server";
3
3
  import { serveStdio } from "@modelcontextprotocol/server/stdio";
4
4
  import * as z from "zod/v4";
5
5
  import { Chess } from "chess.js";
6
+ import { realpathSync } from "node:fs";
7
+ import { createRequire } from "node:module";
8
+ import { pathToFileURL } from "node:url";
6
9
  import { loadEnv } from "./env.js";
7
10
  import { createGame, createGameFromChess, getGame, deleteGame, bumpRevision, } from "./games.js";
8
11
  import { stockfish } from "./engines/stockfish.js";
@@ -19,9 +22,54 @@ const INTENTS = [
19
22
  "ease_off",
20
23
  "give_chance",
21
24
  ];
25
+ const { version: SERVER_VERSION } = createRequire(import.meta.url)("../package.json");
26
+ export const MAX_EVALUATED_MOVES = 10;
27
+ export const MAX_PGN_BYTES = 1024 * 1024;
28
+ export const MAX_PGN_PLIES = 4096;
22
29
  function text(data) {
23
30
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
24
31
  }
32
+ function errorText(data) {
33
+ return { ...text(data), isError: true };
34
+ }
35
+ export function snapshotChess(chess) {
36
+ const history = chess.history({ verbose: true });
37
+ const snapshot = new Chess(history[0]?.before ?? chess.fen());
38
+ for (const move of history) {
39
+ snapshot.move({ from: move.from, to: move.to, promotion: move.promotion });
40
+ }
41
+ return snapshot;
42
+ }
43
+ export function drawResult(chess) {
44
+ if (!chess.isDraw())
45
+ return null;
46
+ if (chess.isStalemate())
47
+ return "stalemate";
48
+ if (chess.isInsufficientMaterial())
49
+ return "insufficient_material";
50
+ if (chess.isThreefoldRepetition())
51
+ return "threefold_repetition";
52
+ if (chess.isDrawByFiftyMoves())
53
+ return "fifty_move_rule";
54
+ return "draw";
55
+ }
56
+ export function parseImportedPgn(pgn) {
57
+ if (Buffer.byteLength(pgn, "utf8") > MAX_PGN_BYTES) {
58
+ throw new ChessError("PGN_TOO_LARGE", `PGN exceeds the ${MAX_PGN_BYTES}-byte limit`);
59
+ }
60
+ let chess;
61
+ try {
62
+ chess = new Chess();
63
+ chess.loadPgn(pgn);
64
+ }
65
+ catch {
66
+ throw new ChessError("INVALID_PGN", "invalid or illegal PGN");
67
+ }
68
+ if (chess.history().length > MAX_PGN_PLIES) {
69
+ throw new ChessError("PGN_TOO_MANY_MOVES", `PGN exceeds the ${MAX_PGN_PLIES}-ply limit`);
70
+ }
71
+ return chess;
72
+ }
25
73
  function stateOf(chess, revision) {
26
74
  const last = chess.history({ verbose: true }).at(-1);
27
75
  return {
@@ -65,15 +113,15 @@ function wrap(handler) {
65
113
  }
66
114
  catch (e) {
67
115
  if (e instanceof ChessError) {
68
- return text({ error: { code: e.code, message: e.message } });
116
+ return errorText({ error: { code: e.code, message: e.message } });
69
117
  }
70
118
  const msg = e instanceof Error ? e.message : String(e);
71
- return text({ error: { code: "INTERNAL", message: msg } });
119
+ return errorText({ error: { code: "INTERNAL", message: msg } });
72
120
  }
73
121
  };
74
122
  }
75
123
  export function buildServer() {
76
- const server = new McpServer({ name: "llm-chess-mcp", version: "0.1.0" }, { capabilities: { tools: {} } });
124
+ const server = new McpServer({ name: "llm-chess-mcp", version: SERVER_VERSION }, { capabilities: { tools: {} } });
77
125
  server.registerTool("create_game", {
78
126
  description: "Create a new chess game and return its game_id. The server is the authoritative source of board state — never track the board yourself. Optionally pass a FEN to start from a custom position.",
79
127
  inputSchema: z.object({
@@ -84,7 +132,9 @@ export function buildServer() {
84
132
  try {
85
133
  id = createGame(fen);
86
134
  }
87
- catch {
135
+ catch (e) {
136
+ if (e instanceof ChessError)
137
+ throw e;
88
138
  throw new ChessError("INVALID_FEN", "invalid FEN");
89
139
  }
90
140
  return text({ game_id: id, revision: 0 });
@@ -137,7 +187,7 @@ export function buildServer() {
137
187
  description: "List all legal moves in the current position (SAN, UCI, piece, capture, promotion).",
138
188
  inputSchema: z.object({ game_id: z.string() }),
139
189
  }, wrap(async ({ game_id }) => {
140
- const { chess } = getGame(game_id);
190
+ const { chess, revision } = getGame(game_id);
141
191
  const moves = chess.moves({ verbose: true }).map((m) => ({
142
192
  san: m.san,
143
193
  uci: m.lan,
@@ -149,7 +199,7 @@ export function buildServer() {
149
199
  isCapture: m.flags.includes("c"),
150
200
  isCheck: m.san.includes("+") || m.san.includes("#"),
151
201
  }));
152
- return text({ game_id, count: moves.length, moves });
202
+ return text({ game_id, revision, count: moves.length, moves });
153
203
  }));
154
204
  server.registerTool("position_analyze", {
155
205
  description: "Run Stockfish on the current position and return the top engine lines (multipv). Scores are from the side-to-move perspective: positive cp = side to move is better; mate N = side to move mates in N. wdl is [win, draw, loss] in permille for the side to move. Use analysis_level (fast/normal/deep) or explicit depth/multipv. Does NOT mutate the game.",
@@ -160,7 +210,8 @@ export function buildServer() {
160
210
  multipv: z.number().int().min(1).max(10).optional(),
161
211
  }),
162
212
  }, wrap(async ({ game_id, analysis_level, depth, multipv }) => {
163
- const { chess, revision } = getGame(game_id);
213
+ const { chess: live, revision } = getGame(game_id);
214
+ const chess = snapshotChess(live);
164
215
  const preset = ANALYSIS_PRESETS[analysis_level];
165
216
  const d = depth ?? preset.depth;
166
217
  const mpv = multipv ?? preset.multipv;
@@ -189,7 +240,8 @@ export function buildServer() {
189
240
  top_n: z.number().int().min(1).max(20).default(5),
190
241
  }),
191
242
  }, wrap(async ({ game_id, elo, oppo_elo, top_n }) => {
192
- const { chess, revision } = getGame(game_id);
243
+ const { chess: live, revision } = getGame(game_id);
244
+ const chess = snapshotChess(live);
193
245
  const moves = await humanMoveDistribution(chess, elo, oppo_elo ?? elo, top_n);
194
246
  return text({ game_id, elo, oppo_elo: oppo_elo ?? elo, revision, moves });
195
247
  }));
@@ -197,11 +249,15 @@ export function buildServer() {
197
249
  description: "Evaluate one or more moves with Stockfish without mutating the game. Pass a single move string or an array of moves to compare. Returns, for each move, the score after the move (from the mover's perspective), cpLoss vs the best move, and a classification (best/excellent/good/inaccuracy/mistake/blunder).",
198
250
  inputSchema: z.object({
199
251
  game_id: z.string(),
200
- move: z.union([z.string(), z.array(z.string())]),
252
+ move: z.union([
253
+ z.string(),
254
+ z.array(z.string()).min(1).max(MAX_EVALUATED_MOVES),
255
+ ]),
201
256
  depth: z.number().int().min(1).max(30).default(15),
202
257
  }),
203
258
  }, wrap(async ({ game_id, move, depth }) => {
204
- const { chess, revision } = getGame(game_id);
259
+ const { chess: live, revision } = getGame(game_id);
260
+ const chess = snapshotChess(live);
205
261
  const moves = Array.isArray(move) ? move : [move];
206
262
  const beforeLines = await stockfish.analyze(chess.fen(), depth, 1);
207
263
  const before = beforeLines[0];
@@ -210,7 +266,7 @@ export function buildServer() {
210
266
  const results = [];
211
267
  for (const mv of moves) {
212
268
  const m = parseMove(chess, mv);
213
- const copy = new Chess(chess.fen());
269
+ const copy = snapshotChess(chess);
214
270
  copy.move({ from: m.from, to: m.to, promotion: m.promotion });
215
271
  if (copy.isCheckmate()) {
216
272
  results.push({
@@ -226,11 +282,12 @@ export function buildServer() {
226
282
  });
227
283
  continue;
228
284
  }
229
- if (copy.isStalemate()) {
285
+ const result = drawResult(copy);
286
+ if (result) {
230
287
  results.push({
231
288
  move: m.san,
232
289
  uci: m.lan,
233
- result: "stalemate",
290
+ result,
234
291
  scoreCp: 0,
235
292
  scoreMate: null,
236
293
  bestCp: beforeCp,
@@ -275,7 +332,8 @@ export function buildServer() {
275
332
  lichess_ratings: z.array(z.number().int()).default([]),
276
333
  }),
277
334
  }, wrap(async ({ game_id, elo, analysis_level, sf_depth, sf_multipv, maia_top_n, lichess_db, lichess_speeds, lichess_ratings, }) => {
278
- const { chess, revision } = getGame(game_id);
335
+ const { chess: live, revision } = getGame(game_id);
336
+ const chess = snapshotChess(live);
279
337
  const preset = ANALYSIS_PRESETS[analysis_level];
280
338
  const d = sf_depth ?? preset.depth;
281
339
  const mpv = sf_multipv ?? preset.multipv;
@@ -306,7 +364,8 @@ export function buildServer() {
306
364
  lichess_ratings: z.array(z.number().int()).default([]),
307
365
  }),
308
366
  }, wrap(async ({ game_id, intent, elo, analysis_level, sf_depth, sf_multipv, maia_top_n, lichess_db, lichess_speeds, lichess_ratings, }) => {
309
- const { chess, revision } = getGame(game_id);
367
+ const { chess: live, revision } = getGame(game_id);
368
+ const chess = snapshotChess(live);
310
369
  const preset = ANALYSIS_PRESETS[analysis_level];
311
370
  const d = sf_depth ?? preset.depth;
312
371
  const mpv = sf_multipv ?? preset.multipv;
@@ -336,33 +395,37 @@ export function buildServer() {
336
395
  if (!explorerEnabled()) {
337
396
  throw new ChessError("LICHESS_DISABLED", "LICHESS_TOKEN not set; opening explorer is disabled");
338
397
  }
339
- const { chess } = getGame(game_id);
398
+ const { chess: live, revision } = getGame(game_id);
399
+ const chess = snapshotChess(live);
340
400
  const result = await openingExplorer(chess, db, speeds, ratings);
341
- return text({ game_id, ...result });
401
+ return text({ game_id, revision, ...result });
342
402
  }));
343
403
  server.registerTool("game_pgn", {
344
404
  description: "Export the current game as PGN.",
345
405
  inputSchema: z.object({ game_id: z.string() }),
346
406
  }, wrap(async ({ game_id }) => {
347
- const { chess } = getGame(game_id);
348
- return text({ game_id, pgn: chess.pgn() });
407
+ const { chess, revision } = getGame(game_id);
408
+ return text({ game_id, revision, pgn: chess.pgn() });
349
409
  }));
350
410
  server.registerTool("game_import_pgn", {
351
411
  description: "Import a PGN into a new game. Returns a new game_id with the position after all PGN moves. Rejects malformed or illegal PGN.",
352
412
  inputSchema: z.object({ pgn: z.string() }),
353
413
  }, wrap(async ({ pgn }) => {
354
- let chess;
355
- try {
356
- chess = new Chess();
357
- chess.loadPgn(pgn);
358
- }
359
- catch {
360
- throw new ChessError("INVALID_PGN", "invalid or illegal PGN");
361
- }
414
+ const chess = parseImportedPgn(pgn);
362
415
  const id = createGameFromChess(chess);
363
416
  return text({ game_id: id, ...stateOf(chess, 0) });
364
417
  }));
365
418
  return server;
366
419
  }
367
420
  loadEnv();
368
- serveStdio(() => buildServer());
421
+ if (process.argv[1] &&
422
+ import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
423
+ const handle = serveStdio(() => buildServer());
424
+ let shutdown = null;
425
+ const close = () => (shutdown ??= Promise.all([stockfish.quit(), handle.close()]).then(() => { }));
426
+ const onClose = () => {
427
+ void close().catch((error) => console.error("shutdown failed", error));
428
+ };
429
+ process.stdin.once("end", onClose);
430
+ process.stdin.once("close", onClose);
431
+ }
package/dist/intents.js CHANGED
@@ -186,11 +186,5 @@ export function rankByIntent(candidates, intent) {
186
186
  .filter((x) => x.score !== -Infinity)
187
187
  .sort((a, b) => b.score - a.score)
188
188
  .map((x) => x.c);
189
- if (ranked.length === 0 && (intent === "ease_off" || intent === "give_chance")) {
190
- const human = candidates.filter((c) => c.human.maia3Prob !== null && c.objective.moverCp !== null);
191
- return human
192
- .sort((a, b) => (a.objective.moverCp ?? -Infinity) - (b.objective.moverCp ?? -Infinity))
193
- .slice(0, 5);
194
- }
195
189
  return ranked;
196
190
  }
@@ -5,29 +5,37 @@ import { dirname, resolve } from "node:path";
5
5
  import { buildInput } from "./tokenize.js";
6
6
  import { vocabIndex } from "./vocab.js";
7
7
  import { mirrorMove } from "./mirror.js";
8
- const MODEL_KEY = process.env.MAIA3_MODEL || "5m";
9
8
  let session = null;
10
9
  let sessionPromise = null;
10
+ const MODEL_KEYS = new Set(["3m", "5m", "23m", "79m"]);
11
11
  function modelPath() {
12
+ const modelKey = process.env.MAIA3_MODEL || "5m";
13
+ if (!MODEL_KEYS.has(modelKey))
14
+ throw new Error(`unsupported Maia3 model: ${modelKey}`);
12
15
  const here = dirname(fileURLToPath(import.meta.url));
13
16
  const candidates = [
14
- resolve(here, "../../models", `maia3-${MODEL_KEY}.onnx`),
15
- resolve(process.cwd(), "models", `maia3-${MODEL_KEY}.onnx`),
17
+ resolve(here, "../../models", `maia3-${modelKey}.onnx`),
18
+ resolve(process.cwd(), "models", `maia3-${modelKey}.onnx`),
16
19
  ];
17
20
  for (const c of candidates) {
18
21
  if (existsSync(c))
19
22
  return c;
20
23
  }
21
- throw new Error(`maia3 model not found (models/maia3-${MODEL_KEY}.onnx). Run \`pnpm export:maia3\` first.`);
24
+ throw new Error(`maia3 model not found (models/maia3-${modelKey}.onnx). Run \`pnpm export:maia3\` first.`);
22
25
  }
23
26
  async function getSession() {
24
27
  if (session)
25
28
  return session;
26
29
  if (sessionPromise)
27
30
  return sessionPromise;
28
- sessionPromise = ort.InferenceSession.create(modelPath()).then((s) => {
31
+ sessionPromise = ort.InferenceSession.create(modelPath())
32
+ .then((s) => {
29
33
  session = s;
30
34
  return s;
35
+ })
36
+ .catch((error) => {
37
+ sessionPromise = null;
38
+ throw error;
31
39
  });
32
40
  return sessionPromise;
33
41
  }
@@ -28,13 +28,13 @@ function tokenizeBoard(chess) {
28
28
  }
29
29
  function historyPositions(chess) {
30
30
  const moves = chess.history({ verbose: true });
31
- const positions = [new Chess()];
32
- const replay = new Chess();
33
- for (const m of moves) {
34
- replay.move({ from: m.from, to: m.to, promotion: m.promotion });
35
- positions.push(new Chess(replay.fen()));
36
- }
37
- return positions;
31
+ if (moves.length >= HISTORY)
32
+ return moves.slice(-HISTORY).map((move) => new Chess(move.after));
33
+ if (moves.length)
34
+ return [new Chess(moves[0].before), ...moves.map((move) => new Chess(move.after))];
35
+ const headers = chess.getHeaders();
36
+ const initialFen = headers.SetUp === "1" && headers.FEN ? headers.FEN : chess.fen();
37
+ return [new Chess(initialFen)];
38
38
  }
39
39
  export function buildInput(chess) {
40
40
  const positions = historyPositions(chess);
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "llm-chess-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "MCP server that lets an LLM analyze, judge, and choose chess moves (Stockfish + Maia3 + Lichess)",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
7
+ "engines": {
8
+ "node": ">=20"
9
+ },
7
10
  "bin": {
8
11
  "llm-chess-mcp": "dist/index.js"
9
12
  },
@@ -11,12 +14,14 @@
11
14
  "dist",
12
15
  "models",
13
16
  "LICENSE",
14
- "README.md"
17
+ "README.md",
18
+ ".env.example"
15
19
  ],
16
20
  "scripts": {
17
21
  "build": "tsc",
18
22
  "dev": "tsx src/index.ts",
19
23
  "start": "node dist/index.js",
24
+ "test": "tsx --test tests/*.test.ts",
20
25
  "export:maia3": "python scripts/export_maia3.py"
21
26
  },
22
27
  "dependencies": {