llm-chess-mcp 0.4.4 → 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 (78) hide show
  1. package/README.md +48 -134
  2. package/dist/chess-copy.d.ts +4 -0
  3. package/dist/chess-copy.js +126 -0
  4. package/dist/chess.d.ts +13 -0
  5. package/dist/chess.js +13 -34
  6. package/dist/cli.d.ts +11 -0
  7. package/dist/cli.js +18 -9
  8. package/dist/domain.d.ts +114 -0
  9. package/dist/domain.js +43 -0
  10. package/dist/engines/stockfish-info.d.ts +14 -0
  11. package/dist/engines/stockfish-info.js +68 -0
  12. package/dist/engines/stockfish.d.ts +60 -0
  13. package/dist/engines/stockfish.js +260 -86
  14. package/dist/env.d.ts +1 -0
  15. package/dist/errors.d.ts +5 -0
  16. package/dist/eval.d.ts +25 -0
  17. package/dist/eval.js +1 -0
  18. package/dist/explorer-core.d.ts +33 -0
  19. package/dist/explorer-core.js +65 -0
  20. package/dist/explorer-limiter.d.ts +12 -0
  21. package/dist/explorer-limiter.js +84 -0
  22. package/dist/explorer-response.d.ts +8 -0
  23. package/dist/explorer-response.js +145 -0
  24. package/dist/explorer-retry.d.ts +20 -0
  25. package/dist/explorer-retry.js +134 -0
  26. package/dist/explorer-transport.d.ts +21 -0
  27. package/dist/explorer-transport.js +68 -0
  28. package/dist/explorer.d.ts +33 -0
  29. package/dist/explorer.js +78 -220
  30. package/dist/games.d.ts +33 -0
  31. package/dist/games.js +13 -26
  32. package/dist/http-config.d.ts +8 -0
  33. package/dist/http-config.js +62 -0
  34. package/dist/http-posts.d.ts +13 -0
  35. package/dist/http-posts.js +29 -0
  36. package/dist/http-sessions.d.ts +23 -0
  37. package/dist/http-sessions.js +122 -0
  38. package/dist/http-work.d.ts +11 -0
  39. package/dist/http-work.js +31 -12
  40. package/dist/http.d.ts +32 -0
  41. package/dist/http.js +269 -219
  42. package/dist/index.d.ts +6 -0
  43. package/dist/index.js +39 -20
  44. package/dist/intent-ranking.d.ts +2 -0
  45. package/dist/intent-ranking.js +72 -0
  46. package/dist/intents.d.ts +39 -0
  47. package/dist/intents.js +86 -105
  48. package/dist/maia3/inference.d.ts +7 -0
  49. package/dist/maia3/inference.js +2 -0
  50. package/dist/maia3/mirror.d.ts +3 -0
  51. package/dist/maia3/tokenize.d.ts +5 -0
  52. package/dist/maia3/vocab.d.ts +3 -0
  53. package/dist/pgn.d.ts +5 -0
  54. package/dist/pgn.js +185 -0
  55. package/dist/server.d.ts +3 -0
  56. package/dist/server.js +20 -2
  57. package/dist/services.d.ts +35 -0
  58. package/dist/services.js +37 -3
  59. package/dist/tool-inputs.d.ts +257 -0
  60. package/dist/tool-inputs.js +39 -32
  61. package/dist/tool-meta.d.ts +141 -0
  62. package/dist/tool-names.d.ts +2 -0
  63. package/dist/tool-result.d.ts +15 -0
  64. package/dist/tool-result.js +8 -3
  65. package/dist/tool-schemas.d.ts +984 -0
  66. package/dist/tool-schemas.js +21 -29
  67. package/dist/tools/analysis.d.ts +5 -0
  68. package/dist/tools/analysis.js +8 -12
  69. package/dist/tools/candidates.d.ts +5 -0
  70. package/dist/tools/candidates.js +29 -13
  71. package/dist/tools/explorer.d.ts +5 -0
  72. package/dist/tools/explorer.js +5 -4
  73. package/dist/tools/game.d.ts +5 -0
  74. package/dist/tools/game.js +25 -17
  75. package/dist/types.d.ts +1 -0
  76. package/dist/types.js +1 -8
  77. package/docs/architecture.md +71 -22
  78. package/package.json +21 -7
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 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
@@ -93,100 +59,25 @@ HTTP options:
93
59
  --allowed-host <host> Allowed Host/Origin hostname; repeat as needed
