llm-chess-mcp 0.4.9 → 0.5.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 CHANGED
@@ -13,13 +13,14 @@ 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 5M** (ONNX) | Human-like move probabilities conditioned on Elo | In-process (`onnxruntime-node`) |
16
+ | **Maia3 5M** (ONNX) | Human-like move probabilities conditioned on Elo | Dedicated Node child processes (`onnxruntime-node`) |
17
17
  | **Lichess explorer** | Real human game statistics | HTTP (needs token) |
18
18
 
19
- Everything runs inside the Node process. No external engine process or Python
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.
19
+ No external engine executable or Python runtime is required at deploy time.
20
+ Stockfish runs in the server process, while Maia inference runs in dedicated
21
+ Node child processes. The published package bundles the Maia3 5M model; other
22
+ export variants are not runtime options unless their ONNX files are provided
23
+ separately.
23
24
 
24
25
  ## Build from source
25
26
 
@@ -68,6 +69,12 @@ const server = await serveHttp({ port: 3000, bodyTimeoutMs: 15_000 });
68
69
  await server.close();
69
70
  ```
70
71
 
72
+ The root API also exports `buildServer`, `GameStore`, `ChessError`,
73
+ `ExplorerError`, the service/domain types needed to provide custom
74
+ `AppServices`, and safe chess helpers including `parseImportedPgn`, `pgnOf`,
75
+ and `snapshotChess`. New integrations should use the package root. Legacy
76
+ `dist/` subpath imports remain available for compatibility.
77
+
71
78
  `bodyTimeoutMs` limits HTTP body upload time; it is not a whole-tool deadline.
72
79
  The deprecated `requestTimeoutMs` alias remains supported when `bodyTimeoutMs`
73
80
  is omitted.
@@ -264,20 +271,23 @@ rejected:
264
271
 
265
272
  - Up to 1,000 games are retained per process; idle games expire after one hour.
266
273
  - `move_evaluate` accepts at most 10 moves per call.
267
- - Imported PGNs are limited to 1 MiB, 256 headers, and 4,096 plies across the
268
- mainline and variations, plus 32,768 structural elements and 16 KiB per
269
- lexical token. Every variation is legality-checked; game state retains the
270
- mainline. UTF-8 BOMs and standard escaped header values are supported.
274
+ - Imported and exported PGNs are limited to 1 MiB, 256 headers, and 4,096
275
+ plies; stored game histories use the same ply limit. Imports also cap the
276
+ mainline and variations together at 32,768 structural elements and 16 KiB
277
+ per lexical token. Every variation is legality-checked; game state retains
278
+ the mainline. UTF-8 BOMs and standard escaped header values are supported.
271
279
  - Custom FENs reject inconsistent castling/en-passant metadata and impossible
272
280
  pawn or promotion material.
273
- - Stockfish accepts up to 32 active or queued analyses.
281
+ - Stockfish accepts up to 32 active or queued analyses. Maia runs at most two
282
+ inferences concurrently and queues up to 32 more.
274
283
  - Lichess Explorer requests run one at a time and share 429 cooldowns.
275
284
  - HTTP retains at most 64 MCP sessions; sessions with no active request expire
276
285
  after 30 minutes. An open GET/SSE stream keeps its session active.
277
- - HTTP accepts bodies up to 2 MiB. It permits 16 concurrent POSTs and downstream
278
- compute/network jobs process-wide, with two of each per session. A separately
279
- bounded control lane keeps MCP cancellation available when normal POST slots
280
- are full. Work keeps its slot after a raw disconnect until it settles. HTTP
286
+ - HTTP accepts bodies up to 2 MiB. After body parsing, it permits 16 concurrent
287
+ POST dispatches and downstream compute/network jobs process-wide, with two of
288
+ each per session. A separate bounded control lane keeps MCP cancellation
289
+ available when normal POST slots are full. Work keeps its slot after a raw
290
+ disconnect until it settles. HTTP
281
291
  also caps connections at 128 and applies a 15-second body upload deadline
282
292
  plus bounded header, socket, and keep-alive timeouts.
283
293
 
@@ -289,10 +299,10 @@ proxy.
289
299
  MCP cancellation notifications, session deletion, and server shutdown propagate
290
300
  to body uploads and Stockfish, Maia, and Lichess work. Stockfish stops safely at
291
301
  its UCI queue boundary, drains queued work during shutdown, and rejects new
292
- analysis until teardown completes. Lichess fetch and retry waits abort immediately. ONNX
293
- Runtime cannot interrupt an inference already executing, so Maia discards its
294
- result after the native call returns. A raw HTTP disconnect alone is not a
295
- cancellation signal.
302
+ analysis until teardown completes. Lichess fetch and retry waits abort
303
+ immediately. Maia runs native inference in dedicated child processes; cancelling
304
+ active work terminates its child, while queued cancellation is immediate. A raw
305
+ HTTP disconnect alone is not a cancellation signal.
296
306
 
297
307
  ## Intents
298
308
 
@@ -1,10 +1,12 @@
1
1
  import { Chess } from "chess.js";
2
2
  import { ChessError } from "./errors.js";
3
+ import { assertPgnPlyLimit, replacePgnHeaders } from "./pgn-shared.js";
3
4
  const ORIGINAL_PIECES = {
4
5
  q: 1,
5
6
  r: 2,
6
7
  n: 2,
7
8
  };
9
+ const CHESS_STATE_KEYS = Reflect.ownKeys(new Chess());
8
10
  function squareColor(square) {
9
11
  return ((square.charCodeAt(0) - 97 + Number(square[1])) % 2);
10
12
  }
@@ -38,6 +40,32 @@ function minimumPawnCaptures(chess, color) {
38
40
  function nonKingMaterial(chess, color) {
39
41
  return ["p", "q", "r", "b", "n"].reduce((total, type) => total + chess.findPiece({ type, color }).length, 0);
40
42
  }
43
+ function clonedChess(chess) {
44
+ const state = Object.create(null);
45
+ for (const key of CHESS_STATE_KEYS) {
46
+ const descriptor = Object.getOwnPropertyDescriptor(chess, key);
47
+ if (!descriptor || !("value" in descriptor)) {
48
+ throw new ChessError("INVALID_FEN", "chess state cannot be cloned");
49
+ }
50
+ Object.defineProperty(state, key, {
51
+ configurable: true,
52
+ enumerable: true,
53
+ value: descriptor.value,
54
+ writable: true,
55
+ });
56
+ }
57
+ try {
58
+ const clone = structuredClone(state);
59
+ Object.setPrototypeOf(clone, Chess.prototype);
60
+ return clone;
61
+ }
62
+ catch {
63
+ throw new ChessError("INVALID_FEN", "chess state cannot be cloned");
64
+ }
65
+ }
66
+ function exactFen(chess) {
67
+ return chess.fen({ forceEnpassantSquare: true });
68
+ }
41
69
  function hasPiece(chess, square, type, color) {
42
70
  const piece = chess.get(square);
43
71
  return piece?.type === type && piece.color === color;
@@ -123,11 +151,8 @@ export function assertLegalPosition(chess) {
123
151
  }
124
152
  assertCastlingPosition(chess, color);
125
153
  const opponent = color === "w" ? "b" : "w";
126
- const opponentPawns = chess.findPiece({ type: "p", color: opponent }).length;
127
154
  const missingOpponentMaterial = 15 - nonKingMaterial(chess, opponent);
128
- const possibleOpponentPromotions = 8 - opponentPawns;
129
- if (minimumPawnCaptures(chess, color) >
130
- missingOpponentMaterial + possibleOpponentPromotions) {
155
+ if (minimumPawnCaptures(chess, color) > missingOpponentMaterial) {
131
156
  throw new ChessError("INVALID_FEN", "FEN pawn files require more captures than opposing material allows");
132
157
  }
133
158
  }
@@ -138,22 +163,103 @@ export function assertLegalPosition(chess) {
138
163
  if (previousKing && chess.isAttacked(previousKing, turn)) {
139
164
  throw new ChessError("INVALID_FEN", "FEN cannot leave the side that just moved in check");
140
165
  }
166
+ const king = chess.findPiece({ type: "k", color: turn })[0];
167
+ if (king) {
168
+ const checkers = chess.attackers(king, previous);
169
+ const leapers = checkers.filter((square) => {
170
+ const type = chess.get(square)?.type;
171
+ return type === "k" || type === "n" || type === "p";
172
+ });
173
+ if (checkers.length > 2 || leapers.length > 1) {
174
+ throw new ChessError("INVALID_FEN", "FEN contains an impossible check topology");
175
+ }
176
+ }
177
+ }
178
+ function expectedInitialFen(headers) {
179
+ const values = new Map();
180
+ for (const [name, value] of headers) {
181
+ const key = name.toLowerCase();
182
+ if (values.has(key)) {
183
+ throw new ChessError("INVALID_PGN", `PGN must not repeat ${name} headers`);
184
+ }
185
+ values.set(key, value);
186
+ }
187
+ const setup = values.get("setup");
188
+ const fen = values.get("fen");
189
+ if (setup !== undefined && setup !== "0" && setup !== "1") {
190
+ throw new ChessError("INVALID_PGN", "PGN SetUp must be 0 or 1");
191
+ }
192
+ if ((setup === "1") !== (fen !== undefined)) {
193
+ throw new ChessError("INVALID_PGN", "PGN SetUp 1 and FEN headers must appear together");
194
+ }
195
+ if (fen === undefined)
196
+ return exactFen(new Chess());
197
+ assertSafeFenCounters(fen);
198
+ let initial;
199
+ try {
200
+ initial = new Chess(fen);
201
+ }
202
+ catch {
203
+ throw new ChessError("INVALID_FEN", "invalid FEN");
204
+ }
205
+ assertLegalPosition(initial);
206
+ return exactFen(initial);
207
+ }
208
+ function validatedHistory(chess) {
209
+ const sourceFen = exactFen(chess);
210
+ const sourceHeaders = Object.entries(chess.getHeaders());
211
+ const shadow = clonedChess(chess);
212
+ const history = Chess.prototype.history.call(shadow, {
213
+ verbose: true,
214
+ });
215
+ if (history.some((move) => move.from === move.to)) {
216
+ throw new ChessError("INVALID_PGN", "null moves are not supported");
217
+ }
218
+ if (exactFen(shadow) !== sourceFen) {
219
+ throw new ChessError("INVALID_FEN", "current position does not match move history");
220
+ }
221
+ const initial = clonedChess(chess);
222
+ while (Chess.prototype.undo.call(initial)) { }
223
+ const initialFen = exactFen(initial);
224
+ if (initialFen !== expectedInitialFen(sourceHeaders)) {
225
+ throw new ChessError("INVALID_PGN", "move history does not match PGN setup headers");
226
+ }
227
+ return { history, initialFen, shadow, sourceHeaders };
141
228
  }
142
229
  export function snapshotChess(chess) {
143
230
  assertLegalPosition(chess);
144
- const history = chess.history({ verbose: true });
145
- const initialFen = history[0]?.before ?? chess.fen();
231
+ const { history, initialFen, shadow, sourceHeaders } = validatedHistory(chess);
232
+ assertPgnPlyLimit(history.length);
146
233
  assertSafeFenCounters(initialFen);
147
234
  const snapshot = new Chess(initialFen);
148
235
  assertLegalPosition(snapshot);
149
- const comments = new Map(chess.getComments().map(({ fen, comment }) => [fen, comment]));
150
- for (const [key, value] of Object.entries(chess.getHeaders())) {
151
- snapshot.setHeader(key, value);
236
+ const getComments = chess.getComments;
237
+ const sourceComments = getComments === Chess.prototype.getComments
238
+ ? Chess.prototype.getComments.call(shadow)
239
+ : getComments.call(chess);
240
+ const comments = new Map(sourceComments.map(({ fen, comment }) => [
241
+ fen,
242
+ /[{}]/.test(comment) ? comment.replace(/[\r\n]+/g, " ") : comment,
243
+ ]));
244
+ const unsafeComments = [...comments.values()].some((comment) => /[{}]/.test(comment));
245
+ let markerPrefix = "\uE000";
246
+ if (unsafeComments) {
247
+ const occupied = [...sourceHeaders.flat(), ...comments.values()].join("\u0000");
248
+ while (occupied.includes(markerPrefix))
249
+ markerPrefix += "\uE001";
152
250
  }
251
+ const markerComments = [];
153
252
  const restoreComment = () => {
154
253
  const comment = comments.get(snapshot.fen());
155
- if (comment !== undefined)
254
+ if (comment === undefined)
255
+ return;
256
+ if (!unsafeComments || !/[{}]/.test(comment)) {
156
257
  snapshot.setComment(comment);
258
+ return;
259
+ }
260
+ const marker = `${markerPrefix}${markerComments.length}${markerPrefix}`;
261
+ markerComments.push(comment);
262
+ snapshot.setComment(marker);
157
263
  };
158
264
  restoreComment();
159
265
  for (const move of history) {
@@ -161,5 +267,31 @@ export function snapshotChess(chess) {
161
267
  restoreComment();
162
268
  }
163
269
  assertSafeFenCounters(snapshot.fen());
164
- return snapshot;
270
+ if (!unsafeComments) {
271
+ replacePgnHeaders(snapshot, sourceHeaders, { removeMissing: true });
272
+ return snapshot;
273
+ }
274
+ const escapedPrefix = markerPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
275
+ const marker = new RegExp(`\\{${escapedPrefix}(\\d+)${escapedPrefix}\\}`, "g");
276
+ const pgn = snapshot.pgn().replace(marker, (_match, index) => {
277
+ return `;${markerComments[Number(index)]}\n`;
278
+ });
279
+ const restored = new Chess();
280
+ restored.loadPgn(pgn);
281
+ const restoredHistory = restored.history({ verbose: true });
282
+ while (restored.undo()) { }
283
+ const restoreSafeComment = () => {
284
+ const comment = comments.get(restored.fen());
285
+ if (comment !== undefined && !/[{}]/.test(comment)) {
286
+ restored.setComment(comment);
287
+ }
288
+ };
289
+ restoreSafeComment();
290
+ for (const move of restoredHistory) {
291
+ restored.move(moveDescriptor(move));
292
+ restoreSafeComment();
293
+ }
294
+ replacePgnHeaders(restored, sourceHeaders, { removeMissing: true });
295
+ assertSafeFenCounters(restored.fen());
296
+ return restored;
165
297
  }
package/dist/cli.js CHANGED
@@ -29,6 +29,9 @@ export function parseCli(args) {
29
29
  let help = false;
30
30
  let hasHttpOption = false;
31
31
  const allowedHosts = [];
32
+ if (args.includes("-h") || args.includes("--help")) {
33
+ return { transport, host, port, path, allowedHosts, help: true };
34
+ }
32
35
  for (let index = 0; index < args.length; index += 1) {
33
36
  const arg = args[index];
34
37
  if (arg === undefined)
@@ -93,21 +96,23 @@ export function parseCli(args) {
93
96
  if (!isCanonicalHttpPath(path)) {
94
97
  throw new Error("--path must be an absolute URL path without query or fragment");
95
98
  }
99
+ const canonicalHost = canonicalHttpHostname(host);
96
100
  const canonicalAllowedHosts = allowedHosts.map(canonicalHttpHostname);
97
- if (!host || canonicalAllowedHosts.some((value) => value === null)) {
101
+ if (canonicalHost === null ||
102
+ canonicalAllowedHosts.some((value) => value === null)) {
98
103
  throw new Error("HTTP hostnames must be non-empty hostnames");
99
104
  }
100
105
  if (transport === "stdio" && hasHttpOption) {
101
106
  throw new Error("HTTP options require --transport http");
102
107
  }
103
108
  if (transport === "http" &&
104
- isWildcardHttpBindHost(host) &&
109
+ isWildcardHttpBindHost(canonicalHost) &&
105
110
  canonicalAllowedHosts.length === 0) {
106
111
  throw new Error("wildcard HTTP binding requires at least one --allowed-host");
107
112
  }
108
113
  return {
109
114
  transport,
110
- host,
115
+ host: canonicalHost,
111
116
  port,
112
117
  path,
113
118
  allowedHosts: canonicalAllowedHosts,
package/dist/domain.d.ts CHANGED
@@ -84,22 +84,30 @@ export interface HumanModel {
84
84
  selfElo: number;
85
85
  opponentElo: number;
86
86
  }
87
- type OpeningStatsValues = {
88
- games: number | null;
89
- frequency: number | null;
90
- white: number | null;
91
- draws: number | null;
92
- black: number | null;
87
+ type EmptyOpeningStats = {
88
+ games: null;
89
+ frequency: null;
90
+ white: null;
91
+ draws: null;
92
+ black: null;
93
+ averageRating: null;
94
+ };
95
+ type AvailableOpeningStats = {
96
+ games: number;
97
+ frequency: number;
98
+ white: number;
99
+ draws: number;
100
+ black: number;
93
101
  averageRating: number | null;
94
102
  };
95
103
  export type OpeningStats = ({
96
- status: "available" | "no_data";
97
- } & OpeningStatsValues) | ({
104
+ status: "available";
105
+ } & (EmptyOpeningStats | AvailableOpeningStats)) | ({
106
+ status: "no_data" | "disabled";
107
+ } & EmptyOpeningStats) | ({
98
108
  status: "unavailable";
99
109
  reason: ExplorerErrorKind;
100
- } & OpeningStatsValues) | ({
101
- status: "disabled";
102
- } & OpeningStatsValues);
110
+ } & EmptyOpeningStats);
103
111
  export interface Candidate {
104
112
  uci: string;
105
113
  san: string;
@@ -38,6 +38,9 @@ export declare class Stockfish {
38
38
  private readonly maxQueue;
39
39
  private readonly timeouts;
40
40
  constructor(options?: StockfishOptions);
41
+ private disposeInitEngine;
42
+ private adoptInitEngine;
43
+ private completeInit;
41
44
  private init;
42
45
  private handshake;
43
46
  private registerInvalidator;