llm-chess-mcp 0.5.0 → 0.7.2

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.
Files changed (76) hide show
  1. package/README.md +30 -18
  2. package/dist/analysis-boundary.d.ts +2 -0
  3. package/dist/analysis-boundary.js +49 -0
  4. package/dist/chess-copy.d.ts +5 -2
  5. package/dist/chess-copy.js +70 -203
  6. package/dist/chess-move.d.ts +7 -0
  7. package/dist/chess-move.js +4 -0
  8. package/dist/chess.d.ts +2 -1
  9. package/dist/chess.js +5 -7
  10. package/dist/cli.d.ts +1 -1
  11. package/dist/cli.js +3 -3
  12. package/dist/domain.d.ts +18 -4
  13. package/dist/domain.js +6 -0
  14. package/dist/engines/stockfish-info.js +13 -7
  15. package/dist/engines/stockfish.d.ts +3 -0
  16. package/dist/engines/stockfish.js +454 -79
  17. package/dist/explorer-limiter.d.ts +1 -1
  18. package/dist/explorer-limiter.js +1 -1
  19. package/dist/explorer-retry.d.ts +2 -2
  20. package/dist/explorer-retry.js +15 -6
  21. package/dist/explorer-transport.d.ts +2 -0
  22. package/dist/explorer-transport.js +21 -2
  23. package/dist/explorer.js +30 -8
  24. package/dist/games.d.ts +4 -0
  25. package/dist/games.js +58 -27
  26. package/dist/http-body.d.ts +13 -0
  27. package/dist/http-body.js +103 -0
  28. package/dist/http-config.d.ts +33 -0
  29. package/dist/http-config.js +105 -0
  30. package/dist/http-posts.d.ts +11 -0
  31. package/dist/http-posts.js +37 -0
  32. package/dist/http-response.d.ts +3 -0
  33. package/dist/http-response.js +17 -0
  34. package/dist/http-runtime.d.ts +15 -0
  35. package/dist/http-runtime.js +308 -0
  36. package/dist/http.d.ts +2 -22
  37. package/dist/http.js +51 -466
  38. package/dist/human-boundary.d.ts +2 -0
  39. package/dist/human-boundary.js +34 -0
  40. package/dist/index.js +15 -15
  41. package/dist/intents.d.ts +1 -1
  42. package/dist/intents.js +60 -26
  43. package/dist/lifecycle.d.ts +2 -0
  44. package/dist/lifecycle.js +24 -0
  45. package/dist/maia3/inference.js +20 -5
  46. package/dist/pgn-lex.d.ts +14 -0
  47. package/dist/pgn-lex.js +72 -0
  48. package/dist/pgn-serialize.d.ts +4 -0
  49. package/dist/pgn-serialize.js +103 -0
  50. package/dist/pgn-shared.d.ts +13 -0
  51. package/dist/pgn-shared.js +43 -0
  52. package/dist/pgn.d.ts +1 -3
  53. package/dist/pgn.js +184 -432
  54. package/dist/position-validation.d.ts +3 -0
  55. package/dist/position-validation.js +372 -0
  56. package/dist/server.js +12 -14
  57. package/dist/services.js +36 -12
  58. package/dist/string-length.d.ts +1 -0
  59. package/dist/string-length.js +6 -0
  60. package/dist/tool-fields.d.ts +4 -0
  61. package/dist/tool-fields.js +16 -0
  62. package/dist/tool-inputs.js +18 -17
  63. package/dist/tool-meta.d.ts +1 -1
  64. package/dist/tool-meta.js +1 -1
  65. package/dist/tool-result.d.ts +22 -3
  66. package/dist/tool-result.js +30 -15
  67. package/dist/tool-schemas.d.ts +2 -8
  68. package/dist/tool-schemas.js +118 -29
  69. package/dist/tools/analysis.js +23 -9
  70. package/dist/tools/candidates.js +106 -24
  71. package/dist/tools/explorer.js +15 -2
  72. package/dist/tools/game.js +8 -8
  73. package/dist/tools/move-boundary.d.ts +8 -0
  74. package/dist/tools/move-boundary.js +13 -0
  75. package/docs/architecture.md +40 -24
  76. package/package.json +2 -3
package/README.md CHANGED
@@ -72,8 +72,11 @@ await server.close();
72
72
  The root API also exports `buildServer`, `GameStore`, `ChessError`,
73
73
  `ExplorerError`, the service/domain types needed to provide custom
