llm-chess-mcp 0.4.5 → 0.4.8

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 (62) hide show
  1. package/README.md +35 -136
  2. package/dist/chess-copy.d.ts +4 -0
  3. package/dist/chess-copy.js +126 -0
  4. package/dist/chess.d.ts +6 -8
  5. package/dist/chess.js +9 -171
  6. package/dist/cli.js +16 -21
  7. package/dist/domain.d.ts +114 -0
  8. package/dist/domain.js +43 -0
  9. package/dist/engines/stockfish-info.d.ts +14 -0
  10. package/dist/engines/stockfish-info.js +68 -0
  11. package/dist/engines/stockfish.d.ts +16 -6
  12. package/dist/engines/stockfish.js +221 -91
  13. package/dist/eval.d.ts +4 -3
  14. package/dist/eval.js +1 -0
  15. package/dist/explorer-core.d.ts +33 -0
  16. package/dist/explorer-core.js +65 -0
  17. package/dist/explorer-limiter.d.ts +12 -0
  18. package/dist/explorer-limiter.js +84 -0
  19. package/dist/explorer-response.d.ts +8 -0
  20. package/dist/explorer-response.js +145 -0
  21. package/dist/explorer-retry.d.ts +20 -0
  22. package/dist/explorer-retry.js +134 -0
  23. package/dist/explorer-transport.d.ts +21 -0
  24. package/dist/explorer-transport.js +68 -0
  25. package/dist/explorer.d.ts +7 -38
  26. package/dist/explorer.js +73 -360
  27. package/dist/games.js +4 -2
  28. package/dist/http-config.d.ts +8 -0
  29. package/dist/http-config.js +62 -0
  30. package/dist/http-posts.d.ts +13 -0
  31. package/dist/http-posts.js +29 -0
  32. package/dist/http-sessions.d.ts +23 -0
  33. package/dist/http-sessions.js +122 -0
  34. package/dist/http-work.d.ts +5 -2
  35. package/dist/http-work.js +31 -12
  36. package/dist/http.js +213 -240
  37. package/dist/index.d.ts +1 -1
  38. package/dist/index.js +22 -5
  39. package/dist/intent-ranking.d.ts +2 -0
  40. package/dist/intent-ranking.js +72 -0
  41. package/dist/intents.d.ts +3 -3
  42. package/dist/intents.js +68 -102
  43. package/dist/maia3/inference.d.ts +1 -1
  44. package/dist/pgn.d.ts +5 -0
  45. package/dist/pgn.js +185 -0
  46. package/dist/server.js +6 -4
  47. package/dist/services.d.ts +17 -3
  48. package/dist/services.js +30 -12
  49. package/dist/tool-inputs.d.ts +22 -0
  50. package/dist/tool-inputs.js +39 -32
  51. package/dist/tool-result.d.ts +6 -4
  52. package/dist/tool-result.js +3 -3
  53. package/dist/tool-schemas.d.ts +174 -60
  54. package/dist/tool-schemas.js +21 -22
  55. package/dist/tools/analysis.js +8 -12
  56. package/dist/tools/candidates.js +10 -10
  57. package/dist/tools/explorer.js +5 -4
  58. package/dist/tools/game.js +21 -16
  59. package/dist/types.d.ts +1 -44
  60. package/dist/types.js +1 -8
  61. package/docs/architecture.md +52 -12
  62. package/package.json +9 -6
package/README.md CHANGED
@@ -16,31 +16,15 @@ judgment; the MCP server handles all the computation.
16
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
- Everything runs inside the Node process no external engine process or Python
19
+ Everything runs inside the Node process. No external engine process or Python
20
20
  runtime is required at deploy time. The published package bundles the Maia3 5M
21
21
  model; other export variants are not runtime options unless their ONNX files are
22
22
  provided separately.
23
23
 
24
- ## Install
24
+ ## Build from source
25
25
 
26
- Requires Node.js 20.3 or newer.
27
-
28
- No install needed — run it directly with `npx`:
29
-
30
- ```bash
31
- npx -y llm-chess-mcp
32
- ```
33
-
34
- The Maia3 model is already bundled, so there's no Python, torch, or engine
35
- binaries to install. `npx` fetches the package on first run and caches it.
36
-
37
- To install it permanently instead:
38
-
39
- ```bash
40
- npm install -g llm-chess-mcp
41
- ```
42
-
43
- ### Build from source
26
+ The published runtime supports Node.js 20.3 and newer. Repository maintenance
27
+ uses Node.js 22.13 or newer because pnpm 11 and the coverage gate require it.
44
28
 
