@chessceo/mcp 0.19.0 → 0.24.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
@@ -12,6 +12,10 @@ import { readFileSync } from "node:fs";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { dirname, join } from "node:path";
14
14
  import { Chess } from "chess.js";
15
+ import { parsePGN } from "./pgn/parser.js";
16
+ import { exportPGN } from "./pgn/exporter.js";
17
+ import { addMove, deleteSubtree, MutationError, promoteVariation, setAnnotations, setComment, setNags, setTag, } from "./pgn/mutations.js";
18
+ import { PathError } from "./pgn/paths.js";
15
19
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
16
20
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
21
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
@@ -36,6 +40,7 @@ function loadBundledDoc(filename, fallbackLabel) {
36
40
  const ENGINE_USAGE_DOC = loadBundledDoc("engine-usage.md", "Engine usage guide");
37
41
  const PREP_STRATEGY_DOC = loadBundledDoc("prep-strategy.md", "Prep strategy guide");
38
42
  const PREP_FILES_DOC = loadBundledDoc("prep-files-guide.md", "Prep files guide");
43
+ const PGN_AUTHORING_DOC = loadBundledDoc("pgn-authoring.md", "PGN authoring guide");
39
44
  // ── HTTP ────────────────────────────────────────────────────────────
40
45
  //
41
46
  // Two auth flavours coexist:
@@ -63,8 +68,16 @@ const AUTHED_TOOLS = new Set([
63
68
  "search_prep_files",
64
69
  "read_prep_file",
65
70
  "create_prep_file",
66
- "save_prep_file",
67
71
  "delete_prep_file",
72
+ "add_move",
73
+ "set_comment",
74
+ "set_nags",
75
+ "set_annotations",
76
+ "delete_subtree",
77
+ "promote_variation",
78
+ "set_tag",
79
+ "apply_mutations",
80
+ "auto_evaluate",
68
81
  "predict_human_move",
69
82
  ]);
70
83
  function isAuthedToolCall(body) {
@@ -241,35 +254,6 @@ const TOOLS = [
241
254
  },
242
255
  },
243
256
  },
