@chessceo/mcp 0.26.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,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";
@@ -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,11 +256,30 @@ 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).",
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.",
255
265
  },
256
266
  },
257
267
  },
258
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`." },
280
+ },
281
+ },
282
+ },
259
283
  {
260
284
  name: "predict_human_move",
261
285
  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" +
@@ -1038,6 +1062,62 @@ async function applyMutation(args, mutator) {
1038
1062
  version: savedRow.version,
1039
1063
  };
1040
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
+ }
1041
1121
  // Rewrite availableMoves[].move UCI → SAN. The prep + position-stats
1042
1122
  // endpoints return moves in UCI on the wire — same LLM-readability
1043
1123
  // concern as engine PVs, and the same wrapper-only fix. Passes the
@@ -1154,25 +1234,36 @@ async function callToolInner(name, args) {
1154
1234
  fetchCompactEval(effectiveFen),
1155
1235
  ]);
1156
1236
  const converted = convertAvailableMovesToSAN(raw, effectiveFen);
1157
- if (ev && converted && typeof converted === "object")
1158
- converted.eval = ev;
1237
+ if (converted && typeof converted === "object") {
1238
+ trimGamesMovetext(converted);
1239
+ if (ev)
1240
+ converted.eval = ev;
1241
+ }
1159
1242
  return converted;
1160
1243
  }
1161
1244
  case "get_position_stats": {
1162
1245
  const fen = resolveFenFromArgs(args);
1246
+ const rawSource = typeof args.source === "string" ? args.source : "gm-classical";
1247
+ const source = rawSource === "main" ? "main" : "gm-classical";
1163
1248
  const [raw, ev] = await Promise.all([
1164
- get("/api/chess/database/main", {
1249
+ get(`/api/chess/database/${source}`, {
1165
1250
  fen,
1166
- limit: typeof args.limit === "number" ? args.limit : 20,
1251
+ limit: typeof args.limit === "number" ? args.limit : 10,
1167
1252
  sort: "relevance",
1168
1253
  }),
1169
1254
  fetchCompactEval(fen),
1170
1255
  ]);
1171
1256
  const converted = convertAvailableMovesToSAN(raw, fen);
1172
- if (ev && converted && typeof converted === "object")
1173
- converted.eval = ev;
1257
+ if (converted && typeof converted === "object") {
1258
+ trimGamesMovetext(converted);
1259
+ converted.source = source;
1260
+ if (ev)
1261
+ converted.eval = ev;
1262
+ }
1174
1263
  return converted;
1175
1264
  }
1265
+ case "describe_position":
1266
+ return describePosition(resolveFenFromArgs(args));
1176
1267
  case "predict_human_move": {
1177
1268
  const fen = resolveFenFromArgs(args);
1178
1269
  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
  }
@@ -133,6 +133,18 @@ Engine numbers go into the NAG, not into prose. Convert the eval to the correct
133
133
 
134
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.}`
135
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
+
136
148
  ### When prose ADDS to the NAG
137
149
 
138
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.26.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": {