@chessceo/mcp 0.12.0 → 0.16.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
@@ -185,11 +185,15 @@ const TOOLS = [
185
185
  },
186
186
  line: {
187
187
  type: "string",
188
- description: "Move sequence in SAN, space-separated, no move numbers required. Example: 'e4 e5 Nf3'. Leave empty for the starting position.",
188
+ 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).",
189
+ },
190
+ moves: {
191
+ type: "string",
192
+ 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
193
  },
190
194
  fen: {
191
195
  type: "string",
192
- description: "Alternative to `line` raw FEN of the target position.",
196
+ description: "Starting position as FEN. Combine with `moves` to walk from there, or use alone.",
193
197
  },
194
198
  limit: {
195
199
  type: "integer",
@@ -210,7 +214,15 @@ const TOOLS = [
210
214
  properties: {
211
215
  fen: {
212
216
  type: "string",
213
- description: "FEN of the position to look up.",
217
+ description: "Starting position as FEN. Combine with `moves` to walk from there.",
218
+ },
219
+ moves: {
220
+ type: "string",
221
+ description: "Optional SAN moves to apply on top of `fen` (or on top of startpos). Example: fen='<tabiya>', moves='b4 a5'.",
222
+ },
223
+ line: {
224
+ type: "string",
225
+ description: "Synonym for `moves` from the starting position; kept for compatibility.",
214
226
  },
215
227
  limit: {
216
228
  type: "integer",
@@ -219,18 +231,22 @@ const TOOLS = [
219
231
  description: "Number of top continuations to return.",
220
232
  },
221
233
  },
222
- required: ["fen"],
223
234
  },
224
235
  },
225
236
  {
226
237
  name: "analyse",
227
- 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 UCI notation (e2e4, not e4). Free (no cloud instance needed) — use liberally.\n\n" +
238
+ 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
239
  "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" +
240
+ "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
241
  "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
242
  inputSchema: {
231
243
  type: "object",
232
244
  properties: {
233
- fen: { type: "string", description: "FEN of the position to analyse." },
245
+ fen: { type: "string", description: "Starting position as FEN (defaults to startpos)." },
246
+ moves: {
247
+ type: "string",
248
+ description: "Optional SAN moves to apply on top of `fen` (or on top of startpos). Example: fen='<tabiya>', moves='b4 a5'.",
249
+ },
234
250
  movetime_ms: {
235
251
  type: "integer",
236
252
  minimum: 100,
@@ -244,7 +260,6 @@ const TOOLS = [
244
260
  description: "Number of candidate lines to return (default 3).",
245
261
  },
246
262
  },
247
- required: ["fen"],
248
263
  },
249
264
  },
250
265
  {
@@ -338,12 +353,17 @@ const TOOLS = [
338
353
  "• 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
354
  "• 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
355
  "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" +
356
+ "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
357
  "For the full guide including worked examples, call the `read_engine_usage_guide` tool.\n\n" +
342
358
  "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
359
  inputSchema: {
344
360
  type: "object",
345
361
  properties: {
346
- fen: { type: "string", description: "FEN of the position to analyse." },
362
+ fen: { type: "string", description: "Starting position as FEN. Combine with `moves` to walk from there." },
363
+ moves: {
364
+ type: "string",
365
+ 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.",
366
+ },
347
367
  movetime_ms: {
348
368
  type: "integer",
349
369
  minimum: 100,
@@ -404,6 +424,135 @@ const TOOLS = [
404
424
  // capped so the two doc-reading tools (~5-10 KB of static markdown each)
405
425
  // don't drown the log stream.
406
426
  const LOG_MAX_CHARS = 4096;
427
+ // Convert a UCI move sequence into SAN by walking it move-by-move on
428
+ // chess.js from the given starting FEN. LLMs reason far better in SAN
429
+ // ("Nf3", "Bxc4") than UCI ("g1f3", "b5c4"), and matches how prep
430
+ // discussion is written in the real world. If a move fails to parse
431
+ // (illegal from the current position — bug or truncated PV), we
432
+ // truncate cleanly rather than throwing so the response still carries
433
+ // what we could convert.
434
+ function uciLineToSAN(startFen, uciMoves) {
435
+ const board = new Chess(startFen);
436
+ const out = [];
437
+ for (const uci of uciMoves) {
438
+ if (uci.length < 4)
439
+ break;
440
+ try {
441
+ const move = board.move({
442
+ from: uci.slice(0, 2),
443
+ to: uci.slice(2, 4),
444
+ promotion: uci.length >= 5 ? uci[4] : undefined,
445
+ });
446
+ if (!move)
447
+ break;
448
+ out.push(move.san);
449
+ }
450
+ catch {
451
+ break;
452
+ }
453
+ }
454
+ return out;
455
+ }
456
+ function uciMoveToSAN(startFen, uci) {
457
+ if (!uci || uci.length < 4)
458
+ return uci;
459
+ const board = new Chess(startFen);
460
+ try {
461
+ const move = board.move({
462
+ from: uci.slice(0, 2),
463
+ to: uci.slice(2, 4),
464
+ promotion: uci.length >= 5 ? uci[4] : undefined,
465
+ });
466
+ return move ? move.san : uci;
467
+ }
468
+ catch {
469
+ return uci;
470
+ }
471
+ }
472
+ // Rewrite the local /chess/database/analyse response (single engine)
473
+ // so PVs come back in SAN.
474
+ function convertAnalyseResponse(raw, startFen) {
475
+ if (!raw || typeof raw !== "object")
476
+ return raw;
477
+ const r = raw;
478
+ if (Array.isArray(r.lines)) {
479
+ for (const line of r.lines) {
480
+ if (Array.isArray(line.pv))
481
+ line.pv = uciLineToSAN(startFen, line.pv);
482
+ }
483
+ }
484
+ return raw;
485
+ }
486
+ // Rewrite the /api/agent/cloud-engines/analyse response (two engines,
487
+ // each with lines[] and a bestMove) so PVs and bestMove come back in SAN.
488
+ function convertCloudSnapshotResponse(raw, startFen) {
489
+ if (!raw || typeof raw !== "object")
490
+ return raw;
491
+ const r = raw;
492
+ for (const eng of [r.stockfish, r.lc0]) {
493
+ if (!eng)
494
+ continue;
495
+ if (Array.isArray(eng.lines)) {
496
+ for (const line of eng.lines) {
497
+ if (Array.isArray(line.pv))
498
+ line.pv = uciLineToSAN(startFen, line.pv);
499
+ }
500
+ }
501
+ if (typeof eng.bestMove === "string")
502
+ eng.bestMove = uciMoveToSAN(startFen, eng.bestMove);
503
+ }
504
+ return raw;
505
+ }
506
+ // Rewrite availableMoves[].move UCI → SAN. The prep + position-stats
507
+ // endpoints return moves in UCI on the wire — same LLM-readability
508
+ // concern as engine PVs, and the same wrapper-only fix. Passes the
509
+ // response through unchanged if there's no availableMoves array.
510
+ function convertAvailableMovesToSAN(raw, fen) {
511
+ if (!raw || typeof raw !== "object")
512
+ return raw;
513
+ const r = raw;
514
+ if (!Array.isArray(r.availableMoves))
515
+ return raw;
516
+ for (const m of r.availableMoves) {
517
+ if (typeof m.move === "string" && m.move.length >= 4) {
518
+ m.move = uciMoveToSAN(fen, m.move);
519
+ }
520
+ }
521
+ return raw;
522
+ }
523
+ // Resolve a starting FEN from any combination of `fen`, `line`, and
524
+ // `moves` the tool received. Three modes, all valid:
525
+ //
526
+ // fen alone → use as-is
527
+ // line/moves alone → walk from startpos
528
+ // fen + moves (or line) → walk from that fen
529
+ //
530
+ // `line` is the historical field name from the backend's prep endpoint;
531
+ // `moves` is the flexible-input name we now surface for LLM ergonomics
532
+ // ("start from this FEN and play these moves next"). They're synonyms
533
+ // here — same SAN sequence, same chess.js walker. `moves` wins if both
534
+ // happen to be provided.
535
+ function resolveFenFromArgs(args) {
536
+ const fenArg = typeof args.fen === "string" ? args.fen.trim() : "";
537
+ const movesArg = typeof args.moves === "string" ? args.moves.trim() : "";
538
+ const lineArg = typeof args.line === "string" ? args.line.trim() : "";
539
+ const sequence = movesArg || lineArg;
540
+ const board = fenArg ? new Chess(fenArg) : new Chess();
541
+ if (sequence) {
542
+ for (const raw of sequence.split(/\s+/)) {
543
+ const san = raw.replace(/^\d+\.+/, "");
544
+ if (!san)
545
+ continue;
546
+ try {
547
+ board.move(san);
548
+ }
549
+ catch {
550
+ throw new Error(`bad SAN token '${raw}' in moves`);
551
+ }
552
+ }
553
+ }
554
+ return board.fen();
555
+ }
407
556
  function stringifyForLog(v) {
408
557
  let s;
409
558
  try {
@@ -440,34 +589,51 @@ async function callToolInner(name, args) {
440
589
  case "get_player_profile":
441
590
  return get("/api/chess/players/profile", { fideId: Number(args.fide_id) });
442
591
  case "get_player_preparation": {
592
+ // Prefer sending `line` to the backend when the caller specified moves
593
+ // WITHOUT a fen — the backend then attaches the cumulative-line SAN to
594
+ // each move in the response (nicer for the LLM's follow-up walks).
595
+ // When a fen was supplied, always resolve the effective position
596
+ // client-side and send just fen — the backend can't reconstruct
597
+ // history it didn't see anyway.
598
+ const fenArg = typeof args.fen === "string" ? args.fen.trim() : "";
599
+ const movesArg = typeof args.moves === "string" ? args.moves.trim() : "";
600
+ const lineArg = typeof args.line === "string" ? args.line.trim() : "";
443
601
  const params = {
444
602
  fideId: Number(args.fide_id),
445
603
  color: String(args.color),
446
604
  compact: "true",
447
605
  };
448
- if (typeof args.line === "string" && args.line.length > 0)
449
- params.line = args.line;
450
- if (typeof args.fen === "string" && args.fen.length > 0)
451
- params.fen = args.fen;
606
+ if (fenArg || movesArg) {
607
+ params.fen = resolveFenFromArgs(args);
608
+ }
609
+ else if (lineArg) {
610
+ params.line = lineArg;
611
+ }
452
612
  if (typeof args.limit === "number")
453
613
  params.limit = args.limit;
454
614
  if (typeof args.offset === "number")
455
615
  params.offset = args.offset;
456
- return get("/api/chess/prep/by-player", params);
616
+ const raw = await get("/api/chess/prep/by-player", params);
617
+ return convertAvailableMovesToSAN(raw, resolveFenFromArgs(args));
457
618
  }
458
- case "get_position_stats":
459
- return get("/api/chess/database/main", {
460
- fen: String(args.fen),
619
+ case "get_position_stats": {
620
+ const fen = resolveFenFromArgs(args);
621
+ const raw = await get("/api/chess/database/main", {
622
+ fen,
461
623
  limit: typeof args.limit === "number" ? args.limit : 20,
462
624
  sort: "relevance",
463
625
  });
626
+ return convertAvailableMovesToSAN(raw, fen);
627
+ }
464
628
  case "analyse": {
465
- const params = { fen: String(args.fen) };
629
+ const fen = resolveFenFromArgs(args);
630
+ const params = { fen };
466
631
  if (typeof args.movetime_ms === "number")
467
632
  params.movetime_ms = args.movetime_ms;
468
633
  if (typeof args.multipv === "number")
469
634
  params.multipv = args.multipv;
470
- return get("/api/chess/database/analyse", params);
635
+ const raw = await get("/api/chess/database/analyse", params);
636
+ return convertAnalyseResponse(raw, fen);
471
637
  }
472
638
  case "get_head_to_head":
473
639
  return get("/api/chess/players/h2h", {
@@ -494,14 +660,16 @@ async function callToolInner(name, args) {
494
660
  case "stop_cloud_engine":
495
661
  return authedRequest("DELETE", `/api/agent/cloud-engines/${encodeURIComponent(String(args.contract_id))}`);
496
662
  case "cloud_analyse": {
497
- const body = { fen: String(args.fen) };
663
+ const fen = resolveFenFromArgs(args);
664
+ const body = { fen };
498
665
  if (typeof args.movetime_ms === "number")
499
666
  body.movetime_ms = args.movetime_ms;
500
667
  if (typeof args.multipv === "number")
501
668
  body.multipv = args.multipv;
502
669
  if (typeof args.contempt === "number")
503
670
  body.contempt = args.contempt;
504
- return authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
671
+ const raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
672
+ return convertCloudSnapshotResponse(raw, fen);
505
673
  }
506
674
  case "read_engine_usage_guide":
507
675
  return { guide: ENGINE_USAGE_DOC };
@@ -549,9 +717,9 @@ async function callToolInner(name, args) {
549
717
  ]);
550
718
  return {
551
719
  position: { line, fen, my_color: myColor },
552
- opponent,
553
- you,
554
- general,
720
+ opponent: convertAvailableMovesToSAN(opponent, fen),
721
+ you: convertAvailableMovesToSAN(you, fen),
722
+ general: convertAvailableMovesToSAN(general, fen),
555
723
  };
556
724
  }
557
725
  default:
@@ -75,6 +75,19 @@ The eval Lc0 returns when contempt is set is **not** the objective eval — it's
75
75
  - **Playing for a win with the "worse" side** — negative contempt if the user needs to avoid drawing lines with Black in a must-win situation.
76
76
  - **Never with zero direction** — always know *why* you're skewing before you set contempt. It's a specific tool for a specific question, not a default knob.
77
77
 
78
+ ## Tricks worth knowing
79
+
80
+ ### Flip side-to-move to see threats
81
+
82
+ If it's Black to move and you want to know what threats *White* has (i.e. "if Black passes, what does White do?"), take the FEN and flip the side-to-move field from `b` to `w` (or vice versa), then run analysis. This is the engine equivalent of the "null move" idea humans use to check for prophylaxis.
83
+
84
+ - Real position: `... b KQkq -` — analyse says +0.2 for Black
85
+ - Flipped: `... w KQkq -` — analyse says +1.4 for White
86
+
87
+ The gap (1.4 - 0.2 = 1.2) is roughly the value of the tempo Black has to spend defending. If it's huge, Black is under real pressure even if the current eval looks calm. If it's tiny, White has no immediate threats and Black can improve at leisure.
88
+
89
+ **Gotcha:** flipping side-to-move invalidates the en passant target field (if the last move was a two-square pawn push, the ep square is now stale). Also, both sides are counted as still having whichever castling rights are in the FEN — don't do this in the middle of a castling sequence. For opening / middlegame threat-checking it's a very useful idiom.
90
+
78
91
  ## Worked example
79
92
 
80
93
  User is preparing Black against a 2600 opponent who plays 1.e4 c5 2.Nf3 d6 3.d4 cxd4 4.Nxd4 Nf6 5.Nc3 a6 6.Be3 e5. You want to know if 7.Nb3 or 7.Nf3 is more testing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chessceo/mcp",
3
- "version": "0.12.0",
3
+ "version": "0.16.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": {