@chessceo/mcp 0.36.6 → 0.40.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
@@ -16,8 +16,8 @@ import { Chess } from "chess.js";
16
16
  import { parsePGN } from "./pgn/parser.js";
17
17
  import { exportPGN } from "./pgn/exporter.js";
18
18
  import { describePosition } from "./pgn/describe.js";
19
- import { addLine, addMove, deleteSubtree, MutationError, promoteVariation, setAnnotations, setCeoEval, setComment, setNags, setTag, } from "./pgn/mutations.js";
20
- import { buildIdIndex, NodeIdError, PathError, resolveNodeId, ROOT_ID } from "./pgn/paths.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";
21
21
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
22
22
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
23
23
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
@@ -76,6 +76,8 @@ const AUTHED_TOOLS = new Set([
76
76
  "list_prep_files",
77
77
  "search_prep_files",
78
78
  "read_prep_file",
79
+ "list_nodes",
80
+ "list_transpositions",
79
81
  "create_prep_file",
80
82
  "delete_prep_file",
81
83
  "add_move",
@@ -94,6 +96,7 @@ const AUTHED_TOOLS = new Set([
94
96
  "deep_analyse_status",
95
97
  "deep_analyse_cancel",
96
98
  "find_position_in_courses",
99
+ "read_course_at_position",
97
100
  "quote_engine_eval",
98
101
  "predict_human_move",
99
102
  "prepare_opponent",
@@ -329,10 +332,21 @@ const TOOLS = [
329
332
  },
330
333
  {
331
334
  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`.",
335
+ 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" +
336
+ "Returns three layers:\n\n" +
337
+ "**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" +
338
+ "**Structural analysis** — chess-concept observations a human sees at a glance:\n" +
339
+ " • `pawnStructure.files` — each file `open`/`half_open_for_white`/`half_open_for_black`/`closed`. Half-open files are natural rook targets.\n" +
340
+ " • `pawnStructure.islands` — count per colour (more = weaker structure).\n" +
341
+ " • `pawnStructure.isolated` / `doubled` / `passed` / `backward` — structural weaknesses (and strengths, for passed).\n" +
342
+ " • `weakSquares` — holes in ranks 3-6 that no friendly pawn can ever attack. Prime real estate for enemy pieces.\n" +
343
+ " • `outposts` — friendly N/B on an enemy hole defended by own pawn. Classic strong squares.\n" +
344
+ " • `bishops` — per-bishop `good`/`mixed`/`bad` from own pawns on its colour. `bishops.pair` flags who has both.\n" +
345
+ " • `space` — squares controlled in the enemy half.\n\n" +
346
+ "**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" +
347
+ " → **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" +
348
+ " → Omitted from the response if Stockfish isn't installed on the server.\n\n" +
349
+ "Position input: prefer `file_id`+`node_id` if inside a prep file. Otherwise `fen`, `moves` from startpos, or `fen + moves`.",
336
350
  inputSchema: {
337
351
  type: "object",
338
352
  properties: {
@@ -345,15 +359,11 @@ const TOOLS = [
345
359
  },
346
360
  {
347
361
  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.",
362
+ 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" +
363
+ "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" +
364
+ "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" +
365
+ "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" +
366
+ "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
367
  inputSchema: {
358
368
  type: "object",
359
369
  properties: {
@@ -364,18 +374,6 @@ const TOOLS = [
364
374
  type: "string",
365
375
  description: "Optional SAN moves to apply on top of `fen` (or startpos). Only used if `node_id` is not set.",
366
376
  },
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
377
  top: {
380
378
  type: "integer",
381
379
  minimum: 1,
@@ -545,13 +543,61 @@ const TOOLS = [
545
543
  },
546
544
  {
547
545
  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" +
546
+ 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" +
547
+ "**Views** (pick the smallest one that answers your question):\n" +
548
+ " • `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" +
549
+ " • `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" +
550
+ " • `spine` — mainline only (children[0] recursively). Great for a 'what does this repertoire cover' summary.\n" +
551
+ " • `pgn` — subtree as raw PGN text (comments, NAGs, [%cal] arrows all preserved). Useful for sanity-checking formatting against reference material.\n\n" +
552
+ "**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" +
553
+ "**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" +
554
+ "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
555
  "**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
556
  inputSchema: {
552
557
  type: "object",
553
558
  properties: {
554
559
  id: { type: "string", description: "Prep file id, from list_prep_files or search_prep_files." },
560
+ 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." },
561
+ node_id: { type: "string", description: "Subtree root (default `'r'` = whole file)." },
562
+ max_depth: { type: "integer", minimum: 0, description: "Cap the returned tree at this many plies below `node_id`. Omit for unlimited." },
563
+ },
564
+ required: ["id"],
565
+ },
566
+ },
567
+ {
568
+ name: "list_nodes",
569
+ 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" +
570
+ "Filters:\n" +
571
+ " • `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" +
572
+ " • `has_comment` — nodes with a text comment. Use to audit what's been annotated.\n" +
573
+ " • `has_annotations` — nodes with arrows or highlighted squares.\n" +
574
+ " • `mainline` — the spine (children[0] recursively). Use for a compact 'what does the repertoire cover' view.\n" +
575
+ " • `novelties` — nodes carrying the `$146` NAG.\n" +
576
+ " • `leaves` — nodes with no children (variation endpoints). Useful for finding lines that need continuation.\n" +
577
+ " • `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" +
578
+ " • `all` — every node id. Use only when you really need the whole list.\n\n" +
579
+ "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).",
580
+ inputSchema: {
581
+ type: "object",
582
+ properties: {
583
+ id: { type: "string", description: "Prep file id." },
584
+ filter: { type: "string", enum: ["missing_eval", "has_comment", "has_annotations", "mainline", "novelties", "leaves", "transpositions", "all"], description: "Which nodes to list." },
585
+ node_id: { type: "string", description: "Subtree root (default `'r'` = whole file)." },
586
+ max_depth: { type: "integer", minimum: 0, description: "Cap the walk at this many plies below `node_id`. Omit for unlimited." },
587
+ },
588
+ required: ["id", "filter"],
589
+ },
590
+ },
591
+ {
592
+ name: "list_transpositions",
593
+ 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" +
594
+ "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" +
595
+ "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" +
596
+ "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.",
597
+ inputSchema: {
598
+ type: "object",
599
+ properties: {
600
+ id: { type: "string", description: "Prep file id." },
555
601
  },
556
602
  required: ["id"],
557
603
  },
@@ -831,10 +877,15 @@ const TOOLS = [
831
877
  },
832
878
  {
833
879
  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" +
880
+ 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" +
881
+ "**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" +
882
+ "Use it as a reference library, not memory. Query patterns:\n" +
883
+ " • 'Does my chosen line have coverage?' → search from the position, read multiple hits, see whether the field agrees on the main response.\n" +
884
+ " • 'What do opposite-colour repertoires recommend against this move?' → search, then read every hit whose author/course maps to the other side.\n" +
885
+ " • '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" +
886
+ " • '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" +
887
+ "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" +
888
+ "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" +
838
889
  "Not available if the fenfind index isn't installed on the server — response includes a clear note in that case.",
839
890
  inputSchema: {
840
891
  type: "object",
@@ -843,6 +894,7 @@ const TOOLS = [
843
894
  node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`/`moves`." },
844
895
  fen: { type: "string", description: "Starting position as FEN. Only used if `node_id` is not set." },
845
896
  moves: { type: "string", description: "Optional SAN moves on top of `fen` (or startpos). Only used if `node_id` is not set." },
897
+ sort: { type: "string", enum: ["recency", "notes"], description: "Ranking. `recency` (default) = most-recently-updated file first. `notes` = deepest annotation first regardless of age." },
846
898
  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
899
  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
900
  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 +902,28 @@ const TOOLS = [
850
902
  },
851
903
  },
852
904
  },
905
+ {
906
+ name: "read_course_at_position",
907
+ 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" +
908
+ "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" +
909
+ "**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" +
910
+ "Usage patterns:\n" +
911
+ " • Read what an author says about a specific position → pass `course_file_id` from a find hit + the FEN.\n" +
912
+ " • Explore a chapter from move 1 → pass `course_file_id` + `chapter`, no FEN.\n" +
913
+ " • Skim deeper into a branch you're interested in → same file/chapter, wider `max_plies_below`.\n" +
914
+ " • **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.",
915
+ inputSchema: {
916
+ type: "object",
917
+ properties: {
918
+ course_file_id: { type: "integer", description: "File id from a `find_position_in_courses` hit (`course_file_id` field)." },
919
+ 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." },
920
+ moves: { type: "string", description: "Alternative to `fen`: SAN moves from startpos." },
921
+ 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." },
922
+ 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." },
923
+ },
924
+ required: ["course_file_id"],
925
+ },
926
+ },
853
927
  {
854
928
  name: "quote_engine_eval",
855
929
  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 +976,9 @@ const TOOLS = [
902
976
  },
903
977
  {
904
978
  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.",
979
+ 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" +
980
+ "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" +
981
+ "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
982
  inputSchema: { type: "object", properties: {} },
909
983
  },
910
984
  {
@@ -1166,6 +1240,29 @@ async function autoEvaluate(args) {
1166
1240
  // If the caller anchored at the root, skip evaluating the root itself
1167
1241
  // (no move); otherwise the anchor node IS a real move and gets evaluated.
1168
1242
  walk(startNode, startNode.id === ROOT_ID);
1243
+ // Dedup transpositions: if two candidate targets share the same
1244
+ // 3-field FEN key, they're the same position reached by different
1245
+ // move orders. Analyse ONE of them — cloud_analyse auto-propagates
1246
+ // the resulting ceoEval to every other node with a matching key
1247
+ // (see storeEvalOnNode), so the twin ends up with the same eval
1248
+ // without a second engine call. Keep DFS-first (mainline-preferred)
1249
+ // occurrence.
1250
+ let skippedTranspositions = 0;
1251
+ {
1252
+ const seen = new Set();
1253
+ const deduped = [];
1254
+ for (const t of targets) {
1255
+ const key = positionKey(t.fen);
1256
+ if (seen.has(key)) {
1257
+ skippedTranspositions++;
1258
+ continue;
1259
+ }
1260
+ seen.add(key);
1261
+ deduped.push(t);
1262
+ }
1263
+ targets.length = 0;
1264
+ targets.push(...deduped);
1265
+ }
1169
1266
  // Nothing to do → return a done job synthetically so the caller doesn't
1170
1267
  // need to special-case the empty response.
1171
1268
  if (targets.length === 0) {
@@ -1177,6 +1274,7 @@ async function autoEvaluate(args) {
1177
1274
  targetCount: 0,
1178
1275
  evaluated: 0,
1179
1276
  errored: 0,
1277
+ failedNodeIds: [],
1180
1278
  finalVersion: g.version,
1181
1279
  startedAt: Date.now(),
1182
1280
  finishedAt: Date.now(),
@@ -1192,6 +1290,7 @@ async function autoEvaluate(args) {
1192
1290
  targetCount: targets.length,
1193
1291
  evaluated: 0,
1194
1292
  errored: 0,
1293
+ failedNodeIds: [],
1195
1294
  startedAt: Date.now(),
1196
1295
  cancelled: false,
1197
1296
  };
@@ -1207,6 +1306,9 @@ async function autoEvaluate(args) {
1207
1306
  return {
1208
1307
  job_id: jobId,
1209
1308
  target_count: targets.length,
1309
+ // Transpositions inside the walk that we skipped because they'll
1310
+ // pick up the eval via auto-propagation. Zero when there are none.
1311
+ skipped_transpositions: skippedTranspositions,
1210
1312
  status: "running",
1211
1313
  // Rough time estimate at the current default movetime. Serialization
1212
1314
  // on the per-combo semaphore means walltime ≈ target_count × movetime.
@@ -1235,8 +1337,15 @@ async function runEvalJob(job, fileId, targets, movetimeMs) {
1235
1337
  job.finalVersion = sr.version;
1236
1338
  pending.length = 0;
1237
1339
  };
1340
+ // Consecutive-failure abort. If N cloud_analyse calls in a row error,
1341
+ // the engine is almost certainly dead (vanished contract, network to
1342
+ // VastAI down) and burning through the rest of the tree just wastes
1343
+ // time. Bail with an explicit reason so a targeted retry is possible.
1344
+ const MAX_CONSECUTIVE_FAILURES = 3;
1345
+ let consecutiveFailures = 0;
1346
+ let aborted = false;
1238
1347
  for (const t of targets) {
1239
- if (job.cancelled)
1348
+ if (job.cancelled || aborted)
1240
1349
  break;
1241
1350
  try {
1242
1351
  const analysis = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen: t.fen, movetime_ms: movetimeMs, multipv: 1 });
@@ -1244,15 +1353,25 @@ async function runEvalJob(job, fileId, targets, movetimeMs) {
1244
1353
  if (ev) {
1245
1354
  pending.push({ op: "set_ceo_eval", node_id: t.nodeId, ceoEval: ev });
1246
1355
  job.evaluated++;
1356
+ consecutiveFailures = 0;
1247
1357
  }
1248
1358
  else {
1249
1359
  job.errored++;
1360
+ job.failedNodeIds.push(t.nodeId);
1361
+ consecutiveFailures++;
1250
1362
  }
1251
1363
  }
1252
1364
  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.
1365
+ // Per-node failure — record the node_id so the caller can retry
1366
+ // just those, and count consecutive failures for the abort check.
1255
1367
  job.errored++;
1368
+ job.failedNodeIds.push(t.nodeId);
1369
+ consecutiveFailures++;
1370
+ }
1371
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
1372
+ aborted = true;
1373
+ job.abortedReason = `aborted after ${MAX_CONSECUTIVE_FAILURES} consecutive cloud_analyse failures — check that the cloud combo is still running (list_cloud_engines)`;
1374
+ break;
1256
1375
  }
1257
1376
  if (pending.length >= SAVE_EVERY_N) {
1258
1377
  try {
@@ -1297,6 +1416,8 @@ function autoEvaluateStatus(args) {
1297
1416
  target_count: job.targetCount,
1298
1417
  evaluated: job.evaluated,
1299
1418
  errored: job.errored,
1419
+ failed_node_ids: job.failedNodeIds, // exact ids for targeted retry — pass as node_id list or check with list_nodes
1420
+ aborted_reason: job.abortedReason, // present when the job stopped early due to consecutive engine failures
1300
1421
  remaining: Math.max(0, job.targetCount - job.evaluated - job.errored),
1301
1422
  done: job.status !== "running",
1302
1423
  error: job.error,
@@ -1475,41 +1596,74 @@ function deepAnalyseCancel(args) {
1475
1596
  // available (venv at $here/.venv/bin/python preferred, then falls back
1476
1597
  // to system python3). DB path is resolved inside fenfind.py itself
1477
1598
  // (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
- }
1599
+ // Path resolution shared by both fenfind + readpgn. FENFIND_PATH env
1600
+ // overrides the bundled tools/fenfind/ directory.
1601
+ // Path to sf_eval helper (spawns local stockfish, parses its `eval`
1602
+ // verbose output). Uses the same resolution pattern as FENFIND_DIR.
1603
+ const SF_EVAL_SCRIPT = (() => {
1604
+ const envPath = process.env.SF_EVAL_PATH?.trim();
1605
+ if (envPath && existsSync(join(envPath, "sf_eval")))
1606
+ return join(envPath, "sf_eval");
1484
1607
  const here = dirname(fileURLToPath(import.meta.url));
1485
- const bundled = join(here, "..", "tools", "fenfind", "fenfind");
1608
+ const bundled = join(here, "..", "tools", "sf_eval", "sf_eval");
1486
1609
  return existsSync(bundled) ? bundled : null;
1487
1610
  })();
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) {
1611
+ const SF_EVAL_TIMEOUT_MS = 12_000;
1612
+ async function runSfEval(fen) {
1613
+ if (!SF_EVAL_SCRIPT) {
1494
1614
  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.",
1615
+ found: false,
1616
+ error: "sf_eval script not bundled; set SF_EVAL_PATH or install tools/sf_eval/",
1497
1617
  };
1498
1618
  }
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
1619
  const stdout = await new Promise((resolve, reject) => {
1510
- const p = spawn(FENFIND_SCRIPT, cliArgs, {
1511
- stdio: ["ignore", "pipe", "pipe"],
1620
+ const p = spawn(SF_EVAL_SCRIPT, ["--fen", fen], { stdio: ["ignore", "pipe", "pipe"] });
1621
+ let out = "";
1622
+ let err = "";
1623
+ p.stdout.on("data", d => { out += d.toString("utf8"); });
1624
+ p.stderr.on("data", d => { err += d.toString("utf8"); });
1625
+ const to = setTimeout(() => {
1626
+ try {
1627
+ p.kill("SIGTERM");
1628
+ }
1629
+ catch { /* already dead */ }
1630
+ reject(new Error(`sf_eval timed out after ${SF_EVAL_TIMEOUT_MS}ms`));
1631
+ }, SF_EVAL_TIMEOUT_MS);
1632
+ p.on("error", e => { clearTimeout(to); reject(e); });
1633
+ p.on("close", code => {
1634
+ clearTimeout(to);
1635
+ if (code !== 0)
1636
+ reject(new Error(`sf_eval exited ${code}: ${err.slice(0, 500)}`));
1637
+ else
1638
+ resolve(out);
1512
1639
  });
1640
+ });
1641
+ try {
1642
+ return JSON.parse(stdout);
1643
+ }
1644
+ catch (e) {
1645
+ throw new Error(`sf_eval returned non-JSON output (${e instanceof Error ? e.message : String(e)}): ${stdout.slice(0, 300)}`);
1646
+ }
1647
+ }
1648
+ const FENFIND_DIR = (() => {
1649
+ const envPath = process.env.FENFIND_PATH?.trim();
1650
+ if (envPath && existsSync(join(envPath, "fenfind")))
1651
+ return envPath;
1652
+ const here = dirname(fileURLToPath(import.meta.url));
1653
+ const bundled = join(here, "..", "tools", "fenfind");
1654
+ return existsSync(join(bundled, "fenfind")) ? bundled : null;
1655
+ })();
1656
+ // Cap on how long we let the subprocess run. SQLite hash lookup returns
1657
+ // sub-second; PGN read from a course file is O(chapter size) and rarely
1658
+ // exceeds a second. 15s is a stuck-process backstop, not a real limit.
1659
+ const FENFIND_TIMEOUT_MS = 15_000;
1660
+ async function runFenfindScript(scriptName, args) {
1661
+ if (!FENFIND_DIR) {
1662
+ throw new Error("fenfind index not installed — set FENFIND_PATH or install the tools/fenfind bundle");
1663
+ }
1664
+ const script = join(FENFIND_DIR, scriptName);
1665
+ return new Promise((resolve, reject) => {
1666
+ const p = spawn(script, args, { stdio: ["ignore", "pipe", "pipe"] });
1513
1667
  let out = "";
1514
1668
  let err = "";
1515
1669
  p.stdout.on("data", d => { out += d.toString("utf8"); });
@@ -1519,25 +1673,71 @@ async function findPositionInCourses(args) {
1519
1673
  p.kill("SIGTERM");
1520
1674
  }
1521
1675
  catch { /* already dead */ }
1522
- reject(new Error(`fenfind timed out after ${FENFIND_TIMEOUT_MS}ms`));
1676
+ reject(new Error(`${scriptName} timed out after ${FENFIND_TIMEOUT_MS}ms`));
1523
1677
  }, FENFIND_TIMEOUT_MS);
1524
1678
  p.on("error", e => { clearTimeout(to); reject(e); });
1525
1679
  p.on("close", code => {
1526
1680
  clearTimeout(to);
1527
- if (code !== 0) {
1528
- reject(new Error(`fenfind exited ${code}: ${err.slice(0, 500)}`));
1529
- }
1530
- else {
1681
+ if (code !== 0)
1682
+ reject(new Error(`${scriptName} exited ${code}: ${err.slice(0, 500)}`));
1683
+ else
1531
1684
  resolve(out);
1532
- }
1533
1685
  });
1534
1686
  });
1687
+ }
1688
+ function parseFenfindJson(scriptName, stdout) {
1535
1689
  try {
1536
1690
  return JSON.parse(stdout);
1537
1691
  }
1538
1692
  catch (e) {
1539
- throw new Error(`fenfind returned non-JSON output (${e instanceof Error ? e.message : String(e)}): ${stdout.slice(0, 300)}`);
1693
+ throw new Error(`${scriptName} returned non-JSON output (${e instanceof Error ? e.message : String(e)}): ${stdout.slice(0, 300)}`);
1694
+ }
1695
+ }
1696
+ async function findPositionInCourses(args) {
1697
+ if (!FENFIND_DIR) {
1698
+ return {
1699
+ status: "not_available",
1700
+ 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.",
1701
+ };
1702
+ }
1703
+ const resolved = await resolveFromNodeOrFen(args);
1704
+ const cliArgs = [resolved.fen, "--json"];
1705
+ if (typeof args.sort === "string" && (args.sort === "recency" || args.sort === "notes")) {
1706
+ cliArgs.push("--sort", args.sort);
1707
+ }
1708
+ if (args.include_games)
1709
+ cliArgs.push("--games");
1710
+ if (args.chapters_mode)
1711
+ cliArgs.push("--chapters");
1712
+ if (typeof args.min_notes_chars === "number")
1713
+ cliArgs.push("--min", String(args.min_notes_chars));
1714
+ if (typeof args.limit === "number")
1715
+ cliArgs.push("-n", String(args.limit));
1716
+ const stdout = await runFenfindScript("fenfind", cliArgs);
1717
+ return parseFenfindJson("fenfind", stdout);
1718
+ }
1719
+ async function readCourseAtPosition(args) {
1720
+ if (!FENFIND_DIR) {
1721
+ return {
1722
+ status: "not_available",
1723
+ note: "fenfind index not installed on this server. Set FENFIND_PATH env var to the directory containing the `fenfind`/`readpgn` scripts and positions.db.",
1724
+ };
1725
+ }
1726
+ const fileId = typeof args.course_file_id === "number" ? args.course_file_id : Number(args.course_file_id);
1727
+ if (!Number.isFinite(fileId) || fileId <= 0) {
1728
+ throw new Error("`course_file_id` is required — pass the value from a find_position_in_courses hit");
1540
1729
  }
1730
+ const cliArgs = ["--file-id", String(fileId)];
1731
+ if (typeof args.fen === "string" && args.fen.trim() !== "")
1732
+ cliArgs.push("--fen", args.fen.trim());
1733
+ if (typeof args.moves === "string" && args.moves.trim() !== "")
1734
+ cliArgs.push("--moves", args.moves.trim());
1735
+ if (typeof args.chapter === "string" && args.chapter.trim() !== "")
1736
+ cliArgs.push("--chapter", args.chapter.trim());
1737
+ if (typeof args.max_plies_below === "number")
1738
+ cliArgs.push("--max-plies-below", String(args.max_plies_below));
1739
+ const stdout = await runFenfindScript("readpgn", cliArgs);
1740
+ return parseFenfindJson("readpgn", stdout);
1541
1741
  }
1542
1742
  // Walk chess.js-free: resolve a path against a tree, throw if invalid.
1543
1743
  function pathIntoTree(root, path) {
@@ -1665,6 +1865,233 @@ async function applyMutation(args, mutator) {
1665
1865
  version: savedRow.version,
1666
1866
  };
1667
1867
  }
1868
+ async function loadPrepFile(id) {
1869
+ const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
1870
+ const g = raw;
1871
+ if (typeof g.pgnContent !== "string")
1872
+ throw new Error("prep file missing pgnContent");
1873
+ return { file: parsePGN(g.pgnContent), version: g.version, fileIdEcho: g.id, pgn: g.pgnContent };
1874
+ }
1875
+ // Recursively project a PrepNode into the requested view. `depthLeft`
1876
+ // null → unlimited; 0 → just the node without children.
1877
+ //
1878
+ // `fenIndex` (optional) enables the `transposes_to` field — for each
1879
+ // node whose position also appears elsewhere in the SAME file, we
1880
+ // annotate it with the OTHER occurrences' ids. Pass null (the default)
1881
+ // to skip the annotation entirely; passing the map costs one lookup
1882
+ // per node projected.
1883
+ function projectNode(node, view, depthLeft, fenIndex = null) {
1884
+ const base = {
1885
+ id: node.id,
1886
+ san: node.san,
1887
+ ply: node.ply,
1888
+ };
1889
+ if (node.nags && node.nags.length > 0)
1890
+ base.nags = node.nags;
1891
+ if (node.comment)
1892
+ base.comment = node.comment;
1893
+ if (node.ceoEval)
1894
+ base.ceoEval = node.ceoEval;
1895
+ if (view === "full") {
1896
+ base.fen = node.fen;
1897
+ if (node.annotations)
1898
+ base.annotations = node.annotations;
1899
+ }
1900
+ if (fenIndex && node.id !== ROOT_ID) {
1901
+ const group = fenIndex.get(positionKey(node.fen));
1902
+ if (group && group.length > 1) {
1903
+ const others = group.filter(n => n.id !== node.id).map(n => n.id);
1904
+ if (others.length > 0)
1905
+ base.transposes_to = others;
1906
+ }
1907
+ }
1908
+ // Children handling depends on view + depth budget.
1909
+ const showChildren = depthLeft === null || depthLeft > 0;
1910
+ const childDepth = depthLeft === null ? null : depthLeft - 1;
1911
+ if (showChildren && node.children.length > 0) {
1912
+ if (view === "spine") {
1913
+ // Only follow children[0] — collapses the tree to the mainline.
1914
+ base.children = [projectNode(node.children[0], view, childDepth, fenIndex)];
1915
+ }
1916
+ else {
1917
+ base.children = node.children.map(c => projectNode(c, view, childDepth, fenIndex));
1918
+ }
1919
+ }
1920
+ else {
1921
+ base.children = [];
1922
+ }
1923
+ return base;
1924
+ }
1925
+ async function readPrepFile(args) {
1926
+ const id = String(args.id);
1927
+ const view = (typeof args.view === "string" && ["compact", "full", "spine", "pgn"].includes(args.view))
1928
+ ? args.view
1929
+ : "compact";
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, version, fileIdEcho, pgn } = await loadPrepFile(id);
1933
+ const idIndex = buildIdIndex(file.root);
1934
+ const path = resolveNodeId(idIndex, startNodeId);
1935
+ const anchor = getNodeByPath(file.root, path);
1936
+ const fenIndex = buildFenIndex(file.root);
1937
+ // How many DISTINCT positions in the file appear more than once,
1938
+ // and how many nodes are involved. Shown in the header so the LLM
1939
+ // sees at a glance whether transpositions matter here before diving
1940
+ // into the tree.
1941
+ let transGroups = 0;
1942
+ let transNodes = 0;
1943
+ for (const arr of fenIndex.values()) {
1944
+ if (arr.length > 1) {
1945
+ transGroups++;
1946
+ transNodes += arr.length;
1947
+ }
1948
+ }
1949
+ const header = {
1950
+ id: fileIdEcho ?? id,
1951
+ version,
1952
+ tags: file.tags,
1953
+ view,
1954
+ node_id: startNodeId,
1955
+ max_depth: maxDepth,
1956
+ transposition_groups: transGroups,
1957
+ transposition_nodes: transNodes,
1958
+ };
1959
+ if (view === "pgn") {
1960
+ // For the root, just return the file's actual PGN as-is. For a
1961
+ // subtree, build a mini-Game from the anchor and export it. Keeps
1962
+ // formatting identical to what the app renders.
1963
+ if (startNodeId === ROOT_ID && (maxDepth === null || maxDepth >= 999)) {
1964
+ return { ...header, pgn };
1965
+ }
1966
+ // Truncate to a subtree with max_depth. Simple: walk the anchor's
1967
+ // subtree, produce a synthetic PGN starting from the anchor's FEN.
1968
+ const subtreePgn = exportSubtreePgn(file, anchor, maxDepth);
1969
+ return { ...header, pgn: subtreePgn };
1970
+ }
1971
+ return { ...header, tree: projectNode(anchor, view, maxDepth, fenIndex) };
1972
+ }
1973
+ // Produce a PGN string for a subtree rooted at `anchor`, truncated
1974
+ // at `maxDepth` plies below (null = unlimited). Reuses the exporter
1975
+ // by building a synthetic PrepFile whose root is a shallow clone of
1976
+ // the anchor with its children trimmed to depth.
1977
+ function exportSubtreePgn(file, anchor, maxDepth) {
1978
+ const trim = (n, depthLeft) => {
1979
+ if (depthLeft !== null && depthLeft <= 0)
1980
+ return { ...n, children: [] };
1981
+ const next = depthLeft === null ? null : depthLeft - 1;
1982
+ return { ...n, children: n.children.map(c => trim(c, next)) };
1983
+ };
1984
+ const trimmedAnchor = trim(anchor, maxDepth);
1985
+ // If the anchor IS the root, exporter handles it. If it's an inner
1986
+ // node, we set the root's FEN to the anchor's position and hang the
1987
+ // trimmed subtree off it. Tags carried over.
1988
+ if (anchor.id === ROOT_ID) {
1989
+ return exportPGN({ tags: file.tags, root: trimmedAnchor });
1990
+ }
1991
+ const syntheticRoot = {
1992
+ id: ROOT_ID,
1993
+ san: null,
1994
+ fen: anchor.fen,
1995
+ ply: 0,
1996
+ children: trimmedAnchor.children,
1997
+ };
1998
+ const tags = { ...file.tags, FEN: anchor.fen, SetUp: "1" };
1999
+ return exportPGN({ tags, root: syntheticRoot });
2000
+ }
2001
+ async function listNodes(args) {
2002
+ const id = String(args.id);
2003
+ const filter = String(args.filter || "");
2004
+ const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0 ? args.node_id : ROOT_ID;
2005
+ const maxDepth = typeof args.max_depth === "number" && args.max_depth >= 0 ? args.max_depth : null;
2006
+ const { file } = await loadPrepFile(id);
2007
+ const idIndex = buildIdIndex(file.root);
2008
+ const path = resolveNodeId(idIndex, startNodeId);
2009
+ const anchor = getNodeByPath(file.root, path);
2010
+ const fenIndex = filter === "transpositions" ? buildFenIndex(file.root) : null;
2011
+ const hits = [];
2012
+ const walk = (node, depthLeft, spineOnly) => {
2013
+ // Root has no san — never emit it as a match. Everything else is fair game.
2014
+ if (node.id !== ROOT_ID) {
2015
+ let include = false;
2016
+ let extra = {};
2017
+ switch (filter) {
2018
+ case "missing_eval":
2019
+ include = !node.ceoEval;
2020
+ break;
2021
+ case "has_comment":
2022
+ include = !!(node.comment && node.comment.length > 0);
2023
+ if (include)
2024
+ extra.comment_preview = (node.comment || "").slice(0, 80);
2025
+ break;
2026
+ case "has_annotations":
2027
+ include = !!(node.annotations && (node.annotations.arrows.length > 0 || node.annotations.highlights.length > 0));
2028
+ break;
2029
+ case "novelties":
2030
+ include = !!(node.nags && node.nags.includes("$146"));
2031
+ break;
2032
+ case "leaves":
2033
+ include = node.children.length === 0;
2034
+ break;
2035
+ case "mainline":
2036
+ include = spineOnly;
2037
+ break;
2038
+ case "transpositions": {
2039
+ const group = fenIndex.get(positionKey(node.fen));
2040
+ if (group && group.length > 1) {
2041
+ include = true;
2042
+ extra.transposes_to = group.filter(n => n.id !== node.id).map(n => n.id);
2043
+ }
2044
+ break;
2045
+ }
2046
+ case "all":
2047
+ include = true;
2048
+ break;
2049
+ default:
2050
+ throw new Error(`unknown filter: ${filter}`);
2051
+ }
2052
+ if (include) {
2053
+ const hit = { node_id: node.id, san: node.san, ply: node.ply };
2054
+ Object.assign(hit, extra);
2055
+ hits.push(hit);
2056
+ }
2057
+ }
2058
+ if (depthLeft !== null && depthLeft <= 0)
2059
+ return;
2060
+ const nextDepth = depthLeft === null ? null : depthLeft - 1;
2061
+ if (filter === "mainline" && spineOnly) {
2062
+ if (node.children.length > 0)
2063
+ walk(node.children[0], nextDepth, true);
2064
+ }
2065
+ else {
2066
+ for (const c of node.children)
2067
+ walk(c, nextDepth, filter === "mainline");
2068
+ }
2069
+ };
2070
+ const rootIsSpineForFilter = filter === "mainline";
2071
+ walk(anchor, maxDepth, rootIsSpineForFilter);
2072
+ return { file_id: id, filter, node_id: startNodeId, max_depth: maxDepth, count: hits.length, nodes: hits };
2073
+ }
2074
+ // list_transpositions — every position that occurs 2+ times in the
2075
+ // file, so the LLM knows where its analysis / prose will double up.
2076
+ async function listTranspositions(args) {
2077
+ const id = String(args.id);
2078
+ const { file } = await loadPrepFile(id);
2079
+ const fenIndex = buildFenIndex(file.root);
2080
+ const groups = [];
2081
+ for (const [key, arr] of fenIndex.entries()) {
2082
+ if (arr.length < 2)
2083
+ continue;
2084
+ groups.push({
2085
+ position_key: key,
2086
+ size: arr.length,
2087
+ node_ids: arr.map(n => n.id),
2088
+ sans: arr.map(n => n.san),
2089
+ });
2090
+ }
2091
+ groups.sort((a, b) => b.size - a.size || a.position_key.localeCompare(b.position_key));
2092
+ const nodeCount = groups.reduce((s, g) => s + g.size, 0);
2093
+ return { file_id: id, group_count: groups.length, node_count: nodeCount, groups };
2094
+ }
1668
2095
  // Strip cruft the LLM doesn't need from the DB-position response.
1669
2096
  // Called AFTER trimGamesMovetext so plyNumber survives long enough to
1670
2097
  // slice each game's movetext. Also renames the `transpositions` field
@@ -1852,10 +2279,21 @@ function normalizeSourceForBackend(src, idx) {
1852
2279
  }
1853
2280
  return out;
1854
2281
  }
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.
2282
+ // Async resolver used by every engine / DB tool. Three paths:
2283
+ //
2284
+ // 1. file_id + node_id → load file, resolve node, return FEN + handle
2285
+ // to persist ceoEval later. Cheapest, most explicit.
2286
+ //
2287
+ // 2. file_id + (fen | moves | line) → load file, resolve FEN
2288
+ // client-side, then scan the file's nodes for one matching that
2289
+ // FEN. If found, return the same handle as (1) so cloud_analyse
2290
+ // auto-stores on the matching node. Fixes the previous footgun
2291
+ // where `cloud_analyse({file_id, moves})` silently dropped the
2292
+ // eval because the server didn't try to match the resulting FEN
2293
+ // back to a node.
2294
+ //
2295
+ // 3. Just fen | moves | line, no file_id → scratch mode, no
2296
+ // persistence. Same as before.
1859
2297
  async function resolveFromNodeOrFen(args) {
1860
2298
  const fileId = typeof args.file_id === "string" ? args.file_id.trim() : "";
1861
2299
  const nodeId = typeof args.node_id === "string" ? args.node_id.trim() : "";
@@ -1870,18 +2308,51 @@ async function resolveFromNodeOrFen(args) {
1870
2308
  const node = getNodeByPath(parsedFile.root, nodePath);
1871
2309
  return {
1872
2310
  fen: node.fen,
1873
- file: {
1874
- id: fileId,
1875
- version: g.version ?? 0,
1876
- parsedFile,
1877
- idIndex,
1878
- nodePath,
1879
- fen: node.fen,
1880
- },
2311
+ file: { id: fileId, version: g.version ?? 0, parsedFile, idIndex, nodePath, fen: node.fen },
1881
2312
  };
1882
2313
  }
2314
+ if (fileId) {
2315
+ // file_id only — resolve FEN from fen/moves/line, then look it up
2316
+ // in the file's nodes. If a node has that FEN, treat this as if
2317
+ // node_id had been supplied (auto-persist on match).
2318
+ const fen = resolveFenFromArgs(args);
2319
+ try {
2320
+ const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(fileId)}`);
2321
+ const g = raw;
2322
+ if (typeof g.pgnContent === "string") {
2323
+ const parsedFile = parsePGN(g.pgnContent);
2324
+ const match = findNodeByFen(parsedFile.root, fen);
2325
+ if (match) {
2326
+ const idIndex = buildIdIndex(parsedFile.root);
2327
+ return {
2328
+ fen,
2329
+ file: { id: fileId, version: g.version ?? 0, parsedFile, idIndex, nodePath: match.path, fen },
2330
+ };
2331
+ }
2332
+ }
2333
+ }
2334
+ catch {
2335
+ // Best-effort: if the file load fails, fall through to scratch mode.
2336
+ }
2337
+ return { fen };
2338
+ }
1883
2339
  return { fen: resolveFenFromArgs(args) };
1884
2340
  }
2341
+ // Search the tree for a node whose FEN matches. Full-tree scan — trees
2342
+ // max out ~1000 nodes so this is fine. FEN comparison is exact string
2343
+ // match (both come from the same chessops normalisation).
2344
+ function findNodeByFen(root, targetFen) {
2345
+ const stack = [{ node: root, path: [] }];
2346
+ while (stack.length > 0) {
2347
+ const { node, path } = stack.pop();
2348
+ if (node.fen === targetFen)
2349
+ return { node, path };
2350
+ for (let i = 0; i < node.children.length; i++) {
2351
+ stack.push({ node: node.children[i], path: [...path, i] });
2352
+ }
2353
+ }
2354
+ return null;
2355
+ }
1885
2356
  // Local wrapper — the mutation module re-exports paths.getNode so this
1886
2357
  // import stays consistent with the rest of the file's imports.
1887
2358
  function getNodeByPath(root, path) {
@@ -1893,22 +2364,41 @@ function getNodeByPath(root, path) {
1893
2364
  }
1894
2365
  return cur;
1895
2366
  }
1896
- // Persist a fresh ceoEval on the node referenced by the file handle.
1897
- // Best-effort — if the file version raced (another agent saved
1898
- // between our GET and our PUT), we silently drop the store rather
1899
- // than fail the analysis the LLM actually asked for. The eval is
1900
- // still returned in the response either way.
2367
+ // Persist a fresh ceoEval on the node referenced by the file handle
2368
+ // AND on every other node in the same file that transposes to the
2369
+ // same position (matches on the frontend's 3-field FEN key: piece
2370
+ // placement + side to move + castling). Best-effort — if the file
2371
+ // version raced (another agent saved between our GET and our PUT),
2372
+ // we silently drop the store rather than fail the analysis the LLM
2373
+ // actually asked for. The eval is still returned in the response
2374
+ // either way.
2375
+ //
2376
+ // Return: ids of every node the eval was stamped on (empty on error).
2377
+ // The primary node's id is always first (if present).
1901
2378
  async function storeEvalOnNode(handle, ev) {
1902
2379
  try {
1903
- const step = setCeoEval(handle.parsedFile, handle.nodePath, ev);
1904
- const newPgn = exportPGN(step.file);
2380
+ const anchor = getNodeByPath(handle.parsedFile.root, handle.nodePath);
2381
+ const key = positionKey(anchor.fen);
2382
+ const fenIndex = buildFenIndex(handle.parsedFile.root);
2383
+ const group = fenIndex.get(key) ?? [anchor];
2384
+ // Resolve every transposed node back to its path. cloneOnPath
2385
+ // rebuilds the spine so we need paths, not references — the
2386
+ // id index was built against the original tree and every id in
2387
+ // `group` exists there.
2388
+ const idIndex = handle.idIndex ?? buildIdIndex(handle.parsedFile.root);
2389
+ const paths = group.map(n => resolveNodeId(idIndex, n.id));
2390
+ const { file: newFile, ids } = setCeoEvalMany(handle.parsedFile, paths, ev);
2391
+ const newPgn = exportPGN(newFile);
1905
2392
  await authedRequest("PUT", `/api/agent/prep-files/${encodeURIComponent(handle.id)}`, {
1906
2393
  pgn: newPgn,
1907
2394
  expected_version: handle.version,
1908
2395
  });
2396
+ // Ensure the primary node (the one the LLM addressed) comes first.
2397
+ const anchorId = anchor.id;
2398
+ return [anchorId, ...ids.filter(x => x !== anchorId)];
1909
2399
  }
1910
2400
  catch {
1911
- // Best-effort — the analysis result is what the LLM asked for.
2401
+ return [];
1912
2402
  }
1913
2403
  }
1914
2404
  function stringifyForLog(v) {
@@ -1924,19 +2414,50 @@ function stringifyForLog(v) {
1924
2414
  }
1925
2415
  return s;
1926
2416
  }
2417
+ // Version tag stamped on every log line so a bug report can be traced
2418
+ // to the exact MCP release that produced it. process.env.npm_package_version
2419
+ // is set by npm when the package is run via `npx` / `npm start` (and by
2420
+ // our systemd unit which uses npx); falls back to reading package.json
2421
+ // during dev when we `node dist/index.js` directly. "unknown" if all
2422
+ // else fails — better than pretending we know.
2423
+ const MCP_VERSION = (() => {
2424
+ const fromEnv = process.env.npm_package_version?.trim();
2425
+ if (fromEnv)
2426
+ return fromEnv;
2427
+ try {
2428
+ const here = dirname(fileURLToPath(import.meta.url));
2429
+ // dist/index.js is one level below package.json; src/index.ts is two
2430
+ // (src/index.ts → ../package.json). Try both.
2431
+ for (const p of [join(here, "..", "package.json"), join(here, "..", "..", "package.json")]) {
2432
+ if (existsSync(p)) {
2433
+ const pkg = JSON.parse(readFileSync(p, "utf8"));
2434
+ if (pkg.version)
2435
+ return pkg.version;
2436
+ }
2437
+ }
2438
+ }
2439
+ catch {
2440
+ // fall through
2441
+ }
2442
+ return "unknown";
2443
+ })();
2444
+ const MCP_TAG = `[mcp v${MCP_VERSION}]`;
2445
+ // One-shot startup line so tailing the log from the beginning shows
2446
+ // the running version immediately, before any tool call.
2447
+ console.error(`${MCP_TAG} chessceo-mcp startup`);
1927
2448
  async function callTool(name, args) {
1928
2449
  const started = Date.now();
1929
- console.error(`[mcp] IN ${name} args=${stringifyForLog(args)}`);
2450
+ console.error(`${MCP_TAG} IN ${name} args=${stringifyForLog(args)}`);
1930
2451
  try {
1931
2452
  const result = await callToolInner(name, args);
1932
2453
  const dur = Date.now() - started;
1933
- console.error(`[mcp] OUT ${name} ok ${dur}ms result=${stringifyForLog(result)}`);
2454
+ console.error(`${MCP_TAG} OUT ${name} ok ${dur}ms result=${stringifyForLog(result)}`);
1934
2455
  return result;
1935
2456
  }
1936
2457
  catch (err) {
1937
2458
  const dur = Date.now() - started;
1938
2459
  const msg = err instanceof Error ? err.message : String(err);
1939
- console.error(`[mcp] OUT ${name} err ${dur}ms error=${JSON.stringify(msg)}`);
2460
+ console.error(`${MCP_TAG} OUT ${name} err ${dur}ms error=${JSON.stringify(msg)}`);
1940
2461
  throw err;
1941
2462
  }
1942
2463
  }
@@ -2012,17 +2533,37 @@ async function callToolInner(name, args) {
2012
2533
  }
2013
2534
  case "describe_position": {
2014
2535
  const resolved = await resolveFromNodeOrFen(args);
2015
- return describePosition(resolved.fen);
2536
+ // Chess-primitive analysis (structural, ~1 ms) + Stockfish eval-
2537
+ // term breakdown (~50-100 ms) in parallel. Merge into one response
2538
+ // so the LLM sees the whole position in one call — chess-concepts,
2539
+ // structural weaknesses, AND engine's per-term reasoning.
2540
+ // If Stockfish isn't installed the eval leg returns {found: false,
2541
+ // error} and we drop it from the response so callers see the same
2542
+ // shape either way (just without `engineEvalTerms`).
2543
+ const [structural, evalRaw] = await Promise.all([
2544
+ Promise.resolve(describePosition(resolved.fen)),
2545
+ runSfEval(resolved.fen).catch(err => ({
2546
+ found: false,
2547
+ error: err instanceof Error ? err.message : String(err),
2548
+ })),
2549
+ ]);
2550
+ const merged = structural;
2551
+ const ev = evalRaw;
2552
+ if (ev && ev.found === true) {
2553
+ merged.engineEvalTerms = { terms: ev.terms, total: ev.total };
2554
+ }
2555
+ return merged;
2016
2556
  }
2017
2557
  case "predict_human_move": {
2018
2558
  const resolved = await resolveFromNodeOrFen(args);
2019
2559
  const fen = resolved.fen;
2560
+ // Rating is fixed at 2850 vs 2850. Not exposed to the LLM —
2561
+ // cross-position comparisons only mean something at a constant
2562
+ // rating, and top-level is the useful reference point for prep.
2020
2563
  const qs = new URLSearchParams();
2021
2564
  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));
2565
+ qs.set("white_elo", "2850");
2566
+ qs.set("black_elo", "2850");
2026
2567
  if (typeof args.top === "number")
2027
2568
  qs.set("top", String(args.top));
2028
2569
  if (Array.isArray(args.prev_fens)) {
@@ -2031,7 +2572,20 @@ async function callToolInner(name, args) {
2031
2572
  qs.append("prev_fen", p);
2032
2573
  }
2033
2574
  }
2034
- return authedRequest("GET", `/api/agent/predict-move?${qs.toString()}`);
2575
+ const raw = await authedRequest("GET", `/api/agent/predict-move?${qs.toString()}`);
2576
+ // Strip uci from each move — san is enough for the LLM and the
2577
+ // duplicate field is context bloat. Also drop whiteElo/blackElo
2578
+ // from the response (always 2850 now — echoing them adds nothing).
2579
+ if (raw && typeof raw === "object") {
2580
+ const r = raw;
2581
+ if (Array.isArray(r.moves)) {
2582
+ for (const m of r.moves)
2583
+ delete m.uci;
2584
+ }
2585
+ delete r.whiteElo;
2586
+ delete r.blackElo;
2587
+ }
2588
+ return raw;
2035
2589
  }
2036
2590
  case "get_head_to_head":
2037
2591
  return get("/api/chess/players/h2h", {
@@ -2081,8 +2635,15 @@ async function callToolInner(name, args) {
2081
2635
  // was supplied and the eval survives via the [%ceo-eval] escape.
2082
2636
  if (resolved.file) {
2083
2637
  const ev = analysisToStoredEval(converted);
2084
- if (ev)
2085
- await storeEvalOnNode(resolved.file, ev);
2638
+ if (ev) {
2639
+ const stamped = await storeEvalOnNode(resolved.file, ev);
2640
+ if (stamped.length > 1) {
2641
+ // Surface the propagation so the LLM sees exactly which
2642
+ // other nodes now carry this eval (and can skip them for
2643
+ // re-analysis).
2644
+ converted.also_stored_on = stamped.slice(1);
2645
+ }
2646
+ }
2086
2647
  }
2087
2648
  return converted;
2088
2649
  }
@@ -2094,23 +2655,18 @@ async function callToolInner(name, args) {
2094
2655
  return deepAnalyseCancel(args);
2095
2656
  case "find_position_in_courses":
2096
2657
  return findPositionInCourses(args);
2658
+ case "read_course_at_position":
2659
+ return readCourseAtPosition(args);
2097
2660
  case "list_prep_files":
2098
2661
  return authedRequest("GET", "/api/agent/prep-files");
2099
2662
  case "search_prep_files":
2100
2663
  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
- }
2664
+ case "read_prep_file":
2665
+ return readPrepFile(args);
2666
+ case "list_nodes":
2667
+ return listNodes(args);
2668
+ case "list_transpositions":
2669
+ return listTranspositions(args);
2114
2670
  case "create_prep_file":
2115
2671
  return authedRequest("POST", "/api/agent/prep-files", {
2116
2672
  name: String(args.name),