74
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.
75
+ and `snapshotChess`. The package root is the supported public API. Deep imports
76
+ under `dist/` are intentionally not exported and will fail with
77
+ `ERR_PACKAGE_PATH_NOT_EXPORTED`; use named root exports instead.
78
+ This removes the previous `dist/*` compatibility exports and is a breaking
79
+ change for integrations that imported internal modules.
77
80
 
78
81
  `bodyTimeoutMs` limits HTTP body upload time; it is not a whole-tool deadline.
79
82
  The deprecated `requestTimeoutMs` alias remains supported when `bodyTimeoutMs`
@@ -173,7 +176,7 @@ codex mcp add llm-chess-mcp --command npx --args -y llm-chess-mcp --env LICHESS_
173
176
  | Tool | Description |
174
177
  |---|---|
175
178
  | `create_game` | Create a game (optionally from a FEN), returns `game_id` |
176
- | `delete_game` | Delete a game and free its session |
179
+ | `delete_game` | Delete a process-shared game and free game capacity |
177
180
  | `game_state` | Authoritative state: FEN, turn, revision, check/mate/draw flags, history, last move, castling (optional ASCII) |
178
181
  | `game_play_move` | Play a move (SAN or UCI) — the only mutating tool, with stale-position guard |
179
182
  | `game_legal_moves` | All legal moves with metadata |
@@ -207,9 +210,10 @@ human-readable summary and must not be parsed as data.
207
210
  `best / excellent / good / inaccuracy / mistake / blunder`.
208
211
  - `maia3Prob` is a **human-likelihood**, not move quality. A high-probability move
209
212
  can still be objectively bad.
210
- - Analysis continuations return `pv` in UCI and the same legal prefix in
211
- `pvSan` as SAN. If an engine line contains an invalid move, `pvSan` stops
212
- before it while the original `pv` remains unchanged.
213
+ - Successful analysis continuations return corresponding `pv` and `pvSan`
214
+ arrays of equal length in UCI and SAN. An invalid engine continuation is
215
+ rejected at the internal tool boundary instead of returning a truncated
216
+ `pvSan`.
213
217
 
214
218
  ## Candidate structure
215
219
 
@@ -272,10 +276,11 @@ rejected:
272
276
  - Up to 1,000 games are retained per process; idle games expire after one hour.
273
277
  - `move_evaluate` accepts at most 10 moves per call.
274
278
  - 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.
279
+ plies; stored snapshots enforce the same byte, header, token, and ply resource
280
+ bounds. Imports also cap the mainline and variations together at 32,768
281
+ structural elements and 16 KiB per lexical token. Every variation is
282
+ legality-checked; game state retains the mainline. UTF-8 BOMs and standard
283
+ escaped header values are supported.
279
284
  - Custom FENs reject inconsistent castling/en-passant metadata and impossible
280
285
  pawn or promotion material.
281
286
  - Stockfish accepts up to 32 active or queued analyses. Maia runs at most two
@@ -283,13 +288,18 @@ rejected:
283
288
  - Lichess Explorer requests run one at a time and share 429 cooldowns.
284
289
  - HTTP retains at most 64 MCP sessions; sessions with no active request expire
285
290
  after 30 minutes. An open GET/SSE stream keeps its session active.
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
291
- also caps connections at 128 and applies a 15-second body upload deadline
292
- plus bounded header, socket, and keep-alive timeouts.
291
+ - HTTP accepts bodies up to 2 MiB under normal body-parser capacity. Once those
292
+ parsers are full, an overflow request receives only a small, up-to-8 KiB
293
+ probe; only a complete MCP cancellation notification can proceed, and no
294
+ accepted parser is preempted. The listener's connection limit bounds overflow
295
+ probes. After body parsing, it permits 16 concurrent POST dispatches and
296
+ downstream compute/network jobs process-wide, with two of each per session.
297
+ A separate bounded control lane prioritizes MCP cancellation when normal
298
+ dispatch capacity is full. If an existing-session POST response closes before
299
+ it finishes, its session is closed and its work is aborted; an uncooperative
300
+ downstream operation still holds capacity until it settles. HTTP also caps
301
+ connections at 128 and applies a 15-second body upload deadline plus bounded
302
+ header, socket, and keep-alive timeouts.
293
303
 
294
304
  Programmatic users can override the HTTP limits through `HttpServerOptions`.
295
305
  These safeguards do not replace public-edge quotas: a public deployment must
