@chessceo/mcp 0.24.0 → 0.29.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
@@ -14,7 +14,8 @@ import { dirname, join } from "node:path";
14
14
  import { Chess } from "chess.js";
15
15
  import { parsePGN } from "./pgn/parser.js";
16
16
  import { exportPGN } from "./pgn/exporter.js";
17
- import { addMove, deleteSubtree, MutationError, promoteVariation, setAnnotations, setComment, setNags, setTag, } from "./pgn/mutations.js";
17
+ import { describePosition } from "./pgn/describe.js";
18
+ import { addMove, deleteSubtree, MutationError, promoteVariation, setAnnotations, setCeoEval, setComment, setNags, setTag, } from "./pgn/mutations.js";
18
19
  import { PathError } from "./pgn/paths.js";
19
20
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
20
21
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -188,6 +189,7 @@ const TOOLS = [
188
189
  name: "get_player_preparation",
189
190
  description: "For a given player, colour and starting position, return both the moves the player actually chose (frequency + win rate) and the underlying games. Position is specified either as a move sequence in SAN (`line`) or a raw FEN. Use `line` iteratively to walk the opening tree: call once with empty `line`, pick a move, call again with `line` extended by that move, etc.\n\n" +
190
191
  "GROUNDING: every claim about the opponent's repertoire must trace back to this tool's output. Don't assert 'they play sharply' or 'they hate isolated queen pawn' without pointing at the actual game counts / win rates in the response. Don't invent 'the opponent typically plays X' — check first. Compute is cheap: run this on more branches instead of pattern-matching from a chess book.\n\n" +
192
+ "AUTO-EVAL: if a cloud combo instance is running, the response includes `.eval` with a compact Stockfish + Lc0 read at the requested position (top move + score + PV) and the corresponding NAG. Do NOT fire a separate cloud_analyse for the same FEN — the eval is right there.\n\n" +
191
193
  "Reading the response — CRITICAL:\n" +
192
194
  "• Win % is one weight, not a verdict. Recommend 1.b3 over 1.d4 because 60% > 50% is wrong. Sample size matters (3 games at 66% is noise; 300 at 55% is signal); avgWhite / avgBlack per move show the rating context (a big score often means a rating gap, not repertoire truth).\n" +
193
195
  "• Prep is symmetric information — both sides see the same history. Assume the opponent knows the weakness you spotted; a weak opponent won't patch it, a strong or improving one already has (but structural weaknesses like 'bad in Catalan structures' hold anyway).\n" +
@@ -229,7 +231,12 @@ const TOOLS = [
229
231
  },
230
232
  {
231
233
  name: "get_position_stats",
232
- description: "Move statistics from all 11.7M+ indexed games at a given position — game counts, win percentages, top continuations. Answers 'from position P, how often does White play 4. O-O vs 4. d3, and which scores better'.",
234
+ description: "Move statistics + example games at a position. Answers 'how often is 4.O-O vs 4.d3 played here and which scores better'.\n\n" +
235
+ "SOURCE (default: `gm-classical`) selects a pre-aggregated database shard:\n" +
236
+ "- `gm-classical` — GM classical games (both players ≥2500, real thinking-time). BEST for opening prep — every move is signal, avgElo ~2600 across all listed moves.\n" +
237
+ "- `main` — the whole 11.7M-game DB. Widest coverage but noisiest (includes 1000-Elo blunder-fests in the move stats). Use as fallback when gm-classical's totalCount is too small to be informative.\n\n" +
238
+ "Game movetext is trimmed to the moves AFTER the queried position (using each game's plyNumber). Saves ~70% of the bytes vs full movetext.\n\n" +
239
+ "AUTO-EVAL: if a cloud combo instance is running, the response includes `.eval` with a compact Stockfish + Lc0 read and the corresponding NAG. Do NOT fire cloud_analyse separately for the same FEN.",
233
240
  inputSchema: {
234
241
  type: "object",
235
242
  properties: {
@@ -249,8 +256,27 @@ const TOOLS = [
249
256
  type: "integer",
250
257
  minimum: 1,
251
258
  maximum: 50,
252
- description: "Number of top continuations to return.",
259
+ description: "Number of example games to return (default 10).",
253
260
  },
261
+ source: {
262
+ type: "string",
263
+ enum: ["gm-classical", "main"],
264
+ description: "Which database shard to query. Default `gm-classical`. Switch to `main` only when gm-classical's totalCount is too low.",
265
+ },
266
+ },
267
+ },
268
+ },
269
+ {
270
+ name: "describe_position",
271
+ 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" +
272
+ "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" +
273
+ "ALSO USE THIS if `add_move` rejects an illegal SAN — the `.legalMoves` array shows exactly what's playable from that position.\n\n" +
274
+ "Position input is flexible — pass `fen`, or `moves` from startpos, or `fen + moves` to walk from there.",
275
+ inputSchema: {
276
+ type: "object",
277
+ properties: {
278
+ fen: { type: "string", description: "Starting position as FEN (defaults to startpos)." },
279
+ moves: { type: "string", description: "Optional SAN moves to apply on top of `fen`." },
254
280
  },
255
281
  },
256
282
  },
@@ -669,7 +695,8 @@ const TOOLS = [
669
695
  },
670
696
  {
671
697
  name: "prep_snapshot",
672
- description: "One call, three parallel fetches at the same position: opponent's stats on their side, your stats on your side, and the 11.7M-game general database at that position. Use this while walking the opening tree — one round trip instead of three separate calls, and you can compare the three views directly (e.g. opponent has 2 games here but the general DB has 8k → prep candidate).",
698
+ description: "One call, three parallel fetches at the same position: opponent's stats on their side, your stats on your side, and the 11.7M-game general database at that position. Use this while walking the opening tree — one round trip instead of three separate calls, and you can compare the three views directly (e.g. opponent has 2 games here but the general DB has 8k → prep candidate).\n\n" +
699
+ "AUTO-EVAL: if a cloud combo instance is running, the response includes a top-level `.eval` (Stockfish + Lc0 read at the shared position) so you get four signals in one call. Do NOT fire cloud_analyse separately for the same FEN.",
673
700
  inputSchema: {
674
701
  type: "object",
675
702
  properties: {
@@ -740,6 +767,17 @@ function uciMoveToSAN(startFen, uci) {
740
767
  return uci;
741
768
  }
742
769
  }
770
+ async function fetchCompactEval(fen) {
771
+ try {
772
+ const raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen, movetime_ms: 1500, multipv: 1 });
773
+ const converted = convertCloudSnapshotResponse(raw, fen);
774
+ const stored = analysisToStoredEval(converted, fen);
775
+ return storedEvalToCompact(stored, converted);
776
+ }
777
+ catch {
778
+ return null;
779
+ }
780
+ }
743
781
  // Rewrite the /api/agent/cloud-engines/analyse response (two engines,
744
782
  // each with lines[] and a bestMove) so PVs and bestMove come back in SAN.
745
783
  function convertCloudSnapshotResponse(raw, startFen) {
@@ -794,6 +832,10 @@ function dispatchMutation(file, op) {
794
832
  const ann = arrows.length === 0 && highlights.length === 0 ? null : { arrows, highlights };
795
833
  return setAnnotations(file, path, ann);
796
834
  }
835
+ case "set_ceo_eval": {
836
+ const ev = op.ceoEval;
837
+ return setCeoEval(file, path, ev ?? null);
838
+ }
797
839
  case "delete_subtree":
798
840
  return deleteSubtree(file, path);
799
841
  case "promote_variation":
@@ -839,8 +881,10 @@ async function applyBatchMutations(args) {
839
881
  return { ok: true, results, version: savedRow.version };
840
882
  }
841
883
  // 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.
884
+ // stash the compact per-engine eval in the node's `ceoEval` field (which
885
+ // survives across sessions and appears on every read_prep_file), and
886
+ // derive the NAG from the SF score. Both writes go via a single batch
887
+ // mutation at the end so a 200-node evaluate is one save.
844
888
  async function autoEvaluate(args) {
845
889
  const id = String(args.id);
846
890
  const startPath = Array.isArray(args.path) ? args.path : [];
@@ -854,7 +898,7 @@ async function autoEvaluate(args) {
854
898
  const targets = [];
855
899
  const walk = (node, path) => {
856
900
  if (path.length > 0) {
857
- if (!onlyMissing || !(node.nags && node.nags.length > 0)) {
901
+ if (!onlyMissing || !node.ceoEval) {
858
902
  targets.push({ path, fen: node.fen });
859
903
  }
860
904
  }
@@ -866,8 +910,8 @@ async function autoEvaluate(args) {
866
910
  if (targets.length === 0)
867
911
  return { ok: true, evaluated: 0, skipped: 0, version: g.version };
868
912
  // 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 = [];
913
+ // over-saturate the engine-ws connection cap (single-client per port).
914
+ const stored = [];
871
915
  const CONCURRENCY = 4;
872
916
  let cursor = 0;
873
917
  async function worker() {
@@ -876,23 +920,27 @@ async function autoEvaluate(args) {
876
920
  const t = targets[idx];
877
921
  try {
878
922
  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 });
923
+ const ev = analysisToStoredEval(analysis, t.fen);
924
+ if (ev)
925
+ stored.push({ path: t.path, ev });
882
926
  }
883
927
  catch {
884
- // ignore per-node failures best-effort auto-annotation
928
+ // best-effort — a single failed node shouldn't kill the batch
885
929
  }
886
930
  }
887
931
  }
888
932
  await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
889
- if (nags.length === 0)
933
+ if (stored.length === 0)
890
934
  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] }));
935
+ const batchMutations = [];
936
+ for (const s of stored) {
937
+ batchMutations.push({ op: "set_ceo_eval", path: s.path, ceoEval: s.ev });
938
+ if (s.ev.nag)
939
+ batchMutations.push({ op: "set_nags", path: s.path, nags: [s.ev.nag] });
940
+ }
893
941
  const saveResult = await applyBatchMutations({ id, mutations: batchMutations, expected_version: g.version });
894
942
  const sr = saveResult;
895
- return { ok: true, evaluated: nags.length, skipped: targets.length - nags.length, version: sr.version };
943
+ return { ok: true, evaluated: stored.length, skipped: targets.length - stored.length, version: sr.version };
896
944
  }
