@chessceo/mcp 0.43.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -7,67 +7,35 @@
7
7
  // endpoints — see the public contract at https://chess.ceo/llms.txt.
8
8
  // No API key, no auth, no state; the API's own rate limits apply.
9
9
  import { createServer as createHttpServer } from "node:http";
10
- import { AsyncLocalStorage } from "node:async_hooks";
11
- import { spawn } from "node:child_process";
12
10
  import { readFileSync, existsSync } from "node:fs";
13
11
  import { fileURLToPath } from "node:url";
14
12
  import { dirname, join } from "node:path";
15
- import { Chess } from "chess.js";
16
13
  import { parsePGN } from "./pgn/parser.js";
17
- import { exportPGN } from "./pgn/exporter.js";
18
14
  import { describePosition } from "./pgn/describe.js";
19
- import { addLine, addMove, deleteSubtree, MutationError, promoteVariation, setAnnotations, setCeoEval, setCeoEvalMany, setComment, setNags, setTag, } from "./pgn/mutations.js";
20
- import { buildFenIndex, buildIdIndex, NodeIdError, PathError, positionKey, resolveNodeId, ROOT_ID } from "./pgn/paths.js";
15
+ import { addLine, addMove, deleteSubtree, promoteVariation, setAnnotations, setComment, setNags, setTag, } from "./pgn/mutations.js";
16
+ import { buildIdIndex, positionKey, resolveNodeId, ROOT_ID } from "./pgn/paths.js";
21
17
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
22
18
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
23
19
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
24
20
  import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