45
29
  ```bash
46
30
  pnpm install
@@ -52,24 +36,6 @@ pnpm test
52
36
  the MCP transport tests. `pnpm check` runs the full local gate; use
53
37
  `pnpm release:check` before publishing.
54
38
 
55
- ### Maintainers
56
-
57
- [Architecture](docs/architecture.md) describes runtime and service boundaries.
58
-
59
- Local quality commands:
60
-
61
- ```bash
62
- pnpm typecheck
63
- pnpm test:coverage
64
- pnpm contract:check
65
- pnpm check
66
- pnpm test:package
67
- ```
68
-
69
- `pnpm test:stress` runs the short real-engine concurrency check.
70
- `pnpm test:live` queries Lichess only when `LICHESS_TOKEN` is set; otherwise it
71
- skips without making a network request.
72
-
73
39
  ## Transports
74
40
 
75
41
  stdio remains the default transport and requires no flags. To expose a local
@@ -111,95 +77,7 @@ does not provide authentication or TLS; use a trusted network or an
111
77
  authenticated reverse proxy when exposing it beyond localhost. Origin values
112
78
  are validated when present, but the server does not emit browser CORS headers.
113
79
 
114
- ### Reverse-proxy deployment
115
-
116
- The HTTP server is intended to run behind a reverse proxy for any non-local
117
- deployment. The proxy owns TLS termination, client authentication, external
118
- rate/connection limits, and any future CORS policy. Bind this process to
119
- localhost only; never expose its port directly through a firewall, container
120
- port mapping, or load balancer.
121
-
122
- For example, start the backend with the public hostname that Nginx will pass
123
- through as `Host`:
124
-
125
- ```bash
126
- node dist/index.js --transport http --host 127.0.0.1 --port 3000 \
127
- --allowed-host chess-mcp.example.com
128
- ```
129
-
130
- This is a minimal Nginx layout. It assumes an identity-aware auth service is
131
- available only on localhost at `127.0.0.1:4180`; configure that service and
132
- the certificate paths for the deployment. The limits are examples, not a
133
- substitute for capacity planning.
134
-
135
- ```nginx
136
- limit_req_zone $binary_remote_addr zone=mcp_req:10m rate=5r/s;
137
- limit_conn_zone $binary_remote_addr zone=mcp_conn:10m;
138
-
139
- server {
140
- listen 443 ssl;
141
- server_name chess-mcp.example.com;
142
- ssl_certificate /etc/ssl/certs/chess-mcp.pem;
143
- ssl_certificate_key /etc/ssl/private/chess-mcp.key;
144
-
145
- location = /_mcp_auth {
146
- internal;
147
- proxy_pass http://127.0.0.1:4180/auth;
148
- proxy_pass_request_body off;
149
- proxy_set_header Content-Length "";
150
- proxy_set_header X-Original-Method $request_method;
151
- proxy_set_header X-Original-URI $request_uri;
152
- }
153
-
154
- location = /mcp {
155
- auth_request /_mcp_auth;
156
- limit_req zone=mcp_req burst=20 nodelay;
157
- limit_conn mcp_conn 10;
158
- client_max_body_size 2m;
159
-
160
- proxy_pass http://127.0.0.1:3000;
161
- proxy_http_version 1.1;
162
- proxy_set_header Connection "";
163
- proxy_set_header Host $host;
164
- proxy_set_header X-Forwarded-Proto https;
165
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
166
- proxy_set_header X-Forwarded-User "";
167
- proxy_set_header X-Forwarded-Email "";
168
- proxy_buffering off;
169
- proxy_read_timeout 90s;
170
-
171
- # Intentionally no Access-Control-Allow-* headers: browser CORS is unsupported.
172
- }
173
- }
174
- ```
175
-
176
- The application does not trust forwarded identity headers and does not assign
177
- games to authenticated users. All games in one process share one `GameStore`;
178
- the opaque `game_id` is the capability to operate a game within the trusted
179
- deployment, not an OAuth token or user identity. Do not disclose it across
180
- trust boundaries.
181
-
182
- This server does not implement MCP OAuth discovery, bearer-token validation,
183
- or browser CORS. A proxy may authenticate access to the endpoint, but that is
184
- deployment policy rather than an application-level identity or ownership
185
- model. Browser clients are unsupported unless a proxy deliberately adds and
186
- maintains the required CORS policy.
187
-
188
- ### Export Maia3 to ONNX (build-time only)
189
-
190
- This step needs Python + PyTorch once. It downloads the Maia3 checkpoint, verifies
191
- the reimplementation against the original, and exports `models/maia3-5m.onnx`.
192
-
193
- ```bash
194
- uv venv .venv-maia3 --python 3.13
195
- uv pip install --python .venv-maia3/bin/python -r scripts/requirements.txt
196
- uv pip install --python .venv-maia3/bin/python "maia3 @ git+https://github.com/CSSLab/maia3.git@1e13597c42d4858b7cfd7cfdae01e297263364b2"
197
- pnpm export:maia3 # -> models/maia3-5m.onnx
198
- ```
199
-
200
- The resulting `.onnx` is committed/bundled; end users never need Python or torch.
201
-
202
- ### Lichess token (optional)
80
+ ## Lichess token (optional)
203
81
 
204
82
  The opening explorer now requires authentication. Generate a personal access token
205
83
  at <https://lichess.org/account/oauth/token/create> and set it in `.env`:
@@ -216,7 +94,9 @@ Explorer filters are strict. Speeds are `ultraBullet`, `bullet`, `blitz`,
216
94
  `1200`, `1400`, `1600`, `1800`, `2000`, `2200`, and `2500`. `masters` accepts
217
95
  neither filter. Invalid filters fail locally. Transient failures (network,
218
96
  timeout, 429, and 5xx) are retried once within a 12-second total budget;
219
- invalid requests and other 4xx responses are not retried.
97
+ invalid requests and other 4xx responses are not retried. Responses must be
98
+ valid UTF-8 JSON and are limited to 1 MiB, 256 moves, and 256 characters per
99
+ move or opening string.
220
100
 
221
101
  ## Configure in your MCP client
222
102
 
@@ -385,15 +265,18 @@ rejected:
385
265
  - Up to 1,000 games are retained per process; idle games expire after one hour.
386
266
  - `move_evaluate` accepts at most 10 moves per call.
387
267
  - Imported PGNs are limited to 1 MiB and 4,096 plies.
268
+ - Custom FENs reject inconsistent castling/en-passant metadata and impossible
269
+ pawn or promotion material.
388
270
  - Stockfish accepts up to 32 active or queued analyses.
389
271
  - Lichess Explorer requests run one at a time and share 429 cooldowns.
390
272
  - HTTP retains at most 64 MCP sessions; sessions with no active request expire
391
273
  after 30 minutes. An open GET/SSE stream keeps its session active.
392
274
  - HTTP accepts bodies up to 2 MiB. It permits 16 concurrent POSTs and downstream
393
- compute/network jobs process-wide, with two of each per session. Work keeps
394
- its slot after a raw disconnect until it settles. HTTP also caps connections
395
- at 128 and applies a 15-second body upload deadline plus bounded header,
396
- socket, and keep-alive timeouts.
275
+ compute/network jobs process-wide, with two of each per session. A separately
276
+ bounded control lane keeps MCP cancellation available when normal POST slots
277
+ are full. Work keeps its slot after a raw disconnect until it settles. HTTP
278
+ also caps connections at 128 and applies a 15-second body upload deadline
279
+ plus bounded header, socket, and keep-alive timeouts.
397
280
 
398
281
  Programmatic users can override the HTTP limits through `HttpServerOptions`.
399
282
  These safeguards do not replace public-edge quotas: a public deployment must
@@ -401,10 +284,12 @@ still enforce request, connection, and authentication limits at the reverse
401
284
  proxy.
402
285
 
403
286
  MCP cancellation notifications, session deletion, and server shutdown propagate
404
- to Stockfish, Maia, and Lichess work. Stockfish stops safely at its UCI queue
405
- boundary; Lichess fetch and retry waits abort immediately. ONNX Runtime cannot
406
- interrupt an inference already executing, so Maia discards its result after the
407
- native call returns. A raw HTTP disconnect alone is not a cancellation signal.
287
+ to body uploads and Stockfish, Maia, and Lichess work. Stockfish stops safely at
288
+ its UCI queue boundary, drains queued work during shutdown, and rejects new
289
+ analysis until teardown completes. Lichess fetch and retry waits abort immediately. ONNX
290
+ Runtime cannot interrupt an inference already executing, so Maia discards its
291
+ result after the native call returns. A raw HTTP disconnect alone is not a
292
+ cancellation signal.
408
293
 
409
294
  ## Intents
410
295
 
@@ -440,6 +325,20 @@ Go deeper only when you need to:
440
325
  - `opening_explorer` — real-game statistics
441
326
  - `move_evaluate` — score a specific move (or compare several)
442
327
 
328
+ ## Export Maia3 to ONNX
329
+
330
+ This step needs Python + PyTorch once. It downloads the Maia3 checkpoint, verifies
331
+ the reimplementation against the original, and exports `models/maia3-5m.onnx`.
332
+
333
+ ```bash
334
+ uv venv .venv-maia3 --python 3.13
335
+ uv pip install --python .venv-maia3/bin/python -r scripts/requirements.txt
336
+ uv pip install --python .venv-maia3/bin/python "maia3 @ git+https://github.com/CSSLab/maia3.git@1e13597c42d4858b7cfd7cfdae01e297263364b2"
337
+ pnpm export:maia3 # -> models/maia3-5m.onnx
338
+ ```
339
+
340
+ The resulting `.onnx` is committed/bundled.
341
+
443
342
  ## Maia3 ONNX verification
444
343
 
445
344
  The exported ONNX model is regression-tested against the upstream Maia3
@@ -0,0 +1,4 @@
1
+ import { Chess } from "chess.js";
2
+ export declare function assertSafeFenCounters(fen: string): void;
3
+ export declare function assertLegalPosition(chess: Chess): void;
4
+ export declare function snapshotChess(chess: Chess): Chess;
@@ -0,0 +1,126 @@
1
+ import { Chess } from "chess.js";
2
+ import { ChessError } from "./errors.js";
3
+ const ORIGINAL_PIECES = {
4
+ q: 1,
5
+ r: 2,
6
+ n: 2,
7
+ };
8
+ function squareColor(square) {
9
+ return ((square.charCodeAt(0) - 97 + Number(square[1])) % 2);
10
+ }
11
+ function hasPiece(chess, square, type, color) {
12
+ const piece = chess.get(square);
13
+ return piece?.type === type && piece.color === color;
14
+ }
15
+ function assertCastlingPosition(chess, color) {
16
+ const rank = color === "w" ? "1" : "8";
17
+ const rights = chess.getCastlingRights(color);
18
+ if ((rights.k || rights.q) &&
19
+ !hasPiece(chess, `e${rank}`, "k", color)) {
20
+ throw new ChessError("INVALID_FEN", "FEN castling rights require a home king");
21
+ }
22
+ if (rights.k && !hasPiece(chess, `h${rank}`, "r", color)) {
23
+ throw new ChessError("INVALID_FEN", "FEN kingside castling rights require a home rook");
24
+ }
25
+ if (rights.q && !hasPiece(chess, `a${rank}`, "r", color)) {
26
+ throw new ChessError("INVALID_FEN", "FEN queenside castling rights require a home rook");
27
+ }
28
+ }
29
+ function assertEnPassantPosition(chess) {
30
+ const fields = chess.fen({ forceEnpassantSquare: true }).split(" ");
31
+ const target = fields[3];
32
+ if (!target || target === "-")
33
+ return;
34
+ const turn = chess.turn();
35
+ const file = target[0];
36
+ const targetRank = turn === "w" ? "6" : "3";
37
+ const pawnRank = turn === "w" ? "5" : "4";
38
+ const originRank = turn === "w" ? "7" : "2";
39
+ const pawnColor = turn === "w" ? "b" : "w";
40
+ const targetSquare = target;
41
+ const pawnSquare = `${file}${pawnRank}`;
42
+ const originSquare = `${file}${originRank}`;
43
+ if (target[1] !== targetRank ||
44
+ chess.get(targetSquare) !== undefined ||
45
+ !hasPiece(chess, pawnSquare, "p", pawnColor) ||
46
+ chess.get(originSquare) !== undefined ||
47
+ fields[4] !== "0" ||
48
+ (turn === "w" && fields[5] === "1")) {
49
+ throw new ChessError("INVALID_FEN", "FEN en passant target does not match a double pawn move");
50
+ }
51
+ }
52
+ function moveDescriptor(move) {
53
+ const base = { from: move.from, to: move.to };
54
+ return move.promotion ? { ...base, promotion: move.promotion } : base;
55
+ }
56
+ function isSafeDecimal(value, minimum) {
57
+ return (/^(?:0|[1-9]\d*)$/.test(value) &&
58
+ Number.isSafeInteger(Number(value)) &&
59
+ Number(value) >= minimum);
60
+ }
61
+ export function assertSafeFenCounters(fen) {
62
+ const fields = fen.split(/\s+/);
63
+ if (fields.length >= 5 && !isSafeDecimal(fields[4] ?? "", 0)) {
64
+ throw new ChessError("INVALID_FEN", "FEN halfmove clock must be a non-negative safe decimal integer");
65
+ }
66
+ if (fields.length >= 6 && !isSafeDecimal(fields[5] ?? "", 1)) {
67
+ throw new ChessError("INVALID_FEN", "FEN fullmove number must be a positive safe decimal integer");
68
+ }
69
+ }
70
+ export function assertLegalPosition(chess) {
71
+ for (const color of ["w", "b"]) {
72
+ if (chess.findPiece({ type: "k", color }).length !== 1) {
73
+ throw new ChessError("INVALID_FEN", "FEN must contain exactly one king per side");
74
+ }
75
+ const pawns = chess.findPiece({ type: "p", color });
76
+ if (pawns.some((square) => square[1] === "1" || square[1] === "8")) {
77
+ throw new ChessError("INVALID_FEN", "FEN pawns cannot occupy the first or eighth rank");
78
+ }
79
+ if (pawns.length > 8) {
80
+ throw new ChessError("INVALID_FEN", "FEN cannot contain more than eight pawns per side");
81
+ }
82
+ const promotedPieces = Object.entries(ORIGINAL_PIECES).reduce((total, [type, original]) => total +
83
+ Math.max(0, chess.findPiece({ type: type, color }).length -
84
+ original), 0);
85
+ const promotedBishops = [0, 1].reduce((total, squareColorValue) => total +
86
+ Math.max(0, chess
87
+ .findPiece({ type: "b", color })
88
+ .filter((square) => squareColor(square) === squareColorValue)
89
+ .length - 1), 0);
90
+ const promoted = promotedPieces + promotedBishops;
91
+ if (promoted > 8 - pawns.length) {
92
+ throw new ChessError("INVALID_FEN", "FEN contains more promoted material than missing pawns allow");
93
+ }
94
+ assertCastlingPosition(chess, color);
95
+ }
96
+ assertEnPassantPosition(chess);
97
+ const turn = chess.turn();
98
+ const previous = turn === "w" ? "b" : "w";
99
+ const previousKing = chess.findPiece({ type: "k", color: previous })[0];
100
+ if (previousKing && chess.isAttacked(previousKing, turn)) {
101
+ throw new ChessError("INVALID_FEN", "FEN cannot leave the side that just moved in check");
102
+ }
103
+ }
104
+ export function snapshotChess(chess) {
105
+ assertLegalPosition(chess);
106
+ const history = chess.history({ verbose: true });
107
+ const initialFen = history[0]?.before ?? chess.fen();
108
+ assertSafeFenCounters(initialFen);
109
+ const snapshot = new Chess(initialFen);
110
+ const comments = new Map(chess.getComments().map(({ fen, comment }) => [fen, comment]));
111
+ for (const [key, value] of Object.entries(chess.getHeaders())) {
112
+ snapshot.setHeader(key, value);
113
+ }
114
+ const restoreComment = () => {
115
+ const comment = comments.get(snapshot.fen());
116
+ if (comment !== undefined)
117
+ snapshot.setComment(comment);
118
+ };
119
+ restoreComment();
120
+ for (const move of history) {
121
+ snapshot.move(moveDescriptor(move));
122
+ restoreComment();
123
+ }
124
+ assertSafeFenCounters(snapshot.fen());
125
+ return snapshot;
126
+ }
package/dist/chess.d.ts CHANGED
@@ -1,15 +1,13 @@
1
1
  import { Chess } from "chess.js";
2
2
  import type { Move } from "chess.js";
3
- import type { ChessState, DrawResult } from "./types.js";
3
+ export { assertLegalPosition, assertSafeFenCounters, snapshotChess, } from "./chess-copy.js";
4
+ export { MAX_PGN_BYTES, MAX_PGN_PLIES, parseImportedPgn, pgnOf } from "./pgn.js";
5
+ import type { ChessState, DrawResult } from "./domain.js";
4
6
  export declare const MAX_EVALUATED_MOVES = 10;
5
- export declare const MAX_PGN_BYTES: number;
6
- export declare const MAX_PGN_PLIES = 4096;
7
- export declare function assertSafeFenCounters(fen: string): void;
8
- export declare function snapshotChess(chess: Chess): Chess;
9
7
  export declare function drawResult(chess: Chess): DrawResult | null;
10
- export declare function pgnOf(chess: Chess): string;
11
- export declare function parseImportedPgn(pgn: string): Chess;
12
- export declare function stateOf(chess: Chess, revision: number): ChessState;
8
+ export declare function stateOf<Revision extends number>(chess: Chess, revision: Revision): ChessState & {
9
+ revision: Revision;
10
+ };
13
11
  export declare function parseMove(chess: Chess, move: string): Move;
14
12
  export declare function playParsedMove(chess: Chess, move: Move): Move;
15
13
  export declare function pvToSan(chess: Chess, pv: readonly string[]): string[];
package/dist/chess.js CHANGED
@@ -1,51 +1,14 @@
1
1
  import { Chess } from "chess.js";
2
+ export { assertLegalPosition, assertSafeFenCounters, snapshotChess, } from "./chess-copy.js";
3
+ export { MAX_PGN_BYTES, MAX_PGN_PLIES, parseImportedPgn, pgnOf } from "./pgn.js";
2
4
  import { ChessError } from "./errors.js";
3
5
  export const MAX_EVALUATED_MOVES = 10;
4
- export const MAX_PGN_BYTES = 1024 * 1024;
5
- export const MAX_PGN_PLIES = 4096;
6
- const PGN_RESULTS = ["1-0", "0-1", "1/2-1/2", "*"];
7
6
  function moveDescriptor(move) {
8
7
  const base = { from: move.from, to: move.to };
9
8
  return move.promotion ? { ...base, promotion: move.promotion } : base;
10
9
  }
11
- function isSafeDecimal(value, minimum) {
12
- return (/^(?:0|[1-9]\d*)$/.test(value) &&
13
- Number.isSafeInteger(Number(value)) &&
14
- Number(value) >= minimum);
15
- }
16
- export function assertSafeFenCounters(fen) {
17
- const fields = fen.split(/\s+/);
18
- if (fields.length >= 5 && !isSafeDecimal(fields[4] ?? "", 0)) {
19
- throw new ChessError("INVALID_FEN", "FEN halfmove clock must be a non-negative safe decimal integer");
20
- }
21
- if (fields.length >= 6 && !isSafeDecimal(fields[5] ?? "", 1)) {
22
- throw new ChessError("INVALID_FEN", "FEN fullmove number must be a positive safe decimal integer");
23
- }
24
- }
25
- export function snapshotChess(chess) {
26
- const history = chess.history({ verbose: true });
27
- const initialFen = history[0]?.before ?? chess.fen();
28
- assertSafeFenCounters(initialFen);
29
- const snapshot = new Chess(initialFen);
30
- const comments = new Map(chess.getComments().map(({ fen, comment }) => [fen, comment]));
31
- for (const [key, value] of Object.entries(chess.getHeaders())) {
32
- snapshot.setHeader(key, value);
33
- }
34
- const restoreComment = () => {
35
- const comment = comments.get(snapshot.fen());
36
- if (comment !== undefined)
37
- snapshot.setComment(comment);
38
- };
39
- restoreComment();
40
- for (const move of history) {
41
- snapshot.move(moveDescriptor(move));
42
- restoreComment();
43
- }
44
- assertSafeFenCounters(snapshot.fen());
45
- return snapshot;
46
- }
47
10
  export function drawResult(chess) {
48
- if (!chess.isDraw())
11
+ if (chess.isCheckmate() || !chess.isDraw())
49
12
  return null;
50
13
  if (chess.isStalemate())
51
14
  return "stalemate";
@@ -57,146 +20,21 @@ export function drawResult(chess) {
57
20
  return "fifty_move_rule";
58
21
  return "draw";
59
22
  }
60
- function withoutPgnComments(pgn) {
61
- let result = "";
62
- let braceComment = false;
63
- let lineComment = false;
64
- let quoted = false;
65
- let escaped = false;
66
- for (const char of pgn) {
67
- if (braceComment) {
68
- if (char === "}")
69
- braceComment = false;
70
- result += char === "\n" || char === "\r" ? char : " ";
71
- continue;
72
- }
73
- if (lineComment) {
74
- if (char === "\n" || char === "\r") {
75
- lineComment = false;
76
- result += char;
77
- }
78
- else {
79
- result += " ";
80
- }
81
- continue;
82
- }
83
- if (quoted) {
84
- result += char;
85
- if (escaped)
86
- escaped = false;
87
- else if (char === "\\")
88
- escaped = true;
89
- else if (char === '"')
90
- quoted = false;
91
- continue;
92
- }
93
- if (char === '"') {
94
- quoted = true;
95
- result += char;
96
- continue;
97
- }
98
- if (char === "{") {
99
- braceComment = true;
100
- result += " ";
101
- }
102
- else if (char === ";") {
103
- lineComment = true;
104
- result += " ";
105
- }
106
- else {
107
- result += char;
108
- }
109
- }
110
- return result;
111
- }
112
- function isPgnResult(value) {
113
- return PGN_RESULTS.includes(value);
114
- }
115
- function declaredPgnResult(pgn) {
116
- const visiblePgn = withoutPgnComments(pgn);
117
- const headerResults = [
118
- ...visiblePgn.matchAll(/^\s*\[\s*Result\s+"((?:\\.|[^"\\])*)"\s*\]\s*$/gm),
119
- ].map((match) => match[1] ?? "");
120
- const movetext = visiblePgn.replace(/^\s*\[[^\r\n]*\]\s*$/gm, "");
121
- const markers = [
122
- ...movetext.matchAll(/(?:^|\s)(1-0|0-1|1\/2-1\/2|\*)(?=\s|$)/g),
123
- ].map((match) => match[1] ?? "");
124
- const results = [...headerResults, ...markers];
125
- if (!results.every(isPgnResult)) {
126
- throw new ChessError("INVALID_PGN", "invalid PGN result");
127
- }
128
- const result = results[0];
129
- if (results.some((value) => value !== result)) {
130
- throw new ChessError("INVALID_PGN", "PGN result header and marker disagree");
131
- }
132
- return result;
133
- }
134
- function validatePgnFenCounters(pgn) {
135
- for (const match of withoutPgnComments(pgn).matchAll(/^\s*\[\s*FEN\s+"((?:\\.|[^"\\])*)"\s*\]\s*$/gim)) {
136
- assertSafeFenCounters(match[1] ?? "");
137
- }
138
- }
139
- function validateResultForPosition(chess, result) {
140
- if (result === undefined)
141
- return;
142
- if (chess.isCheckmate()) {
143
- const expected = chess.turn() === "w" ? "0-1" : "1-0";
144
- if (result === expected)
145
- return;
146
- throw new ChessError("INVALID_PGN", `checkmate result must be ${expected}`);
147
- }
148
- if ((chess.isStalemate() || chess.isInsufficientMaterial()) &&
149
- (result === "1-0" || result === "0-1")) {
150
- throw new ChessError("INVALID_PGN", "a drawn position cannot have a decisive result");
151
- }
152
- }
153
- export function pgnOf(chess) {
154
- const result = chess.isCheckmate()
155
- ? (chess.turn() === "w" ? "0-1" : "1-0")
156
- : chess.isDraw() && (!chess.getHeaders().Result || chess.getHeaders().Result === "*")
157
- ? "1/2-1/2"
158
- : undefined;
159
- if (result === undefined)
160
- return chess.pgn();
161
- const snapshot = snapshotChess(chess);
162
- snapshot.setHeader("Result", result);
163
- return snapshot.pgn();
164
- }
165
- export function parseImportedPgn(pgn) {
166
- if (Buffer.byteLength(pgn, "utf8") > MAX_PGN_BYTES) {
167
- throw new ChessError("PGN_TOO_LARGE", `PGN exceeds the ${MAX_PGN_BYTES}-byte limit`);
168
- }
169
- validatePgnFenCounters(pgn);
170
- const result = declaredPgnResult(pgn);
171
- let chess;
172
- try {
173
- chess = new Chess();
174
- chess.loadPgn(pgn);
175
- }
176
- catch {
177
- throw new ChessError("INVALID_PGN", "invalid or illegal PGN");
178
- }
179
- assertSafeFenCounters(chess.fen());
180
- if (chess.history().length > MAX_PGN_PLIES) {
181
- throw new ChessError("PGN_TOO_MANY_MOVES", `PGN exceeds the ${MAX_PGN_PLIES}-ply limit`);
182
- }
183
- validateResultForPosition(chess, result);
184
- return chess;
185
- }
186
23
  export function stateOf(chess, revision) {
187
24
  const last = chess.history({ verbose: true }).at(-1);
25
+ const isCheckmate = chess.isCheckmate();
188
26
  return {
189
27
  fen: chess.fen(),
190
28
  turn: chess.turn(),
191
29
  revision,
192
30
  isCheck: chess.isCheck(),
193
- isCheckmate: chess.isCheckmate(),
31
+ isCheckmate,
194
32
  isStalemate: chess.isStalemate(),
195
- isDraw: chess.isDraw(),
33
+ isDraw: !isCheckmate && chess.isDraw(),
196
34
  isGameOver: chess.isGameOver(),
197
- isInsufficientMaterial: chess.isInsufficientMaterial(),
198
- isThreefoldRepetition: chess.isThreefoldRepetition(),
199
- isDrawByFiftyMoves: chess.isDrawByFiftyMoves(),
35
+ isInsufficientMaterial: !isCheckmate && chess.isInsufficientMaterial(),
36
+ isThreefoldRepetition: !isCheckmate && chess.isThreefoldRepetition(),
37
+ isDrawByFiftyMoves: !isCheckmate && chess.isDrawByFiftyMoves(),
200
38
  moveNumber: chess.moveNumber(),
201
39
  history: chess.history(),
202
40
  lastMove: last ? { san: last.san, uci: last.lan } : null,
package/dist/cli.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { canonicalHttpHostname, DEFAULT_HTTP_HOST, DEFAULT_HTTP_PATH, DEFAULT_HTTP_PORT, isCanonicalHttpPath, isWildcardHttpBindHost, } from "./http-config.js";
1
2
  export const HELP = `Usage: llm-chess-mcp [options]
