@chessceo/mcp 0.23.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
@@ -76,6 +76,8 @@ const AUTHED_TOOLS = new Set([
76
76
  "delete_subtree",
77
77
  "promote_variation",
78
78
  "set_tag",
79
+ "apply_mutations",
80
+ "auto_evaluate",
79
81
  "predict_human_move",
80
82
  ]);
81
83
  function isAuthedToolCall(body) {
@@ -252,35 +254,6 @@ const TOOLS = [
252
254
  },
253
255
  },
254
256
  },
255
- {
256
- name: "analyse",
257
- 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" +
258
- "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" +
259
- "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" +
260
- "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.",
261
- inputSchema: {
262
- type: "object",
263
- properties: {
264
- fen: { type: "string", description: "Starting position as FEN (defaults to startpos)." },
265
- moves: {
266
- type: "string",
267
- description: "Optional SAN moves to apply on top of `fen` (or on top of startpos). Example: fen='<tabiya>', moves='b4 a5'.",
268
- },
269
- movetime_ms: {
270
- type: "integer",
271
- minimum: 100,
272
- maximum: 10000,
273
- description: "Think time in milliseconds (default 2000).",
274
- },
275
- multipv: {
276
- type: "integer",
277
- minimum: 1,
278
- maximum: 10,
279
- description: "Number of candidate lines to return (default 3).",
280
- },
281
- },
282
- },
283
- },
284
257
  {
285
258
  name: "predict_human_move",
286
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" +
@@ -610,6 +583,56 @@ const TOOLS = [
610
583
  required: ["id", "path"],
611
584
  },
612
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" },
632
+ },
633
+ required: ["id"],
634
+ },
635
+ },
613
636
  {
614
637
  name: "set_tag",
615
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.",
@@ -717,20 +740,6 @@ function uciMoveToSAN(startFen, uci) {
717
740
  return uci;
718
741
  }
719
742
  }
720
- // Rewrite the local /chess/database/analyse response (single engine)
721
- // so PVs come back in SAN.
722
- function convertAnalyseResponse(raw, startFen) {
723
- if (!raw || typeof raw !== "object")
724
- return raw;
725
- const r = raw;
726
- if (Array.isArray(r.lines)) {
727
- for (const line of r.lines) {
728
- if (Array.isArray(line.pv))
729
- line.pv = uciLineToSAN(startFen, line.pv);
730
- }
731
- }
732
- return raw;
733
- }
734
743
  // Rewrite the /api/agent/cloud-engines/analyse response (two engines,
735
744
  // each with lines[] and a bestMove) so PVs and bestMove come back in SAN.
736
745
  function convertCloudSnapshotResponse(raw, startFen) {
@@ -767,6 +776,169 @@ function argPath(args) {
767
776
  }
768
777
  return out;
769
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")
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
+ }
770
942
  // Load-mutate-save: fetch current PGN, parse, apply mutation, re-export,
771
943
  // save with optimistic lock. Auto-saves so every tool call is atomic;
772
944
  // the LLM never sees intermediate state.
@@ -922,16 +1094,6 @@ async function callToolInner(name, args) {
922
1094
  });
923
1095
  return convertAvailableMovesToSAN(raw, fen);
924
1096
  }
925
- case "analyse": {
926
- const fen = resolveFenFromArgs(args);
927
- const params = { fen };
928
- if (typeof args.movetime_ms === "number")
929
- params.movetime_ms = args.movetime_ms;
930
- if (typeof args.multipv === "number")
931
- params.multipv = args.multipv;
932
- const raw = await get("/api/chess/database/analyse", params);
933
- return convertAnalyseResponse(raw, fen);
934
- }
935
1097
  case "predict_human_move": {
936
1098
  const fen = resolveFenFromArgs(args);
937
1099
  const qs = new URLSearchParams();
@@ -1032,6 +1194,10 @@ async function callToolInner(name, args) {
1032
1194
  file: setTag(file, String(args.key), String(args.value ?? "")),
1033
1195
  path: [],
1034
1196
  }));