@@ -302,7 +312,9 @@ its UCI queue boundary, drains queued work during shutdown, and rejects new
302
312
  analysis until teardown completes. Lichess fetch and retry waits abort
303
313
  immediately. Maia runs native inference in dedicated child processes; cancelling
304
314
  active work terminates its child, while queued cancellation is immediate. A raw
305
- HTTP disconnect alone is not a cancellation signal.
315
+ response disconnect for an existing-session POST closes that session and aborts
316
+ its work. Reconnect with a new session, then re-read the process-shared game
317
+ state before retrying a move.
306
318
 
307
319
  ## Intents
308
320
 
@@ -0,0 +1,2 @@
1
+ import type { SfLine } from "./domain.js";
2
+ export declare function validateAnalysisLines(lines: readonly SfLine[], requestedMultipv: number): void;
@@ -0,0 +1,49 @@
1
+ import { MAX_MULTIPV, WDL_TOTAL } from "./domain.js";
2
+ function validScore(value) {
3
+ return value === null || (typeof value === "number" && Number.isFinite(value));
4
+ }
5
+ function validWdl(value) {
6
+ return value === null ||
7
+ (Array.isArray(value) &&
8
+ value.length === 3 &&
9
+ value.every((count) => typeof count === "number" &&
10
+ Number.isSafeInteger(count) &&
11
+ count >= 0 &&
12
+ count <= WDL_TOTAL) &&
13
+ value[0] + value[1] + value[2] === WDL_TOTAL);
14
+ }
15
+ export function validateAnalysisLines(lines, requestedMultipv) {
16
+ if (!Number.isSafeInteger(requestedMultipv) ||
17
+ requestedMultipv < 1 ||
18
+ requestedMultipv > MAX_MULTIPV) {
19
+ throw new RangeError("invalid requested analysis multipv");
20
+ }
21
+ if (!Array.isArray(lines) || lines.length > requestedMultipv) {
22
+ throw new RangeError("analysis returned too many lines");
23
+ }
24
+ const ranks = new Set();
25
+ for (const line of lines) {
26
+ if (typeof line !== "object" ||
27
+ line === null ||
28
+ Array.isArray(line) ||
29
+ !Number.isSafeInteger(line.multipv) ||
30
+ line.multipv < 1 ||
31
+ line.multipv > requestedMultipv ||
32
+ ranks.has(line.multipv)) {
33
+ throw new RangeError("invalid analysis multipv rank");
34
+ }
35
+ ranks.add(line.multipv);
36
+ if (!validScore(line.scoreCp) || !validScore(line.scoreMate)) {
37
+ throw new RangeError("analysis line has an invalid score");
38
+ }
39
+ if (line.scoreCp !== null && line.scoreMate !== null) {
40
+ throw new RangeError("analysis line has conflicting scores");
41
+ }
42
+ if (!validWdl(line.wdl))
43
+ throw new RangeError("analysis line has invalid WDL");
44
+ if (!Array.isArray(line.pv) ||
45
+ !Array.from(line.pv).every((move) => typeof move === "string" && move.length > 0)) {
46
+ throw new RangeError("analysis line has invalid PV");
47
+ }
48
+ }
49
+ }
@@ -1,4 +1,7 @@
1
1
  import { Chess } from "chess.js";
2
- export declare function assertSafeFenCounters(fen: string): void;
3
- export declare function assertLegalPosition(chess: Chess): void;
2
+ export { assertLegalPosition, assertSafeFenCounters, } from "./position-validation.js";
3
+ export declare function snapshotChessWithPgn(chess: Chess): {
4
+ chess: Chess;
5
+ pgn: string;
6
+ };
4
7
  export declare function snapshotChess(chess: Chess): Chess;
@@ -1,45 +1,11 @@
1
1
  import { Chess } from "chess.js";
2
+ import { materializeMove } from "./chess-move.js";
2
3
  import { ChessError } from "./errors.js";
3
- import { assertPgnPlyLimit, replacePgnHeaders } from "./pgn-shared.js";
4
- const ORIGINAL_PIECES = {
5
- q: 1,
6
- r: 2,
7
- n: 2,
8
- };
4
+ import { assertPgnPlyLimit, pgnHeaderIndex, pgnSetupHeaders, replacePgnHeaders, terminalPgnResult, } from "./pgn-shared.js";
5
+ import { serializePgn } from "./pgn-serialize.js";
6
+ import { assertLegalPosition, assertSafeFenCounters, } from "./position-validation.js";
7
+ export { assertLegalPosition, assertSafeFenCounters, } from "./position-validation.js";
9
8
  const CHESS_STATE_KEYS = Reflect.ownKeys(new Chess());
