@chessceo/mcp 0.36.6 → 0.39.1

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
@@ -76,6 +76,7 @@ const AUTHED_TOOLS = new Set([
76
76
  "list_prep_files",
77
77
  "search_prep_files",
78
78
  "read_prep_file",
79
+ "list_nodes",
79
80
  "create_prep_file",
80
81
  "delete_prep_file",
81
82
  "add_move",
@@ -94,6 +95,7 @@ const AUTHED_TOOLS = new Set([
94
95
  "deep_analyse_status",
95
96
  "deep_analyse_cancel",
96
97
  "find_position_in_courses",
98
+ "read_course_at_position",
97
99
  "quote_engine_eval",
98
100
  "predict_human_move",
99
101
  "prepare_opponent",
@@ -329,10 +331,21 @@ const TOOLS = [
329
331
  },
330
332
  {
331
333
  name: "describe_position",
332
- description: "Structured facts about a chess position: piece placements per colour (SAN-style letters), material balance in pawn units, list of every contested piece (attackers + defenders), hanging pieces, checkers if in check, castling rights, en passant square, side to move, and the full list of LEGAL MOVES for the side to move. Pure computation — no engine, ~1 ms per call.\n\n" +
333
- "USE THIS BEFORE COMMENTING ON A POSITION. LLMs are not reliable at reading FEN strings — you'll misplace pieces or invent captures. This tool gives you the same board state a human sees.\n\n" +
334
- "ALSO USE THIS if `add_move` rejects an illegal SAN the `.legalMoves` array shows exactly what's playable from that position.\n\n" +
335
- "Position input: prefer `file_id`+`node_id` if you're inside a prep file. Otherwise `fen`, `moves` from startpos, or `fen + moves`.",
334
+ description: "Everything you need to understand a position in one call. USE BEFORE COMMENTING pieces get misplaced when reading a FEN, hanging pieces missed, 'the knight on d5' turns out to not exist. ~50-100 ms per call (chess-primitive analysis is instant; the Stockfish leg dominates wall time).\n\n" +
335
+ "Returns three layers:\n\n" +
336
+ "**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" +
337
+ "**Structural analysis** chess-concept observations a human sees at a glance:\n" +
338
+ " • `pawnStructure.files` — each file `open`/`half_open_for_white`/`half_open_for_black`/`closed`. Half-open files are natural rook targets.\n" +
339
+ " • `pawnStructure.islands` — count per colour (more = weaker structure).\n" +
340
+ " • `pawnStructure.isolated` / `doubled` / `passed` / `backward` — structural weaknesses (and strengths, for passed).\n" +
341
+ " • `weakSquares` — holes in ranks 3-6 that no friendly pawn can ever attack. Prime real estate for enemy pieces.\n" +
342
+ " • `outposts` — friendly N/B on an enemy hole defended by own pawn. Classic strong squares.\n" +
343
+ " • `bishops` — per-bishop `good`/`mixed`/`bad` from own pawns on its colour. `bishops.pair` flags who has both.\n" +
344
+ " • `space` — squares controlled in the enemy half.\n\n" +
345
+ "**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" +
346
+ " → **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" +
347
+ " → Omitted from the response if Stockfish isn't installed on the server.\n\n" +
348
+ "Position input: prefer `file_id`+`node_id` if inside a prep file. Otherwise `fen`, `moves` from startpos, or `fen + moves`.",
336
349
  inputSchema: {
337
350
  type: "object",
338
351
  properties: {
@@ -345,15 +358,11 @@ const TOOLS = [
345
358
  },
346
359
  {
347
360
  name: "predict_human_move",
348
- description: "Predicts what a HUMAN of the given rating will most likely play from a position. Rating-conditioned neural net (ResNet-20x256) you get the top-N most likely moves with their probabilities and a WDL value head (White POV).\n\n" +
349
- "This is a completely different question from the engine tools (analyse / cloud_analyse):\n" +
350
- " Engines answer: 'what is objectively best?'\n" +
351
- " predict_human_move answers: 'what will my 2200-rated opponent actually play here?'\n\n" +
352
- "Prime use cases in prep:\n" +
353
- "• After you've found what the opponent SHOULD do (with the engine), check what they'll ACTUALLY do at their rating. If the top human move is a mistake, you have a real practical advantage.\n" +
354
- "• The WDL head is rating-aware — a 400-point gap will show up as a big win probability even in equal positions (the model has learned that human errors compound).\n" +
355
- "• Pass `prev_fen` (most recent first) when analysing mid-trade positions — without history the model treats the position as quiet.\n\n" +
356
- "Position input: prefer `file_id`+`node_id` when inside a prep file. Otherwise `fen`, `moves` from startpos, or `fen + moves`. Default rating 2400 both sides. ~1-2s per call. **Premium (or admin/moderator) only** — anonymous calls get 402.",
361
+ 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" +
362
+ "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" +
363
+ "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" +
364
+ "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" +
365
+ "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.",
357
366
  inputSchema: {
358
367
  type: "object",
359
368
  properties: {
@@ -364,18 +373,6 @@ const TOOLS = [
364
373
  type: "string",
365
374
  description: "Optional SAN moves to apply on top of `fen` (or startpos). Only used if `node_id` is not set.",
366
375
  },
367
- white_elo: {
368
- type: "integer",
369
- minimum: 100,
370
- maximum: 3400,
371
- description: "White's rating (default 2400).",
372
- },
373
- black_elo: {
374
- type: "integer",
375
- minimum: 100,
376
- maximum: 3400,
377
- description: "Black's rating (default 2400).",
378
- },
379
376
  top: {
380
377
  type: "integer",
381
378
  minimum: 1,
@@ -545,17 +542,50 @@ const TOOLS = [
545
542
  },
546
543
  {
547
544
  name: "read_prep_file",
548
- description: "Read one prep file. Returns a compact tree (each node carries a stable `id`, `san`, `fen`, `ply`, optional `comment`/`nags`/`annotations`/`ceoEval`, and `children`) plus tags and the `version` for optimistic locking on subsequent mutations. NO raw PGN all edits go through the mutation tools (add_move / add_line / set_comment / set_nags / set_annotations / delete_subtree / promote_variation / set_tag), which validate SAN and structure for you.\n\n" +
549
- "**Node addressing.** Every node has a stable `id` — root is `'r'`, every other node is an 8-hex-char content hash derived from its parent's id + its SAN. Sibling insertions, deletions and variation promotions do NOT change any other node's id. Pass this id as `node_id` (or `parent_id` for add_move / add_line) to every mutation and engine/DB tool.\n\n" +
545
+ 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" +
546
+ "**Views** (pick the smallest one that answers your question):\n" +
547
+ " • `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" +
548
+ " • `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" +
549
+ " • `spine` — mainline only (children[0] recursively). Great for a 'what does this repertoire cover' summary.\n" +
550
+ " • `pgn` — subtree as raw PGN text (comments, NAGs, [%cal] arrows all preserved). Useful for sanity-checking formatting against reference material.\n\n" +
551
+ "**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" +
552
+ "**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" +
553
+ "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" +
550
554
  "**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.",
551
555
  inputSchema: {
552
556
  type: "object",
553
557
  properties: {
554
558
  id: { type: "string", description: "Prep file id, from list_prep_files or search_prep_files." },
559
+ 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." },
560
+ node_id: { type: "string", description: "Subtree root (default `'r'` = whole file)." },
561
+ max_depth: { type: "integer", minimum: 0, description: "Cap the returned tree at this many plies below `node_id`. Omit for unlimited." },
555
562
  },
556
563
  required: ["id"],
557
564
  },
558
565
  },
566
+ {
567
+ name: "list_nodes",
568
+ 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" +
569
+ "Filters:\n" +
570
+ " • `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" +
571
+ " • `has_comment` — nodes with a text comment. Use to audit what's been annotated.\n" +
572
+ " • `has_annotations` — nodes with arrows or highlighted squares.\n" +
573
+ " • `mainline` — the spine (children[0] recursively). Use for a compact 'what does the repertoire cover' view.\n" +
574
+ " • `novelties` — nodes carrying the `$146` NAG.\n" +
575
+ " • `leaves` — nodes with no children (variation endpoints). Useful for finding lines that need continuation.\n" +
576
+ " • `all` — every node id. Use only when you really need the whole list.\n\n" +
577
+ "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; `missing_eval` includes nothing extra (just the addressing).",
578
+ inputSchema: {
579
+ type: "object",
580
+ properties: {
581
+ id: { type: "string", description: "Prep file id." },
582
+ filter: { type: "string", enum: ["missing_eval", "has_comment", "has_annotations", "mainline", "novelties", "leaves", "all"], description: "Which nodes to list." },
583
+ node_id: { type: "string", description: "Subtree root (default `'r'` = whole file)." },
584
+ max_depth: { type: "integer", minimum: 0, description: "Cap the walk at this many plies below `node_id`. Omit for unlimited." },
585
+ },
586
+ required: ["id", "filter"],
587
+ },
588
+ },
559
589
  {
560
590
  name: "create_prep_file",
561
591
  description: "Create a new (empty) prep file. `name` becomes the Event PGN tag. You then extend it with mutation tools (add_move, set_comment, …).\n\n" +
@@ -831,10 +861,13 @@ const TOOLS = [
831
861
  },
832
862
  {
833
863
  name: "find_position_in_courses",
834
- description: "Look up which of the USER's own Chessable / PGN courses cover a position, ranked by how much ANNOTATED material sits below that position in each course. This is the LLM's window into what the user has personally studied — not a general database. Complements engine + big-DB tools: the general database says what the world plays, this says what the user has literature on.\n\n" +
835
- "Ranking: files/chapters where the position sits at a branching point with lots of text under it rank first. A chapter that merely passes through the position on its way somewhere else ranks last, which is the whole point — the LLM wants to point the user at where the material actually explains this specific position.\n\n" +
836
- "Use this to: cite specific chapters the user already owns when recommending an opening decision, cross-check whether the user has coverage of a rare line, find author overlap when the user asks 'what do I have on this?'\n\n" +
837
- "Returns: `{fen, found, total_occurrences, excluded: {game_db_hits, unmapped_files, thin_entries_below_min, min_notes_chars}, hits: [{course, file, author, chapter, line, ply, notes_chars, subtree_moves}], truncated}`. `notes_chars` is the total characters of commentary in the subtree rooted at this occurrence — the primary rank signal. `ply` tells you how deep in the chapter's game tree this position sits (small ply = near the chapter's start).\n\n" +
864
+ 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" +
865
+ "Use it as a reference library, not memory. Query patterns:\n" +
866
+ " 'Does my chosen line have coverage?' search from the position, see which repertoires cover it.\n" +
867
+ " 'What do opposite-colour repertoires recommend against this move?' search, filter by author/course to the other side.\n" +
868
+ " • 'Has anyone tried my novelty before?' → search the position, if hits exist read the ones that reach it.\n\n" +
869
+ "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" +
870
+ "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.\n\n" +
838
871
  "Not available if the fenfind index isn't installed on the server — response includes a clear note in that case.",
839
872
  inputSchema: {
840
873
  type: "object",
@@ -843,6 +876,7 @@ const TOOLS = [
843
876
  node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`/`moves`." },
844
877
  fen: { type: "string", description: "Starting position as FEN. Only used if `node_id` is not set." },
845
878
  moves: { type: "string", description: "Optional SAN moves on top of `fen` (or startpos). Only used if `node_id` is not set." },
879
+ sort: { type: "string", enum: ["recency", "notes"], description: "Ranking. `recency` (default) = most-recently-updated file first. `notes` = deepest annotation first regardless of age." },
846
880
  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." },
847
881
  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." },
848
882
  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." },
@@ -850,6 +884,27 @@ const TOOLS = [
850
884
  },
851
885
  },
852
886
  },
887
+ {
888
+ name: "read_course_at_position",
889
+ 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" +
890
+ "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" +
891
+ "Usage patterns:\n" +
892
+ " • Read what an author says about a specific position → pass `course_file_id` from a find hit + the FEN.\n" +
893
+ " • Explore a chapter from move 1 → pass `course_file_id` + `chapter`, no FEN.\n" +
894
+ " • Skim deeper into a branch you're interested in → same file/chapter, wider `max_plies_below`.\n" +
895
+ " • Compare how two authors annotate the same position → two calls with different `course_file_id`s.",
896
+ inputSchema: {
897
+ type: "object",
898
+ properties: {
899
+ course_file_id: { type: "integer", description: "File id from a `find_position_in_courses` hit (`course_file_id` field)." },
900
+ 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." },
901
+ moves: { type: "string", description: "Alternative to `fen`: SAN moves from startpos." },
902
+ 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." },
903
+ 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." },
904
+ },
905
+ required: ["course_file_id"],
906
+ },
907
+ },
853
908
  {
854
909
  name: "quote_engine_eval",
855
910
  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" +
@@ -902,9 +957,9 @@ const TOOLS = [
902
957
  },
903
958
  {
904
959
  name: "read_example_prep_files",
905
- description: "Returns two full reference PGNs authored by a strong human coach one a general opening overview (Italian Fried Liver, both sides, 1600+ audience) and one a one-sided repertoire (Najdorf 6.f4 for White, 2200+ audience). Bundled with the MCP; not the user's files.\n\n" +
906
- "Read these ONCE per session before writing your first substantial prep file. The authoring guide (read_pgn_authoring_guide) tells you the rules; these examples show you what the rules look like when applied by someone who knows what they're doing comment density, when to cite games by player name, how to use `$146` / `$3` / `$44` sparingly and correctly, when a bare `[%csl Rf7]` says everything, how to acknowledge transpositions, how to phrase practical guidance vs objective evaluation. Study the rhythm before writing your own.\n\n" +
907
- "Response: `{ overview: <pgn>, repertoire: <pgn> }`. Raw PGN comments, arrows, NAGs, and stored evals all intact.",
960
+ 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" +
961
+ "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" +
962
+ "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.",
908
963
  inputSchema: { type: "object", properties: {} },
909
964
  },
910
965
  {
@@ -1177,6 +1232,7 @@ async function autoEvaluate(args) {
1177
1232
  targetCount: 0,
1178
1233
  evaluated: 0,
1179
1234
  errored: 0,
1235
+ failedNodeIds: [],
1180
1236
  finalVersion: g.version,
1181
1237
  startedAt: Date.now(),
1182
1238
  finishedAt: Date.now(),
@@ -1192,6 +1248,7 @@ async function autoEvaluate(args) {
1192
1248
  targetCount: targets.length,
1193
1249
  evaluated: 0,
1194
1250
  errored: 0,
1251
+ failedNodeIds: [],
1195
1252
  startedAt: Date.now(),
1196
1253
  cancelled: false,
1197
1254
  };
@@ -1235,8 +1292,15 @@ async function runEvalJob(job, fileId, targets, movetimeMs) {
1235
1292
  job.finalVersion = sr.version;
1236
1293
  pending.length = 0;
1237
1294
  };
1295
+ // Consecutive-failure abort. If N cloud_analyse calls in a row error,
1296
+ // the engine is almost certainly dead (vanished contract, network to
1297
+ // VastAI down) and burning through the rest of the tree just wastes
1298
+ // time. Bail with an explicit reason so a targeted retry is possible.
1299
+ const MAX_CONSECUTIVE_FAILURES = 3;
1300
+ let consecutiveFailures = 0;
1301
+ let aborted = false;
1238
1302
  for (const t of targets) {
1239
- if (job.cancelled)
1303
+ if (job.cancelled || aborted)
1240
1304
  break;
1241
1305
  try {
1242
1306
  const analysis = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen: t.fen, movetime_ms: movetimeMs, multipv: 1 });
@@ -1244,15 +1308,25 @@ async function runEvalJob(job, fileId, targets, movetimeMs) {
1244
1308
  if (ev) {
1245
1309
  pending.push({ op: "set_ceo_eval", node_id: t.nodeId, ceoEval: ev });
1246
1310
  job.evaluated++;
1311
+ consecutiveFailures = 0;
1247
1312
  }
1248
1313
  else {
1249
1314
  job.errored++;
1315
+ job.failedNodeIds.push(t.nodeId);
1316
+ consecutiveFailures++;
1250
1317
  }
1251
1318
  }
1252
1319
  catch {
1253
- // Per-node failure — record and continue; a bad FEN or a transient
1254
- // engine hiccup on one node shouldn't kill the whole walk.
1320
+ // Per-node failure — record the node_id so the caller can retry
1321
+ // just those, and count consecutive failures for the abort check.
1255
1322
  job.errored++;
1323
+ job.failedNodeIds.push(t.nodeId);
1324
+ consecutiveFailures++;
1325
+ }
1326
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
1327
+ aborted = true;
1328
+ job.abortedReason = `aborted after ${MAX_CONSECUTIVE_FAILURES} consecutive cloud_analyse failures — check that the cloud combo is still running (list_cloud_engines)`;
1329
+ break;
1256
1330
  }
1257
1331
  if (pending.length >= SAVE_EVERY_N) {
1258
1332
  try {
@@ -1297,6 +1371,8 @@ function autoEvaluateStatus(args) {
1297
1371
  target_count: job.targetCount,
1298
1372
  evaluated: job.evaluated,
1299
1373
  errored: job.errored,
1374
+ failed_node_ids: job.failedNodeIds, // exact ids for targeted retry — pass as node_id list or check with list_nodes
1375
+ aborted_reason: job.abortedReason, // present when the job stopped early due to consecutive engine failures
1300
1376
  remaining: Math.max(0, job.targetCount - job.evaluated - job.errored),
1301
1377
  done: job.status !== "running",
1302
1378
  error: job.error,
@@ -1475,41 +1551,74 @@ function deepAnalyseCancel(args) {
1475
1551
  // available (venv at $here/.venv/bin/python preferred, then falls back
1476
1552
  // to system python3). DB path is resolved inside fenfind.py itself
1477
1553
  // (FENFIND_DB env, then ~/positions.db).
1478
- const FENFIND_SCRIPT = (() => {
1479
- const envPath = process.env.FENFIND_PATH?.trim();
1480
- if (envPath) {
1481
- const p = join(envPath, "fenfind");
1482
- return existsSync(p) ? p : null;
1483
- }
1554
+ // Path resolution shared by both fenfind + readpgn. FENFIND_PATH env
1555
+ // overrides the bundled tools/fenfind/ directory.
1556
+ // Path to sf_eval helper (spawns local stockfish, parses its `eval`
1557
+ // verbose output). Uses the same resolution pattern as FENFIND_DIR.
1558
+ const SF_EVAL_SCRIPT = (() => {
1559
+ const envPath = process.env.SF_EVAL_PATH?.trim();
1560
+ if (envPath && existsSync(join(envPath, "sf_eval")))
1561
+ return join(envPath, "sf_eval");
1484
1562
  const here = dirname(fileURLToPath(import.meta.url));
1485
- const bundled = join(here, "..", "tools", "fenfind", "fenfind");
1563
+ const bundled = join(here, "..", "tools", "sf_eval", "sf_eval");
1486
1564
  return existsSync(bundled) ? bundled : null;
1487
1565
  })();
1488
- // Cap on how long we let fenfind run — SQLite query with a hash index
1489
- // should return in well under a second, but a stuck subprocess
1490
- // shouldn't pin the MCP handler.
1491
- const FENFIND_TIMEOUT_MS = 15_000;
1492
- async function findPositionInCourses(args) {
1493
- if (!FENFIND_SCRIPT) {
1566
+ const SF_EVAL_TIMEOUT_MS = 12_000;
1567
+ async function runSfEval(fen) {
1568
+ if (!SF_EVAL_SCRIPT) {
1494
1569
  return {
1495
- status: "not_available",
1496
- 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.",
1570
+ found: false,
1571
+ error: "sf_eval script not bundled; set SF_EVAL_PATH or install tools/sf_eval/",
1497
1572
  };
1498
1573
  }
1499
- const resolved = await resolveFromNodeOrFen(args);
1500
- const cliArgs = [resolved.fen, "--json"];
1501
- if (args.include_games)
1502
- cliArgs.push("--games");
1503
- if (args.chapters_mode)
1504
- cliArgs.push("--chapters");
1505
- if (typeof args.min_notes_chars === "number")
1506
- cliArgs.push("--min", String(args.min_notes_chars));
1507
- if (typeof args.limit === "number")
1508
- cliArgs.push("-n", String(args.limit));
1509
1574
  const stdout = await new Promise((resolve, reject) => {
1510
- const p = spawn(FENFIND_SCRIPT, cliArgs, {
1511
- stdio: ["ignore", "pipe", "pipe"],
1575
+ const p = spawn(SF_EVAL_SCRIPT, ["--fen", fen], { stdio: ["ignore", "pipe", "pipe"] });
1576
+ let out = "";
1577
+ let err = "";
1578
+ p.stdout.on("data", d => { out += d.toString("utf8"); });
1579
+ p.stderr.on("data", d => { err += d.toString("utf8"); });
1580
+ const to = setTimeout(() => {
1581
+ try {
1582
+ p.kill("SIGTERM");
1583
+ }
1584
+ catch { /* already dead */ }
1585
+ reject(new Error(`sf_eval timed out after ${SF_EVAL_TIMEOUT_MS}ms`));
1586
+ }, SF_EVAL_TIMEOUT_MS);
1587
+ p.on("error", e => { clearTimeout(to); reject(e); });
1588
+ p.on("close", code => {
1589
+ clearTimeout(to);
1590
+ if (code !== 0)
1591
+ reject(new Error(`sf_eval exited ${code}: ${err.slice(0, 500)}`));
1592
+ else
1593
+ resolve(out);
1512
1594
  });
1595
+ });
1596
+ try {
1597
+ return JSON.parse(stdout);
1598
+ }
1599
+ catch (e) {
1600
+ throw new Error(`sf_eval returned non-JSON output (${e instanceof Error ? e.message : String(e)}): ${stdout.slice(0, 300)}`);
1601
+ }
1602
+ }
1603
+ const FENFIND_DIR = (() => {
1604
+ const envPath = process.env.FENFIND_PATH?.trim();
1605
+ if (envPath && existsSync(join(envPath, "fenfind")))
1606
+ return envPath;
1607
+ const here = dirname(fileURLToPath(import.meta.url));
1608
+ const bundled = join(here, "..", "tools", "fenfind");
1609
+ return existsSync(join(bundled, "fenfind")) ? bundled : null;
1610
+ })();
1611
+ // Cap on how long we let the subprocess run. SQLite hash lookup returns
1612
+ // sub-second; PGN read from a course file is O(chapter size) and rarely
1613
+ // exceeds a second. 15s is a stuck-process backstop, not a real limit.
1614
+ const FENFIND_TIMEOUT_MS = 15_000;
1615
+ async function runFenfindScript(scriptName, args) {
1616
+ if (!FENFIND_DIR) {
1617
+ throw new Error("fenfind index not installed — set FENFIND_PATH or install the tools/fenfind bundle");
1618
+ }
1619
+ const script = join(FENFIND_DIR, scriptName);
1620
+ return new Promise((resolve, reject) => {
1621
+ const p = spawn(script, args, { stdio: ["ignore", "pipe", "pipe"] });
1513
1622
  let out = "";
1514
1623
  let err = "";
1515
1624
  p.stdout.on("data", d => { out += d.toString("utf8"); });
@@ -1519,25 +1628,71 @@ async function findPositionInCourses(args) {
1519
1628
  p.kill("SIGTERM");
1520
1629
  }
1521
1630
  catch { /* already dead */ }
1522
- reject(new Error(`fenfind timed out after ${FENFIND_TIMEOUT_MS}ms`));
1631
+ reject(new Error(`${scriptName} timed out after ${FENFIND_TIMEOUT_MS}ms`));
1523
1632
  }, FENFIND_TIMEOUT_MS);
1524
1633
  p.on("error", e => { clearTimeout(to); reject(e); });
1525
1634
  p.on("close", code => {
1526
1635
  clearTimeout(to);
1527
- if (code !== 0) {
1528
- reject(new Error(`fenfind exited ${code}: ${err.slice(0, 500)}`));
1529
- }
1530
- else {
1636
+ if (code !== 0)
1637
+ reject(new Error(`${scriptName} exited ${code}: ${err.slice(0, 500)}`));
1638
+ else
1531
1639
  resolve(out);
1532
- }
1533
1640
  });
1534
1641
  });
1642
+ }
1643
+ function parseFenfindJson(scriptName, stdout) {
1535
1644
  try {
1536
1645
  return JSON.parse(stdout);
1537
1646
  }
1538
1647
  catch (e) {
1539
- throw new Error(`fenfind returned non-JSON output (${e instanceof Error ? e.message : String(e)}): ${stdout.slice(0, 300)}`);
1648
+ throw new Error(`${scriptName} returned non-JSON output (${e instanceof Error ? e.message : String(e)}): ${stdout.slice(0, 300)}`);
1649
+ }
1650
+ }
1651
+ async function findPositionInCourses(args) {
1652
+ if (!FENFIND_DIR) {
1653
+ return {
1654
+ status: "not_available",
1655
+ 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.",
1656
+ };
1657
+ }
1658
+ const resolved = await resolveFromNodeOrFen(args);
1659
+ const cliArgs = [resolved.fen, "--json"];
1660
+ if (typeof args.sort === "string" && (args.sort === "recency" || args.sort === "notes")) {
1661
+ cliArgs.push("--sort", args.sort);
1662
+ }
1663
+ if (args.include_games)
1664
+ cliArgs.push("--games");
1665
+ if (args.chapters_mode)
1666
+ cliArgs.push("--chapters");
1667
+ if (typeof args.min_notes_chars === "number")
1668
+ cliArgs.push("--min", String(args.min_notes_chars));
1669
+ if (typeof args.limit === "number")
1670
+ cliArgs.push("-n", String(args.limit));
1671
+ const stdout = await runFenfindScript("fenfind", cliArgs);
1672
+ return parseFenfindJson("fenfind", stdout);
1673
+ }
1674
+ async function readCourseAtPosition(args) {
1675
+ if (!FENFIND_DIR) {
1676
+ return {
1677
+ status: "not_available",
1678
+ note: "fenfind index not installed on this server. Set FENFIND_PATH env var to the directory containing the `fenfind`/`readpgn` scripts and positions.db.",
1679
+ };
1680
+ }
1681
+ const fileId = typeof args.course_file_id === "number" ? args.course_file_id : Number(args.course_file_id);
1682
+ if (!Number.isFinite(fileId) || fileId <= 0) {
1683
+ throw new Error("`course_file_id` is required — pass the value from a find_position_in_courses hit");
1540
1684
  }
1685
+ const cliArgs = ["--file-id", String(fileId)];
1686
+ if (typeof args.fen === "string" && args.fen.trim() !== "")
1687
+ cliArgs.push("--fen", args.fen.trim());
1688
+ if (typeof args.moves === "string" && args.moves.trim() !== "")
1689
+ cliArgs.push("--moves", args.moves.trim());
1690
+ if (typeof args.chapter === "string" && args.chapter.trim() !== "")
1691
+ cliArgs.push("--chapter", args.chapter.trim());
1692
+ if (typeof args.max_plies_below === "number")
1693
+ cliArgs.push("--max-plies-below", String(args.max_plies_below));
1694
+ const stdout = await runFenfindScript("readpgn", cliArgs);
1695
+ return parseFenfindJson("readpgn", stdout);
1541
1696
  }
1542
1697
  // Walk chess.js-free: resolve a path against a tree, throw if invalid.
1543
1698
  function pathIntoTree(root, path) {
@@ -1665,6 +1820,174 @@ async function applyMutation(args, mutator) {
1665
1820
  version: savedRow.version,
1666
1821
  };
1667
1822
  }
1823
+ async function loadPrepFile(id) {
1824
+ const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
1825
+ const g = raw;
1826
+ if (typeof g.pgnContent !== "string")
1827
+ throw new Error("prep file missing pgnContent");
1828
+ return { file: parsePGN(g.pgnContent), version: g.version, fileIdEcho: g.id, pgn: g.pgnContent };
1829
+ }
1830
+ // Recursively project a PrepNode into the requested view. `depthLeft`
1831
+ // null → unlimited; 0 → just the node without children.
1832
+ function projectNode(node, view, depthLeft) {
1833
+ const base = {
1834
+ id: node.id,
1835
+ san: node.san,
1836
+ ply: node.ply,
1837
+ };
1838
+ if (node.nags && node.nags.length > 0)
1839
+ base.nags = node.nags;
1840
+ if (node.comment)
1841
+ base.comment = node.comment;
1842
+ if (node.ceoEval)
1843
+ base.ceoEval = node.ceoEval;
1844
+ if (view === "full") {
1845
+ base.fen = node.fen;
1846
+ if (node.annotations)
1847
+ base.annotations = node.annotations;
1848
+ }
1849
+ // Children handling depends on view + depth budget.
1850
+ const showChildren = depthLeft === null || depthLeft > 0;
1851
+ const childDepth = depthLeft === null ? null : depthLeft - 1;
1852
+ if (showChildren && node.children.length > 0) {
1853
+ if (view === "spine") {
1854
+ // Only follow children[0] — collapses the tree to the mainline.
1855
+ base.children = [projectNode(node.children[0], view, childDepth)];
1856
+ }
1857
+ else {
1858
+ base.children = node.children.map(c => projectNode(c, view, childDepth));
1859
+ }
1860
+ }
1861
+ else {
1862
+ base.children = [];
1863
+ }
1864
+ return base;
1865
+ }
1866
+ async function readPrepFile(args) {
1867
+ const id = String(args.id);
1868
+ const view = (typeof args.view === "string" && ["compact", "full", "spine", "pgn"].includes(args.view))
1869
+ ? args.view
1870
+ : "compact";
1871
+ const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0 ? args.node_id : ROOT_ID;
1872
+ const maxDepth = typeof args.max_depth === "number" && args.max_depth >= 0 ? args.max_depth : null;
1873
+ const { file, version, fileIdEcho, pgn } = await loadPrepFile(id);
1874
+ const idIndex = buildIdIndex(file.root);
1875
+ const path = resolveNodeId(idIndex, startNodeId);
1876
+ const anchor = getNodeByPath(file.root, path);
1877
+ const header = {
1878
+ id: fileIdEcho ?? id,
1879
+ version,
1880
+ tags: file.tags,
1881
+ view,
1882
+ node_id: startNodeId,
1883
+ max_depth: maxDepth,
1884
+ };
1885
+ if (view === "pgn") {
1886
+ // For the root, just return the file's actual PGN as-is. For a
1887
+ // subtree, build a mini-Game from the anchor and export it. Keeps
1888
+ // formatting identical to what the app renders.
1889
+ if (startNodeId === ROOT_ID && (maxDepth === null || maxDepth >= 999)) {
1890
+ return { ...header, pgn };
1891
+ }
1892
+ // Truncate to a subtree with max_depth. Simple: walk the anchor's
1893
+ // subtree, produce a synthetic PGN starting from the anchor's FEN.
1894
+ const subtreePgn = exportSubtreePgn(file, anchor, maxDepth);
1895
+ return { ...header, pgn: subtreePgn };
1896
+ }
1897
+ return { ...header, tree: projectNode(anchor, view, maxDepth) };
1898
+ }
1899
+ // Produce a PGN string for a subtree rooted at `anchor`, truncated
1900
+ // at `maxDepth` plies below (null = unlimited). Reuses the exporter
1901
+ // by building a synthetic PrepFile whose root is a shallow clone of
1902
+ // the anchor with its children trimmed to depth.
1903
+ function exportSubtreePgn(file, anchor, maxDepth) {
1904
+ const trim = (n, depthLeft) => {
1905
+ if (depthLeft !== null && depthLeft <= 0)
1906
+ return { ...n, children: [] };
1907
+ const next = depthLeft === null ? null : depthLeft - 1;
1908
+ return { ...n, children: n.children.map(c => trim(c, next)) };
1909
+ };
1910
+ const trimmedAnchor = trim(anchor, maxDepth);
1911
+ // If the anchor IS the root, exporter handles it. If it's an inner
1912
+ // node, we set the root's FEN to the anchor's position and hang the
1913
+ // trimmed subtree off it. Tags carried over.
1914
+ if (anchor.id === ROOT_ID) {
1915
+ return exportPGN({ tags: file.tags, root: trimmedAnchor });
1916
+ }
1917
+ const syntheticRoot = {
1918
+ id: ROOT_ID,
1919
+ san: null,
1920
+ fen: anchor.fen,
1921
+ ply: 0,
1922
+ children: trimmedAnchor.children,
1923
+ };
1924
+ const tags = { ...file.tags, FEN: anchor.fen, SetUp: "1" };
1925
+ return exportPGN({ tags, root: syntheticRoot });
1926
+ }
1927
+ async function listNodes(args) {
1928
+ const id = String(args.id);
1929
+ const filter = String(args.filter || "");
1930
+ const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0 ? args.node_id : ROOT_ID;
1931
+ const maxDepth = typeof args.max_depth === "number" && args.max_depth >= 0 ? args.max_depth : null;
1932
+ const { file } = await loadPrepFile(id);
1933
+ const idIndex = buildIdIndex(file.root);
1934
+ const path = resolveNodeId(idIndex, startNodeId);
1935
+ const anchor = getNodeByPath(file.root, path);
1936
+ const hits = [];
1937
+ const walk = (node, depthLeft, spineOnly) => {
1938
+ // Root has no san — never emit it as a match. Everything else is fair game.
1939
+ if (node.id !== ROOT_ID) {
1940
+ let include = false;
1941
+ let extra = {};
1942
+ switch (filter) {
1943
+ case "missing_eval":
1944
+ include = !node.ceoEval;
1945
+ break;
1946
+ case "has_comment":
1947
+ include = !!(node.comment && node.comment.length > 0);
1948
+ if (include)
1949
+ extra.comment_preview = (node.comment || "").slice(0, 80);
1950
+ break;
1951
+ case "has_annotations":
1952
+ include = !!(node.annotations && (node.annotations.arrows.length > 0 || node.annotations.highlights.length > 0));
1953
+ break;
1954
+ case "novelties":
1955
+ include = !!(node.nags && node.nags.includes("$146"));
1956
+ break;
1957
+ case "leaves":
1958
+ include = node.children.length === 0;
1959
+ break;
1960
+ case "mainline":
1961
+ include = spineOnly;
1962
+ break;
1963
+ case "all":
1964
+ include = true;
1965
+ break;
1966
+ default:
1967
+ throw new Error(`unknown filter: ${filter}`);
1968
+ }
1969
+ if (include) {
1970
+ const hit = { node_id: node.id, san: node.san, ply: node.ply };
1971
+ Object.assign(hit, extra);
1972
+ hits.push(hit);
1973
+ }
1974
+ }
1975
+ if (depthLeft !== null && depthLeft <= 0)
1976
+ return;
1977
+ const nextDepth = depthLeft === null ? null : depthLeft - 1;
1978
+ if (filter === "mainline" && spineOnly) {
1979
+ if (node.children.length > 0)
1980
+ walk(node.children[0], nextDepth, true);
1981
+ }
1982
+ else {
1983
+ for (const c of node.children)
1984
+ walk(c, nextDepth, filter === "mainline");
1985
+ }
1986
+ };
1987
+ const rootIsSpineForFilter = filter === "mainline";
1988
+ walk(anchor, maxDepth, rootIsSpineForFilter);
1989
+ return { file_id: id, filter, node_id: startNodeId, max_depth: maxDepth, count: hits.length, nodes: hits };
1990
+ }
1668
1991
  // Strip cruft the LLM doesn't need from the DB-position response.
1669
1992
  // Called AFTER trimGamesMovetext so plyNumber survives long enough to
1670
1993
  // slice each game's movetext. Also renames the `transpositions` field
@@ -1852,10 +2175,21 @@ function normalizeSourceForBackend(src, idx) {
1852
2175
  }
1853
2176
  return out;
1854
2177
  }
1855
- // Async resolver used by every engine / DB tool. If the caller supplied
1856
- // file_id + node_id (preferred), load the file, resolve the node, and
1857
- // return both the FEN and a handle we can use to persist ceoEval later.
1858
- // Otherwise fall back to the raw fen/moves/line inputs.
2178
+ // Async resolver used by every engine / DB tool. Three paths:
2179
+ //
2180
+ // 1. file_id + node_id load file, resolve node, return FEN + handle
2181
+ // to persist ceoEval later. Cheapest, most explicit.
2182
+ //
2183
+ // 2. file_id + (fen | moves | line) → load file, resolve FEN
2184
+ // client-side, then scan the file's nodes for one matching that
2185
+ // FEN. If found, return the same handle as (1) so cloud_analyse
2186
+ // auto-stores on the matching node. Fixes the previous footgun
2187
+ // where `cloud_analyse({file_id, moves})` silently dropped the
2188
+ // eval because the server didn't try to match the resulting FEN
2189
+ // back to a node.
2190
+ //
2191
+ // 3. Just fen | moves | line, no file_id → scratch mode, no
2192
+ // persistence. Same as before.
1859
2193
  async function resolveFromNodeOrFen(args) {
1860
2194
  const fileId = typeof args.file_id === "string" ? args.file_id.trim() : "";
1861
2195
  const nodeId = typeof args.node_id === "string" ? args.node_id.trim() : "";
@@ -1870,18 +2204,51 @@ async function resolveFromNodeOrFen(args) {
1870
2204
  const node = getNodeByPath(parsedFile.root, nodePath);
1871
2205
  return {
1872
2206
  fen: node.fen,
1873
- file: {
1874
- id: fileId,
1875
- version: g.version ?? 0,
1876
- parsedFile,
1877
- idIndex,
1878
- nodePath,
1879
- fen: node.fen,
1880
- },
2207
+ file: { id: fileId, version: g.version ?? 0, parsedFile, idIndex, nodePath, fen: node.fen },
1881
2208
  };
1882
2209
  }
2210
+ if (fileId) {
2211
+ // file_id only — resolve FEN from fen/moves/line, then look it up
2212
+ // in the file's nodes. If a node has that FEN, treat this as if
2213
+ // node_id had been supplied (auto-persist on match).
2214
+ const fen = resolveFenFromArgs(args);
2215
+ try {
2216
+ const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(fileId)}`);
2217
+ const g = raw;
2218
+ if (typeof g.pgnContent === "string") {
2219
+ const parsedFile = parsePGN(g.pgnContent);
2220
+ const match = findNodeByFen(parsedFile.root, fen);
2221
+ if (match) {
2222
+ const idIndex = buildIdIndex(parsedFile.root);
2223
+ return {
2224
+ fen,
2225
+ file: { id: fileId, version: g.version ?? 0, parsedFile, idIndex, nodePath: match.path, fen },
2226
+ };
2227
+ }
2228
+ }
2229
+ }
2230
+ catch {
2231
+ // Best-effort: if the file load fails, fall through to scratch mode.
2232
+ }
2233
+ return { fen };
2234
+ }
1883
2235
  return { fen: resolveFenFromArgs(args) };
1884
2236
  }
2237
+ // Search the tree for a node whose FEN matches. Full-tree scan — trees
2238
+ // max out ~1000 nodes so this is fine. FEN comparison is exact string
2239
+ // match (both come from the same chessops normalisation).
2240
+ function findNodeByFen(root, targetFen) {
2241
+ const stack = [{ node: root, path: [] }];
2242
+ while (stack.length > 0) {
2243
+ const { node, path } = stack.pop();
2244
+ if (node.fen === targetFen)
2245
+ return { node, path };
2246
+ for (let i = 0; i < node.children.length; i++) {
2247
+ stack.push({ node: node.children[i], path: [...path, i] });
2248
+ }
2249
+ }
2250
+ return null;
2251
+ }
1885
2252
  // Local wrapper — the mutation module re-exports paths.getNode so this
1886
2253
  // import stays consistent with the rest of the file's imports.
1887
2254
  function getNodeByPath(root, path) {
@@ -1924,19 +2291,50 @@ function stringifyForLog(v) {
1924
2291
  }
1925
2292
  return s;
1926
2293
  }
2294
+ // Version tag stamped on every log line so a bug report can be traced
2295
+ // to the exact MCP release that produced it. process.env.npm_package_version
2296
+ // is set by npm when the package is run via `npx` / `npm start` (and by
2297
+ // our systemd unit which uses npx); falls back to reading package.json
2298
+ // during dev when we `node dist/index.js` directly. "unknown" if all
2299
+ // else fails — better than pretending we know.
2300
+ const MCP_VERSION = (() => {
2301
+ const fromEnv = process.env.npm_package_version?.trim();
2302
+ if (fromEnv)
2303
+ return fromEnv;
2304
+ try {
2305
+ const here = dirname(fileURLToPath(import.meta.url));
2306
+ // dist/index.js is one level below package.json; src/index.ts is two
2307
+ // (src/index.ts → ../package.json). Try both.
2308
+ for (const p of [join(here, "..", "package.json"), join(here, "..", "..", "package.json")]) {
2309
+ if (existsSync(p)) {
2310
+ const pkg = JSON.parse(readFileSync(p, "utf8"));
2311
+ if (pkg.version)
2312
+ return pkg.version;
2313
+ }
2314
+ }
2315
+ }
2316
+ catch {
2317
+ // fall through
2318
+ }
2319
+ return "unknown";
2320
+ })();
2321
+ const MCP_TAG = `[mcp v${MCP_VERSION}]`;
2322
+ // One-shot startup line so tailing the log from the beginning shows
2323
+ // the running version immediately, before any tool call.
2324
+ console.error(`${MCP_TAG} chessceo-mcp startup`);
1927
2325
  async function callTool(name, args) {
1928
2326
  const started = Date.now();
1929
- console.error(`[mcp] IN ${name} args=${stringifyForLog(args)}`);
2327
+ console.error(`${MCP_TAG} IN ${name} args=${stringifyForLog(args)}`);
1930
2328
  try {
1931
2329
  const result = await callToolInner(name, args);
1932
2330
  const dur = Date.now() - started;
1933
- console.error(`[mcp] OUT ${name} ok ${dur}ms result=${stringifyForLog(result)}`);
2331
+ console.error(`${MCP_TAG} OUT ${name} ok ${dur}ms result=${stringifyForLog(result)}`);
1934
2332
  return result;
1935
2333
  }
1936
2334
  catch (err) {
1937
2335
  const dur = Date.now() - started;
1938
2336
  const msg = err instanceof Error ? err.message : String(err);
1939
- console.error(`[mcp] OUT ${name} err ${dur}ms error=${JSON.stringify(msg)}`);
2337
+ console.error(`${MCP_TAG} OUT ${name} err ${dur}ms error=${JSON.stringify(msg)}`);
1940
2338
  throw err;
1941
2339
  }
1942
2340
  }
@@ -2012,17 +2410,37 @@ async function callToolInner(name, args) {
2012
2410
  }
2013
2411
  case "describe_position": {
2014
2412
  const resolved = await resolveFromNodeOrFen(args);
2015
- return describePosition(resolved.fen);
2413
+ // Chess-primitive analysis (structural, ~1 ms) + Stockfish eval-
2414
+ // term breakdown (~50-100 ms) in parallel. Merge into one response
2415
+ // so the LLM sees the whole position in one call — chess-concepts,
2416
+ // structural weaknesses, AND engine's per-term reasoning.
2417
+ // If Stockfish isn't installed the eval leg returns {found: false,
2418
+ // error} and we drop it from the response so callers see the same
2419
+ // shape either way (just without `engineEvalTerms`).
2420
+ const [structural, evalRaw] = await Promise.all([
2421
+ Promise.resolve(describePosition(resolved.fen)),
2422
+ runSfEval(resolved.fen).catch(err => ({
2423
+ found: false,
2424
+ error: err instanceof Error ? err.message : String(err),
2425
+ })),
2426
+ ]);
2427
+ const merged = structural;
2428
+ const ev = evalRaw;
2429
+ if (ev && ev.found === true) {
2430
+ merged.engineEvalTerms = { terms: ev.terms, total: ev.total };
2431
+ }
2432
+ return merged;
2016
2433
  }
2017
2434
  case "predict_human_move": {
2018
2435
  const resolved = await resolveFromNodeOrFen(args);
2019
2436
  const fen = resolved.fen;
2437
+ // Rating is fixed at 2850 vs 2850. Not exposed to the LLM —
2438
+ // cross-position comparisons only mean something at a constant
2439
+ // rating, and top-level is the useful reference point for prep.
2020
2440
  const qs = new URLSearchParams();
2021
2441
  qs.set("fen", fen);
2022
- if (typeof args.white_elo === "number")
2023
- qs.set("white_elo", String(args.white_elo));
2024
- if (typeof args.black_elo === "number")
2025
- qs.set("black_elo", String(args.black_elo));
2442
+ qs.set("white_elo", "2850");
2443
+ qs.set("black_elo", "2850");
2026
2444
  if (typeof args.top === "number")
2027
2445
  qs.set("top", String(args.top));
2028
2446
  if (Array.isArray(args.prev_fens)) {
@@ -2031,7 +2449,20 @@ async function callToolInner(name, args) {
2031
2449
  qs.append("prev_fen", p);
2032
2450
  }
2033
2451
  }
2034
- return authedRequest("GET", `/api/agent/predict-move?${qs.toString()}`);
2452
+ const raw = await authedRequest("GET", `/api/agent/predict-move?${qs.toString()}`);
2453
+ // Strip uci from each move — san is enough for the LLM and the
2454
+ // duplicate field is context bloat. Also drop whiteElo/blackElo
2455
+ // from the response (always 2850 now — echoing them adds nothing).
2456
+ if (raw && typeof raw === "object") {
2457
+ const r = raw;
2458
+ if (Array.isArray(r.moves)) {
2459
+ for (const m of r.moves)
2460
+ delete m.uci;
2461
+ }
2462
+ delete r.whiteElo;
2463
+ delete r.blackElo;
2464
+ }
2465
+ return raw;
2035
2466
  }
2036
2467
  case "get_head_to_head":
2037
2468
  return get("/api/chess/players/h2h", {
@@ -2094,23 +2525,16 @@ async function callToolInner(name, args) {
2094
2525
  return deepAnalyseCancel(args);
2095
2526
  case "find_position_in_courses":
2096
2527
  return findPositionInCourses(args);
2528
+ case "read_course_at_position":
2529
+ return readCourseAtPosition(args);
2097
2530
  case "list_prep_files":
2098
2531
  return authedRequest("GET", "/api/agent/prep-files");
2099
2532
  case "search_prep_files":
2100
2533
  return authedRequest("GET", `/api/agent/prep-files/search?q=${encodeURIComponent(String(args.query))}`);
2101
- case "read_prep_file": {
2102
- const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(String(args.id))}`);
2103
- const g = raw;
2104
- if (typeof g.pgnContent !== "string")
2105
- throw new Error("prep file missing pgnContent");
2106
- const file = parsePGN(g.pgnContent);
2107
- return {
2108
- id: g.id,
2109
- version: g.version,
2110
- tags: file.tags,
2111
- tree: file.root,
2112
- };
2113
- }
2534
+ case "read_prep_file":
2535
+ return readPrepFile(args);
2536
+ case "list_nodes":
2537
+ return listNodes(args);
2114
2538
  case "create_prep_file":
2115
2539
  return authedRequest("POST", "/api/agent/prep-files", {
2116
2540
  name: String(args.name),