1197
+ case "apply_mutations":
1198
+ return applyBatchMutations(args);
1199
+ case "auto_evaluate":
1200
+ return autoEvaluate(args);
1035
1201
  case "read_engine_usage_guide":
1036
1202
  return { guide: ENGINE_USAGE_DOC };
1037
1203
  case "read_prep_strategy_guide":
@@ -16,29 +16,54 @@ Nodes are addressed by **path**: an array of child indices from the root.
16
16
 
17
17
  ## Workflow
18
18
 
19
- The mutation tool set is:
19
+ Reading:
20
20
 
21
21
  - `read_prep_file(id)` → returns `{version, tags, tree}`. Every node in `tree` has `san`, `fen`, `ply`, optional `comment`, `nags`, `annotations`, and `children`.
22
- - `add_move(id, path, san)` — appends a new child of `path`. If `path` already has children, the new node becomes a variation. Returns the effective path of the new node.
23
- - `set_comment(id, path, comment)` replace comment. Empty string clears.
24
- - `set_nags(id, path, nags)` — replace NAG list. Empty array clears.
25
- - `set_annotations(id, path, {arrows, highlights})` replace visual annotations. Empty arrays clear.
26
- - `delete_subtree(id, path)`remove node + descendants. Refuses to delete the root.
22
+
23
+ Building (use theseone call for many ops):
24
+
25
+ - **`apply_mutations(id, mutations[])`** — batch. This is the primary build tool. Send 100 mutations in one call → one load-parse-mutate-save cycle instead of 100. When you're writing a repertoire from scratch, EVERYTHING should go through this. Individual mutation tools are for surgical follow-up edits, not for bulk work.
26
+ - **`auto_evaluate(id, path?)`**walk the tree from `path` and auto-assign the right NAG to every node by running cloud_analyse on each position. Standard threshold table (below). Costs real engine time but frees you from hand-annotating evals. Skip nodes that already have a NAG by default.
27
+
28
+ Per-op mutation tools (single-op calls, use for edits after the bulk build):
29
+
30
+ - `add_move(id, path, san)` — appends a new child. If `path` has children, new node becomes a variation.
31
+ - `set_comment(id, path, comment)` — replace comment (empty string clears).
32
+ - `set_nags(id, path, nags)` — replace NAGs (empty array clears).
33
+ - `set_annotations(id, path, {arrows, highlights})` — replace visual annotations.
34
+ - `delete_subtree(id, path)` — remove node + descendants.
27
35
  - `promote_variation(id, path)` — make the node at `path` its parent's mainline.
28
- - `set_tag(id, key, value)` — set/clear a game-level tag.
36
+ - `set_tag(id, key, value)` — set/clear a game-level PGN tag.
29
37
 
30
- Every mutation **auto-saves** with optimistic locking. Response is `{ok: true, path, version}`. Pass the returned `version` as `expected_version` on your next mutation to catch concurrent edits.
38
+ All mutations **auto-save** with optimistic locking. Response includes the new `version`; pass it as `expected_version` on your next call to catch concurrent edits.
31
39
 
32
40
  ## Typical build order
33
41
 
34
42
  1. `read_prep_file` — see what's there.
35
- 2. `add_move` to extend the mainline: `add_move(id, [], "e4")` then `add_move(id, [0], "c5")` etc.
36
- 3. `add_move` for a variation at some node: `add_move(id, [0, 0], "Nc3")` adds Nc3 as a sibling of the current mainline's move 2.
37
- 4. `set_comment` for plans / prep-signal, `set_nags` for evaluations, `set_annotations` for arrows and squares always on the node the annotation belongs to.
38
- 5. `promote_variation` if a variation is more important than the current mainline.
39
- 6. `delete_subtree` to prune.
43
+ 2. `apply_mutations([...])` one call with your whole intended build (all `add_move` for the moves and variations, plus any `set_comment`/`set_annotations` you already know at author time). Skip `set_nags` — auto_evaluate handles that.
44
+ 3. `auto_evaluate(id)` walk the tree, get engine evals, assign NAGs. One shot.
45
+ 4. Individual mutation tools ONLY for surgical follow-ups (fix one comment, add one arrow, promote a specific variation, prune a branch).
46
+
47
+ The build-cost math: a 200-move file via individual `add_move` calls is 200 saves ≈ 100+ seconds of tool overhead. The same file via one `apply_mutations` call is one save ≈ 500ms. Use batch by default.
48
+
49
+ ### Path math in a batch
50
+
51
+ Paths in each mutation resolve against the tree AFTER previous ops in the batch. When you `add_move(path=[0], san='c5')`, the new c5 lands at `[0, 0]` if [0] had no children, or `[0, N]` if it had N. Your next mutation in the batch can address the new node directly. The tree is deterministic — you can compute all paths ahead of time.
40
52
 
