@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 +191 -23
- package/docs/engine-usage.md +13 -0
- package/package.json +1 -1
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
|
|
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: "
|
|
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
|
|
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
|
|
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: "
|
|
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
|
|
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 (
|
|
449
|
-
params.
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
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
|
-
|
|
460
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
package/docs/engine-usage.md
CHANGED
|
@@ -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