94
60
  ```
95
61
 
96
- Binding to `0.0.0.0` or `::` requires at least one `--allowed-host`. HTTP mode
97
- does not provide authentication or TLS; use a trusted network or an
98
- authenticated reverse proxy when exposing it beyond localhost. Origin values
99
- are validated when present, but the server does not emit browser CORS headers.
100
-
101
- ### Reverse-proxy deployment
62
+ The package also exposes a typed ESM API:
102
63
 
103
- The HTTP server is intended to run behind a reverse proxy for any non-local
104
- deployment. The proxy owns TLS termination, client authentication, external
105
- rate/connection limits, and any future CORS policy. Bind this process to
106
- localhost only; never expose its port directly through a firewall, container
107
- port mapping, or load balancer.
64
+ ```js
65
+ import { serveHttp } from "llm-chess-mcp";
108
66
 
109
- For example, start the backend with the public hostname that Nginx will pass
110
- through as `Host`:
111
-
112
- ```bash
113
- node dist/index.js --transport http --host 127.0.0.1 --port 3000 \
114
- --allowed-host chess-mcp.example.com
115
- ```
116
-
117
- This is a minimal Nginx layout. It assumes an identity-aware auth service is
118
- available only on localhost at `127.0.0.1:4180`; configure that service and
119
- the certificate paths for the deployment. The limits are examples, not a
120
- substitute for capacity planning.
121
-
122
- ```nginx
123
- limit_req_zone $binary_remote_addr zone=mcp_req:10m rate=5r/s;
124
- limit_conn_zone $binary_remote_addr zone=mcp_conn:10m;
125
-
126
- server {
127
- listen 443 ssl;
128
- server_name chess-mcp.example.com;
129
- ssl_certificate /etc/ssl/certs/chess-mcp.pem;
130
- ssl_certificate_key /etc/ssl/private/chess-mcp.key;
131
-
132
- location = /_mcp_auth {
133
- internal;
134
- proxy_pass http://127.0.0.1:4180/auth;
135
- proxy_pass_request_body off;
136
- proxy_set_header Content-Length "";
137
- proxy_set_header X-Original-Method $request_method;
138
- proxy_set_header X-Original-URI $request_uri;
139
- }
140
-
141
- location = /mcp {
142
- auth_request /_mcp_auth;
143
- limit_req zone=mcp_req burst=20 nodelay;
144
- limit_conn mcp_conn 10;
145
- client_max_body_size 2m;
146
-
147
- proxy_pass http://127.0.0.1:3000;
148
- proxy_http_version 1.1;
149
- proxy_set_header Connection "";
150
- proxy_set_header Host $host;
151
- proxy_set_header X-Forwarded-Proto https;
152
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
153
- proxy_set_header X-Forwarded-User "";
154
- proxy_set_header X-Forwarded-Email "";
155
- proxy_buffering off;
156
- proxy_read_timeout 90s;
157
-
158
- # Intentionally no Access-Control-Allow-* headers: browser CORS is unsupported.
159
- }
160
- }
67
+ const server = await serveHttp({ port: 3000, bodyTimeoutMs: 15_000 });
68
+ await server.close();
161
69
  ```
162
70
 
