@chessceo/mcp 0.42.0 → 0.43.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
@@ -73,8 +73,10 @@ const AUTHED_TOOLS = new Set([
73
73
  "list_cloud_engines",
74
74
  "stop_cloud_engine",
75
75
  "cloud_analyse",
76
+ "list_collections",
76
77
  "list_prep_files",
77
78
  "search_prep_files",
79
+ "find_position_in_files",
78
80
  "read_prep_file",
79
81
  "list_nodes",
80
82
  "list_transpositions",
@@ -174,6 +176,72 @@ async function authedRequest(method, path, body) {
174
176
  }
175
177
  return text.length ? JSON.parse(text) : null;
176
178
  }
179
+ // ── Backend I/O layer ──────────────────────────────────────────────
180
+ //
181
+ // v0.43: MCP surface moved off the single-`/mcp`-folder model onto the
182
+ // full user PGN library. LLM-facing tool ids are opaque composites of
183
+ // the form `<collection_id>:<game_id>` so every existing tool that
184
+ // takes `id` keeps taking `id` — the composite splits back into two
185
+ // pieces at the HTTP layer. Backend routes live under
186
+ // `/api/agent/pgns/*` (same handlers as `/me/pgns/*` browser routes;
187
+ // a path-rewrite middleware in the server main.go maps the two).
188
+ const PGN_BASE = "/api/agent/pgns";
189
+ // Browser handlers wrap successful responses as
190
+ // { success: true, message: "...", data: <actual thing> }
191
+ // via handlers.RespondSuccess. Peel that off; leave anything without
192
+ // the envelope unchanged (some endpoints return raw bodies).
193
+ function unwrap(raw) {
194
+ if (raw &&
195
+ typeof raw === "object" &&
196
+ "data" in raw &&
197
+ raw.success === true) {
198
+ return raw.data;
199
+ }
200
+ return raw;
201
+ }
202
+ // LLM-facing "prep file id" is always "<collection_id>:<game_id>" —
203
+ // makeFileId to compose from a listing, splitFileId at the HTTP edge.
204
+ function makeFileId(collectionId, gameId) {
205
+ return `${collectionId}:${gameId}`;
206
+ }
207
+ function splitFileId(id) {
208
+ const idx = id.indexOf(":");
209
+ if (idx <= 0 || idx === id.length - 1) {
210
+ throw new Error(`invalid prep file id "${id}" — expected "<collection_id>:<game_id>" ` +
211
+ `(get one from list_prep_files, search_prep_files, find_position_in_files, ` +
212
+ `or the create_prep_file return value)`);
213
+ }
214
+ return { collectionId: id.slice(0, idx), gameId: id.slice(idx + 1) };
215
+ }
216
+ // GET one game by composite id. Throws on 404.
217
+ async function fetchGame(id) {
218
+ const { collectionId, gameId } = splitFileId(id);
219
+ const raw = await authedRequest("GET", `${PGN_BASE}/${encodeURIComponent(collectionId)}/games/${encodeURIComponent(gameId)}`);
220
+ const g = unwrap(raw);
221
+ if (!g || typeof g.pgnContent !== "string") {
222
+ throw new Error("prep file missing pgnContent");
223
+ }
224
+ return g;
225
+ }
226
+ // PUT the game body. Returns the saved game (new version).
227
+ async function saveGame(id, pgn, expectedVersion) {
228
+ const { collectionId, gameId } = splitFileId(id);
229
+ const body = { pgnContent: pgn };
230
+ if (typeof expectedVersion === "number")
231
+ body.baseVersion = expectedVersion;
232
+ const raw = await authedRequest("PUT", `${PGN_BASE}/${encodeURIComponent(collectionId)}/games/${encodeURIComponent(gameId)}`, body);
233
+ return unwrap(raw);
234
+ }
235
+ // POST a new game into the given collection. Returns the created game.
236
+ async function createGame(collectionId, pgn) {
237
+ const raw = await authedRequest("POST", `${PGN_BASE}/${encodeURIComponent(collectionId)}/games`, { pgnContent: pgn });
238
+ return unwrap(raw);
239
+ }
240
+ // DELETE (soft) a game by composite id.
241
+ async function deleteGame(id) {
242
+ const { collectionId, gameId } = splitFileId(id);
243
+ await authedRequest("DELETE", `${PGN_BASE}/${encodeURIComponent(collectionId)}/games/${encodeURIComponent(gameId)}`);
244
+ }
177
245
  // ── Tool definitions ───────────────────────────────────────────────
178
246
  //
179
247
  // Descriptions are written for the LLM, not humans — they should hint
@@ -332,7 +400,8 @@ const TOOLS = [
332
400
  },
333
401
  {
334
402
  name: "describe_position",
335
- description: "Everything you need to understand a position in one call. USE BEFORE COMMENTING — pieces get misplaced when reading a FEN, hanging pieces missed, 'the knight on d5' turns out to not exist. ~50-100 ms per call (chess-primitive analysis is instant; the Stockfish leg dominates wall time).\n\n" +
403
+ description: "**CALL WHEN**: about to write ANY comment on a position that describes what's happening on the board — piece activity, structure, plans, weaknesses. This is the single biggest lever for prose quality in the whole system. Live audit: nodes where describe_position was called first produced comments grounded specifically in the position (correct piece squares, real pawn structure, actual weak squares); nodes where it wasn't produced generic pattern-matched prose that confidently named pieces on wrong squares. `set_comment` now emits a warning whenever a substantive comment lands on a node whose position was never grounded via describe_position this session — that warning is telling you to fix a class of hallucination that already showed up in your output. Cheap: chess-primitive analysis is instant, Stockfish leg is ~50-100 ms, no billing.\n\n" +
404
+ "Everything you need to understand a position in one call. Pieces get misplaced when reading a FEN, hanging pieces missed, 'the knight on d5' turns out to not exist.\n\n" +
336
405
  "Returns three layers:\n\n" +
337
406
  "**Board state** — piece placements per colour, material balance in pawn units, contested pieces (attackers + defenders), hanging pieces, checkers if in check, castling rights, en passant, side to move, full LEGAL MOVES list. Use `.legalMoves` when `add_move` rejects an illegal SAN.\n\n" +
338
407
  "**Structural analysis** — chess-concept observations a human sees at a glance:\n" +
@@ -533,13 +602,27 @@ const TOOLS = [
533
602
  },
534
603
  },
535
604
  {
536
- name: "list_prep_files",
537
- description: "List every prep file the user has (all games inside their dedicated AI Prep collection). Returns id, PGN tags (Event, White, Black, Date, etc. — read the Event tag for the user-facing name), size, updated_at. ALWAYS call this before create_prep_file to check for existing coverage — creating a second 'Prep vs Firouzja' when one already exists is a common LLM failure. If the user has many, use search_prep_files with a query to narrow down.",
605
+ name: "list_collections",
606
+ description: "List the user's own PGN collections — every collection they've created, not just prep files. Response: `{collections: [{id, title, icon, folder_path, game_count, updated_at, position_search_enabled}]}`.\n\n" +
607
+ "**Call this before create_prep_file** — the LLM must pick a collection to write into (no default landing folder any more; the old hidden `/mcp` collection was retired in v0.43). Also useful when the user asks a position-shaped question — LLM can then run find_position_in_files to see which of these collections already covers the position.\n\n" +
608
+ "Encrypted collections (client-side-encrypted PGN) are excluded — the server can't read their contents, so they'd be dead weight on this surface.",
538
609
  inputSchema: { type: "object", properties: {} },
539
610
  },
611
+ {
612
+ name: "list_prep_files",
613
+ description: "List the games (prep files) inside one of the user's collections. **Requires `collection_id`** — call `list_collections` first if you don't have one. Returns id (composite `<collection_id>:<game_id>`, opaque to the LLM — pass as-is to read_prep_file / mutation tools), PGN header fields, updated_at.\n\n" +
614
+ "For cross-collection discovery use `search_prep_files` (text) or `find_position_in_files` (position); list_prep_files is the browse-one-collection tool.",
615
+ inputSchema: {
616
+ type: "object",
617
+ properties: {
618
+ collection_id: { type: "string", description: "Collection id from list_collections. Required." },
619
+ },
620
+ required: ["collection_id"],
621
+ },
622
+ },
540
623
  {
541
624
  name: "search_prep_files",
542
- description: "Text search over the user's prep files (matches PGN headers, comments, and content). Use this instead of list_prep_files when you know a keyword — e.g. search_prep_files(query='Firouzja') or search_prep_files(query='Najdorf').",
625
+ description: "Text search over the user's prep files ACROSS ALL their collections (matches PGN headers, comments, and content). Use when you know a keyword — e.g. search_prep_files(query='Firouzja') or search_prep_files(query='Najdorf'). Cheaper than paging list_collections + list_prep_files to find one file by name.",
543
626
  inputSchema: {
544
627
  type: "object",
545
628
  properties: {
@@ -548,6 +631,22 @@ const TOOLS = [
548
631
  required: ["query"],
549
632
  },
550
633
  },
634
+ {
635
+ name: "find_position_in_files",
636
+ description: "Position search across every one of the user's EDITABLE prep files (all their non-encrypted collections). Given a FEN, returns which of the user's files reach that exact position (or a transposition of it — matched by zobrist hash, so move-order variants are found automatically). Recency-sorted.\n\n" +
637
+ "Distinct from `find_position_in_courses`: courses are READ-ONLY reference material (Chessable PGNs, downloaded backups); this searches the user's OWN editable prep. Common workflow: user asks about a position → call this first to see if their existing prep covers it → if yes, extend that file; if no, consider whether to start new prep.\n\n" +
638
+ "Position input: `file_id`+`node_id` (from an already-open prep file), or `fen`, or `moves` from startpos, or `fen`+`moves`.",
639
+ inputSchema: {
640
+ type: "object",
641
+ properties: {
642
+ file_id: { type: "string", description: "Prep file id. With `node_id`, derives FEN from the tree." },
643
+ node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'." },
644
+ fen: { type: "string", description: "Position as FEN. Only used if `file_id`/`node_id` not set." },
645
+ moves: { type: "string", description: "SAN moves from startpos (or on top of fen)." },
646
+ line: { type: "string", description: "Alias for moves." },
647
+ },
648
+ },
649
+ },
551
650
  {
552
651
  name: "read_prep_file",
553
652
  description: "Read one prep file. Response always includes `id`, `version`, `tags`. The tree/PGN part is controlled by `view` and `node_id`/`max_depth` — large files (500+ nodes) can otherwise blow the LLM's token limit.\n\n" +
@@ -611,17 +710,23 @@ const TOOLS = [
611
710
  },
612
711
  {
613
712
  name: "create_prep_file",
614
- description: "Create a new (empty) prep file. `name` becomes the Event PGN tag. You then extend it with mutation tools (add_move, set_comment, …).\n\n" +
615
- "ALWAYS call list_prep_files (or search_prep_files with the opponent / opening keyword) FIRST — creating a duplicate 'Prep vs Firouzja' when one exists is the #1 LLM failure mode. If a file already covers the topic, add moves to that one instead.",
713
+ description: "Create a new (empty) prep file in the specified collection. `name` becomes the Event PGN tag. You then extend it with mutation tools (add_move, set_comment, …).\n\n" +
714
+ "**collection_id is REQUIRED** — call `list_collections` first to pick where it lives. There is no default landing folder any more (v0.43: the old hidden `/mcp` collection was removed; prep files now live wherever the user organizes them).\n\n" +
715
+ "**Duplicate-check first.** Call `search_prep_files(query=<opponent / opening keyword>)` OR `find_position_in_files(fen=...)` before creating — a second 'Prep vs Firouzja' file when one already exists is a common LLM failure mode. If a file already covers the topic, extend that one instead.\n\n" +
716
+ "Response: `{ok, id, collection_id, version}` — `id` is a composite you pass to every other prep-file tool as `id` or `file_id`.",
616
717
  inputSchema: {
617
718
  type: "object",
618
719
  properties: {
720
+ collection_id: {
721
+ type: "string",
722
+ description: "Collection id from list_collections. Required — this is where the new file lands.",
723
+ },
619
724
  name: {
620
725
  type: "string",
621
726
  description: "User-facing name — becomes the [Event] tag. Example: 'Prep vs Firouzja (Black) 2026-07-23'.",
622
727
  },
623
728
  },
624
- required: ["name"],
729
+ required: ["collection_id", "name"],
625
730
  },
626
731
  },
627
732
  {
@@ -668,7 +773,10 @@ const TOOLS = [
668
773
  },
669
774
  {
670
775
  name: "set_comment",
671
- description: "Set (or clear, with empty string) the text comment on the node identified by `node_id`. Comments are for plans, prep-signal, and interpretation the app can't derive — NOT for describing moves that should be variations instead. Auto-saves.",
776
+ description: "Set (or clear, with empty string) the text comment on the node identified by `node_id`. Comments are for plans, prep-signal, and interpretation the app can't derive — NOT for describing moves that should be variations instead. Auto-saves.\n\n" +
777
+ "**Two guardrails fire in the response as `warnings: [...]`:**\n" +
778
+ " 1. **Content scan** — comments containing spread lists (`≈50, ≈42, …`), raw centipawn values (`≈−60`, `+0.35`, `at depth 24`), or roster restatement (`146 GM games — Nakamura, …`) are all restating what the app already renders. The warning names the fix (set the NAG and drop the number; label the character not the numbers; cite a specific game instead of a count).\n" +
779
+ " 2. **Ungrounded prose** — substantive comments (≥40 chars) on a node whose position was never passed to `describe_position` this session are prone to hallucinated structural claims (piece on wrong square, invented captures, misidentified pawn structure). Call `describe_position` with `file_id`+`node_id` BEFORE writing prose about the position; the same node's warning clears once the position is described.",
672
780
  inputSchema: {
673
781
  type: "object",
674
782
  properties: {
@@ -1092,6 +1200,16 @@ const positionsStatsChecked = new Set();
1092
1200
  // Nodes we've ALREADY warned on for the "no stats check" pattern this
1093
1201
  // session, so repeated adds under the same parent don't spam the LLM.
1094
1202
  const noStatsWarned = new Set();
1203
+ // Session-lifetime memory of positions the LLM has called
1204
+ // `describe_position` on. Same 3-field FEN key. Live-log audit
1205
+ // (2026-07-27 Modern Defence session): 13 describe_position calls
1206
+ // vs 50+ set_comment ops — most comments were written blind. When
1207
+ // describe_position IS called before commentary, prose accuracy
1208
+ // jumps sharply (user's own observation). This tracks the same way
1209
+ // as positionsStatsChecked and drives the noDescribeWarning below.
1210
+ const positionsDescribed = new Set();
1211
+ // Once-per-node dedup for the describe warning.
1212
+ const noDescribeWarned = new Set();
1095
1213
  // Detect the anti-patterns the LLM keeps producing in comment prose.
1096
1214
  // All of these restate what the app already renders elsewhere:
1097
1215
  // - spread lists ("5.O-O ≈50, 6.h3 ≈42, ...")
@@ -1229,8 +1347,12 @@ function dispatchMutation(file, idIndex, op) {
1229
1347
  case "set_comment": {
1230
1348
  const commentStr = typeof op.comment === "string" ? op.comment : "";
1231
1349
  const commentWarns = commentAntiPatterns(commentStr);
1232
- const step = setComment(file, resolve(nodeIdField("node_id")), commentStr);
1233
- return { ...step, ...(commentWarns.length > 0 ? { warnings: commentWarns } : {}) };
1350
+ const targetPath = resolve(nodeIdField("node_id"));
1351
+ const targetNode = getNodeByPath(file.root, targetPath);
1352
+ const describeWarn = noDescribeWarning(targetNode, commentStr);
1353
+ const step = setComment(file, targetPath, commentStr);
1354
+ const all = [...commentWarns, ...(describeWarn ? [describeWarn] : [])];
1355
+ return { ...step, ...(all.length > 0 ? { warnings: all } : {}) };
1234
1356
  }
1235
1357
  case "set_nags":
1236
1358
  return setNags(file, resolve(nodeIdField("node_id")), Array.isArray(op.nags) ? op.nags.map(String) : []);
@@ -1263,10 +1385,7 @@ async function applyBatchMutations(args) {
1263
1385
  const mutations = Array.isArray(args.mutations) ? args.mutations : [];
1264
1386
  if (mutations.length === 0)
1265
1387
  throw new Error("mutations array required");
1266
- const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
1267
- const g = raw;
1268
- if (typeof g.pgnContent !== "string")
1269
- throw new Error("prep file missing pgnContent");
1388
+ const g = await fetchGame(id);
1270
1389
  let file = parsePGN(g.pgnContent);
1271
1390
  let idIndex = buildIdIndex(file.root);
1272
1391
  const results = [];
@@ -1290,12 +1409,8 @@ async function applyBatchMutations(args) {
1290
1409
  }
1291
1410
  const newPgn = exportPGN(file);
1292
1411
  const expected = typeof args.expected_version === "number" ? args.expected_version : g.version;
1293
- const saved = await authedRequest("PUT", `/api/agent/prep-files/${encodeURIComponent(id)}`, {
1294
- pgn: newPgn,
1295
- expected_version: expected,
1296
- });
1297
- const savedRow = saved;
1298
- return { ok: true, results, version: savedRow.version };
1412
+ const saved = await saveGame(id, newPgn, expected);
1413
+ return { ok: true, results, version: saved.version };
1299
1414
  }
1300
1415
  const evalJobs = new Map();
1301
1416
  // GC finished jobs after this long so status polling remains useful
@@ -1330,10 +1445,7 @@ async function autoEvaluate(args) {
1330
1445
  : ROOT_ID;
1331
1446
  const onlyMissing = args.only_missing !== false; // default true
1332
1447
  const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 1500;
1333
- const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
1334
- const g = raw;
1335
- if (typeof g.pgnContent !== "string")
1336
- throw new Error("prep file missing pgnContent");
1448
+ const g = await fetchGame(id);
1337
1449
  const file = parsePGN(g.pgnContent);
1338
1450
  const idIndex = buildIdIndex(file.root);
1339
1451
  const startPath = resolveNodeId(idIndex, startNodeId);
@@ -1946,10 +2058,7 @@ function storedEvalToCompact(ev, analysis) {
1946
2058
  // resolve node_ids without rebuilding the index itself.
1947
2059
  async function applyMutation(args, mutator) {
1948
2060
  const id = String(args.id);
1949
- const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
1950
- const g = raw;
1951
- if (typeof g.pgnContent !== "string")
1952
- throw new Error("prep file missing pgnContent");
2061
+ const g = await fetchGame(id);
1953
2062
  const file = parsePGN(g.pgnContent);
1954
2063
  const idIndex = buildIdIndex(file.root);
1955
2064
  let result;
@@ -1964,20 +2073,36 @@ async function applyMutation(args, mutator) {
1964
2073
  }
1965
2074
  const newPgn = exportPGN(result.file);
1966
2075
  const expected = typeof args.expected_version === "number" ? args.expected_version : g.version;
1967
- const saved = await authedRequest("PUT", `/api/agent/prep-files/${encodeURIComponent(id)}`, {
1968
- pgn: newPgn,
1969
- expected_version: expected,
1970
- });
1971
- const savedRow = saved;
2076
+ const saved = await saveGame(id, newPgn, expected);
1972
2077
  return {
1973
2078
  ok: true,
1974
2079
  node_id: result.id,
1975
2080
  ...(result.results !== undefined ? { line: result.results } : {}),
1976
2081
  ...(result.warning ? { warning: result.warning } : {}),
1977
2082
  ...(result.warnings && result.warnings.length > 0 ? { warnings: result.warnings } : {}),
1978
- version: savedRow.version,
2083
+ version: saved.version,
1979
2084
  };
1980
2085
  }
2086
+ // Compute the "you never called describe_position on this node" warning.
2087
+ // Fires from set_comment when the comment is substantive (>= 40 chars —
2088
+ // anything shorter is a label / pointer, doesn't need structural
2089
+ // grounding). LLMs are unreliable at reading FEN strings and confidently
2090
+ // describe positions that don't match the actual board; describe_position
2091
+ // is a pure-computation grounding pass that reliably fixes this. Warn
2092
+ // once per node.
2093
+ function noDescribeWarning(node, comment) {
2094
+ if (node.id === ROOT_ID)
2095
+ return undefined;
2096
+ if (comment.length < 40)
2097
+ return undefined;
2098
+ const key = positionKey(node.fen);
2099
+ if (positionsDescribed.has(key))
2100
+ return undefined;
2101
+ if (noDescribeWarned.has(node.id))
2102
+ return undefined;
2103
+ noDescribeWarned.add(node.id);
2104
+ return `substantive comment (${comment.length} chars) on a node whose position was never grounded via describe_position this session (id=${node.id}, ${node.san}). LLMs invent captures, miscount pieces, and swap files/ranks when reading FEN strings — describe_position is a pure-computation pass (~1 ms, no engine cost, structural facts + Stockfish's per-term eval breakdown) that reliably prevents this class of hallucination. In live audits, prose accuracy jumps sharply on nodes where describe_position was called first. Call describe_position with file_id+node_id=${node.id} BEFORE writing prose. Warned once per node.`;
2105
+ }
1981
2106
  // Compute the "you never DB-checked this parent" warning. Called from
1982
2107
  // add_move / add_line handlers with the parent node. Returns undefined
1983
2108
  // when either (a) the parent was checked this session (or is root — the
@@ -1997,11 +2122,11 @@ function noStatsCheckWarning(parent) {
1997
2122
  return `no get_position_stats call for the parent (id=${parent.id}, ${parent.san}) this session. Course chapter titles describe what an author chose to cover, not what practical opponents play — treating "the So chapter says 6.O-O-O" as "the mainline is 6.O-O-O" is the exact pattern this warning exists to catch. Call get_position_stats at this position (via file_id+node_id=${parent.id}) BEFORE deciding which branches belong here; suppress this warning by making that call. Warned once per parent per session.`;
1998
2123
  }
1999
2124
  async function loadPrepFile(id) {
2000
- const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(id)}`);
2001
- const g = raw;
2002
- if (typeof g.pgnContent !== "string")
2003
- throw new Error("prep file missing pgnContent");
2004
- return { file: parsePGN(g.pgnContent), version: g.version, fileIdEcho: g.id, pgn: g.pgnContent };
2125
+ const g = await fetchGame(id);
2126
+ // Echo the composite id back so read_prep_file responses match the
2127
+ // exact id the LLM passed in. The backend returns the raw game_id;
2128
+ // recompose so the LLM never sees the split form.
2129
+ return { file: parsePGN(g.pgnContent), version: g.version, fileIdEcho: id, pgn: g.pgnContent };
2005
2130
  }
2006
2131
  // Recursively project a PrepNode into the requested view. `depthLeft`
2007
2132
  // null → unlimited; 0 → just the node without children.
@@ -2053,6 +2178,91 @@ function projectNode(node, view, depthLeft, fenIndex = null) {
2053
2178
  }
2054
2179
  return base;
2055
2180
  }
2181
+ async function listCollections(_args) {
2182
+ const raw = await authedRequest("GET", PGN_BASE);
2183
+ const collections = unwrap(raw) ?? [];
2184
+ return {
2185
+ collections: collections.map(c => ({
2186
+ id: c.id,
2187
+ title: c.title,
2188
+ icon: c.icon,
2189
+ folder_path: c.folderPath,
2190
+ game_count: c.gameCount,
2191
+ position_search_enabled: c.positionSearchEnabled,
2192
+ updated_at: c.updatedAt,
2193
+ })),
2194
+ };
2195
+ }
2196
+ // Convert a browser-returned game list row into the LLM shape (composite
2197
+ // id, cleaned field names).
2198
+ function projectGameRow(row) {
2199
+ const collId = row.collectionId ?? "";
2200
+ return {
2201
+ id: collId ? makeFileId(collId, row.id) : row.id,
2202
+ collection_id: collId,
2203
+ collection_title: row.collectionTitle,
2204
+ event: row.event,
2205
+ white: row.white_player,
2206
+ black: row.black_player,
2207
+ eco: row.eco,
2208
+ opening: row.opening,
2209
+ updated_at: row.updated_at,
2210
+ ply: row.ply,
2211
+ };
2212
+ }
2213
+ async function listPrepFiles(args) {
2214
+ const collectionId = typeof args.collection_id === "string" ? args.collection_id.trim() : "";
2215
+ if (!collectionId) {
2216
+ throw new Error("collection_id required — call list_collections to see your options, or search across collections with search_prep_files / find_position_in_files");
2217
+ }
2218
+ // Browser handler at GET /me/pgns/{id}/games returns a paginated list.
2219
+ const raw = await authedRequest("GET", `${PGN_BASE}/${encodeURIComponent(collectionId)}/games?page=1&limit=200`);
2220
+ const data = unwrap(raw);
2221
+ const games = data?.games ?? (Array.isArray(data) ? data : []);
2222
+ return { collection_id: collectionId, prep_files: games.map(projectGameRow) };
2223
+ }
2224
+ async function searchPrepFiles(args) {
2225
+ const q = typeof args.query === "string" ? args.query.trim() : "";
2226
+ if (!q)
2227
+ throw new Error("query required");
2228
+ const raw = await authedRequest("GET", `${PGN_BASE}/games/search?q=${encodeURIComponent(q)}&limit=100`);
2229
+ const data = unwrap(raw);
2230
+ const games = data?.games ?? [];
2231
+ return { query: q, prep_files: games.map(projectGameRow) };
2232
+ }
2233
+ async function findPositionInFiles(args) {
2234
+ // FEN can come from a node handle OR a direct fen/moves/line. Reuse
2235
+ // the same resolver everything else uses.
2236
+ const resolved = await resolveFromNodeOrFen(args);
2237
+ const fen = resolved.fen;
2238
+ const raw = await authedRequest("GET", `${PGN_BASE}/games/search?position=${encodeURIComponent(fen)}&limit=100`);
2239
+ const data = unwrap(raw);
2240
+ const games = data?.games ?? [];
2241
+ return {
2242
+ fen,
2243
+ match_count: games.length,
2244
+ prep_files: games.map(projectGameRow),
2245
+ };
2246
+ }
2247
+ async function createPrepFile(args) {
2248
+ const collectionId = typeof args.collection_id === "string" ? args.collection_id.trim() : "";
2249
+ if (!collectionId) {
2250
+ throw new Error("collection_id required — call list_collections to pick where the new file lives. There is no default landing folder any more (v0.43: the old hidden /mcp collection was removed).");
2251
+ }
2252
+ const name = String(args.name || "").trim();
2253
+ if (!name)
2254
+ throw new Error("name is required");
2255
+ // Seed with a PGN carrying the LLM-chosen name as the Event tag so
2256
+ // subsequent list_prep_files calls display something useful.
2257
+ const seedPgn = `[Event "${name.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]\n\n*\n`;
2258
+ const game = await createGame(collectionId, seedPgn);
2259
+ return {
2260
+ ok: true,
2261
+ id: makeFileId(game.collectionId ?? collectionId, game.id),
2262
+ collection_id: game.collectionId ?? collectionId,
2263
+ version: game.version,
2264
+ };
2265
+ }
2056
2266
  async function readPrepFile(args) {
2057
2267
  const id = String(args.id);
2058
2268
  const view = (typeof args.view === "string" && ["compact", "full", "spine", "pgn"].includes(args.view))
@@ -2429,10 +2639,7 @@ async function resolveFromNodeOrFen(args) {
2429
2639
  const fileId = typeof args.file_id === "string" ? args.file_id.trim() : "";
2430
2640
  const nodeId = typeof args.node_id === "string" ? args.node_id.trim() : "";
2431
2641
  if (fileId && nodeId) {
2432
- const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(fileId)}`);
2433
- const g = raw;
2434
- if (typeof g.pgnContent !== "string")
2435
- throw new Error("prep file missing pgnContent");
2642
+ const g = await fetchGame(fileId);
2436
2643
  const parsedFile = parsePGN(g.pgnContent);
2437
2644
  const idIndex = buildIdIndex(parsedFile.root);
2438
2645
  const nodePath = resolveNodeId(idIndex, nodeId);
@@ -2448,18 +2655,15 @@ async function resolveFromNodeOrFen(args) {
2448
2655
  // node_id had been supplied (auto-persist on match).
2449
2656
  const fen = resolveFenFromArgs(args);
2450
2657
  try {
2451
- const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(fileId)}`);
2452
- const g = raw;
2453
- if (typeof g.pgnContent === "string") {
2454
- const parsedFile = parsePGN(g.pgnContent);
2455
- const match = findNodeByFen(parsedFile.root, fen);
2456
- if (match) {
2457
- const idIndex = buildIdIndex(parsedFile.root);
2458
- return {
2459
- fen,
2460
- file: { id: fileId, version: g.version ?? 0, parsedFile, idIndex, nodePath: match.path, fen },
2461
- };
2462
- }
2658
+ const g = await fetchGame(fileId);
2659
+ const parsedFile = parsePGN(g.pgnContent);
2660
+ const match = findNodeByFen(parsedFile.root, fen);
2661
+ if (match) {
2662
+ const idIndex = buildIdIndex(parsedFile.root);
2663
+ return {
2664
+ fen,
2665
+ file: { id: fileId, version: g.version ?? 0, parsedFile, idIndex, nodePath: match.path, fen },
2666
+ };
2463
2667
  }
2464
2668
  }
2465
2669
  catch {
@@ -2520,10 +2724,7 @@ async function storeEvalOnNode(handle, ev) {
2520
2724
  const paths = group.map(n => resolveNodeId(idIndex, n.id));
2521
2725
  const { file: newFile, ids } = setCeoEvalMany(handle.parsedFile, paths, ev);
2522
2726
  const newPgn = exportPGN(newFile);
2523
- await authedRequest("PUT", `/api/agent/prep-files/${encodeURIComponent(handle.id)}`, {
2524
- pgn: newPgn,
2525
- expected_version: handle.version,
2526
- });
2727
+ await saveGame(handle.id, newPgn, handle.version);
2527
2728
  // Ensure the primary node (the one the LLM addressed) comes first.
2528
2729
  const anchorId = anchor.id;
2529
2730
  return [anchorId, ...ids.filter(x => x !== anchorId)];
@@ -2687,6 +2888,10 @@ async function callToolInner(name, args) {
2687
2888
  if (ev && ev.found === true) {
2688
2889
  merged.engineEvalTerms = { terms: ev.terms, total: ev.total };
2689
2890
  }
2891
+ // Record so set_comment on this node won't fire the "not described"
2892
+ // warning. Keyed by 3-field FEN so a described position is
2893
+ // credited across its transpositions too.
2894
+ positionsDescribed.add(positionKey(resolved.fen));
2690
2895
  return merged;
2691
2896
  }
2692
2897
  case "predict_human_move": {
@@ -2807,10 +3012,14 @@ async function callToolInner(name, args) {
2807
3012
  return findPositionInCourses(args);
2808
3013
  case "read_course_at_position":
2809
3014
  return readCourseAtPosition(args);
3015
+ case "list_collections":
3016
+ return listCollections(args);
2810
3017
  case "list_prep_files":
2811
- return authedRequest("GET", "/api/agent/prep-files");
3018
+ return listPrepFiles(args);
2812
3019
  case "search_prep_files":
2813
- return authedRequest("GET", `/api/agent/prep-files/search?q=${encodeURIComponent(String(args.query))}`);
3020
+ return searchPrepFiles(args);
3021
+ case "find_position_in_files":
3022
+ return findPositionInFiles(args);
2814
3023
  case "read_prep_file":
2815
3024
  return readPrepFile(args);
2816
3025
  case "list_nodes":
@@ -2818,11 +3027,11 @@ async function callToolInner(name, args) {
2818
3027
  case "list_transpositions":
2819
3028
  return listTranspositions(args);
2820
3029
  case "create_prep_file":
2821
- return authedRequest("POST", "/api/agent/prep-files", {
2822
- name: String(args.name),
2823
- });
2824
- case "delete_prep_file":
2825
- return authedRequest("DELETE", `/api/agent/prep-files/${encodeURIComponent(String(args.id))}`);
3030
+ return createPrepFile(args);
3031
+ case "delete_prep_file": {
3032
+ await deleteGame(String(args.id));
3033
+ return { ok: true };
3034
+ }
2826
3035
  case "add_move":
2827
3036
  return applyMutation(args, (file, idIndex) => {
2828
3037
  const parentPath = resolveNodeId(idIndex, argNodeId(args, "parent_id"));
@@ -2853,10 +3062,14 @@ async function callToolInner(name, args) {
2853
3062
  const commentStr = typeof args.comment === "string" ? args.comment : "";
2854
3063
  const commentWarns = commentAntiPatterns(commentStr);
2855
3064
  return applyMutation(args, (file, idIndex) => {
2856
- const step = setComment(file, resolveNodeId(idIndex, argNodeId(args)), commentStr);
3065
+ const targetPath = resolveNodeId(idIndex, argNodeId(args));
3066
+ const targetNode = getNodeByPath(file.root, targetPath);
3067
+ const describeWarn = noDescribeWarning(targetNode, commentStr);
3068
+ const step = setComment(file, targetPath, commentStr);
3069
+ const all = [...commentWarns, ...(describeWarn ? [describeWarn] : [])];
2857
3070
  return {
2858
3071
  ...step,
2859
- ...(commentWarns.length > 0 ? { warnings: commentWarns } : {}),
3072
+ ...(all.length > 0 ? { warnings: all } : {}),
2860
3073
  };
2861
3074
  });
2862
3075
  }
@@ -2890,10 +3103,7 @@ async function callToolInner(name, args) {
2890
3103
  case "quote_engine_eval": {
2891
3104
  const fileId = String(args.id);
2892
3105
  const nodeId = argNodeId(args);
2893
- const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(fileId)}`);
2894
- const g = raw;
2895
- if (typeof g.pgnContent !== "string")
2896
- throw new Error("prep file missing pgnContent");
3106
+ const g = await fetchGame(fileId);
2897
3107
  const file = parsePGN(g.pgnContent);
2898
3108
  const idIndex = buildIdIndex(file.root);
2899
3109
  const path = resolveNodeId(idIndex, nodeId);
@@ -316,7 +316,9 @@ Contempt scale is signed 0-100 (same as the web UI's ContemptStrength slider). T
316
316
 
317
317
  ### Before you write any commentary: describe_position
318
318
 
319
- 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)`. Pure computation (~1 ms, no engine), returns two layers:
319
+ **This is the biggest lever for prose quality in the whole system.** Live audit of a recent session — 13 `describe_position` calls versus 50+ `set_comment` ops. The nodes where `describe_position` was called first produced comments that grounded specifically in the position (correct piece squares, real pawn structure, actual weak squares). The nodes where it wasn't produced generic prose that pattern-matched to similar-*looking* positions and confidently named pieces on wrong squares. This gap is why `set_comment` now emits a warning whenever a substantive comment (≥40 chars) lands on a node whose position was never grounded via `describe_position` this session.
320
+
321
+ 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` (with `file_id`+`node_id` inside a prep file). Pure computation (~1 ms, no engine), returns two layers:
320
322
 
321
323
  **Board state** — piece placements, material, contested pieces (attackers + defenders), hanging list, check state, castling, en passant, legal moves. Fixes the *"Black's queen on c7 is defended by the knight on d5"* failure when actually there's no knight on d5 and the queen is on c8.
322
324
 
@@ -4,16 +4,23 @@ You can save chess prep to the user's chess.ceo account and read it back across
4
4
 
5
5
  ## The mental model
6
6
 
7
- The user has **one** collection dedicated to your work — labelled "AI Prep" in their chess.ceo app with a 🤖 icon. Inside it, each **prep file** is one PGN game with variations. You never see the collection itself; the tools operate directly on the files inside it.
7
+ The user has **any number of PGN collections** in their chess.ceo library — you have full access to every non-encrypted one. A **prep file** is one PGN game with variations, inside a collection. Every prep file id is a composite `<collection_id>:<game_id>` — opaque to you, pass it through unchanged to any tool that takes `id` or `file_id`.
8
8
 
9
- File-level tools:
9
+ Discovery tools:
10
10
 
11
- - `list_prep_files` — show me all my prep files
12
- - `search_prep_files(query)` — find by opponent name / opening keyword
13
- - `read_prep_file(id)` — parsed tree (every node carries a stable `id`) + tags + `version`
14
- - `create_prep_file(name)` — new empty file, `name` becomes the [Event] tag
11
+ - `list_collections` — show me all the user's collections (their organizational scheme is theirs; browse before creating)
12
+ - `list_prep_files(collection_id)` — the games inside one collection
13
+ - `search_prep_files(query)` — text search across ALL of the user's collections
14
+ - `find_position_in_files(fen)` — position search across ALL of the user's collections (matched by zobrist hash so move-order variants are found automatically). Distinct from `find_position_in_courses` — that's the user's read-only reference library (Chessable etc.); this is their own editable prep
15
+
16
+ Per-file tools:
17
+
18
+ - `read_prep_file(id)` — parsed tree (every node carries a stable content-derived `id`) + tags + `version`
19
+ - `create_prep_file(collection_id, name)` — new empty file inside the given collection, `name` becomes the [Event] tag
15
20
  - `delete_prep_file(id)` — soft delete (user can restore from app)
16
21
 
22
+ **No default landing folder.** v0.43 removed the old hidden `/mcp` collection — prep files now live wherever the user organizes them. Every `create_prep_file` call REQUIRES `collection_id`; call `list_collections` first if you don't have one. Permanent delete is not exposed on this surface — the user does that from the app.
23
+
17
24
  Mutation tools (edit an existing file — you never touch raw PGN):
18
25
 
19
26
  - `apply_mutations(id, [...])` — batch: N ops in one save. **Primary build tool.**
@@ -25,11 +32,12 @@ Every mutation call takes a `node_id` (or `parent_id` for add-style ops) and aut
25
32
 
26
33
  **Before creating a new file, search for an existing one.** LLMs make three "Prep vs Firouzja" files in a row all the time. Always:
27
34
 
28
- 1. `list_prep_files` (if the user has ≤20-30 files) or `search_prep_files(query=<opponent name>)` for their key term
29
- 2. Read the ones that look relevant
30
- 3. Decide: extend an existing one (save_prep_file) or genuinely start fresh (create_prep_file)
35
+ 1. **Text search first**: `search_prep_files(query=<opponent name or opening keyword>)` — searches across every collection the user owns.
36
+ 2. **Position search when the request is position-shaped** ("prep me against 6.f3 in the Najdorf"): `find_position_in_files(fen=<the specific tabiya>)` — catches files that reach the position via a different move order too. This is often more accurate than text search because file names don't always mention every position they cover.
37
+ 3. Read the ones that look relevant.
38
+ 4. Decide: extend an existing one or genuinely start fresh (`create_prep_file(collection_id, name)`).
31
39
 
32
- Duplicate files are the #1 way to lose your user's trust in this system.
40
+ Duplicate files are the #1 way to lose your user's trust in this system. Two searches (text + position) cost roughly nothing and catch nearly all overlap.
33
41
 
34
42
  ## Before writing any prose: read the examples
35
43
 
@@ -107,6 +115,8 @@ Keep it short enough to fit in a picker (30-40 chars). Long titles get truncated
107
115
  - User asks "I found a novelty in the Najdorf" and a Najdorf file exists → extend.
108
116
  - Rule of thumb: if the user's request semantically overlaps with an existing file's [Event] name or main opening line, extend.
109
117
 
110
- ## Icons and appearance
118
+ ## Appearance
119
+
120
+ Prep files land in whichever collection the user picked (via `create_prep_file(collection_id, name)`). They're first-class citizens in that collection — the user can browse, edit, share, or delete them from the app exactly like manually-created games. Your only visibility signal is the [Event] tag; make it descriptive.
111
121
 
112
- The user sees your files in a collection called "AI Prep" with a 🤖 icon under folder `/mcp` in their chess.ceo app. This is intentional — they can tell at a glance which prep came from you, and they can browse / edit / delete from the app just like their manual work. Your files are first-class citizens on their account.
122
+ The `/mcp` "AI Prep" hidden folder from earlier versions no longer exists. If the user has a collection literally named "AI Prep" it's one they created themselves.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chessceo/mcp",
3
- "version": "0.42.0",
3
+ "version": "0.43.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": {