llm-chess-mcp 0.4.0 → 0.4.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/README.md CHANGED
@@ -98,6 +98,80 @@ does not provide authentication or TLS; use a trusted network or an
98
98
  authenticated reverse proxy when exposing it beyond localhost. Origin values
99
99
  are validated when present, but the server does not emit browser CORS headers.
100
100
 
101
+ ### Reverse-proxy deployment
102
+
103
+ The HTTP server is intended to run behind a reverse proxy for any non-local
104
+ deployment. The proxy owns TLS termination, client authentication, external
105
+ rate/connection limits, and any future CORS policy. Bind this process to
106
+ localhost only; never expose its port directly through a firewall, container
107
+ port mapping, or load balancer.
108
+
109
+ For example, start the backend with the public hostname that Nginx will pass
110
+ through as `Host`:
111
+
112
+ ```bash
113
+ node dist/index.js --transport http --host 127.0.0.1 --port 3000 \
114
+ --allowed-host chess-mcp.example.com
115
+ ```
116
+
117
+ This is a minimal Nginx layout. It assumes an identity-aware auth service is
118
+ available only on localhost at `127.0.0.1:4180`; configure that service and
119
+ the certificate paths for the deployment. The limits are examples, not a
120
+ substitute for capacity planning.
121
+
122
+ ```nginx
123
+ limit_req_zone $binary_remote_addr zone=mcp_req:10m rate=5r/s;
124
+ limit_conn_zone $binary_remote_addr zone=mcp_conn:10m;
125
+
126
+ server {
127
+ listen 443 ssl;
128
+ server_name chess-mcp.example.com;
129
+ ssl_certificate /etc/ssl/certs/chess-mcp.pem;
130
+ ssl_certificate_key /etc/ssl/private/chess-mcp.key;
131
+
132
+ location = /_mcp_auth {
133
+ internal;
134
+ proxy_pass http://127.0.0.1:4180/auth;
135
+ proxy_pass_request_body off;
136
+ proxy_set_header Content-Length "";
137
+ proxy_set_header X-Original-Method $request_method;
138
+ proxy_set_header X-Original-URI $request_uri;
139
+ }
140
+
141
+ location = /mcp {
142
+ auth_request /_mcp_auth;
143
+ limit_req zone=mcp_req burst=20 nodelay;
144
+ limit_conn mcp_conn 10;
145
+ client_max_body_size 2m;
146
+
147
+ proxy_pass http://127.0.0.1:3000;
148
+ proxy_http_version 1.1;
149
+ proxy_set_header Connection "";
150
+ proxy_set_header Host $host;
151
+ proxy_set_header X-Forwarded-Proto https;
152
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
153
+ proxy_set_header X-Forwarded-User "";
154
+ proxy_set_header X-Forwarded-Email "";
155
+ proxy_buffering off;
156
+ proxy_read_timeout 90s;
157
+
158
+ # Intentionally no Access-Control-Allow-* headers: browser CORS is unsupported.
159
+ }
160
+ }
161
+ ```
162
+
163
+ The application does not trust forwarded identity headers and does not assign
164
+ games to authenticated users. All games in one process share one `GameStore`;
165
+ the opaque `game_id` is the capability to operate a game within the trusted
166
+ deployment, not an OAuth token or user identity. Do not disclose it across
167
+ trust boundaries.
168
+
169
+ This server does not implement MCP OAuth discovery, bearer-token validation,
170
+ or browser CORS. A proxy may authenticate access to the endpoint, but that is
171
+ deployment policy rather than an application-level identity or ownership
172
+ model. Browser clients are unsupported unless a proxy deliberately adds and
173
+ maintains the required CORS policy.
174
+
101
175
  ### Export Maia3 to ONNX (build-time only)
102
176
 
103
177
  This step needs Python + PyTorch once. It downloads the Maia3 checkpoint, verifies
@@ -205,7 +279,7 @@ codex mcp add llm-chess-mcp --command npx --args -y llm-chess-mcp --env LICHESS_
205
279
  | `game_legal_moves` | All legal moves with metadata |
206
280
  | `game_pgn` | Export the game as PGN |
