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