41
- Every step returns the new `version`; carry it forward.
53
+ For a linear mainline build from an empty file:
54
+ ```
55
+ {op: "add_move", path: [], san: "e4"} // lands at [0]
56
+ {op: "add_move", path: [0], san: "c5"} // lands at [0, 0]
57
+ {op: "add_move", path: [0, 0], san: "Nf3"} // lands at [0, 0, 0]
58
+ {op: "add_move", path: [0, 0, 0], san: "d6"} // lands at [0, 0, 0, 0]
59
+ ...
60
+ ```
61
+
62
+ For a variation at some point:
63
+ ```
64
+ {op: "add_move", path: [0, 0], san: "Nc3"} // variation branching after move 2; lands at [0, 0, 1]
65
+ {op: "add_move", path: [0, 0, 1], san: "d5"} // continue the variation; lands at [0, 0, 1, 0]
66
+ ```
42
67
 
43
68
  ## Variations vs prose
44
69
 
@@ -56,6 +81,7 @@ Prose is NEVER for:
56
81
  - Move sequences ("then Nf3, then Bg5, ...") — use variations.
57
82
  - Move recommendations ("here White should play h4") — add_move it.
58
83
  - Restating the eval a NAG already conveys.
84
+ - **Describing a sibling variation you already added as a branch.** If move A has variation B added as `add_move(A, sibling)`, don't ALSO write `{if B then ...}` on A. The reader clicks B on the board — the branch is already there.
59
85
 
60
86
  ## Move-judgment symbols (NAGs)
61
87
 
@@ -96,9 +122,14 @@ Engine numbers go into the NAG, not into prose. Convert the eval to the correct
96
122
  | `\|eval\| ≥ 1.3` | `$18` (+−) | `$19` (−+) |
97
123
  | Sharp, hard to evaluate | `$13` (∞) | `$13` (∞) |
98
124
 
99
- **Don't paste raw engine numbers into comments.** `{Stockfish gives +0.35}` is noise `$14` is the signal. Same for `{Lc0 says +0.15}`.
125
+ **Prefer auto_evaluate for the NAG.** Don't hand-write NAGs when building call `auto_evaluate(id)` after your build and it assigns the right glyph to every node from actual engine analysis. Manual `set_nags` is for overrides (mark a novelty with $146, flag a `$5 !?` piece sac).
126
+
127
+ **Engine numbers in prose: brief only.** If you must reference a number, keep it compact:
128
+
129
+ - Good: `{... +0.2}` — bare number at the end of a note
130
+ - Bad: `{Stockfish depth 22 shows +0.25, Lc0 depth 18 shows +0.4, both engines agree}` — verbose noise
100
131
 
101
- **Don't name the engine unless it adds signal.** Naming Stockfish adds nothing to a `$14` on a routine position. Naming it makes sense when there's a mismatch worth flagging: `$14 {Human predictor gives 47% win at 2200 vs 2600the Elo gap does the practical work.}`
132
+ **Don't name the engine unless it adds signal.** "SF: +0.15" for a routine position is noise. Name it when there's a mismatch worth flagging: `{Human predictor gives Black 47% win despite the -0.35 objective eval — Elo gap does the work.}`
102
133
 
103
134
  ### When prose ADDS to the NAG
104
135
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chessceo/mcp",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Model Context Protocol server for chess.ceo — 11.7M+ games, ~1.5M FIDE player profiles, opening preparation, live broadcasts.",
5
5
  "type": "module",
6
6
  "bin": {