897
945
  // Walk chess.js-free: resolve a path against a tree, throw if invalid.
898
946
  function pathIntoTree(root, path) {
@@ -905,39 +953,81 @@ function pathIntoTree(root, path) {
905
953
  }
906
954
  return cur;
907
955
  }
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) {
956
+ // Read the cloud analyse response and build a StoredEval (compact form —
957
+ // no PVs, White-POV cp / mate, depth, and derived NAG). Returns null if
958
+ // there's no usable Stockfish signal (SF is the source of truth for
959
+ // the NAG per docs).
960
+ function analysisToStoredEval(analysis, fen) {
912
961
  if (!analysis || typeof analysis !== "object")
913
962
  return null;
914
963
  const r = analysis;
915
- const line = r.stockfish?.lines?.[0];
916
- if (!line)
964
+ const whiteToMove = / w /.test(fen);
965
+ const flip = (n) => (whiteToMove ? n : -n);
966
+ const engineEval = (block) => {
967
+ const line = block?.lines?.[0];
968
+ if (!line)
969
+ return undefined;
970
+ const depth = line.depth ?? block?.depth;
971
+ if (typeof line.mate === "number")
972
+ return { mate: flip(line.mate), depth };
973
+ if (typeof line.scoreCp === "number")
974
+ return { cp: flip(line.scoreCp), depth };
975
+ return undefined;
976
+ };
977
+ const sf = engineEval(r.stockfish);
978
+ const lc0 = engineEval(r.lc0);
979
+ if (!sf && !lc0)
917
980
  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 {
981
+ const ev = {};
982
+ if (sf)
983
+ ev.sf = sf;
984
+ if (lc0)
985
+ ev.lc0 = lc0;
986
+ ev.nag = nagFromCp(sf?.cp, sf?.mate) ?? nagFromCp(lc0?.cp, lc0?.mate) ?? undefined;
987
+ return ev;
988
+ }
989
+ function nagFromCp(cp, mate) {
990
+ let effective;
991
+ if (typeof mate === "number")
992
+ effective = mate > 0 ? 10000 : -10000;
993
+ else if (typeof cp === "number")
994
+ effective = cp;
995
+ else
929
996
  return null;
930
- }
931
- if (!whiteToMove)
932
- cp = -cp;
933
- const abs = Math.abs(cp);
997
+ const abs = Math.abs(effective);
934
998
  if (abs < 25)
935
999
  return "$10";
936
1000
  if (abs < 60)
937
- return cp > 0 ? "$14" : "$15";
1001
+ return effective > 0 ? "$14" : "$15";
938
1002
  if (abs < 130)
939
- return cp > 0 ? "$16" : "$17";
940
- return cp > 0 ? "$18" : "$19";
1003
+ return effective > 0 ? "$16" : "$17";
1004
+ return effective > 0 ? "$18" : "$19";
1005
+ }
1006
+ // Adapter for the compact eval attached to live query responses. Same
1007
+ // derivation logic; different output shape (needs the .nag + summary
1008
+ // used by get_position_stats / prep_snapshot).
1009
+ function storedEvalToCompact(ev, analysis) {
1010
+ if (!ev)
1011
+ return null;
1012
+ const a = analysis;
1013
+ const compact = { nag: ev.nag ?? null };
1014
+ if (ev.sf) {
1015
+ compact.stockfish = {
1016
+ cp: ev.sf.cp,
1017
+ mate: ev.sf.mate,
1018
+ bestMove: a.stockfish?.bestMove,
1019
+ pv: a.stockfish?.lines?.[0]?.pv,
1020
+ };
1021
+ }
1022
+ if (ev.lc0) {
1023
+ compact.lc0 = {
1024
+ cp: ev.lc0.cp,
1025
+ mate: ev.lc0.mate,
1026
+ bestMove: a.lc0?.bestMove,
1027
+ pv: a.lc0?.lines?.[0]?.pv,
1028
+ };
1029
+ }
1030
+ return compact;
941
1031
  }
942
1032
  // Load-mutate-save: fetch current PGN, parse, apply mutation, re-export,
943
1033
  // save with optimistic lock. Auto-saves so every tool call is atomic;
@@ -972,6 +1062,62 @@ async function applyMutation(args, mutator) {
972
1062
  version: savedRow.version,
973
1063
  };
974
1064
  }
