llm-chess-mcp 0.4.1 → 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
@@ -279,7 +279,7 @@ codex mcp add llm-chess-mcp --command npx --args -y llm-chess-mcp --env LICHESS_
279
279
  | `game_legal_moves` | All legal moves with metadata |
280
280
  | `game_pgn` | Export the game as PGN |
281
281
  | `game_import_pgn` | Import a PGN into a new game |
282
- | `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 |
283
283
  | `human_move_distribution` | Maia3 human-move probabilities at a target Elo |
284
284
  | `move_evaluate` | Score one or more moves + cpLoss + classification |
285
285
  | `move_candidates` | **Primary tool**: unified candidates (objective + human + opening) |
@@ -307,6 +307,9 @@ human-readable summary and must not be parsed as data.
307
307
  `best / excellent / good / inaccuracy / mistake / blunder`.
308
308
  - `maia3Prob` is a **human-likelihood**, not move quality. A high-probability move
309
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.
310
313
 
311
314
  ## Candidate structure
312
315
 
@@ -370,8 +373,8 @@ rejected:
370
373
  - `move_evaluate` accepts at most 10 moves per call.
371
374
  - Imported PGNs are limited to 1 MiB and 4,096 plies.
372
375
  - Stockfish accepts up to 32 active or queued analyses.
373
- - HTTP retains at most 64 MCP sessions; sessions with no POST activity expire
374
- 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.
375
378
  - HTTP accepts bodies up to 2 MiB. It permits 16 concurrent POSTs and downstream
376
379
  compute/network jobs process-wide, with two of each per session. Work keeps
377
380
  its slot after a raw disconnect until it settles. HTTP also caps connections
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
+ }
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
  };
package/dist/tool-meta.js CHANGED
@@ -46,7 +46,7 @@ export const TOOL_META = {
46
46
  },
47
47
  position_analyze: {
48
48
  title: "Analyze Chess Position",
49
- 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.",
49
+ description: "Run Stockfish on the current position and return the top engine lines (multipv) as UCI pv and SAN pvSan. 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.",
50
50
  annotations: readOnly(),
51
51
  },
52
52
  human_move_distribution: {
@@ -56,7 +56,7 @@ export const TOOL_META = {
56
56
  },
57
57
  move_evaluate: {
58
58
  title: "Evaluate Chess Moves",
59
- 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).",
59
+ 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, a classification (best/excellent/good/inaccuracy/mistake/blunder), and the continuation as UCI pv and SAN pvSan.",
60
60
  annotations: readOnly(),
61
61
  },
62
62
  move_candidates: {
@@ -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
+ ];
@@ -45,6 +45,10 @@ export const SfLineSchema = z.strictObject({
45
45
  wdl: wdl.nullable(),
46
46
  pv: z.array(z.string()),
47
47
  });
48
+ export const AnalysisLineSchema = z.strictObject({
49
+ ...SfLineSchema.shape,
50
+ pvSan: z.array(z.string()),
51
+ });
48
52
  export const OpeningStatsSchema = z.strictObject({
49
53
  status: z.enum(["available", "no_data", "unavailable", "disabled"]),
50
54
  reason: z.enum(EXPLORER_ERROR_KINDS).optional(),
@@ -121,7 +125,7 @@ export const PositionAnalyzeOutputSchema = z.strictObject({
121
125
  turn: color,
122
126
  revision,
123
127
  analysis_level: z.enum(["fast", "normal", "deep"]),
124
- lines: z.array(SfLineSchema),
128
+ lines: z.array(AnalysisLineSchema),
125
129
  });
126
130
  export const Maia3MoveSchema = z.strictObject({
127
131
  uci: z.string(),
@@ -155,6 +159,7 @@ const moveEvaluation = z.strictObject({
155
159
  .enum(["best", "excellent", "good", "inaccuracy", "mistake", "blunder"])
156
160
  .nullable(),
157
161
  pv: z.array(z.string()),
162
+ pvSan: z.array(z.string()),
158
163
  });
159
164
  export const MoveEvaluateOutputSchema = z.strictObject({
160
165
  game_id: z.string(),
@@ -1,4 +1,4 @@
1
- import { drawResult, parseMove, playParsedMove, snapshotChess, } from "../chess.js";
1
+ import { drawResult, parseMove, playParsedMove, pvToSan, snapshotChess, } from "../chess.js";
2
2
  import { ANALYSIS_PRESETS, classifyCpLoss, evalToCp, negateEval, toEval, } from "../eval.js";
3
3
  import { TOOL_INPUT_SCHEMAS } from "../tool-inputs.js";
4
4
  import { TOOL_META } from "../tool-meta.js";
@@ -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;
@@ -28,6 +27,7 @@ export function registerAnalysisTools(server, services) {
28
27
  scoreMate: line.scoreMate,
29
28
  wdl: line.wdl,
30
29
  pv: line.pv,
30
+ pvSan: pvToSan(chess, line.pv),
31
31
  })),
32
32
  };
33
33
  return toolResult(payload, `Analyzed game ${game_id} at revision ${revision}; ${payload.lines.length} lines`);
@@ -37,8 +37,7 @@ export function registerAnalysisTools(server, services) {
37
37
  inputSchema: TOOL_INPUT_SCHEMAS.human_move_distribution,
38
38
  outputSchema: TOOL_OUTPUT_SCHEMAS.human_move_distribution,
39
39
  }, safeHandler(TOOL_INPUT_SCHEMAS.human_move_distribution, async ({ game_id, elo, oppo_elo, top_n }, signal) => {
40
- const { chess: live, revision } = services.games.getGame(game_id);
41
- const chess = snapshotChess(live);
40
+ const { chess, revision } = services.games.getSnapshot(game_id);
42
41
  const opponentElo = oppo_elo ?? elo;
43
42
  const moves = await services.humanMoveDistribution(chess, elo, opponentElo, top_n, signal);
44
43
  const payload = {
@@ -55,8 +54,7 @@ export function registerAnalysisTools(server, services) {
55
54
  inputSchema: TOOL_INPUT_SCHEMAS.move_evaluate,
56
55
  outputSchema: TOOL_OUTPUT_SCHEMAS.move_evaluate,
57
56
  }, safeHandler(TOOL_INPUT_SCHEMAS.move_evaluate, async ({ game_id, move, depth }, signal) => {
58
- const { chess: live, revision } = services.games.getGame(game_id);
59
- const chess = snapshotChess(live);
57
+ const { chess, revision } = services.games.getSnapshot(game_id);
60
58
  const moves = Array.isArray(move) ? move : [move];
61
59
  const beforeLines = await services.analyze(chess.fen(), depth, 1, signal);
62
60
  signal.throwIfAborted();
@@ -80,6 +78,7 @@ export function registerAnalysisTools(server, services) {
80
78
  cpLoss: null,
81
79
  classification: "best",
82
80
  pv: [],
81
+ pvSan: [],
83
82
  });
84
83
  continue;
85
84
  }
@@ -98,6 +97,7 @@ export function registerAnalysisTools(server, services) {
98
97
  ? classifyCpLoss(cpLoss)
99
98
  : null,
100
99
  pv: [],
100
+ pvSan: [],
101
101
  });
102
102
  continue;
103
103
  }
@@ -108,6 +108,7 @@ export function registerAnalysisTools(server, services) {
108
108
  const moverEval = afterEval ? negateEval(afterEval) : null;
109
109
  const afterCp = moverEval ? evalToCp(moverEval) : null;
110
110
  const cpLoss = afterCp !== null && beforeCp !== null ? beforeCp - afterCp : null;
111
+ const pv = after?.pv ?? [];
111
112
  results.push({
112
113
  move: parsed.san,
113
114
  uci: parsed.lan,
@@ -119,7 +120,8 @@ export function registerAnalysisTools(server, services) {
119
120
  classification: cpLoss !== null
120
121
  ? classifyCpLoss(cpLoss)
121
122
  : null,
122
- pv: after?.pv ?? [],
123
+ pv,
124
+ pvSan: pvToSan(copy, pv),
123
125
  });
124
126
  }
125
127
  return toolResult({ game_id, revision, results }, `Evaluated ${results.length} move${results.length === 1 ? "" : "s"} in game ${game_id}`);
@@ -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.1",
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",