@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 +176 -12
- package/docs/engine-usage.md +13 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
425
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
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