1065
+ // Trim every game's `moves` field to just the plies AFTER the queried
1066
+ // position, using each game's `plyNumber`. Massive token save — a game
1067
+ // 80 plies long queried at ply 12 drops to ~68 plies of movetext. Ports
1068
+ // the frontend's GamesTable.getMoveDisplay() trim logic.
1069
+ function trimGamesMovetext(response) {
1070
+ if (!response || typeof response !== "object")
1071
+ return;
1072
+ const r = response;
1073
+ if (!Array.isArray(r.games))
1074
+ return;
1075
+ for (const g of r.games) {
1076
+ if (typeof g.moves === "string" && typeof g.plyNumber === "number" && g.plyNumber > 0) {
1077
+ g.moves = trimMovesToPly(g.moves, g.plyNumber);
1078
+ }
1079
+ }
1080
+ }
1081
+ function trimMovesToPly(moves, plyNumber) {
1082
+ // Split into plain SAN tokens, dropping standalone move-number tokens
1083
+ // ("1.", "12...") and any glued number prefix on a SAN token ("1.e4").
1084
+ // Result markers ("*", "1-0", "0-1", "1/2-1/2") are stripped so they
1085
+ // don't get counted as plies.
1086
+ const tokens = [];
1087
+ for (const chunk of moves.split(/\s+/)) {
1088
+ if (!chunk)
1089
+ continue;
1090
+ const cleaned = chunk.replace(/^\d+\.+/, "");
1091
+ if (!cleaned)
1092
+ continue;
1093
+ if (/^(1-0|0-1|1\/2-1\/2|\*)$/.test(cleaned))
1094
+ continue;
1095
+ tokens.push(cleaned);
1096
+ }
1097
+ const remaining = tokens.slice(plyNumber);
1098
+ if (remaining.length === 0)
1099
+ return "";
1100
+ // Reconstruct with move numbering. First move gets "N..." if it's
1101
+ // Black's move (starting the slice mid-move-pair), so the reader knows
1102
+ // moves were dropped.
1103
+ const out = [];
1104
+ let ply = plyNumber;
1105
+ for (let i = 0; i < remaining.length; i++) {
1106
+ const san = remaining[i];
1107
+ const moveNumber = Math.floor(ply / 2) + 1;
1108
+ if (ply % 2 === 0) {
1109
+ out.push(`${moveNumber}. ${san}`);
1110
+ }
1111
+ else if (i === 0) {
1112
+ out.push(`${moveNumber}... ${san}`);
1113
+ }
1114
+ else {
1115
+ out.push(san);
1116
+ }
1117
+ ply++;
1118
+ }
1119
+ return out.join(" ");
1120
+ }
975
1121
  // Rewrite availableMoves[].move UCI → SAN. The prep + position-stats