2
3
 
3
4
  Options:
@@ -20,25 +21,11 @@ function splitOption(arg) {
20
21
  const index = arg.indexOf("=");
21
22
  return index === -1 ? null : [arg.slice(0, index), arg.slice(index + 1)];
22
23
  }
23
- function isCanonicalHttpPath(path) {
24
- if (!path.startsWith("/") ||
25
- path.startsWith("//") ||
26
- path.includes("?") ||
27
- path.includes("#")) {
28
- return false;
29
- }
30
- try {
31
- return new URL(path, "http://localhost").pathname === path;
32
- }
33
- catch {
34
- return false;
35
- }
36
- }
37
24
  export function parseCli(args) {
38
25
  let transport = "stdio";
39
- let host = "127.0.0.1";
40
- let port = 3_000;
41
- let path = "/mcp";
26
+ let host = DEFAULT_HTTP_HOST;
27
+ let port = DEFAULT_HTTP_PORT;
28
+ let path = DEFAULT_HTTP_PATH;
42
29
  let help = false;
43
30
  let hasHttpOption = false;
44
31
  const allowedHosts = [];
@@ -106,16 +93,24 @@ export function parseCli(args) {
106
93
  if (!isCanonicalHttpPath(path)) {
107
94
  throw new Error("--path must be an absolute URL path without query or fragment");
108
95
  }
109
- if (!host || allowedHosts.some((value) => !value || /[/?#]/.test(value))) {
96
+ const canonicalAllowedHosts = allowedHosts.map(canonicalHttpHostname);
97
+ if (!host || canonicalAllowedHosts.some((value) => value === null)) {
110
98
  throw new Error("HTTP hostnames must be non-empty hostnames");
111
99
  }
112
100
  if (transport === "stdio" && hasHttpOption) {
113
101
  throw new Error("HTTP options require --transport http");
114
102
  }
115
103
  if (transport === "http" &&
116
- (host === "0.0.0.0" || host === "::" || host === "[::]") &&
117
- allowedHosts.length === 0) {
104
+ isWildcardHttpBindHost(host) &&
105
+ canonicalAllowedHosts.length === 0) {
118
106
  throw new Error("wildcard HTTP binding requires at least one --allowed-host");
119
107
  }
120
- return { transport, host, port, path, allowedHosts, help };
108
+ return {
109
+ transport,
110
+ host,
111
+ port,
112
+ path,
113
+ allowedHosts: canonicalAllowedHosts,
114
+ help,
115
+ };
121
116
  }