25
- const BASE = process.env.CHESSCEO_BASE_URL ?? "https://chess.ceo";
26
- const UA = `chessceo-mcp/${process.env.npm_package_version ?? "0.1.0"} (+https://chess.ceo)`;
27
- // The engine-usage guide ships in the package (see package.json "files").
28
- // Loaded once at startup and returned verbatim by the engine_usage_primer
29
- // prompt — LLM hosts surface it in their slash menu so a user can push the
30
- // full doc into the conversation on demand.
31
- function loadBundledDoc(filename, fallbackLabel) {
32
- try {
33
- const here = dirname(fileURLToPath(import.meta.url));
34
- // dist/index.js → ../docs/*.md when packaged; src/index.ts →
35
- // ../docs/*.md during dev. Same resolution either way.
36
- return readFileSync(join(here, "..", "docs", filename), "utf8");
37
- }
38
- catch {
39
- return `${fallbackLabel} not bundled with this install of @chessceo/mcp.`;
40
- }
41
- }
42
- const ENGINE_USAGE_DOC = loadBundledDoc("engine-usage.md", "Engine usage guide");
43
- const PREP_STRATEGY_DOC = loadBundledDoc("prep-strategy.md", "Prep strategy guide");
44
- const PREP_FILES_DOC = loadBundledDoc("prep-files-guide.md", "Prep files guide");
45
- const PGN_AUTHORING_DOC = loadBundledDoc("pgn-authoring.md", "PGN authoring guide");
46
- // Reference PGNs authored by a strong human coach. LLM pulls these when
47
- // it wants to see the commentary style, NAG discipline, and annotation
48
- // density we want it to hit. Kept as raw PGN so the LLM can parse them
49
- // against its own understanding of the game (comments, arrows, NAGs
50
- // all intact) — not summarised into English.
51
- const EXAMPLE_OVERVIEW_PGN = loadBundledDoc("examples/italian-fried-liver.pgn", "Italian Fried Liver overview example");
52
- const EXAMPLE_REPERTOIRE_PGN = loadBundledDoc("examples/najdorf-6-f4-white.pgn", "Najdorf 6.f4 White repertoire example");
53
- // ── HTTP ────────────────────────────────────────────────────────────
54
- //
55
- // Two auth flavours coexist:
56
- // - Anonymous GETs (players, positions, prep, live) — no auth.
57
- // - Authed tools (cloud engines) — `Authorization: Bearer mcp_...`.
58
- //
59
- // The token comes from one of two sources:
60
- // - stdio: `CHESSCEO_TOKEN` env var, set by the MCP host config. Bare
61
- // `mcp_...` — we prepend the `Bearer ` scheme when building the header.
62
- // - streamable-http: the caller's `Authorization` header, forwarded
63
- // per-request via AsyncLocalStorage so tool handlers can see it even
64
- // though the MCP SDK's request handler doesn't know about HTTP.
65
- const authContext = new AsyncLocalStorage();
66
- // Tools that require an MCP token — cloud engine tools operate on the
67
- // user's rented instances so we can't service them anonymously. The
68
- // streamable-http transport uses this list to decide whether to trigger
69
- // the OAuth discovery flow via 401 + WWW-Authenticate before the SDK
70
- // gets a chance to handle the call.
21
+ import { TOOLS } from "./tools.js";
22
+ import { PROMPTS } from "./prompts.js";
23
+ import { commentAntiPatterns, longLineWarning, noDescribeWarning, noStatsCheckWarning, positionsDescribed, positionsStatsChecked, } from "./warnings.js";
24
+ import { authContext, authedRequest, deleteGame, fetchGame, get, makeFileId, restoreGame, } from "./http.js";
25
+ import { analysisToStoredEval, capPvsInResponse, convertCloudSnapshotResponse, fetchCompactEval, } from "./analysis/response.js";
26
+ import { autoEvaluate, autoEvaluateCancel, autoEvaluateStatus, } from "./analysis/auto.js";
27
+ import { deepAnalyseCancel, deepAnalyseStart, deepAnalyseStatus, } from "./analysis/deep.js";
28
+ import { getNodeByPath, resolveFromNodeOrFen, storeEvalOnNode, } from "./analysis/file_handle.js";
29
+ import { findPositionInCourses, readCourseAtPosition, runSfEval } from "./courses.js";
30
+ import { applyBatchMutations, applyMutation, argNodeId, } from "./prep/mutations.js";
31
+ import { listNodes, listTranspositions, readPrepFile, } from "./prep/read.js";
32
+ import { createPrepFile, findPositionInFiles, listCollections, listPrepFiles, searchPrepFiles, } from "./prep/library.js";
33
+ import { convertAvailableMovesToSAN, normalizeSourceForBackend, stripPositionResponse, trimGamesMovetext, } from "./response_transforms.js";
34
+ // Tools that require an MCP token — cloud engine + prep-file tools
35
+ // operate on the caller's own account so we can't service them
36
+ // anonymously. The streamable-http transport uses this list to decide
37
+ // whether to trigger the OAuth discovery flow via 401 + WWW-Authenticate
38
+ // before the SDK gets a chance to handle the call.
71
39
  const AUTHED_TOOLS = new Set([
72
40
  "start_cloud_engine",
73
41
  "list_cloud_engines",
@@ -82,6 +50,7 @@ const AUTHED_TOOLS = new Set([
82
50
  "list_transpositions",
83
51
  "create_prep_file",
84
52
  "delete_prep_file",
53
+ "restore_prep_file",
85
54
  "add_move",
86
55
  "add_line",
87
56
  "set_comment",
@@ -115,2624 +84,38 @@ function isAuthedToolCall(body) {
115
84
  const name = b.params?.name;
116
85
  return typeof name === "string" && AUTHED_TOOLS.has(name);
117
86
  }
118
- function resolveAuthHeader() {
119
- const store = authContext.getStore();
120
- if (store?.authHeader)
121
- return store.authHeader;
122
- const env = process.env.CHESSCEO_TOKEN?.trim();
123
- if (!env)
124
- return undefined;
125
- return env.toLowerCase().startsWith("bearer ") ? env : `Bearer ${env}`;
126
- }
127
- async function get(path, params) {
128
- const url = new URL(path, BASE);
129
- for (const [k, v] of Object.entries(params)) {
130
- if (v !== undefined && v !== null && v !== "")
131
- url.searchParams.set(k, String(v));
132
- }
133
- const res = await fetch(url, { headers: { "User-Agent": UA, "Accept": "application/json" } });
134
- if (!res.ok) {
135
- // Bubble up the ProblemDetail body when the API returns one — LLM can
136
- // then correct the query (e.g. wrong fideId) rather than retry blind.
137
- let body;
138
- try {
139
- body = await res.text();
140
- }
141
- catch {
142
- body = "";
143
- }
144
- throw new Error(`chess.ceo ${res.status}: ${body.slice(0, 500)}`);
145
- }
146
- return res.json();
147
- }
148
- // authedRequest is the shared code path for POST/GET/DELETE calls that need
149
- // an MCP token. Missing-token errors are surfaced early with a message the
150
- // LLM can act on (either configure CHESSCEO_TOKEN or generate a token in
151
- // user settings) rather than a generic 401 from the backend.
152
- async function authedRequest(method, path, body) {
153
- const auth = resolveAuthHeader();
154
- if (!auth) {
155
- throw new Error("No MCP token available. Set CHESSCEO_TOKEN env (stdio mode) or pass an " +
156
- "Authorization: Bearer mcp_... header (streamable-http mode). Generate " +
157
- "a token at chess.ceo → user settings → MCP tokens.");
158
- }
159
- const url = new URL(path, BASE);
160
- const headers = {
161
- "User-Agent": UA,
162
- "Accept": "application/json",
163
- "Authorization": auth,
164
- };
165
- const init = { method, headers };
166
- if (body !== undefined) {
167
- headers["Content-Type"] = "application/json";
168
- init.body = JSON.stringify(body);
169
- }
170
- const res = await fetch(url, init);
171
- if (res.status === 204)
172
- return null;
173
- const text = await res.text();
174
- if (!res.ok) {
175
- throw new Error(`chess.ceo ${res.status}: ${text.slice(0, 500)}`);
176
- }
177
- return text.length ? JSON.parse(text) : null;
178
- }
179
- // ── Backend I/O layer ──────────────────────────────────────────────
180
- //
181
- // v0.43: MCP surface moved off the single-`/mcp`-folder model onto the
182
- // full user PGN library. LLM-facing tool ids are opaque composites of
183
- // the form `<collection_id>:<game_id>` so every existing tool that
184
- // takes `id` keeps taking `id` — the composite splits back into two
185
- // pieces at the HTTP layer. Backend routes live under
186
- // `/api/agent/pgns/*` (same handlers as `/me/pgns/*` browser routes;
187
- // a path-rewrite middleware in the server main.go maps the two).
188
- const PGN_BASE = "/api/agent/pgns";
189
- // Browser handlers wrap successful responses as
190
- // { success: true, message: "...", data: <actual thing> }
191
- // via handlers.RespondSuccess. Peel that off; leave anything without
192
- // the envelope unchanged (some endpoints return raw bodies).
193
- function unwrap(raw) {
194
- if (raw &&
195
- typeof raw === "object" &&
196
- "data" in raw &&
197
- raw.success === true) {
198
- return raw.data;
199
- }
200
- return raw;
201
- }
202
- // LLM-facing "prep file id" is always "<collection_id>:<game_id>" —
203
- // makeFileId to compose from a listing, splitFileId at the HTTP edge.
204
- function makeFileId(collectionId, gameId) {
205
- return `${collectionId}:${gameId}`;
206
- }
207
- function splitFileId(id) {
208
- const idx = id.indexOf(":");
209
- if (idx <= 0 || idx === id.length - 1) {
210
- throw new Error(`invalid prep file id "${id}" — expected "<collection_id>:<game_id>" ` +
211
- `(get one from list_prep_files, search_prep_files, find_position_in_files, ` +
212
- `or the create_prep_file return value)`);
87
+ // The engine-usage guide ships in the package (see package.json "files").
88
+ // Loaded once at startup and returned verbatim by the engine_usage_primer
89
+ // prompt — LLM hosts surface it in their slash menu so a user can push the
90
+ // full doc into the conversation on demand.
91
+ function loadBundledDoc(filename, fallbackLabel) {
92
+ try {
93
+ const here = dirname(fileURLToPath(import.meta.url));
94
+ // dist/index.js → ../docs/*.md when packaged; src/index.ts →
95
+ // ../docs/*.md during dev. Same resolution either way.
96
+ return readFileSync(join(here, "..", "docs", filename), "utf8");
213
97
  }
214
- return { collectionId: id.slice(0, idx), gameId: id.slice(idx + 1) };
215
- }
216
- // GET one game by composite id. Throws on 404.
217
- async function fetchGame(id) {
218
- const { collectionId, gameId } = splitFileId(id);
219
- const raw = await authedRequest("GET", `${PGN_BASE}/${encodeURIComponent(collectionId)}/games/${encodeURIComponent(gameId)}`);
220
- const g = unwrap(raw);
221
- if (!g || typeof g.pgnContent !== "string") {
222
- throw new Error("prep file missing pgnContent");
98
+ catch {
99
+ return `${fallbackLabel} not bundled with this install of @chessceo/mcp.`;
223
100
  }
224
- return g;
225
- }
226
- // PUT the game body. Returns the saved game (new version).
227
- async function saveGame(id, pgn, expectedVersion) {
228
- const { collectionId, gameId } = splitFileId(id);
229
- const body = { pgnContent: pgn };
230
- if (typeof expectedVersion === "number")
231
- body.baseVersion = expectedVersion;
232
- const raw = await authedRequest("PUT", `${PGN_BASE}/${encodeURIComponent(collectionId)}/games/${encodeURIComponent(gameId)}`, body);
233
- return unwrap(raw);
234
101
  }
235
- // POST a new game into the given collection. Returns the created game.
236
- async function createGame(collectionId, pgn) {
237
- const raw = await authedRequest("POST", `${PGN_BASE}/${encodeURIComponent(collectionId)}/games`, { pgnContent: pgn });
238
- return unwrap(raw);
239
- }
240
- // DELETE (soft) a game by composite id.
241
- async function deleteGame(id) {
242
- const { collectionId, gameId } = splitFileId(id);
243
- await authedRequest("DELETE", `${PGN_BASE}/${encodeURIComponent(collectionId)}/games/${encodeURIComponent(gameId)}`);
244
- }
245
- // ── Tool definitions ───────────────────────────────────────────────
246
- //
247
- // Descriptions are written for the LLM, not humans — they should hint
248
- // at when to call the tool, what inputs mean, and what the response
249
- // contains. Terse is fine; the LLM already reads the parameter names.
250
- const TOOLS = [
251
- {
252
- name: "search_player",
253
- description: "Fuzzy name lookup for FIDE-rated chess players. Returns candidate matches with their FIDE ID, current rating, title (GM/IM/etc.), and country. Use this to resolve a plain-English name (e.g. 'Carlsen', 'Ding Liren') to the FIDE ID that every other tool needs.",
254
- inputSchema: {
255
- type: "object",
256
- properties: {
257
- name: {
258
- type: "string",
259
- description: "Player name or partial name. Case-insensitive, fuzzy.",
260
- },
261
- },
262
- required: ["name"],
263
- },
264
- },
265
- {
266
- name: "get_player_profile",
267
- description: "Full stats for one player: identity, monthly rating history, peak / trend stats, career W/D/L by color and time control, top-10 openings as White and Black, opponent analysis by rating bracket, notable wins and worst losses, top events with performance ratings. Often enough on its own for 'how strong is X, what do they play, who have they beaten'.",
268
- inputSchema: {
269
- type: "object",
270
- properties: {
271
- fide_id: {
272
- type: "integer",
273
- description: "FIDE ID from search_player.",
274
- },
275
- },
276
- required: ["fide_id"],
277
- },
278
- },
279
- {
280
- name: "prepare_opponent",
281
- description: "Create a prep SESSION combining games from one or more sources — FIDE database, Chess.com account, Lichess account — with optional filters (colour, date range, time control). Returns a session `token` you pass to `get_prep_position` to query stats at any position within that filtered corpus.\n\n" +
282
- "This is the main opponent-prep tool. Use it whenever a user asks 'prep me against X' — call once with the right sources+filters, then walk the tree with `get_prep_position(session_token, ...)`. Sessions are cached on the server (list existing ones with `list_prep_sessions` to avoid rebuilding).\n\n" +
283
- "SOURCES (1-10 per call, combined into one gameset):\n" +
284
- "- `fide` — needs `fideId`. Optional filters: `color`, `startMonth`/`endMonth`, `timeControl` (`classical`|`rapid`|`blitz`), `excludeOnline`.\n" +
285
- "- `chesscom` — needs `username`. Filters: `color`, `startMonth`/`endMonth` (**required** for chesscom/lichess), `timeControl`.\n" +
286
- "- `lichess` — needs `username`. Same filters as chesscom; `timeControl` also accepts `bullet` on Lichess.\n\n" +
287
- "Multi-source example: one player with both a FIDE ID and a Lichess account → two sources in one call, all their games combined into one session.\n\n" +
288
- "GROUNDING: every claim about the opponent's repertoire must trace back to a `get_prep_position` call on this session. Don't assert 'they play sharply' or 'they hate isolated queen pawn' without pointing at actual game counts / win rates in the response. Prep is a two-player game — see `read_opening_prep_guide` before recommending an opening plan.",
289
- inputSchema: {
290
- type: "object",
291
- properties: {
292
- sources: {
293
- type: "array",
294
- minItems: 1,
295
- maxItems: 10,
296
- description: "1-10 game sources, all combined into one filtered session.",
297
- items: {
298
- type: "object",
299
- properties: {
300
- type: { type: "string", enum: ["fide", "chesscom", "lichess"], description: "Source type." },
301
- fide_id: { type: "integer", description: "FIDE ID (required for type='fide')." },
302
- username: { type: "string", description: "Platform username (required for type='chesscom'/'lichess')." },
303
- color: { type: "string", enum: ["white", "black"], description: "Filter: only games where this side is played by the source player. Omit for both colours." },
304
- start_month: { type: "string", pattern: "^\\d{4}/\\d{2}$", description: "Filter: games from this month onwards, format 'YYYY/MM'. **Required** for chesscom/lichess." },
305
- end_month: { type: "string", pattern: "^\\d{4}/\\d{2}$", description: "Filter: games up to this month, format 'YYYY/MM'. **Required** for chesscom/lichess." },
306
- time_control: { type: "string", enum: ["classical", "rapid", "blitz", "bullet"], description: "Filter: only this time control. `bullet` is Lichess-only." },
307
- exclude_online: { type: "boolean", description: "FIDE-only: exclude online-flagged games (default false)." },
308
- },
309
- required: ["type"],
310
- },
311
- },
312
- },
313
- required: ["sources"],
314
- },
315
- },
316
- {
317
- name: "get_prep_position",
318
- description: "Query one position within a prep session created by `prepare_opponent`. Returns move statistics (frequency + win rate + last-played date per move) plus the actual games played from that position, in one call.\n\n" +
319
- "Position input: prefer `file_id`+`node_id` when inside a prep file (server derives FEN from the tree). Otherwise pass `fen`.\n\n" +
320
- "AUTO-EVAL: if a cloud combo instance is running, the response includes `.eval` (Stockfish + Lc0 read at the position) so you don't need a separate cloud_analyse.\n\n" +
321
- "Reading the response — CRITICAL:\n" +
322
- "• Win % is one weight, not a verdict. Sample size matters (3 games at 66% is noise; 300 at 55% is signal).\n" +
323
- "• Prep is symmetric information — both sides see the same history. Assume the opponent knows the weakness you spotted.\n" +
324
- "• Recency > career. The last 12-24 months dominate — filter your session with `start_month` if the player's repertoire shifted.\n" +
325
- "• Opponent will deviate early. Prep is a tree — cover the 2 most likely replies at each real branching point, not one 20-move line.\n\n" +
326
- "For the full guide call `read_opening_prep_guide`.",
327
- inputSchema: {
328
- type: "object",
329
- properties: {
330
- session_token: { type: "string", description: "Session token from `prepare_opponent`." },
331
- file_id: { type: "string", description: "Prep file id — combine with `node_id` for tree-addressed position lookup." },
332
- node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`." },
333
- fen: { type: "string", description: "Position as FEN. Only used if `node_id` is not set." },
334
- limit: { type: "integer", minimum: 1, maximum: 50, description: "Games to return (default 10)." },
335
- offset: { type: "integer", minimum: 0 },
336
- },
337
- required: ["session_token"],
338
- },
339
- },
340
- {
341
- name: "list_prep_sessions",
342
- description: "List the caller's active prep sessions with their tokens and metadata. Call this BEFORE `prepare_opponent` to reuse an existing session instead of rebuilding — sessions cost real backend work for chesscom/lichess (downloading months of games), so re-using saves time. Response includes source description, game count, and creation time per session.",
343
- inputSchema: { type: "object", properties: {} },
344
- },
345
- {
346
- name: "delete_prep_session",
347
- description: "Delete one prep session by token. Free-form cleanup — sessions do expire automatically, but this is useful when you're done with one or when you want to force a rebuild after upstream data changed.",
348
- inputSchema: {
349
- type: "object",
350
- properties: {
351
- session_token: { type: "string", description: "Token from `list_prep_sessions` or the response of `prepare_opponent`." },
352
- },
353
- required: ["session_token"],
354
- },
355
- },
356
- {
357
- name: "get_position_stats",
358
- description: "Move statistics + example games at a position. Answers 'how often is 4.O-O vs 4.d3 played here and which scores better'.\n\n" +
359
- "SOURCE (default: `gm-classical`) selects a pre-aggregated database shard:\n" +
360
- "- `gm-classical` — GM classical games (both players ≥2500, real thinking-time). BEST for opening prep — every move is signal, avgElo ~2600 across all listed moves.\n" +
361
- "- `main` — the whole 11.7M-game DB. Widest coverage but noisiest (includes 1000-Elo blunder-fests in the move stats). Use as fallback when gm-classical's totalCount is too small to be informative.\n\n" +
362
- "Game movetext is trimmed to the moves AFTER the queried position (using each game's plyNumber). Saves ~70% of the bytes vs full movetext.\n\n" +
363
- "AUTO-EVAL: if a cloud combo instance is running, the response includes `.eval` with a compact Stockfish + Lc0 read and the corresponding NAG. Do NOT fire cloud_analyse separately for the same FEN. When called with `file_id`+`node_id`, the eval is also auto-stored on that node's `ceoEval` — later readable via quote_engine_eval.",
364
- inputSchema: {
365
- type: "object",
366
- properties: {
367
- file_id: {
368
- type: "string",
369
- description: "Prep file id. **Prefer file_id+node_id over `fen`** when a prep file is open — the server derives the FEN from the tree.",
370
- },
371
- node_id: {
372
- type: "string",
373
- description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`/`moves`/`line`.",
374
- },
375
- fen: {
376
- type: "string",
377
- description: "Starting position as FEN. Combine with `moves`. Only used if `node_id` is not set.",
378
- },
379
- moves: {
380
- type: "string",
381
- description: "Optional SAN moves on top of `fen` (or startpos). Only used if `node_id` is not set.",
382
- },
383
- line: {
384
- type: "string",
385
- description: "Synonym for `moves` from startpos; kept for compatibility.",
386
- },
387
- limit: {
388
- type: "integer",
389
- minimum: 1,
390
- maximum: 50,
391
- description: "Number of example games to return (default 10).",
392
- },
393
- source: {
394
- type: "string",
395
- enum: ["gm-classical", "main"],
396
- description: "Which database shard to query. Default `gm-classical`. Switch to `main` only when gm-classical's totalCount is too low.",
397
- },
398
- },
399
- },
400
- },
401
- {
402
- name: "describe_position",
403
- description: "**CALL WHEN**: about to write ANY comment on a position that describes what's happening on the board — piece activity, structure, plans, weaknesses. This is the single biggest lever for prose quality in the whole system. Live audit: nodes where describe_position was called first produced comments grounded specifically in the position (correct piece squares, real pawn structure, actual weak squares); nodes where it wasn't produced generic pattern-matched prose that confidently named pieces on wrong squares. `set_comment` now emits a warning whenever a substantive comment lands on a node whose position was never grounded via describe_position this session — that warning is telling you to fix a class of hallucination that already showed up in your output. Cheap: chess-primitive analysis is instant, Stockfish leg is ~50-100 ms, no billing.\n\n" +
404
- "Everything you need to understand a position in one call. Pieces get misplaced when reading a FEN, hanging pieces missed, 'the knight on d5' turns out to not exist.\n\n" +
405
- "Returns three layers:\n\n" +
406
- "**Board state** — piece placements per colour, material balance in pawn units, contested pieces (attackers + defenders), hanging pieces, checkers if in check, castling rights, en passant, side to move, full LEGAL MOVES list. Use `.legalMoves` when `add_move` rejects an illegal SAN.\n\n" +
407
- "**Structural analysis** — chess-concept observations a human sees at a glance:\n" +
408
- " • `pawnStructure.files` — each file `open`/`half_open_for_white`/`half_open_for_black`/`closed`. Half-open files are natural rook targets.\n" +
409
- " • `pawnStructure.islands` — count per colour (more = weaker structure).\n" +
410
- " • `pawnStructure.isolated` / `doubled` / `passed` / `backward` — structural weaknesses (and strengths, for passed).\n" +
411
- " • `weakSquares` — holes in ranks 3-6 that no friendly pawn can ever attack. Prime real estate for enemy pieces.\n" +
412
- " • `outposts` — friendly N/B on an enemy hole defended by own pawn. Classic strong squares.\n" +
413
- " • `bishops` — per-bishop `good`/`mixed`/`bad` from own pawns on its colour. `bishops.pair` flags who has both.\n" +
414
- " • `space` — squares controlled in the enemy half.\n\n" +
415
- "**Engine eval terms** (`engineEvalTerms`) — Stockfish's classical eval decomposed into 13 named contributing terms (Material, Imbalance, Pawns, Knights, Bishops, Rooks, Queens, Mobility, King safety, Threats, Passed, Space, Winnable), each with white / black / total values in mg + eg. Stockfish's own answer to WHY the position stands the way it does.\n" +
416
- " → **Primary use: the delta pattern.** Call `describe_position` on the position BEFORE and AFTER a candidate move, compare `engineEvalTerms` — the term with the biggest shift tells you WHAT the move changed (king safety collapsed → move exposed the king; mobility jumped → move improved coordination). Kim et al. NAACL 2025 showed this named-delta pattern roughly doubles LLM chess-commentary correctness vs a bare eval number.\n" +
417
- " → Omitted from the response if Stockfish isn't installed on the server.\n\n" +
418
- "Position input: prefer `file_id`+`node_id` if inside a prep file. Otherwise `fen`, `moves` from startpos, or `fen + moves`.",
419
- inputSchema: {
420
- type: "object",
421
- properties: {
422
- file_id: { type: "string", description: "Prep file id. When combined with `node_id`, describes that node's position." },
423
- node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'." },
424
- fen: { type: "string", description: "Starting position as FEN (defaults to startpos). Only used if `node_id` is not set." },
425
- moves: { type: "string", description: "Optional SAN moves to apply on top of `fen`. Only used if `node_id` is not set." },
426
- },
427
- },
428
- },
429
- {
430
- name: "predict_human_move",
431
- description: "Neural net (ResNet-20x256) trained on real games. Always evaluated at **2850 vs 2850** (top-level play) — the rating is fixed on purpose, so cross-position comparisons stay apples-to-apples. Returns two signals — both useful, treat as independent:\n\n" +
432
- "1. **Top-N most likely moves** (`moves: [{san, p}, ...]`) — what a top player will actually pick. Different question from engines: cloud_analyse says objectively best, this says what the human will play. If the human top move is a mistake, that's a real practical advantage.\n\n" +
433
- "2. **`wdlWhitePov: {win, draw, loss}`** — game-outcome prediction, White POV. Directly comparable across positions: call on two positions, compare `draw` to find which line is drawier / more forcing. Two-line comparisons are how you answer 'must-win with Black, which of these openings gives more play'.\n\n" +
434
- "Pass `prev_fens` (most recent first) when the position is mid-trade — without history the model treats it as quiet, which under-counts practical chances.\n\n" +
435
- "Position input: prefer `file_id`+`node_id` when inside a prep file. Otherwise `fen`, `moves` from startpos, or `fen + moves`. ~1-2s per call. **Premium (or admin/moderator) only** — anonymous calls get 402.",
436
- inputSchema: {
437
- type: "object",
438
- properties: {
439
- file_id: { type: "string", description: "Prep file id. Combine with `node_id` to point at a tree node's position." },
440
- node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'." },
441
- fen: { type: "string", description: "Starting position as FEN. Only used if `node_id` is not set." },
442
- moves: {
443
- type: "string",
444
- description: "Optional SAN moves to apply on top of `fen` (or startpos). Only used if `node_id` is not set.",
445
- },
446
- top: {
447
- type: "integer",
448
- minimum: 1,
449
- maximum: 20,
450
- description: "Number of top predicted moves to return (default 5).",
451
- },
452
- prev_fens: {
453
- type: "array",
454
- items: { type: "string" },
455
- description: "Previous FEN(s), most recent first. Optional — omit for quiet-position analysis. Useful mid-trade so the model doesn't assume the position is stable.",
456
- },
457
- },
458
- },
459
- },
460
- {
461
- name: "get_head_to_head",
462
- description: "Complete head-to-head record between two players. Includes overall and per-colour W/D/L (from player A's perspective), splits by time control, most-played openings between them, first / last meeting, average game length, and the game list.",
463
- inputSchema: {
464
- type: "object",
465
- properties: {
466
- fide_id_a: { type: "integer", description: "FIDE ID of player A (record is from A's perspective)." },
467
- fide_id_b: { type: "integer", description: "FIDE ID of player B." },
468
- limit: { type: "integer", minimum: 1, maximum: 10 },
469
- offset: { type: "integer", minimum: 0 },
470
- },
471
- required: ["fide_id_a", "fide_id_b"],
472
- },
473
- },
474
- {
475
- name: "list_live_tournaments",
476
- description: "Tournaments currently being broadcast live on chess.ceo. Use this when the user asks 'what's on right now' / 'live tournaments today'.",
477
- inputSchema: { type: "object", properties: {} },
478
- },
479
- {
480
- name: "list_tournament_players",
481
- description: "Players participating in one live-broadcast tournament.",
482
- inputSchema: {
483
- type: "object",
484
- properties: {
485
- tour_id: { type: "string", description: "Tournament ID from list_live_tournaments." },
486
- },
487
- required: ["tour_id"],
488
- },
489
- },
490
- {
491
- name: "list_player_live_tournaments",
492
- description: "Which currently-live broadcasts a given player is competing in. Use when the user asks 'is X playing anywhere right now'.",
493
- inputSchema: {
494
- type: "object",
495
- properties: {
496
- fide_id: { type: "integer", description: "FIDE ID from search_player." },
497
- },
498
- required: ["fide_id"],
499
- },
500
- },
501
- {
502
- name: "list_cloud_machine_options",
503
- description: "Returns the catalog of combo cloud-engine machine types the user can start (SKU, human display name, cost per hour, availability). ALWAYS call this before start_cloud_engine — SKU strings like 'rtx-5090-64' do not match the display names ('Stockfish 32 CPUs + Lc0 1× RTX 5090') and are NOT guessable. Present the user the display names + prices; pass the SKU to start_cloud_engine.",
504
- inputSchema: { type: "object", properties: {} },
505
- },
506
- {
507
- name: "start_cloud_engine",
508
- description: "Rent a combo GPU instance (Stockfish + Lc0 in the same container) on the user's chess.ceo account. Real money — billed per second while running.\n\n" +
509
- "CRITICAL: `machine_type` must be an exact SKU from `list_cloud_machine_options` (e.g. 'rtx-5090-64', NOT 'rtx-5090'). Guessing SKUs will fail. Call list_cloud_machine_options first, show the user the display names + prices, get their confirmation, then pass the SKU here.\n\n" +
510
- "Use list_cloud_engines first to check if the user already has one running; don't start a second combo unless the user asked for it. Requires an MCP token with agent access.",
511
- inputSchema: {
512
- type: "object",
513
- properties: {
514
- machine_type: {
515
- type: "string",
516
- description: "SKU from list_cloud_machine_options (e.g. 'rtx-5090-64', 'rtx-5090-dual-64'). MUST be the exact SKU, not the display name and not a guess.",
517
- },
518
- },
519
- required: ["machine_type"],
520
- },
521
- },
522
- {
523
- name: "list_cloud_engines",
524
- description: "List the user's currently running cloud engines. Use before starting a new one, or to find the contract_id for stop_cloud_engine. `cloud_analyse` auto-picks the only running combo, so listing is only necessary when the user might have zero or several.",
525
- inputSchema: { type: "object", properties: {} },
526
- },
527
- {
528
- name: "stop_cloud_engine",
529
- description: "Destroy a running cloud engine. Billing stops immediately. Use the contract_id from `list_cloud_engines` — don't guess.",
530
- inputSchema: {
531
- type: "object",
532
- properties: {
533
- contract_id: {
534
- type: "string",
535
- description: "Instance contract_id, from list_cloud_engines.",
536
- },
537
- },
538
- required: ["contract_id"],
539
- },
540
- },
541
- {
542
- name: "cloud_analyse",
543
- description: "Runs a synchronous ~2s analysis on the user's running combo instance and returns both Stockfish and Lc0's final read for the FEN — depth, top-N candidate moves with scores (**scoreCp is White-POV centipawns**: +20 = White is +0.20 pawns better regardless of whose turn it is; matches the sign convention used everywhere else in this MCP, including the stored ceoEval). Mate is White-POV plies-to-mate (+5 = White mates in 5). Also returns each engine's principal variation.\n\n" +
544
- "GROUNDING: every claim you make about a position must trace back to actual engine output from a call in THIS session. Don't invent evaluations, don't name 'best moves' you haven't seen the engine list, don't fabricate variations that 'look plausible.' Compute is cheap — call this 5-10 times while walking a tree rather than pattern-matching from your training data. When you don't have data for the position, either run the tool or say so; don't fill the gap with chess prose the user can't distinguish from measured output.\n\n" +
545
- "Auto-picks the caller's only running combo instance; errors clearly if there are zero (start one first with start_cloud_engine) or more than one (destroy the extras first).\n\n" +
546
- "How to read the response:\n" +
547
- "• Stockfish is objective truth — trust it for 'does this line hold?' 'is there a tactic?' 'is this endgame drawn?' A Stockfish 0.00 means 'objectively equal', NOT 'trivial draw' — one side can still be much harder to play in practice.\n" +
548
- "• Lc0 is practical eval — trust it for 'which side is easier?' 'which candidate is best when Stockfish shows several as equal?' Lc0 sees long-term positional factors Stockfish's fixed search can miss.\n" +
549
- "• When they agree → high confidence. When they disagree → look at both scores and reason WHY (Stockfish sharply higher = tactic Lc0 missed; Lc0 higher = long-term positional edge past Stockfish's horizon). Never dismiss either — the disagreement is the signal.\n\n" +
550
- "Contempt (`contempt`) skews Lc0 (only Lc0 — Stockfish always stays objective) toward White (positive) or Black (negative). Signed 0-100 strength — same scale as the web UI's ContemptStrength slider (the server multiplies by 8 to produce Lc0's internal cp bias). Typical values: ±15 for a light nudge, ±30-60 for real fighting play, ±80-100 for maximum steer. Use it to find non-objective 'practical' ideas or when the user needs to lean toward fighting/solid lines with a specific colour. Do NOT quote a contempt-biased eval as objective — cross-check with Stockfish.\n\n" +
551
- "Also useful: pass `moves` on top of `fen` to explore a variation without computing FENs yourself (e.g. fen='<tabiya>', moves='b4 a5 c3'). And the flip-side-to-move threat check documented in the guide is a great free trick.\n\n" +
552
- "**PVs are capped at 6 plies by default (3 full moves), and lines that got truncated are marked with `pv_truncated: true`.** This is deliberate: the tail of a PV is where the engine's confidence collapses, AND pasting a long PV into `add_line` as if it were prepared repertoire is the #1 documented anti-pattern of this MCP — a 15-move PV is one line of engine output through positions where both sides had real choices, not a repertoire. To see further, don't raise `pv_max_plies`; instead, walk the tree one branch at a time with a fresh `cloud_analyse` at each position where the opponent has real alternatives — that's what makes it prep instead of pasted output. Only raise the cap when you're verifying a forcing sequence (a mate, a forced tactical resolution), not to build lines.\n\n" +
553
- "For the full guide including worked examples, call the `read_engine_usage_guide` tool.\n\n" +
554
- "Not for casual questions — this costs real money per second. Use `get_position_stats` for anything that doesn't require deep prep.\n\n" +
555
- "**When called with `file_id`+`node_id` (preferred inside a prep file), the resulting eval is auto-stored on that node's `ceoEval` — you can then quote it with quote_engine_eval on any later call.** This is what makes engine attribution trustworthy: prose that says 'engines say X on node Y' can only be true if a call was actually made against node_id=Y.",
556
- inputSchema: {
557
- type: "object",
558
- properties: {
559
- file_id: { type: "string", description: "Prep file id. **Prefer file_id+node_id over fen** when inside a prep file — the FEN comes from the tree AND the result is stored on the node." },
560
- node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`/`moves`." },
561
- fen: { type: "string", description: "Starting position as FEN. Only used if `node_id` is not set." },
562
- moves: {
563
- type: "string",
564
- description: "Optional SAN moves to apply on top of `fen` (or startpos). Only used if `node_id` is not set.",
565
- },
566
- movetime_ms: {
567
- type: "integer",
568
- minimum: 100,
569
- maximum: 10000,
570
- description: "Think time in milliseconds (default 2000).",
571
- },
572
- stockfish_multipv: {
573
- type: "integer",
574
- minimum: 1,
575
- maximum: 10,
576
- description: "Stockfish candidate lines (default 2). Kept tight because each extra PV steals search bandwidth from the top choice — SF is the 'what's objectively best' leg, use a low multipv to keep it strong. Raise only when you specifically need SF's take on a wide range of candidates.",
577
- },
578
- lc0_multipv: {
579
- type: "integer",
580
- minimum: 1,
581
- maximum: 10,
582
- description: "Lc0 candidate lines (default 8). Kept wide because multipv doesn't degrade Lc0's strength the way it does Stockfish's — Lc0 is the 'find inspiration / explore practical tries' leg, use a high multipv to get a full slate of ideas.",
583
- },
584
- contempt: {
585
- type: "integer",
586
- minimum: -100,
587
- maximum: 100,
588
- description: "Lc0 contempt bias. Signed 0-100 strength (same scale as the web UI's ContemptStrength slider — server multiplies by 8 to get the internal cp bias). 0 = objective (default). Positive favours White, negative favours Black. Typical: ±15 light nudge, ±30-60 real fighting play, ±80-100 maximum steer. Not applied to Stockfish. See engine_usage_primer for when to use.",
589
- },
590
- engines: {
591
- type: "array",
592
- items: { type: "string", enum: ["stockfish", "lc0"] },
593
- description: "Which engines to run. Default = both. Use `[\"lc0\"]` to skip Stockfish (e.g. while a deep_analyse job is holding the SF slot on the same combo). Use `[\"stockfish\"]` when only the objective read matters. The skipped engine's field is omitted from the response.",
594
- },
595
- pv_max_plies: {
596
- type: "integer",
597
- minimum: 1,
598
- maximum: 40,
599
- description: "Cap each returned PV to this many plies (default 6 = 3 full moves). PVs beyond ~6 plies are speculative and are the anti-pattern behind pasted-engine-line 'prep' — don't raise unless you're specifically checking a forcing tactic or verifying a mate. When a line was truncated, the response marks it with `pv_truncated: true`.",
600
- },
601
- },
602
- },
603
- },
604
- {
605
- name: "list_collections",
606
- description: "List the user's own PGN collections — every collection they've created, not just prep files. Response: `{collections: [{id, title, icon, folder_path, game_count, updated_at, position_search_enabled}]}`.\n\n" +
607
- "**Call this before create_prep_file** — the LLM must pick a collection to write into (no default landing folder any more; the old hidden `/mcp` collection was retired in v0.43). Also useful when the user asks a position-shaped question — LLM can then run find_position_in_files to see which of these collections already covers the position.\n\n" +
608
- "Encrypted collections (client-side-encrypted PGN) are excluded — the server can't read their contents, so they'd be dead weight on this surface.",
609
- inputSchema: { type: "object", properties: {} },
610
- },
611
- {
612
- name: "list_prep_files",
613
- description: "List the games (prep files) inside one of the user's collections. **Requires `collection_id`** — call `list_collections` first if you don't have one. Returns id (composite `<collection_id>:<game_id>`, opaque to the LLM — pass as-is to read_prep_file / mutation tools), PGN header fields, updated_at.\n\n" +
614
- "For cross-collection discovery use `search_prep_files` (text) or `find_position_in_files` (position); list_prep_files is the browse-one-collection tool.",
615
- inputSchema: {
616
- type: "object",
617
- properties: {
618
- collection_id: { type: "string", description: "Collection id from list_collections. Required." },
619
- },
620
- required: ["collection_id"],
621
- },
622
- },
623
- {
624
- name: "search_prep_files",
625
- description: "Text search over the user's prep files ACROSS ALL their collections (matches PGN headers, comments, and content). Use when you know a keyword — e.g. search_prep_files(query='Firouzja') or search_prep_files(query='Najdorf'). Cheaper than paging list_collections + list_prep_files to find one file by name.",
626
- inputSchema: {
627
- type: "object",
628
- properties: {
629
- query: { type: "string", description: "Free-text query (opponent name, opening name, event keyword)." },
630
- },
631
- required: ["query"],
632
- },
633
- },
634
- {
635
- name: "find_position_in_files",
636
- description: "Position search across every one of the user's EDITABLE prep files (all their non-encrypted collections). Given a FEN, returns which of the user's files reach that exact position (or a transposition of it — matched by zobrist hash, so move-order variants are found automatically). Recency-sorted.\n\n" +
637
- "Distinct from `find_position_in_courses`: courses are READ-ONLY reference material (Chessable PGNs, downloaded backups); this searches the user's OWN editable prep. Common workflow: user asks about a position → call this first to see if their existing prep covers it → if yes, extend that file; if no, consider whether to start new prep.\n\n" +
638
- "Position input: `file_id`+`node_id` (from an already-open prep file), or `fen`, or `moves` from startpos, or `fen`+`moves`.",
639
- inputSchema: {
640
- type: "object",
641
- properties: {
642
- file_id: { type: "string", description: "Prep file id. With `node_id`, derives FEN from the tree." },
643
- node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'." },
644
- fen: { type: "string", description: "Position as FEN. Only used if `file_id`/`node_id` not set." },
645
- moves: { type: "string", description: "SAN moves from startpos (or on top of fen)." },
646
- line: { type: "string", description: "Alias for moves." },
647
- },
648
- },
649
- },
650
- {
651
- name: "read_prep_file",
652
- description: "Read one prep file. Response always includes `id`, `version`, `tags`. The tree/PGN part is controlled by `view` and `node_id`/`max_depth` — large files (500+ nodes) can otherwise blow the LLM's token limit.\n\n" +
653
- "**Views** (pick the smallest one that answers your question):\n" +
654
- " • `compact` (default) — per-node: `id`, `san`, `ply`, `nags`, `comment`, `ceoEval`, `children`. Drops `fen` and `annotations`. Typical size: ~120 chars/node vs ~330 in `full`.\n" +
655
- " • `full` — everything (`fen`, `annotations` too). Use when you actually need the FEN inline or want to inspect arrows/highlights. On a 500+-node file this can exceed token limits.\n" +
656
- " • `spine` — mainline only (children[0] recursively). Great for a 'what does this repertoire cover' summary.\n" +
657
- " • `pgn` — subtree as raw PGN text (comments, NAGs, [%cal] arrows all preserved). Useful for sanity-checking formatting against reference material.\n\n" +
658
- "**Node addressing.** Every node has a stable `id` — root is `'r'`, every other node is an 8-hex-char content hash of parent-id + SAN. Sibling insertions, deletions, variation promotions never shift ids. Pass as `node_id` (or `parent_id` for add_move / add_line) to every mutation and engine/DB tool.\n\n" +
659
- "**Scoping.** `node_id` starts the tree from a subtree root (default `'r'`). `max_depth` caps the tree at that many plies below the anchor (default unlimited). Use both to drill into a specific branch without dumping the whole file — the LLM never needs to see the full 800-node tree at once.\n\n" +
660
- "For querying the tree without reading it (\"which nodes have no ceoEval?\", \"give me the mainline spine\") call `list_nodes` — cheaper than parsing a full read.\n\n" +
661
- "**Every engine/DB tool accepts `file_id`+`node_id`** (get_position_stats, cloud_analyse, describe_position, predict_human_move, prep_snapshot, get_prep_position, quote_engine_eval). Use it whenever a file is open — the server derives the FEN from the tree, so you can't 'analyse the wrong position' by mis-typing a FEN.",
662
- inputSchema: {
663
- type: "object",
664
- properties: {
665
- id: { type: "string", description: "Prep file id, from list_prep_files or search_prep_files." },
666
- view: { type: "string", enum: ["compact", "full", "spine", "pgn"], description: "Response shape. Default `compact` — drops fen + annotations to keep token count sane. See tool description for when to use each." },
667
- node_id: { type: "string", description: "Subtree root (default `'r'` = whole file)." },
668
- max_depth: { type: "integer", minimum: 0, description: "Cap the returned tree at this many plies below `node_id`. Omit for unlimited." },
669
- },
670
- required: ["id"],
671
- },
672
- },
673
- {
674
- name: "list_nodes",
675
- description: "Cheap tree queries without reading the whole file. Returns only the node ids matching the filter (plus san, ply, and any filter-specific bits), so the LLM can find what it needs in ~KBs instead of MBs.\n\n" +
676
- "Filters:\n" +
677
- " • `missing_eval` — nodes without a stored `ceoEval`. Use before `auto_evaluate` to know how much work is left, or to target a small batch.\n" +
678
- " • `has_comment` — nodes with a text comment. Use to audit what's been annotated.\n" +
679
- " • `has_annotations` — nodes with arrows or highlighted squares.\n" +
680
- " • `mainline` — the spine (children[0] recursively). Use for a compact 'what does the repertoire cover' view.\n" +
681
- " • `novelties` — nodes carrying the `$146` NAG.\n" +
682
- " • `leaves` — nodes with no children (variation endpoints). Useful for finding lines that need continuation.\n" +
683
- " • `transpositions` — nodes that share their position with at least one other node in the same file (piece placement + side to move + castling rights match). Response includes `transposes_to: [node_id, …]` per hit so you can see the partners without a second call. Use this BEFORE auto_evaluate on a large branch to see where analysis will double up, and BEFORE writing prose to know which nodes can share commentary via 'transposes to line X'.\n" +
684
- " • `all` — every node id. Use only when you really need the whole list.\n\n" +
685
- "Response: `{ file_id, filter, count, nodes: [{node_id, san, ply, ...}] }`. `...` is filter-specific — e.g. `has_comment` includes the first 80 chars of the comment; `transpositions` includes `transposes_to`; `missing_eval` includes nothing extra (just the addressing).",
686
- inputSchema: {
687
- type: "object",
688
- properties: {
689
- id: { type: "string", description: "Prep file id." },
690
- filter: { type: "string", enum: ["missing_eval", "has_comment", "has_annotations", "mainline", "novelties", "leaves", "transpositions", "all"], description: "Which nodes to list." },
691
- node_id: { type: "string", description: "Subtree root (default `'r'` = whole file)." },
692
- max_depth: { type: "integer", minimum: 0, description: "Cap the walk at this many plies below `node_id`. Omit for unlimited." },
693
- },
694
- required: ["id", "filter"],
695
- },
696
- },
697
- {
698
- name: "list_transpositions",
699
- description: "Group every position in a prep file that appears more than once — the same piece placement + side-to-move + castling rights reached by different move orders. Chess move orders diverge and re-converge constantly (1.d4 Nf6 2.c4 e6 3.Nc3 vs 1.c4 e6 2.Nc3 Nf6 3.d4 land on the same position); if you analyse both branches independently or write the same commentary twice, you're wasting engine time and inviting inconsistency.\n\n" +
700
- "Call this BEFORE `auto_evaluate` on a big subtree to see how much work will actually be new, and BEFORE writing prose to know which nodes can share a comment or should point at each other with 'transposes to line X'.\n\n" +
701
- "Note: engine evals auto-propagate — when `cloud_analyse({file_id, node_id})` stores `ceoEval` on a node, it also stamps every transposition of that position in the same file (see the response's `also_stored_on`). And `auto_evaluate({only_missing: true})` naturally skips the twin because it now has an eval. So detection is cheap AND propagation is automatic; this tool is for prose planning and one-shot audits, not for gating engine work.\n\n" +
702
- "Response: `{ file_id, group_count, node_count, groups: [{ position_key, size, node_ids, sans }] }`. `position_key` is the 3-field FEN prefix used as the match key; `size` is how many nodes share it; `sans` are the moves that led to each occurrence (parallel with `node_ids`, DFS order — first entry is the earliest/mainline-preferred occurrence). Only groups with size ≥ 2 are returned; sorted by size descending.",
703
- inputSchema: {
704
- type: "object",
705
- properties: {
706
- id: { type: "string", description: "Prep file id." },
707
- },
708
- required: ["id"],
709
- },
710
- },
711
- {
712
- name: "create_prep_file",
713
- description: "Create a new (empty) prep file in the specified collection. `name` becomes the Event PGN tag. You then extend it with mutation tools (add_move, set_comment, …).\n\n" +
714
- "**collection_id is REQUIRED** — call `list_collections` first to pick where it lives. There is no default landing folder any more (v0.43: the old hidden `/mcp` collection was removed; prep files now live wherever the user organizes them).\n\n" +
715
- "**Duplicate-check first.** Call `search_prep_files(query=<opponent / opening keyword>)` OR `find_position_in_files(fen=...)` before creating — a second 'Prep vs Firouzja' file when one already exists is a common LLM failure mode. If a file already covers the topic, extend that one instead.\n\n" +
716
- "Response: `{ok, id, collection_id, version}` — `id` is a composite you pass to every other prep-file tool as `id` or `file_id`.",
717
- inputSchema: {
718
- type: "object",
719
- properties: {
720
- collection_id: {
721
- type: "string",
722
- description: "Collection id from list_collections. Required — this is where the new file lands.",
723
- },
724
- name: {
725
- type: "string",
726
- description: "User-facing name — becomes the [Event] tag. Example: 'Prep vs Firouzja (Black) 2026-07-23'.",
727
- },
728
- },
729
- required: ["collection_id", "name"],
730
- },
731
- },
732
- {
733
- name: "delete_prep_file",
734
- description: "Soft-delete a prep file. User can restore from the app's recycle bin. Rare — usually you extend or edit instead.",
735
- inputSchema: {
736
- type: "object",
737
- properties: {
738
- id: { type: "string", description: "Prep file id." },
739
- },
740
- required: ["id"],
741
- },
742
- },
743
- {
744
- name: "add_move",
745
- description: "Append a move as a new child of the node identified by `parent_id`. If the parent already has children, the new move becomes a variation (appended at the end); use promote_variation afterwards to make it the mainline. SAN is validated against the position — illegal moves are rejected with a clear error.\n\n" +
746
- "Auto-saves. Returns `{node_id, version}` — the id of the new node (pass this to follow-up set_comment / set_nags / etc.) and the new file version for optimistic locking. **Node ids are content-derived and stable** — sibling insertions, deletions, and promotions do NOT change any other node's id.",
747
- inputSchema: {
748
- type: "object",
749
- properties: {
750
- id: { type: "string", description: "Prep file id." },
751
- parent_id: { type: "string", description: "Node id of the parent (the position the move is played FROM). Root id is 'r'." },
752
- san: { type: "string", description: "The move in SAN notation (e.g. 'Nf3', 'exd5', 'O-O', 'Qxf7+')." },
753
- expected_version: { type: "integer", description: "Optimistic-lock check; pass the `version` from your last read." },
754
- },
755
- required: ["id", "parent_id", "san"],
756
- },
757
- },
758
- {
759
- name: "add_line",
760
- description: "Append a linear sequence of moves under `parent_id`. Each SAN in the list becomes the mainline child of the previous — one call instead of N add_move calls for a straight variation. If the parent already has other children, this whole line is appended as a variation (promote_variation the first move if you want it as the mainline).\n\n" +
761
- "**Anti-pattern: pasting an engine PV as a single long `add_line`.** Real prep is a tree, not a line. Almost every position along a variation has more than one plausible move — pasting a 12+-ply engine PV without branching at those points is the #1 documented failure mode of this MCP: it produces a page that reads as prep but ignores every decision the opponent actually gets to make. Long unbranched lines get a warning field in the response starting at ~9 plies and a strong warning at 14+ plies. Rule of thumb: if you added ≥8 plies in one call, at least half of them should have branched. Genuine exceptions exist (forced mates, obligated exchange sequences) — in those cases add a comment naming what makes the sequence forced (`{Every move here is forced by the mate threat.}`), so the reader knows it's forced by chess, not by LLM laziness.\n\n" +
762
- "Auto-saves. Returns `{node_id, line: [{node_id, san}, ...], version}` — `node_id` is the last (leaf) node's id, `line` is every node created in order so you can address any of them next. When long-and-linear, also includes `warning: \"...\"`.",
763
- inputSchema: {
764
- type: "object",
765
- properties: {
766
- id: { type: "string", description: "Prep file id." },
767
- parent_id: { type: "string", description: "Node id of the parent to build from. Root is 'r'." },
768
- sans: { type: "array", items: { type: "string" }, minItems: 1, description: "SAN moves in order, e.g. ['e4','e5','Nf3','Nc6','Bb5']." },
769
- expected_version: { type: "integer" },
770
- },
771
- required: ["id", "parent_id", "sans"],
772
- },
773
- },
774
- {
775
- name: "set_comment",
776
- description: "Set (or clear, with empty string) the text comment on the node identified by `node_id`. Comments are for plans, prep-signal, and interpretation the app can't derive — NOT for describing moves that should be variations instead. Auto-saves.\n\n" +
777
- "**Two guardrails fire in the response as `warnings: [...]`:**\n" +
778
- " 1. **Content scan** — comments containing spread lists (`≈50, ≈42, …`), raw centipawn values (`≈−60`, `+0.35`, `at depth 24`), or roster restatement (`146 GM games — Nakamura, …`) are all restating what the app already renders. The warning names the fix (set the NAG and drop the number; label the character not the numbers; cite a specific game instead of a count).\n" +
779
- " 2. **Ungrounded prose** — substantive comments (≥40 chars) on a node whose position was never passed to `describe_position` this session are prone to hallucinated structural claims (piece on wrong square, invented captures, misidentified pawn structure). Call `describe_position` with `file_id`+`node_id` BEFORE writing prose about the position; the same node's warning clears once the position is described.",
780
- inputSchema: {
781
- type: "object",
782
- properties: {
783
- id: { type: "string", description: "Prep file id." },
784
- node_id: { type: "string", description: "Node id from read_prep_file / add_move." },
785
- comment: { type: "string", description: "New comment text. Empty string clears." },
786
- expected_version: { type: "integer" },
787
- },
788
- required: ["id", "node_id", "comment"],
789
- },
790
- },
791
- {
792
- name: "set_nags",
793
- description: "Replace the list of NAGs on the node identified by `node_id`. Empty array clears them. NAGs are your EDITORIAL call — see read_pgn_authoring_guide for the discipline (novelty $146, sharp choice $5, decisive $18/$19, etc.). Do NOT set $10 '=' on every equal position; that's board noise. Auto-saves.",
794
- inputSchema: {
795
- type: "object",
796
- properties: {
797
- id: { type: "string" },
798
- node_id: { type: "string" },
799
- nags: { type: "array", items: { type: "string", pattern: "^\\$\\d+$" }, description: "NAG list, e.g. ['$14'] or ['$146', '$44']." },
800
- expected_version: { type: "integer" },
801
- },
802
- required: ["id", "node_id", "nags"],
803
- },
804
- },
805
- {
806
- name: "set_annotations",
807
- description: "Replace the visual annotations (arrows + coloured squares) on the node identified by `node_id`. Passing empty arrays clears them.\n\n" +
808
- "Colours: green, red, yellow, light-blue, dark-blue, orange. Keep it LIGHT: 1-3 arrows and 2-3 squares per move maximum. Twenty arrows is noise, not signal. Auto-saves.",
809
- inputSchema: {
810
- type: "object",
811
- properties: {
812
- id: { type: "string" },
813
- node_id: { type: "string" },
814
- arrows: {
815
- type: "array",
816
- items: {
817
- type: "object",
818
- properties: {
819
- color: { type: "string", enum: ["green", "red", "yellow", "light-blue", "dark-blue", "orange"] },
820
- from: { type: "string", pattern: "^[a-h][1-8]$" },
821
- to: { type: "string", pattern: "^[a-h][1-8]$" },
822
- },
823
- required: ["color", "from", "to"],
824
- },
825
- },
826
- highlights: {
827
- type: "array",
828
- items: {
829
- type: "object",
830
- properties: {
831
- color: { type: "string", enum: ["green", "red", "yellow", "light-blue", "dark-blue", "orange"] },
832
- square: { type: "string", pattern: "^[a-h][1-8]$" },
833
- },
834
- required: ["color", "square"],
835
- },
836
- },
837
- expected_version: { type: "integer" },
838
- },
839
- required: ["id", "node_id"],
840
- },
841
- },
842
- {
843
- name: "delete_subtree",
844
- description: "Delete the node identified by `node_id` and all its descendants. Refuses to delete the root. Auto-saves.",
845
- inputSchema: {
846
- type: "object",
847
- properties: {
848
- id: { type: "string" },
849
- node_id: { type: "string" },
850
- expected_version: { type: "integer" },
851
- },
852
- required: ["id", "node_id"],
853
- },
854
- },
855
- {
856
- name: "promote_variation",
857
- description: "Make the node identified by `node_id` its parent's mainline (children[0]), demoting the current mainline (and any other siblings) into variation order. Silently no-op if already the mainline. Auto-saves.",
858
- inputSchema: {
859
- type: "object",
860
- properties: {
861
- id: { type: "string" },
862
- node_id: { type: "string", description: "Node id of the variation to promote. Cannot be root." },
863
- expected_version: { type: "integer" },
864
- },
865
- required: ["id", "node_id"],
866
- },
867
- },
868
- {
869
- name: "apply_mutations",
870
- description: "Batch: apply a list of mutations in one call. One load-parse-mutate-export-save cycle for N ops, so building a 100-move repertoire costs one HTTP round-trip and one save instead of 100. This is the RIGHT way to build a file — use single mutations only for surgical follow-up edits.\n\n" +
871
- "Each mutation is `{op, node_id | parent_id, ...args}` where `op` is one of: add_move, add_line, set_comment, set_nags, set_annotations, delete_subtree, promote_variation, set_tag. Same arg shape as the individual tools. Ops apply in order; because node ids are content-derived (hash of parent_id + san), a node created by an early op has a deterministic id you can reference in later ops in the same batch.\n\n" +
872
- "Any op error aborts the batch (nothing saved). Response is `{ok, results: [{node_id, line?}], version}` — one entry per op with the id it landed on (add_line also returns the full line array).",
873
- inputSchema: {
874
- type: "object",
875
- properties: {
876
- id: { type: "string" },
877
- expected_version: { type: "integer" },
878
- mutations: {
879
- type: "array",
880
- minItems: 1,
881
- items: {
882
- type: "object",
883
- properties: {
884
- op: { type: "string", enum: ["add_move", "add_line", "set_comment", "set_nags", "set_annotations", "delete_subtree", "promote_variation", "set_tag"] },
885
- node_id: { type: "string" },
886
- parent_id: { type: "string" },
887
- san: { type: "string" },
888
- sans: { type: "array", items: { type: "string" } },
889
- comment: { type: "string" },
890
- nags: { type: "array", items: { type: "string", pattern: "^\\$\\d+$" } },
891
- arrows: { type: "array" },
892
- highlights: { type: "array" },
893
- key: { type: "string" },
894
- value: { type: "string" },
895
- },
896
- required: ["op"],
897
- },
898
- },
899
- },
900
- required: ["id", "mutations"],
901
- },
902
- },
903
- {
904
- name: "auto_evaluate",
905
- description: "Walk the tree from `node_id` (default `'r'` = whole file) and populate the persistent `ceoEval` on every descendant via cloud_analyse. Requires a running cloud combo instance.\n\n" +
906
- "**Async job — returns immediately.** Response: `{ job_id, target_count, status: 'running', estimated_seconds }`. Then poll `auto_evaluate_status(job_id)` until `done: true`. Cancel a run with `auto_evaluate_cancel(job_id)` — partial progress is preserved. Do useful other work between polls (write more of the tree, walk the opponent's repertoire) — the engine runs in the background.\n\n" +
907
- "Progress is checkpointed to the prep file every 8 successfully-evaluated nodes, so a cancel / crash / MCP restart mid-run leaves the tree partially populated rather than losing everything. On MCP restart the job record disappears; re-run auto_evaluate and `only_missing=true` naturally skips what was already saved.\n\n" +
908
- "**Does NOT set visible NAGs.** NAG placement is your call, not the engine's — an opening tree full of 0.00 positions doesn't need a `$10` (=) glyph on every move. Use quote_engine_eval on individual nodes before writing prose that references engine numbers.\n\n" +
909
- "Costs real money — one cloud_analyse per node. A 200-node walk at default movetime is ~5 min of engine time (calls serialise on the per-combo semaphore in the backend).",
910
- inputSchema: {
911
- type: "object",
912
- properties: {
913
- id: { type: "string" },
914
- node_id: { type: "string", description: "Subtree root (default 'r' = whole file)." },
915
- only_missing: { type: "boolean", description: "Skip nodes that already carry a stored ceoEval (default true)." },
916
- movetime_ms: { type: "integer", minimum: 500, maximum: 5000, description: "Per-node cloud_analyse think time (default 1500)." },
917
- },
918
- required: ["id"],
919
- },
920
- },
921
- {
922
- name: "auto_evaluate_status",
923
- description: "Poll the status of an auto_evaluate job. Response: `{ status: 'running' | 'done' | 'cancelled' | 'error' | 'not_found', target_count, evaluated, errored, remaining, done, error?, version? }`. When `status: 'not_found'` the job either expired (kept ~15 min after completion), never existed, or the MCP restarted since it was created — re-run auto_evaluate.\n\n" +
924
- "Typical poll cadence: every 3-5 s for small walks, every 10-30 s for large ones. Don't hammer — status is a pure in-memory read but polling doesn't speed the engine up.",
925
- inputSchema: {
926
- type: "object",
927
- properties: {
928
- job_id: { type: "string", description: "Job id from the `auto_evaluate` response." },
929
- },
930
- required: ["job_id"],
931
- },
932
- },
933
- {
934
- name: "auto_evaluate_cancel",
935
- description: "Ask a running auto_evaluate job to stop as soon as its current node finishes. Whatever progress was completed before cancellation is durably saved (checkpoint on cancel). Idempotent — cancelling an already-finished job is a no-op with a clear note in the response.",
936
- inputSchema: {
937
- type: "object",
938
- properties: {
939
- job_id: { type: "string", description: "Job id from the `auto_evaluate` response." },
940
- },
941
- required: ["job_id"],
942
- },
943
- },
944
- {
945
- name: "deep_analyse",
946
- description: "Start a long Stockfish think on a single position (up to 5 min movetime). Returns a `job_id` immediately; poll `deep_analyse_status(job_id)` for the result, cancel with `deep_analyse_cancel(job_id)`. Runs SF only — Lc0 doesn't benefit from long thinks past a handful of seconds — and **holds only the SF engine slot on the combo, so `cloud_analyse(..., engines: [\"lc0\"])` stays available for other work in parallel**.\n\n" +
947
- "Use this when a specific critical position deserves depth — a novelty candidate, a hairy tactical shot, a difficult endgame — and you want Stockfish at depth 35+ rather than the ~depth 22 you get from a 2s cloud_analyse. Movetime is in ms; typical: 30_000-60_000 for 'careful check', 120_000-300_000 for 'find the truth'.\n\n" +
948
- "Result shape when done matches cloud_analyse's Stockfish leg (depth, top-N candidates with scoreCp/mate, best move, PV). Auto-stores the eval on `file_id`+`node_id` when both are supplied, same as cloud_analyse.",
949
- inputSchema: {
950
- type: "object",
951
- properties: {
952
- file_id: { type: "string", description: "Prep file id. Combine with `node_id` to derive FEN from the tree AND persist the result on the node's ceoEval." },
953
- node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`/`moves`." },
954
- fen: { type: "string", description: "Position as FEN. Only used if `node_id` is not set." },
955
- moves: { type: "string", description: "Optional SAN moves on top of `fen`. Only used if `node_id` is not set." },
956
- movetime_ms: {
957
- type: "integer",
958
- minimum: 5_000,
959
- maximum: 300_000,
960
- description: "Think time in ms. Default 60_000 (1 min). Max 300_000 (5 min).",
961
- },
962
- multipv: {
963
- type: "integer",
964
- minimum: 1,
965
- maximum: 10,
966
- description: "Number of candidate lines (default 2). Stockfish gets weaker as multipv grows — each extra PV steals search bandwidth from the top choice — so keep this low unless you specifically want to see several candidates ranked deep.",
967
- },
968
- },
969
- },
970
- },
971
- {
972
- name: "deep_analyse_status",
973
- description: "Poll a deep_analyse job. Response: `{ status: 'running' | 'done' | 'cancelled' | 'error' | 'not_found', elapsed_ms, movetime_ms, result?, error? }`. `result` shape when done: `{ engine, depth, timeMs, bestMove, lines: [{rank, depth, scoreCp?, mate?, pv, nodes?}] }` — the SF leg of a cloud_analyse response.\n\n" +
974
- "Poll cadence: every ~15-30s for long thinks; there's no penalty for polling more often but the engine progresses at its own pace.",
975
- inputSchema: {
976
- type: "object",
977
- properties: {
978
- job_id: { type: "string", description: "Job id from `deep_analyse`." },
979
- },
980
- required: ["job_id"],
981
- },
982
- },
983
- {
984
- name: "deep_analyse_cancel",
985
- description: "Ask a running deep_analyse job to stop early. The engine returns whatever it's found so far as the final result. Useful when a partial result at depth 25 is enough and you don't want to wait for depth 40. Idempotent for already-finished jobs.",
986
- inputSchema: {
987
- type: "object",
988
- properties: {
989
- job_id: { type: "string", description: "Job id from `deep_analyse`." },
990
- },
991
- required: ["job_id"],
992
- },
993
- },
994
- {
995
- name: "find_position_in_courses",
996
- description: "Look up which of the USER's own Chessable / PGN courses cover a position. This is the LLM's window into what the user has personally studied — not a general database. Two-step: `find_position_in_courses` returns metadata (course, chapter, author, updated_at, notes_chars, `course_file_id`); `read_course_at_position` fetches the actual commentary + variations from a specific hit.\n\n" +
997
- "**Read multiple hits, not just the top one.** A search commonly returns 3-10 courses covering the same position. Different authors recommend different moves, weight lines differently, and disagree about which sidelines matter — that disagreement is exactly the information you want. Default assumption: read the top 3-5 hits by recency, more if the position is critical (novelty candidate, main-line trunk, sharp tactical junction). Reading only the first hit gives you one author's opinion; reading five gives you the actual state of theory as your user's library sees it.\n\n" +
998
- "Use it as a reference library, not memory. Query patterns:\n" +
999
- " • 'Does my chosen line have coverage?' → search from the position, read multiple hits, see whether the field agrees on the main response.\n" +
1000
- " • 'What do opposite-colour repertoires recommend against this move?' → search, then read every hit whose author/course maps to the other side.\n" +
1001
- " • 'Has anyone tried my novelty before?' → search the position, if hits exist read all of them (a novelty that appears in ONE 2019 course is still a novelty to serious opponents; a novelty covered by three 2025 courses is not).\n" +
1002
- " • 'What are the main disagreements between authors?' → read the top 3-5 hits, diff the recommended moves against each other; if two Chessable authors branch differently at move 8, that's a decision point worth annotating in your own file.\n\n" +
1003
- "Default sort is `recency` (most-recently-updated file first — theory shifts, 10-year-old material is less trustworthy than 2-month-old). Switch to `notes` when you specifically want the deepest annotated chapter regardless of age.\n\n" +
1004
- "Returns: `{fen, found, total_occurrences, sort, excluded, hits: [{course_file_id, course, file, author, chapter, line, ply, notes_chars, subtree_moves, updated_at}], truncated}`. Pass `course_file_id` to `read_course_at_position` to actually see the material — and pass it more than once, on the top few hits, not just the first one.\n\n" +
1005
- "Not available if the fenfind index isn't installed on the server — response includes a clear note in that case.",
1006
- inputSchema: {
1007
- type: "object",
1008
- properties: {
1009
- file_id: { type: "string", description: "Prep file id. Combine with `node_id` to derive FEN from the tree." },
1010
- node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`/`moves`." },
1011
- fen: { type: "string", description: "Starting position as FEN. Only used if `node_id` is not set." },
1012
- moves: { type: "string", description: "Optional SAN moves on top of `fen` (or startpos). Only used if `node_id` is not set." },
1013
- sort: { type: "string", enum: ["recency", "notes"], description: "Ranking. `recency` (default) = most-recently-updated file first. `notes` = deepest annotation first regardless of age." },
1014
- include_games: { type: "boolean", description: "Include hits from game-database PGNs (player headers instead of course/chapter titles). Default false — those are noise for course-lookup." },
1015
- chapters_mode: { type: "boolean", description: "Return every chapter separately rather than best-per-course. Default false. Useful when a course has multiple chapters covering the same position." },
1016
- min_notes_chars: { type: "number", description: "Minimum notes_chars per hit to be included. Default 400 (~a paragraph of prose). Set to 0 to see every occurrence." },
1017
- limit: { type: "integer", description: "Max hits to return (default 25)." },
1018
- },
1019
- },
1020
- },
1021
- {
1022
- name: "read_course_at_position",
1023
- description: "Read the actual commentary + variations from a course file at a specific position. Second half of the find→read pair — `find_position_in_courses` returns metadata; this returns the material itself.\n\n" +
1024
- "Response includes the subtree as PGN (comments, NAGs, `[%cal]`/`[%csl]` arrows all preserved), plus the moves-to-position and chapter metadata. Depth-capped by `max_plies_below` (default 20) to keep responses small — widen when you want to see deeper analysis, or call with a different `fen` to jump to another position in the same file.\n\n" +
1025
- "**Called once per search is a smell.** When `find_position_in_courses` returned 5 hits and you only read the first, you have 1 author's view of the position, not a survey. Read the top 3-5 hits by default; compare their recommendations and disagreements — that comparison is the value the user's library provides over your training data.\n\n" +
1026
- "Usage patterns:\n" +
1027
- " • Read what an author says about a specific position → pass `course_file_id` from a find hit + the FEN.\n" +
1028
- " • Explore a chapter from move 1 → pass `course_file_id` + `chapter`, no FEN.\n" +
1029
- " • Skim deeper into a branch you're interested in → same file/chapter, wider `max_plies_below`.\n" +
1030
- " • **Compare how multiple authors annotate the same position → several calls with different `course_file_id`s (this is the common case, not the exception).** If the top hits recommend different moves, that's a decision point worth annotating with the disagreement itself.",
1031
- inputSchema: {
1032
- type: "object",
1033
- properties: {
1034
- course_file_id: { type: "integer", description: "File id from a `find_position_in_courses` hit (`course_file_id` field)." },
1035
- fen: { type: "string", description: "Position to walk to (matched by polyglot Zobrist hash, so move-order transpositions work). Omit to return the chapter from move 1." },
1036
- moves: { type: "string", description: "Alternative to `fen`: SAN moves from startpos." },
1037
- chapter: { type: "string", description: "Substring match on chapter title (the White header in the PGN). Omit to auto-pick the first chapter containing the position; supply when a course has multiple chapters and you want a specific one." },
1038
- max_plies_below: { type: "integer", minimum: 0, maximum: 200, description: "How many plies of subtree to include below the target position. Default 20. Cap 200." },
1039
- },
1040
- required: ["course_file_id"],
1041
- },
1042
- },
1043
- {
1044
- name: "quote_engine_eval",
1045
- description: "Return the stored engine eval for a node, or null if that node was never analysed. **Call this before writing prose or NAGs that quote engine numbers** — if it returns null, you have no measurement to cite. Do NOT infer an eval for the node from siblings or children; either analyse it (cloud_analyse with node_id) or omit the number from your prose.\n\n" +
1046
- "Response: `{ ceoEval: { sf: {cp, depth}, lc0: {cp, depth}, nag } | null }`. `cp` is White-POV centipawns as an integer (+20 = +0.20). `nag` is the threshold-derived glyph as a SUGGESTION — promote to a visible NAG via set_nags only when a glyph on that move carries editorial signal.",
1047
- inputSchema: {
1048
- type: "object",
1049
- properties: {
1050
- id: { type: "string", description: "Prep file id." },
1051
- node_id: { type: "string", description: "Node id whose stored eval you want to quote." },
1052
- },
1053
- required: ["id", "node_id"],
1054
- },
1055
- },
1056
- {
1057
- name: "set_tag",
1058
- description: "Set or clear a game-level PGN tag (Event, Site, Date, White, Black, Result, or any custom tag). Passing empty string removes the tag. Auto-saves.",
1059
- inputSchema: {
1060
- type: "object",
1061
- properties: {
1062
- id: { type: "string" },
1063
- key: { type: "string", description: "Tag key, e.g. 'Event', 'White', 'Date'." },
1064
- value: { type: "string", description: "Tag value. Empty string removes the tag." },
1065
- expected_version: { type: "integer" },
1066
- },
1067
- required: ["id", "key", "value"],
1068
- },
1069
- },
1070
- {
1071
- name: "read_engine_usage_guide",
1072
- description: "Returns the full chess.ceo engine-usage guide: when to trust Stockfish (objective truth) vs Lc0 (practical eval), how to read disagreements between them, and how to use Lc0 contempt to find non-objective 'practical' ideas. Call this ONCE per session before running expensive `cloud_analyse` calls or when the user asks WHY the engines gave certain scores. Same content is also available as the `engine_usage_primer` prompt (for clients that surface prompts as slash commands), but many clients do not expose prompts to the model — this tool works everywhere.",
1073
- inputSchema: { type: "object", properties: {} },
1074
- },
1075
- {
1076
- name: "read_opening_prep_guide",
1077
- description: "**CALL WHEN**: the user asks about OPENING PREPARATION — 'prep me against X', 'what should I play vs the Najdorf', 'help me build a repertoire against 1.e4', 'walk this opponent's Sveshnikov'. This guide is chess-and-analysis philosophy, not storage semantics.\n\n" +
1078
- "Covers: why win% is one weight not a verdict, why prep is a two-player game with symmetric information (opponent sees your history too), how sample size and recency change the reading, when 'revealed weaknesses' are actionable vs already patched, how to choose between the GM-classical DB and the main DB, when to combine chesscom/lichess sources with FIDE, the three chess.com profile shapes (consistent / eclectic / split-personality), the reversed-colours scarcity trick, how to calibrate surprise (rare secondary lines inside the existing repertoire, not big first-move switches).\n\n" +
1079
- "Different tool: `read_prep_files_guide` covers the FILE STORAGE feature (how to list/create/save prep files) — call that only when about to manipulate files, not for opening questions.",
1080
- inputSchema: { type: "object", properties: {} },
1081
- },
1082
- {
1083
- name: "read_prep_files_guide",
1084
- description: "**CALL WHEN**: you're about to CREATE, LIST, SAVE, or DELETE a prep file — the persistent file storage feature. Not for opening prep philosophy (that's `read_opening_prep_guide`) and not for how to write PGN (that's `read_pgn_authoring_guide`).\n\n" +
1085
- "Covers: the AI Prep folder, when to list vs search vs create (avoid duplicate 'Prep vs Firouzja' files), optimistic locking with `version`, naming conventions for the [Event] tag, node-id addressing basics.",
1086
- inputSchema: { type: "object", properties: {} },
1087
- },
1088
- {
1089
- name: "read_pgn_authoring_guide",
1090
- description: "Returns the guide on how to write correct, useful PGN — mainline discipline, variations as moves (never prose describing moves), NAG symbols including novelty ($146), unclear ($13), compensation ($44) and the standard set, ChessBase arrow/coloured-square syntax ([%cal] / [%csl]), and common pitfalls the parser will reject. Call this ONCE per session before any save_prep_file call, or any time you're producing PGN output for the user.",
1091
- inputSchema: { type: "object", properties: {} },
1092
- },
1093
- {
1094
- name: "read_example_prep_files",
1095
- description: "**CALL WHEN**: about to write ANY prose commentary in a prep file, ever. Even one comment. Even one variation. This is not optional and not once-per-project — call it early in the session and read the examples before your first `set_comment` or `apply_mutations` batch that includes comments. Log analysis showed <5% of sessions call this despite it being the single biggest quality lift documented in this MCP; that's the mistake this description is trying to fix.\n\n" +
1096
- "Why: `read_pgn_authoring_guide` tells you the rules in prose. These files show you the *sound* of them applied by a strong human coach — comment density (short and load-bearing, not verbose), how citations look in-line (`WeiYi-Svidler` not `\"Svidler's choice at the FIDE World Blitz Team, June 2026\"`), when `$146` / `$3` / `$44` earn their place, when a bare `[%csl Rf7]` says everything a sentence would say. LLMs default to florid, restate-what's-visible commentary; reading these once inoculates against that.\n\n" +
1097
- "Two files bundled with the MCP (not the user's own): one general opening overview (Italian Fried Liver, both sides, 1600+ audience) and one one-sided repertoire (Najdorf 6.f4 for White, 2200+ audience). Response: `{ overview: <pgn>, repertoire: <pgn> }` — raw PGN with comments, arrows, NAGs, stored evals intact.",
1098
- inputSchema: { type: "object", properties: {} },
1099
- },
1100
- {
1101
- name: "prep_snapshot",
1102
- description: "One call, three parallel fetches at the same position: opponent's stats on their side, your stats on your side, and the 11.7M-game general database at that position. Use this while walking the opening tree — one round trip instead of three separate calls, and you can compare the three views directly (e.g. opponent has 2 games here but the general DB has 8k → prep candidate).\n\n" +
1103
- "AUTO-EVAL: if a cloud combo instance is running, the response includes a top-level `.eval` (Stockfish + Lc0 read at the shared position) so you get four signals in one call. Do NOT fire cloud_analyse separately for the same FEN.",
1104
- inputSchema: {
1105
- type: "object",
1106
- properties: {
1107
- fide_id_me: { type: "integer", description: "Your FIDE ID." },
1108
- fide_id_opponent: { type: "integer", description: "Opponent's FIDE ID." },
1109
- my_color: { type: "string", enum: ["white", "black"], description: "The colour YOU will play." },
1110
- file_id: { type: "string", description: "Prep file id. **Prefer file_id+node_id** when inside a prep file." },
1111
- node_id: { type: "string", description: "Node id inside `file_id`. When set, overrides `line`/`fen`." },
1112
- line: {
1113
- type: "string",
1114
- description: "Move sequence in SAN, space-separated. Empty = starting position. Only used if `node_id` is not set.",
1115
- },
1116
- fen: {
1117
- type: "string",
1118
- description: "Alternative to line — raw FEN of the target position. Only used if `node_id` is not set.",
1119
- },
1120
- },
1121
- required: ["fide_id_me", "fide_id_opponent", "my_color"],
1122
- },
1123
- },
1124
- ];
102
+ const ENGINE_USAGE_DOC = loadBundledDoc("engine-usage.md", "Engine usage guide");
103
+ const PREP_STRATEGY_DOC = loadBundledDoc("prep-strategy.md", "Prep strategy guide");
104
+ const PREP_FILES_DOC = loadBundledDoc("prep-files-guide.md", "Prep files guide");
105
+ const PGN_AUTHORING_DOC = loadBundledDoc("pgn-authoring.md", "PGN authoring guide");
106
+ // Reference PGNs authored by a strong human coach. LLM pulls these when
107
+ // it wants to see the commentary style, NAG discipline, and annotation
108
+ // density we want it to hit. Kept as raw PGN so the LLM can parse them
109
+ // against its own understanding of the game (comments, arrows, NAGs
110
+ // all intact) — not summarised into English.
111
+ const EXAMPLE_OVERVIEW_PGN = loadBundledDoc("examples/italian-fried-liver.pgn", "Italian Fried Liver overview example");
112
+ const EXAMPLE_REPERTOIRE_PGN = loadBundledDoc("examples/najdorf-6-f4-white.pgn", "Najdorf 6.f4 White repertoire example");
1125
113
  // Log every tool call in and out. Keeps args + response payloads together
1126
114
  // with a per-call duration so we can trace what the LLM asked for and what
1127
115
  // it got back on the same journalctl line. Response is JSON-stringified and
1128
116
  // capped so the two doc-reading tools (~5-10 KB of static markdown each)
1129
117
  // don't drown the log stream.
1130
118
  const LOG_MAX_CHARS = 4096;
1131
- // Convert a UCI move sequence into SAN by walking it move-by-move on
1132
- // chess.js from the given starting FEN. LLMs reason far better in SAN
1133
- // ("Nf3", "Bxc4") than UCI ("g1f3", "b5c4"), and matches how prep
1134
- // discussion is written in the real world. If a move fails to parse
1135
- // (illegal from the current position — bug or truncated PV), we
1136
- // truncate cleanly rather than throwing so the response still carries
1137
- // what we could convert.
1138
- function uciLineToSAN(startFen, uciMoves) {
1139
- const board = new Chess(startFen);
1140
- const out = [];
1141
- for (const uci of uciMoves) {
1142
- if (uci.length < 4)
1143
- break;
1144
- try {
1145
- const move = board.move({
1146
- from: uci.slice(0, 2),
1147
- to: uci.slice(2, 4),
1148
- promotion: uci.length >= 5 ? uci[4] : undefined,
1149
- });
1150
- if (!move)
1151
- break;
1152
- out.push(move.san);
1153
- }
1154
- catch {
1155
- break;
1156
- }
1157
- }
1158
- return out;
1159
- }
1160
- function uciMoveToSAN(startFen, uci) {
1161
- if (!uci || uci.length < 4)
1162
- return uci;
1163
- const board = new Chess(startFen);
1164
- try {
1165
- const move = board.move({
1166
- from: uci.slice(0, 2),
1167
- to: uci.slice(2, 4),
1168
- promotion: uci.length >= 5 ? uci[4] : undefined,
1169
- });
1170
- return move ? move.san : uci;
1171
- }
1172
- catch {
1173
- return uci;
1174
- }
1175
- }
1176
- async function fetchCompactEval(fen) {
1177
- try {
1178
- const raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen, movetime_ms: 1500, multipv: 1 });
1179
- const converted = convertCloudSnapshotResponse(raw, fen);
1180
- const stored = analysisToStoredEval(converted);
1181
- return storedEvalToCompact(stored, converted);
1182
- }
1183
- catch {
1184
- return null;
1185
- }
1186
- }
1187
- // Rewrite the /api/agent/cloud-engines/analyse response (two engines,
1188
- // each with lines[] and a bestMove) so PVs and bestMove come back in SAN.
1189
- // Session-lifetime memory of which positions the LLM has actually asked
1190
- // the DB about via `get_position_stats`. Keyed by the 3-field FEN
1191
- // (piece placement + side to move + castling — same key used for
1192
- // transposition detection). Used to warn on `add_move` / `add_line` under
1193
- // a parent the LLM never DB-checked, which is the exact shape of the
1194
- // bug where the LLM read course chapters and cargo-culted a "mainline"
1195
- // that the actual games at the position don't play.
1196
- //
1197
- // One MCP server process per user, so this Set is effectively per-user
1198
- // for the length of a session. Not persisted — a new session starts empty.
1199
- const positionsStatsChecked = new Set();
1200
- // Nodes we've ALREADY warned on for the "no stats check" pattern this
1201
- // session, so repeated adds under the same parent don't spam the LLM.
1202
- const noStatsWarned = new Set();
1203
- // Session-lifetime memory of positions the LLM has called
1204
- // `describe_position` on. Same 3-field FEN key. Live-log audit
1205
- // (2026-07-27 Modern Defence session): 13 describe_position calls
1206
- // vs 50+ set_comment ops — most comments were written blind. When
1207
- // describe_position IS called before commentary, prose accuracy
1208
- // jumps sharply (user's own observation). This tracks the same way
1209
- // as positionsStatsChecked and drives the noDescribeWarning below.
1210
- const positionsDescribed = new Set();
1211
- // Once-per-node dedup for the describe warning.
1212
- const noDescribeWarned = new Set();
1213
- // Detect the anti-patterns the LLM keeps producing in comment prose.
1214
- // All of these restate what the app already renders elsewhere:
1215
- // - spread lists ("5.O-O ≈50, 6.h3 ≈42, ...")
1216
- // - raw centipawn values in prose ("≈-60", "+0.35", "at depth 24")
1217
- // - long roster restatement ("146 GM games, Nakamura, Kramnik, MVL")
1218
- // Return an array of warning strings — one per matched category — so the
1219
- // LLM sees exactly which pattern to remove.
1220
- function commentAntiPatterns(comment) {
1221
- if (!comment || typeof comment !== "string")
1222
- return [];
1223
- const warns = [];
1224
- // Spread list: ≈ followed by a 2-3-digit number, appearing 2+ times
1225
- // (one appearance is a stray, two+ is a comma-separated spread the LLM
1226
- // pasted from stats output).
1227
- const spreadMatches = comment.match(/≈\s*[+\-−]?\d{1,3}/g) ?? [];
1228
- if (spreadMatches.length >= 2) {
1229
- warns.push("comment contains a spread list (≈ + counts) — the DB viewer already shows sibling counts and fashion scores next to every move, so this is doubled noise. Name the character of the choice instead (\"solid vs sharp\", \"old vs fashionable\") or drop the numbers.");
1230
- }
1231
- // Raw centipawn in prose: "+0.35", "-0.20", "+80" (not preceded by move
1232
- // number). Also "at depth N" or "N nodes" — engine metadata as prose.
1233
- if (/(?:^|[^\d.])[+-]\d\.\d\d(?!\d)/.test(comment) || /≈\s*[+\-−]?\d{2,3}\b/.test(comment) ||
1234
- /\bat depth \d+\b/i.test(comment) || /\b\d{2,3}M nodes\b/.test(comment)) {
1235
- warns.push("comment contains raw centipawn values or engine metadata — the app renders ceoEval + NAG glyph next to every node, so these numbers are doubled noise AND opaque (readers can't tell if ≈-60 means eval, spread, or something else). Set the NAG (set_nags) and let the glyph carry the judgment; drop the number from the prose.");
1236
- }
1237
- // Roster: "N GM games" pattern
1238
- if (/\b\d{2,4}\s+GM games\b/i.test(comment)) {
1239
- warns.push("comment restates game count — the app shows the count on hover. Either cite a specific game with signal (\"Caruana-Liang, Superbet 2026\") or drop the number.");
1240
- }
1241
- return warns;
1242
- }
1243
- // Return a warning string when an add_line is suspiciously long-and-linear
1244
- // (the anti-pattern: LLM pastes a 15-ply engine PV into a single add_line
1245
- // call as if it were prepared repertoire). Two thresholds so the message
1246
- // escalates — a 10-ply Berlin mainline is fine, a 20-ply LLM extrapolation
1247
- // almost never is. Threshold applies at the CALL level, not against
1248
- // existing tree depth — the anti-pattern is a single tool call adding
1249
- // many plies at once with no user thought about where the branching should
1250
- // live.
1251
- function longLineWarning(sansLength) {
1252
- if (sansLength >= 14) {
1253
- return `you added ${sansLength} plies in one call without branching — this is the shape of a pasted engine PV, not a repertoire. Real prep branches at every ply where the opponent has meaningful alternatives. Either (a) delete the tail and rebuild with add_move at each decision point, calling cloud_analyse + get_position_stats to see what actually gets played, or (b) if this really is one forcing sequence (mate combination, tactical winner), add a comment naming what makes it forced. Long unbranched lines with no comment default to "engine PV pasted as prep" in the reader's eyes.`;
1254
- }
1255
- if (sansLength >= 9) {
1256
- return `${sansLength}-ply linear line — check that every ply is a genuine only-move or a documented mainline. If the opponent has real alternatives at any ply (get_position_stats would show 2+ moves with meaningful frequency), that ply should branch instead. Prep is a tree, not a line.`;
1257
- }
1258
- return undefined;
1259
- }
1260
- // Trim every PV in a converted cloud-analyse response to `maxPlies`
1261
- // and mark each trimmed line with `pv_truncated: true` so the LLM
1262
- // sees what happened. Applied ONLY to cloud_analyse (short synchronous
1263
- // snapshot); deep_analyse is the explicit "give me the deep line"
1264
- // tool and keeps its full PV.
1265
- function capPvsInResponse(converted, maxPlies) {
1266
- if (!converted || typeof converted !== "object")
1267
- return;
1268
- const r = converted;
1269
- for (const eng of [r.stockfish, r.lc0]) {
1270
- if (!eng || !Array.isArray(eng.lines))
1271
- continue;
1272
- for (const line of eng.lines) {
1273
- if (Array.isArray(line.pv) && line.pv.length > maxPlies) {
1274
- line.pv = line.pv.slice(0, maxPlies);
1275
- line.pv_truncated = true;
1276
- }
1277
- }
1278
- }
1279
- converted.pv_max_plies = maxPlies;
1280
- }
1281
- function convertCloudSnapshotResponse(raw, startFen) {
1282
- if (!raw || typeof raw !== "object")
1283
- return raw;
1284
- const r = raw;
1285
- for (const eng of [r.stockfish, r.lc0]) {
1286
- if (!eng)
1287
- continue;
1288
- if (Array.isArray(eng.lines)) {
1289
- for (const line of eng.lines) {
1290
- if (Array.isArray(line.pv))
1291
- line.pv = uciLineToSAN(startFen, line.pv);
1292
- }
1293
- }
1294
- if (typeof eng.bestMove === "string")
1295
- eng.bestMove = uciMoveToSAN(startFen, eng.bestMove);
1296
- }
1297
- return raw;
1298
- }
1299
- // Extract a node id from the args. Accepts either `node_id` or a
1300
- // `parent_id` alias for the add-style tools. Throws with a helpful
1301
- // message if malformed.
1302
- function argNodeId(args, key = "node_id") {
1303
- const raw = args[key];
1304
- if (typeof raw !== "string" || raw.length === 0) {
1305
- throw new Error(`\`${key}\` is required (call read_prep_file to get valid node ids)`);
1306
- }
1307
- return raw.trim();
1308
- }
1309
- // Dispatch table for the batch tool: name → mutator that returns
1310
- // { file, id } where id is the node the mutation touched. The batch
1311
- // caller rebuilds the id → path index between ops so newly-created
1312
- // nodes are addressable within the same batch.
1313
- function dispatchMutation(file, idIndex, op) {
1314
- const kind = String(op.op);
1315
- // Small local helper — resolves a node_id (or parent_id) op field to
1316
- // a path against the CURRENT tree state.
1317
- const nodeIdField = (key) => {
1318
- const raw = op[key];
1319
- if (typeof raw !== "string" || raw.length === 0) {
1320
- throw new Error(`\`${key}\` required on op ${kind}`);
1321
- }
1322
- return raw.trim();
1323
- };
1324
- const resolve = (id) => resolveNodeId(idIndex, id);
1325
- switch (kind) {
1326
- case "add_move": {
1327
- const parentPath = resolve(nodeIdField("parent_id"));
1328
- const parent = getNodeByPath(file.root, parentPath);
1329
- const noStatsWarn = noStatsCheckWarning(parent);
1330
- const step = addMove(file, parentPath, String(op.san));
1331
- return { ...step, ...(noStatsWarn ? { warning: noStatsWarn } : {}) };
1332
- }
1333
- case "add_line": {
1334
- const sans = Array.isArray(op.sans) ? op.sans.map(String) : [];
1335
- const parentPath = resolve(nodeIdField("parent_id"));
1336
- const parent = getNodeByPath(file.root, parentPath);
1337
- const step = addLine(file, parentPath, sans);
1338
- const lastId = step.line.length > 0 ? step.line[step.line.length - 1].id : nodeIdField("parent_id");
1339
- // Same anti-pattern warnings as the standalone add_line case —
1340
- // long unbranched line + no-stats-check parent are both bugs
1341
- // whether they land solo or inside a batch.
1342
- const longLineWarn = longLineWarning(sans.length);
1343
- const noStatsWarn = noStatsCheckWarning(parent);
1344
- const warnings = [longLineWarn, noStatsWarn].filter((s) => !!s);
1345
- return { file: step.file, id: lastId, results: step.line, ...(warnings.length > 0 ? { warnings } : {}) };
1346
- }
1347
- case "set_comment": {
1348
- const commentStr = typeof op.comment === "string" ? op.comment : "";
1349
- const commentWarns = commentAntiPatterns(commentStr);
1350
- const targetPath = resolve(nodeIdField("node_id"));
1351
- const targetNode = getNodeByPath(file.root, targetPath);
1352
- const describeWarn = noDescribeWarning(targetNode, commentStr);
1353
- const step = setComment(file, targetPath, commentStr);
1354
- const all = [...commentWarns, ...(describeWarn ? [describeWarn] : [])];
1355
- return { ...step, ...(all.length > 0 ? { warnings: all } : {}) };
1356
- }
1357
- case "set_nags":
1358
- return setNags(file, resolve(nodeIdField("node_id")), Array.isArray(op.nags) ? op.nags.map(String) : []);
1359
- case "set_annotations": {
1360
- const arrows = Array.isArray(op.arrows) ? op.arrows : [];
1361
- const highlights = Array.isArray(op.highlights) ? op.highlights : [];
1362
- const ann = arrows.length === 0 && highlights.length === 0 ? null : { arrows, highlights };
1363
- return setAnnotations(file, resolve(nodeIdField("node_id")), ann);
1364
- }
1365
- case "set_ceo_eval": {
1366
- const ev = op.ceoEval;
1367
- return setCeoEval(file, resolve(nodeIdField("node_id")), ev ?? null);
1368
- }
1369
- case "delete_subtree":
1370
- return deleteSubtree(file, resolve(nodeIdField("node_id")));
1371
- case "promote_variation":
1372
- return promoteVariation(file, resolve(nodeIdField("node_id")));
1373
- case "set_tag":
1374
- return { file: setTag(file, String(op.key), String(op.value ?? "")), id: ROOT_ID };
1375
- default:
1376
- throw new Error(`unknown mutation op: ${kind}`);
1377
- }
1378
- }
1379
- // Batch: load, parse, apply N mutations in order, export, save.
1380
- // All-or-nothing — any error aborts and nothing is saved. The id index
1381
- // is rebuilt after each op so nodes created earlier in the batch can be
1382
- // addressed by later ops via their newly-derived node_id.
1383
- async function applyBatchMutations(args) {
1384
- const id = String(args.id);
1385
- const mutations = Array.isArray(args.mutations) ? args.mutations : [];
1386
- if (mutations.length === 0)
1387
- throw new Error("mutations array required");
1388
- const g = await fetchGame(id);
1389
- let file = parsePGN(g.pgnContent);
1390
- let idIndex = buildIdIndex(file.root);
1391
- const results = [];
1392
- for (let i = 0; i < mutations.length; i++) {
1393
- const op = mutations[i];
1394
- try {
1395
- const step = dispatchMutation(file, idIndex, op);
1396
- file = step.file;
1397
- idIndex = buildIdIndex(file.root);
1398
- results.push({
1399
- node_id: step.id,
1400
- ...(step.results !== undefined ? { line: step.results } : {}),
1401
- ...(step.warning ? { warning: step.warning } : {}),
1402
- ...(step.warnings && step.warnings.length > 0 ? { warnings: step.warnings } : {}),
1403
- });
1404
- }
1405
- catch (err) {
1406
- const msg = err instanceof Error ? err.message : String(err);
1407
- throw new Error(`mutation #${i} (${String(op.op)}) failed: ${msg}`);
1408
- }
1409
- }
1410
- const newPgn = exportPGN(file);
1411
- const expected = typeof args.expected_version === "number" ? args.expected_version : g.version;
1412
- const saved = await saveGame(id, newPgn, expected);
1413
- return { ok: true, results, version: saved.version };
1414
- }
1415
- const evalJobs = new Map();
1416
- // GC finished jobs after this long so status polling remains useful
1417
- // for a while but the map doesn't grow unbounded across long uptimes.
1418
- const EVAL_JOB_TTL_MS = 15 * 60 * 1000;
1419
- // Checkpoint interval — save progress every N successfully-evaluated
1420
- // nodes so a mid-run kill leaves the tree partially populated. Small
1421
- // enough that <15s of work is at risk per checkpoint on a slow combo,
1422
- // large enough that the save overhead stays a small fraction of the
1423
- // per-node cost.
1424
- const SAVE_EVERY_N = 8;
1425
- function newEvalJobId() {
1426
- // 12 hex chars, low collision (same 32-bit width as node ids ×1.5).
1427
- const rand = Math.random().toString(16).slice(2, 8);
1428
- return `evj_${Date.now().toString(16)}${rand}`;
1429
- }
1430
- // Sweep expired jobs on every start/status call — cheap, doesn't need
1431
- // a background timer, keeps the map bounded to active + recent jobs.
1432
- function reapExpiredEvalJobs() {
1433
- const now = Date.now();
1434
- for (const [k, j] of evalJobs) {
1435
- if (j.finishedAt && now - j.finishedAt > EVAL_JOB_TTL_MS) {
1436
- evalJobs.delete(k);
1437
- }
1438
- }
1439
- }
1440
- async function autoEvaluate(args) {
1441
- reapExpiredEvalJobs();
1442
- const id = String(args.id);
1443
- const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0
1444
- ? String(args.node_id)
1445
- : ROOT_ID;
1446
- const onlyMissing = args.only_missing !== false; // default true
1447
- const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 1500;
1448
- const g = await fetchGame(id);
1449
- const file = parsePGN(g.pgnContent);
1450
- const idIndex = buildIdIndex(file.root);
1451
- const startPath = resolveNodeId(idIndex, startNodeId);
1452
- const targets = [];
1453
- const walk = (node, isStartAndRoot) => {
1454
- if (!isStartAndRoot) {
1455
- if (!onlyMissing || !node.ceoEval) {
1456
- targets.push({ nodeId: node.id, fen: node.fen });
1457
- }
1458
- }
1459
- for (const child of node.children)
1460
- walk(child, false);
1461
- };
1462
- const startNode = getNodeByPath(file.root, startPath);
1463
- // If the caller anchored at the root, skip evaluating the root itself
1464
- // (no move); otherwise the anchor node IS a real move and gets evaluated.
1465
- walk(startNode, startNode.id === ROOT_ID);
1466
- // Dedup transpositions: if two candidate targets share the same
1467
- // 3-field FEN key, they're the same position reached by different
1468
- // move orders. Analyse ONE of them — cloud_analyse auto-propagates
1469
- // the resulting ceoEval to every other node with a matching key
1470
- // (see storeEvalOnNode), so the twin ends up with the same eval
1471
- // without a second engine call. Keep DFS-first (mainline-preferred)
1472
- // occurrence.
1473
- let skippedTranspositions = 0;
1474
- {
1475
- const seen = new Set();
1476
- const deduped = [];
1477
- for (const t of targets) {
1478
- const key = positionKey(t.fen);
1479
- if (seen.has(key)) {
1480
- skippedTranspositions++;
1481
- continue;
1482
- }
1483
- seen.add(key);
1484
- deduped.push(t);
1485
- }
1486
- targets.length = 0;
1487
- targets.push(...deduped);
1488
- }
1489
- // Nothing to do → return a done job synthetically so the caller doesn't
1490
- // need to special-case the empty response.
1491
- if (targets.length === 0) {
1492
- const jobId = newEvalJobId();
1493
- evalJobs.set(jobId, {
1494
- id: jobId,
1495
- fileId: id,
1496
- status: "done",
1497
- targetCount: 0,
1498
- evaluated: 0,
1499
- errored: 0,
1500
- failedNodeIds: [],
1501
- finalVersion: g.version,
1502
- startedAt: Date.now(),
1503
- finishedAt: Date.now(),
1504
- cancelled: false,
1505
- });
1506
- return { job_id: jobId, target_count: 0, status: "done", version: g.version };
1507
- }
1508
- const jobId = newEvalJobId();
1509
- const job = {
1510
- id: jobId,
1511
- fileId: id,
1512
- status: "running",
1513
- targetCount: targets.length,
1514
- evaluated: 0,
1515
- errored: 0,
1516
- failedNodeIds: [],
1517
- startedAt: Date.now(),
1518
- cancelled: false,
1519
- };
1520
- evalJobs.set(jobId, job);
1521
- // Unawaited — runs concurrently with the tool response. Any thrown
1522
- // error gets recorded on the job so the LLM's status poll surfaces
1523
- // it instead of the process seeing an unhandled rejection.
1524
- void runEvalJob(job, id, targets, movetimeMs).catch(err => {
1525
- job.status = "error";
1526
- job.error = err instanceof Error ? err.message : String(err);
1527
- job.finishedAt = Date.now();
1528
- });
1529
- return {
1530
- job_id: jobId,
1531
- target_count: targets.length,
1532
- // Transpositions inside the walk that we skipped because they'll
1533
- // pick up the eval via auto-propagation. Zero when there are none.
1534
- skipped_transpositions: skippedTranspositions,
1535
- status: "running",
1536
- // Rough time estimate at the current default movetime. Serialization
1537
- // on the per-combo semaphore means walltime ≈ target_count × movetime.
1538
- estimated_seconds: Math.round((targets.length * movetimeMs) / 1000),
1539
- };
1540
- }
1541
- // Worker body — walks targets sequentially (concurrency > 1 is a lie
1542
- // against the per-combo semaphore in the backend anyway), checkpoints
1543
- // every SAVE_EVERY_N successfully-evaluated nodes so partial progress
1544
- // is durable, and re-anchors the version after each save.
1545
- async function runEvalJob(job, fileId, targets, movetimeMs) {
1546
- const pending = [];
1547
- const flush = async () => {
1548
- if (pending.length === 0)
1549
- return;
1550
- // No expected_version — auto_evaluate treats concurrent edits by
1551
- // the LLM as last-write-wins on the ceoEval field specifically.
1552
- // Safe because set_ceo_eval is idempotent per node and other
1553
- // mutations (add_move / set_comment / etc.) don't touch ceoEval.
1554
- const saved = await applyBatchMutations({
1555
- id: fileId,
1556
- mutations: pending,
1557
- });
1558
- const sr = saved;
1559
- if (typeof sr.version === "number")
1560
- job.finalVersion = sr.version;
1561
- pending.length = 0;
1562
- };
1563
- // Consecutive-failure abort. If N cloud_analyse calls in a row error,
1564
- // the engine is almost certainly dead (vanished contract, network to
1565
- // VastAI down) and burning through the rest of the tree just wastes
1566
- // time. Bail with an explicit reason so a targeted retry is possible.
1567
- const MAX_CONSECUTIVE_FAILURES = 3;
1568
- let consecutiveFailures = 0;
1569
- let aborted = false;
1570
- for (const t of targets) {
1571
- if (job.cancelled || aborted)
1572
- break;
1573
- try {
1574
- const analysis = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen: t.fen, movetime_ms: movetimeMs, multipv: 1 });
1575
- const ev = analysisToStoredEval(analysis);
1576
- if (ev) {
1577
- pending.push({ op: "set_ceo_eval", node_id: t.nodeId, ceoEval: ev });
1578
- job.evaluated++;
1579
- consecutiveFailures = 0;
1580
- }
1581
- else {
1582
- job.errored++;
1583
- job.failedNodeIds.push(t.nodeId);
1584
- consecutiveFailures++;
1585
- }
1586
- }
1587
- catch {
1588
- // Per-node failure — record the node_id so the caller can retry
1589
- // just those, and count consecutive failures for the abort check.
1590
- job.errored++;
1591
- job.failedNodeIds.push(t.nodeId);
1592
- consecutiveFailures++;
1593
- }
1594
- if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
1595
- aborted = true;
1596
- job.abortedReason = `aborted after ${MAX_CONSECUTIVE_FAILURES} consecutive cloud_analyse failures — check that the cloud combo is still running (list_cloud_engines)`;
1597
- break;
1598
- }
1599
- if (pending.length >= SAVE_EVERY_N) {
1600
- try {
1601
- await flush();
1602
- }
1603
- catch {
1604
- // Save failure is bad but not fatal — try again on the next
1605
- // checkpoint or at the end. Progress remains in `pending`
1606
- // so nothing is lost as long as the process stays alive.
1607
- }
1608
- }
1609
- }
1610
- // Final flush regardless of cancellation — durably persist whatever
1611
- // work was completed before the user asked to stop.
1612
- try {
1613
- await flush();
1614
- }
1615
- catch (err) {
1616
- job.error = err instanceof Error ? err.message : String(err);
1617
- job.status = "error";
1618
- job.finishedAt = Date.now();
1619
- return;
1620
- }
1621
- job.status = job.cancelled ? "cancelled" : "done";
1622
- job.finishedAt = Date.now();
1623
- }
1624
- function autoEvaluateStatus(args) {
1625
- reapExpiredEvalJobs();
1626
- const jobId = String(args.job_id || "").trim();
1627
- if (!jobId)
1628
- throw new Error("`job_id` is required");
1629
- const job = evalJobs.get(jobId);
1630
- if (!job) {
1631
- return {
1632
- status: "not_found",
1633
- note: "Job unknown — either expired (kept ~15 min after completion), never existed, or the MCP process restarted since it was created. Re-run auto_evaluate to start over; the `only_missing` default will skip nodes already evaluated in the prep file.",
1634
- };
1635
- }
1636
- return {
1637
- job_id: job.id,
1638
- status: job.status,
1639
- target_count: job.targetCount,
1640
- evaluated: job.evaluated,
1641
- errored: job.errored,
1642
- failed_node_ids: job.failedNodeIds, // exact ids for targeted retry — pass as node_id list or check with list_nodes
1643
- aborted_reason: job.abortedReason, // present when the job stopped early due to consecutive engine failures
1644
- remaining: Math.max(0, job.targetCount - job.evaluated - job.errored),
1645
- done: job.status !== "running",
1646
- error: job.error,
1647
- version: job.finalVersion,
1648
- started_at_ms: job.startedAt,
1649
- finished_at_ms: job.finishedAt,
1650
- };
1651
- }
1652
- function autoEvaluateCancel(args) {
1653
- const jobId = String(args.job_id || "").trim();
1654
- if (!jobId)
1655
- throw new Error("`job_id` is required");
1656
- const job = evalJobs.get(jobId);
1657
- if (!job)
1658
- return { status: "not_found" };
1659
- if (job.status !== "running") {
1660
- return { status: job.status, note: "Job already finished; nothing to cancel." };
1661
- }
1662
- job.cancelled = true;
1663
- // status transitions to "cancelled" on the next per-node iteration
1664
- // inside runEvalJob, after the final flush persists progress.
1665
- return { status: "cancelling", evaluated_so_far: job.evaluated };
1666
- }
1667
- const deepJobs = new Map();
1668
- const DEEP_JOB_TTL_MS = 15 * 60 * 1000;
1669
- function newDeepJobId() {
1670
- const rand = Math.random().toString(16).slice(2, 8);
1671
- return `deep_${Date.now().toString(16)}${rand}`;
1672
- }
1673
- function reapExpiredDeepJobs() {
1674
- const now = Date.now();
1675
- for (const [k, j] of deepJobs) {
1676
- if (j.finishedAt && now - j.finishedAt > DEEP_JOB_TTL_MS) {
1677
- deepJobs.delete(k);
1678
- }
1679
- }
1680
- }
1681
- async function deepAnalyseStart(args) {
1682
- reapExpiredDeepJobs();
1683
- const resolved = await resolveFromNodeOrFen(args);
1684
- const fen = resolved.fen;
1685
- const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 60_000;
1686
- // Default 2 — SF loses meaningful strength at higher multipv, so a
1687
- // deep think is best spent on a tight candidate list. Matches the
1688
- // cloud_analyse stockfish_multipv default.
1689
- const multipv = typeof args.multipv === "number" ? args.multipv : 2;
1690
- const jobId = newDeepJobId();
1691
- const job = {
1692
- id: jobId,
1693
- status: "running",
1694
- fileHandle: resolved.file,
1695
- fen,
1696
- movetimeMs,
1697
- multipv,
1698
- startedAt: Date.now(),
1699
- cancelController: new AbortController(),
1700
- };
1701
- deepJobs.set(jobId, job);
1702
- // Kick off the long HTTP call unawaited — resolves when the backend
1703
- // returns the SF snapshot. authedRequest is a plain fetch under the
1704
- // hood; abort signal flows via cancelController.
1705
- void runDeepJob(job).catch(err => {
1706
- job.status = "error";
1707
- job.error = err instanceof Error ? err.message : String(err);
1708
- job.finishedAt = Date.now();
1709
- });
1710
- return {
1711
- job_id: jobId,
1712
- status: "running",
1713
- movetime_ms: movetimeMs,
1714
- fen,
1715
- };
1716
- }
1717
- async function runDeepJob(job) {
1718
- const body = {
1719
- fen: job.fen,
1720
- movetime_ms: job.movetimeMs,
1721
- stockfish_multipv: job.multipv,
1722
- engines: ["stockfish"],
1723
- };
1724
- let raw;
1725
- try {
1726
- // TODO(future): plumb an AbortSignal through authedRequest for
1727
- // real mid-flight cancellation. For now, cancel just marks the
1728
- // job so the caller stops polling; the backend still runs the
1729
- // engine to completion and the result is stored on the job
1730
- // record but flagged cancelled.
1731
- raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
1732
- }
1733
- catch (err) {
1734
- job.status = "error";
1735
- job.error = err instanceof Error ? err.message : String(err);
1736
- job.finishedAt = Date.now();
1737
- return;
1738
- }
1739
- const converted = convertCloudSnapshotResponse(raw, job.fen);
1740
- const sf = converted.stockfish;
1741
- if (job.cancelController.signal.aborted) {
1742
- job.status = "cancelled";
1743
- }
1744
- else {
1745
- job.status = "done";
1746
- }
1747
- job.result = sf ?? null;
1748
- job.finishedAt = Date.now();
1749
- // Same node-persistence as cloud_analyse: if the caller anchored on
1750
- // file_id+node_id, store the SF-only eval as the node's ceoEval so
1751
- // quote_engine_eval can cite it later. We build a StoredEval that has
1752
- // only the sf leg — no Lc0 was run.
1753
- if (job.fileHandle && sf) {
1754
- const ev = analysisToStoredEval({ stockfish: sf });
1755
- if (ev) {
1756
- try {
1757
- await storeEvalOnNode(job.fileHandle, ev);
1758
- }
1759
- catch {
1760
- // best-effort — the analysis result is what the LLM asked for
1761
- }
1762
- }
1763
- }
1764
- }
1765
- function deepAnalyseStatus(args) {
1766
- reapExpiredDeepJobs();
1767
- const jobId = String(args.job_id || "").trim();
1768
- if (!jobId)
1769
- throw new Error("`job_id` is required");
1770
- const job = deepJobs.get(jobId);
1771
- if (!job) {
1772
- return {
1773
- status: "not_found",
1774
- note: "Job unknown — expired (kept ~15 min after completion), never existed, or the MCP process restarted.",
1775
- };
1776
- }
1777
- return {
1778
- job_id: job.id,
1779
- status: job.status,
1780
- movetime_ms: job.movetimeMs,
1781
- elapsed_ms: (job.finishedAt ?? Date.now()) - job.startedAt,
1782
- fen: job.fen,
1783
- result: job.result,
1784
- error: job.error,
1785
- started_at_ms: job.startedAt,
1786
- finished_at_ms: job.finishedAt,
1787
- };
1788
- }
1789
- function deepAnalyseCancel(args) {
1790
- const jobId = String(args.job_id || "").trim();
1791
- if (!jobId)
1792
- throw new Error("`job_id` is required");
1793
- const job = deepJobs.get(jobId);
1794
- if (!job)
1795
- return { status: "not_found" };
1796
- if (job.status !== "running") {
1797
- return { status: job.status, note: "Job already finished; nothing to cancel." };
1798
- }
1799
- job.cancelController.abort();
1800
- // Status flips to "cancelled" when runDeepJob observes the abort on
1801
- // completion. Backend keeps churning until movetime elapses (mid-
1802
- // flight abort of the HTTP call is a follow-up).
1803
- return { status: "cancelling", elapsed_ms: Date.now() - job.startedAt };
1804
- }
1805
- // ── find_position_in_courses: fenfind subprocess wrapper ───────────
1806
- //
1807
- // fenfind is a small python tool that indexes chess PGN files by
1808
- // polyglot Zobrist hash. Given a position it returns which of the
1809
- // user's Chessable / PGN files cover it, ranked by how much annotated
1810
- // material sits below that position in each course. Runs as a
1811
- // subprocess of the MCP so we can reuse python-chess's polyglot
1812
- // hashing (matching the pre-built positions.db) instead of porting
1813
- // the hash function to TS.
1814
- //
1815
- // Path resolution order (`FENFIND_PATH` env var overrides):
1816
- // 1. $FENFIND_PATH/fenfind
1817
- // 2. <package-root>/tools/fenfind/fenfind (ships with the npm package)
1818
- // The bash wrapper picks a python interpreter with python-chess
1819
- // available (venv at $here/.venv/bin/python preferred, then falls back
1820
- // to system python3). DB path is resolved inside fenfind.py itself
1821
- // (FENFIND_DB env, then ~/positions.db).
1822
- // Path resolution shared by both fenfind + readpgn. FENFIND_PATH env
1823
- // overrides the bundled tools/fenfind/ directory.
1824
- // Path to sf_eval helper (spawns local stockfish, parses its `eval`
1825
- // verbose output). Uses the same resolution pattern as FENFIND_DIR.
1826
- const SF_EVAL_SCRIPT = (() => {
1827
- const envPath = process.env.SF_EVAL_PATH?.trim();
1828
- if (envPath && existsSync(join(envPath, "sf_eval")))
1829
- return join(envPath, "sf_eval");
1830
- const here = dirname(fileURLToPath(import.meta.url));
1831
- const bundled = join(here, "..", "tools", "sf_eval", "sf_eval");
1832
- return existsSync(bundled) ? bundled : null;
1833
- })();
1834
- const SF_EVAL_TIMEOUT_MS = 12_000;
1835
- async function runSfEval(fen) {
1836
- if (!SF_EVAL_SCRIPT) {
1837
- return {
1838
- found: false,
1839
- error: "sf_eval script not bundled; set SF_EVAL_PATH or install tools/sf_eval/",
1840
- };
1841
- }
1842
- const stdout = await new Promise((resolve, reject) => {
1843
- const p = spawn(SF_EVAL_SCRIPT, ["--fen", fen], { stdio: ["ignore", "pipe", "pipe"] });
1844
- let out = "";
1845
- let err = "";
1846
- p.stdout.on("data", d => { out += d.toString("utf8"); });
1847
- p.stderr.on("data", d => { err += d.toString("utf8"); });
1848
- const to = setTimeout(() => {
1849
- try {
1850
- p.kill("SIGTERM");
1851
- }
1852
- catch { /* already dead */ }
1853
- reject(new Error(`sf_eval timed out after ${SF_EVAL_TIMEOUT_MS}ms`));
1854
- }, SF_EVAL_TIMEOUT_MS);
1855
- p.on("error", e => { clearTimeout(to); reject(e); });
1856
- p.on("close", code => {
1857
- clearTimeout(to);
1858
- if (code !== 0)
1859
- reject(new Error(`sf_eval exited ${code}: ${err.slice(0, 500)}`));
1860
- else
1861
- resolve(out);
1862
- });
1863
- });
1864
- try {
1865
- return JSON.parse(stdout);
1866
- }
1867
- catch (e) {
1868
- throw new Error(`sf_eval returned non-JSON output (${e instanceof Error ? e.message : String(e)}): ${stdout.slice(0, 300)}`);
1869
- }
1870
- }
1871
- const FENFIND_DIR = (() => {
1872
- const envPath = process.env.FENFIND_PATH?.trim();
1873
- if (envPath && existsSync(join(envPath, "fenfind")))
1874
- return envPath;
1875
- const here = dirname(fileURLToPath(import.meta.url));
1876
- const bundled = join(here, "..", "tools", "fenfind");
1877
- return existsSync(join(bundled, "fenfind")) ? bundled : null;
1878
- })();
1879
- // Cap on how long we let the subprocess run. SQLite hash lookup returns
1880
- // sub-second; PGN read from a course file is O(chapter size) and rarely
1881
- // exceeds a second. 15s is a stuck-process backstop, not a real limit.
1882
- const FENFIND_TIMEOUT_MS = 15_000;
1883
- async function runFenfindScript(scriptName, args) {
1884
- if (!FENFIND_DIR) {
1885
- throw new Error("fenfind index not installed — set FENFIND_PATH or install the tools/fenfind bundle");
1886
- }
1887
- const script = join(FENFIND_DIR, scriptName);
1888
- return new Promise((resolve, reject) => {
1889
- const p = spawn(script, args, { stdio: ["ignore", "pipe", "pipe"] });
1890
- let out = "";
1891
- let err = "";
1892
- p.stdout.on("data", d => { out += d.toString("utf8"); });
1893
- p.stderr.on("data", d => { err += d.toString("utf8"); });
1894
- const to = setTimeout(() => {
1895
- try {
1896
- p.kill("SIGTERM");
1897
- }
1898
- catch { /* already dead */ }
1899
- reject(new Error(`${scriptName} timed out after ${FENFIND_TIMEOUT_MS}ms`));
1900
- }, FENFIND_TIMEOUT_MS);
1901
- p.on("error", e => { clearTimeout(to); reject(e); });
1902
- p.on("close", code => {
1903
- clearTimeout(to);
1904
- if (code !== 0)
1905
- reject(new Error(`${scriptName} exited ${code}: ${err.slice(0, 500)}`));
1906
- else
1907
- resolve(out);
1908
- });
1909
- });
1910
- }
1911
- function parseFenfindJson(scriptName, stdout) {
1912
- try {
1913
- return JSON.parse(stdout);
1914
- }
1915
- catch (e) {
1916
- throw new Error(`${scriptName} returned non-JSON output (${e instanceof Error ? e.message : String(e)}): ${stdout.slice(0, 300)}`);
1917
- }
1918
- }
1919
- async function findPositionInCourses(args) {
1920
- if (!FENFIND_DIR) {
1921
- return {
1922
- status: "not_available",
1923
- note: "fenfind index not installed on this server. Set FENFIND_PATH env var to the directory containing the `fenfind` script and positions.db, or install the tools/fenfind bundle shipped in the npm package.",
1924
- };
1925
- }
1926
- const resolved = await resolveFromNodeOrFen(args);
1927
- const cliArgs = [resolved.fen, "--json"];
1928
- if (typeof args.sort === "string" && (args.sort === "recency" || args.sort === "notes")) {
1929
- cliArgs.push("--sort", args.sort);
1930
- }
1931
- if (args.include_games)
1932
- cliArgs.push("--games");
1933
- if (args.chapters_mode)
1934
- cliArgs.push("--chapters");
1935
- if (typeof args.min_notes_chars === "number")
1936
- cliArgs.push("--min", String(args.min_notes_chars));
1937
- if (typeof args.limit === "number")
1938
- cliArgs.push("-n", String(args.limit));
1939
- const stdout = await runFenfindScript("fenfind", cliArgs);
1940
- return parseFenfindJson("fenfind", stdout);
1941
- }
1942
- async function readCourseAtPosition(args) {
1943
- if (!FENFIND_DIR) {
1944
- return {
1945
- status: "not_available",
1946
- note: "fenfind index not installed on this server. Set FENFIND_PATH env var to the directory containing the `fenfind`/`readpgn` scripts and positions.db.",
1947
- };
1948
- }
1949
- const fileId = typeof args.course_file_id === "number" ? args.course_file_id : Number(args.course_file_id);
1950
- if (!Number.isFinite(fileId) || fileId <= 0) {
1951
- throw new Error("`course_file_id` is required — pass the value from a find_position_in_courses hit");
1952
- }
1953
- const cliArgs = ["--file-id", String(fileId)];
1954
- if (typeof args.fen === "string" && args.fen.trim() !== "")
1955
- cliArgs.push("--fen", args.fen.trim());
1956
- if (typeof args.moves === "string" && args.moves.trim() !== "")
1957
- cliArgs.push("--moves", args.moves.trim());
1958
- if (typeof args.chapter === "string" && args.chapter.trim() !== "")
1959
- cliArgs.push("--chapter", args.chapter.trim());
1960
- if (typeof args.max_plies_below === "number")
1961
- cliArgs.push("--max-plies-below", String(args.max_plies_below));
1962
- const stdout = await runFenfindScript("readpgn", cliArgs);
1963
- return parseFenfindJson("readpgn", stdout);
1964
- }
1965
- // Walk chess.js-free: resolve a path against a tree, throw if invalid.
1966
- function pathIntoTree(root, path) {
1967
- let cur = root;
1968
- for (let i = 0; i < path.length; i++) {
1969
- if (path[i] < 0 || path[i] >= cur.children.length) {
1970
- throw new Error(`path segment ${i}=${path[i]} out of bounds`);
1971
- }
1972
- cur = cur.children[path[i]];
1973
- }
1974
- return cur;
1975
- }
1976
- // Read the cloud analyse response and build a StoredEval (compact form —
1977
- // no PVs, White-POV cp / mate, depth, and derived NAG). Returns null if
1978
- // there's no usable Stockfish signal (SF is the source of truth for
1979
- // the NAG per docs).
1980
- function analysisToStoredEval(analysis) {
1981
- if (!analysis || typeof analysis !== "object")
1982
- return null;
1983
- const r = analysis;
1984
- // Backend returns White-POV cp/mate (engine-ws flips in ParseInfo
1985
- // based on side-to-move, cloud_snapshot passes through). Pure
1986
- // pass-through here — a previous sign-flip on black-to-move was
1987
- // wrong and silently inverted every Black-to-move stored eval.
1988
- const engineEval = (block) => {
1989
- const line = block?.lines?.[0];
1990
- if (!line)
1991
- return undefined;
1992
- const depth = line.depth ?? block?.depth;
1993
- if (typeof line.mate === "number")
1994
- return { mate: line.mate, depth };
1995
- if (typeof line.scoreCp === "number")
1996
- return { cp: line.scoreCp, depth };
1997
- return undefined;
1998
- };
1999
- const sf = engineEval(r.stockfish);
2000
- const lc0 = engineEval(r.lc0);
2001
- if (!sf && !lc0)
2002
- return null;
2003
- const ev = {};
2004
- if (sf)
2005
- ev.sf = sf;
2006
- if (lc0)
2007
- ev.lc0 = lc0;
2008
- ev.nag = nagFromCp(sf?.cp, sf?.mate) ?? nagFromCp(lc0?.cp, lc0?.mate) ?? undefined;
2009
- return ev;
2010
- }
2011
- function nagFromCp(cp, mate) {
2012
- let effective;
2013
- if (typeof mate === "number")
2014
- effective = mate > 0 ? 10000 : -10000;
2015
- else if (typeof cp === "number")
2016
- effective = cp;
2017
- else
2018
- return null;
2019
- const abs = Math.abs(effective);
2020
- if (abs < 25)
2021
- return "$10";
2022
- if (abs < 60)
2023
- return effective > 0 ? "$14" : "$15";
2024
- if (abs < 130)
2025
- return effective > 0 ? "$16" : "$17";
2026
- return effective > 0 ? "$18" : "$19";
2027
- }
2028
- // Adapter for the compact eval attached to live query responses. Same
2029
- // derivation logic; different output shape (needs the .nag + summary
2030
- // used by get_position_stats / prep_snapshot).
2031
- function storedEvalToCompact(ev, analysis) {
2032
- if (!ev)
2033
- return null;
2034
- const a = analysis;
2035
- const compact = { nag: ev.nag ?? null };
2036
- if (ev.sf) {
2037
- compact.stockfish = {
2038
- cp: ev.sf.cp,
2039
- mate: ev.sf.mate,
2040
- bestMove: a.stockfish?.bestMove,
2041
- pv: a.stockfish?.lines?.[0]?.pv,
2042
- };
2043
- }
2044
- if (ev.lc0) {
2045
- compact.lc0 = {
2046
- cp: ev.lc0.cp,
2047
- mate: ev.lc0.mate,
2048
- bestMove: a.lc0?.bestMove,
2049
- pv: a.lc0?.lines?.[0]?.pv,
2050
- };
2051
- }
2052
- return compact;
2053
- }
2054
- // Load-mutate-save: fetch current PGN, parse, apply mutation, re-export,
2055
- // save with optimistic lock. Auto-saves so every tool call is atomic;
2056
- // the LLM never sees intermediate state. The mutator is called with
2057
- // both the parsed file and its id → path index, so the mutation can
2058
- // resolve node_ids without rebuilding the index itself.
2059
- async function applyMutation(args, mutator) {
2060
- const id = String(args.id);
2061
- const g = await fetchGame(id);
2062
- const file = parsePGN(g.pgnContent);
2063
- const idIndex = buildIdIndex(file.root);
2064
- let result;
2065
- try {
2066
- result = mutator(file, idIndex);
2067
- }
2068
- catch (err) {
2069
- if (err instanceof MutationError || err instanceof PathError || err instanceof NodeIdError) {
2070
- throw new Error(`mutation rejected: ${err.message}`);
2071
- }
2072
- throw err;
2073
- }
2074
- const newPgn = exportPGN(result.file);
2075
- const expected = typeof args.expected_version === "number" ? args.expected_version : g.version;
2076
- const saved = await saveGame(id, newPgn, expected);
2077
- return {
2078
- ok: true,
2079
- node_id: result.id,
2080
- ...(result.results !== undefined ? { line: result.results } : {}),
2081
- ...(result.warning ? { warning: result.warning } : {}),
2082
- ...(result.warnings && result.warnings.length > 0 ? { warnings: result.warnings } : {}),
2083
- version: saved.version,
2084
- };
2085
- }
2086
- // Compute the "you never called describe_position on this node" warning.
2087
- // Fires from set_comment when the comment is substantive (>= 40 chars —
2088
- // anything shorter is a label / pointer, doesn't need structural
2089
- // grounding). LLMs are unreliable at reading FEN strings and confidently
2090
- // describe positions that don't match the actual board; describe_position
2091
- // is a pure-computation grounding pass that reliably fixes this. Warn
2092
- // once per node.
2093
- function noDescribeWarning(node, comment) {
2094
- if (node.id === ROOT_ID)
2095
- return undefined;
2096
- if (comment.length < 40)
2097
- return undefined;
2098
- const key = positionKey(node.fen);
2099
- if (positionsDescribed.has(key))
2100
- return undefined;
2101
- if (noDescribeWarned.has(node.id))
2102
- return undefined;
2103
- noDescribeWarned.add(node.id);
2104
- return `substantive comment (${comment.length} chars) on a node whose position was never grounded via describe_position this session (id=${node.id}, ${node.san}). LLMs invent captures, miscount pieces, and swap files/ranks when reading FEN strings — describe_position is a pure-computation pass (~1 ms, no engine cost, structural facts + Stockfish's per-term eval breakdown) that reliably prevents this class of hallucination. In live audits, prose accuracy jumps sharply on nodes where describe_position was called first. Call describe_position with file_id+node_id=${node.id} BEFORE writing prose. Warned once per node.`;
2105
- }
2106
- // Compute the "you never DB-checked this parent" warning. Called from
2107
- // add_move / add_line handlers with the parent node. Returns undefined
2108
- // when either (a) the parent was checked this session (or is root — the
2109
- // starting position doesn't need a DB check), (b) we already warned on
2110
- // this parent (dedup so building a big branching subtree isn't spammy),
2111
- // or (c) the mutator is running against a parent whose position has a
2112
- // stored ceoEval (implies the LLM has done SOME analytical work here).
2113
- function noStatsCheckWarning(parent) {
2114
- if (parent.id === ROOT_ID)
2115
- return undefined;
2116
- const key = positionKey(parent.fen);
2117
- if (positionsStatsChecked.has(key))
2118
- return undefined;
2119
- if (noStatsWarned.has(parent.id))
2120
- return undefined;
2121
- noStatsWarned.add(parent.id);
2122
- return `no get_position_stats call for the parent (id=${parent.id}, ${parent.san}) this session. Course chapter titles describe what an author chose to cover, not what practical opponents play — treating "the So chapter says 6.O-O-O" as "the mainline is 6.O-O-O" is the exact pattern this warning exists to catch. Call get_position_stats at this position (via file_id+node_id=${parent.id}) BEFORE deciding which branches belong here; suppress this warning by making that call. Warned once per parent per session.`;
2123
- }
2124
- async function loadPrepFile(id) {
2125
- const g = await fetchGame(id);
2126
- // Echo the composite id back so read_prep_file responses match the
2127
- // exact id the LLM passed in. The backend returns the raw game_id;
2128
- // recompose so the LLM never sees the split form.
2129
- return { file: parsePGN(g.pgnContent), version: g.version, fileIdEcho: id, pgn: g.pgnContent };
2130
- }
2131
- // Recursively project a PrepNode into the requested view. `depthLeft`
2132
- // null → unlimited; 0 → just the node without children.
2133
- //
2134
- // `fenIndex` (optional) enables the `transposes_to` field — for each
2135
- // node whose position also appears elsewhere in the SAME file, we
2136
- // annotate it with the OTHER occurrences' ids. Pass null (the default)
2137
- // to skip the annotation entirely; passing the map costs one lookup
2138
- // per node projected.
2139
- function projectNode(node, view, depthLeft, fenIndex = null) {
2140
- const base = {
2141
- id: node.id,
2142
- san: node.san,
2143
- ply: node.ply,
2144
- };
2145
- if (node.nags && node.nags.length > 0)
2146
- base.nags = node.nags;
2147
- if (node.comment)
2148
- base.comment = node.comment;
2149
- if (node.ceoEval)
2150
- base.ceoEval = node.ceoEval;
2151
- if (view === "full") {
2152
- base.fen = node.fen;
2153
- if (node.annotations)
2154
- base.annotations = node.annotations;
2155
- }
2156
- if (fenIndex && node.id !== ROOT_ID) {
2157
- const group = fenIndex.get(positionKey(node.fen));
2158
- if (group && group.length > 1) {
2159
- const others = group.filter(n => n.id !== node.id).map(n => n.id);
2160
- if (others.length > 0)
2161
- base.transposes_to = others;
2162
- }
2163
- }
2164
- // Children handling depends on view + depth budget.
2165
- const showChildren = depthLeft === null || depthLeft > 0;
2166
- const childDepth = depthLeft === null ? null : depthLeft - 1;
2167
- if (showChildren && node.children.length > 0) {
2168
- if (view === "spine") {
2169
- // Only follow children[0] — collapses the tree to the mainline.
2170
- base.children = [projectNode(node.children[0], view, childDepth, fenIndex)];
2171
- }
2172
- else {
2173
- base.children = node.children.map(c => projectNode(c, view, childDepth, fenIndex));
2174
- }
2175
- }
2176
- else {
2177
- base.children = [];
2178
- }
2179
- return base;
2180
- }
2181
- async function listCollections(_args) {
2182
- const raw = await authedRequest("GET", PGN_BASE);
2183
- const collections = unwrap(raw) ?? [];
2184
- return {
2185
- collections: collections.map(c => ({
2186
- id: c.id,
2187
- title: c.title,
2188
- icon: c.icon,
2189
- folder_path: c.folderPath,
2190
- game_count: c.gameCount,
2191
- position_search_enabled: c.positionSearchEnabled,
2192
- updated_at: c.updatedAt,
2193
- })),
2194
- };
2195
- }
2196
- // Convert a browser-returned game list row into the LLM shape (composite
2197
- // id, cleaned field names).
2198
- function projectGameRow(row) {
2199
- const collId = row.collectionId ?? "";
2200
- return {
2201
- id: collId ? makeFileId(collId, row.id) : row.id,
2202
- collection_id: collId,
2203
- collection_title: row.collectionTitle,
2204
- event: row.event,
2205
- white: row.white_player,
2206
- black: row.black_player,
2207
- eco: row.eco,
2208
- opening: row.opening,
2209
- updated_at: row.updated_at,
2210
- ply: row.ply,
2211
- };
2212
- }
2213
- async function listPrepFiles(args) {
2214
- const collectionId = typeof args.collection_id === "string" ? args.collection_id.trim() : "";
2215
- if (!collectionId) {
2216
- throw new Error("collection_id required — call list_collections to see your options, or search across collections with search_prep_files / find_position_in_files");
2217
- }
2218
- // Browser handler at GET /me/pgns/{id}/games returns a paginated list.
2219
- const raw = await authedRequest("GET", `${PGN_BASE}/${encodeURIComponent(collectionId)}/games?page=1&limit=200`);
2220
- const data = unwrap(raw);
2221
- const games = data?.games ?? (Array.isArray(data) ? data : []);
2222
- return { collection_id: collectionId, prep_files: games.map(projectGameRow) };
2223
- }
2224
- async function searchPrepFiles(args) {
2225
- const q = typeof args.query === "string" ? args.query.trim() : "";
2226
- if (!q)
2227
- throw new Error("query required");
2228
- const raw = await authedRequest("GET", `${PGN_BASE}/games/search?q=${encodeURIComponent(q)}&limit=100`);
2229
- const data = unwrap(raw);
2230
- const games = data?.games ?? [];
2231
- return { query: q, prep_files: games.map(projectGameRow) };
2232
- }
2233
- async function findPositionInFiles(args) {
2234
- // FEN can come from a node handle OR a direct fen/moves/line. Reuse
2235
- // the same resolver everything else uses.
2236
- const resolved = await resolveFromNodeOrFen(args);
2237
- const fen = resolved.fen;
2238
- const raw = await authedRequest("GET", `${PGN_BASE}/games/search?position=${encodeURIComponent(fen)}&limit=100`);
2239
- const data = unwrap(raw);
2240
- const games = data?.games ?? [];
2241
- return {
2242
- fen,
2243
- match_count: games.length,
2244
- prep_files: games.map(projectGameRow),
2245
- };
2246
- }
2247
- async function createPrepFile(args) {
2248
- const collectionId = typeof args.collection_id === "string" ? args.collection_id.trim() : "";
2249
- if (!collectionId) {
2250
- throw new Error("collection_id required — call list_collections to pick where the new file lives. There is no default landing folder any more (v0.43: the old hidden /mcp collection was removed).");
2251
- }
2252
- const name = String(args.name || "").trim();
2253
- if (!name)
2254
- throw new Error("name is required");
2255
- // Seed with a PGN carrying the LLM-chosen name as the Event tag so
2256
- // subsequent list_prep_files calls display something useful.
2257
- const seedPgn = `[Event "${name.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]\n\n*\n`;
2258
- const game = await createGame(collectionId, seedPgn);
2259
- return {
2260
- ok: true,
2261
- id: makeFileId(game.collectionId ?? collectionId, game.id),
2262
- collection_id: game.collectionId ?? collectionId,
2263
- version: game.version,
2264
- };
2265
- }
2266
- async function readPrepFile(args) {
2267
- const id = String(args.id);
2268
- const view = (typeof args.view === "string" && ["compact", "full", "spine", "pgn"].includes(args.view))
2269
- ? args.view
2270
- : "compact";
2271
- const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0 ? args.node_id : ROOT_ID;
2272
- const maxDepth = typeof args.max_depth === "number" && args.max_depth >= 0 ? args.max_depth : null;
2273
- const { file, version, fileIdEcho, pgn } = await loadPrepFile(id);
2274
- const idIndex = buildIdIndex(file.root);
2275
- const path = resolveNodeId(idIndex, startNodeId);
2276
- const anchor = getNodeByPath(file.root, path);
2277
- const fenIndex = buildFenIndex(file.root);
2278
- // How many DISTINCT positions in the file appear more than once,
2279
- // and how many nodes are involved. Shown in the header so the LLM
2280
- // sees at a glance whether transpositions matter here before diving
2281
- // into the tree.
2282
- let transGroups = 0;
2283
- let transNodes = 0;
2284
- for (const arr of fenIndex.values()) {
2285
- if (arr.length > 1) {
2286
- transGroups++;
2287
- transNodes += arr.length;
2288
- }
2289
- }
2290
- const header = {
2291
- id: fileIdEcho ?? id,
2292
- version,
2293
- tags: file.tags,
2294
- view,
2295
- node_id: startNodeId,
2296
- max_depth: maxDepth,
2297
- transposition_groups: transGroups,
2298
- transposition_nodes: transNodes,
2299
- };
2300
- if (view === "pgn") {
2301
- // For the root, just return the file's actual PGN as-is. For a
2302
- // subtree, build a mini-Game from the anchor and export it. Keeps
2303
- // formatting identical to what the app renders.
2304
- if (startNodeId === ROOT_ID && (maxDepth === null || maxDepth >= 999)) {
2305
- return { ...header, pgn };
2306
- }
2307
- // Truncate to a subtree with max_depth. Simple: walk the anchor's
2308
- // subtree, produce a synthetic PGN starting from the anchor's FEN.
2309
- const subtreePgn = exportSubtreePgn(file, anchor, maxDepth);
2310
- return { ...header, pgn: subtreePgn };
2311
- }
2312
- return { ...header, tree: projectNode(anchor, view, maxDepth, fenIndex) };
2313
- }
2314
- // Produce a PGN string for a subtree rooted at `anchor`, truncated
2315
- // at `maxDepth` plies below (null = unlimited). Reuses the exporter
2316
- // by building a synthetic PrepFile whose root is a shallow clone of
2317
- // the anchor with its children trimmed to depth.
2318
- function exportSubtreePgn(file, anchor, maxDepth) {
2319
- const trim = (n, depthLeft) => {
2320
- if (depthLeft !== null && depthLeft <= 0)
2321
- return { ...n, children: [] };
2322
- const next = depthLeft === null ? null : depthLeft - 1;
2323
- return { ...n, children: n.children.map(c => trim(c, next)) };
2324
- };
2325
- const trimmedAnchor = trim(anchor, maxDepth);
2326
- // If the anchor IS the root, exporter handles it. If it's an inner
2327
- // node, we set the root's FEN to the anchor's position and hang the
2328
- // trimmed subtree off it. Tags carried over.
2329
- if (anchor.id === ROOT_ID) {
2330
- return exportPGN({ tags: file.tags, root: trimmedAnchor });
2331
- }
2332
- const syntheticRoot = {
2333
- id: ROOT_ID,
2334
- san: null,
2335
- fen: anchor.fen,
2336
- ply: 0,
2337
- children: trimmedAnchor.children,
2338
- };
2339
- const tags = { ...file.tags, FEN: anchor.fen, SetUp: "1" };
2340
- return exportPGN({ tags, root: syntheticRoot });
2341
- }
2342
- async function listNodes(args) {
2343
- const id = String(args.id);
2344
- const filter = String(args.filter || "");
2345
- const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0 ? args.node_id : ROOT_ID;
2346
- const maxDepth = typeof args.max_depth === "number" && args.max_depth >= 0 ? args.max_depth : null;
2347
- const { file } = await loadPrepFile(id);
2348
- const idIndex = buildIdIndex(file.root);
2349
- const path = resolveNodeId(idIndex, startNodeId);
2350
- const anchor = getNodeByPath(file.root, path);
2351
- const fenIndex = filter === "transpositions" ? buildFenIndex(file.root) : null;
2352
- const hits = [];
2353
- const walk = (node, depthLeft, spineOnly) => {
2354
- // Root has no san — never emit it as a match. Everything else is fair game.
2355
- if (node.id !== ROOT_ID) {
2356
- let include = false;
2357
- let extra = {};
2358
- switch (filter) {
2359
- case "missing_eval":
2360
- include = !node.ceoEval;
2361
- break;
2362
- case "has_comment":
2363
- include = !!(node.comment && node.comment.length > 0);
2364
- if (include)
2365
- extra.comment_preview = (node.comment || "").slice(0, 80);
2366
- break;
2367
- case "has_annotations":
2368
- include = !!(node.annotations && (node.annotations.arrows.length > 0 || node.annotations.highlights.length > 0));
2369
- break;
2370
- case "novelties":
2371
- include = !!(node.nags && node.nags.includes("$146"));
2372
- break;
2373
- case "leaves":
2374
- include = node.children.length === 0;
2375
- break;
2376
- case "mainline":
2377
- include = spineOnly;
2378
- break;
2379
- case "transpositions": {
2380
- const group = fenIndex.get(positionKey(node.fen));
2381
- if (group && group.length > 1) {
2382
- include = true;
2383
- extra.transposes_to = group.filter(n => n.id !== node.id).map(n => n.id);
2384
- }
2385
- break;
2386
- }
2387
- case "all":
2388
- include = true;
2389
- break;
2390
- default:
2391
- throw new Error(`unknown filter: ${filter}`);
2392
- }
2393
- if (include) {
2394
- const hit = { node_id: node.id, san: node.san, ply: node.ply };
2395
- Object.assign(hit, extra);
2396
- hits.push(hit);
2397
- }
2398
- }
2399
- if (depthLeft !== null && depthLeft <= 0)
2400
- return;
2401
- const nextDepth = depthLeft === null ? null : depthLeft - 1;
2402
- if (filter === "mainline" && spineOnly) {
2403
- if (node.children.length > 0)
2404
- walk(node.children[0], nextDepth, true);
2405
- }
2406
- else {
2407
- for (const c of node.children)
2408
- walk(c, nextDepth, filter === "mainline");
2409
- }
2410
- };
2411
- const rootIsSpineForFilter = filter === "mainline";
2412
- walk(anchor, maxDepth, rootIsSpineForFilter);
2413
- return { file_id: id, filter, node_id: startNodeId, max_depth: maxDepth, count: hits.length, nodes: hits };
2414
- }
2415
- // list_transpositions — every position that occurs 2+ times in the
2416
- // file, so the LLM knows where its analysis / prose will double up.
2417
- async function listTranspositions(args) {
2418
- const id = String(args.id);
2419
- const { file } = await loadPrepFile(id);
2420
- const fenIndex = buildFenIndex(file.root);
2421
- const groups = [];
2422
- for (const [key, arr] of fenIndex.entries()) {
2423
- if (arr.length < 2)
2424
- continue;
2425
- groups.push({
2426
- position_key: key,
2427
- size: arr.length,
2428
- node_ids: arr.map(n => n.id),
2429
- sans: arr.map(n => n.san),
2430
- });
2431
- }
2432
- groups.sort((a, b) => b.size - a.size || a.position_key.localeCompare(b.position_key));
2433
- const nodeCount = groups.reduce((s, g) => s + g.size, 0);
2434
- return { file_id: id, group_count: groups.length, node_count: nodeCount, groups };
2435
- }
2436
- // Strip cruft the LLM doesn't need from the DB-position response.
2437
- // Called AFTER trimGamesMovetext so plyNumber survives long enough to
2438
- // slice each game's movetext. Also renames the `transpositions` field
2439
- // to something the LLM can parse without knowing chess-DB jargon.
2440
- function stripPositionResponse(r) {
2441
- if (!r || typeof r !== "object")
2442
- return;
2443
- const t = r;
2444
- delete t.hash; // internal zobrist string
2445
- delete t.source; // internal "database" marker; we overwrite with our own .source
2446
- delete t.totalGames; // duplicates statistics.totalCount often; hasMore covers pagination
2447
- if (Array.isArray(t.moves)) {
2448
- for (const m of t.moves) {
2449
- if (typeof m.transpositions === "number") {
2450
- m.reachedViaTransposition = m.transpositions;
2451
- delete m.transpositions;
2452
- }
2453
- // Backend calls it "hotness" — a 0-100 time-decayed popularity score
2454
- // (recent + played often = high). Rename to something an LLM can read
2455
- // without guessing it means "on a winning streak".
2456
- if (typeof m.hotness === "number") {
2457
- m.fashionScore = m.hotness;
2458
- delete m.hotness;
2459
- }
2460
- }
2461
- }
2462
- if (Array.isArray(t.games)) {
2463
- for (const g of t.games) {
2464
- delete g.gameId;
2465
- delete g.whiteTitle;
2466
- delete g.blackTitle;
2467
- delete g.whiteTeam;
2468
- delete g.blackTeam;
2469
- delete g.round;
2470
- delete g.plyNumber;
2471
- delete g.relevance;
2472
- delete g.site;
2473
- delete g.ply;
2474
- }
2475
- }
2476
- }
2477
- // Trim every game's `moves` field to just the plies AFTER the queried
2478
- // position, using each game's `plyNumber`. Massive token save — a game
2479
- // 80 plies long queried at ply 12 drops to ~68 plies of movetext. Ports
2480
- // the frontend's GamesTable.getMoveDisplay() trim logic.
2481
- function trimGamesMovetext(response) {
2482
- if (!response || typeof response !== "object")
2483
- return;
2484
- const r = response;
2485
- if (!Array.isArray(r.games))
2486
- return;
2487
- for (const g of r.games) {
2488
- if (typeof g.moves === "string" && typeof g.plyNumber === "number" && g.plyNumber > 0) {
2489
- g.moves = trimMovesToPly(g.moves, g.plyNumber);
2490
- }
2491
- }
2492
- }
2493
- function trimMovesToPly(moves, plyNumber) {
2494
- // Split into plain SAN tokens, dropping standalone move-number tokens
2495
- // ("1.", "12...") and any glued number prefix on a SAN token ("1.e4").
2496
- // Result markers ("*", "1-0", "0-1", "1/2-1/2") are stripped so they
2497
- // don't get counted as plies.
2498
- const tokens = [];
2499
- for (const chunk of moves.split(/\s+/)) {
2500
- if (!chunk)
2501
- continue;
2502
- const cleaned = chunk.replace(/^\d+\.+/, "");
2503
- if (!cleaned)
2504
- continue;
2505
- if (/^(1-0|0-1|1\/2-1\/2|\*)$/.test(cleaned))
2506
- continue;
2507
- tokens.push(cleaned);
2508
- }
2509
- const remaining = tokens.slice(plyNumber);
2510
- if (remaining.length === 0)
2511
- return "";
2512
- // Reconstruct with move numbering. First move gets "N..." if it's
2513
- // Black's move (starting the slice mid-move-pair), so the reader knows
2514
- // moves were dropped.
2515
- const out = [];
2516
- let ply = plyNumber;
2517
- for (let i = 0; i < remaining.length; i++) {
2518
- const san = remaining[i];
2519
- const moveNumber = Math.floor(ply / 2) + 1;
2520
- if (ply % 2 === 0) {
2521
- out.push(`${moveNumber}. ${san}`);
2522
- }
2523
- else if (i === 0) {
2524
- out.push(`${moveNumber}... ${san}`);
2525
- }
2526
- else {
2527
- out.push(san);
2528
- }
2529
- ply++;
2530
- }
2531
- return out.join(" ");
2532
- }
2533
- // Rewrite availableMoves[].move UCI → SAN. The prep + position-stats
2534
- // endpoints return moves in UCI on the wire — same LLM-readability
2535
- // concern as engine PVs, and the same wrapper-only fix. Passes the
2536
- // response through unchanged if there's no availableMoves array.
2537
- function convertAvailableMovesToSAN(raw, fen) {
2538
- if (!raw || typeof raw !== "object")
2539
- return raw;
2540
- const r = raw;
2541
- if (!Array.isArray(r.availableMoves))
2542
- return raw;
2543
- for (const m of r.availableMoves) {
2544
- if (typeof m.move === "string" && m.move.length >= 4) {
2545
- m.move = uciMoveToSAN(fen, m.move);
2546
- }
2547
- }
2548
- return raw;
2549
- }
2550
- // Resolve a starting FEN from any combination of `fen`, `line`, and
2551
- // `moves` the tool received. Three modes, all valid:
2552
- //
2553
- // fen alone → use as-is
2554
- // line/moves alone → walk from startpos
2555
- // fen + moves (or line) → walk from that fen
2556
- //
2557
- // `line` is the historical field name from the backend's prep endpoint;
2558
- // `moves` is the flexible-input name we now surface for LLM ergonomics
2559
- // ("start from this FEN and play these moves next"). They're synonyms
2560
- // here — same SAN sequence, same chess.js walker. `moves` wins if both
2561
- // happen to be provided.
2562
- function resolveFenFromArgs(args) {
2563
- const fenArg = typeof args.fen === "string" ? args.fen.trim() : "";
2564
- const movesArg = typeof args.moves === "string" ? args.moves.trim() : "";
2565
- const lineArg = typeof args.line === "string" ? args.line.trim() : "";
2566
- const sequence = movesArg || lineArg;
2567
- const board = fenArg ? new Chess(fenArg) : new Chess();
2568
- if (sequence) {
2569
- for (const raw of sequence.split(/\s+/)) {
2570
- const san = raw.replace(/^\d+\.+/, "");
2571
- if (!san)
2572
- continue;
2573
- try {
2574
- board.move(san);
2575
- }
2576
- catch {
2577
- throw new Error(`bad SAN token '${raw}' in moves`);
2578
- }
2579
- }
2580
- }
2581
- return board.fen();
2582
- }
2583
- // Normalize one MCP `prepare_opponent` source into the shape the backend's
2584
- // /api/chess/prep/prepare-multi expects. Handles two impedance mismatches:
2585
- // - snake_case → camelCase (fide_id → fideId, start_month → startMonth, etc.)
2586
- // - the unified `time_control` string → per-source-type filter:
2587
- // * fide / chesscom → timeFormats: ["Classical" | "Rapid" | "Blitz"]
2588
- // * lichess → perfType: "classical" | "rapid" | "blitz" | "bullet"
2589
- // Backend validates required fields per source type, so we don't need to
2590
- // pre-reject missing username/fideId here — it'll come back as a 400 the
2591
- // LLM can act on.
2592
- function normalizeSourceForBackend(src, idx) {
2593
- const type = typeof src.type === "string" ? src.type : "";
2594
- if (type !== "fide" && type !== "chesscom" && type !== "lichess") {
2595
- throw new Error(`sources[${idx}].type must be one of fide|chesscom|lichess (got ${JSON.stringify(src.type)})`);
2596
- }
2597
- const out = { type };
2598
- if (typeof src.fide_id === "number")
2599
- out.fideId = src.fide_id;
2600
- if (typeof src.username === "string" && src.username.trim() !== "")
2601
- out.username = src.username.trim();
2602
- if (typeof src.color === "string" && (src.color === "white" || src.color === "black"))
2603
- out.color = src.color;
2604
- if (typeof src.start_month === "string" && src.start_month.trim() !== "")
2605
- out.startMonth = src.start_month.trim();
2606
- if (typeof src.end_month === "string" && src.end_month.trim() !== "")
2607
- out.endMonth = src.end_month.trim();
2608
- if (typeof src.exclude_online === "boolean")
2609
- out.excludeOnline = src.exclude_online;
2610
- const tc = typeof src.time_control === "string" ? src.time_control : "";
2611
- if (tc) {
2612
- if (type === "lichess") {
2613
- out.perfType = tc;
2614
- }
2615
- else {
2616
- // fide + chesscom take a titlecased timeFormats array.
2617
- const titled = tc.charAt(0).toUpperCase() + tc.slice(1);
2618
- out.timeFormats = [titled];
2619
- }
2620
- }
2621
- return out;
2622
- }
2623
- // Async resolver used by every engine / DB tool. Three paths:
2624
- //
2625
- // 1. file_id + node_id → load file, resolve node, return FEN + handle
2626
- // to persist ceoEval later. Cheapest, most explicit.
2627
- //
2628
- // 2. file_id + (fen | moves | line) → load file, resolve FEN
2629
- // client-side, then scan the file's nodes for one matching that
2630
- // FEN. If found, return the same handle as (1) so cloud_analyse
2631
- // auto-stores on the matching node. Fixes the previous footgun
2632
- // where `cloud_analyse({file_id, moves})` silently dropped the
2633
- // eval because the server didn't try to match the resulting FEN
2634
- // back to a node.
2635
- //
2636
- // 3. Just fen | moves | line, no file_id → scratch mode, no
2637
- // persistence. Same as before.
2638
- async function resolveFromNodeOrFen(args) {
2639
- const fileId = typeof args.file_id === "string" ? args.file_id.trim() : "";
2640
- const nodeId = typeof args.node_id === "string" ? args.node_id.trim() : "";
2641
- if (fileId && nodeId) {
2642
- const g = await fetchGame(fileId);
2643
- const parsedFile = parsePGN(g.pgnContent);
2644
- const idIndex = buildIdIndex(parsedFile.root);
2645
- const nodePath = resolveNodeId(idIndex, nodeId);
2646
- const node = getNodeByPath(parsedFile.root, nodePath);
2647
- return {
2648
- fen: node.fen,
2649
- file: { id: fileId, version: g.version ?? 0, parsedFile, idIndex, nodePath, fen: node.fen },
2650
- };
2651
- }
2652
- if (fileId) {
2653
- // file_id only — resolve FEN from fen/moves/line, then look it up
2654
- // in the file's nodes. If a node has that FEN, treat this as if
2655
- // node_id had been supplied (auto-persist on match).
2656
- const fen = resolveFenFromArgs(args);
2657
- try {
2658
- const g = await fetchGame(fileId);
2659
- const parsedFile = parsePGN(g.pgnContent);
2660
- const match = findNodeByFen(parsedFile.root, fen);
2661
- if (match) {
2662
- const idIndex = buildIdIndex(parsedFile.root);
2663
- return {
2664
- fen,
2665
- file: { id: fileId, version: g.version ?? 0, parsedFile, idIndex, nodePath: match.path, fen },
2666
- };
2667
- }
2668
- }
2669
- catch {
2670
- // Best-effort: if the file load fails, fall through to scratch mode.
2671
- }
2672
- return { fen };
2673
- }
2674
- return { fen: resolveFenFromArgs(args) };
2675
- }
2676
- // Search the tree for a node whose FEN matches. Full-tree scan — trees
2677
- // max out ~1000 nodes so this is fine. FEN comparison is exact string
2678
- // match (both come from the same chessops normalisation).
2679
- function findNodeByFen(root, targetFen) {
2680
- const stack = [{ node: root, path: [] }];
2681
- while (stack.length > 0) {
2682
- const { node, path } = stack.pop();
2683
- if (node.fen === targetFen)
2684
- return { node, path };
2685
- for (let i = 0; i < node.children.length; i++) {
2686
- stack.push({ node: node.children[i], path: [...path, i] });
2687
- }
2688
- }
2689
- return null;
2690
- }
2691
- // Local wrapper — the mutation module re-exports paths.getNode so this
2692
- // import stays consistent with the rest of the file's imports.
2693
- function getNodeByPath(root, path) {
2694
- let cur = root;
2695
- for (const idx of path) {
2696
- if (idx < 0 || idx >= cur.children.length)
2697
- throw new Error(`invalid node path segment ${idx}`);
2698
- cur = cur.children[idx];
2699
- }
2700
- return cur;
2701
- }
2702
- // Persist a fresh ceoEval on the node referenced by the file handle
2703
- // AND on every other node in the same file that transposes to the
2704
- // same position (matches on the frontend's 3-field FEN key: piece
2705
- // placement + side to move + castling). Best-effort — if the file
2706
- // version raced (another agent saved between our GET and our PUT),
2707
- // we silently drop the store rather than fail the analysis the LLM
2708
- // actually asked for. The eval is still returned in the response
2709
- // either way.
2710
- //
2711
- // Return: ids of every node the eval was stamped on (empty on error).
2712
- // The primary node's id is always first (if present).
2713
- async function storeEvalOnNode(handle, ev) {
2714
- try {
2715
- const anchor = getNodeByPath(handle.parsedFile.root, handle.nodePath);
2716
- const key = positionKey(anchor.fen);
2717
- const fenIndex = buildFenIndex(handle.parsedFile.root);
2718
- const group = fenIndex.get(key) ?? [anchor];
2719
- // Resolve every transposed node back to its path. cloneOnPath
2720
- // rebuilds the spine so we need paths, not references — the
2721
- // id index was built against the original tree and every id in
2722
- // `group` exists there.
2723
- const idIndex = handle.idIndex ?? buildIdIndex(handle.parsedFile.root);
2724
- const paths = group.map(n => resolveNodeId(idIndex, n.id));
2725
- const { file: newFile, ids } = setCeoEvalMany(handle.parsedFile, paths, ev);
2726
- const newPgn = exportPGN(newFile);
2727
- await saveGame(handle.id, newPgn, handle.version);
2728
- // Ensure the primary node (the one the LLM addressed) comes first.
2729
- const anchorId = anchor.id;
2730
- return [anchorId, ...ids.filter(x => x !== anchorId)];
2731
- }
2732
- catch {
2733
- return [];
2734
- }
2735
- }
2736
119
  function stringifyForLog(v) {
2737
120
  let s;
2738
121
  try {
@@ -3032,6 +415,10 @@ async function callToolInner(name, args) {
3032
415
  await deleteGame(String(args.id));
3033
416
  return { ok: true };
3034
417
  }
418
+ case "restore_prep_file": {
419
+ const game = await restoreGame(String(args.id));
420
+ return { ok: true, id: makeFileId(game.collectionId, game.id), version: game.version };
421
+ }
3035
422
  case "add_move":
3036
423
  return applyMutation(args, (file, idIndex) => {
3037
424
  const parentPath = resolveNodeId(idIndex, argNodeId(args, "parent_id"));
@@ -3095,7 +482,7 @@ async function callToolInner(name, args) {
3095
482
  case "apply_mutations":
3096
483
  return applyBatchMutations(args);
3097
484
  case "auto_evaluate":
3098
- return autoEvaluate(args);
485
+ return autoEvaluate(args, applyBatchMutations);
3099
486
  case "auto_evaluate_status":
3100
487
  return autoEvaluateStatus(args);
3101
488
  case "auto_evaluate_cancel":
@@ -3153,51 +540,6 @@ async function callToolInner(name, args) {
3153
540
  throw new Error(`Unknown tool: ${name}`);
3154
541
  }
3155
542
  }
3156
- // ── Prompt templates ───────────────────────────────────────────────
3157
- //
3158
- // MCP prompts are pre-baked instructions the host surfaces in a slash-menu
3159
- // (Claude Desktop, Cursor, etc.). Users pick one and the templated content
3160
- // gets injected into the conversation. Perfect for workflows the LLM
3161
- // wouldn't reliably discover from tool descriptions alone — chess prep
3162
- // especially, where "call the right tools in the right order weighted by
3163
- // recency and format" is non-obvious.
3164
- const PROMPTS = [
3165
- {
3166
- name: "prepare_for_game",
3167
- description: "Prep workflow for an upcoming chess game. Walks both players' repertoires and identifies where the opponent is weakest.",
3168
- arguments: [
3169
- { name: "me", description: "Your name (or FIDE ID)", required: true },
3170
- { name: "opponent", description: "Opponent's name (or FIDE ID)", required: true },
3171
- { name: "my_color", description: "The color you'll be playing: 'white' or 'black'. Optional — if you don't know yet, ask.", required: false },
3172
- { name: "time_control", description: "Optional: 'classical', 'rapid', 'blitz'. Weights which of the opponent's games matter most.", required: false },
3173
- ],
3174
- },
3175
- {
3176
- name: "scout_player",
3177
- description: "Deep scouting report on one player. Their style, top openings, recent form, and where they've been beaten.",
3178
- arguments: [
3179
- { name: "player", description: "Player name or FIDE ID", required: true },
3180
- ],
3181
- },
3182
- {
3183
- name: "head_to_head_briefing",
3184
- description: "Briefing on the history between two players — who has the edge, what openings decide their meetings, style clash.",
3185
- arguments: [
3186
- { name: "player_a", description: "First player (name or FIDE ID)", required: true },
3187
- { name: "player_b", description: "Second player (name or FIDE ID)", required: true },
3188
- ],
3189
- },
3190
- {
3191
- name: "engine_usage_primer",
3192
- description: "Full guide on how to use the chess.ceo cloud engines — Stockfish vs Lc0 tradeoffs, when to trust which, how to read disagreements, and how to use Lc0 contempt to find practical ideas. Read before running expensive cloud_analyse calls or when the user asks WHY the engines gave certain scores.",
3193
- arguments: [],
3194
- },
3195
- {
3196
- name: "prep_strategy_primer",
3197
- description: "Full guide on how to reason about opening preparation — why win% is one weight not a rule, why prep is a two-player game with symmetric information, when 'revealed weaknesses' are actionable, how to use move-order tricks, and how to calibrate surprise. Read before recommending an opening plan, especially when the user is preparing for a specific real opponent.",
3198
- arguments: [],
3199
- },
3200
- ];
3201
543
  // The workflow text for prepare_for_game. Kept in one place so both the
3202
544
  // MCP prompt handler and the /prepare fallback can share it.
3203
545
  const PREP_WORKFLOW = (args) => {