976
1122
  // endpoints return moves in UCI on the wire — same LLM-readability
977
1123
  // concern as engine PVs, and the same wrapper-only fix. Passes the
@@ -1082,18 +1228,42 @@ async function callToolInner(name, args) {
1082
1228
  params.limit = args.limit;
1083
1229
  if (typeof args.offset === "number")
1084
1230
  params.offset = args.offset;
1085
- const raw = await get("/api/chess/prep/by-player", params);
1086
- return convertAvailableMovesToSAN(raw, resolveFenFromArgs(args));
1231
+ const effectiveFen = resolveFenFromArgs(args);
1232
+ const [raw, ev] = await Promise.all([
1233
+ get("/api/chess/prep/by-player", params),
1234
+ fetchCompactEval(effectiveFen),
1235
+ ]);
1236
+ const converted = convertAvailableMovesToSAN(raw, effectiveFen);
1237
+ if (converted && typeof converted === "object") {
1238
+ trimGamesMovetext(converted);
1239
+ if (ev)
1240
+ converted.eval = ev;
1241
+ }
1242
+ return converted;
1087
1243
  }
1088
1244
  case "get_position_stats": {
1089
1245
  const fen = resolveFenFromArgs(args);
1090
- const raw = await get("/api/chess/database/main", {
1091
- fen,
1092
- limit: typeof args.limit === "number" ? args.limit : 20,
1093
- sort: "relevance",
1094
- });
1095
- return convertAvailableMovesToSAN(raw, fen);
1246
+ const rawSource = typeof args.source === "string" ? args.source : "gm-classical";
1247
+ const source = rawSource === "main" ? "main" : "gm-classical";
1248
+ const [raw, ev] = await Promise.all([
1249
+ get(`/api/chess/database/${source}`, {
1250
+ fen,
1251
+ limit: typeof args.limit === "number" ? args.limit : 10,
1252
+ sort: "relevance",
1253
+ }),
1254
+ fetchCompactEval(fen),
1255
+ ]);
1256
+ const converted = convertAvailableMovesToSAN(raw, fen);
1257
+ if (converted && typeof converted === "object") {
1258
+ trimGamesMovetext(converted);
1259
+ converted.source = source;
1260
+ if (ev)
1261
+ converted.eval = ev;
1262
+ }
1263
+ return converted;
1096
1264
  }
1265
+ case "describe_position":
1266
+ return describePosition(resolveFenFromArgs(args));
1097
1267
  case "predict_human_move": {
1098
1268
  const fen = resolveFenFromArgs(args);
1099
1269
  const qs = new URLSearchParams();
@@ -1241,13 +1411,15 @@ async function callToolInner(name, args) {
1241
1411
  compact: "true",
1242
1412
  ...(line.length > 0 ? { line } : { fen }),
1243
1413
  });