207
281
  | `game_import_pgn` | Import a PGN into a new game |
208
- | `position_analyze` | Stockfish multipv lines (cp/mate/WDL + PV), `analysis_level` preset |
282
+ | `position_analyze` | Stockfish multipv lines (cp/mate/WDL + UCI/SAN PV), `analysis_level` preset |
209
283
  | `human_move_distribution` | Maia3 human-move probabilities at a target Elo |
210
284
  | `move_evaluate` | Score one or more moves + cpLoss + classification |
211
285
  | `move_candidates` | **Primary tool**: unified candidates (objective + human + opening) |
@@ -233,6 +307,9 @@ human-readable summary and must not be parsed as data.
233
307
  `best / excellent / good / inaccuracy / mistake / blunder`.
234
308
  - `maia3Prob` is a **human-likelihood**, not move quality. A high-probability move
235
309
  can still be objectively bad.
310
+ - Analysis continuations return `pv` in UCI and the same legal prefix in
311
+ `pvSan` as SAN. If an engine line contains an invalid move, `pvSan` stops
312
+ before it while the original `pv` remains unchanged.
236
313
 
237
314
  ## Candidate structure
238
315
 
@@ -292,10 +369,27 @@ rejected:
292
369
 
293
370
  ## Runtime limits
294
371
 
295
- - Up to 1,000 game sessions are retained; idle sessions expire after one hour.
372
+ - Up to 1,000 games are retained per process; idle games expire after one hour.
296
373
  - `move_evaluate` accepts at most 10 moves per call.
297
374
  - Imported PGNs are limited to 1 MiB and 4,096 plies.
298
375
  - Stockfish accepts up to 32 active or queued analyses.
376
+ - HTTP retains at most 64 MCP sessions; sessions with no POST activity expire
377
+ after 30 minutes.
378
+ - HTTP accepts bodies up to 2 MiB. It permits 16 concurrent POSTs and downstream
379
+ compute/network jobs process-wide, with two of each per session. Work keeps
380
+ its slot after a raw disconnect until it settles. HTTP also caps connections
381
+ at 128 and applies bounded header, upload, socket, and keep-alive timeouts.
382
+
383
+ Programmatic users can override the HTTP limits through `HttpServerOptions`.
384
+ These safeguards do not replace public-edge quotas: a public deployment must
385
+ still enforce request, connection, and authentication limits at the reverse
386
+ proxy.
387
+
388
+ MCP cancellation notifications, session deletion, and server shutdown propagate
389
+ to Stockfish, Maia, and Lichess work. Stockfish stops safely at its UCI queue
390
+ boundary; Lichess fetch and retry waits abort immediately. ONNX Runtime cannot
391
+ interrupt an inference already executing, so Maia discards its result after the
392
+ native call returns. A raw HTTP disconnect alone is not a cancellation signal.
299
393
 
300
394
  ## Intents
301
395
 
package/dist/chess.js CHANGED
@@ -81,3 +81,20 @@ export function parseMove(chess, move) {
81
81
  export function playParsedMove(chess, move) {
82
82
  return chess.move(moveDescriptor(move));
83
83
  }
84
+ export function pvToSan(chess, pv) {
85
+ const copy = new Chess(chess.fen());
86
+ const san = [];
87
+ for (const uci of pv) {
88
+ try {
89
+ const move = parseMove(copy, uci);
90
+ san.push(move.san);
91
+ playParsedMove(copy, move);
92
+ }
93
+ catch (error) {
94
+ if (!(error instanceof ChessError))
95
+ throw error;
96
+ break;
97
+ }
98
+ }
99
+ return san;
100
+ }
@@ -30,6 +30,9 @@ function loadStockfish() {
30
30
  function asError(error) {
31
31
  return error instanceof Error ? error : new Error(String(error));
32
32
  }
33
+ function abortError(signal) {
34
+ return asError(signal.reason ?? "stockfish request cancelled");
35
+ }
33
36
  function parseScore(token) {
34
37
  if (token.startsWith("cp"))
35
38
  return { cp: Number(token.slice(2)), mate: null };
@@ -178,34 +181,104 @@ export class Stockfish {
178
181
  }
179
182
  });
180
183
  }
