llm-chess-mcp 0.1.2 → 0.1.3
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/.env.example +9 -0
- package/README.md +21 -11
- package/dist/engines/stockfish.js +264 -59
- package/dist/env.js +2 -1
- package/dist/games.js +36 -9
- package/dist/index.js +91 -28
- package/dist/intents.js +0 -6
- package/dist/maia3/inference.js +13 -5
- package/dist/maia3/tokenize.js +7 -7
- package/package.json +7 -2
package/.env.example
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Lichess opening explorer (optional; explorer tools disabled if unset)
|
|
2
|
+
# Generate at https://lichess.org/account/oauth/token/create
|
|
3
|
+
LICHESS_TOKEN=
|
|
4
|
+
|
|
5
|
+
# The published package bundles only Maia3 5m (~20 MB). Leave at 5m.
|
|
6
|
+
MAIA3_MODEL=5m
|
|
7
|
+
|
|
8
|
+
# Stockfish flavor: lite-single (default, ~7MB) | lite | single | full
|
|
9
|
+
STOCKFISH_FLAVOR=lite-single
|
package/README.md
CHANGED
|
@@ -13,14 +13,18 @@ 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** (ONNX) | Human-like move probabilities conditioned on Elo | In-process (`onnxruntime-node`) |
|
|
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
19
|
Everything runs inside the Node process — no external engine process or Python
|
|
20
|
-
runtime is required at deploy time.
|
|
20
|
+
runtime is required at deploy time. The published package bundles the Maia3 5M
|
|
21
|
+
model; other export variants are not runtime options unless their ONNX files are
|
|
22
|
+
provided separately.
|
|
21
23
|
|
|
22
24
|
## Install
|
|
23
25
|
|
|
26
|
+
Requires Node.js 20 or newer.
|
|
27
|
+
|
|
24
28
|
No install needed — run it directly with `npx`:
|
|
25
29
|
|
|
26
30
|
```bash
|
|
@@ -41,6 +45,7 @@ npm install -g llm-chess-mcp
|
|
|
41
45
|
```bash
|
|
42
46
|
pnpm install
|
|
43
47
|
pnpm build
|
|
48
|
+
pnpm test
|
|
44
49
|
```
|
|
45
50
|
|
|
46
51
|
### Export Maia3 to ONNX (build-time only)
|
|
@@ -51,7 +56,7 @@ the reimplementation against the original, and exports `models/maia3-5m.onnx`.
|
|
|
51
56
|
```bash
|
|
52
57
|
uv venv .venv-maia3 --python 3.13
|
|
53
58
|
uv pip install --python .venv-maia3/bin/python -r scripts/requirements.txt
|
|
54
|
-
uv pip install --python .venv-maia3/bin/python "maia3 @ git+https://github.com/CSSLab/maia3"
|
|
59
|
+
uv pip install --python .venv-maia3/bin/python "maia3 @ git+https://github.com/CSSLab/maia3.git@1e13597c42d4858b7cfd7cfdae01e297263364b2"
|
|
55
60
|
pnpm export:maia3 # -> models/maia3-5m.onnx
|
|
56
61
|
```
|
|
57
62
|
|
|
@@ -84,8 +89,7 @@ Add to `opencode.json` (project) or `~/.config/opencode/opencode.json` (global):
|
|
|
84
89
|
"command": ["npx", "-y", "llm-chess-mcp"],
|
|
85
90
|
"enabled": true,
|
|
86
91
|
"environment": {
|
|
87
|
-
"LICHESS_TOKEN": "your-token"
|
|
88
|
-
"MAIA3_MODEL": "5m"
|
|
92
|
+
"LICHESS_TOKEN": "your-token"
|
|
89
93
|
}
|
|
90
94
|
}
|
|
91
95
|
}
|
|
@@ -107,8 +111,7 @@ claude mcp add llm-chess-mcp -- npx -y llm-chess-mcp
|
|
|
107
111
|
"command": "npx",
|
|
108
112
|
"args": ["-y", "llm-chess-mcp"],
|
|
109
113
|
"env": {
|
|
110
|
-
"LICHESS_TOKEN": "your-token"
|
|
111
|
-
"MAIA3_MODEL": "5m"
|
|
114
|
+
"LICHESS_TOKEN": "your-token"
|
|
112
115
|
}
|
|
113
116
|
}
|
|
114
117
|
}
|
|
@@ -126,7 +129,6 @@ args = ["-y", "llm-chess-mcp"]
|
|
|
126
129
|
|
|
127
130
|
[mcp_servers.llm-chess-mcp.env]
|
|
128
131
|
LICHESS_TOKEN = "your-token"
|
|
129
|
-
MAIA3_MODEL = "5m"
|
|
130
132
|
```
|
|
131
133
|
|
|
132
134
|
Or via the CLI:
|
|
@@ -223,6 +225,13 @@ rejected:
|
|
|
223
225
|
{ "error": { "code": "STALE_POSITION", "message": "position changed: expected revision 2, current 3" } }
|
|
224
226
|
```
|
|
225
227
|
|
|
228
|
+
## Runtime limits
|
|
229
|
+
|
|
230
|
+
- Up to 1,000 game sessions are retained; idle sessions expire after one hour.
|
|
231
|
+
- `move_evaluate` accepts at most 10 moves per call.
|
|
232
|
+
- Imported PGNs are limited to 1 MiB and 4,096 plies.
|
|
233
|
+
- Stockfish accepts up to 32 active or queued analyses.
|
|
234
|
+
|
|
226
235
|
## Intents
|
|
227
236
|
|
|
228
237
|
`move_candidates_by_intent` ranks candidates for a chosen intent. It is a
|
|
@@ -283,6 +292,7 @@ It bundles and depends on third-party components:
|
|
|
283
292
|
| [onnxruntime-node](https://github.com/microsoft/onnxruntime) | MIT | Microsoft |
|
|
284
293
|
| [chess.js](https://github.com/jhlywa/chess.js) | BSD-2-Clause | Jeff Hlywa |
|
|
285
294
|
|
|
286
|
-
The Maia3 model
|
|
287
|
-
`UofTCSSLab/Maia3-5M`
|
|
288
|
-
(`scripts/export_maia3.py`); the runtime
|
|
295
|
+
The bundled Maia3 model (`models/maia3-5m.onnx`) is derived from
|
|
296
|
+
[`UofTCSSLab/Maia3-5M` at `b6559de2398d7140b985f28fd2c19fb5e47ddabe`](https://huggingface.co/UofTCSSLab/Maia3-5M/tree/b6559de2398d7140b985f28fd2c19fb5e47ddabe).
|
|
297
|
+
The ONNX export is a build-time step (`scripts/export_maia3.py`); the runtime
|
|
298
|
+
does not execute any Maia3 Python code.
|
|
@@ -1,7 +1,34 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
const require = createRequire(import.meta.url);
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
export const STOCKFISH_FLAVORS = [
|
|
4
|
+
"full",
|
|
5
|
+
"lite",
|
|
6
|
+
"single",
|
|
7
|
+
"lite-single",
|
|
8
|
+
"single-lite",
|
|
9
|
+
"asm",
|
|
10
|
+
];
|
|
11
|
+
const DEFAULT_FLAVOR = "lite-single";
|
|
12
|
+
const FLAVORS = new Set(STOCKFISH_FLAVORS);
|
|
13
|
+
const DEFAULT_TIMEOUTS = {
|
|
14
|
+
init: 15000,
|
|
15
|
+
handshake: 15000,
|
|
16
|
+
analyze: 30000,
|
|
17
|
+
stopGrace: 2000,
|
|
18
|
+
};
|
|
19
|
+
const DEFAULT_MAX_QUEUE = 32;
|
|
20
|
+
export function resolveStockfishFlavor(value) {
|
|
21
|
+
const normalized = (value || DEFAULT_FLAVOR).toLowerCase();
|
|
22
|
+
if (!FLAVORS.has(normalized)) {
|
|
23
|
+
throw new Error(`invalid STOCKFISH_FLAVOR: ${JSON.stringify(value)}; expected one of ${STOCKFISH_FLAVORS.join(", ")}`);
|
|
24
|
+
}
|
|
25
|
+
return normalized;
|
|
26
|
+
}
|
|
27
|
+
function loadStockfish() {
|
|
28
|
+
return require("stockfish");
|
|
29
|
+
}
|
|
30
|
+
function asError(error) {
|
|
31
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
5
32
|
}
|
|
6
33
|
function parseScore(token) {
|
|
7
34
|
if (token.startsWith("cp"))
|
|
@@ -17,56 +44,176 @@ function parseWdl(line) {
|
|
|
17
44
|
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
18
45
|
}
|
|
19
46
|
export class Stockfish {
|
|
20
|
-
|
|
21
|
-
ready = null;
|
|
47
|
+
session = null;
|
|
22
48
|
queue = Promise.resolve();
|
|
49
|
+
queued = 0;
|
|
50
|
+
quitGeneration = 0;
|
|
51
|
+
terminated = new WeakSet();
|
|
52
|
+
initEngine;
|
|
53
|
+
configuredFlavor;
|
|
54
|
+
maxQueue;
|
|
55
|
+
timeouts;
|
|
56
|
+
constructor(options = {}) {
|
|
57
|
+
this.initEngine = options.init;
|
|
58
|
+
this.configuredFlavor = options.flavor;
|
|
59
|
+
this.maxQueue = options.maxQueue ?? DEFAULT_MAX_QUEUE;
|
|
60
|
+
this.timeouts = { ...DEFAULT_TIMEOUTS, ...options.timeouts };
|
|
61
|
+
if (!Number.isInteger(this.maxQueue) || this.maxQueue < 1) {
|
|
62
|
+
throw new Error("stockfish maxQueue must be a positive integer");
|
|
63
|
+
}
|
|
64
|
+
for (const [name, timeout] of Object.entries(this.timeouts)) {
|
|
65
|
+
if (!Number.isFinite(timeout) || timeout < 1) {
|
|
66
|
+
throw new Error(`stockfish ${name} timeout must be positive`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
23
70
|
init() {
|
|
24
|
-
if (this.
|
|
25
|
-
return this.ready;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
this.handshake().then(resolve, reject);
|
|
32
|
-
});
|
|
33
|
-
this.engine = engine;
|
|
34
|
-
engine.listener = () => { };
|
|
71
|
+
if (this.session)
|
|
72
|
+
return this.session.ready;
|
|
73
|
+
let resolve;
|
|
74
|
+
let reject;
|
|
75
|
+
const ready = new Promise((res, rej) => {
|
|
76
|
+
resolve = res;
|
|
77
|
+
reject = rej;
|
|
35
78
|
});
|
|
36
|
-
|
|
79
|
+
const session = {
|
|
80
|
+
engine: null,
|
|
81
|
+
ready,
|
|
82
|
+
initSettled: false,
|
|
83
|
+
initTimer: null,
|
|
84
|
+
resolve,
|
|
85
|
+
reject,
|
|
86
|
+
aborts: new Set(),
|
|
87
|
+
};
|
|
88
|
+
this.session = session;
|
|
89
|
+
session.initTimer = setTimeout(() => this.failSession(session, new Error("stockfish init timeout")), this.timeouts.init);
|
|
90
|
+
let callbackCalled = false;
|
|
91
|
+
try {
|
|
92
|
+
const selectedFlavor = resolveStockfishFlavor(this.configuredFlavor ?? process.env.STOCKFISH_FLAVOR);
|
|
93
|
+
const engine = (this.initEngine ?? loadStockfish())(selectedFlavor, (error, initializedEngine) => {
|
|
94
|
+
callbackCalled = true;
|
|
95
|
+
if (this.session !== session) {
|
|
96
|
+
this.terminate(initializedEngine);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (session.initSettled) {
|
|
100
|
+
if (initializedEngine !== session.engine)
|
|
101
|
+
this.terminate(initializedEngine);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (session.engine && session.engine !== initializedEngine) {
|
|
105
|
+
this.terminate(session.engine);
|
|
106
|
+
}
|
|
107
|
+
session.engine = initializedEngine;
|
|
108
|
+
if (error) {
|
|
109
|
+
this.failSession(session, asError(error));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
initializedEngine.listener = () => { };
|
|
113
|
+
if (session.initTimer)
|
|
114
|
+
clearTimeout(session.initTimer);
|
|
115
|
+
session.initTimer = null;
|
|
116
|
+
this.handshake(session).then(() => {
|
|
117
|
+
if (this.session !== session || session.initSettled)
|
|
118
|
+
return;
|
|
119
|
+
session.initSettled = true;
|
|
120
|
+
session.resolve();
|
|
121
|
+
}, (handshakeError) => this.failSession(session, asError(handshakeError)));
|
|
122
|
+
});
|
|
123
|
+
if (!callbackCalled && this.session === session && !session.initSettled) {
|
|
124
|
+
session.engine = engine;
|
|
125
|
+
engine.listener = () => { };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
this.failSession(session, asError(error));
|
|
130
|
+
}
|
|
131
|
+
return ready;
|
|
37
132
|
}
|
|
38
|
-
handshake() {
|
|
133
|
+
handshake(session) {
|
|
39
134
|
return new Promise((resolve, reject) => {
|
|
40
|
-
const engine =
|
|
135
|
+
const engine = session.engine;
|
|
136
|
+
if (!engine) {
|
|
137
|
+
reject(new Error("stockfish initialized without an engine"));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
41
140
|
let stage = 0;
|
|
42
|
-
|
|
43
|
-
|
|
141
|
+
let settled = false;
|
|
142
|
+
const finish = (error) => {
|
|
143
|
+
if (settled)
|
|
144
|
+
return;
|
|
145
|
+
settled = true;
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
session.aborts.delete(abort);
|
|
148
|
+
if (engine.listener === listener)
|
|
149
|
+
engine.listener = null;
|
|
150
|
+
if (error)
|
|
151
|
+
reject(error);
|
|
152
|
+
else
|
|
153
|
+
resolve();
|
|
154
|
+
};
|
|
155
|
+
const abort = (error) => finish(error);
|
|
156
|
+
const listener = (line) => {
|
|
44
157
|
if (stage === 0 && line === "uciok") {
|
|
45
158
|
stage = 1;
|
|
46
|
-
|
|
159
|
+
try {
|
|
160
|
+
engine.sendCommand("isready");
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
finish(asError(error));
|
|
164
|
+
}
|
|
47
165
|
}
|
|
48
166
|
else if (stage === 1 && line === "readyok") {
|
|
49
|
-
|
|
50
|
-
engine.listener = null;
|
|
51
|
-
resolve();
|
|
167
|
+
finish();
|
|
52
168
|
}
|
|
53
169
|
};
|
|
54
|
-
|
|
170
|
+
const timer = setTimeout(() => finish(new Error("stockfish handshake timeout")), this.timeouts.handshake);
|
|
171
|
+
session.aborts.add(abort);
|
|
172
|
+
engine.listener = listener;
|
|
173
|
+
try {
|
|
174
|
+
engine.sendCommand("uci");
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
finish(asError(error));
|
|
178
|
+
}
|
|
55
179
|
});
|
|
56
180
|
}
|
|
57
181
|
enqueue(fn) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
182
|
+
if (this.queued >= this.maxQueue) {
|
|
183
|
+
return Promise.reject(new Error("stockfish queue full"));
|
|
184
|
+
}
|
|
185
|
+
this.queued++;
|
|
186
|
+
const run = this.queue.then(fn);
|
|
187
|
+
this.queue = run.then(() => { }, () => { });
|
|
188
|
+
return run.finally(() => {
|
|
189
|
+
this.queued--;
|
|
190
|
+
});
|
|
61
191
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
return this.enqueue(() =>
|
|
192
|
+
analyze(fen, depth, multipv) {
|
|
193
|
+
const quitGeneration = this.quitGeneration;
|
|
194
|
+
return this.enqueue(async () => {
|
|
195
|
+
if (quitGeneration !== this.quitGeneration) {
|
|
196
|
+
throw new Error("stockfish request cancelled");
|
|
197
|
+
}
|
|
198
|
+
await this.init();
|
|
199
|
+
if (quitGeneration !== this.quitGeneration) {
|
|
200
|
+
throw new Error("stockfish request cancelled");
|
|
201
|
+
}
|
|
202
|
+
const session = this.session;
|
|
203
|
+
if (!session)
|
|
204
|
+
throw new Error("stockfish unavailable after initialization");
|
|
205
|
+
return this.doAnalyze(session, fen, depth, multipv);
|
|
206
|
+
});
|
|
65
207
|
}
|
|
66
|
-
doAnalyze(fen, depth, multipv) {
|
|
208
|
+
doAnalyze(session, fen, depth, multipv) {
|
|
67
209
|
return new Promise((resolve, reject) => {
|
|
68
|
-
const engine =
|
|
210
|
+
const engine = session.engine;
|
|
211
|
+
if (!engine) {
|
|
212
|
+
reject(new Error("stockfish engine unavailable"));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
69
215
|
const byPv = new Map();
|
|
216
|
+
let settled = false;
|
|
70
217
|
let stopTimer = null;
|
|
71
218
|
let failTimer = null;
|
|
72
219
|
const cleanup = () => {
|
|
@@ -76,8 +223,28 @@ export class Stockfish {
|
|
|
76
223
|
clearTimeout(failTimer);
|
|
77
224
|
stopTimer = null;
|
|
78
225
|
failTimer = null;
|
|
226
|
+
session.aborts.delete(abort);
|
|
227
|
+
if (engine.listener === listener)
|
|
228
|
+
engine.listener = null;
|
|
79
229
|
};
|
|
80
|
-
|
|
230
|
+
const succeed = () => {
|
|
231
|
+
if (settled)
|
|
232
|
+
return;
|
|
233
|
+
settled = true;
|
|
234
|
+
cleanup();
|
|
235
|
+
resolve([...byPv.values()].sort((a, b) => a.multipv - b.multipv));
|
|
236
|
+
};
|
|
237
|
+
const fail = (error, reset) => {
|
|
238
|
+
if (settled)
|
|
239
|
+
return;
|
|
240
|
+
settled = true;
|
|
241
|
+
cleanup();
|
|
242
|
+
if (reset)
|
|
243
|
+
this.invalidateSession(session, error);
|
|
244
|
+
reject(error);
|
|
245
|
+
};
|
|
246
|
+
const abort = (error) => fail(error, false);
|
|
247
|
+
const listener = (line) => {
|
|
81
248
|
if (line.startsWith("info") && line.includes(" multipv ")) {
|
|
82
249
|
const m = line.match(/multipv (\d+)/);
|
|
83
250
|
const s = line.match(/ score (cp -?\d+|mate -?\d+)/);
|
|
@@ -95,39 +262,77 @@ export class Stockfish {
|
|
|
95
262
|
});
|
|
96
263
|
}
|
|
97
264
|
else if (line.startsWith("bestmove")) {
|
|
98
|
-
|
|
99
|
-
engine.listener = null;
|
|
100
|
-
resolve([...byPv.values()].sort((a, b) => a.multipv - b.multipv));
|
|
265
|
+
succeed();
|
|
101
266
|
}
|
|
102
267
|
};
|
|
103
|
-
|
|
104
|
-
engine.
|
|
105
|
-
engine.sendCommand("setoption name UCI_ShowWDL value true");
|
|
106
|
-
engine.sendCommand(`go depth ${depth}`);
|
|
268
|
+
session.aborts.add(abort);
|
|
269
|
+
engine.listener = listener;
|
|
107
270
|
stopTimer = setTimeout(() => {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}, 30000);
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
reset() {
|
|
120
|
-
if (this.engine) {
|
|
271
|
+
failTimer = setTimeout(() => fail(new Error("stockfish analyze timeout"), true), this.timeouts.stopGrace);
|
|
272
|
+
try {
|
|
273
|
+
engine.sendCommand("stop");
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
fail(asError(error), true);
|
|
277
|
+
}
|
|
278
|
+
}, this.timeouts.analyze);
|
|
121
279
|
try {
|
|
122
|
-
|
|
280
|
+
engine.sendCommand("position fen " + fen);
|
|
281
|
+
engine.sendCommand(`setoption name MultiPV value ${multipv}`);
|
|
282
|
+
engine.sendCommand("setoption name UCI_ShowWDL value true");
|
|
283
|
+
engine.sendCommand(`go depth ${depth}`);
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
fail(asError(error), true);
|
|
287
|
+
return;
|
|
123
288
|
}
|
|
124
|
-
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
failSession(session, error) {
|
|
292
|
+
if (session.initSettled)
|
|
293
|
+
return;
|
|
294
|
+
session.initSettled = true;
|
|
295
|
+
if (session.initTimer)
|
|
296
|
+
clearTimeout(session.initTimer);
|
|
297
|
+
session.initTimer = null;
|
|
298
|
+
this.invalidateSession(session, error);
|
|
299
|
+
session.reject(error);
|
|
300
|
+
}
|
|
301
|
+
invalidateSession(session, error) {
|
|
302
|
+
if (this.session !== session)
|
|
303
|
+
return;
|
|
304
|
+
this.session = null;
|
|
305
|
+
if (session.initTimer)
|
|
306
|
+
clearTimeout(session.initTimer);
|
|
307
|
+
session.initTimer = null;
|
|
308
|
+
if (!session.initSettled) {
|
|
309
|
+
session.initSettled = true;
|
|
310
|
+
session.reject(error);
|
|
311
|
+
}
|
|
312
|
+
const aborts = [...session.aborts];
|
|
313
|
+
session.aborts.clear();
|
|
314
|
+
if (session.engine) {
|
|
315
|
+
session.engine.listener = null;
|
|
316
|
+
this.terminate(session.engine);
|
|
317
|
+
}
|
|
318
|
+
for (const abort of aborts)
|
|
319
|
+
abort(error);
|
|
320
|
+
}
|
|
321
|
+
terminate(engine) {
|
|
322
|
+
if (this.terminated.has(engine))
|
|
323
|
+
return;
|
|
324
|
+
this.terminated.add(engine);
|
|
325
|
+
engine.listener = null;
|
|
326
|
+
try {
|
|
327
|
+
engine.terminate();
|
|
125
328
|
}
|
|
126
|
-
|
|
127
|
-
this.ready = null;
|
|
329
|
+
catch { }
|
|
128
330
|
}
|
|
129
331
|
async quit() {
|
|
130
|
-
this.
|
|
332
|
+
this.quitGeneration++;
|
|
333
|
+
const session = this.session;
|
|
334
|
+
if (session)
|
|
335
|
+
this.invalidateSession(session, new Error("stockfish quit"));
|
|
131
336
|
}
|
|
132
337
|
}
|
|
133
338
|
export const stockfish = new Stockfish();
|
package/dist/env.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
+
const ALLOWED_KEYS = new Set(["LICHESS_TOKEN", "MAIA3_MODEL", "STOCKFISH_FLAVOR"]);
|
|
3
4
|
export function loadEnv(path = ".env") {
|
|
4
5
|
let text;
|
|
5
6
|
try {
|
|
@@ -17,7 +18,7 @@ export function loadEnv(path = ".env") {
|
|
|
17
18
|
continue;
|
|
18
19
|
const key = trimmed.slice(0, eq).trim();
|
|
19
20
|
const value = trimmed.slice(eq + 1).trim();
|
|
20
|
-
if (key && process.env[key] === undefined) {
|
|
21
|
+
if (ALLOWED_KEYS.has(key) && process.env[key] === undefined) {
|
|
21
22
|
process.env[key] = value.replace(/^["']|["']$/g, "");
|
|
22
23
|
}
|
|
23
24
|
}
|
package/dist/games.js
CHANGED
|
@@ -2,36 +2,63 @@ import { Chess } from "chess.js";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { ChessError } from "./errors.js";
|
|
4
4
|
const games = new Map();
|
|
5
|
-
export
|
|
5
|
+
export const MAX_GAMES = 1_000;
|
|
6
|
+
export const GAME_TTL_MS = 60 * 60 * 1_000;
|
|
7
|
+
function isExpired(game, now) {
|
|
8
|
+
return now - game.lastAccessedAt >= GAME_TTL_MS;
|
|
9
|
+
}
|
|
10
|
+
export function cleanupGames(now = Date.now()) {
|
|
11
|
+
let removed = 0;
|
|
12
|
+
for (const [id, game] of games) {
|
|
13
|
+
if (!isExpired(game, now))
|
|
14
|
+
continue;
|
|
15
|
+
games.delete(id);
|
|
16
|
+
removed += 1;
|
|
17
|
+
}
|
|
18
|
+
return removed;
|
|
19
|
+
}
|
|
20
|
+
function storeGame(chess) {
|
|
21
|
+
cleanupGames();
|
|
22
|
+
if (games.size >= MAX_GAMES) {
|
|
23
|
+
throw new ChessError("GAME_LIMIT_REACHED", `game session limit reached: ${MAX_GAMES}`);
|
|
24
|
+
}
|
|
6
25
|
const id = randomUUID();
|
|
7
|
-
const
|
|
8
|
-
games.set(id, { chess, createdAt:
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
games.set(id, { chess, createdAt: now, lastAccessedAt: now, revision: 0 });
|
|
9
28
|
return id;
|
|
10
29
|
}
|
|
30
|
+
export function createGame(fen) {
|
|
31
|
+
const chess = fen ? new Chess(fen) : new Chess();
|
|
32
|
+
return storeGame(chess);
|
|
33
|
+
}
|
|
11
34
|
export function createGameFromChess(chess) {
|
|
12
|
-
|
|
13
|
-
games.set(id, { chess, createdAt: Date.now(), revision: 0 });
|
|
14
|
-
return id;
|
|
35
|
+
return storeGame(chess);
|
|
15
36
|
}
|
|
16
37
|
export function getGame(id) {
|
|
17
38
|
const g = games.get(id);
|
|
18
39
|
if (!g)
|
|
19
40
|
throw new ChessError("GAME_NOT_FOUND", `game not found: ${id}`);
|
|
41
|
+
if (isExpired(g, Date.now())) {
|
|
42
|
+
games.delete(id);
|
|
43
|
+
throw new ChessError("GAME_EXPIRED", `game expired: ${id}`);
|
|
44
|
+
}
|
|
45
|
+
g.lastAccessedAt = Date.now();
|
|
20
46
|
return g;
|
|
21
47
|
}
|
|
22
48
|
export function bumpRevision(id) {
|
|
23
|
-
const g =
|
|
24
|
-
if (!g)
|
|
25
|
-
throw new ChessError("GAME_NOT_FOUND", `game not found: ${id}`);
|
|
49
|
+
const g = getGame(id);
|
|
26
50
|
g.revision += 1;
|
|
27
51
|
return g.revision;
|
|
28
52
|
}
|
|
29
53
|
export function deleteGame(id) {
|
|
54
|
+
cleanupGames();
|
|
30
55
|
return games.delete(id);
|
|
31
56
|
}
|
|
32
57
|
export function listGames() {
|
|
58
|
+
cleanupGames();
|
|
33
59
|
return [...games.keys()];
|
|
34
60
|
}
|
|
35
61
|
export function gameCount() {
|
|
62
|
+
cleanupGames();
|
|
36
63
|
return games.size;
|
|
37
64
|
}
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,9 @@ import { McpServer } from "@modelcontextprotocol/server";
|
|
|
3
3
|
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
4
4
|
import * as z from "zod/v4";
|
|
5
5
|
import { Chess } from "chess.js";
|
|
6
|
+
import { realpathSync } from "node:fs";
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
6
9
|
import { loadEnv } from "./env.js";
|
|
7
10
|
import { createGame, createGameFromChess, getGame, deleteGame, bumpRevision, } from "./games.js";
|
|
8
11
|
import { stockfish } from "./engines/stockfish.js";
|
|
@@ -19,9 +22,54 @@ const INTENTS = [
|
|
|
19
22
|
"ease_off",
|
|
20
23
|
"give_chance",
|
|
21
24
|
];
|
|
25
|
+
const { version: SERVER_VERSION } = createRequire(import.meta.url)("../package.json");
|
|
26
|
+
export const MAX_EVALUATED_MOVES = 10;
|
|
27
|
+
export const MAX_PGN_BYTES = 1024 * 1024;
|
|
28
|
+
export const MAX_PGN_PLIES = 4096;
|
|
22
29
|
function text(data) {
|
|
23
30
|
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
24
31
|
}
|
|
32
|
+
function errorText(data) {
|
|
33
|
+
return { ...text(data), isError: true };
|
|
34
|
+
}
|
|
35
|
+
export function snapshotChess(chess) {
|
|
36
|
+
const history = chess.history({ verbose: true });
|
|
37
|
+
const snapshot = new Chess(history[0]?.before ?? chess.fen());
|
|
38
|
+
for (const move of history) {
|
|
39
|
+
snapshot.move({ from: move.from, to: move.to, promotion: move.promotion });
|
|
40
|
+
}
|
|
41
|
+
return snapshot;
|
|
42
|
+
}
|
|
43
|
+
export function drawResult(chess) {
|
|
44
|
+
if (!chess.isDraw())
|
|
45
|
+
return null;
|
|
46
|
+
if (chess.isStalemate())
|
|
47
|
+
return "stalemate";
|
|
48
|
+
if (chess.isInsufficientMaterial())
|
|
49
|
+
return "insufficient_material";
|
|
50
|
+
if (chess.isThreefoldRepetition())
|
|
51
|
+
return "threefold_repetition";
|
|
52
|
+
if (chess.isDrawByFiftyMoves())
|
|
53
|
+
return "fifty_move_rule";
|
|
54
|
+
return "draw";
|
|
55
|
+
}
|
|
56
|
+
export function parseImportedPgn(pgn) {
|
|
57
|
+
if (Buffer.byteLength(pgn, "utf8") > MAX_PGN_BYTES) {
|
|
58
|
+
throw new ChessError("PGN_TOO_LARGE", `PGN exceeds the ${MAX_PGN_BYTES}-byte limit`);
|
|
59
|
+
}
|
|
60
|
+
let chess;
|
|
61
|
+
try {
|
|
62
|
+
chess = new Chess();
|
|
63
|
+
chess.loadPgn(pgn);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new ChessError("INVALID_PGN", "invalid or illegal PGN");
|
|
67
|
+
}
|
|
68
|
+
if (chess.history().length > MAX_PGN_PLIES) {
|
|
69
|
+
throw new ChessError("PGN_TOO_MANY_MOVES", `PGN exceeds the ${MAX_PGN_PLIES}-ply limit`);
|
|
70
|
+
}
|
|
71
|
+
return chess;
|
|
72
|
+
}
|
|
25
73
|
function stateOf(chess, revision) {
|
|
26
74
|
const last = chess.history({ verbose: true }).at(-1);
|
|
27
75
|
return {
|
|
@@ -65,15 +113,15 @@ function wrap(handler) {
|
|
|
65
113
|
}
|
|
66
114
|
catch (e) {
|
|
67
115
|
if (e instanceof ChessError) {
|
|
68
|
-
return
|
|
116
|
+
return errorText({ error: { code: e.code, message: e.message } });
|
|
69
117
|
}
|
|
70
118
|
const msg = e instanceof Error ? e.message : String(e);
|
|
71
|
-
return
|
|
119
|
+
return errorText({ error: { code: "INTERNAL", message: msg } });
|
|
72
120
|
}
|
|
73
121
|
};
|
|
74
122
|
}
|
|
75
123
|
export function buildServer() {
|
|
76
|
-
const server = new McpServer({ name: "llm-chess-mcp", version:
|
|
124
|
+
const server = new McpServer({ name: "llm-chess-mcp", version: SERVER_VERSION }, { capabilities: { tools: {} } });
|
|
77
125
|
server.registerTool("create_game", {
|
|
78
126
|
description: "Create a new chess game and return its game_id. The server is the authoritative source of board state — never track the board yourself. Optionally pass a FEN to start from a custom position.",
|
|
79
127
|
inputSchema: z.object({
|
|
@@ -84,7 +132,9 @@ export function buildServer() {
|
|
|
84
132
|
try {
|
|
85
133
|
id = createGame(fen);
|
|
86
134
|
}
|
|
87
|
-
catch {
|
|
135
|
+
catch (e) {
|
|
136
|
+
if (e instanceof ChessError)
|
|
137
|
+
throw e;
|
|
88
138
|
throw new ChessError("INVALID_FEN", "invalid FEN");
|
|
89
139
|
}
|
|
90
140
|
return text({ game_id: id, revision: 0 });
|
|
@@ -137,7 +187,7 @@ export function buildServer() {
|
|
|
137
187
|
description: "List all legal moves in the current position (SAN, UCI, piece, capture, promotion).",
|
|
138
188
|
inputSchema: z.object({ game_id: z.string() }),
|
|
139
189
|
}, wrap(async ({ game_id }) => {
|
|
140
|
-
const { chess } = getGame(game_id);
|
|
190
|
+
const { chess, revision } = getGame(game_id);
|
|
141
191
|
const moves = chess.moves({ verbose: true }).map((m) => ({
|
|
142
192
|
san: m.san,
|
|
143
193
|
uci: m.lan,
|
|
@@ -149,7 +199,7 @@ export function buildServer() {
|
|
|
149
199
|
isCapture: m.flags.includes("c"),
|
|
150
200
|
isCheck: m.san.includes("+") || m.san.includes("#"),
|
|
151
201
|
}));
|
|
152
|
-
return text({ game_id, count: moves.length, moves });
|
|
202
|
+
return text({ game_id, revision, count: moves.length, moves });
|
|
153
203
|
}));
|
|
154
204
|
server.registerTool("position_analyze", {
|
|
155
205
|
description: "Run Stockfish on the current position and return the top engine lines (multipv). Scores are from the side-to-move perspective: positive cp = side to move is better; mate N = side to move mates in N. wdl is [win, draw, loss] in permille for the side to move. Use analysis_level (fast/normal/deep) or explicit depth/multipv. Does NOT mutate the game.",
|
|
@@ -160,7 +210,8 @@ export function buildServer() {
|
|
|
160
210
|
multipv: z.number().int().min(1).max(10).optional(),
|
|
161
211
|
}),
|
|
162
212
|
}, wrap(async ({ game_id, analysis_level, depth, multipv }) => {
|
|
163
|
-
const { chess, revision } = getGame(game_id);
|
|
213
|
+
const { chess: live, revision } = getGame(game_id);
|
|
214
|
+
const chess = snapshotChess(live);
|
|
164
215
|
const preset = ANALYSIS_PRESETS[analysis_level];
|
|
165
216
|
const d = depth ?? preset.depth;
|
|
166
217
|
const mpv = multipv ?? preset.multipv;
|
|
@@ -189,7 +240,8 @@ export function buildServer() {
|
|
|
189
240
|
top_n: z.number().int().min(1).max(20).default(5),
|
|
190
241
|
}),
|
|
191
242
|
}, wrap(async ({ game_id, elo, oppo_elo, top_n }) => {
|
|
192
|
-
const { chess, revision } = getGame(game_id);
|
|
243
|
+
const { chess: live, revision } = getGame(game_id);
|
|
244
|
+
const chess = snapshotChess(live);
|
|
193
245
|
const moves = await humanMoveDistribution(chess, elo, oppo_elo ?? elo, top_n);
|
|
194
246
|
return text({ game_id, elo, oppo_elo: oppo_elo ?? elo, revision, moves });
|
|
195
247
|
}));
|
|
@@ -197,11 +249,15 @@ export function buildServer() {
|
|
|
197
249
|
description: "Evaluate one or more moves with Stockfish without mutating the game. Pass a single move string or an array of moves to compare. Returns, for each move, the score after the move (from the mover's perspective), cpLoss vs the best move, and a classification (best/excellent/good/inaccuracy/mistake/blunder).",
|
|
198
250
|
inputSchema: z.object({
|
|
199
251
|
game_id: z.string(),
|
|
200
|
-
move: z.union([
|
|
252
|
+
move: z.union([
|
|
253
|
+
z.string(),
|
|
254
|
+
z.array(z.string()).min(1).max(MAX_EVALUATED_MOVES),
|
|
255
|
+
]),
|
|
201
256
|
depth: z.number().int().min(1).max(30).default(15),
|
|
202
257
|
}),
|
|
203
258
|
}, wrap(async ({ game_id, move, depth }) => {
|
|
204
|
-
const { chess, revision } = getGame(game_id);
|
|
259
|
+
const { chess: live, revision } = getGame(game_id);
|
|
260
|
+
const chess = snapshotChess(live);
|
|
205
261
|
const moves = Array.isArray(move) ? move : [move];
|
|
206
262
|
const beforeLines = await stockfish.analyze(chess.fen(), depth, 1);
|
|
207
263
|
const before = beforeLines[0];
|
|
@@ -210,7 +266,7 @@ export function buildServer() {
|
|
|
210
266
|
const results = [];
|
|
211
267
|
for (const mv of moves) {
|
|
212
268
|
const m = parseMove(chess, mv);
|
|
213
|
-
const copy =
|
|
269
|
+
const copy = snapshotChess(chess);
|
|
214
270
|
copy.move({ from: m.from, to: m.to, promotion: m.promotion });
|
|
215
271
|
if (copy.isCheckmate()) {
|
|
216
272
|
results.push({
|
|
@@ -226,11 +282,12 @@ export function buildServer() {
|
|
|
226
282
|
});
|
|
227
283
|
continue;
|
|
228
284
|
}
|
|
229
|
-
|
|
285
|
+
const result = drawResult(copy);
|
|
286
|
+
if (result) {
|
|
230
287
|
results.push({
|
|
231
288
|
move: m.san,
|
|
232
289
|
uci: m.lan,
|
|
233
|
-
result
|
|
290
|
+
result,
|
|
234
291
|
scoreCp: 0,
|
|
235
292
|
scoreMate: null,
|
|
236
293
|
bestCp: beforeCp,
|
|
@@ -275,7 +332,8 @@ export function buildServer() {
|
|
|
275
332
|
lichess_ratings: z.array(z.number().int()).default([]),
|
|
276
333
|
}),
|
|
277
334
|
}, wrap(async ({ game_id, elo, analysis_level, sf_depth, sf_multipv, maia_top_n, lichess_db, lichess_speeds, lichess_ratings, }) => {
|
|
278
|
-
const { chess, revision } = getGame(game_id);
|
|
335
|
+
const { chess: live, revision } = getGame(game_id);
|
|
336
|
+
const chess = snapshotChess(live);
|
|
279
337
|
const preset = ANALYSIS_PRESETS[analysis_level];
|
|
280
338
|
const d = sf_depth ?? preset.depth;
|
|
281
339
|
const mpv = sf_multipv ?? preset.multipv;
|
|
@@ -306,7 +364,8 @@ export function buildServer() {
|
|
|
306
364
|
lichess_ratings: z.array(z.number().int()).default([]),
|
|
307
365
|
}),
|
|
308
366
|
}, wrap(async ({ game_id, intent, elo, analysis_level, sf_depth, sf_multipv, maia_top_n, lichess_db, lichess_speeds, lichess_ratings, }) => {
|
|
309
|
-
const { chess, revision } = getGame(game_id);
|
|
367
|
+
const { chess: live, revision } = getGame(game_id);
|
|
368
|
+
const chess = snapshotChess(live);
|
|
310
369
|
const preset = ANALYSIS_PRESETS[analysis_level];
|
|
311
370
|
const d = sf_depth ?? preset.depth;
|
|
312
371
|
const mpv = sf_multipv ?? preset.multipv;
|
|
@@ -336,33 +395,37 @@ export function buildServer() {
|
|
|
336
395
|
if (!explorerEnabled()) {
|
|
337
396
|
throw new ChessError("LICHESS_DISABLED", "LICHESS_TOKEN not set; opening explorer is disabled");
|
|
338
397
|
}
|
|
339
|
-
const { chess } = getGame(game_id);
|
|
398
|
+
const { chess: live, revision } = getGame(game_id);
|
|
399
|
+
const chess = snapshotChess(live);
|
|
340
400
|
const result = await openingExplorer(chess, db, speeds, ratings);
|
|
341
|
-
return text({ game_id, ...result });
|
|
401
|
+
return text({ game_id, revision, ...result });
|
|
342
402
|
}));
|
|
343
403
|
server.registerTool("game_pgn", {
|
|
344
404
|
description: "Export the current game as PGN.",
|
|
345
405
|
inputSchema: z.object({ game_id: z.string() }),
|
|
346
406
|
}, wrap(async ({ game_id }) => {
|
|
347
|
-
const { chess } = getGame(game_id);
|
|
348
|
-
return text({ game_id, pgn: chess.pgn() });
|
|
407
|
+
const { chess, revision } = getGame(game_id);
|
|
408
|
+
return text({ game_id, revision, pgn: chess.pgn() });
|
|
349
409
|
}));
|
|
350
410
|
server.registerTool("game_import_pgn", {
|
|
351
411
|
description: "Import a PGN into a new game. Returns a new game_id with the position after all PGN moves. Rejects malformed or illegal PGN.",
|
|
352
412
|
inputSchema: z.object({ pgn: z.string() }),
|
|
353
413
|
}, wrap(async ({ pgn }) => {
|
|
354
|
-
|
|
355
|
-
try {
|
|
356
|
-
chess = new Chess();
|
|
357
|
-
chess.loadPgn(pgn);
|
|
358
|
-
}
|
|
359
|
-
catch {
|
|
360
|
-
throw new ChessError("INVALID_PGN", "invalid or illegal PGN");
|
|
361
|
-
}
|
|
414
|
+
const chess = parseImportedPgn(pgn);
|
|
362
415
|
const id = createGameFromChess(chess);
|
|
363
416
|
return text({ game_id: id, ...stateOf(chess, 0) });
|
|
364
417
|
}));
|
|
365
418
|
return server;
|
|
366
419
|
}
|
|
367
420
|
loadEnv();
|
|
368
|
-
|
|
421
|
+
if (process.argv[1] &&
|
|
422
|
+
import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
423
|
+
const handle = serveStdio(() => buildServer());
|
|
424
|
+
let shutdown = null;
|
|
425
|
+
const close = () => (shutdown ??= Promise.all([stockfish.quit(), handle.close()]).then(() => { }));
|
|
426
|
+
const onClose = () => {
|
|
427
|
+
void close().catch((error) => console.error("shutdown failed", error));
|
|
428
|
+
};
|
|
429
|
+
process.stdin.once("end", onClose);
|
|
430
|
+
process.stdin.once("close", onClose);
|
|
431
|
+
}
|
package/dist/intents.js
CHANGED
|
@@ -186,11 +186,5 @@ export function rankByIntent(candidates, intent) {
|
|
|
186
186
|
.filter((x) => x.score !== -Infinity)
|
|
187
187
|
.sort((a, b) => b.score - a.score)
|
|
188
188
|
.map((x) => x.c);
|
|
189
|
-
if (ranked.length === 0 && (intent === "ease_off" || intent === "give_chance")) {
|
|
190
|
-
const human = candidates.filter((c) => c.human.maia3Prob !== null && c.objective.moverCp !== null);
|
|
191
|
-
return human
|
|
192
|
-
.sort((a, b) => (a.objective.moverCp ?? -Infinity) - (b.objective.moverCp ?? -Infinity))
|
|
193
|
-
.slice(0, 5);
|
|
194
|
-
}
|
|
195
189
|
return ranked;
|
|
196
190
|
}
|
package/dist/maia3/inference.js
CHANGED
|
@@ -5,29 +5,37 @@ import { dirname, resolve } from "node:path";
|
|
|
5
5
|
import { buildInput } from "./tokenize.js";
|
|
6
6
|
import { vocabIndex } from "./vocab.js";
|
|
7
7
|
import { mirrorMove } from "./mirror.js";
|
|
8
|
-
const MODEL_KEY = process.env.MAIA3_MODEL || "5m";
|
|
9
8
|
let session = null;
|
|
10
9
|
let sessionPromise = null;
|
|
10
|
+
const MODEL_KEYS = new Set(["3m", "5m", "23m", "79m"]);
|
|
11
11
|
function modelPath() {
|
|
12
|
+
const modelKey = process.env.MAIA3_MODEL || "5m";
|
|
13
|
+
if (!MODEL_KEYS.has(modelKey))
|
|
14
|
+
throw new Error(`unsupported Maia3 model: ${modelKey}`);
|
|
12
15
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
13
16
|
const candidates = [
|
|
14
|
-
resolve(here, "../../models", `maia3-${
|
|
15
|
-
resolve(process.cwd(), "models", `maia3-${
|
|
17
|
+
resolve(here, "../../models", `maia3-${modelKey}.onnx`),
|
|
18
|
+
resolve(process.cwd(), "models", `maia3-${modelKey}.onnx`),
|
|
16
19
|
];
|
|
17
20
|
for (const c of candidates) {
|
|
18
21
|
if (existsSync(c))
|
|
19
22
|
return c;
|
|
20
23
|
}
|
|
21
|
-
throw new Error(`maia3 model not found (models/maia3-${
|
|
24
|
+
throw new Error(`maia3 model not found (models/maia3-${modelKey}.onnx). Run \`pnpm export:maia3\` first.`);
|
|
22
25
|
}
|
|
23
26
|
async function getSession() {
|
|
24
27
|
if (session)
|
|
25
28
|
return session;
|
|
26
29
|
if (sessionPromise)
|
|
27
30
|
return sessionPromise;
|
|
28
|
-
sessionPromise = ort.InferenceSession.create(modelPath())
|
|
31
|
+
sessionPromise = ort.InferenceSession.create(modelPath())
|
|
32
|
+
.then((s) => {
|
|
29
33
|
session = s;
|
|
30
34
|
return s;
|
|
35
|
+
})
|
|
36
|
+
.catch((error) => {
|
|
37
|
+
sessionPromise = null;
|
|
38
|
+
throw error;
|
|
31
39
|
});
|
|
32
40
|
return sessionPromise;
|
|
33
41
|
}
|
package/dist/maia3/tokenize.js
CHANGED
|
@@ -28,13 +28,13 @@ function tokenizeBoard(chess) {
|
|
|
28
28
|
}
|
|
29
29
|
function historyPositions(chess) {
|
|
30
30
|
const moves = chess.history({ verbose: true });
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return
|
|
31
|
+
if (moves.length >= HISTORY)
|
|
32
|
+
return moves.slice(-HISTORY).map((move) => new Chess(move.after));
|
|
33
|
+
if (moves.length)
|
|
34
|
+
return [new Chess(moves[0].before), ...moves.map((move) => new Chess(move.after))];
|
|
35
|
+
const headers = chess.getHeaders();
|
|
36
|
+
const initialFen = headers.SetUp === "1" && headers.FEN ? headers.FEN : chess.fen();
|
|
37
|
+
return [new Chess(initialFen)];
|
|
38
38
|
}
|
|
39
39
|
export function buildInput(chess) {
|
|
40
40
|
const positions = historyPositions(chess);
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "llm-chess-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "MCP server that lets an LLM analyze, judge, and choose chess moves (Stockfish + Maia3 + Lichess)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "AGPL-3.0",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20"
|
|
9
|
+
},
|
|
7
10
|
"bin": {
|
|
8
11
|
"llm-chess-mcp": "dist/index.js"
|
|
9
12
|
},
|
|
@@ -11,12 +14,14 @@
|
|
|
11
14
|
"dist",
|
|
12
15
|
"models",
|
|
13
16
|
"LICENSE",
|
|
14
|
-
"README.md"
|
|
17
|
+
"README.md",
|
|
18
|
+
".env.example"
|
|
15
19
|
],
|
|
16
20
|
"scripts": {
|
|
17
21
|
"build": "tsc",
|
|
18
22
|
"dev": "tsx src/index.ts",
|
|
19
23
|
"start": "node dist/index.js",
|
|
24
|
+
"test": "tsx --test tests/*.test.ts",
|
|
20
25
|
"export:maia3": "python scripts/export_maia3.py"
|
|
21
26
|
},
|
|
22
27
|
"dependencies": {
|