1244
- const [opponent, you, general] = await Promise.all([
1414
+ const [opponent, you, general, ev] = await Promise.all([
1245
1415
  get("/api/chess/prep/by-player", prepParams(opp, oppColor)),
1246
1416
  get("/api/chess/prep/by-player", prepParams(me, myColor)),
1247
1417
  get("/api/chess/database/main", { fen, limit: 20, sort: "relevance" }),
1418
+ fetchCompactEval(fen),
1248
1419
  ]);
1249
1420
  return {
1250
1421
  position: { line, fen, my_color: myColor },
1422
+ eval: ev,
1251
1423
  opponent: convertAvailableMovesToSAN(opponent, fen),
1252
1424
  you: convertAvailableMovesToSAN(you, fen),
1253
1425
  general: convertAvailableMovesToSAN(general, fen),
@@ -0,0 +1,221 @@
1
+ // describe_position — structured facts about a position that LLMs can't
2
+ // reliably read out of a FEN string on their own. Pure computation
3
+ // (chessops); no engine needed.
4
+ //
5
+ // Output covers piece placements, material balance in pawn units,
6
+ // attackers/defenders on every piece that is currently contested, a
7
+ // hanging list (attacked and undefended), checkers if in check, and
8
+ // castling / ep state. NOT covered: pins, discovered attacks, tactical
9
+ // patterns — those need the engine and are already available via
10
+ // cloud_analyse.
11
+ import { parseFen } from "chessops/fen";
12
+ import { Chess } from "chessops/chess";
13
+ import { attacks } from "chessops/attacks";
14
+ import { makeSquare, squareRank } from "chessops/util";
15
+ import { makeSan } from "chessops/san";
16
+ const ROLE_LETTER = {
17
+ pawn: "", knight: "N", bishop: "B", rook: "R", queen: "Q", king: "K",
18
+ };
19
+ // SAN convention: pawn is empty prefix. For inventory listings we use "P"
20
+ // so counts are readable.
21
+ const ROLE_LETTER_INV = {
22
+ pawn: "P", knight: "N", bishop: "B", rook: "R", queen: "Q", king: "K",
23
+ };
24
+ const ROLE_VALUE = {
25
+ pawn: 1, knight: 3, bishop: 3, rook: 5, queen: 9, king: 0,
26
+ };
27
+ function pieceSAN(piece, sq) {
28
+ return `${ROLE_LETTER[piece.role]}${makeSquare(sq)}`;
29
+ }
30
+ export function describePosition(fen) {
31
+ const setup = parseFen(fen);
32
+ if (setup.isErr)
33
+ throw new Error(`bad fen: ${setup.error}`);
34
+ const posResult = Chess.fromSetup(setup.value);
35
+ if (posResult.isErr)
36
+ throw new Error(`bad position: ${posResult.error}`);
37
+ const pos = posResult.value;
38
+ const board = pos.board;
39
+ const whitePieces = [];
40
+ const blackPieces = [];
41
+ const materialW = { P: 0, N: 0, B: 0, R: 0, Q: 0, K: 0 };
42
+ const materialB = { P: 0, N: 0, B: 0, R: 0, Q: 0, K: 0 };
43
+ for (const [sq, piece] of board) {
44
+ const invLetter = ROLE_LETTER_INV[piece.role];
45
+ const entry = { piece: invLetter, square: makeSquare(sq) };
46
+ if (piece.color === "white") {
47
+ whitePieces.push(entry);
48
+ materialW[invLetter]++;
49
+ }
50
+ else {
51
+ blackPieces.push(entry);
52
+ materialB[invLetter]++;
53
+ }
54
+ }
55
+ whitePieces.sort(pieceSortKey);
56
+ blackPieces.sort(pieceSortKey);
57
+ const wValue = pieceValueSum(materialW);
58
+ const bValue = pieceValueSum(materialB);
59
+ const diff = wValue - bValue;
60
+ const summary = diff === 0 ? `even (both ${wValue})` :
61
+ diff > 0 ? `white +${diff} (${wValue} vs ${bValue})` :
62
+ `black +${-diff} (${wValue} vs ${bValue})`;
63
+ // Contested + hanging computation: for every piece, find who attacks
64
+ // it and who defends it. attacks() returns the squares a piece
65
+ // controls given the occupied board — we invert it into "attackers-of"
66
+ // by iterating all pieces once and testing membership.
67
+ const contested = [];
68
+ const hangingWhite = [];
69
+ const hangingBlack = [];
70
+ for (const [sq, piece] of board) {
71
+ const both = attackersOf(pos, sq);
72
+ const oppSide = piece.color === "white" ? "black" : "white";
73
+ const attackers = both[oppSide];
74
+ const defenders = both[piece.color];
75
+ if (attackers.length === 0)
76
+ continue;
77
+ const targetName = pieceSAN(piece, sq);
78
+ contested.push({
79
+ target: targetName,
80
+ color: piece.color,
81
+ attackers: attackers.map(a => pieceSANFromBoard(board, a)),
82
+ defenders: defenders.map(d => pieceSANFromBoard(board, d)),
83
+ });
84
+ if (defenders.length === 0) {
85
+ if (piece.color === "white")
86
+ hangingWhite.push(targetName);
87
+ else
88
+ hangingBlack.push(targetName);
89
+ }
90
+ }
91
+ // Order contested by target square for stable output the LLM can
92
+ // scan.
93
+ contested.sort((a, b) => a.target.slice(-2).localeCompare(b.target.slice(-2)));
94
+ // Castling rights encoded in the standard FEN letters (KQkq).
95
+ const castlingW = castlingLetters(pos, "white");
96
+ const castlingB = castlingLetters(pos, "black");
97
+ const checkers = [];
98
+ const ctx = pos.ctx();
99
+ for (const sq of ctx.checkers)
100
+ checkers.push(pieceSANFromBoard(board, sq));
101
+ const legalMoves = generateLegalMoves(pos);
102
+ return {
103
+ fen,
104
+ sideToMove: pos.turn,
105
+ inCheck: pos.isCheck(),
106
+ checkers,
107
+ material: {
108
+ white: materialW,
109
+ black: materialB,
110
+ diff,
111
+ summary,
112
+ },
113
+ pieces: { white: whitePieces, black: blackPieces },
114
+ contested,
115
+ hanging: { white: hangingWhite, black: hangingBlack },
116
+ castling: { white: castlingW, black: castlingB },
117
+ epSquare: typeof pos.epSquare === "number" ? makeSquare(pos.epSquare) : null,
118
+ legalMoves,
119
+ };
120
+ }
121
+ // All legal moves for the side to move, in SAN. Sorted for stable output.
122
+ // Handles promotion by expanding each pawn-promotion destination into
123
+ // the four promotion pieces (=Q, =R, =B, =N).
124
+ function generateLegalMoves(pos) {
125
+ const out = [];
126
+ for (const [from, piece] of pos.board) {
127
+ if (piece.color !== pos.turn)
128
+ continue;
129
+ const dests = pos.dests(from);
130
+ for (const to of dests) {
131
+ const isPromotion = piece.role === "pawn" &&
132
+ ((piece.color === "white" && squareRank(to) === 7) ||
133
+ (piece.color === "black" && squareRank(to) === 0));
134
+ if (isPromotion) {
135
+ for (const promo of ["queen", "rook", "bishop", "knight"]) {
136
+ const san = makeSan(pos, { from, to, promotion: promo });
137
+ if (san)
138
+ out.push(san);
139
+ }
140
+ }
141
+ else {
142
+ const san = makeSan(pos, { from, to });
143
+ if (san)
144
+ out.push(san);
145
+ }
146
+ }
147
+ }
148
+ return out.sort();
149
+ }
150
+ // Attackers-of a square, split by colour. A piece cannot attack its own
151
+ // square, so we skip the target index. The `occupied` for sliding
152
+ // pieces is the whole board minus nothing — chessops's `attacks()`
153
+ // takes care of blockers.
154
+ function attackersOf(pos, target) {
155
+ const white = [];
156
+ const black = [];
157
+ for (const [sq, piece] of pos.board) {
158
+ if (sq === target)
159
+ continue;
160
+ const set = attacks(piece, sq, pos.board.occupied);
161
+ if (set.has(target)) {
162
+ if (piece.color === "white")
163
+ white.push(sq);
164
+ else
165
+ black.push(sq);
166
+ }
167
+ }
168
+ return { white, black };
169
+ }
170
+ function pieceSANFromBoard(board, sq) {
171
+ const piece = board.get(sq);
172
+ if (!piece)
173
+ return makeSquare(sq);
174
+ return pieceSAN(piece, sq);
175
+ }
176
+ // Sort: kings first, then queens, rooks, bishops, knights, pawns; within
177
+ // each type, by rank descending then file ascending. Matches the visual
178
+ // scan a human does.
179
+ const ROLE_ORDER = ["king", "queen", "rook", "bishop", "knight", "pawn"];
180
+ function pieceSortKey(a, b) {
181
+ const roleA = roleOfEntry(a);
182
+ const roleB = roleOfEntry(b);
183
+ const orderA = ROLE_ORDER.indexOf(roleA);
184
+ const orderB = ROLE_ORDER.indexOf(roleB);
185
+ if (orderA !== orderB)
186
+ return orderA - orderB;
187
+ return a.square.localeCompare(b.square);
188
+ }
189
+ function roleOfEntry(e) {
190
+ switch (e.piece) {
191
+ case "K": return "king";
192
+ case "Q": return "queen";
193
+ case "R": return "rook";
194
+ case "B": return "bishop";
195
+ case "N": return "knight";
196
+ default: return "pawn";
197
+ }
198
+ }
199
+ function pieceValueSum(m) {
200
+ return m.P * ROLE_VALUE.pawn +
201
+ m.N * ROLE_VALUE.knight +
202
+ m.B * ROLE_VALUE.bishop +
203
+ m.R * ROLE_VALUE.rook +
204
+ m.Q * ROLE_VALUE.queen;
205
+ }
206
+ // Convert chessops castling representation to the standard FEN-letter
207
+ // form for the given color: "K" (kingside), "Q" (queenside), "KQ",
208
+ // or "" if none remain.
209
+ function castlingLetters(pos, color) {
210
+ let out = "";
211
+ const castles = pos.castles;
212
+ // castles.rooksSideOf(color, 'a') and .rooksSideOf(color, 'h') return
213
+ // the rook square or undefined per side. Fall back to inspecting the
214
+ // stored castling squares — chessops's public API varies by version.
215
+ const rooks = castles.rook;
216
+ if (rooks?.[color]?.h !== undefined)
217
+ out += color === "white" ? "K" : "k";
218
+ if (rooks?.[color]?.a !== undefined)
219
+ out += color === "white" ? "Q" : "q";
220
+ return out;
221
+ }
@@ -106,23 +106,57 @@ function renderNagsAndComment(node) {
106
106
  }
107
107
  return bits.join(" ");
108
108
  }
109
- // Build the [%cal ...] / [%csl ...] fragments if this node has arrows
110
- // or highlighted squares. Returns "" if none.
109
+ // Build the [%cal ...] / [%csl ...] / [%ceo-eval ...] fragments if
110
+ // this node has any. Returns "" if none.
111
111
  function renderNodeAnnotation(node) {
112
- if (!node.annotations)
113
- return "";
114
112
  const bits = [];
115
- if (node.annotations.arrows.length > 0) {
116
- const entries = node.annotations.arrows
117
- .map(a => `${codeFromColor(a.color)}${a.from}${a.to}`)
118
- .join(",");
119
- bits.push(`[%cal ${entries}]`);
113
+ if (node.annotations) {
114
+ if (node.annotations.arrows.length > 0) {
115
+ const entries = node.annotations.arrows
116
+ .map(a => `${codeFromColor(a.color)}${a.from}${a.to}`)
117
+ .join(",");
118
+ bits.push(`[%cal ${entries}]`);
119
+ }
120
+ if (node.annotations.highlights.length > 0) {
121
+ const entries = node.annotations.highlights
122
+ .map(h => `${codeFromColor(h.color)}${h.square}`)
123
+ .join(",");
124
+ bits.push(`[%csl ${entries}]`);
125
+ }
120
126
  }
121
- if (node.annotations.highlights.length > 0) {
122
- const entries = node.annotations.highlights
123
- .map(h => `${codeFromColor(h.color)}${h.square}`)
124
- .join(",");
125
- bits.push(`[%csl ${entries}]`);
127
+ if (node.ceoEval) {
128
+ const s = renderCeoEval(node.ceoEval);
129
+ if (s)
130
+ bits.push(s);
126
131
  }
127
132
  return bits.join(" ");
128
133
  }
134
+ // Serialise a stored eval as `[%ceo-eval sf=+0.20/38 lc0=+0.35/24 nag=$14]`.
135
+ // Compact: no PVs (regenerable via cloud_analyse), decimal cp for
136
+ // readability, mate as `MN`/`-MN`, depth as `/N`.
137
+ function renderCeoEval(ev) {
138
+ const bits = [];
139
+ if (ev.sf)
140
+ bits.push(`sf=${formatEngineEval(ev.sf)}`);
141
+ if (ev.lc0)
142
+ bits.push(`lc0=${formatEngineEval(ev.lc0)}`);
143
+ if (ev.nag)
144
+ bits.push(`nag=${ev.nag}`);
145
+ return bits.length > 0 ? `[%ceo-eval ${bits.join(" ")}]` : "";
146
+ }
147
+ function formatEngineEval(e) {
148
+ let body;
149
+ if (typeof e.mate === "number") {
150
+ body = e.mate >= 0 ? `+M${e.mate}` : `M${e.mate}`; // "+M5" / "M-5"
151
+ }
152
+ else if (typeof e.cp === "number") {
153
+ const pawns = e.cp / 100;
154
+ body = pawns >= 0 ? `+${pawns.toFixed(2)}` : pawns.toFixed(2);
155
+ }
156
+ else {
157
+ body = "+0.00";
158
+ }
159
+ if (typeof e.depth === "number")
160
+ body += `/${e.depth}`;
161
+ return body;
162
+ }
@@ -108,6 +108,17 @@ export function promoteVariation(file, path) {
108
108
  parent.children = [promoted, ...rest];
109
109
  return { file: { tags: file.tags, root: newRoot }, path: [...parentPath, 0] };
110
110
  }
111
+ // Replace the stored ceoEval on the node at `path`. Passing null clears.
112
+ // This is what auto_evaluate calls per-node; also exposed as an op the
113
+ // LLM can invoke directly if it wants to overwrite a stale eval.
114
+ export function setCeoEval(file, path, ev) {
115
+ const { root: newRoot, target } = cloneOnPath(file.root, path);
116
+ if (ev === null || (!ev.sf && !ev.lc0 && !ev.nag))
117
+ delete target.ceoEval;
118
+ else
119
+ target.ceoEval = ev;
120
+ return { file: { tags: file.tags, root: newRoot }, path };
121
+ }
111
122
  // Set or clear a tag. Passing null / empty removes.
112
123
  export function setTag(file, key, value) {
113
124
  const cleanedKey = key.trim();
@@ -133,7 +144,7 @@ function validateSan(fen, san) {
133
144
  const board = pos.value;
134
145
  const move = parseSan(board, san);
135
146
  if (!move) {
136
- throw new MutationError(`illegal SAN "${san}" at fen "${fen}" check the position (side to move, piece disambiguation, promotion piece) and re-check the path.`);
147
+ throw new MutationError(`illegal SAN "${san}" at fen "${fen}". Call describe_position on this fen to see the full list of legal moves.`);
137
148
  }
138
149
  return move;
139
150
  }
@@ -13,8 +13,33 @@ import { opposite } from "chessops/util";
13
13
  import { colorFromCode, } from "./types.js";
14
14
  const CAL_RE = /\[%cal\s+([^\]]+)\]/g;
15
15
  const CSL_RE = /\[%csl\s+([^\]]+)\]/g;
16
- const ANY_CMD_RE = /\[%[a-z]+\s+[^\]]+\]/g;
17
- // Parse a comment string, splitting out visual annotations from prose.
16
+ const CEO_EVAL_RE = /\[%ceo-eval\s+([^\]]+)\]/g;
17
+ const ANY_CMD_RE = /\[%[a-z-]+\s+[^\]]+\]/g;
18
+ // Parse a `sf=+0.20/38` or `lc0=M-5/22` fragment into its numeric parts.
19
+ // Returns null if the value doesn't parse; the parser tolerates missing
20
+ // depth and mate notation.
21
+ function parseEngineEvalFragment(raw) {
22
+ const m = raw.match(/^([+-]?)(?:M(-?\d+)|(\d+(?:\.\d+)?))(?:\/(\d+))?$/i);
23
+ if (!m)
24
+ return null;
25
+ const sign = m[1] === "-" ? -1 : 1;
26
+ const out = {};
27
+ if (m[2] !== undefined) {
28
+ out.mate = sign * parseInt(m[2], 10);
29
+ }
30
+ else if (m[3] !== undefined) {
31
+ // pawn units → centipawns
32
+ out.cp = Math.round(sign * parseFloat(m[3]) * 100);
33
+ }
34
+ else {
35
+ return null;
36
+ }
37
+ if (m[4] !== undefined)
38
+ out.depth = parseInt(m[4], 10);
39
+ return out;
40
+ }
41
+ // Parse a comment string, splitting out visual annotations + stored
42
+ // evals from prose.
18
43
  export function parseCommentAnnotations(comment) {
19
44
  const arrows = [];
20
45
  const highlights = [];
@@ -32,9 +57,36 @@ export function parseCommentAnnotations(comment) {
32
57
  highlights.push({ color: colorFromCode(e[1]), square: e[2].toLowerCase() });
33
58
  }
34
59
  }
