@chessceo/mcp 0.44.0 → 0.46.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.
@@ -0,0 +1,242 @@
1
+ // Prep-file reads and list-queries. Everything the LLM calls to inspect
2
+ // a file WITHOUT mutating it:
3
+ // - readPrepFile (with four view modes: compact / full / spine / pgn)
4
+ // - listNodes (cheap filter-based node queries)
5
+ // - listTranspositions (positions occurring 2+ times in the file)
6
+ //
7
+ // Extracted from index.ts in v0.44 as part of the file split. Was
8
+ // originally split off the read-case handler because both readPrepFile
9
+ // and listNodes need the same load-parse pipeline and share the
10
+ // compact / spine / pgn view logic.
11
+ import { fetchGame } from "../http.js";
12
+ import { parsePGN } from "../pgn/parser.js";
13
+ import { exportPGN } from "../pgn/exporter.js";
14
+ import { buildFenIndex, buildIdIndex, positionKey, resolveNodeId, ROOT_ID, } from "../pgn/paths.js";
15
+ import { getNodeByPath } from "../analysis/file_handle.js";
16
+ export async function loadPrepFile(id) {
17
+ const g = await fetchGame(id);
18
+ // Echo the composite id back so read_prep_file responses match the
19
+ // exact id the LLM passed in. The backend returns the raw game_id;
20
+ // recompose so the LLM never sees the split form.
21
+ return { file: parsePGN(g.pgnContent), version: g.version, fileIdEcho: id, pgn: g.pgnContent };
22
+ }
23
+ // Recursively project a PrepNode into the requested view. `depthLeft`
24
+ // null → unlimited; 0 → just the node without children.
25
+ //
26
+ // `fenIndex` (optional) enables the `transposes_to` field — for each
27
+ // node whose position also appears elsewhere in the SAME file, we
28
+ // annotate it with the OTHER occurrences' ids. Pass null (the default)
29
+ // to skip the annotation entirely; passing the map costs one lookup
30
+ // per node projected.
31
+ export function projectNode(node, view, depthLeft, fenIndex = null) {
32
+ const base = {
33
+ id: node.id,
34
+ san: node.san,
35
+ ply: node.ply,
36
+ };
37
+ if (node.nags && node.nags.length > 0)
38
+ base.nags = node.nags;
39
+ if (node.comment)
40
+ base.comment = node.comment;
41
+ if (node.ceoEval)
42
+ base.ceoEval = node.ceoEval;
43
+ if (view === "full") {
44
+ base.fen = node.fen;
45
+ if (node.annotations)
46
+ base.annotations = node.annotations;
47
+ }
48
+ if (fenIndex && node.id !== ROOT_ID) {
49
+ const group = fenIndex.get(positionKey(node.fen));
50
+ if (group && group.length > 1) {
51
+ const others = group.filter(n => n.id !== node.id).map(n => n.id);
52
+ if (others.length > 0)
53
+ base.transposes_to = others;
54
+ }
55
+ }
56
+ // Children handling depends on view + depth budget.
57
+ const showChildren = depthLeft === null || depthLeft > 0;
58
+ const childDepth = depthLeft === null ? null : depthLeft - 1;
59
+ if (showChildren && node.children.length > 0) {
60
+ if (view === "spine") {
61
+ // Only follow children[0] — collapses the tree to the mainline.
62
+ base.children = [projectNode(node.children[0], view, childDepth, fenIndex)];
63
+ }
64
+ else {
65
+ base.children = node.children.map(c => projectNode(c, view, childDepth, fenIndex));
66
+ }
67
+ }
68
+ else {
69
+ base.children = [];
70
+ }
71
+ return base;
72
+ }
73
+ export async function readPrepFile(args) {
74
+ const id = String(args.id);
75
+ const view = (typeof args.view === "string" && ["compact", "full", "spine", "pgn"].includes(args.view))
76
+ ? args.view
77
+ : "compact";
78
+ const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0 ? args.node_id : ROOT_ID;
79
+ const maxDepth = typeof args.max_depth === "number" && args.max_depth >= 0 ? args.max_depth : null;
80
+ const { file, version, fileIdEcho, pgn } = await loadPrepFile(id);
81
+ const idIndex = buildIdIndex(file.root);
82
+ const path = resolveNodeId(idIndex, startNodeId);
83
+ const anchor = getNodeByPath(file.root, path);
84
+ const fenIndex = buildFenIndex(file.root);
85
+ // How many DISTINCT positions in the file appear more than once,
86
+ // and how many nodes are involved. Shown in the header so the LLM
87
+ // sees at a glance whether transpositions matter here before diving
88
+ // into the tree.
89
+ let transGroups = 0;
90
+ let transNodes = 0;
91
+ for (const arr of fenIndex.values()) {
92
+ if (arr.length > 1) {
93
+ transGroups++;
94
+ transNodes += arr.length;
95
+ }
96
+ }
97
+ const header = {
98
+ id: fileIdEcho ?? id,
99
+ version,
100
+ tags: file.tags,
101
+ view,
102
+ node_id: startNodeId,
103
+ max_depth: maxDepth,
104
+ transposition_groups: transGroups,
105
+ transposition_nodes: transNodes,
106
+ };
107
+ if (view === "pgn") {
108
+ // For the root, just return the file's actual PGN as-is. For a
109
+ // subtree, build a mini-Game from the anchor and export it. Keeps
110
+ // formatting identical to what the app renders.
111
+ if (startNodeId === ROOT_ID && (maxDepth === null || maxDepth >= 999)) {
112
+ return { ...header, pgn };
113
+ }
114
+ // Truncate to a subtree with max_depth. Simple: walk the anchor's
115
+ // subtree, produce a synthetic PGN starting from the anchor's FEN.
116
+ const subtreePgn = exportSubtreePgn(file, anchor, maxDepth);
117
+ return { ...header, pgn: subtreePgn };
118
+ }
119
+ return { ...header, tree: projectNode(anchor, view, maxDepth, fenIndex) };
120
+ }
121
+ // Produce a PGN string for a subtree rooted at `anchor`, truncated
122
+ // at `maxDepth` plies below (null = unlimited). Reuses the exporter
123
+ // by building a synthetic PrepFile whose root is a shallow clone of
124
+ // the anchor with its children trimmed to depth.
125
+ function exportSubtreePgn(file, anchor, maxDepth) {
126
+ const trim = (n, depthLeft) => {
127
+ if (depthLeft !== null && depthLeft <= 0)
128
+ return { ...n, children: [] };
129
+ const next = depthLeft === null ? null : depthLeft - 1;
130
+ return { ...n, children: n.children.map(c => trim(c, next)) };
131
+ };
132
+ const trimmedAnchor = trim(anchor, maxDepth);
133
+ // If the anchor IS the root, exporter handles it. If it's an inner
134
+ // node, we set the root's FEN to the anchor's position and hang the
135
+ // trimmed subtree off it. Tags carried over.
136
+ if (anchor.id === ROOT_ID) {
137
+ return exportPGN({ tags: file.tags, root: trimmedAnchor });
138
+ }
139
+ const syntheticRoot = {
140
+ id: ROOT_ID,
141
+ san: null,
142
+ fen: anchor.fen,
143
+ ply: 0,
144
+ children: trimmedAnchor.children,
145
+ };
146
+ const tags = { ...file.tags, FEN: anchor.fen, SetUp: "1" };
147
+ return exportPGN({ tags, root: syntheticRoot });
148
+ }
149
+ export async function listNodes(args) {
150
+ const id = String(args.id);
151
+ const filter = String(args.filter || "");
152
+ const startNodeId = typeof args.node_id === "string" && args.node_id.length > 0 ? args.node_id : ROOT_ID;
153
+ const maxDepth = typeof args.max_depth === "number" && args.max_depth >= 0 ? args.max_depth : null;
154
+ const { file } = await loadPrepFile(id);
155
+ const idIndex = buildIdIndex(file.root);
156
+ const path = resolveNodeId(idIndex, startNodeId);
157
+ const anchor = getNodeByPath(file.root, path);
158
+ const fenIndex = filter === "transpositions" ? buildFenIndex(file.root) : null;
159
+ const hits = [];
160
+ const walk = (node, depthLeft, spineOnly) => {
161
+ // Root has no san — never emit it as a match. Everything else is fair game.
162
+ if (node.id !== ROOT_ID) {
163
+ let include = false;
164
+ let extra = {};
165
+ switch (filter) {
166
+ case "missing_eval":
167
+ include = !node.ceoEval;
168
+ break;
169
+ case "has_comment":
170
+ include = !!(node.comment && node.comment.length > 0);
171
+ if (include)
172
+ extra.comment_preview = (node.comment || "").slice(0, 80);
173
+ break;
174
+ case "has_annotations":
175
+ include = !!(node.annotations && (node.annotations.arrows.length > 0 || node.annotations.highlights.length > 0));
176
+ break;
177
+ case "novelties":
178
+ include = !!(node.nags && node.nags.includes("$146"));
179
+ break;
180
+ case "leaves":
181
+ include = node.children.length === 0;
182
+ break;
183
+ case "mainline":
184
+ include = spineOnly;
185
+ break;
186
+ case "transpositions": {
187
+ const group = fenIndex.get(positionKey(node.fen));
188
+ if (group && group.length > 1) {
189
+ include = true;
190
+ extra.transposes_to = group.filter(n => n.id !== node.id).map(n => n.id);
191
+ }
192
+ break;
193
+ }
194
+ case "all":
195
+ include = true;
196
+ break;
197
+ default:
198
+ throw new Error(`unknown filter: ${filter}`);
199
+ }
200
+ if (include) {
201
+ const hit = { node_id: node.id, san: node.san, ply: node.ply };
202
+ Object.assign(hit, extra);
203
+ hits.push(hit);
204
+ }
205
+ }
206
+ if (depthLeft !== null && depthLeft <= 0)
207
+ return;
208
+ const nextDepth = depthLeft === null ? null : depthLeft - 1;
209
+ if (filter === "mainline" && spineOnly) {
210
+ if (node.children.length > 0)
211
+ walk(node.children[0], nextDepth, true);
212
+ }
213
+ else {
214
+ for (const c of node.children)
215
+ walk(c, nextDepth, filter === "mainline");
216
+ }
217
+ };
218
+ const rootIsSpineForFilter = filter === "mainline";
219
+ walk(anchor, maxDepth, rootIsSpineForFilter);
220
+ return { file_id: id, filter, node_id: startNodeId, max_depth: maxDepth, count: hits.length, nodes: hits };
221
+ }
222
+ // list_transpositions — every position that occurs 2+ times in the
223
+ // file, so the LLM knows where its analysis / prose will double up.
224
+ export async function listTranspositions(args) {
225
+ const id = String(args.id);
226
+ const { file } = await loadPrepFile(id);
227
+ const fenIndex = buildFenIndex(file.root);
228
+ const groups = [];
229
+ for (const [key, arr] of fenIndex.entries()) {
230
+ if (arr.length < 2)
231
+ continue;
232
+ groups.push({
233
+ position_key: key,
234
+ size: arr.length,
235
+ node_ids: arr.map(n => n.id),
236
+ sans: arr.map(n => n.san),
237
+ });
238
+ }
239
+ groups.sort((a, b) => b.size - a.size || a.position_key.localeCompare(b.position_key));
240
+ const nodeCount = groups.reduce((s, g) => s + g.size, 0);
241
+ return { file_id: id, group_count: groups.length, node_count: nodeCount, groups };
242
+ }
@@ -0,0 +1,169 @@
1
+ // Response shape transformations applied to backend payloads before
2
+ // they reach the LLM. Two responsibilities:
3
+ // - Drop internal / debug-only fields the LLM doesn't need
4
+ // (`hash`, `plyNumber`, `relevance`, etc.).
5
+ // - Rewrite backend jargon into LLM-friendly names
6
+ // (`transpositions` → `reachedViaTransposition`, `hotness` →
7
+ // `fashionScore`, UCI moves → SAN).
8
+ //
9
+ // Plus a couple of tool-input adapters that also live here for lack of
10
+ // a better home:
11
+ // - `trimMovesToPly` (used by trimGamesMovetext, exported for reuse).
12
+ // - `normalizeSourceForBackend` (prepare_opponent request adapter).
13
+ //
14
+ // Extracted from index.ts in v0.44 as part of the file split.
15
+ import { uciMoveToSAN } from "./analysis/response.js";
16
+ // Strip cruft the LLM doesn't need from the DB-position response.
17
+ // Called AFTER trimGamesMovetext so plyNumber survives long enough to
18
+ // slice each game's movetext. Also renames the `transpositions` field
19
+ // to something the LLM can parse without knowing chess-DB jargon.
20
+ export function stripPositionResponse(r) {
21
+ if (!r || typeof r !== "object")
22
+ return;
23
+ const t = r;
24
+ delete t.hash; // internal zobrist string
25
+ delete t.source; // internal "database" marker; we overwrite with our own .source
26
+ delete t.totalGames; // duplicates statistics.totalCount often; hasMore covers pagination
27
+ if (Array.isArray(t.moves)) {
28
+ for (const m of t.moves) {
29
+ if (typeof m.transpositions === "number") {
30
+ m.reachedViaTransposition = m.transpositions;
31
+ delete m.transpositions;
32
+ }
33
+ // Backend calls it "hotness" — a 0-100 time-decayed popularity score
34
+ // (recent + played often = high). Rename to something an LLM can read
35
+ // without guessing it means "on a winning streak".
36
+ if (typeof m.hotness === "number") {
37
+ m.fashionScore = m.hotness;
38
+ delete m.hotness;
39
+ }
40
+ }
41
+ }
42
+ if (Array.isArray(t.games)) {
43
+ for (const g of t.games) {
44
+ delete g.gameId;
45
+ delete g.whiteTitle;
46
+ delete g.blackTitle;
47
+ delete g.whiteTeam;
48
+ delete g.blackTeam;
49
+ delete g.round;
50
+ delete g.plyNumber;
51
+ delete g.relevance;
52
+ delete g.site;
53
+ delete g.ply;
54
+ }
55
+ }
56
+ }
57
+ // Trim every game's `moves` field to just the plies AFTER the queried
58
+ // position, using each game's `plyNumber`. Massive token save — a game
59
+ // 80 plies long queried at ply 12 drops to ~68 plies of movetext. Ports
60
+ // the frontend's GamesTable.getMoveDisplay() trim logic.
61
+ export function trimGamesMovetext(response) {
62
+ if (!response || typeof response !== "object")
63
+ return;
64
+ const r = response;
65
+ if (!Array.isArray(r.games))
66
+ return;
67
+ for (const g of r.games) {
68
+ if (typeof g.moves === "string" && typeof g.plyNumber === "number" && g.plyNumber > 0) {
69
+ g.moves = trimMovesToPly(g.moves, g.plyNumber);
70
+ }
71
+ }
72
+ }
73
+ export function trimMovesToPly(moves, plyNumber) {
74
+ // Split into plain SAN tokens, dropping standalone move-number tokens
75
+ // ("1.", "12...") and any glued number prefix on a SAN token ("1.e4").
76
+ // Result markers ("*", "1-0", "0-1", "1/2-1/2") are stripped so they
77
+ // don't get counted as plies.
78
+ const tokens = [];
79
+ for (const chunk of moves.split(/\s+/)) {
80
+ if (!chunk)
81
+ continue;
82
+ const cleaned = chunk.replace(/^\d+\.+/, "");
83
+ if (!cleaned)
84
+ continue;
85
+ if (/^(1-0|0-1|1\/2-1\/2|\*)$/.test(cleaned))
86
+ continue;
87
+ tokens.push(cleaned);
88
+ }
89
+ const remaining = tokens.slice(plyNumber);
90
+ if (remaining.length === 0)
91
+ return "";
92
+ // Reconstruct with move numbering. First move gets "N..." if it's
93
+ // Black's move (starting the slice mid-move-pair), so the reader knows
94
+ // moves were dropped.
95
+ const out = [];
96
+ let ply = plyNumber;
97
+ for (let i = 0; i < remaining.length; i++) {
98
+ const san = remaining[i];
99
+ const moveNumber = Math.floor(ply / 2) + 1;
100
+ if (ply % 2 === 0) {
101
+ out.push(`${moveNumber}. ${san}`);
102
+ }
103
+ else if (i === 0) {
104
+ out.push(`${moveNumber}... ${san}`);
105
+ }
106
+ else {
107
+ out.push(san);
108
+ }
109
+ ply++;
110
+ }
111
+ return out.join(" ");
112
+ }
113
+ // Rewrite availableMoves[].move UCI → SAN. The prep + position-stats
114
+ // endpoints return moves in UCI on the wire — same LLM-readability
115
+ // concern as engine PVs, and the same wrapper-only fix. Passes the
116
+ // response through unchanged if there's no availableMoves array.
117
+ export function convertAvailableMovesToSAN(raw, fen) {
118
+ if (!raw || typeof raw !== "object")
119
+ return raw;
120
+ const r = raw;
121
+ if (!Array.isArray(r.availableMoves))
122
+ return raw;
123
+ for (const m of r.availableMoves) {
124
+ if (typeof m.move === "string" && m.move.length >= 4) {
125
+ m.move = uciMoveToSAN(fen, m.move);
126
+ }
127
+ }
128
+ return raw;
129
+ }
130
+ // Normalize one MCP `prepare_opponent` source into the shape the backend's
131
+ // /api/chess/prep/prepare-multi expects. Handles two impedance mismatches:
132
+ // - snake_case → camelCase (fide_id → fideId, start_month → startMonth, etc.)
133
+ // - the unified `time_control` string → per-source-type filter:
134
+ // * fide / chesscom → timeFormats: ["Classical" | "Rapid" | "Blitz"]
135
+ // * lichess → perfType: "classical" | "rapid" | "blitz" | "bullet"
136
+ // Backend validates required fields per source type, so we don't need to
137
+ // pre-reject missing username/fideId here — it'll come back as a 400 the
138
+ // LLM can act on.
139
+ export function normalizeSourceForBackend(src, idx) {
140
+ const type = typeof src.type === "string" ? src.type : "";
141
+ if (type !== "fide" && type !== "chesscom" && type !== "lichess") {
142
+ throw new Error(`sources[${idx}].type must be one of fide|chesscom|lichess (got ${JSON.stringify(src.type)})`);
143
+ }
144
+ const out = { type };
145
+ if (typeof src.fide_id === "number")
146
+ out.fideId = src.fide_id;
147
+ if (typeof src.username === "string" && src.username.trim() !== "")
148
+ out.username = src.username.trim();
149
+ if (typeof src.color === "string" && (src.color === "white" || src.color === "black"))
150
+ out.color = src.color;
151
+ if (typeof src.start_month === "string" && src.start_month.trim() !== "")
152
+ out.startMonth = src.start_month.trim();
153
+ if (typeof src.end_month === "string" && src.end_month.trim() !== "")
154
+ out.endMonth = src.end_month.trim();
155
+ if (typeof src.exclude_online === "boolean")
156
+ out.excludeOnline = src.exclude_online;
157
+ const tc = typeof src.time_control === "string" ? src.time_control : "";
158
+ if (tc) {
159
+ if (type === "lichess") {
160
+ out.perfType = tc;
161
+ }
162
+ else {
163
+ // fide + chesscom take a titlecased timeFormats array.
164
+ const titled = tc.charAt(0).toUpperCase() + tc.slice(1);
165
+ out.timeFormats = [titled];
166
+ }
167
+ }
168
+ return out;
169
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chessceo/mcp",
3
- "version": "0.44.0",
3
+ "version": "0.46.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": {