10
- function squareColor(square) {
11
- return ((square.charCodeAt(0) - 97 + Number(square[1])) % 2);
12
- }
13
- function minimumPawnCaptures(chess, color) {
14
- const pawns = chess
15
- .findPiece({ type: "p", color })
16
- .map((square) => ({
17
- advances: color === "w" ? Number(square[1]) - 2 : 7 - Number(square[1]),
18
- file: square.charCodeAt(0) - 97,
19
- }))
20
- .sort((left, right) => left.file - right.file);
21
- let costs = new Map([[0, 0]]);
22
- for (const pawn of pawns) {
23
- const next = new Map();
24
- for (const [mask, cost] of costs) {
25
- for (let original = 0; original < 8; original += 1) {
26
- const bit = 1 << original;
27
- if (mask & bit)
28
- continue;
29
- const captures = Math.abs(original - pawn.file);
30
- if (captures > pawn.advances)
31
- continue;
32
- const nextMask = mask | bit;
33
- next.set(nextMask, Math.min(next.get(nextMask) ?? Infinity, cost + captures));
34
- }
35
- }
36
- costs = next;
37
- }
38
- return Math.min(...costs.values());
39
- }
40
- function nonKingMaterial(chess, color) {
41
- return ["p", "q", "r", "b", "n"].reduce((total, type) => total + chess.findPiece({ type, color }).length, 0);
42
- }
43
9
  function clonedChess(chess) {
44
10
  const state = Object.create(null);
45
11
  for (const key of CHESS_STATE_KEYS) {
@@ -66,132 +32,8 @@ function clonedChess(chess) {
66
32
  function exactFen(chess) {
67
33
  return chess.fen({ forceEnpassantSquare: true });
68
34
  }
69
- function hasPiece(chess, square, type, color) {
70
- const piece = chess.get(square);
71
- return piece?.type === type && piece.color === color;
72
- }
73
- function assertCastlingPosition(chess, color) {
74
- const rank = color === "w" ? "1" : "8";
75
- const rights = chess.getCastlingRights(color);
76
- if ((rights.k || rights.q) &&
77
- !hasPiece(chess, `e${rank}`, "k", color)) {
78
- throw new ChessError("INVALID_FEN", "FEN castling rights require a home king");
79
- }
80
- if (rights.k && !hasPiece(chess, `h${rank}`, "r", color)) {
81
- throw new ChessError("INVALID_FEN", "FEN kingside castling rights require a home rook");
82
- }
83
- if (rights.q && !hasPiece(chess, `a${rank}`, "r", color)) {
84
- throw new ChessError("INVALID_FEN", "FEN queenside castling rights require a home rook");
85
- }
86
- }
87
- function assertEnPassantPosition(chess) {
88
- const fields = chess.fen({ forceEnpassantSquare: true }).split(" ");
89
- const target = fields[3];
90
- if (!target || target === "-")
91
- return;
92
- const turn = chess.turn();
93
- const file = target[0];
94
- const targetRank = turn === "w" ? "6" : "3";
95
- const pawnRank = turn === "w" ? "5" : "4";
96
- const originRank = turn === "w" ? "7" : "2";
97
- const pawnColor = turn === "w" ? "b" : "w";
98
- const targetSquare = target;
99
- const pawnSquare = `${file}${pawnRank}`;
100
- const originSquare = `${file}${originRank}`;
101
- if (target[1] !== targetRank ||
102
- chess.get(targetSquare) !== undefined ||
103
- !hasPiece(chess, pawnSquare, "p", pawnColor) ||
104
- chess.get(originSquare) !== undefined ||
105
- fields[4] !== "0" ||
106
- (turn === "w" && fields[5] === "1")) {
107
- throw new ChessError("INVALID_FEN", "FEN en passant target does not match a double pawn move");
108
- }
109
- }
110
- function moveDescriptor(move) {
111
- const base = { from: move.from, to: move.to };
112
- return move.promotion ? { ...base, promotion: move.promotion } : base;
113
- }
114
- function isSafeDecimal(value, minimum) {
115
- return (/^(?:0|[1-9]\d*)$/.test(value) &&
116
- Number.isSafeInteger(Number(value)) &&
117
- Number(value) >= minimum);
118
- }
119
- export function assertSafeFenCounters(fen) {
120
- const fields = fen.split(/\s+/);
121
- if (fields.length >= 5 && !isSafeDecimal(fields[4] ?? "", 0)) {
122
- throw new ChessError("INVALID_FEN", "FEN halfmove clock must be a non-negative safe decimal integer");
123
- }
124
- if (fields.length >= 6 && !isSafeDecimal(fields[5] ?? "", 1)) {
125
- throw new ChessError("INVALID_FEN", "FEN fullmove number must be a positive safe decimal integer");
126
- }
127
- }
128
- export function assertLegalPosition(chess) {
129
- for (const color of ["w", "b"]) {
130
- if (chess.findPiece({ type: "k", color }).length !== 1) {
131
- throw new ChessError("INVALID_FEN", "FEN must contain exactly one king per side");
132
- }
133
- const pawns = chess.findPiece({ type: "p", color });
134
- if (pawns.some((square) => square[1] === "1" || square[1] === "8")) {
135
- throw new ChessError("INVALID_FEN", "FEN pawns cannot occupy the first or eighth rank");
136
- }
137
- if (pawns.length > 8) {
138
- throw new ChessError("INVALID_FEN", "FEN cannot contain more than eight pawns per side");
139
- }
140
- const promotedPieces = Object.entries(ORIGINAL_PIECES).reduce((total, [type, original]) => total +
141
- Math.max(0, chess.findPiece({ type: type, color }).length -
142
- original), 0);
143
- const promotedBishops = [0, 1].reduce((total, squareColorValue) => total +
144
- Math.max(0, chess
145
- .findPiece({ type: "b", color })
146
- .filter((square) => squareColor(square) === squareColorValue)
147
- .length - 1), 0);
148
- const promoted = promotedPieces + promotedBishops;
149
- if (promoted > 8 - pawns.length) {
150
- throw new ChessError("INVALID_FEN", "FEN contains more promoted material than missing pawns allow");
151
- }
152
- assertCastlingPosition(chess, color);
153
- const opponent = color === "w" ? "b" : "w";
154
- const missingOpponentMaterial = 15 - nonKingMaterial(chess, opponent);
155
- if (minimumPawnCaptures(chess, color) > missingOpponentMaterial) {
156
- throw new ChessError("INVALID_FEN", "FEN pawn files require more captures than opposing material allows");
157
- }
158
- }
159
- assertEnPassantPosition(chess);
160
- const turn = chess.turn();
161
- const previous = turn === "w" ? "b" : "w";
162
- const previousKing = chess.findPiece({ type: "k", color: previous })[0];
163
- if (previousKing && chess.isAttacked(previousKing, turn)) {
164
- throw new ChessError("INVALID_FEN", "FEN cannot leave the side that just moved in check");
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
35
  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
- }
36
+ const { fen } = pgnSetupHeaders(pgnHeaderIndex(headers));
195
37
  if (fen === undefined)
196
38
  return exactFen(new Chess());
197
39
  assertSafeFenCounters(fen);
@@ -226,21 +68,69 @@ function validatedHistory(chess) {
226
68
  }
227
69
  return { history, initialFen, shadow, sourceHeaders };
228
70
  }
229
- export function snapshotChess(chess) {
230
- assertLegalPosition(chess);
231
- const { history, initialFen, shadow, sourceHeaders } = validatedHistory(chess);
232
- assertPgnPlyLimit(history.length);
233
- assertSafeFenCounters(initialFen);
234
- const snapshot = new Chess(initialFen);
235
- assertLegalPosition(snapshot);
71
+ function commentsByFen(chess, shadow) {
236
72
  const getComments = chess.getComments;
237
73
  const sourceComments = getComments === Chess.prototype.getComments
238
74
  ? Chess.prototype.getComments.call(shadow)
239
75
  : getComments.call(chess);
240
- const comments = new Map(sourceComments.map(({ fen, comment }) => [
76
+ return new Map(sourceComments.map(({ fen, comment }) => [
241
77
  fen,
242
78
  /[{}]/.test(comment) ? comment.replace(/[\r\n]+/g, " ") : comment,
243
79
  ]));
80
+ }
81
+ function replayHistory(chess, history, restoreComment) {
82
+ restoreComment();
83
+ for (const move of history) {
84
+ chess.move(materializeMove(move));
85
+ restoreComment();
86
+ }
87
+ }
88
+ function snapshotPgn(chess) {
89
+ const result = terminalPgnResult(chess);
90
+ const originalResult = chess.getHeaders().Result;
91
+ if (result !== undefined)
92
+ chess.setHeader("Result", result);
93
+ try {
94
+ const headers = Object.entries(chess.getHeaders());
95
+ return serializePgn(() => Chess.prototype.pgn.call(chess), headers, Chess.prototype.getComments.call(chess).map(({ comment }) => comment));
96
+ }
97
+ finally {
98
+ if (result !== undefined) {
99
+ if (originalResult === undefined)
100
+ chess.removeHeader("Result");
101
+ else
102
+ chess.setHeader("Result", originalResult);
103
+ }
104
+ }
105
+ }
106
+ function restoreUnsafeComments(snapshot, comments, markerPrefix, markerComments, sourceHeaders) {
107
+ const escapedPrefix = markerPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
108
+ const marker = new RegExp(`\\{${escapedPrefix}(\\d+)${escapedPrefix}\\}`, "g");
109
+ const pgn = snapshot.pgn().replace(marker, (_match, index) => {
110
+ return `;${markerComments[Number(index)]}\n`;
111
+ });
112
+ const restored = new Chess();
113
+ restored.loadPgn(pgn);
114
+ const restoredHistory = restored.history({ verbose: true });
115
+ while (restored.undo()) { }
116
+ replayHistory(restored, restoredHistory, () => {
117
+ const comment = comments.get(restored.fen());
118
+ if (comment !== undefined && !/[{}]/.test(comment)) {
119
+ restored.setComment(comment);
120
+ }
121
+ });
122
+ replacePgnHeaders(restored, sourceHeaders, { removeMissing: true });
123
+ assertSafeFenCounters(restored.fen());
124
+ return restored;
125
+ }
126
+ export function snapshotChessWithPgn(chess) {
127
+ assertLegalPosition(chess);
128
+ const { history, initialFen, shadow, sourceHeaders } = validatedHistory(chess);
129
+ assertPgnPlyLimit(history.length);
130
+ assertSafeFenCounters(initialFen);
131
+ const snapshot = new Chess(initialFen);
132
+ assertLegalPosition(snapshot);
133
+ const comments = commentsByFen(chess, shadow);
244
134
  const unsafeComments = [...comments.values()].some((comment) => /[{}]/.test(comment));
245
135
  let markerPrefix = "\uE000";
246
136
  if (unsafeComments) {
@@ -249,7 +139,7 @@ export function snapshotChess(chess) {
249
139
  markerPrefix += "\uE001";
250
140
  }
251
141
  const markerComments = [];
252
- const restoreComment = () => {
142
+ replayHistory(snapshot, history, () => {
253
143
  const comment = comments.get(snapshot.fen());
254
144
  if (comment === undefined)
255
145
  return;
@@ -260,38 +150,15 @@ export function snapshotChess(chess) {
260
150
  const marker = `${markerPrefix}${markerComments.length}${markerPrefix}`;
261
151
  markerComments.push(comment);
262
152
  snapshot.setComment(marker);
263
- };
264
- restoreComment();
265
- for (const move of history) {
266
- snapshot.move(moveDescriptor(move));
267
- restoreComment();
268
- }
153
+ });
269
154
  assertSafeFenCounters(snapshot.fen());
270
155
  if (!unsafeComments) {
271
156
  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();
157
+ return { chess: snapshot, pgn: snapshotPgn(snapshot) };
293
158
  }
294
- replacePgnHeaders(restored, sourceHeaders, { removeMissing: true });
295
- assertSafeFenCounters(restored.fen());
296
- return restored;
159
+ const restored = restoreUnsafeComments(snapshot, comments, markerPrefix, markerComments, sourceHeaders);
160
+ return { chess: restored, pgn: snapshotPgn(restored) };
161
+ }
162
+ export function snapshotChess(chess) {
163
+ return snapshotChessWithPgn(chess).chess;
297
164
  }
@@ -0,0 +1,7 @@
1
+ import type { PieceSymbol, Square } from "chess.js";
2
+ export type MoveDescriptor = {
3
+ from: Square;
4
+ to: Square;
5
+ promotion?: PieceSymbol;
6
+ };
7
+ export declare function materializeMove(move: MoveDescriptor): MoveDescriptor;
@@ -0,0 +1,4 @@
1
+ export function materializeMove(move) {
2
+ const { from, to, promotion } = move;
3
+ return promotion ? { from, to, promotion } : { from, to };
4
+ }
package/dist/chess.d.ts CHANGED
@@ -3,11 +3,12 @@ import type { Move } from "chess.js";
3
3
  export { assertLegalPosition, assertSafeFenCounters, snapshotChess, } from "./chess-copy.js";
4
4
  export { MAX_PGN_BYTES, MAX_PGN_HEADERS, MAX_PGN_PLIES, MAX_PGN_TOKEN_BYTES, parseImportedPgn, pgnOf, } from "./pgn.js";
5
5
  import type { ChessState, DrawResult } from "./domain.js";
6
+ import { type MoveDescriptor } from "./chess-move.js";
6
7
  export declare const MAX_EVALUATED_MOVES = 10;
7
8
  export declare function drawResult(chess: Chess): DrawResult | null;
8
9
  export declare function stateOf<Revision extends number>(chess: Chess, revision: Revision): ChessState & {
9
10
  revision: Revision;
10
11
  };
11
12
  export declare function parseMove(chess: Chess, move: string): Move;
12
- export declare function playParsedMove(chess: Chess, move: Move): Move;
13
+ export declare function playParsedMove(chess: Chess, move: MoveDescriptor): Move;
13
14
  export declare function pvToSan(chess: Chess, pv: readonly string[]): string[];
package/dist/chess.js CHANGED
@@ -2,11 +2,8 @@ import { Chess } from "chess.js";
2
2
  export { assertLegalPosition, assertSafeFenCounters, snapshotChess, } from "./chess-copy.js";
3
3
  export { MAX_PGN_BYTES, MAX_PGN_HEADERS, MAX_PGN_PLIES, MAX_PGN_TOKEN_BYTES, parseImportedPgn, pgnOf, } from "./pgn.js";
4
4
  import { ChessError } from "./errors.js";
5
+ import { materializeMove } from "./chess-move.js";
5
6
  export const MAX_EVALUATED_MOVES = 10;
6
- function moveDescriptor(move) {
7
- const base = { from: move.from, to: move.to };
8
- return move.promotion ? { ...base, promotion: move.promotion } : base;
9
- }
10
7
  export function drawResult(chess) {
11
8
  if (chess.isCheckmate() || !chess.isDraw())
12
9
  return null;
@@ -21,7 +18,8 @@ export function drawResult(chess) {
21
18
  return "draw";
22
19
  }
23
20
  export function stateOf(chess, revision) {
24
- const last = chess.history({ verbose: true }).at(-1);
21
+ const history = chess.history({ verbose: true });
22
+ const last = history.at(-1);
25
23
  const isCheckmate = chess.isCheckmate();
26
24
  return {
27
25
  fen: chess.fen(),
@@ -36,7 +34,7 @@ export function stateOf(chess, revision) {
36
34
  isThreefoldRepetition: !isCheckmate && chess.isThreefoldRepetition(),
37
35
  isDrawByFiftyMoves: !isCheckmate && chess.isDrawByFiftyMoves(),
38
36
  moveNumber: chess.moveNumber(),
39
- history: chess.history(),
37
+ history: history.map((move) => move.san),
40
38
  lastMove: last ? { san: last.san, uci: last.lan } : null,
41
39
  castling: {
42
40
  whiteKingside: chess.getCastlingRights("w").k,
@@ -58,7 +56,7 @@ export function parseMove(chess, move) {
58
56
  return found;
59
57
  }
60
58
  export function playParsedMove(chess, move) {
61
- return chess.move(moveDescriptor(move));
59
+ return chess.move(materializeMove(move));
62
60
  }
63
61
  export function pvToSan(chess, pv) {
64
62
  const copy = new Chess(chess.fen());
package/dist/cli.d.ts CHANGED
@@ -7,5 +7,5 @@ export type CliOptions = {
7
7
  allowedHosts: string[];
8
8
  help: boolean;
9
9
  };
10
- export declare const HELP = "Usage: llm-chess-mcp [options]\n\nOptions:\n --transport <stdio|http> Transport to use (default: stdio)\n --http Shortcut for --transport http\n --host <host> HTTP bind host (default: 127.0.0.1)\n --port <port> HTTP listen port (default: 3000)\n --path <path> HTTP endpoint path (default: /mcp)\n --allowed-host <host> Allowed HTTP Host/Origin hostname (repeatable)\n -h, --help Show this help\n";
10
+ export declare const HELP = "Usage: llm-chess-mcp [options]\n\nOptions:\n --transport <stdio|http> Transport to use (default: stdio)\n --http Shortcut for --transport http\n --host <host> HTTP bind host (default: 127.0.0.1)\n --port <port> HTTP listen port; 0 selects an available port (default: 3000)\n --path <path> HTTP endpoint path (default: /mcp)\n --allowed-host <host> Allowed HTTP Host/Origin hostname (repeatable)\n -h, --help Show this help\n";
11
11
  export declare function parseCli(args: string[]): CliOptions;
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ Options:
5
5
  --transport <stdio|http> Transport to use (default: stdio)
6
6
  --http Shortcut for --transport http
7
7
  --host <host> HTTP bind host (default: 127.0.0.1)
8
- --port <port> HTTP listen port (default: 3000)
8
+ --port <port> HTTP listen port; 0 selects an available port (default: 3000)
9
9
  --path <path> HTTP endpoint path (default: /mcp)
10
10
  --allowed-host <host> Allowed HTTP Host/Origin hostname (repeatable)
11
11
  -h, --help Show this help
@@ -90,8 +90,8 @@ export function parseCli(args) {
90
90
  throw new Error(`unknown option: ${option}`);
91
91
  }
92
92
  }
93
- if (!Number.isInteger(port) || port < 1 || port > 65_535) {
94
- throw new Error("--port must be between 1 and 65535");
93
+ if (!Number.isInteger(port) || port < 0 || port > 65_535) {
94
+ throw new Error("--port must be between 0 and 65535");
95
95
  }
96
96
  if (!isCanonicalHttpPath(path)) {
97
97
  throw new Error("--path must be an absolute URL path without query or fragment");
package/dist/domain.d.ts CHANGED
@@ -13,6 +13,12 @@ export declare const MOVE_EVALUATION_RESULTS: readonly ["ongoing", "checkmate",
13
13
  export type MoveEvaluationResult = (typeof MOVE_EVALUATION_RESULTS)[number];
14
14
  export declare const ANALYSIS_LEVELS: readonly ["fast", "normal", "deep"];
15
15
  export type AnalysisLevel = (typeof ANALYSIS_LEVELS)[number];
16
+ export declare const MAX_ANALYSIS_DEPTH = 30;
17
+ export declare const MAX_MULTIPV = 10;
18
+ export declare const MAX_HUMAN_MOVES = 20;
19
+ export declare const HUMAN_PROBABILITY_TOLERANCE = 0.00001;
20
+ export declare const GAME_ID_MAX_LENGTH = 256;
21
+ export declare const WDL_TOTAL = 1000;
16
22
  export declare const MOVE_CLASSIFICATIONS: readonly ["best", "excellent", "good", "inaccuracy", "mistake", "blunder"];
17
23
  export type MoveClassification = (typeof MOVE_CLASSIFICATIONS)[number];
18
24
  export declare const INTENTS: readonly ["best", "strong", "natural", "balanced", "ease_off", "give_chance"];
@@ -49,13 +55,21 @@ export interface ChessState {
49
55
  };
50
56
  }
51
57
  export type Wdl = [number, number, number];
52
- export interface SfLine {
58
+ type SfScore = {
59
+ scoreCp: number;
60
+ scoreMate: null;
61
+ } | {
62
+ scoreCp: null;
63
+ scoreMate: number;
64
+ } | {
65
+ scoreCp: null;
66
+ scoreMate: null;
67
+ };
68
+ export type SfLine = {
53
69
  multipv: number;
54
- scoreCp: number | null;
55
- scoreMate: number | null;
56
70
  wdl: Wdl | null;
57
71
  pv: string[];
58
- }
72
+ } & SfScore;
59
73
  export interface Maia3Move {
60
74
  uci: string;
61
75
  san: string;
package/dist/domain.js CHANGED
@@ -25,6 +25,12 @@ export const MOVE_EVALUATION_RESULTS = [
25
25
  ...DRAW_RESULTS,
26
26
  ];
27
27
  export const ANALYSIS_LEVELS = ["fast", "normal", "deep"];
28
+ export const MAX_ANALYSIS_DEPTH = 30;
29
+ export const MAX_MULTIPV = 10;
30
+ export const MAX_HUMAN_MOVES = 20;
31
+ export const HUMAN_PROBABILITY_TOLERANCE = 1e-5;
32
+ export const GAME_ID_MAX_LENGTH = 256;
33
+ export const WDL_TOTAL = 1_000;
28
34
  export const MOVE_CLASSIFICATIONS = [
29
35
  "best",
30
36
  "excellent",