163
- The application does not trust forwarded identity headers and does not assign
164
- games to authenticated users. All games in one process share one `GameStore`;
165
- the opaque `game_id` is the capability to operate a game within the trusted
166
- deployment, not an OAuth token or user identity. Do not disclose it across
167
- trust boundaries.
168
-
169
- This server does not implement MCP OAuth discovery, bearer-token validation,
170
- or browser CORS. A proxy may authenticate access to the endpoint, but that is
171
- deployment policy rather than an application-level identity or ownership
172
- model. Browser clients are unsupported unless a proxy deliberately adds and
173
- maintains the required CORS policy.
174
-
175
- ### Export Maia3 to ONNX (build-time only)
176
-
177
- This step needs Python + PyTorch once. It downloads the Maia3 checkpoint, verifies
178
- the reimplementation against the original, and exports `models/maia3-5m.onnx`.
71
+ `bodyTimeoutMs` limits HTTP body upload time; it is not a whole-tool deadline.
72
+ The deprecated `requestTimeoutMs` alias remains supported when `bodyTimeoutMs`
73
+ is omitted.
179
74
 
180
- ```bash
181
- uv venv .venv-maia3 --python 3.13
182
- uv pip install --python .venv-maia3/bin/python -r scripts/requirements.txt
183
- uv pip install --python .venv-maia3/bin/python "maia3 @ git+https://github.com/CSSLab/maia3.git@1e13597c42d4858b7cfd7cfdae01e297263364b2"
184
- pnpm export:maia3 # -> models/maia3-5m.onnx
185
- ```
186
-
187
- The resulting `.onnx` is committed/bundled; end users never need Python or torch.
75
+ Binding to `0.0.0.0` or `::` requires at least one `--allowed-host`. HTTP mode
76
+ does not provide authentication or TLS; use a trusted network or an
77
+ authenticated reverse proxy when exposing it beyond localhost. Origin values
78
+ are validated when present, but the server does not emit browser CORS headers.
188
79
 
189
- ### Lichess token (optional)
80
+ ## Lichess token (optional)
190
81
 
191
82
  The opening explorer now requires authentication. Generate a personal access token
192
83
  at <https://lichess.org/account/oauth/token/create> and set it in `.env`:
@@ -203,7 +94,9 @@ Explorer filters are strict. Speeds are `ultraBullet`, `bullet`, `blitz`,
203
94
  `1200`, `1400`, `1600`, `1800`, `2000`, `2200`, and `2500`. `masters` accepts
204
95
  neither filter. Invalid filters fail locally. Transient failures (network,
205
96
  timeout, 429, and 5xx) are retried once within a 12-second total budget;
206
- 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.
207
100
 
208
101
  ## Configure in your MCP client
209
102
 
@@ -372,13 +265,18 @@ rejected:
372
265
  - Up to 1,000 games are retained per process; idle games expire after one hour.
373
266
  - `move_evaluate` accepts at most 10 moves per call.
374
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.
375
270
  - Stockfish accepts up to 32 active or queued analyses.
271
+ - Lichess Explorer requests run one at a time and share 429 cooldowns.
376
272
  - HTTP retains at most 64 MCP sessions; sessions with no active request expire
377
273
  after 30 minutes. An open GET/SSE stream keeps its session active.
378
274
  - HTTP accepts bodies up to 2 MiB. It permits 16 concurrent POSTs and downstream
379
- compute/network jobs process-wide, with two of each per session. Work keeps
380
- its slot after a raw disconnect until it settles. HTTP also caps connections
381
- at 128 and applies bounded header, upload, 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.
382
280
 
383
281
  Programmatic users can override the HTTP limits through `HttpServerOptions`.
384
282
  These safeguards do not replace public-edge quotas: a public deployment must
@@ -386,10 +284,12 @@ still enforce request, connection, and authentication limits at the reverse
386
284
  proxy.
387
285
 
388
286
  MCP cancellation notifications, session deletion, and server shutdown propagate
389
- to Stockfish, Maia, and Lichess work. Stockfish stops safely at its UCI queue
390
- boundary; Lichess fetch and retry waits abort immediately. ONNX Runtime cannot
391
- interrupt an inference already executing, so Maia discards its result after the
392
- 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.
393
293
 
394
294
  ## Intents
395
295
 
@@ -425,6 +325,20 @@ Go deeper only when you need to:
425
325
  - `opening_explorer` — real-game statistics
426
326
  - `move_evaluate` — score a specific move (or compare several)
427
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
+
428
342
  ## Maia3 ONNX verification
