@chessceo/mcp 0.26.0 → 0.30.2

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,6 +14,7 @@ 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 { describePosition } from "./pgn/describe.js";
17
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";
@@ -192,7 +193,7 @@ const TOOLS = [
192
193
  "Reading the response — CRITICAL:\n" +
193
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" +
194
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" +
195
- "• Recency > career. The last 12-24 months dominate. This endpoint's compact/LLM view deliberately omits per-move `hotness` — at the individual level it's trailing noise. The general DB endpoint keeps it (there it's fashion signal).\n" +
196
+ "• Recency > career. The last 12-24 months dominate. This endpoint's compact/LLM view deliberately omits per-move `fashionScore` — at the individual level it's trailing noise. The general DB endpoint (`get_position_stats`) keeps it, where it's real fashion signal (what the top field is playing this month).\n" +
196
197
  "• Opponent will deviate early. Prep is a tree, not a line — cover the 2 most likely replies at each real branching point, not one 20-move line.\n" +
197
198
  "• Surprise is a scalpel. Don't tell a lifelong 1.e4 player to switch to 1.d4 — meta-signal screams prep. Rare secondary lines within the user's existing repertoire (e.g. 6.Bc4 instead of usual 6.Bg5 vs the Najdorf) are where surprise is real.\n\n" +
198
199
  "For the full guide call the `read_prep_strategy_guide` tool.",
@@ -230,8 +231,12 @@ const TOOLS = [
230
231
  },
231
232
  {
232
233
  name: "get_position_stats",
233
- 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'.\n\n" +
234
- "AUTO-EVAL: if a cloud combo instance is running, the response includes `.eval` with a compact Stockfish + Lc0 read (top move + score + PV) and the corresponding NAG. Do NOT fire a separate cloud_analyse for the same FEN — the eval is right there.",
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.",
235
240
  inputSchema: {
236
241
  type: "object",
237
242
  properties: {
@@ -251,8 +256,27 @@ const TOOLS = [
251
256
  type: "integer",
252
257
  minimum: 1,
253
258
  maximum: 50,
254
- description: "Number of top continuations to return.",
259
+ description: "Number of example games to return (default 10).",
255
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`." },
256
280
  },
257
281
  },
258
282
  },
@@ -620,15 +644,16 @@ const TOOLS = [
620
644
  },
621
645
  {
622
646
  name: "auto_evaluate",
623
- 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" +
624
- "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" +
625
- "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.",
647
+ description: "Walk the tree from `path` (default `[]` = whole file) and populate the persistent `ceoEval` (Stockfish + Lc0 numbers) on every node via cloud_analyse. Requires a running cloud combo instance.\n\n" +
648
+ "**Does NOT set visible NAGs.** NAG placement is your call, not the engine's an opening tree full of 0.00 positions doesn't need a `$10` (=) glyph on every move (that's just noise on the board), and a `!` on a novelty or a `?!` on a risky committal is a judgment call the engine can't make. Use this tool to persist the raw numbers on every node, then re-read the file and hand-pick NAGs on the moves where a glyph carries real signal (novelty, sharp choice, real mistake, decisive advantage).\n\n" +
649
+ "On re-read every node with a stored eval carries `ceoEval: { sf: {cp, depth}, lc0: {cp, depth} }` — the numbers travel with the file. Use `only_missing=true` (default) to skip nodes already evaluated on repeat runs.\n\n" +
650
+ "Costs real money — hits cloud_analyse per node. A 200-node tree at 1.5s/node = ~5 min of engine time. Runs 4 evaluations in parallel to save wall time.",
626
651
  inputSchema: {
627
652
  type: "object",
628
653
  properties: {
629
654
  id: { type: "string" },
630
655
  path: { type: "array", items: { type: "integer", minimum: 0 }, description: "Subtree root (default = whole file)." },
631
- only_missing: { type: "boolean", description: "Skip nodes that already carry a NAG (default true)." },
656
+ only_missing: { type: "boolean", description: "Skip nodes that already carry a stored ceoEval (default true)." },
632
657
  movetime_ms: { type: "integer", minimum: 500, maximum: 5000, description: "Per-node cloud_analyse think time (default 1500)." },
633
658
  expected_version: { type: "integer" },
634
659
  },
@@ -911,8 +936,6 @@ async function autoEvaluate(args) {
911
936
  const batchMutations = [];
912
937
  for (const s of stored) {
913
938
  batchMutations.push({ op: "set_ceo_eval", path: s.path, ceoEval: s.ev });
914
- if (s.ev.nag)
915
- batchMutations.push({ op: "set_nags", path: s.path, nags: [s.ev.nag] });
916
939
  }
917
940
  const saveResult = await applyBatchMutations({ id, mutations: batchMutations, expected_version: g.version });
918
941
  const sr = saveResult;
@@ -1038,6 +1061,103 @@ async function applyMutation(args, mutator) {
1038
1061
  version: savedRow.version,
1039
1062
  };
1040
1063
  }
1064
+ // Strip cruft the LLM doesn't need from the DB-position response.
1065
+ // Called AFTER trimGamesMovetext so plyNumber survives long enough to
1066
+ // slice each game's movetext. Also renames the `transpositions` field
1067
+ // to something the LLM can parse without knowing chess-DB jargon.
1068
+ function stripPositionResponse(r) {
1069
+ if (!r || typeof r !== "object")
1070
+ return;
1071
+ const t = r;
1072
+ delete t.hash; // internal zobrist string
1073
+ delete t.source; // internal "database" marker; we overwrite with our own .source
1074
+ delete t.totalGames; // duplicates statistics.totalCount often; hasMore covers pagination
1075
+ if (Array.isArray(t.moves)) {
1076
+ for (const m of t.moves) {
1077
+ if (typeof m.transpositions === "number") {
1078
+ m.reachedViaTransposition = m.transpositions;
1079
+ delete m.transpositions;
1080
+ }
1081
+ // Backend calls it "hotness" — a 0-100 time-decayed popularity score
1082
+ // (recent + played often = high). Rename to something an LLM can read
1083
+ // without guessing it means "on a winning streak".
1084
+ if (typeof m.hotness === "number") {
1085
+ m.fashionScore = m.hotness;
1086
+ delete m.hotness;
1087
+ }
1088
+ }
1089
+ }
1090
+ if (Array.isArray(t.games)) {
1091
+ for (const g of t.games) {
1092
+ delete g.gameId;
1093
+ delete g.whiteTitle;
1094
+ delete g.blackTitle;
1095
+ delete g.whiteTeam;
1096
+ delete g.blackTeam;
1097
+ delete g.round;
1098
+ delete g.plyNumber;
1099
+ delete g.relevance;
1100
+ delete g.site;
1101
+ delete g.ply;
1102
+ }
1103
+ }
1104
+ }
1105
+ // Trim every game's `moves` field to just the plies AFTER the queried
1106
+ // position, using each game's `plyNumber`. Massive token save — a game
1107
+ // 80 plies long queried at ply 12 drops to ~68 plies of movetext. Ports
1108
+ // the frontend's GamesTable.getMoveDisplay() trim logic.
1109
+ function trimGamesMovetext(response) {
1110
+ if (!response || typeof response !== "object")
1111
+ return;
1112
+ const r = response;
1113
+ if (!Array.isArray(r.games))
1114
+ return;
1115
+ for (const g of r.games) {
1116
+ if (typeof g.moves === "string" && typeof g.plyNumber === "number" && g.plyNumber > 0) {
1117
+ g.moves = trimMovesToPly(g.moves, g.plyNumber);
1118
+ }
1119
+ }
1120
+ }
1121
+ function trimMovesToPly(moves, plyNumber) {
1122
+ // Split into plain SAN tokens, dropping standalone move-number tokens
1123
+ // ("1.", "12...") and any glued number prefix on a SAN token ("1.e4").
1124
+ // Result markers ("*", "1-0", "0-1", "1/2-1/2") are stripped so they
1125
+ // don't get counted as plies.
1126
+ const tokens = [];
1127
+ for (const chunk of moves.split(/\s+/)) {
1128
+ if (!chunk)
1129
+ continue;
1130
+ const cleaned = chunk.replace(/^\d+\.+/, "");
1131
+ if (!cleaned)
1132
+ continue;
1133
+ if (/^(1-0|0-1|1\/2-1\/2|\*)$/.test(cleaned))
1134
+ continue;
1135
+ tokens.push(cleaned);
1136
+ }
1137
+ const remaining = tokens.slice(plyNumber);
1138
+ if (remaining.length === 0)
1139
+ return "";
1140
+ // Reconstruct with move numbering. First move gets "N..." if it's
1141
+ // Black's move (starting the slice mid-move-pair), so the reader knows
1142
+ // moves were dropped.
1143
+ const out = [];
1144
+ let ply = plyNumber;
1145
+ for (let i = 0; i < remaining.length; i++) {
1146
+ const san = remaining[i];
1147
+ const moveNumber = Math.floor(ply / 2) + 1;
1148
+ if (ply % 2 === 0) {
1149
+ out.push(`${moveNumber}. ${san}`);
1150
+ }
1151
+ else if (i === 0) {
1152
+ out.push(`${moveNumber}... ${san}`);
1153
+ }
1154
+ else {
1155
+ out.push(san);
1156
+ }
1157
+ ply++;
1158
+ }
1159
+ return out.join(" ");
1160
+ }
1041
1161
  // Rewrite availableMoves[].move UCI → SAN. The prep + position-stats
1042
1162
  // endpoints return moves in UCI on the wire — same LLM-readability
1043
1163
  // concern as engine PVs, and the same wrapper-only fix. Passes the
@@ -1154,25 +1274,38 @@ async function callToolInner(name, args) {
1154
1274
  fetchCompactEval(effectiveFen),
1155
1275
  ]);
1156
1276
  const converted = convertAvailableMovesToSAN(raw, effectiveFen);
1157
- if (ev && converted && typeof converted === "object")
1158
- converted.eval = ev;
1277
+ if (converted && typeof converted === "object") {
1278
+ trimGamesMovetext(converted);
1279
+ stripPositionResponse(converted);
1280
+ if (ev)
1281
+ converted.eval = ev;
1282
+ }
1159
1283
  return converted;
1160
1284
  }
1161
1285
  case "get_position_stats": {
1162
1286
  const fen = resolveFenFromArgs(args);
1287
+ const rawSource = typeof args.source === "string" ? args.source : "gm-classical";
1288
+ const source = rawSource === "main" ? "main" : "gm-classical";
1163
1289
  const [raw, ev] = await Promise.all([
1164
- get("/api/chess/database/main", {
1290
+ get(`/api/chess/database/${source}`, {
1165
1291
  fen,
1166
- limit: typeof args.limit === "number" ? args.limit : 20,
1292
+ limit: typeof args.limit === "number" ? args.limit : 10,
1167
1293
  sort: "relevance",
1168
1294
  }),
1169
1295
  fetchCompactEval(fen),
1170
1296
  ]);
1171
1297
  const converted = convertAvailableMovesToSAN(raw, fen);
1172
- if (ev && converted && typeof converted === "object")
1173
- converted.eval = ev;
1298
+ if (converted && typeof converted === "object") {
1299
+ trimGamesMovetext(converted);
1300
+ stripPositionResponse(converted);
1301
+ converted.source = source;
1302
+ if (ev)
1303
+ converted.eval = ev;
1304
+ }
1174
1305
  return converted;
1175
1306
  }
1307
+ case "describe_position":
1308
+ return describePosition(resolveFenFromArgs(args));
1176
1309
  case "predict_human_move": {
1177
1310
  const fen = resolveFenFromArgs(args);
1178
1311
  const qs = new URLSearchParams();
@@ -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
+ }
@@ -144,7 +144,7 @@ function validateSan(fen, san) {
144
144
  const board = pos.value;
145
145
  const move = parseSan(board, san);
146
146
  if (!move) {
147
- 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.`);
148
148
  }
149
149
  return move;
150
150
  }
@@ -23,7 +23,7 @@ Reading:
23
23
  Building (use these — one call for many ops):
24
24
 
25
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.
26
+ - **`auto_evaluate(id, path?)`** — walk the tree from `path` and populate the persistent `ceoEval` (Stockfish + Lc0 numbers) on every node by running cloud_analyse on each position. **Does NOT set visible NAGs** — the numbers land in a hidden `[%ceo-eval]` tag so you can read them back later. NAG placement (`$1` `!`, `$14` `⩲`, `$16` `±`, `$18` `+−`, etc.) is your editorial call, not an automatic mapping see the section on NAG discipline below. Skip nodes that already have a stored eval by default.
27
27
 
28
28
  Per-op mutation tools (single-op calls, use for edits after the bulk build):
29
29
 
@@ -40,9 +40,9 @@ All mutations **auto-save** with optimistic locking. Response includes the new `
40
40
  ## Typical build order
41
41
 
42
42
  1. `read_prep_file` — see what's there.
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).
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, plus any `set_nags` where you already have a clear judgment novelty `$146`, `!?` speculative sac, obvious `?` blunder in a sideline you're rejecting).
44
+ 3. `auto_evaluate(id)` — walk the tree and PERSIST engine numbers on every node. Does not touch visible NAGs. Cheap way to get every position's Stockfish + Lc0 read baked into the file for later reference.
45
+ 4. Re-read the file and add NAGs where they carry real signal (see NAG discipline below). Use individual mutation tools for surgical follow-ups (fix one comment, add one arrow, promote a specific variation, prune a branch).
46
46
 
47
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
48
 
@@ -122,7 +122,21 @@ Engine numbers go into the NAG, not into prose. Convert the eval to the correct
122
122
  | `\|eval\| ≥ 1.3` | `$18` (+−) | `$19` (−+) |
123
123
  | Sharp, hard to evaluate | `$13` (∞) | `$13` (∞) |
124
124
 
125
- **Prefer auto_evaluate for the NAG.** Don't hand-write NAGs when buildingcall `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).
125
+ **NAGs are editorial, not automatic.** `auto_evaluate` persists engine numbers on every node but does NOT set visible NAGs because a repertoire full of 0.00 opening theory does not want a `$10` (=) glyph plastered on every move. That's noise, not signal. NAGs are your call: mark moves that carry a judgment a reader can't derive by looking at the board.
126
+
127
+ Where NAGs actually earn their place:
128
+
129
+ - **`$146` (novelty)** — a move that hasn't appeared at this level. Only set if you actually know this (e.g. saw 0 games via `get_position_stats`).
130
+ - **`$5 !?` (interesting/speculative)** — a committal move you're recommending anyway (a piece sac, a positional pawn concession).
131
+ - **`$1 !` / `$3 !!`** — objectively good moves the reader might miss.
132
+ - **`$2 ?` / `$4 ??` / `$6 ?!`** — mistakes in a sideline you're showing to reject.
133
+ - **`$14/$15` (⩲/⩱)** and **`$16/$17` (±/∓)** — assessments on positions where the direction of pressure matters for the plan you're teaching. Skip these on 0.00 theory nodes.
134
+ - **`$18/$19` (+−/−+)** — decisive advantages, use where the reader should recognise the position is winning.
135
+ - **`$44` (compensation)** on a genuine gambit; **`$13` (∞)** on a genuinely sharp/unclear position that engines don't resolve.
136
+
137
+ Where NAGs are noise: every `$10` "=" glyph on every equal position. If the whole tree is 0.00, that's the *default state* — leave it unmarked and the reader understands.
138
+
139
+ When reading back a file, `node.ceoEval.nag` carries the threshold-derived NAG (`$10` / `$14` / `$16` / `$18` etc.) — treat it as a *suggestion*, not an automatic write. Promote it to a visible NAG only when a glyph on that move actually helps the reader.
126
140
 
127
141
  **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
142
 
@@ -133,6 +147,27 @@ Engine numbers go into the NAG, not into prose. Convert the eval to the correct
133
147
 
134
148
  **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.}`
135
149
 
150
+ **Only quote engine numbers for positions you actually called `cloud_analyse` on** (or that carry a `ceoEval` from an earlier auto_evaluate, or an auto-attached `.eval` from a query tool). Do NOT infer an eval for a parent position from its children and then write "both engines 0.00" on the parent — that reads as a measurement but is a guess. If the position wasn't analysed, either analyse it or omit the number: prose can say "both continuations run to 0.00, so this looks balanced" without claiming an eval that wasn't taken.
151
+
152
+ **When you set `contempt`, attribute it to Lc0 only.** Contempt affects Lc0 exclusively — Stockfish always analyses objectively. So don't write "both engines at contempt −15" — that misleads the reader into thinking SF was biased too. Correct phrasings:
153
+
154
+ - `{Lc0 (contempt −15): 0.00 — even biased toward Black, still balanced. SF objective: 0.00.}`
155
+ - `{With Lc0 nudged toward Black (contempt −15) it still picks Qg6 at 0.00; SF's objective read also 0.00.}`
156
+
157
+ If you only quote the Lc0 number, disclose the bias in the same sentence — `{Lc0 gives Black +0.30}` with an undisclosed `contempt=-15` is a false objectivity claim.
158
+
159
+ ### Before you write any commentary: describe_position
160
+
161
+ 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:
162
+
163
+ - All piece placements per colour (SAN-style: `Nf3`, `Bg7`, `e4`)
164
+ - Material balance in pawn units (`"white +1 (39 vs 38)"`)
165
+ - Every contested piece: attackers + defenders (e.g. `{target: "e5", color: "black", attackers: ["Nf3"], defenders: ["Nc6"]}`)
166
+ - Hanging list — attacked and undefended
167
+ - Check state + checkers, castling rights, en passant
168
+
169
+ 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.
170
+
136
171
  ### When prose ADDS to the NAG
137
172
 
138
173
  Prose is worth writing when the NAG *understates* something the human should know:
@@ -21,7 +21,7 @@ The failure mode is *authoritative-sounding recipe = 30% real tool output + 70%
21
21
 
22
22
  ## Numbers are inputs, not verdicts
23
23
 
24
- The move statistics endpoints return win %, game counts, and (in the big DB) hotness. Treat every one of these as a *weight*, not a *rule*.
24
+ The move statistics endpoints return win %, game counts, and (in the big DB) a `fashionScore` (0–100, how in-fashion the move is right now at the top level — recent + played often). Treat every one of these as a *weight*, not a *rule*.
25
25
 
26
26
  - **Sample size scales trust.** 3 games at 66% is noise; 300 at 55% is signal. A great score is nice — with volume. When the opponent has only 2-4 games in a variation, the "opponent-specific" score is basically the general-DB score anyway.
27
27
  - **Score doesn't automatically indicate a level gap.** A 60% variation isn't necessarily "stronger players crushing weaker ones." Look at the per-move `avgWhite` / `avgBlack` fields (returned on every move statistic) before drawing conclusions about who is playing whom.
@@ -41,7 +41,7 @@ Every recommendation should be filtered through: "does this survive the fact tha
41
41
 
42
42
  The opponent's last 12-24 months matter far more than a 10-year career average. Repertoires evolve — a lifelong Najdorf player might have quietly become a Petroff player last year, and their old career stats will lie to you if you skim.
43
43
 
44
- **Related product note (2026-07-23):** the `get_player_preparation` endpoint (compact / LLM view) deliberately strips the per-move `hotness` field. At the individual-player level, "hotness" is trailing noise — the opponent's opening trend is already captured in game dates. Hotness stays on the general-DB endpoint, where it's genuinely useful: it means fashion (what the whole top field is playing this month).
44
+ **Related product note (2026-07-23):** the `get_player_preparation` endpoint (compact / LLM view) deliberately strips the per-move `fashionScore` field. At the individual-player level, fashion is trailing noise — the opponent's opening trend is already captured in game dates. `fashionScore` stays on the general-DB endpoint (`get_position_stats`), where it's genuinely useful: it's what the whole top field is playing this month.
45
45
 
46
46
  ## Prep is a tree, not a line
47
47
 
@@ -68,7 +68,7 @@ Deep prep is a weapon you spend, not one you own. A prepared novelty against a s
68
68
 
69
69
  ## Move-order tricks aren't visible in raw win rates
70
70
 
71
- The `trs` (transpositions) field on move statistics tells you how much a given position folds into related structures. Move-order tricks are legitimate prep and position stats alone can't recommend them.
71
+ The `reachedViaTransposition` field on move statistics tells you how many of the games at that position arrived via a different move order — a hint at how much the line folds into related structures. Move-order tricks are legitimate prep and position stats alone can't recommend them.
72
72
 
73
73
  - Considering 1.Nf3 or 1.c4 as a duck around 1.d4? First check the opponent's repertoire *against 1.d4*. If they play the King's Indian, most 1.Nf3/1.c4 lines transpose anyway — the trick doesn't help.
74
74
  - Move orders are useful when the opponent plays something that specifically depends on the move order — e.g. a Nimzo player who never gets to play the Nimzo because you go 1.Nf3-2.g3.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chessceo/mcp",
3
- "version": "0.26.0",
3
+ "version": "0.30.2",
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": {