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.
- package/README.md +48 -134
- package/dist/chess-copy.d.ts +4 -0
- package/dist/chess-copy.js +126 -0
- package/dist/chess.d.ts +13 -0
- package/dist/chess.js +13 -34
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +18 -9
- package/dist/domain.d.ts +114 -0
- package/dist/domain.js +43 -0
- package/dist/engines/stockfish-info.d.ts +14 -0
- package/dist/engines/stockfish-info.js +68 -0
- package/dist/engines/stockfish.d.ts +60 -0
- package/dist/engines/stockfish.js +260 -86
- package/dist/env.d.ts +1 -0
- package/dist/errors.d.ts +5 -0
- package/dist/eval.d.ts +25 -0
- package/dist/eval.js +1 -0
- package/dist/explorer-core.d.ts +33 -0
- package/dist/explorer-core.js +65 -0
- package/dist/explorer-limiter.d.ts +12 -0
- package/dist/explorer-limiter.js +84 -0
- package/dist/explorer-response.d.ts +8 -0
- package/dist/explorer-response.js +145 -0
- package/dist/explorer-retry.d.ts +20 -0
- package/dist/explorer-retry.js +134 -0
- package/dist/explorer-transport.d.ts +21 -0
- package/dist/explorer-transport.js +68 -0
- package/dist/explorer.d.ts +33 -0
- package/dist/explorer.js +78 -220
- package/dist/games.d.ts +33 -0
- package/dist/games.js +13 -26
- package/dist/http-config.d.ts +8 -0
- package/dist/http-config.js +62 -0
- package/dist/http-posts.d.ts +13 -0
- package/dist/http-posts.js +29 -0
- package/dist/http-sessions.d.ts +23 -0
- package/dist/http-sessions.js +122 -0
- package/dist/http-work.d.ts +11 -0
- package/dist/http-work.js +31 -12
- package/dist/http.d.ts +32 -0
- package/dist/http.js +269 -219
- package/dist/index.d.ts +6 -0
- package/dist/index.js +39 -20
- package/dist/intent-ranking.d.ts +2 -0
- package/dist/intent-ranking.js +72 -0
- package/dist/intents.d.ts +39 -0
- package/dist/intents.js +86 -105
- package/dist/maia3/inference.d.ts +7 -0
- package/dist/maia3/inference.js +2 -0
- package/dist/maia3/mirror.d.ts +3 -0
- package/dist/maia3/tokenize.d.ts +5 -0
- package/dist/maia3/vocab.d.ts +3 -0
- package/dist/pgn.d.ts +5 -0
- package/dist/pgn.js +185 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +20 -2
- package/dist/services.d.ts +35 -0
- package/dist/services.js +37 -3
- package/dist/tool-inputs.d.ts +257 -0
- package/dist/tool-inputs.js +39 -32
- package/dist/tool-meta.d.ts +141 -0
- package/dist/tool-names.d.ts +2 -0
- package/dist/tool-result.d.ts +15 -0
- package/dist/tool-result.js +8 -3
- package/dist/tool-schemas.d.ts +984 -0
- package/dist/tool-schemas.js +21 -29
- package/dist/tools/analysis.d.ts +5 -0
- package/dist/tools/analysis.js +8 -12
- package/dist/tools/candidates.d.ts +5 -0
- package/dist/tools/candidates.js +29 -13
- package/dist/tools/explorer.d.ts +5 -0
- package/dist/tools/explorer.js +5 -4
- package/dist/tools/game.d.ts +5 -0
- package/dist/tools/game.js +25 -17
- package/dist/types.d.ts +1 -0
- package/dist/types.js +1 -8
- package/docs/architecture.md +71 -22
- 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
|
|
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
|
-
##
|
|
24
|
+
## Build from source
|
|
25
25
|
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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.
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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,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
ADDED
|
@@ -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
|
|
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
|
|
75
|
-
|
|
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 =
|
|
26
|
-
let port =
|
|
27
|
-
let path =
|
|
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 (!
|
|
93
|
+
if (!isCanonicalHttpPath(path)) {
|
|
93
94
|
throw new Error("--path must be an absolute URL path without query or fragment");
|
|
94
95
|
}
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
103
|
-
|
|
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 {
|
|
108
|
+
return {
|
|
109
|
+
transport,
|
|
110
|
+
host,
|
|
111
|
+
port,
|
|
112
|
+
path,
|
|
113
|
+
allowedHosts: canonicalAllowedHosts,
|
|
114
|
+
help,
|
|
115
|
+
};
|
|
107
116
|
}
|
package/dist/domain.d.ts
ADDED
|
@@ -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 {};
|