429
343
 
430
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
+ }
@@ -0,0 +1,13 @@
1
+ import { Chess } from "chess.js";
2
+ import type { Move } from "chess.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";
6
+ export declare const MAX_EVALUATED_MOVES = 10;
7
+ export declare function drawResult(chess: Chess): DrawResult | null;
8
+ export declare function stateOf<Revision extends number>(chess: Chess, revision: Revision): ChessState & {
9
+ revision: Revision;
10
+ };
11
+ export declare function parseMove(chess: Chess, move: string): Move;
12
+ export declare function playParsedMove(chess: Chess, move: Move): Move;
13
+ export declare function pvToSan(chess: Chess, pv: readonly string[]): string[];
package/dist/chess.js CHANGED
@@ -1,21 +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
6
  function moveDescriptor(move) {
7
7
  const base = { from: move.from, to: move.to };
8
8
  return move.promotion ? { ...base, promotion: move.promotion } : base;
9
9
  }
10
- export function snapshotChess(chess) {
11
- const history = chess.history({ verbose: true });
12
- const snapshot = new Chess(history[0]?.before ?? chess.fen());
13
- for (const move of history)
14
- snapshot.move(moveDescriptor(move));
15
- return snapshot;
16
- }
17
10
  export function drawResult(chess) {
18
- if (!chess.isDraw())
11
+ if (chess.isCheckmate() || !chess.isDraw())
19
12
  return null;
20
13
  if (chess.isStalemate())
21
14
  return "stalemate";
@@ -27,37 +20,21 @@ export function drawResult(chess) {
27
20
  return "fifty_move_rule";
28
21
  return "draw";
29
22
  }
30
- export function parseImportedPgn(pgn) {
31
- if (Buffer.byteLength(pgn, "utf8") > MAX_PGN_BYTES) {
32
- throw new ChessError("PGN_TOO_LARGE", `PGN exceeds the ${MAX_PGN_BYTES}-byte limit`);
33
- }
34
- let chess;
35
- try {
36
- chess = new Chess();
37
- chess.loadPgn(pgn);
38
- }
39
- catch {
40
- throw new ChessError("INVALID_PGN", "invalid or illegal PGN");
41
- }
42
- if (chess.history().length > MAX_PGN_PLIES) {
43
- throw new ChessError("PGN_TOO_MANY_MOVES", `PGN exceeds the ${MAX_PGN_PLIES}-ply limit`);
44
- }
45
- return chess;
46
- }
47
23
  export function stateOf(chess, revision) {
48
24
  const last = chess.history({ verbose: true }).at(-1);
25
+ const isCheckmate = chess.isCheckmate();
49
26
  return {
50
27
  fen: chess.fen(),
51
28
  turn: chess.turn(),
52
29
  revision,
53
30
  isCheck: chess.isCheck(),
54
- isCheckmate: chess.isCheckmate(),
31
+ isCheckmate,
55
32
  isStalemate: chess.isStalemate(),
56
- isDraw: chess.isDraw(),
33
+ isDraw: !isCheckmate && chess.isDraw(),
57
34
  isGameOver: chess.isGameOver(),
58
- isInsufficientMaterial: chess.isInsufficientMaterial(),
59
- isThreefoldRepetition: chess.isThreefoldRepetition(),
60
- isDrawByFiftyMoves: chess.isDrawByFiftyMoves(),
35
+ isInsufficientMaterial: !isCheckmate && chess.isInsufficientMaterial(),
36
+ isThreefoldRepetition: !isCheckmate && chess.isThreefoldRepetition(),
37
+ isDrawByFiftyMoves: !isCheckmate && chess.isDrawByFiftyMoves(),
61
38
  moveNumber: chess.moveNumber(),
62
39
  history: chess.history(),
63
40
  lastMove: last ? { san: last.san, uci: last.lan } : null,
@@ -71,8 +48,10 @@ export function stateOf(chess, revision) {
71
48
  }
72
49
  export function parseMove(chess, move) {
73
50
  const legal = chess.moves({ verbose: true });
74
- const san = move.replace(/[+#]$/, "");
75
- const found = legal.find((candidate) => candidate.san.replace(/[+#]$/, "") === san) ??
51
+ const found = legal.find((candidate) => candidate.san === move) ??
52
+ (!/[+#]$/.test(move)
53
+ ? legal.find((candidate) => candidate.san.replace(/[+#]$/, "") === move)
54
+ : undefined) ??
76
55
  legal.find((candidate) => candidate.lan === move);
77
56
  if (!found)
78
57
  throw new ChessError("ILLEGAL_MOVE", `illegal move: ${move}`);
package/dist/cli.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ export type TransportKind = "stdio" | "http";
2
+ export type CliOptions = {
3
+ transport: TransportKind;
4
+ host: string;
5
+ port: number;
6
+ path: string;
7
+ allowedHosts: string[];
8
+ help: boolean;
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";
11
+ export declare function parseCli(args: string[]): CliOptions;
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:
@@ -22,9 +23,9 @@ function splitOption(arg) {
22
23
  }
23
24
  export function parseCli(args) {
24
25
  let transport = "stdio";
25
- let host = "127.0.0.1";
26
- let port = 3_000;
27
- let path = "/mcp";
26
+ let host = DEFAULT_HTTP_HOST;
27
+ let port = DEFAULT_HTTP_PORT;
28
+ let path = DEFAULT_HTTP_PATH;
28
29
  let help = false;
29
30
  let hasHttpOption = false;
30
31
  const allowedHosts = [];
@@ -89,19 +90,27 @@ export function parseCli(args) {
89
90
  if (!Number.isInteger(port) || port < 1 || port > 65_535) {
90
91
  throw new Error("--port must be between 1 and 65535");
91
92
  }
92
- if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
93
+ if (!isCanonicalHttpPath(path)) {
93
94
  throw new Error("--path must be an absolute URL path without query or fragment");
94
95
  }
95
- if (!host || allowedHosts.some((value) => !value)) {
96
- throw new Error("HTTP hostnames must not be empty");
96
+ const canonicalAllowedHosts = allowedHosts.map(canonicalHttpHostname);
97
+ if (!host || canonicalAllowedHosts.some((value) => value === null)) {
98
+ throw new Error("HTTP hostnames must be non-empty hostnames");
97
99
  }
98
100
  if (transport === "stdio" && hasHttpOption) {
99
101
  throw new Error("HTTP options require --transport http");
100
102
  }
101
103
  if (transport === "http" &&
102
- (host === "0.0.0.0" || host === "::" || host === "[::]") &&
103
- allowedHosts.length === 0) {
104
+ isWildcardHttpBindHost(host) &&
105
+ canonicalAllowedHosts.length === 0) {
104
106
  throw new Error("wildcard HTTP binding requires at least one --allowed-host");
105
107
  }
106
- return { transport, host, port, path, allowedHosts, help };
108
+ return {
109
+ transport,
110
+ host,
111
+ port,
112
+ path,
113
+ allowedHosts: canonicalAllowedHosts,
114
+ help,
115
+ };
107
116
  }
@@ -0,0 +1,114 @@
1
+ import type { Chess } from "chess.js";
2
+ export declare const EXPLORER_ERROR_KINDS: readonly ["disabled", "invalid_input", "timeout", "network", "auth", "rate_limited", "upstream", "http", "invalid_response"];
3
+ export type ExplorerErrorKind = (typeof EXPLORER_ERROR_KINDS)[number];
4
+ export declare const COLORS: readonly ["w", "b"];
5
+ export type Color = (typeof COLORS)[number];
6
+ export declare const PIECES: readonly ["p", "n", "b", "r", "q", "k"];
7
+ export type Piece = (typeof PIECES)[number];
8
+ export declare const PROMOTIONS: readonly ["n", "b", "r", "q"];
9
+ export type Promotion = (typeof PROMOTIONS)[number];
10
+ export declare const DRAW_RESULTS: readonly ["stalemate", "insufficient_material", "threefold_repetition", "fifty_move_rule", "draw"];
11
+ export type DrawResult = (typeof DRAW_RESULTS)[number];
12
+ export declare const MOVE_EVALUATION_RESULTS: readonly ["ongoing", "checkmate", "stalemate", "insufficient_material", "threefold_repetition", "fifty_move_rule", "draw"];
13
+ export type MoveEvaluationResult = (typeof MOVE_EVALUATION_RESULTS)[number];
14
+ export declare const ANALYSIS_LEVELS: readonly ["fast", "normal", "deep"];
15
+ export type AnalysisLevel = (typeof ANALYSIS_LEVELS)[number];
16
+ export declare const MOVE_CLASSIFICATIONS: readonly ["best", "excellent", "good", "inaccuracy", "mistake", "blunder"];
17
+ export type MoveClassification = (typeof MOVE_CLASSIFICATIONS)[number];
18
+ export declare const INTENTS: readonly ["best", "strong", "natural", "balanced", "ease_off", "give_chance"];
19
+ export type Intent = (typeof INTENTS)[number];
20
+ export interface GameRecord {
21
+ chess: Chess;
22
+ createdAt: number;
23
+ lastAccessedAt: number;
24
+ revision: number;
25
+ }
26
+ export interface ChessState {
27
+ fen: string;
28
+ turn: Color;
29
+ revision: number;
30
+ isCheck: boolean;
31
+ isCheckmate: boolean;
32
+ isStalemate: boolean;
33
+ isDraw: boolean;
34
+ isGameOver: boolean;
35
+ isInsufficientMaterial: boolean;
36
+ isThreefoldRepetition: boolean;
37
+ isDrawByFiftyMoves: boolean;
38
+ moveNumber: number;
39
+ history: string[];
40
+ lastMove: {
41
+ san: string;
42
+ uci: string;
43
+ } | null;
44
+ castling: {
45
+ whiteKingside: boolean;
46
+ whiteQueenside: boolean;
47
+ blackKingside: boolean;
48
+ blackQueenside: boolean;
49
+ };
50
+ }
51
+ export type Wdl = [number, number, number];
52
+ export interface SfLine {
53
+ multipv: number;
54
+ scoreCp: number | null;
55
+ scoreMate: number | null;
56
+ wdl: Wdl | null;
57
+ pv: string[];
58
+ }
59
+ export interface Maia3Move {
60
+ uci: string;
61
+ san: string;
62
+ prob: number;
63
+ }
64
+ export interface LichessMove {
65
+ uci: string;
66
+ san: string;
67
+ white: number;
68
+ draws: number;
69
+ black: number;
70
+ count: number;
71
+ averageRating: number | null;
72
+ }
73
+ export interface Objective {
74
+ rank: number | null;
75
+ moverCp: number | null;
76
+ whiteCp: number | null;
77
+ cpLoss: number | null;
78
+ moverMate: number | null;
79
+ whiteMate: number | null;
80
+ wdl: Wdl | null;
81
+ }
82
+ export interface HumanModel {
83
+ maia3Prob: number | null;
84
+ selfElo: number;
85
+ opponentElo: number;
86
+ }
87
+ type OpeningStatsValues = {
88
+ games: number | null;
89
+ frequency: number | null;
90
+ white: number | null;
91
+ draws: number | null;
92
+ black: number | null;
93
+ averageRating: number | null;
94
+ };
95
+ export type OpeningStats = ({
96
+ status: "available" | "no_data";
97
+ } & OpeningStatsValues) | ({
98
+ status: "unavailable";
99
+ reason: ExplorerErrorKind;
100
+ } & OpeningStatsValues) | ({
101
+ status: "disabled";
102
+ } & OpeningStatsValues);
103
+ export interface Candidate {
104
+ uci: string;
105
+ san: string;
106
+ objective: Objective;
107
+ human: HumanModel;
108
+ opening: OpeningStats;
109
+ }
110
+ export interface MoveSensitivity {
111
+ level: "low" | "medium" | "high";
112
+ topMoveSpreadCp: number | null;
113
+ }
114
+ export {};