@chessceo/mcp 0.15.0 → 0.17.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
@@ -35,6 +35,7 @@ function loadBundledDoc(filename, fallbackLabel) {
35
35
  }
36
36
  const ENGINE_USAGE_DOC = loadBundledDoc("engine-usage.md", "Engine usage guide");
37
37
  const PREP_STRATEGY_DOC = loadBundledDoc("prep-strategy.md", "Prep strategy guide");
38
+ const PREP_FILES_DOC = loadBundledDoc("prep-files-guide.md", "Prep files guide");
38
39
  // ── HTTP ────────────────────────────────────────────────────────────
39
40
  //
40
41
  // Two auth flavours coexist:
@@ -58,6 +59,12 @@ const AUTHED_TOOLS = new Set([
58
59
  "list_cloud_engines",
59
60
  "stop_cloud_engine",
60
61
  "cloud_analyse",
62
+ "list_prep_files",
63
+ "search_prep_files",
64
+ "read_prep_file",
65
+ "create_prep_file",
66
+ "save_prep_file",
67
+ "delete_prep_file",
61
68
  ]);
62
69
  function isAuthedToolCall(body) {
63
70
  if (!body || typeof body !== "object")
@@ -185,11 +192,15 @@ const TOOLS = [
185
192
  },
186
193
  line: {
187
194
  type: "string",
188
- description: "Move sequence in SAN, space-separated, no move numbers required. Example: 'e4 e5 Nf3'. Leave empty for the starting position.",
195
+ description: "Move sequence in SAN from the starting position, space-separated, no move numbers required. Example: 'e4 e5 Nf3'. Leave empty for startpos. Alias: `moves` (same thing).",
196
+ },
197
+ moves: {
198
+ type: "string",
199
+ description: "SAN moves to apply on top of `fen` (or on top of startpos if no fen). Same shape as `line`. Use this when you have a starting FEN and want to walk from there — e.g. fen='<game tabiya>', moves='b4 a5 c3' to explore that continuation. Wins over `line` if both are set.",
189
200
  },
190
201
  fen: {
191
202
  type: "string",
192
- description: "Alternative to `line` raw FEN of the target position.",
203
+ description: "Starting position as FEN. Combine with `moves` to walk from there, or use alone.",
193
204
  },
194
205
  limit: {
195
206
  type: "integer",
@@ -210,7 +221,15 @@ const TOOLS = [
210
221
  properties: {
211
222
  fen: {
212
223
  type: "string",
213
- description: "FEN of the position to look up.",
224
+ description: "Starting position as FEN. Combine with `moves` to walk from there.",
225
+ },
226
+ moves: {
227
+ type: "string",
228
+ description: "Optional SAN moves to apply on top of `fen` (or on top of startpos). Example: fen='<tabiya>', moves='b4 a5'.",
229
+ },
230
+ line: {
231
+ type: "string",
232
+ description: "Synonym for `moves` from the starting position; kept for compatibility.",
214
233
  },
215
234
  limit: {
216
235
  type: "integer",
@@ -219,18 +238,22 @@ const TOOLS = [
219
238
  description: "Number of top continuations to return.",
220
239
  },
221
240
  },
222
- required: ["fen"],
223
241
  },
224
242
  },
225
243
  {
226
244
  name: "analyse",
227
245
  description: "Short Stockfish evaluation at a position. Returns the top-N candidate moves with score (centipawns from side-to-move POV, positive = advantage; or mate distance) and the principal variation for each. Defaults: 2s think time, top-3 lines. PV moves come back in SAN (e4, Nf3, Bxc4 — not UCI). Free (no cloud instance needed) — use liberally.\n\n" +
228
246
  "GROUNDING: cite this tool's actual output when you claim things about positions. Don't invent evaluations from general principles or training data — if you don't have engine output for a FEN, call this. Compute is cheap.\n\n" +
247
+ "Position input is flexible — pass `fen`, or `moves` from startpos, or `fen + moves` to walk from an arbitrary position. Also see the flip-side-to-move threat check in read_engine_usage_guide.\n\n" +
229
248
  "Use this to sanity-check candidate lines from get_position_stats or get_player_preparation — human game frequency tells you what people play, engine evaluation tells you what's actually good.",
230
249
  inputSchema: {
231
250
  type: "object",
232
251
  properties: {
233
- fen: { type: "string", description: "FEN of the position to analyse." },
252
+ fen: { type: "string", description: "Starting position as FEN (defaults to startpos)." },
253
+ moves: {
254
+ type: "string",
255
+ description: "Optional SAN moves to apply on top of `fen` (or on top of startpos). Example: fen='<tabiya>', moves='b4 a5'.",
256
+ },
234
257
  movetime_ms: {
235
258
  type: "integer",
236
259
  minimum: 100,
@@ -244,7 +267,6 @@ const TOOLS = [
244
267
  description: "Number of candidate lines to return (default 3).",
245
268
  },
246
269
  },
247
- required: ["fen"],
248
270
  },
249
271
  },
250
272
  {
@@ -338,12 +360,17 @@ const TOOLS = [
338
360
  "• Lc0 is practical eval — trust it for 'which side is easier?' 'which candidate is best when Stockfish shows several as equal?' Lc0 sees long-term positional factors Stockfish's fixed search can miss.\n" +
339
361
  "• When they agree → high confidence. When they disagree → look at both scores and reason WHY (Stockfish sharply higher = tactic Lc0 missed; Lc0 higher = long-term positional edge past Stockfish's horizon). Never dismiss either — the disagreement is the signal.\n\n" +
340
362
  "Contempt (`contempt`) skews Lc0 (only Lc0 — Stockfish always stays objective) toward White (positive) or Black (negative). Practical range -20..+20. Use it to find non-objective 'practical' ideas or when the user needs to steer toward fighting/solid lines with a specific colour. Do NOT quote a contempt-biased eval as objective — cross-check with Stockfish.\n\n" +
363
+ "Also useful: pass `moves` on top of `fen` to explore a variation without computing FENs yourself (e.g. fen='<tabiya>', moves='b4 a5 c3'). And the flip-side-to-move threat check documented in the guide is a great free trick.\n\n" +
341
364
  "For the full guide including worked examples, call the `read_engine_usage_guide` tool.\n\n" +
342
365
  "Not for casual questions — this costs real money per second. Use the free `analyse` tool (single Stockfish, 2s) or `get_position_stats` for anything that doesn't require deep prep.",
343
366
  inputSchema: {
344
367
  type: "object",
345
368
  properties: {
346
- fen: { type: "string", description: "FEN of the position to analyse." },
369
+ fen: { type: "string", description: "Starting position as FEN. Combine with `moves` to walk from there." },
370
+ moves: {
371
+ type: "string",
372
+ description: "Optional SAN moves to apply on top of `fen` (or on top of startpos). Example: fen='<tabiya>', moves='b4 a5 c3' analyses the position after those three moves.",
373
+ },
347
374
  movetime_ms: {
348
375
  type: "integer",
349
376
  minimum: 100,
@@ -366,6 +393,84 @@ const TOOLS = [
366
393
  required: ["fen"],
367
394
  },
368
395
  },
396
+ {
397
+ name: "list_prep_files",
398
+ 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.",
399
+ inputSchema: { type: "object", properties: {} },
400
+ },
401
+ {
402
+ name: "search_prep_files",
403
+ 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').",
404
+ inputSchema: {
405
+ type: "object",
406
+ properties: {
407
+ query: { type: "string", description: "Free-text query (opponent name, opening name, event keyword)." },
408
+ },
409
+ required: ["query"],
410
+ },
411
+ },
412
+ {
413
+ name: "read_prep_file",
414
+ description: "Read one prep file. Returns the full PGN text and a compact tree JSON of the mainline (SAN + FEN per move) for programmatic reasoning. Also carries the `version` field you need to pass back to save_prep_file for the optimistic-lock check.",
415
+ inputSchema: {
416
+ type: "object",
417
+ properties: {
418
+ id: { type: "string", description: "Prep file id, from list_prep_files or search_prep_files." },
419
+ },
420
+ required: ["id"],
421
+ },
422
+ },
423
+ {
424
+ name: "create_prep_file",
425
+ description: "Create a new prep file. `name` becomes the Event PGN tag (user-facing label). Optional `pgn` seeds the file — if omitted, a minimal empty PGN is written and you can save_prep_file into it later.\n\n" +
426
+ "ALWAYS run list_prep_files (or search_prep_files with the opponent / opening keyword) BEFORE creating, so you don't duplicate an existing file. If a file already exists that fits, extend it via save_prep_file instead.",
427
+ inputSchema: {
428
+ type: "object",
429
+ properties: {
430
+ name: {
431
+ type: "string",
432
+ description: "User-facing name for the file — becomes the [Event] tag in the PGN. Example: 'Prep vs Firouzja (Black) 2026-07-23'.",
433
+ },
434
+ pgn: {
435
+ type: "string",
436
+ description: "Optional initial PGN content. Supports full PGN with headers and parenthesised variations. If omitted, an empty PGN is created.",
437
+ },
438
+ },
439
+ required: ["name"],
440
+ },
441
+ },
442
+ {
443
+ name: "save_prep_file",
444
+ description: "Save (replace) a prep file's PGN content. Optimistic-lock via `expected_version` — pass the `version` you got from read_prep_file. If someone else (user in the app, or another agent session) updated the file since you read it, save returns 409 with the current version — re-read and merge.\n\n" +
445
+ "PGN structure guidance:\n" +
446
+ "- Keep the mainline clean (your top recommendation).\n" +
447
+ "- Alternative candidates go in parenthesised variations at the branching move.\n" +
448
+ "- Attach one-sentence commentary at key branch points using PGN {curly-brace comments}. Cite the actual tool output ('Lc0 gives +0.15 in this line') — don't dress the file up with unsourced chess prose.\n" +
449
+ "- Use PGN NAGs for evaluations: $1 !, $2 ?, $3 !!, $4 ??, $6 =, $10 = (equal), $14 +/= (slight edge), etc. Optional but nice.",
450
+ inputSchema: {
451
+ type: "object",
452
+ properties: {
453
+ id: { type: "string", description: "Prep file id." },
454
+ pgn: { type: "string", description: "Full replacement PGN (headers + moves)." },
455
+ expected_version: {
456
+ type: "integer",
457
+ description: "The `version` you got from read_prep_file. If omitted, no optimistic check runs (last-write-wins — riskier).",
458
+ },
459
+ },
460
+ required: ["id", "pgn"],
461
+ },
462
+ },
463
+ {
464
+ name: "delete_prep_file",
465
+ description: "Soft-delete a prep file. The user can restore it from the chess.ceo app's recycle bin if you deleted something valuable. Rare — usually you extend or replace instead.",
466
+ inputSchema: {
467
+ type: "object",
468
+ properties: {
469
+ id: { type: "string", description: "Prep file id." },
470
+ },
471
+ required: ["id"],
472
+ },
473
+ },
369
474
  {
370
475
  name: "read_engine_usage_guide",
371
476
  description: "Returns the full chess.ceo engine-usage guide: when to trust Stockfish (objective truth) vs Lc0 (practical eval), how to read disagreements between them, and how to use Lc0 contempt to find non-objective 'practical' ideas. Call this ONCE per session before running expensive `cloud_analyse` calls or when the user asks WHY the engines gave certain scores. Same content is also available as the `engine_usage_primer` prompt (for clients that surface prompts as slash commands), but many clients do not expose prompts to the model — this tool works everywhere.",
@@ -376,6 +481,11 @@ const TOOLS = [
376
481
  description: "Returns the full chess.ceo prep-strategy guide: why win% is one weight not a verdict, why prep is a two-player game with symmetric information (opponent sees your history too), how sample size and recency change the reading, when 'revealed weaknesses' are actionable vs already patched, how to use move-order tricks with the `trs` field, and how to calibrate surprise (rare secondary lines inside the existing repertoire, not big first-move switches). Call this ONCE per session before recommending an opening plan, especially when the user is preparing for a specific real opponent.",
377
482
  inputSchema: { type: "object", properties: {} },
378
483
  },
484
+ {
485
+ name: "read_prep_files_guide",
486
+ description: "Returns the full guide on how to store prep in the user's chess.ceo account via list_prep_files / read_prep_file / create_prep_file / save_prep_file / delete_prep_file. Covers: how to structure a repertoire PGN (mainline + variations + comments + NAGs), the critical 'search-before-create' habit to avoid duplicates, optimistic-locking with `version`, and how to write comments that cite tool output instead of inventing chess prose. Call this ONCE per session before your first create_prep_file / save_prep_file call.",
487
+ inputSchema: { type: "object", properties: {} },
488
+ },
379
489
  {
380
490
  name: "prep_snapshot",
381
491
  description: "One call, three parallel fetches at the same position: opponent's stats on their side, your stats on your side, and the 11.7M-game general database at that position. Use this while walking the opening tree — one round trip instead of three separate calls, and you can compare the three views directly (e.g. opponent has 2 games here but the general DB has 8k → prep candidate).",
@@ -483,6 +593,24 @@ function convertCloudSnapshotResponse(raw, startFen) {
483
593
  }
484
594
  return raw;
485
595
  }
596
+ function pgnToTree(pgn) {
597
+ try {
598
+ const board = new Chess();
599
+ // chess.js loadPgn accepts full PGN incl. tags; loadPgn returns true on
600
+ // success or throws on newer versions. Wrap defensively.
601
+ board.loadPgn(pgn);
602
+ // history({verbose:true}) walks the mainline only; variations aren't
603
+ // exposed by chess.js's built-in PGN loader at time of writing. If the
604
+ // PGN carries parenthesised variations they get flattened.
605
+ const hist = board.history({ verbose: true });
606
+ if (hist.length === 0)
607
+ return [];
608
+ return hist.map(h => ({ san: h.san, fen: h.after }));
609
+ }
610
+ catch {
611
+ return null;
612
+ }
613
+ }
486
614
  // Rewrite availableMoves[].move UCI → SAN. The prep + position-stats
487
615
  // endpoints return moves in UCI on the wire — same LLM-readability
488
616
  // concern as engine PVs, and the same wrapper-only fix. Passes the
@@ -500,18 +628,26 @@ function convertAvailableMovesToSAN(raw, fen) {
500
628
  }
501
629
  return raw;
502
630
  }
503
- // Resolve a starting FEN from either the `fen` or `line` argument the tool
504
- // received. Same logic prep_snapshot already had inline — extracted so we
505
- // can reuse it wherever we need to walk a SAN line to a concrete FEN
506
- // (e.g. for UCISAN conversion of availableMoves).
631
+ // Resolve a starting FEN from any combination of `fen`, `line`, and
632
+ // `moves` the tool received. Three modes, all valid:
633
+ //
634
+ // fen alone use as-is
635
+ // line/moves alone → walk from startpos
636
+ // fen + moves (or line) → walk from that fen
637
+ //
638
+ // `line` is the historical field name from the backend's prep endpoint;
639
+ // `moves` is the flexible-input name we now surface for LLM ergonomics
640
+ // ("start from this FEN and play these moves next"). They're synonyms
641
+ // here — same SAN sequence, same chess.js walker. `moves` wins if both
642
+ // happen to be provided.
507
643
  function resolveFenFromArgs(args) {
508
644
  const fenArg = typeof args.fen === "string" ? args.fen.trim() : "";
509
- if (fenArg)
510
- return fenArg;
645
+ const movesArg = typeof args.moves === "string" ? args.moves.trim() : "";
511
646
  const lineArg = typeof args.line === "string" ? args.line.trim() : "";
512
- const board = new Chess();
513
- if (lineArg) {
514
- for (const raw of lineArg.split(/\s+/)) {
647
+ const sequence = movesArg || lineArg;
648
+ const board = fenArg ? new Chess(fenArg) : new Chess();
649
+ if (sequence) {
650
+ for (const raw of sequence.split(/\s+/)) {
515
651
  const san = raw.replace(/^\d+\.+/, "");
516
652
  if (!san)
517
653
  continue;
@@ -519,7 +655,7 @@ function resolveFenFromArgs(args) {
519
655
  board.move(san);
520
656
  }
521
657
  catch {
522
- throw new Error(`bad SAN token '${raw}' in line`);
658
+ throw new Error(`bad SAN token '${raw}' in moves`);
523
659
  }
524
660
  }
525
661
  }
@@ -561,15 +697,26 @@ async function callToolInner(name, args) {
561
697
  case "get_player_profile":
562
698
  return get("/api/chess/players/profile", { fideId: Number(args.fide_id) });
563
699
  case "get_player_preparation": {
700
+ // Prefer sending `line` to the backend when the caller specified moves
701
+ // WITHOUT a fen — the backend then attaches the cumulative-line SAN to
702
+ // each move in the response (nicer for the LLM's follow-up walks).
703
+ // When a fen was supplied, always resolve the effective position
704
+ // client-side and send just fen — the backend can't reconstruct
705
+ // history it didn't see anyway.
706
+ const fenArg = typeof args.fen === "string" ? args.fen.trim() : "";
707
+ const movesArg = typeof args.moves === "string" ? args.moves.trim() : "";
708
+ const lineArg = typeof args.line === "string" ? args.line.trim() : "";
564
709
  const params = {
565
710
  fideId: Number(args.fide_id),
566
711
  color: String(args.color),
567
712
  compact: "true",
568
713
  };
569
- if (typeof args.line === "string" && args.line.length > 0)
570
- params.line = args.line;
571
- if (typeof args.fen === "string" && args.fen.length > 0)
572
- params.fen = args.fen;
714
+ if (fenArg || movesArg) {
715
+ params.fen = resolveFenFromArgs(args);
716
+ }
717
+ else if (lineArg) {
718
+ params.line = lineArg;
719
+ }
573
720
  if (typeof args.limit === "number")
574
721
  params.limit = args.limit;
575
722
  if (typeof args.offset === "number")
@@ -578,7 +725,7 @@ async function callToolInner(name, args) {
578
725
  return convertAvailableMovesToSAN(raw, resolveFenFromArgs(args));
579
726
  }
580
727
  case "get_position_stats": {
581
- const fen = String(args.fen);
728
+ const fen = resolveFenFromArgs(args);
582
729
  const raw = await get("/api/chess/database/main", {
583
730
  fen,
584
731
  limit: typeof args.limit === "number" ? args.limit : 20,
@@ -587,7 +734,7 @@ async function callToolInner(name, args) {
587
734
  return convertAvailableMovesToSAN(raw, fen);
588
735
  }
589
736
  case "analyse": {
590
- const fen = String(args.fen);
737
+ const fen = resolveFenFromArgs(args);
591
738
  const params = { fen };
592
739
  if (typeof args.movetime_ms === "number")
593
740
  params.movetime_ms = args.movetime_ms;
@@ -621,7 +768,7 @@ async function callToolInner(name, args) {
621
768
  case "stop_cloud_engine":
622
769
  return authedRequest("DELETE", `/api/agent/cloud-engines/${encodeURIComponent(String(args.contract_id))}`);
623
770
  case "cloud_analyse": {
624
- const fen = String(args.fen);
771
+ const fen = resolveFenFromArgs(args);
625
772
  const body = { fen };
626
773
  if (typeof args.movetime_ms === "number")
627
774
  body.movetime_ms = args.movetime_ms;
@@ -632,10 +779,41 @@ async function callToolInner(name, args) {
632
779
  const raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
633
780
  return convertCloudSnapshotResponse(raw, fen);
634
781
  }
782
+ case "list_prep_files":
783
+ return authedRequest("GET", "/api/agent/prep-files");
784
+ case "search_prep_files":
785
+ return authedRequest("GET", `/api/agent/prep-files/search?q=${encodeURIComponent(String(args.query))}`);
786
+ case "read_prep_file": {
787
+ const raw = await authedRequest("GET", `/api/agent/prep-files/${encodeURIComponent(String(args.id))}`);
788
+ // MCP layer adds the tree JSON view — backend stays pure PGN in/out.
789
+ // If parse fails the LLM still has the raw PGN text to work with.
790
+ if (raw && typeof raw === "object") {
791
+ const g = raw;
792
+ if (typeof g.pgnContent === "string") {
793
+ const tree = pgnToTree(g.pgnContent);
794
+ return { ...raw, tree };
795
+ }
796
+ }
797
+ return raw;
798
+ }
799
+ case "create_prep_file":
800
+ return authedRequest("POST", "/api/agent/prep-files", {
801
+ name: String(args.name),
802
+ pgn: typeof args.pgn === "string" ? args.pgn : undefined,
803
+ });
804
+ case "save_prep_file":
805
+ return authedRequest("PUT", `/api/agent/prep-files/${encodeURIComponent(String(args.id))}`, {
806
+ pgn: String(args.pgn),
807
+ expected_version: typeof args.expected_version === "number" ? args.expected_version : undefined,
808
+ });
809
+ case "delete_prep_file":
810
+ return authedRequest("DELETE", `/api/agent/prep-files/${encodeURIComponent(String(args.id))}`);
635
811
  case "read_engine_usage_guide":
636
812
  return { guide: ENGINE_USAGE_DOC };
637
813
  case "read_prep_strategy_guide":
638
814
  return { guide: PREP_STRATEGY_DOC };
815
+ case "read_prep_files_guide":
816
+ return { guide: PREP_FILES_DOC };
639
817
  case "prep_snapshot": {
640
818
  const me = Number(args.fide_id_me);
641
819
  const opp = Number(args.fide_id_opponent);
@@ -0,0 +1,125 @@
1
+ # Prep files guide
2
+
3
+ You can save chess prep to the user's chess.ceo account and read it back across sessions. This is the persistence layer that turns your analysis from ephemeral chat into a durable, viewable-in-app repertoire.
4
+
5
+ ## The mental model
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.
8
+
9
+ Six tools:
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)` — full PGN + parsed mainline tree + `version`
14
+ - `create_prep_file(name, pgn?)` — new file, `name` becomes the [Event] tag
15
+ - `save_prep_file(id, pgn, expected_version)` — replace the whole PGN
16
+ - `delete_prep_file(id)` — soft delete (user can restore from app)
17
+
18
+ ## The single most important habit
19
+
20
+ **Before creating a new file, search for an existing one.** LLMs make three "Prep vs Firouzja" files in a row all the time. Always:
21
+
22
+ 1. `list_prep_files` (if the user has ≤20-30 files) or `search_prep_files(query=<opponent name>)` for their key term
23
+ 2. Read the ones that look relevant
24
+ 3. Decide: extend an existing one (save_prep_file) or genuinely start fresh (create_prep_file)
25
+
26
+ Duplicate files are the #1 way to lose your user's trust in this system.
27
+
28
+ ## PGN structure
29
+
30
+ Real chess prep files are PGN with parenthesised variations. Learn the shape:
31
+
32
+ ```pgn
33
+ [Event "Prep vs Firouzja (Black) — 2026-07-23"]
34
+ [White "Firouzja, Alireza"]
35
+ [Black "Van Foreest, Jorden"]
36
+ [Date "2026.07.23"]
37
+
38
+ 1. e4 c5 2. Nf3 d6 3. d4 cxd4 4. Nxd4 Nf6 5. Nc3 a6 6. Be3 e5 7. Nb3
39
+ {Main line. Firouzja plays this in 68% of his English Attacks (32 games, 2023-2025).}
40
+ 7... Be6 8. f3
41
+ (8. Qd2 Nbd7 9. f3 h5 {Sideline he tried once and lost — probably patched.})
42
+ 8... Nbd7 9. Qd2 h5
43
+ {Critical position for us. Lc0 at multipv=3 gives:
44
+ - h5: +0.12 (main line, active)
45
+ - Be7: +0.08 (calm, more classical)
46
+ - Rc8: 0.00 (Stockfish preferred)}
47
+
48
+ *
49
+ ```
50
+
51
+ The pieces:
52
+
53
+ - **PGN tag pairs at the top** (`[Key "Value"]`) — `Event` is the name you show the user. `White` / `Black` set the game headers. `Date` is standard.
54
+ - **Mainline** = your top recommendation. Numbered SAN moves separated by whitespace.
55
+ - **Variations** in parentheses `(...)` at the branching move. Nesting is allowed but be conservative — three deep is a lot.
56
+ - **Comments** in curly braces `{...}` between moves. This is where you cite tool output:
57
+ - `{Lc0 says +0.15 in this line vs 0.00 for the alternative}`
58
+ - `{Firouzja plays this in 32/47 games with Black. Scores 41%.}`
59
+ - `{Stockfish sees h4 as a tactical shot; Lc0 disagrees. Deep look needed.}`
60
+ - **NAG glyphs** for evaluations at a move (optional): `$1` (!), `$2` (?), `$3` (!!), `$4` (??), `$6` (=), `$10` (=), `$14` (+/=). Placed right after the SAN: `7. Nb3 $1`.
61
+ - **Result marker** at the end: `*` for unfinished (prep files are always unfinished), `1-0`, `0-1`, `1/2-1/2`.
62
+
63
+ ## Editing without breaking the tree
64
+
65
+ You get the whole PGN back from `read_prep_file`, edit it in your head, send back via `save_prep_file`. There's no partial-patch API — small edits still resend the full text. The file is small (repertoire = maybe 5-20 KB), that's fine.
66
+
67
+ Common LLM failure modes to catch yourself doing:
68
+
69
+ - **Unbalanced parentheses.** Every `(` needs a matching `)`. Count them if you added variations. The backend parses on save; if invalid, you get 400 with the parser error and have to retry.
70
+ - **Bad SAN.** `Nfd7` where you meant `Nbd7`. Always trace the position in your head (or use the tree from read_prep_file's response) before writing a move.
71
+ - **Forgotten move numbers.** `1. e4 c5 2. Nf3` — after each White move you need `<num>.`, after each Black move the number continues implicitly until the next full move. In variations at Black's move, PGN wants `2... Nc6`.
72
+ - **Nested variations losing context.** `1.e4 e5 (1...c5 (2.Nf3 d6))` — the inner variation branches at `2.Nf3` off the c5 sideline, not off the mainline. This gets confusing fast; two levels is usually enough.
73
+ - **Blank Event tag** — the user's list_prep_files response shows the Event tag as the name. Empty tag = "Untitled" everywhere.
74
+
75
+ ## Optimistic locking
76
+
77
+ `read_prep_file` returns a `version` integer. Pass it back as `expected_version` on `save_prep_file`. If someone else (the user in the app, or a parallel agent session) updated the file since you read it, save returns `409 Conflict` with the current version. You should:
78
+
79
+ 1. Re-read the file to see what changed.
80
+ 2. Merge your edits with theirs.
81
+ 3. Retry the save with the new version.
82
+
83
+ If you don't pass `expected_version`, it's last-write-wins — you might silently overwrite the user's manual edits. Only skip it when you know the file is untouched (e.g. you just created it).
84
+
85
+ ## Grounding
86
+
87
+ Same rule as everywhere else in this MCP: **cite tool output in your PGN comments, don't invent chess prose**. A prep file that reads "White has practical chances" without a tool call to back it up is worse than a file that says "TODO: run cloud_analyse here". The user can view the file; if the commentary doesn't match the actual engine output, they see it.
88
+
89
+ Concrete pattern:
90
+
91
+ ```
92
+ 7. Nb3 {Lc0 at movetime=2000 gives +0.14 for White in the resulting IQP structure.
93
+ Stockfish scores 0.00 — the disagreement is the long-term positional weight Lc0
94
+ sees on the c6 pawn. Verified by running cloud_analyse at move 12 and confirming
95
+ Lc0's evaluation persists.}
96
+ ```
97
+
98
+ vs
99
+
100
+ ```
101
+ 7. Nb3 {A strong positional move that gives White good chances.}
102
+ ```
103
+
104
+ The first is what earns trust; the second is what makes the file worthless.
105
+
106
+ ## Naming conventions
107
+
108
+ For the [Event] tag (which is the user-visible file name):
109
+
110
+ - Opponent prep: `"Prep vs <Player> (<Color>) — <YYYY-MM-DD>"`
111
+ - Opening study: `"<Opening> — <side>"` e.g. `"Najdorf 6.Bg5 — Black side"`
112
+ - Position analysis: `"<Position description> — <date>"`
113
+
114
+ Keep it short enough to fit in a picker (30-40 chars). Long titles get truncated in the app UI.
115
+
116
+ ## When to create vs extend
117
+
118
+ - User asks "prep me against X" and no file matches → create.
119
+ - User asks "check line Y for our Firouzja prep" and a file matches → extend that file with new variations, save with expected_version.
120
+ - User asks "I found a novelty in the Najdorf" and a Najdorf file exists → extend.
121
+ - Rule of thumb: if the user's request semantically overlaps with an existing file's [Event] name or main opening line, extend.
122
+
123
+ ## Icons and appearance
124
+
125
+ 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chessceo/mcp",
3
- "version": "0.15.0",
3
+ "version": "0.17.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": {