60
+ let ceoEval;
61
+ for (const m of comment.matchAll(CEO_EVAL_RE)) {
62
+ const ev = ceoEval ?? {};
63
+ for (const entry of m[1].trim().split(/\s+/)) {
64
+ const eq = entry.indexOf("=");
65
+ if (eq < 0)
66
+ continue;
67
+ const k = entry.slice(0, eq).toLowerCase();
68
+ const v = entry.slice(eq + 1);
69
+ if (k === "sf") {
70
+ const parsed = parseEngineEvalFragment(v);
71
+ if (parsed)
72
+ ev.sf = parsed;
73
+ }
74
+ else if (k === "lc0") {
75
+ const parsed = parseEngineEvalFragment(v);
76
+ if (parsed)
77
+ ev.lc0 = parsed;
78
+ }
79
+ else if (k === "nag") {
80
+ if (/^\$\d+$/.test(v))
81
+ ev.nag = v;
82
+ }
83
+ }
84
+ if (ev.sf || ev.lc0 || ev.nag)
85
+ ceoEval = ev;
86
+ }
35
87
  const text = comment.replace(ANY_CMD_RE, "").replace(/\s+/g, " ").trim();
36
88
  const annotations = arrows.length || highlights.length ? { arrows, highlights } : undefined;
37
- return { text, annotations };
89
+ return { text, annotations, ceoEval };
38
90
  }
