llm-chess-mcp 0.4.9 → 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 +143 -11
- 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 -123
- 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,10 +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
|
};
|
|
9
|
+
const CHESS_STATE_KEYS = Reflect.ownKeys(new Chess());
|
|
8
10
|
function squareColor(square) {
|
|
9
11
|
return ((square.charCodeAt(0) - 97 + Number(square[1])) % 2);
|
|
10
12
|
}
|
|
@@ -38,6 +40,32 @@ function minimumPawnCaptures(chess, color) {
|
|
|
38
40
|
function nonKingMaterial(chess, color) {
|
|
39
41
|
return ["p", "q", "r", "b", "n"].reduce((total, type) => total + chess.findPiece({ type, color }).length, 0);
|
|
40
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
|
+
}
|
|
41
69
|
function hasPiece(chess, square, type, color) {
|
|
42
70
|
const piece = chess.get(square);
|
|
43
71
|
return piece?.type === type && piece.color === color;
|
|
@@ -123,11 +151,8 @@ export function assertLegalPosition(chess) {
|
|
|
123
151
|
}
|
|
124
152
|
assertCastlingPosition(chess, color);
|
|
125
153
|
const opponent = color === "w" ? "b" : "w";
|
|
126
|
-
const opponentPawns = chess.findPiece({ type: "p", color: opponent }).length;
|
|
127
154
|
const missingOpponentMaterial = 15 - nonKingMaterial(chess, opponent);
|
|
128
|
-
|
|
129
|
-
if (minimumPawnCaptures(chess, color) >
|
|
130
|
-
missingOpponentMaterial + possibleOpponentPromotions) {
|
|
155
|
+
if (minimumPawnCaptures(chess, color) > missingOpponentMaterial) {
|
|
131
156
|
throw new ChessError("INVALID_FEN", "FEN pawn files require more captures than opposing material allows");
|
|
132
157
|
}
|
|
133
158
|
}
|
|
@@ -138,22 +163,103 @@ export function assertLegalPosition(chess) {
|
|
|
138
163
|
if (previousKing && chess.isAttacked(previousKing, turn)) {
|
|
139
164
|
throw new ChessError("INVALID_FEN", "FEN cannot leave the side that just moved in check");
|
|
140
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 };
|
|
141
228
|
}
|
|
142
229
|
export function snapshotChess(chess) {
|
|
143
230
|
assertLegalPosition(chess);
|
|
144
|
-
const history
|
|
145
|
-
|
|
231
|
+
const { history, initialFen, shadow, sourceHeaders } = validatedHistory(chess);
|
|
232
|
+
assertPgnPlyLimit(history.length);
|
|
146
233
|
assertSafeFenCounters(initialFen);
|
|
147
234
|
const snapshot = new Chess(initialFen);
|
|
148
235
|
assertLegalPosition(snapshot);
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
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 }) => [
|
|
241
|
+
fen,
|
|
242
|
+
/[{}]/.test(comment) ? comment.replace(/[\r\n]+/g, " ") : comment,
|
|
243
|
+
]));
|
|
244
|
+
const unsafeComments = [...comments.values()].some((comment) => /[{}]/.test(comment));
|
|
245
|
+
let markerPrefix = "\uE000";
|
|
246
|
+
if (unsafeComments) {
|
|
247
|
+
const occupied = [...sourceHeaders.flat(), ...comments.values()].join("\u0000");
|
|
248
|
+
while (occupied.includes(markerPrefix))
|
|
249
|
+
markerPrefix += "\uE001";
|
|
152
250
|
}
|
|
251
|
+
const markerComments = [];
|
|
153
252
|
const restoreComment = () => {
|
|
154
253
|
const comment = comments.get(snapshot.fen());
|
|
155
|
-
if (comment
|
|
254
|
+
if (comment === undefined)
|
|
255
|
+
return;
|
|
256
|
+
if (!unsafeComments || !/[{}]/.test(comment)) {
|
|
156
257
|
snapshot.setComment(comment);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const marker = `${markerPrefix}${markerComments.length}${markerPrefix}`;
|
|
261
|
+
markerComments.push(comment);
|
|
262
|
+
snapshot.setComment(marker);
|
|
157
263
|
};
|
|
158
264
|
restoreComment();
|
|
159
265
|
for (const move of history) {
|
|
@@ -161,5 +267,31 @@ export function snapshotChess(chess) {
|
|
|
161
267
|
restoreComment();
|
|
162
268
|
}
|
|
163
269
|
assertSafeFenCounters(snapshot.fen());
|
|
164
|
-
|
|
270
|
+
if (!unsafeComments) {
|
|
271
|
+
replacePgnHeaders(snapshot, sourceHeaders, { removeMissing: true });
|
|
272
|
+
return snapshot;
|
|
273
|
+
}
|
|
274
|
+
const escapedPrefix = markerPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
275
|
+
const marker = new RegExp(`\\{${escapedPrefix}(\\d+)${escapedPrefix}\\}`, "g");
|
|
276
|
+
const pgn = snapshot.pgn().replace(marker, (_match, index) => {
|
|
277
|
+
return `;${markerComments[Number(index)]}\n`;
|
|
278
|
+
});
|
|
279
|
+
const restored = new Chess();
|
|
280
|
+
restored.loadPgn(pgn);
|
|
281
|
+
const restoredHistory = restored.history({ verbose: true });
|
|
282
|
+
while (restored.undo()) { }
|
|
283
|
+
const restoreSafeComment = () => {
|
|
284
|
+
const comment = comments.get(restored.fen());
|
|
285
|
+
if (comment !== undefined && !/[{}]/.test(comment)) {
|
|
286
|
+
restored.setComment(comment);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
restoreSafeComment();
|
|
290
|
+
for (const move of restoredHistory) {
|
|
291
|
+
restored.move(moveDescriptor(move));
|
|
292
|
+
restoreSafeComment();
|
|
293
|
+
}
|
|
294
|
+
replacePgnHeaders(restored, sourceHeaders, { removeMissing: true });
|
|
295
|
+
assertSafeFenCounters(restored.fen());
|
|
296
|
+
return restored;
|
|
165
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;
|