@chessceo/mcp 0.11.0 → 0.15.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
@@ -224,7 +224,7 @@ const TOOLS = [
224
224
  },
225
225
  {
226
226
  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" +
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 SAN (e4, Nf3, Bxc4 — not UCI). Free (no cloud instance needed) — use liberally.\n\n" +
228
228
  "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" +
229
229
  "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
230
  inputSchema: {
@@ -398,7 +398,163 @@ const TOOLS = [
398
398
  },
399
399
  },
400
400
  ];
401
+ // Log every tool call in and out. Keeps args + response payloads together
402
+ // with a per-call duration so we can trace what the LLM asked for and what
403
+ // it got back on the same journalctl line. Response is JSON-stringified and
404
+ // capped so the two doc-reading tools (~5-10 KB of static markdown each)
405
+ // don't drown the log stream.
406
+ const LOG_MAX_CHARS = 4096;
407
+ // Convert a UCI move sequence into SAN by walking it move-by-move on
408
+ // chess.js from the given starting FEN. LLMs reason far better in SAN
409
+ // ("Nf3", "Bxc4") than UCI ("g1f3", "b5c4"), and matches how prep
410
+ // discussion is written in the real world. If a move fails to parse
411
+ // (illegal from the current position — bug or truncated PV), we
412
+ // truncate cleanly rather than throwing so the response still carries
413
+ // what we could convert.
414
+ function uciLineToSAN(startFen, uciMoves) {
415
+ const board = new Chess(startFen);
416
+ const out = [];
417
+ for (const uci of uciMoves) {
418
+ if (uci.length < 4)
419
+ break;
420
+ try {
421
+ const move = board.move({
422
+ from: uci.slice(0, 2),
423
+ to: uci.slice(2, 4),
424
+ promotion: uci.length >= 5 ? uci[4] : undefined,
425
+ });
426
+ if (!move)
427
+ break;
428
+ out.push(move.san);
429
+ }
430
+ catch {
431
+ break;
432
+ }
433
+ }
434
+ return out;
435
+ }
436
+ function uciMoveToSAN(startFen, uci) {
437
+ if (!uci || uci.length < 4)
438
+ return uci;
439
+ const board = new Chess(startFen);
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
+ return move ? move.san : uci;
447
+ }
448
+ catch {
449
+ return uci;
450
+ }
451
+ }
452
+ // Rewrite the local /chess/database/analyse response (single engine)
453
+ // so PVs come back in SAN.
454
+ function convertAnalyseResponse(raw, startFen) {
455
+ if (!raw || typeof raw !== "object")
456
+ return raw;
457
+ const r = raw;
458
+ if (Array.isArray(r.lines)) {
459
+ for (const line of r.lines) {
460
+ if (Array.isArray(line.pv))
461
+ line.pv = uciLineToSAN(startFen, line.pv);
462
+ }
463
+ }
464
+ return raw;
465
+ }
466
+ // Rewrite the /api/agent/cloud-engines/analyse response (two engines,
467
+ // each with lines[] and a bestMove) so PVs and bestMove come back in SAN.
468
+ function convertCloudSnapshotResponse(raw, startFen) {
469
+ if (!raw || typeof raw !== "object")
470
+ return raw;
471
+ const r = raw;
472
+ for (const eng of [r.stockfish, r.lc0]) {
473
+ if (!eng)
474
+ continue;
475
+ if (Array.isArray(eng.lines)) {
476
+ for (const line of eng.lines) {
477
+ if (Array.isArray(line.pv))
478
+ line.pv = uciLineToSAN(startFen, line.pv);
479
+ }
480
+ }
481
+ if (typeof eng.bestMove === "string")
482
+ eng.bestMove = uciMoveToSAN(startFen, eng.bestMove);
483
+ }
484
+ return raw;
485
+ }
486
+ // Rewrite availableMoves[].move UCI → SAN. The prep + position-stats
487
+ // endpoints return moves in UCI on the wire — same LLM-readability
488
+ // concern as engine PVs, and the same wrapper-only fix. Passes the
489
+ // response through unchanged if there's no availableMoves array.
490
+ function convertAvailableMovesToSAN(raw, fen) {
491
+ if (!raw || typeof raw !== "object")
492
+ return raw;
493
+ const r = raw;
494
+ if (!Array.isArray(r.availableMoves))
495
+ return raw;
496
+ for (const m of r.availableMoves) {
497
+ if (typeof m.move === "string" && m.move.length >= 4) {
498
+ m.move = uciMoveToSAN(fen, m.move);
499
+ }
500
+ }
501
+ return raw;
502
+ }
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 UCI→SAN conversion of availableMoves).
507
+ function resolveFenFromArgs(args) {
508
+ const fenArg = typeof args.fen === "string" ? args.fen.trim() : "";
509
+ if (fenArg)
510
+ return fenArg;
511
+ 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+/)) {
515
+ const san = raw.replace(/^\d+\.+/, "");
516
+ if (!san)
517
+ continue;
518
+ try {
519
+ board.move(san);
520
+ }
521
+ catch {
522
+ throw new Error(`bad SAN token '${raw}' in line`);
523
+ }
524
+ }
525
+ }
526
+ return board.fen();
527
+ }
528
+ function stringifyForLog(v) {
529
+ let s;
530
+ try {
531
+ s = JSON.stringify(v);
532
+ }
533
+ catch {
534
+ s = String(v);
535
+ }
536
+ if (s.length > LOG_MAX_CHARS) {
537
+ s = s.slice(0, LOG_MAX_CHARS) + `…+${s.length - LOG_MAX_CHARS}chars`;
538
+ }
539
+ return s;
540
+ }
401
541
  async function callTool(name, args) {
542
+ const started = Date.now();
543
+ console.error(`[mcp] IN ${name} args=${stringifyForLog(args)}`);
544
+ try {
545
+ const result = await callToolInner(name, args);
546
+ const dur = Date.now() - started;
547
+ console.error(`[mcp] OUT ${name} ok ${dur}ms result=${stringifyForLog(result)}`);
548
+ return result;
549
+ }
550
+ catch (err) {
551
+ const dur = Date.now() - started;
552
+ const msg = err instanceof Error ? err.message : String(err);
553
+ console.error(`[mcp] OUT ${name} err ${dur}ms error=${JSON.stringify(msg)}`);
554
+ throw err;
555
+ }
556
+ }
557
+ async function callToolInner(name, args) {
402
558
  switch (name) {
403
559
  case "search_player":
404
560
  return get("/api/chess/players/search/simple", { q: String(args.name), view: "llm" });
@@ -418,21 +574,27 @@ async function callTool(name, args) {
418
574
  params.limit = args.limit;
419
575
  if (typeof args.offset === "number")
420
576
  params.offset = args.offset;
421
- return get("/api/chess/prep/by-player", params);
577
+ const raw = await get("/api/chess/prep/by-player", params);
578
+ return convertAvailableMovesToSAN(raw, resolveFenFromArgs(args));
422
579
  }
423
- case "get_position_stats":
424
- return get("/api/chess/database/main", {
425
- fen: String(args.fen),
580
+ case "get_position_stats": {
581
+ const fen = String(args.fen);
582
+ const raw = await get("/api/chess/database/main", {
583
+ fen,
426
584
  limit: typeof args.limit === "number" ? args.limit : 20,
427
585
  sort: "relevance",
428
586
  });
587
+ return convertAvailableMovesToSAN(raw, fen);
588
+ }
429
589
  case "analyse": {
430
- const params = { fen: String(args.fen) };
590
+ const fen = String(args.fen);
591
+ const params = { fen };
431
592
  if (typeof args.movetime_ms === "number")
432
593
  params.movetime_ms = args.movetime_ms;
433
594
  if (typeof args.multipv === "number")
434
595
  params.multipv = args.multipv;
435
- return get("/api/chess/database/analyse", params);
596
+ const raw = await get("/api/chess/database/analyse", params);
597
+ return convertAnalyseResponse(raw, fen);
436
598
  }
437
599
  case "get_head_to_head":
438
600
  return get("/api/chess/players/h2h", {
@@ -459,14 +621,16 @@ async function callTool(name, args) {
459
621
  case "stop_cloud_engine":
460
622
  return authedRequest("DELETE", `/api/agent/cloud-engines/${encodeURIComponent(String(args.contract_id))}`);
461
623
  case "cloud_analyse": {
462
- const body = { fen: String(args.fen) };
624
+ const fen = String(args.fen);
625
+ const body = { fen };
463
626
  if (typeof args.movetime_ms === "number")
464
627
  body.movetime_ms = args.movetime_ms;
465
628
  if (typeof args.multipv === "number")
466
629
  body.multipv = args.multipv;
467
630
  if (typeof args.contempt === "number")
468
631
  body.contempt = args.contempt;
469
- return authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
632
+ const raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
633
+ return convertCloudSnapshotResponse(raw, fen);
470
634
  }
471
635
  case "read_engine_usage_guide":
472
636
  return { guide: ENGINE_USAGE_DOC };
@@ -514,9 +678,9 @@ async function callTool(name, args) {
514
678
  ]);
515
679
  return {
516
680
  position: { line, fen, my_color: myColor },
517
- opponent,
518
- you,
519
- general,
681
+ opponent: convertAvailableMovesToSAN(opponent, fen),
682
+ you: convertAvailableMovesToSAN(you, fen),
683
+ general: convertAvailableMovesToSAN(general, fen),
520
684
  };
521
685
  }
522
686
  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.11.0",
3
+ "version": "0.15.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": {