39
91
  // Parse full PGN → PrepFile. Throws on unrecoverable errors (no games,
40
92
  // invalid starting FEN). Illegal SAN moves in the movetext are skipped
@@ -62,6 +114,8 @@ export function parsePGN(pgn) {
62
114
  root.comment = parsed.text;
63
115
  if (parsed.annotations)
64
116
  root.annotations = parsed.annotations;
117
+ if (parsed.ceoEval)
118
+ root.ceoEval = parsed.ceoEval;
65
119
  }
66
120
  const build = (childNode, treeParent, pos, ply) => {
67
121
  if (!childNode.data || typeof childNode.data.san !== "string")
@@ -97,6 +151,8 @@ export function parsePGN(pgn) {
97
151
  node.comment = parsed.text;
98
152
  if (parsed.annotations)
99
153
  node.annotations = parsed.annotations;
154
+ if (parsed.ceoEval)
155
+ node.ceoEval = parsed.ceoEval;
100
156
  }
101
157
  if (Array.isArray(childNode.data.nags) && childNode.data.nags.length > 0) {
102
158
  node.nags = childNode.data.nags.map((n) => `$${n}`);
@@ -124,6 +124,8 @@ Engine numbers go into the NAG, not into prose. Convert the eval to the correct
124
124
 
125
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
126
 
127
+ **Persisted evals travel with the file.** Every node with an eval gets `ceoEval: { sf: {cp: 25, depth: 32}, lc0: {cp: 30, depth: 18}, nag: "$14" }` on subsequent `read_prep_file` calls. Stored as `[%ceo-eval sf=+0.25/32 lc0=+0.30/18 nag=$14]` inside the PGN comment (an escape tag the app hides from the board view, just like [%cal] / [%csl]). So a re-read after auto_evaluate gives you every position's number without re-running cloud_analyse. Query tools (get_position_stats, get_player_preparation, prep_snapshot) also auto-attach a live `eval` at the request position when a cloud engine is running.
128
+
127
129
  **Engine numbers in prose: brief only.** If you must reference a number, keep it compact:
128
130
 
129
131
  - Good: `{... +0.2}` — bare number at the end of a note
@@ -131,6 +133,18 @@ Engine numbers go into the NAG, not into prose. Convert the eval to the correct
131
133
 
132
134
  **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.}`
133
135
 
136
+ ### Before you write any commentary: describe_position
137
+
138
+ LLMs are not reliable at reading FEN strings — you'll swap files/ranks, invent captures, miscount pieces. Before you write a comment describing what's happening in a position, call `describe_position(fen)`. It's pure computation (no engine, ~1ms) and returns the same board state a human sees:
139
+
140
+ - All piece placements per colour (SAN-style: `Nf3`, `Bg7`, `e4`)
141
+ - Material balance in pawn units (`"white +1 (39 vs 38)"`)
142
+ - Every contested piece: attackers + defenders (e.g. `{target: "e5", color: "black", attackers: ["Nf3"], defenders: ["Nc6"]}`)
143
+ - Hanging list — attacked and undefended
144
+ - Check state + checkers, castling rights, en passant
145
+
146
+ Use it whenever you're about to describe a position from memory or from your reading of a FEN. The failure mode this prevents: `{Black's queen on c7 is defended by the knight on d5.}` when actually there's no knight on d5 and the queen is on c8.
147
+
134
148
  ### When prose ADDS to the NAG
135
149
 
136
150
  Prose is worth writing when the NAG *understates* something the human should know:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chessceo/mcp",
3
- "version": "0.24.0",
3
+ "version": "0.29.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": {