181
- enqueue(fn) {
184
+ release(request) {
185
+ if (request.released)
186
+ return;
187
+ request.released = true;
188
+ this.queued--;
189
+ if (request.signal && request.abortListener) {
190
+ request.signal.removeEventListener("abort", request.abortListener);
191
+ }
192
+ request.abortListener = null;
193
+ }
194
+ enqueue(request, fn) {
182
195
  if (this.queued >= this.maxQueue) {
183
- return Promise.reject(new Error("stockfish queue full"));
196
+ return false;
184
197
  }
185
198
  this.queued++;
186
199
  const run = this.queue.then(fn);
187
200
  this.queue = run.then(() => { }, () => { });
188
- return run.finally(() => {
189
- this.queued--;
190
- });
201
+ void run.finally(() => this.release(request));
202
+ return true;
191
203
  }
192
- analyze(fen, depth, multipv) {
204
+ analyze(fen, depth, multipv, signal) {
205
+ if (signal?.aborted) {
206
+ return Promise.reject(abortError(signal));
207
+ }
193
208
  const quitGeneration = this.quitGeneration;
194
- return this.enqueue(async () => {
195
- if (quitGeneration !== this.quitGeneration) {
196
- throw new Error("stockfish request cancelled");
209
+ let request;
210
+ const result = new Promise((resolve, reject) => {
211
+ request = {
212
+ cancelled: false,
213
+ cancellation: null,
214
+ started: false,
215
+ released: false,
216
+ signal,
217
+ abortListener: null,
218
+ stop: null,
219
+ resolve,
220
+ reject,
221
+ };
222
+ });
223
+ const cancel = () => {
224
+ if (request.cancelled)
225
+ return;
226
+ const error = request.signal
227
+ ? abortError(request.signal)
228
+ : new Error("stockfish request cancelled");
229
+ request.cancelled = true;
230
+ request.cancellation = error;
231
+ if (request.started) {
232
+ request.stop?.(error);
197
233
  }
198
- await this.init();
199
- if (quitGeneration !== this.quitGeneration) {
200
- throw new Error("stockfish request cancelled");
234
+ else {
235
+ request.reject(error);
236
+ this.release(request);
201
237
  }
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
- });
238
+ };
239
+ request.abortListener = cancel;
240
+ if (!this.enqueue(request, async () => {
241
+ if (request.cancelled)
242
+ return;
243
+ request.started = true;
244
+ try {
245
+ if (quitGeneration !== this.quitGeneration) {
246
+ throw new Error("stockfish request cancelled");
247
+ }
248
+ await this.init();
249
+ if (request.cancelled || quitGeneration !== this.quitGeneration) {
250
+ if (!request.cancelled) {
251
+ throw new Error("stockfish request cancelled");
252
+ }
253
+ return;
254
+ }
255
+ const session = this.session;
256
+ if (!session)
257
+ throw new Error("stockfish unavailable after initialization");
258
+ const lines = await this.doAnalyze(session, fen, depth, multipv, (stop) => {
259
+ request.stop = stop;
260
+ });
261
+ if (!request.cancelled)
262
+ request.resolve(lines);
263
+ }
264
+ catch (error) {
265
+ if (!request.cancelled)
266
+ request.reject(asError(error));
267
+ }
268
+ finally {
269
+ request.stop = null;
270
+ if (request.cancellation)
271
+ request.reject(request.cancellation);
272
+ }
273
+ })) {
274
+ return Promise.reject(new Error("stockfish queue full"));
275
+ }
276
+ signal?.addEventListener("abort", cancel, { once: true });
277
+ if (signal?.aborted)
278
+ cancel();
279
+ return result;
207
280
  }
208
- doAnalyze(session, fen, depth, multipv) {
281
+ doAnalyze(session, fen, depth, multipv, setStop) {
209
282
  return new Promise((resolve, reject) => {
210
283
  const engine = session.engine;
211
284
  if (!engine) {
@@ -214,6 +287,8 @@ export class Stockfish {
214
287
  }
215
288
  const byPv = new Map();
216
289
  let settled = false;
290
+ let cancellation = null;
291
+ let stopSent = false;
217
292
  let stopTimer = null;
218
293
  let failTimer = null;
219
294
  const cleanup = () => {
@@ -223,6 +298,7 @@ export class Stockfish {
223
298
  clearTimeout(failTimer);
224
299
  stopTimer = null;
225
300
  failTimer = null;
301
+ setStop(null);
226
302
  session.aborts.delete(abort);
227
303
  if (engine.listener === listener)
228
304
  engine.listener = null;
@@ -244,6 +320,23 @@ export class Stockfish {
244
320
  reject(error);
245
321
  };
246
322
  const abort = (error) => fail(error, false);
323
+ const stop = (error, cancelled) => {
324
+ if (cancelled)
325
+ cancellation ??= error;
326
+ if (stopSent)
327
+ return;
328
+ stopSent = true;
329
+ if (stopTimer)
330
+ clearTimeout(stopTimer);
331
+ stopTimer = null;
332
+ failTimer = setTimeout(() => fail(cancellation ?? error, true), this.timeouts.stopGrace);
333
+ try {
334
+ engine.sendCommand("stop");
335
+ }
336
+ catch (sendError) {
337
+ fail(asError(sendError), true);
338
+ }
339
+ };
247
340
  const listener = (line) => {
248
341
  if (line.startsWith("info") && line.includes(" multipv ")) {
249
342
  const multipv = line.match(/multipv (?<value>\d+)/)?.groups?.value;
@@ -268,19 +361,17 @@ export class Stockfish {
268
361
  });
269
362
  }
270
363
  else if (line.startsWith("bestmove")) {
271
- succeed();
364
+ if (cancellation)
365
+ fail(cancellation, false);
366
+ else
367
+ succeed();
272
368
  }
273
369
  };
274
370
  session.aborts.add(abort);
275
371
  engine.listener = listener;
372
+ setStop((error) => stop(error, true));
276
373
  stopTimer = setTimeout(() => {
277
- failTimer = setTimeout(() => fail(new Error("stockfish analyze timeout"), true), this.timeouts.stopGrace);
278
- try {
279
- engine.sendCommand("stop");
280
- }
281
- catch (error) {
282
- fail(asError(error), true);
283
- }
374
+ stop(new Error("stockfish analyze timeout"), false);
284
375
  }, this.timeouts.analyze);
285
376
  try {
286
377
  engine.sendCommand("position fen " + fen);
package/dist/explorer.js CHANGED
@@ -105,7 +105,32 @@ function isRetryable(kind) {
105
105
  kind === "rate_limited" ||
106
106
  kind === "upstream");
107
107
  }
108
+ function throwIfAborted(signal) {
109
+ signal?.throwIfAborted();
110
+ }
111
+ async function sleepWithSignal(sleep, ms, signal) {
112
+ throwIfAborted(signal);
113
+ if (!signal)
114
+ return sleep(ms);
115
+ await new Promise((resolve, reject) => {
116
+ const onAbort = () => {
117
+ cleanup();
118
+ reject(signal.reason);
119
+ };
120
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
121
+ signal.addEventListener("abort", onAbort, { once: true });
122
+ sleep(ms).then(() => {
123
+ cleanup();
124
+ resolve();
125
+ }, (cause) => {
126
+ cleanup();
127
+ reject(cause);
128
+ });
129
+ });
130
+ }
108
131
  export async function openingExplorer(chess, db, speeds, ratings, options = {}) {
132
+ const callerSignal = options.signal;
133
+ throwIfAborted(callerSignal);
109
134
  const token = options.token ?? process.env.LICHESS_TOKEN ?? "";
110
135
  if (!token)
111
136
  throw error("disabled");
@@ -136,10 +161,12 @@ export async function openingExplorer(chess, db, speeds, ratings, options = {})
136
161
  const legalMoves = new Map(chess.moves({ verbose: true }).map((move) => [move.lan, move.san]));
137
162
  let lastError = error("network");
138
163
  for (let attempt = 0; attempt < EXPLORER_MAX_ATTEMPTS; attempt += 1) {
164
+ throwIfAborted(callerSignal);
139
165
  const remaining = deadline - now();
140
166
  if (remaining <= 0)
141
167
  throw error("timeout");
142
- const signal = timeout(Math.max(1, Math.min(EXPLORER_ATTEMPT_TIMEOUT_MS, remaining)));
168
+ const attemptSignal = timeout(Math.max(1, Math.min(EXPLORER_ATTEMPT_TIMEOUT_MS, remaining)));
169
+ const signal = AbortSignal.any(callerSignal ? [callerSignal, attemptSignal] : [attemptSignal]);
143
170
  let response;
144
171
  try {
145
172
  response = await request(url, {
@@ -148,13 +175,15 @@ export async function openingExplorer(chess, db, speeds, ratings, options = {})
148
175
  });
149
176
  }
150
177
  catch {
178
+ throwIfAborted(callerSignal);
151
179
  lastError = error(signal.aborted ? "timeout" : "network");
152
180
  if (attempt + 1 >= EXPLORER_MAX_ATTEMPTS)
153
181
  throw lastError;
154
182
  const delay = Math.min(EXPLORER_DEFAULT_RETRY_DELAY_MS, Math.max(0, deadline - now()));
155
- await sleep(delay);
183
+ await sleepWithSignal(sleep, delay, callerSignal);
156
184
  continue;
157
185
  }
186
+ throwIfAborted(callerSignal);
158
187
  if (!response.ok) {
159
188
  const kind = response.status === 401 || response.status === 403
160
189
  ? "auth"
@@ -172,7 +201,7 @@ export async function openingExplorer(chess, db, speeds, ratings, options = {})
172
201
  if (delay > EXPLORER_MAX_RETRY_DELAY_MS || delay >= retryBudget) {
173
202
  throw lastError;
174
203
  }
175
- await sleep(delay);
204
+ await sleepWithSignal(sleep, delay, callerSignal);
176
205
  continue;
177
206
  }
178
207
  let body;
@@ -180,17 +209,19 @@ export async function openingExplorer(chess, db, speeds, ratings, options = {})
180
209
  body = await response.json();
181
210
  }
182
211
  catch (cause) {
212
+ throwIfAborted(callerSignal);
183
213
  if (signal.aborted || cause instanceof TypeError) {
184
214
  lastError = error(signal.aborted ? "timeout" : "network");
185
215
  if (attempt + 1 < EXPLORER_MAX_ATTEMPTS) {
186
216
  const delay = Math.min(EXPLORER_DEFAULT_RETRY_DELAY_MS, Math.max(0, deadline - now()));
187
- await sleep(delay);
217
+ await sleepWithSignal(sleep, delay, callerSignal);
188
218
  continue;
189
219
  }
190
220
  throw lastError;
191
221
  }
192
222
  throw error("invalid_response");
193
223
  }
224
+ throwIfAborted(callerSignal);
194
225
  const parsed = responseSchema.safeParse(body);
195
226
  if (!parsed.success)
196
227
  throw error("invalid_response");
@@ -0,0 +1,34 @@
1
+ import { ChessError } from "./errors.js";
2
+ export class HttpWorkAdmission {
3
+ max;
4
+ maxPerSession;
5
+ #active = 0;
6
+ constructor(max, maxPerSession) {
7
+ this.max = max;
8
+ this.maxPerSession = maxPerSession;
9
+ }
10
+ session(lifecycle) {
11
+ let active = 0;
12
+ return async (request, work) => {
13
+ const signal = AbortSignal.any([request, lifecycle]);
14
+ signal.throwIfAborted();
15
+ if (active >= this.maxPerSession) {
16
+ throw new ChessError("SERVER_BUSY", "MCP session work limit reached");
17
+ }
18
+ if (this.#active >= this.max) {
19
+ throw new ChessError("SERVER_BUSY", "server work limit reached");
20
+ }
21
+ active += 1;
22
+ this.#active += 1;
23
+ try {
24
+ const result = await work(signal);
25
+ signal.throwIfAborted();
26
+ return result;
27
+ }
28
+ finally {
29
+ active -= 1;
30
+ this.#active -= 1;
31
+ }
32
+ };
33
+ }
34
+ }