llm-chess-mcp 0.4.3 → 0.4.4

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
@@ -373,8 +373,8 @@ rejected:
373
373
  - `move_evaluate` accepts at most 10 moves per call.
374
374
  - Imported PGNs are limited to 1 MiB and 4,096 plies.
375
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.
376
+ - HTTP retains at most 64 MCP sessions; sessions with no active request expire
377
+ after 30 minutes. An open GET/SSE stream keeps its session active.
378
378
  - HTTP accepts bodies up to 2 MiB. It permits 16 concurrent POSTs and downstream
379
379
  compute/network jobs process-wide, with two of each per session. Work keeps
380
380
  its slot after a raw disconnect until it settles. HTTP also caps connections
package/dist/explorer.js CHANGED
@@ -128,7 +128,7 @@ async function sleepWithSignal(sleep, ms, signal) {
128
128
  });
129
129
  });
130
130
  }
131
- export async function openingExplorer(chess, db, speeds, ratings, options = {}) {
131
+ function setupExplorerRequest(chess, db, speeds, ratings, options) {
132
132
  const callerSignal = options.signal;
133
133
  throwIfAborted(callerSignal);
134
134
  const token = options.token ?? process.env.LICHESS_TOKEN ?? "";
@@ -145,120 +145,149 @@ export async function openingExplorer(chess, db, speeds, ratings, options = {})
145
145
  if (db === "masters" && (speeds.length > 0 || ratings.length > 0)) {
146
146
  throw error("invalid_input");
147
147
  }
148
- const request = options.fetch ?? globalThis.fetch;
149
- const sleep = options.sleep ??
150
- ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
151
- const timeout = options.timeout ?? ((ms) => AbortSignal.timeout(ms));
152
- const now = options.now ?? Date.now;
153
- const deadline = now() + EXPLORER_TOTAL_TIMEOUT_MS;
154
148
  const params = new URLSearchParams();
155
149
  params.set("fen", chess.fen());
156
150
  if (speeds.length)
157
151
  params.set("speeds", speeds.join(","));
158
152
  if (ratings.length)
159
153
  params.set("ratings", ratings.join(","));
160
- const url = `${BASE}/${db}?${params}`;
161
- const legalMoves = new Map(chess.moves({ verbose: true }).map((move) => [move.lan, move.san]));
162
- let lastError = error("network");
163
- for (let attempt = 0; attempt < EXPLORER_MAX_ATTEMPTS; attempt += 1) {
154
+ const now = options.now ?? Date.now;
155
+ return {
156
+ callerSignal,
157
+ db,
158
+ deadline: now() + EXPLORER_TOTAL_TIMEOUT_MS,
159
+ legalMoves: new Map(chess.moves({ verbose: true }).map((move) => [move.lan, move.san])),
160
+ now,
161
+ request: options.fetch ?? globalThis.fetch,
162
+ sleep: options.sleep ??
163
+ ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
164
+ timeout: options.timeout ?? ((ms) => AbortSignal.timeout(ms)),
165
+ token,
166
+ url: `${BASE}/${db}?${params}`,
167
+ };
168
+ }
169
+ async function attemptRequest(setup) {
170
+ const { callerSignal, deadline, now, request, timeout, token, url } = setup;
171
+ throwIfAborted(callerSignal);
172
+ const remaining = deadline - now();
173
+ if (remaining <= 0)
174
+ throw error("timeout");
175
+ const attemptSignal = timeout(Math.max(1, Math.min(EXPLORER_ATTEMPT_TIMEOUT_MS, remaining)));
176
+ const signal = AbortSignal.any(callerSignal ? [callerSignal, attemptSignal] : [attemptSignal]);
177
+ let response;
178
+ try {
179
+ response = await request(url, {
180
+ headers: { Authorization: `Bearer ${token}` },
181
+ signal,
182
+ });
183
+ }
184
+ catch {
164
185
  throwIfAborted(callerSignal);
165
- const remaining = deadline - now();
166
- if (remaining <= 0)
167
- throw error("timeout");
168
- const attemptSignal = timeout(Math.max(1, Math.min(EXPLORER_ATTEMPT_TIMEOUT_MS, remaining)));
169
- const signal = AbortSignal.any(callerSignal ? [callerSignal, attemptSignal] : [attemptSignal]);
170
- let response;
171
- try {
172
- response = await request(url, {
173
- headers: { Authorization: `Bearer ${token}` },
174
- signal,
175
- });
186
+ return { error: error(signal.aborted ? "timeout" : "network") };
187
+ }
188
+ throwIfAborted(callerSignal);
189
+ if (response.ok)
190
+ return { response, signal };
191
+ const kind = response.status === 401 || response.status === 403
192
+ ? "auth"
193
+ : response.status === 429
194
+ ? "rate_limited"
195
+ : response.status >= 500 && response.status <= 599
196
+ ? "upstream"
197
+ : "http";
198
+ return {
199
+ error: error(kind, response.status),
200
+ retryAfter: response.headers.get("retry-after"),
201
+ };
202
+ }
203
+ async function normalizeSuccessfulResponse(response, signal, setup) {
204
+ let body;
205
+ try {
206
+ body = await response.json();
207
+ }
208
+ catch (cause) {
209
+ throwIfAborted(setup.callerSignal);
210
+ if (signal.aborted || cause instanceof TypeError) {
211
+ throw error(signal.aborted ? "timeout" : "network");
176
212
  }
177
- catch {
178
- throwIfAborted(callerSignal);
179
- lastError = error(signal.aborted ? "timeout" : "network");
180
- if (attempt + 1 >= EXPLORER_MAX_ATTEMPTS)
181
- throw lastError;
182
- const delay = Math.min(EXPLORER_DEFAULT_RETRY_DELAY_MS, Math.max(0, deadline - now()));
183
- await sleepWithSignal(sleep, delay, callerSignal);
184
- continue;
213
+ throw error("invalid_response");
214
+ }
215
+ throwIfAborted(setup.callerSignal);
216
+ const parsed = responseSchema.safeParse(body);
217
+ if (!parsed.success)
218
+ throw error("invalid_response");
219
+ const data = parsed.data;
220
+ const ucis = new Set();
221
+ let white = 0;
222
+ let draws = 0;
223
+ let black = 0;
224
+ for (const move of data.moves) {
225
+ if (ucis.has(move.uci) || setup.legalMoves.get(move.uci) !== move.san) {
226
+ throw error("invalid_response");
185
227
  }
186
- throwIfAborted(callerSignal);
187
- if (!response.ok) {
188
- const kind = response.status === 401 || response.status === 403
189
- ? "auth"
190
- : response.status === 429
191
- ? "rate_limited"
192
- : response.status >= 500 && response.status <= 599
193
- ? "upstream"
194
- : "http";
195
- lastError = error(kind, response.status);
196
- if (!isRetryable(kind) || attempt + 1 >= EXPLORER_MAX_ATTEMPTS) {
228
+ ucis.add(move.uci);
229
+ white += move.white;
230
+ draws += move.draws;
231
+ black += move.black;
232
+ }
233
+ if (white > data.white || draws > data.draws || black > data.black) {
234
+ throw error("invalid_response");
235
+ }
236
+ return {
237
+ db: setup.db,
238
+ white: data.white,
239
+ draws: data.draws,
240
+ black: data.black,
241
+ moves: data.moves.map((move) => ({
242
+ uci: move.uci,
243
+ san: move.san,
244
+ white: move.white,
245
+ draws: move.draws,
246
+ black: move.black,
247
+ count: move.white + move.draws + move.black,
248
+ averageRating: move.averageRating ?? null,
249
+ })),
250
+ opening: data.opening ?? null,
251
+ };
252
+ }
253
+ async function retryAfterNetworkFailure(setup) {
254
+ const delay = Math.min(EXPLORER_DEFAULT_RETRY_DELAY_MS, Math.max(0, setup.deadline - setup.now()));
255
+ await sleepWithSignal(setup.sleep, delay, setup.callerSignal);
256
+ }
257
+ export async function openingExplorer(chess, db, speeds, ratings, options = {}) {
258
+ const setup = setupExplorerRequest(chess, db, speeds, ratings, options);
259
+ let lastError = error("network");
260
+ for (let attempt = 0; attempt < EXPLORER_MAX_ATTEMPTS; attempt += 1) {
261
+ const attemptResult = await attemptRequest(setup);
262
+ if ("error" in attemptResult) {
263
+ lastError = attemptResult.error;
264
+ if (!isRetryable(lastError.kind) || attempt + 1 >= EXPLORER_MAX_ATTEMPTS) {
197
265
  throw lastError;
198
266
  }
199
- const delay = retryAfterMs(response.headers.get("retry-after"), now());
200
- const retryBudget = Math.max(0, deadline - now());
267
+ if (attemptResult.retryAfter === undefined) {
268
+ await retryAfterNetworkFailure(setup);
269
+ continue;
270
+ }
271
+ const delay = retryAfterMs(attemptResult.retryAfter, setup.now());
272
+ const retryBudget = Math.max(0, setup.deadline - setup.now());
201
273
  if (delay > EXPLORER_MAX_RETRY_DELAY_MS || delay >= retryBudget) {
202
274
  throw lastError;
203
275
  }
204
- await sleepWithSignal(sleep, delay, callerSignal);
276
+ await sleepWithSignal(setup.sleep, delay, setup.callerSignal);
205
277
  continue;
206
278
  }
207
- let body;
208
279
  try {
209
- body = await response.json();
280
+ return await normalizeSuccessfulResponse(attemptResult.response, attemptResult.signal, setup);
210
281
  }
211
282
  catch (cause) {
212
- throwIfAborted(callerSignal);
213
- if (signal.aborted || cause instanceof TypeError) {
214
- lastError = error(signal.aborted ? "timeout" : "network");
215
- if (attempt + 1 < EXPLORER_MAX_ATTEMPTS) {
216
- const delay = Math.min(EXPLORER_DEFAULT_RETRY_DELAY_MS, Math.max(0, deadline - now()));
217
- await sleepWithSignal(sleep, delay, callerSignal);
218
- continue;
219
- }
220
- throw lastError;
283
+ if (!(cause instanceof ExplorerError) || !isRetryable(cause.kind)) {
284
+ throw cause;
221
285
  }
222
- throw error("invalid_response");
223
- }
224
- throwIfAborted(callerSignal);
225
- const parsed = responseSchema.safeParse(body);
226
- if (!parsed.success)
227
- throw error("invalid_response");
228
- const data = parsed.data;
229
- const ucis = new Set();
230
- let white = 0;
231
- let draws = 0;
232
- let black = 0;
233
- for (const move of data.moves) {
234
- if (ucis.has(move.uci) ||
235
- legalMoves.get(move.uci) !== move.san) {
236
- throw error("invalid_response");
237
- }
238
- ucis.add(move.uci);
239
- white += move.white;
240
- draws += move.draws;
241
- black += move.black;
242
- }
243
- if (white > data.white || draws > data.draws || black > data.black) {
244
- throw error("invalid_response");
286
+ lastError = cause;
287
+ if (attempt + 1 >= EXPLORER_MAX_ATTEMPTS)
288
+ throw lastError;
289
+ await retryAfterNetworkFailure(setup);
245
290
  }
246
- return {
247
- db,
248
- white: data.white,
249
- draws: data.draws,
250
- black: data.black,
251
- moves: data.moves.map((move) => ({
252
- uci: move.uci,
253
- san: move.san,
254
- white: move.white,
255
- draws: move.draws,
256
- black: move.black,
257
- count: move.white + move.draws + move.black,
258
- averageRating: move.averageRating ?? null,
259
- })),
260
- opening: data.opening ?? null,
261
- };
262
291
  }
263
292
  throw lastError;
264
293
  }
package/dist/games.js CHANGED
@@ -1,8 +1,29 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { Chess } from "chess.js";
3
+ import { playParsedMove, snapshotChess } from "./chess.js";
3
4
  import { ChessError } from "./errors.js";
4
5
  export const MAX_GAMES = 1_000;
5
6
  export const GAME_TTL_MS = 60 * 60 * 1_000;
7
+ function cloneChess(chess) {
8
+ const copy = snapshotChess(chess);
9
+ for (const [key, value] of Object.entries(chess.getHeaders())) {
10
+ copy.setHeader(key, value);
11
+ }
12
+ const comments = new Map(chess.getComments().map(({ fen, comment }) => [fen, comment]));
13
+ const reversed = [];
14
+ while (true) {
15
+ const comment = comments.get(copy.fen());
16
+ if (comment !== undefined)
17
+ copy.setComment(comment);
18
+ const move = copy.undo();
19
+ if (!move)
20
+ break;
21
+ reversed.push(move);
22
+ }
23
+ for (const move of reversed.reverse())
24
+ playParsedMove(copy, move);
25
+ return copy;
26
+ }
6
27
  export class GameStore {
7
28
  maxGames;
8
29
  idleTtlMs;
@@ -44,10 +65,28 @@ export class GameStore {
44
65
  if (this.games.has(id)) {
45
66
  throw new ChessError("GAME_ID_COLLISION", `game ID already exists: ${id}`);
46
67
  }
47
- this.games.set(id, { chess, createdAt: now, lastAccessedAt: now, revision: 0 });
68
+ this.games.set(id, {
69
+ chess: cloneChess(chess),
70
+ createdAt: now,
71
+ lastAccessedAt: now,
72
+ revision: 0,
73
+ });
48
74
  return id;
49
75
  }
50
- getGame(id) {
76
+ getSnapshot(id) {
77
+ const game = this.getLiveGame(id);
78
+ return { chess: cloneChess(game.chess), revision: game.revision };
79
+ }
80
+ applyMove(id, expectedRevision, move) {
81
+ const game = this.getLiveGame(id);
82
+ if (expectedRevision !== game.revision) {
83
+ throw new ChessError("STALE_POSITION", `position changed: expected revision ${expectedRevision}, current ${game.revision}`);
84
+ }
85
+ playParsedMove(game.chess, move);
86
+ game.revision += 1;
87
+ return { chess: cloneChess(game.chess), revision: game.revision };
88
+ }
89
+ getLiveGame(id) {
51
90
  const game = this.games.get(id);
52
91
  if (!game)
53
92
  throw new ChessError("GAME_NOT_FOUND", `game not found: ${id}`);
@@ -59,11 +98,6 @@ export class GameStore {
59
98
  game.lastAccessedAt = now;
60
99
  return game;
61
100
  }
62
- bumpRevision(id) {
63
- const game = this.getGame(id);
64
- game.revision += 1;
65
- return game.revision;
66
- }
67
101
  deleteGame(id) {
68
102
  this.cleanupGames();
69
103
  return this.games.delete(id);
@@ -81,11 +115,3 @@ export class GameStore {
81
115
  }
82
116
  }
83
117
  export const defaultGameStore = new GameStore();
84
- export const cleanupGames = (now) => defaultGameStore.cleanupGames(now);
85
- export const createGame = (fen) => defaultGameStore.createGame(fen);
86
- export const createGameFromChess = (chess) => defaultGameStore.createGameFromChess(chess);
87
- export const getGame = (id) => defaultGameStore.getGame(id);
88
- export const bumpRevision = (id) => defaultGameStore.bumpRevision(id);
89
- export const deleteGame = (id) => defaultGameStore.deleteGame(id);
90
- export const listGames = () => defaultGameStore.listGames();
91
- export const gameCount = () => defaultGameStore.gameCount();
package/dist/http.js CHANGED
@@ -166,6 +166,18 @@ function hasUnsupportedContentEncoding(req) {
166
166
  return (contentEncoding !== undefined &&
167
167
  (typeof contentEncoding !== "string" || contentEncoding.trim().toLowerCase() !== "identity"));
168
168
  }
169
+ async function parsePostBody(req, limit, timeoutMs) {
170
+ const body = await readPostBody(req, limit, timeoutMs);
171
+ if (!body.ok)
172
+ return body;
173
+ if (!isJsonContentType(req)) {
174
+ return { ok: false, status: 415, message: "Content-Type must be application/json" };
175
+ }
176
+ if (hasUnsupportedContentEncoding(req)) {
177
+ return { ok: false, status: 415, message: "Content-Encoding must be identity" };
178
+ }
179
+ return body;
180
+ }
169
181
  const UNABORTABLE_SIGNAL = new AbortController().signal;
170
182
  function scopedServices(services, run) {
171
183
  return {
@@ -222,6 +234,17 @@ export async function serveHttp(options = {}, services = defaultAppServices) {
222
234
  .filter(([, session]) => session.activeRequests === 0 && now - session.lastUsedAt >= limits.sessionIdleTtlMs)
223
235
  .map(([id, session]) => closeSession(id, session)));
224
236
  };
237
+ const withActiveSession = async (session, work) => {
238
+ session.activeRequests += 1;
239
+ session.lastUsedAt = Date.now();
240
+ try {
241
+ return await work();
242
+ }
243
+ finally {
244
+ session.activeRequests -= 1;
245
+ session.lastUsedAt = Date.now();
246
+ }
247
+ };
225
248
  const releasePost = (session) => {
226
249
  activePosts -= 1;
227
250
  if (session)
@@ -290,33 +313,22 @@ export async function serveHttp(options = {}, services = defaultAppServices) {
290
313
  closeWithError(req, res, 503, "server request limit reached", { "retry-after": "1" });
291
314
  return;
292
315
  }
293
- session.activeRequests += 1;
294
- session.lastUsedAt = Date.now();
295
316
  try {
296
- const body = await readPostBody(req, limits.maxRequestBodyBytes, limits.requestTimeoutMs);
297
- if (!body.ok) {
298
- closeWithError(req, res, body.status, body.message);
299
- return;
300
- }
301
- if (!isJsonContentType(req)) {
302
- closeWithError(req, res, 415, "Content-Type must be application/json");
303
- return;
304
- }
305
- if (hasUnsupportedContentEncoding(req)) {
306
- closeWithError(req, res, 415, "Content-Encoding must be identity");
307
- return;
308
- }
309
- await session.transport.handleRequest(req, res, body.value);
317
+ await withActiveSession(session, async () => {
318
+ const body = await parsePostBody(req, limits.maxRequestBodyBytes, limits.requestTimeoutMs);
319
+ if (!body.ok) {
320
+ closeWithError(req, res, body.status, body.message);
321
+ return;
322
+ }
323
+ await session.transport.handleRequest(req, res, body.value);
324
+ });
310
325
  }
311
326
  finally {
312
- session.activeRequests -= 1;
313
- session.lastUsedAt = Date.now();
314
327
  releasePost(session);
315
328
  }
316
329
  return;
317
330
  }
318
- session.lastUsedAt = Date.now();
319
- await session.transport.handleRequest(req, res);
331
+ await withActiveSession(session, () => session.transport.handleRequest(req, res));
320
332
  return;
321
333
  }
322
334
  if (req.method !== "POST") {
@@ -328,90 +340,80 @@ export async function serveHttp(options = {}, services = defaultAppServices) {
328
340
  closeWithError(req, res, 503, "server request limit reached", { "retry-after": "1" });
329
341
  return;
330
342
  }
331
- const body = await readPostBody(req, limits.maxRequestBodyBytes, limits.requestTimeoutMs);
332
- if (!body.ok) {
333
- releasePost(undefined);
334
- closeWithError(req, res, body.status, body.message);
335
- return;
336
- }
337
- if (!isJsonContentType(req)) {
338
- releasePost(undefined);
339
- closeWithError(req, res, 415, "Content-Type must be application/json");
340
- return;
341
- }
342
- if (hasUnsupportedContentEncoding(req)) {
343
- releasePost(undefined);
344
- closeWithError(req, res, 415, "Content-Encoding must be identity");
345
- return;
346
- }
347
- if (!isInitializeRequest(body.value)) {
348
- releasePost(undefined);
349
- closeWithError(req, res, 400, "MCP session initialization required");
350
- return;
351
- }
352
- await reapExpiredSessions();
353
- if (closing) {
354
- releasePost(undefined);
355
- closeWithError(req, res, 503, "server is shutting down", { "retry-after": "1" });
356
- return;
357
- }
358
- if (sessions.size + pendingInitializations >= limits.maxSessions) {
359
- releasePost(undefined);
360
- closeWithError(req, res, 503, "MCP session limit reached", { "retry-after": "1" });
361
- return;
362
- }
363
- pendingInitializations += 1;
364
- let initializedId;
365
- let session;
366
- let hasReservation = true;
367
- const transport = new NodeStreamableHTTPServerTransport({
368
- sessionIdGenerator: randomUUID,
369
- onsessioninitialized: (newId) => {
370
- initializedId = newId;
343
+ try {
344
+ const body = await parsePostBody(req, limits.maxRequestBodyBytes, limits.requestTimeoutMs);
345
+ if (!body.ok) {
346
+ closeWithError(req, res, body.status, body.message);
347
+ return;
348
+ }
349
+ if (!isInitializeRequest(body.value)) {
350
+ closeWithError(req, res, 400, "MCP session initialization required");
351
+ return;
352
+ }
353
+ await reapExpiredSessions();
354
+ if (closing) {
355
+ closeWithError(req, res, 503, "server is shutting down", { "retry-after": "1" });
356
+ return;
357
+ }
358
+ if (sessions.size + pendingInitializations >= limits.maxSessions) {
359
+ closeWithError(req, res, 503, "MCP session limit reached", { "retry-after": "1" });
360
+ return;
361
+ }
362
+ pendingInitializations += 1;
363
+ let initializedId;
364
+ let session;
365
+ let hasReservation = true;
366
+ const transport = new NodeStreamableHTTPServerTransport({
367
+ sessionIdGenerator: randomUUID,
368
+ onsessioninitialized: (newId) => {
369
+ initializedId = newId;
370
+ initializing.delete(session);
371
+ if (hasReservation) {
372
+ hasReservation = false;
373
+ pendingInitializations -= 1;
374
+ }
375
+ session.lastUsedAt = Date.now();
376
+ sessions.set(newId, session);
377
+ },
378
+ onsessionclosed: (closedId) => {
379
+ const current = sessions.get(closedId);
380
+ if (current === session)
381
+ sessions.delete(closedId);
382
+ },
383
+ });
384
+ const abort = new AbortController();
385
+ const mcp = buildServer(scopedServices(services, workAdmission.session(abort.signal)));
386
+ session = {
387
+ server: mcp,
388
+ transport,
389
+ abort,
390
+ lastUsedAt: Date.now(),
391
+ activeRequests: 0,
392
+ activePosts: 0,
393
+ };
394
+ initializing.add(session);
395
+ transport.onclose = () => {
396
+ session.abort.abort(new DOMException("MCP session closed", "AbortError"));
397
+ if (initializedId && sessions.get(initializedId) === session) {
398
+ sessions.delete(initializedId);
399
+ }
400
+ };
401
+ try {
402
+ await withActiveSession(session, async () => {
403
+ await mcp.connect(transport);
404
+ await transport.handleRequest(req, res, body.value);
405
+ });
406
+ }
407
+ finally {
371
408
  initializing.delete(session);
372
- if (hasReservation) {
373
- hasReservation = false;
409
+ if (hasReservation)
374
410
  pendingInitializations -= 1;
375
- }
376
- session.lastUsedAt = Date.now();
377
- sessions.set(newId, session);
378
- },
379
- onsessionclosed: (closedId) => {
380
- const current = sessions.get(closedId);
381
- if (current === session)
382
- sessions.delete(closedId);
383
- },
384
- });
385
- const abort = new AbortController();
386
- const mcp = buildServer(scopedServices(services, workAdmission.session(abort.signal)));
387
- session = {
388
- server: mcp,
389
- transport,
390
- abort,
391
- lastUsedAt: Date.now(),
392
- activeRequests: 1,
393
- activePosts: 0,
394
- };
395
- initializing.add(session);
396
- transport.onclose = () => {
397
- session.abort.abort(new DOMException("MCP session closed", "AbortError"));
398
- if (initializedId && sessions.get(initializedId) === session) {
399
- sessions.delete(initializedId);
411
+ if (!initializedId)
412
+ await mcp.close();
400
413
  }
401
- };
402
- try {
403
- await mcp.connect(transport);
404
- await transport.handleRequest(req, res, body.value);
405
414
  }
406
415
  finally {
407
- session.activeRequests -= 1;
408
- session.lastUsedAt = Date.now();
409
- initializing.delete(session);
410
416
  releasePost(undefined);
411
- if (hasReservation)
412
- pendingInitializations -= 1;
413
- if (!initializedId)
414
- await mcp.close();
415
417
  }
416
418
  };
417
419
  const headerTimers = new WeakMap();
package/dist/intents.js CHANGED
@@ -1,6 +1,3 @@
1
- import { stockfish } from "./engines/stockfish.js";
2
- import { humanMoveDistribution } from "./maia3/inference.js";
3
- import { ExplorerError, openingExplorer, explorerEnabled } from "./explorer.js";
4
1
  import { toEval, evalToCp } from "./eval.js";
5
2
  function softmax(values, temperature) {
6
3
  const scaled = values.map((v) => v / temperature);
@@ -122,31 +119,34 @@ export function candidateSetFromData(chess, elo, sfLines, maiaMoves, lichessResu
122
119
  moveSensitivity: computeMoveSensitivity(normalizedSfLines.map(({ line }) => line)),
123
120
  };
124
121
  }
125
- export async function computeCandidates(chess, elo, sfDepth, sfMultipv, maiaTopN, lichess, signal) {
126
- const [sfLines, maiaMoves, lichessResult] = await Promise.all([
127
- stockfish.analyze(chess.fen(), sfDepth, sfMultipv, signal),
128
- humanMoveDistribution(chess, elo, elo, maiaTopN, signal),
129
- lichess && explorerEnabled()
130
- ? openingExplorer(chess, lichess.db, lichess.speeds, lichess.ratings, {
131
- ...(signal ? { signal } : {}),
132
- })
133
- .then(explorerCandidateData)
134
- .catch((e) => {
135
- signal?.throwIfAborted();
136
- return {
137
- status: "unavailable",
138
- reason: e instanceof ExplorerError ? e.reason : "upstream",
139
- totalGames: null,
140
- moves: [],
141
- };
142
- })
143
- : Promise.resolve({
144
- status: "disabled",
145
- totalGames: null,
146
- moves: [],
147
- }),
148
- ]);
149
- return candidateSetFromData(chess, elo, sfLines, maiaMoves, lichessResult);
122
+ export function createCandidateComputation(dependencies) {
123
+ return {
124
+ computeCandidates: async (chess, elo, sfDepth, sfMultipv, maiaTopN, lichess, signal) => {
125
+ const [sfLines, maiaMoves, lichessResult] = await Promise.all([
126
+ dependencies.analyze(chess.fen(), sfDepth, sfMultipv, signal),
127
+ dependencies.humanMoveDistribution(chess, elo, elo, maiaTopN, signal),
128
+ lichess && dependencies.explorerEnabled()
129
+ ? dependencies
130
+ .openingExplorer(chess, lichess.db, lichess.speeds, lichess.ratings, signal)
131
+ .then(explorerCandidateData)
132
+ .catch((error) => {
133
+ signal?.throwIfAborted();
134
+ return {
135
+ status: "unavailable",
136
+ reason: dependencies.explorerFailureReason(error),
137
+ totalGames: null,
138
+ moves: [],
139
+ };
140
+ })
141
+ : Promise.resolve({
142
+ status: "disabled",
143
+ totalGames: null,
144
+ moves: [],
145
+ }),
146
+ ]);
147
+ return candidateSetFromData(chess, elo, sfLines, maiaMoves, lichessResult);
148
+ },
149
+ };
150
150
  }
151
151
  function winMargin(c) {
152
152
  const wdl = c.objective.wdl;
package/dist/services.js CHANGED
@@ -1,15 +1,24 @@
1
1
  import { stockfish } from "./engines/stockfish.js";
2
- import { explorerEnabled, openingExplorer, } from "./explorer.js";
2
+ import { ExplorerError, explorerEnabled, openingExplorer, } from "./explorer.js";
3
3
  import { defaultGameStore } from "./games.js";
4
- import { computeCandidates, rankByIntent } from "./intents.js";
4
+ import { createCandidateComputation, rankByIntent } from "./intents.js";
5
5
  import { humanMoveDistribution } from "./maia3/inference.js";
6
+ const analyze = (fen, depth, multipv, signal) => stockfish.analyze(fen, depth, multipv, signal);
7
+ const openExplorer = (chess, db, speeds, ratings, signal) => openingExplorer(chess, db, speeds, ratings, signal === undefined ? {} : { signal });
8
+ const candidateComputation = createCandidateComputation({
9
+ analyze,
10
+ humanMoveDistribution,
11
+ explorerEnabled,
12
+ openingExplorer: openExplorer,
13
+ explorerFailureReason: (error) => error instanceof ExplorerError ? error.reason : "upstream",
14
+ });
6
15
  export const defaultAppServices = {
7
16
  games: defaultGameStore,
8
- analyze: (fen, depth, multipv, signal) => stockfish.analyze(fen, depth, multipv, signal),
17
+ analyze,
9
18
  quit: () => stockfish.quit(),
10
19
  humanMoveDistribution,
11
20
  explorerEnabled,
12
- openingExplorer: (chess, db, speeds, ratings, signal) => openingExplorer(chess, db, speeds, ratings, signal === undefined ? {} : { signal }),
13
- computeCandidates,
21
+ openingExplorer: openExplorer,
22
+ computeCandidates: candidateComputation.computeCandidates,
14
23
  rankByIntent,
15
24
  };
@@ -0,0 +1,15 @@
1
+ export const TOOL_NAMES = [
2
+ "create_game",
3
+ "delete_game",
4
+ "game_import_pgn",
5
+ "game_legal_moves",
6
+ "game_pgn",
7
+ "game_play_move",
8
+ "game_state",
9
+ "human_move_distribution",
10
+ "move_candidates",
11
+ "move_candidates_by_intent",
12
+ "move_evaluate",
13
+ "opening_explorer",
14
+ "position_analyze",
15
+ ];
@@ -10,8 +10,7 @@ export function registerAnalysisTools(server, services) {
10
10
  inputSchema: TOOL_INPUT_SCHEMAS.position_analyze,
11
11
  outputSchema: TOOL_OUTPUT_SCHEMAS.position_analyze,
12
12
  }, safeHandler(TOOL_INPUT_SCHEMAS.position_analyze, async ({ game_id, analysis_level, depth, multipv }, signal) => {
13
- const { chess: live, revision } = services.games.getGame(game_id);
14
- const chess = snapshotChess(live);
13
+ const { chess, revision } = services.games.getSnapshot(game_id);
15
14
  const preset = ANALYSIS_PRESETS[analysis_level];
16
15
  const d = depth ?? preset.depth;
17
16
  const mpv = multipv ?? preset.multipv;
@@ -38,8 +37,7 @@ export function registerAnalysisTools(server, services) {
38
37
  inputSchema: TOOL_INPUT_SCHEMAS.human_move_distribution,
39
38
  outputSchema: TOOL_OUTPUT_SCHEMAS.human_move_distribution,
40
39
  }, safeHandler(TOOL_INPUT_SCHEMAS.human_move_distribution, async ({ game_id, elo, oppo_elo, top_n }, signal) => {
41
- const { chess: live, revision } = services.games.getGame(game_id);
42
- const chess = snapshotChess(live);
40
+ const { chess, revision } = services.games.getSnapshot(game_id);
43
41
  const opponentElo = oppo_elo ?? elo;
44
42
  const moves = await services.humanMoveDistribution(chess, elo, opponentElo, top_n, signal);
45
43
  const payload = {
@@ -56,8 +54,7 @@ export function registerAnalysisTools(server, services) {
56
54
  inputSchema: TOOL_INPUT_SCHEMAS.move_evaluate,
57
55
  outputSchema: TOOL_OUTPUT_SCHEMAS.move_evaluate,
58
56
  }, safeHandler(TOOL_INPUT_SCHEMAS.move_evaluate, async ({ game_id, move, depth }, signal) => {
59
- const { chess: live, revision } = services.games.getGame(game_id);
60
- const chess = snapshotChess(live);
57
+ const { chess, revision } = services.games.getSnapshot(game_id);
61
58
  const moves = Array.isArray(move) ? move : [move];
62
59
  const beforeLines = await services.analyze(chess.fen(), depth, 1, signal);
63
60
  signal.throwIfAborted();
@@ -1,66 +1,50 @@
1
- import { snapshotChess } from "../chess.js";
2
1
  import { ANALYSIS_PRESETS } from "../eval.js";
3
2
  import { TOOL_INPUT_SCHEMAS } from "../tool-inputs.js";
4
3
  import { TOOL_META } from "../tool-meta.js";
5
4
  import { safeHandler, toolResult } from "../tool-result.js";
6
5
  import { TOOL_OUTPUT_SCHEMAS } from "../tool-schemas.js";
6
+ async function candidatePayload(services, { game_id, elo, analysis_level, sf_depth, sf_multipv, maia_top_n, lichess_db, lichess_speeds, lichess_ratings, }, signal) {
7
+ const { chess, revision } = services.games.getSnapshot(game_id);
8
+ const preset = ANALYSIS_PRESETS[analysis_level];
9
+ const { candidates, moveSensitivity } = await services.computeCandidates(chess, elo, sf_depth ?? preset.depth, sf_multipv ?? preset.multipv, maia_top_n, {
10
+ db: lichess_db,
11
+ speeds: lichess_speeds,
12
+ ratings: lichess_ratings,
13
+ }, signal);
14
+ signal.throwIfAborted();
15
+ return {
16
+ game_id,
17
+ revision,
18
+ fen: chess.fen(),
19
+ turn: chess.turn(),
20
+ elo,
21
+ analysis_level,
22
+ moveSensitivity,
23
+ candidates,
24
+ };
25
+ }
7
26
  export function registerCandidateTools(server, services) {
8
27
  const moveCandidatesSchema = TOOL_INPUT_SCHEMAS.move_candidates;
9
28
  server.registerTool("move_candidates", {
10
29
  ...TOOL_META.move_candidates,
11
30
  inputSchema: moveCandidatesSchema,
12
31
  outputSchema: TOOL_OUTPUT_SCHEMAS.move_candidates,
13
- }, safeHandler(moveCandidatesSchema, async ({ game_id, elo, analysis_level, sf_depth, sf_multipv, maia_top_n, lichess_db, lichess_speeds, lichess_ratings, }, signal) => {
14
- const { chess: live, revision } = services.games.getGame(game_id);
15
- const chess = snapshotChess(live);
16
- const preset = ANALYSIS_PRESETS[analysis_level];
17
- const depth = sf_depth ?? preset.depth;
18
- const multipv = sf_multipv ?? preset.multipv;
19
- const { candidates, moveSensitivity } = await services.computeCandidates(chess, elo, depth, multipv, maia_top_n, {
20
- db: lichess_db,
21
- speeds: lichess_speeds,
22
- ratings: lichess_ratings,
23
- }, signal);
24
- signal.throwIfAborted();
25
- return toolResult({
26
- game_id,
27
- revision,
28
- fen: chess.fen(),
29
- turn: chess.turn(),
30
- elo,
31
- analysis_level,
32
- moveSensitivity,
33
- candidates,
34
- }, `${candidates.length} candidates for game ${game_id} at revision ${revision}`);
32
+ }, safeHandler(moveCandidatesSchema, async (input, signal) => {
33
+ const payload = await candidatePayload(services, input, signal);
34
+ return toolResult(payload, `${payload.candidates.length} candidates for game ${payload.game_id} at revision ${payload.revision}`);
35
35
  }));
36
36
  const byIntentSchema = TOOL_INPUT_SCHEMAS.move_candidates_by_intent;
37
37
  server.registerTool("move_candidates_by_intent", {
38
38
  ...TOOL_META.move_candidates_by_intent,
39
39
  inputSchema: byIntentSchema,
40
40
  outputSchema: TOOL_OUTPUT_SCHEMAS.move_candidates_by_intent,
41
- }, safeHandler(byIntentSchema, async ({ game_id, intent, elo, analysis_level, sf_depth, sf_multipv, maia_top_n, lichess_db, lichess_speeds, lichess_ratings, }, signal) => {
42
- const { chess: live, revision } = services.games.getGame(game_id);
43
- const chess = snapshotChess(live);
44
- const preset = ANALYSIS_PRESETS[analysis_level];
45
- const depth = sf_depth ?? preset.depth;
46
- const multipv = sf_multipv ?? preset.multipv;
47
- const { candidates, moveSensitivity } = await services.computeCandidates(chess, elo, depth, multipv, maia_top_n, {
48
- db: lichess_db,
49
- speeds: lichess_speeds,
50
- ratings: lichess_ratings,
51
- }, signal);
52
- signal.throwIfAborted();
53
- const ranked = services.rankByIntent(candidates, intent);
41
+ }, safeHandler(byIntentSchema, async ({ intent, ...input }, signal) => {
42
+ const payload = await candidatePayload(services, input, signal);
43
+ const candidates = services.rankByIntent(payload.candidates, intent);
54
44
  return toolResult({
55
- game_id,
56
- revision,
57
- fen: chess.fen(),
58
- turn: chess.turn(),
45
+ ...payload,
59
46
  intent,
60
- elo,
61
- analysis_level,
62
- moveSensitivity,
63
- candidates: ranked,
64
- }, `${ranked.length} ${intent} candidates for game ${game_id} at revision ${revision}`);
47
+ candidates,
48
+ }, `${candidates.length} ${intent} candidates for game ${payload.game_id} at revision ${payload.revision}`);
65
49
  }));
66
50
  }
@@ -1,4 +1,3 @@
1
- import { snapshotChess } from "../chess.js";
2
1
  import { ChessError } from "../errors.js";
3
2
  import { OpeningExplorerInputSchema } from "../tool-inputs.js";
4
3
  import { TOOL_META } from "../tool-meta.js";
@@ -13,8 +12,7 @@ export function registerExplorerTool(server, services) {
13
12
  if (!services.explorerEnabled()) {
14
13
  throw new ChessError("LICHESS_DISABLED", "LICHESS_TOKEN not set; opening explorer is disabled");
15
14
  }
16
- const { chess: live, revision } = services.games.getGame(game_id);
17
- const chess = snapshotChess(live);
15
+ const { chess, revision } = services.games.getSnapshot(game_id);
18
16
  const result = await services.openingExplorer(chess, db, speeds, ratings, signal);
19
17
  return toolResult({ game_id, revision, ...result }, `Lichess ${db} returned ${result.moves.length} moves for game ${game_id}`);
20
18
  }));
@@ -1,4 +1,4 @@
1
- import { parseImportedPgn, parseMove, playParsedMove, stateOf } from "../chess.js";
1
+ import { parseImportedPgn, parseMove, stateOf } from "../chess.js";
2
2
  import { ChessError } from "../errors.js";
3
3
  import { TOOL_INPUT_SCHEMAS } from "../tool-inputs.js";
4
4
  import { TOOL_META } from "../tool-meta.js";
@@ -39,7 +39,7 @@ export function registerGameTools(server, services) {
39
39
  inputSchema: TOOL_INPUT_SCHEMAS.game_state,
40
40
  outputSchema: TOOL_OUTPUT_SCHEMAS.game_state,
41
41
  }, safeHandler(TOOL_INPUT_SCHEMAS.game_state, async ({ game_id, include_ascii }) => {
42
- const { chess, revision } = services.games.getGame(game_id);
42
+ const { chess, revision } = services.games.getSnapshot(game_id);
43
43
  const state = stateOf(chess, revision);
44
44
  return toolResult({
45
45
  game_id,
@@ -52,7 +52,7 @@ export function registerGameTools(server, services) {
52
52
  inputSchema: TOOL_INPUT_SCHEMAS.game_play_move,
53
53
  outputSchema: TOOL_OUTPUT_SCHEMAS.game_play_move,
54
54
  }, safeHandler(TOOL_INPUT_SCHEMAS.game_play_move, async ({ game_id, move, expected_revision }, signal) => {
55
- const { chess, revision } = services.games.getGame(game_id);
55
+ const { chess, revision } = services.games.getSnapshot(game_id);
56
56
  if (expected_revision !== revision) {
57
57
  throw new ChessError("STALE_POSITION", `position changed: expected revision ${expected_revision}, current ${revision}`);
58
58
  }
@@ -61,16 +61,15 @@ export function registerGameTools(server, services) {
61
61
  }
62
62
  const parsed = parseMove(chess, move);
63
63
  signal.throwIfAborted();
64
- playParsedMove(chess, parsed);
65
- const newRevision = services.games.bumpRevision(game_id);
66
- return toolResult({ game_id, move: parsed.san, ...stateOf(chess, newRevision) }, `Played ${parsed.san} in game ${game_id}; revision ${newRevision}`);
64
+ const { chess: next, revision: newRevision } = services.games.applyMove(game_id, expected_revision, parsed);
65
+ return toolResult({ game_id, move: parsed.san, ...stateOf(next, newRevision) }, `Played ${parsed.san} in game ${game_id}; revision ${newRevision}`);
67
66
  }));
68
67
  server.registerTool("game_legal_moves", {
69
68
  ...TOOL_META.game_legal_moves,
70
69
  inputSchema: TOOL_INPUT_SCHEMAS.game_legal_moves,
71
70
  outputSchema: TOOL_OUTPUT_SCHEMAS.game_legal_moves,
72
71
  }, safeHandler(TOOL_INPUT_SCHEMAS.game_legal_moves, async ({ game_id }, signal) => {
73
- const { chess, revision } = services.games.getGame(game_id);
72
+ const { chess, revision } = services.games.getSnapshot(game_id);
74
73
  const moves = [];
75
74
  for (const move of chess.moves({ verbose: true })) {
76
75
  signal.throwIfAborted();
@@ -93,7 +92,7 @@ export function registerGameTools(server, services) {
93
92
  inputSchema: TOOL_INPUT_SCHEMAS.game_pgn,
94
93
  outputSchema: TOOL_OUTPUT_SCHEMAS.game_pgn,
95
94
  }, safeHandler(TOOL_INPUT_SCHEMAS.game_pgn, async ({ game_id }) => {
96
- const { chess, revision } = services.games.getGame(game_id);
95
+ const { chess, revision } = services.games.getSnapshot(game_id);
97
96
  return toolResult({ game_id, revision, pgn: chess.pgn() }, `Exported PGN for game ${game_id} at revision ${revision}`);
98
97
  }));
99
98
  server.registerTool("game_import_pgn", {
@@ -112,10 +112,10 @@ a 2 MiB byte cap, initialization reserves one of 64 session slots atomically,
112
112
  and only 16 POSTs process-wide or two per session may run concurrently. The same
113
113
  limits independently bound downstream compute and network jobs. A job retains
114
114
  its slot after the HTTP response or socket closes and releases it only when the
115
- service promise settles. Idle sessions expire after 30 minutes without deleting
116
- their process-shared games. GET SSE streams do not consume POST permits and are
117
- closed when their session expires. Header, upload, connection, socket, and
118
- keep-alive limits are enforced by the Node listener.
115
+ service promise settles. Sessions with no active request expire after 30 minutes
116
+ without deleting their process-shared games. Open GET SSE streams keep their
117
+ session active without consuming POST permits. Header, upload, connection,
118
+ socket, and keep-alive limits are enforced by the Node listener.
119
119
 
120
120
  The server has no MCP OAuth endpoints, OAuth discovery metadata, bearer-token
121
121
  validation, or browser CORS support. A reverse proxy may implement its own
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-chess-mcp",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
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",