244
- {
245
- name: "analyse",
246
- description: "Short Stockfish evaluation at a position. Returns the top-N candidate moves with score (centipawns from side-to-move POV, positive = advantage; or mate distance) and the principal variation for each. Defaults: 2s think time, top-3 lines. PV moves come back in SAN (e4, Nf3, Bxc4 — not UCI). Free (no cloud instance needed) — use liberally.\n\n" +
247
- "GROUNDING: cite this tool's actual output when you claim things about positions. Don't invent evaluations from general principles or training data — if you don't have engine output for a FEN, call this. Compute is cheap.\n\n" +
248
- "Position input is flexible — pass `fen`, or `moves` from startpos, or `fen + moves` to walk from an arbitrary position. Also see the flip-side-to-move threat check in read_engine_usage_guide.\n\n" +
249
- "Use this to sanity-check candidate lines from get_position_stats or get_player_preparation — human game frequency tells you what people play, engine evaluation tells you what's actually good.",
250
- inputSchema: {
251
- type: "object",
252
- properties: {
253
- fen: { type: "string", description: "Starting position as FEN (defaults to startpos)." },
254
- moves: {
255
- type: "string",
256
- description: "Optional SAN moves to apply on top of `fen` (or on top of startpos). Example: fen='<tabiya>', moves='b4 a5'.",
257
- },
258
- movetime_ms: {
259
- type: "integer",
260
- minimum: 100,
261
- maximum: 10000,
262
- description: "Think time in milliseconds (default 2000).",
263
- },
264
- multipv: {
265
- type: "integer",
266
- minimum: 1,
267
- maximum: 10,
268
- description: "Number of candidate lines to return (default 3).",
269
- },
270
- },
271
- },
272
- },
273
257
  {
274
258
  name: "predict_human_move",
275
259
  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" +
@@ -457,7 +441,8 @@ const TOOLS = [
457
441
  },
458
442
  {
459
443
  name: "read_prep_file",
460
- description: "Read one prep file. Returns the full PGN text and a compact tree JSON of the mainline (SAN + FEN per move) for programmatic reasoning. Also carries the `version` field you need to pass back to save_prep_file for the optimistic-lock check.",
444
+ description: "Read one prep file. Returns a compact tree (path-addressable, one node per move) plus tags and the `version` for optimistic locking on subsequent mutations. NO raw PGN all edits go through the mutation tools (add_move / set_comment / set_nags / set_annotations / delete_subtree / promote_variation / set_tag), which validate SAN and structure for you.\n\n" +
445
+ "Path addressing: paths are arrays of child indices. `[]` = root position. `[0]` = the first mainline move. `[0, 1]` = the second variation branching after the first move. `[0, 0, 0]` = three plies deep on the mainline. Every mutation returns the effective path it landed at.",
461
446
  inputSchema: {
462
447
  type: "object",
463
448
  properties: {
@@ -468,55 +453,200 @@ const TOOLS = [
468
453
  },
469
454
  {
470
455
  name: "create_prep_file",
471
- description: "Create a new prep file. `name` becomes the Event PGN tag (user-facing label). Optional `pgn` seeds the file if omitted, a minimal empty PGN is written and you can save_prep_file into it later.\n\n" +
472
- "ALWAYS run list_prep_files (or search_prep_files with the opponent / opening keyword) BEFORE creating, so you don't duplicate an existing file. If a file already exists that fits, extend it via save_prep_file instead.",
456
+ 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" +
457
+ "ALWAYS call list_prep_files (or search_prep_files with the opponent / opening keyword) FIRST creating a duplicate 'Prep vs Firouzja' when one exists is the #1 LLM failure mode. If a file already covers the topic, add moves to that one instead.",
473
458
  inputSchema: {
474
459
  type: "object",
475
460
  properties: {
476
461
  name: {
477
462
  type: "string",
478
- description: "User-facing name for the file — becomes the [Event] tag in the PGN. Example: 'Prep vs Firouzja (Black) 2026-07-23'.",
479
- },
480
- pgn: {
481
- type: "string",
482
- description: "Optional initial PGN content. Supports full PGN with headers and parenthesised variations. If omitted, an empty PGN is created.",
463
+ description: "User-facing name — becomes the [Event] tag. Example: 'Prep vs Firouzja (Black) 2026-07-23'.",
483
464
  },
484
465
  },
485
466
  required: ["name"],
486
467
  },
487
468
  },
488
469
  {
489
- name: "save_prep_file",
490
- description: "Save (replace) a prep file's PGN content. Optimistic-lock via `expected_version` — pass the `version` you got from read_prep_file. If someone else (user in the app, or another agent session) updated the file since you read it, save returns 409 with the current version — re-read and merge.\n\n" +
491
- "PGN structure guidance:\n" +
492
- "- Keep the mainline clean (your top recommendation).\n" +
493
- "- Alternative candidates go in parenthesised variations at the branching move.\n" +
494
- "- Attach one-sentence commentary at key branch points using PGN {curly-brace comments}. Cite the actual tool output ('Lc0 gives +0.15 in this line') — don't dress the file up with unsourced chess prose.\n" +
495
- "- Use PGN NAGs for evaluations: $1 !, $2 ?, $3 !!, $4 ??, $6 =, $10 = (equal), $14 +/= (slight edge), etc. Optional but nice.",
470
+ name: "delete_prep_file",
471
+ description: "Soft-delete a prep file. User can restore from the app's recycle bin. Rare usually you extend or edit instead.",
496
472
  inputSchema: {
497
473
  type: "object",
498
474
  properties: {
499
475
  id: { type: "string", description: "Prep file id." },
500
- pgn: { type: "string", description: "Full replacement PGN (headers + moves)." },
501
- expected_version: {
502
- type: "integer",
503
- description: "The `version` you got from read_prep_file. If omitted, no optimistic check runs (last-write-wins — riskier).",
504
- },
505
476
  },
506
- required: ["id", "pgn"],
477
+ required: ["id"],
507
478
  },
508
479
  },
509
480
  {
510
- name: "delete_prep_file",
511
- description: "Soft-delete a prep file. The user can restore it from the chess.ceo app's recycle bin if you deleted something valuable. Rareusually you extend or replace instead.",
481
+ name: "add_move",
482
+ description: "Append a move as a new child of the node at `path`. If the node already has children, the new move becomes a variation (appended at the end). Use promote_variation afterwards if you want it to be the mainline. SAN is validated against the position illegal moves are rejected with a clear error.\n\n" +
483
+ "Auto-saves. Returns `{path, version}` — the effective path the new node landed at (which you pass to follow-up set_comment / set_nags / etc.) and the new file version for optimistic locking on your next mutation.",
484
+ inputSchema: {
485
+ type: "object",
486
+ properties: {
487
+ id: { type: "string", description: "Prep file id." },
488
+ path: { type: "array", items: { type: "integer", minimum: 0 }, description: "Path to the parent node the move is played FROM." },
489
+ san: { type: "string", description: "The move in SAN notation (e.g. 'Nf3', 'exd5', 'O-O', 'Qxf7+')." },
490
+ expected_version: { type: "integer", description: "Optimistic-lock check; pass the `version` from your last read." },
491
+ },
492
+ required: ["id", "path", "san"],
493
+ },
494
+ },
495
+ {
496
+ name: "set_comment",
497
+ description: "Set (or clear, with empty string) the text comment on the node at `path`. Comments are for plans, prep-signal, and interpretation the app can't derive — NOT for describing moves that should be variations instead. Auto-saves.",
512
498
  inputSchema: {
513
499
  type: "object",
514
500
  properties: {
515
501
  id: { type: "string", description: "Prep file id." },
502
+ path: { type: "array", items: { type: "integer", minimum: 0 } },
503
+ comment: { type: "string", description: "New comment text. Empty string clears." },
504
+ expected_version: { type: "integer" },
505
+ },
506
+ required: ["id", "path", "comment"],
507
+ },
508
+ },
509
+ {
510
+ name: "set_nags",
511
+ description: "Replace the list of NAGs on the node at `path`. Empty array clears them. See read_pgn_authoring_guide for the full NAG table and eval→NAG thresholds (|eval|<0.25 → $10; <0.6 → $14/$15; <1.3 → $16/$17; ≥1.3 → $18/$19). Auto-saves.",
512
+ inputSchema: {
513
+ type: "object",
514
+ properties: {
515
+ id: { type: "string" },
516
+ path: { type: "array", items: { type: "integer", minimum: 0 } },
517
+ nags: { type: "array", items: { type: "string", pattern: "^\\$\\d+$" }, description: "NAG list, e.g. ['$14'] or ['$146', '$44']." },
518
+ expected_version: { type: "integer" },
519
+ },
520
+ required: ["id", "path", "nags"],
521
+ },
522
+ },
523
+ {
524
+ name: "set_annotations",
525
+ description: "Replace the visual annotations (arrows + coloured squares) on the node at `path`. Passing empty arrays clears them.\n\n" +
526
+ "Colours: green, red, yellow, light-blue, dark-blue, orange. Keep it LIGHT: 1-3 arrows and 2-3 squares per move maximum. Twenty arrows is noise, not signal. Auto-saves.",
527
+ inputSchema: {
528
+ type: "object",
529
+ properties: {
530
+ id: { type: "string" },
531
+ path: { type: "array", items: { type: "integer", minimum: 0 } },
532
+ arrows: {
533
+ type: "array",
534
+ items: {
535
+ type: "object",
536
+ properties: {
537
+ color: { type: "string", enum: ["green", "red", "yellow", "light-blue", "dark-blue", "orange"] },
538
+ from: { type: "string", pattern: "^[a-h][1-8]$" },
539
+ to: { type: "string", pattern: "^[a-h][1-8]$" },
540
+ },
541
+ required: ["color", "from", "to"],
542
+ },
543
+ },
544
+ highlights: {
545
+ type: "array",
546
+ items: {
547
+ type: "object",
548
+ properties: {
549
+ color: { type: "string", enum: ["green", "red", "yellow", "light-blue", "dark-blue", "orange"] },
550
+ square: { type: "string", pattern: "^[a-h][1-8]$" },
551
+ },
552
+ required: ["color", "square"],
553
+ },
554
+ },
555
+ expected_version: { type: "integer" },
556
+ },
557
+ required: ["id", "path"],
558
+ },
559
+ },
560
+ {
561
+ name: "delete_subtree",
562
+ description: "Delete the node at `path` and all its descendants. Refuses to delete the root. Auto-saves.",
563
+ inputSchema: {
564
+ type: "object",
565
+ properties: {
566
+ id: { type: "string" },
567
+ path: { type: "array", items: { type: "integer", minimum: 0 } },
568
+ expected_version: { type: "integer" },
569
+ },
570
+ required: ["id", "path"],
571
+ },
572
+ },
573
+ {
574
+ name: "promote_variation",
575
+ description: "Make the node at `path` its parent's mainline (children[0]), demoting the current mainline (and any other siblings) into variation order. Silently no-op if already the mainline. Auto-saves.",
576
+ inputSchema: {
577
+ type: "object",
578
+ properties: {
579
+ id: { type: "string" },
580
+ path: { type: "array", items: { type: "integer", minimum: 0 }, description: "Path to the node to promote. Length ≥ 1." },
581
+ expected_version: { type: "integer" },
582
+ },
583
+ required: ["id", "path"],
584
+ },
585
+ },
586
+ {
587
+ name: "apply_mutations",
588
+ description: "Batch: apply a list of mutations in one call. One load-parse-mutate-export-save cycle for N ops, so building a 100-move repertoire costs one HTTP round-trip and one save instead of 100. This is the RIGHT way to build a file — use single mutations only for surgical follow-up edits.\n\n" +
589
+ "Each mutation is `{op, path, ...args}` where `op` is one of: add_move, set_comment, set_nags, set_annotations, delete_subtree, promote_variation, set_tag. Same arg shape as the individual tools. Ops apply in order; `path` is resolved against the tree AFTER preceding ops in the batch — so after `{op: 'add_move', path: [0], san: 'Nc3'}` the new node's path is `[0, N]` where N is the parent's previous children count (or `[0, 0]` if it was empty).\n\n" +
590
+ "Any op error aborts the batch (nothing saved). Response is `{ok, results: [{path}], version}` giving the effective path each op landed at plus the new file version.",
591
+ inputSchema: {
592
+ type: "object",
593
+ properties: {
594
+ id: { type: "string" },
595
+ expected_version: { type: "integer" },
596
+ mutations: {
597
+ type: "array",
598
+ minItems: 1,
599
+ items: {
600
+ type: "object",
601
+ properties: {
602
+ op: { type: "string", enum: ["add_move", "set_comment", "set_nags", "set_annotations", "delete_subtree", "promote_variation", "set_tag"] },
603
+ path: { type: "array", items: { type: "integer", minimum: 0 } },
604
+ san: { type: "string" },
605
+ comment: { type: "string" },
606
+ nags: { type: "array", items: { type: "string", pattern: "^\\$\\d+$" } },
607
+ arrows: { type: "array" },
608
+ highlights: { type: "array" },
609
+ key: { type: "string" },
610
+ value: { type: "string" },
611
+ },
612
+ required: ["op"],
613
+ },
614
+ },
615
+ },
616
+ required: ["id", "mutations"],
617
+ },
618
+ },
619
+ {
620
+ name: "auto_evaluate",
621
+ description: "Walk the tree from `path` (default `[]` = whole file) and auto-assign NAGs to every node based on the Stockfish eval from cloud_analyse. Uses the standard eval → NAG thresholds (|eval|<0.25 → $10, <0.6 → $14/$15, <1.3 → $16/$17, ≥1.3 → $18/$19). Requires a running cloud combo instance.\n\n" +
622
+ "This is the automate-the-boring-part tool. Instead of hand-annotating each move with a NAG, you build the tree via apply_mutations (no NAGs needed), then call auto_evaluate once and every node gets the right glyph.\n\n" +
623
+ "Costs real money — hits cloud_analyse per node. A 200-node tree at 1.5s/node = ~5 min of engine time. Use `only_missing=true` (default) to skip nodes that already have a NAG on subsequent runs. Runs 4 evaluations in parallel to save wall time.",
624
+ inputSchema: {
625
+ type: "object",
626
+ properties: {
627
+ id: { type: "string" },
628
+ path: { type: "array", items: { type: "integer", minimum: 0 }, description: "Subtree root (default = whole file)." },
629
+ only_missing: { type: "boolean", description: "Skip nodes that already carry a NAG (default true)." },
630
+ movetime_ms: { type: "integer", minimum: 500, maximum: 5000, description: "Per-node cloud_analyse think time (default 1500)." },
631
+ expected_version: { type: "integer" },
516
632
  },
517
633
  required: ["id"],
518
634
  },
519
635
  },
636
+ {
637
+ name: "set_tag",
638
+ description: "Set or clear a game-level PGN tag (Event, Site, Date, White, Black, Result, or any custom tag). Passing empty string removes the tag. Auto-saves.",
639
+ inputSchema: {
640
+ type: "object",
641
+ properties: {
642
+ id: { type: "string" },
643
+ key: { type: "string", description: "Tag key, e.g. 'Event', 'White', 'Date'." },
644
+ value: { type: "string", description: "Tag value. Empty string removes the tag." },
645
+ expected_version: { type: "integer" },
646
+ },
647
+ required: ["id", "key", "value"],
648
+ },
649
+ },
520
650
  {
521
651
  name: "read_engine_usage_guide",
522
652
  description: "Returns the full chess.ceo engine-usage guide: when to trust Stockfish (objective truth) vs Lc0 (practical eval), how to read disagreements between them, and how to use Lc0 contempt to find non-objective 'practical' ideas. Call this ONCE per session before running expensive `cloud_analyse` calls or when the user asks WHY the engines gave certain scores. Same content is also available as the `engine_usage_primer` prompt (for clients that surface prompts as slash commands), but many clients do not expose prompts to the model — this tool works everywhere.",
@@ -529,7 +659,12 @@ const TOOLS = [
529
659
  },
530
660
  {
531
661
  name: "read_prep_files_guide",
532
- description: "Returns the full guide on how to store prep in the user's chess.ceo account via list_prep_files / read_prep_file / create_prep_file / save_prep_file / delete_prep_file. Covers: how to structure a repertoire PGN (mainline + variations + comments + NAGs), the critical 'search-before-create' habit to avoid duplicates, optimistic-locking with `version`, and how to write comments that cite tool output instead of inventing chess prose. Call this ONCE per session before your first create_prep_file / save_prep_file call.",
662
+ description: "Returns the guide to the prep-files FEATURE how the storage works, when to list vs search vs create, optimistic locking with `version`, and naming conventions for the [Event] tag. Call this ONCE per session before your first create_prep_file. For how to actually WRITE PGN (mainline, variations, NAGs, arrows, comments) call read_pgn_authoring_guide instead — separate concern.",
663
+ inputSchema: { type: "object", properties: {} },
664
+ },
665
+ {
666
+ name: "read_pgn_authoring_guide",
667
+ description: "Returns the guide on how to write correct, useful PGN — mainline discipline, variations as moves (never prose describing moves), NAG symbols including novelty ($146), unclear ($13), compensation ($44) and the standard set, ChessBase arrow/coloured-square syntax ([%cal] / [%csl]), and common pitfalls the parser will reject. Call this ONCE per session before any save_prep_file call, or any time you're producing PGN output for the user.",
533
668
  inputSchema: { type: "object", properties: {} },
534
669
  },
535
670
  {
@@ -605,20 +740,6 @@ function uciMoveToSAN(startFen, uci) {
605
740
  return uci;
606
741
  }
607
742
  }
608
- // Rewrite the local /chess/database/analyse response (single engine)
609
- // so PVs come back in SAN.
610
- function convertAnalyseResponse(raw, startFen) {
611
- if (!raw || typeof raw !== "object")
612
- return raw;
613
- const r = raw;
614
- if (Array.isArray(r.lines)) {
615
- for (const line of r.lines) {
616
- if (Array.isArray(line.pv))
617
- line.pv = uciLineToSAN(startFen, line.pv);
618
- }
619
- }
620
- return raw;
621
- }
622
743
  // Rewrite the /api/agent/cloud-engines/analyse response (two engines,
623
744
  // each with lines[] and a bestMove) so PVs and bestMove come back in SAN.
624
745
  function convertCloudSnapshotResponse(raw, startFen) {
@@ -639,23 +760,217 @@ function convertCloudSnapshotResponse(raw, startFen) {
639
760
  }
640
761
  return raw;
641
762
  }
642
- function pgnToTree(pgn) {
643
- try {
644
- const board = new Chess();
645
- // chess.js loadPgn accepts full PGN incl. tags; loadPgn returns true on
646
- // success or throws on newer versions. Wrap defensively.
647
- board.loadPgn(pgn);
648
- // history({verbose:true}) walks the mainline only; variations aren't
649
- // exposed by chess.js's built-in PGN loader at time of writing. If the
650
- // PGN carries parenthesised variations they get flattened.
651
- const hist = board.history({ verbose: true });
652
- if (hist.length === 0)
653
- return [];
654
- return hist.map(h => ({ san: h.san, fen: h.after }));
763
+ // Path arg parsing for the mutation tools. Rejects malformed input
764
+ // with a clear message the LLM can act on.
765
+ function argPath(args) {
766
+ const raw = args.path;
767
+ if (!Array.isArray(raw))
768
+ throw new Error("`path` must be an array of non-negative integers");
769
+ const out = [];
770
+ for (let i = 0; i < raw.length; i++) {
771
+ const n = raw[i];
772
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
773
+ throw new Error(`path[${i}] must be a non-negative integer (got ${JSON.stringify(n)})`);
774
+ }
775
+ out.push(n);
655
776
  }
656
- catch {
777
+ return out;
778
+ }
779
+ // Dispatch table for the batch tool: name → mutator that returns the
780
+ // standard { file, path } shape. Reused for individual and batch calls.
781
+ function dispatchMutation(file, op) {
782
+ const kind = String(op.op);
783
+ const path = Array.isArray(op.path) ? op.path : [];
784
+ switch (kind) {
785
+ case "add_move":
786
+ return addMove(file, path, String(op.san));
787
+ case "set_comment":
788
+ return setComment(file, path, typeof op.comment === "string" ? op.comment : "");
789
+ case "set_nags":
790
+ return setNags(file, path, Array.isArray(op.nags) ? op.nags.map(String) : []);
791
+ case "set_annotations": {
792
+ const arrows = Array.isArray(op.arrows) ? op.arrows : [];
793
+ const highlights = Array.isArray(op.highlights) ? op.highlights : [];
794
+ const ann = arrows.length === 0 && highlights.length === 0 ? null : { arrows, highlights };
795
+ return setAnnotations(file, path, ann);
796
+ }
797
+ case "delete_subtree":
798
+ return deleteSubtree(file, path);
799
+ case "promote_variation":
800
+ return promoteVariation(file, path);
801
+ case "set_tag":
802
+ return { file: setTag(file, String(op.key), String(op.value ?? "")), path: [] };
803
+ default:
804
+ throw new Error(`unknown mutation op: ${kind}`);
805
+ }
806
+ }
807
+ // Batch: load, parse, apply N mutations in order, export, save. All-or-nothing —
808
+ // any error aborts and nothing is saved.
809
+ async function applyBatchMutations(args) {
810
+ const id = String(args.id);
811
+ const mutations = Array.isArray(args.mutations) ? args.mutations : [];
812
+ if (mutations.length === 0)
813
+ throw new Error("mutations array required");
814
+ const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
815
+ const g = raw;
816
+ if (typeof g.pgnContent !== "string")
817
+ throw new Error("prep file missing pgnContent");
818
+ let file = parsePGN(g.pgnContent);
819
+ const results = [];
820
+ for (let i = 0; i < mutations.length; i++) {
821
+ const op = mutations[i];
822
+ try {
823
+ const step = dispatchMutation(file, op);
824
+ file = step.file;
825
+ results.push({ path: step.path });
826
+ }
827
+ catch (err) {
828
+ const msg = err instanceof Error ? err.message : String(err);
829
+ throw new Error(`mutation #${i} (${String(op.op)}) failed: ${msg}`);
830
+ }
831
+ }
832
+ const newPgn = exportPGN(file);
833
+ const expected = typeof args.expected_version === "number" ? args.expected_version : g.version;
834
+ const saved = await authedRequest("PUT", `/api/agent/prep-files/${encodeURIComponent(id)}`, {
835
+ pgn: newPgn,
836
+ expected_version: expected,
837
+ });
838
+ const savedRow = saved;
839
+ return { ok: true, results, version: savedRow.version };
840
+ }
841
+ // Auto-evaluate: walk the tree from `path`, run cloud_analyse on each node,
842
+ // convert the SF score to a NAG per the standard thresholds, and apply
843
+ // everything in one batch mutation. Uses batching to avoid N saves.
844
+ async function autoEvaluate(args) {
845
+ const id = String(args.id);
846
+ const startPath = Array.isArray(args.path) ? args.path : [];
847
+ const onlyMissing = args.only_missing !== false; // default true
848
+ const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 1500;
849
+ const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
850
+ const g = raw;
851
+ if (typeof g.pgnContent !== "string")
852
+ throw new Error("prep file missing pgnContent");
853
+ const file = parsePGN(g.pgnContent);
854
+ const targets = [];
855
+ const walk = (node, path) => {
856
+ if (path.length > 0) {
857
+ if (!onlyMissing || !(node.nags && node.nags.length > 0)) {
858
+ targets.push({ path, fen: node.fen });
859
+ }
860
+ }
861
+ for (let i = 0; i < node.children.length; i++)
862
+ walk(node.children[i], [...path, i]);
863
+ };
864
+ const startNode = pathIntoTree(file.root, startPath);
865
+ walk(startNode, startPath);
866
+ if (targets.length === 0)
867
+ return { ok: true, evaluated: 0, skipped: 0, version: g.version };
868
+ // Dispatch cloud_analyse in parallel with a 4-way cap so we don't
869
+ // over-saturate the engine's WS connection cap (single-client per port).
870
+ const nags = [];
871
+ const CONCURRENCY = 4;
872
+ let cursor = 0;
873
+ async function worker() {
874
+ while (cursor < targets.length) {
875
+ const idx = cursor++;
876
+ const t = targets[idx];
877
+ try {
878
+ const analysis = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen: t.fen, movetime_ms: movetimeMs, multipv: 1 });
879
+ const nag = analysisToNag(analysis);
880
+ if (nag)
881
+ nags.push({ path: t.path, nag });
882
+ }
883
+ catch {
884
+ // ignore per-node failures — best-effort auto-annotation
885
+ }
886
+ }
887
+ }
888
+ await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
889
+ if (nags.length === 0)
890
+ return { ok: true, evaluated: 0, skipped: targets.length, version: g.version };
891
+ // Apply all NAGs as a single batch.
892
+ const batchMutations = nags.map(n => ({ op: "set_nags", path: n.path, nags: [n.nag] }));
893
+ const saveResult = await applyBatchMutations({ id, mutations: batchMutations, expected_version: g.version });
894
+ const sr = saveResult;
895
+ return { ok: true, evaluated: nags.length, skipped: targets.length - nags.length, version: sr.version };
896
+ }
897
+ // Walk chess.js-free: resolve a path against a tree, throw if invalid.
898
+ function pathIntoTree(root, path) {
899
+ let cur = root;
900
+ for (let i = 0; i < path.length; i++) {
901
+ if (path[i] < 0 || path[i] >= cur.children.length) {
902
+ throw new Error(`path segment ${i}=${path[i]} out of bounds`);
903
+ }
904
+ cur = cur.children[path[i]];
905
+ }
906
+ return cur;
907
+ }
908
+ // Read Stockfish's rank-1 line, convert to White-POV centipawns, and
909
+ // bucket into a NAG per the standard threshold table. Returns null if
910
+ // the response shape doesn't carry a usable score.
911
+ function analysisToNag(analysis) {
912
+ if (!analysis || typeof analysis !== "object")
657
913
  return null;
914
+ const r = analysis;
915
+ const line = r.stockfish?.lines?.[0];
916
+ if (!line)
917
+ return null;
918
+ // Determine side to move from the FEN so we can convert side-to-move
919
+ // POV score to White POV.
920
+ const whiteToMove = typeof r.fen === "string" && / w /.test(r.fen);
921
+ let cp;
922
+ if (typeof line.mate === "number") {
923
+ cp = line.mate > 0 ? 10000 : -10000;
924
+ }
925
+ else if (typeof line.scoreCp === "number") {
926
+ cp = line.scoreCp;
927
+ }
928
+ else {
929
+ return null;
930
+ }
931
+ if (!whiteToMove)
932
+ cp = -cp;
933
+ const abs = Math.abs(cp);
934
+ if (abs < 25)
935
+ return "$10";
936
+ if (abs < 60)
937
+ return cp > 0 ? "$14" : "$15";
938
+ if (abs < 130)
939
+ return cp > 0 ? "$16" : "$17";
940
+ return cp > 0 ? "$18" : "$19";
941
+ }
942
+ // Load-mutate-save: fetch current PGN, parse, apply mutation, re-export,
943
+ // save with optimistic lock. Auto-saves so every tool call is atomic;
944
+ // the LLM never sees intermediate state.
945
+ async function applyMutation(args, mutator) {
946
+ const id = String(args.id);
947
+ const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
948
+ const g = raw;
949
+ if (typeof g.pgnContent !== "string")
950
+ throw new Error("prep file missing pgnContent");
951
+ const file = parsePGN(g.pgnContent);
952
+ let result;
953
+ try {
954
+ result = mutator(file);
955
+ }
956
+ catch (err) {
957
+ if (err instanceof MutationError || err instanceof PathError) {
958
+ throw new Error(`mutation rejected: ${err.message}`);
959
+ }
960
+ throw err;
658
961
  }
962
+ const newPgn = exportPGN(result.file);
963
+ const expected = typeof args.expected_version === "number" ? args.expected_version : g.version;
964
+ const saved = await authedRequest("PUT", `/api/agent/prep-files/${encodeURIComponent(id)}`, {
965
+ pgn: newPgn,
966
+ expected_version: expected,
967
+ });
968
+ const savedRow = saved;
969
+ return {
970
+ ok: true,
971
+ path: result.path,
972
+ version: savedRow.version,
973
+ };
659
974
  }
660
975
  // Rewrite availableMoves[].move UCI → SAN. The prep + position-stats
661
976
  // endpoints return moves in UCI on the wire — same LLM-readability
@@ -779,16 +1094,6 @@ async function callToolInner(name, args) {
779
1094
  });
780
1095
  return convertAvailableMovesToSAN(raw, fen);
781
1096
  }
782
- case "analyse": {
783
- const fen = resolveFenFromArgs(args);
784
- const params = { fen };
785
- if (typeof args.movetime_ms === "number")
786
- params.movetime_ms = args.movetime_ms;
787
- if (typeof args.multipv === "number")
788
- params.multipv = args.multipv;
789
- const raw = await get("/api/chess/database/analyse", params);
790
- return convertAnalyseResponse(raw, fen);
791
- }
792
1097
  case "predict_human_move": {
793
1098
  const fen = resolveFenFromArgs(args);
794
1099
  const qs = new URLSearchParams();
@@ -849,35 +1154,58 @@ async function callToolInner(name, args) {
849
1154
  return authedRequest("GET", `/api/agent/prep-files/search?q=${encodeURIComponent(String(args.query))}`);
850
1155
  case "read_prep_file": {
851
1156
  const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(String(args.id))}`);
852
- // MCP layer adds the tree JSON view — backend stays pure PGN in/out.
853
- // If parse fails the LLM still has the raw PGN text to work with.
854
- if (raw && typeof raw === "object") {
855
- const g = raw;
856
- if (typeof g.pgnContent === "string") {
857
- const tree = pgnToTree(g.pgnContent);
858
- return { ...raw, tree };
859
- }
860
- }
861
- return raw;
1157
+ const g = raw;
1158
+ if (typeof g.pgnContent !== "string")
1159
+ throw new Error("prep file missing pgnContent");
1160
+ const file = parsePGN(g.pgnContent);
1161
+ return {
1162
+ id: g.id,
1163
+ version: g.version,
1164
+ tags: file.tags,
1165
+ tree: file.root,
1166
+ };
862
1167
  }
863
1168
  case "create_prep_file":
864
1169
  return authedRequest("POST", "/api/agent/prep-files", {
865
1170
  name: String(args.name),
866
- pgn: typeof args.pgn === "string" ? args.pgn : undefined,
867
- });
868
- case "save_prep_file":
869
- return authedRequest("PUT", `/api/agent/prep-files/${encodeURIComponent(String(args.id))}`, {
870
- pgn: String(args.pgn),
871
- expected_version: typeof args.expected_version === "number" ? args.expected_version : undefined,
872
1171
  });
873
1172
  case "delete_prep_file":
874
1173
  return authedRequest("DELETE", `/api/agent/prep-files/${encodeURIComponent(String(args.id))}`);
1174
+ case "add_move":
1175
+ return applyMutation(args, file => addMove(file, argPath(args), String(args.san)));
1176
+ case "set_comment":
1177
+ return applyMutation(args, file => setComment(file, argPath(args), typeof args.comment === "string" ? args.comment : ""));
1178
+ case "set_nags":
1179
+ return applyMutation(args, file => setNags(file, argPath(args), Array.isArray(args.nags) ? args.nags.map(String) : []));
1180
+ case "set_annotations": {
1181
+ const arrowsRaw = Array.isArray(args.arrows) ? args.arrows : [];
1182
+ const highlightsRaw = Array.isArray(args.highlights) ? args.highlights : [];
1183
+ const ann = (arrowsRaw.length === 0 && highlightsRaw.length === 0)
1184
+ ? null
1185
+ : { arrows: arrowsRaw, highlights: highlightsRaw };
1186
+ return applyMutation(args, file => setAnnotations(file, argPath(args), ann));
1187
+ }
1188
+ case "delete_subtree":
1189
+ return applyMutation(args, file => deleteSubtree(file, argPath(args)));
1190
+ case "promote_variation":
1191
+ return applyMutation(args, file => promoteVariation(file, argPath(args)));
1192
+ case "set_tag":
1193
+ return applyMutation(args, file => ({
1194
+ file: setTag(file, String(args.key), String(args.value ?? "")),
1195
+ path: [],
1196
+ }));
1197
+ case "apply_mutations":
1198
+ return applyBatchMutations(args);
1199
+ case "auto_evaluate":
1200
+ return autoEvaluate(args);
875
1201
  case "read_engine_usage_guide":
876
1202
  return { guide: ENGINE_USAGE_DOC };
877
1203
  case "read_prep_strategy_guide":
878
1204
  return { guide: PREP_STRATEGY_DOC };
879
1205
  case "read_prep_files_guide":
880
1206
  return { guide: PREP_FILES_DOC };
1207
+ case "read_pgn_authoring_guide":
1208
+ return { guide: PGN_AUTHORING_DOC };
881
1209
  case "prep_snapshot": {
882
1210
  const me = Number(args.fide_id_me);
883
1211
  const opp = Number(args.fide_id_opponent);