@chessceo/mcp 0.33.1 → 0.34.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
@@ -89,6 +89,9 @@ const AUTHED_TOOLS = new Set([
89
89
  "auto_evaluate",
90
90
  "auto_evaluate_status",
91
91
  "auto_evaluate_cancel",
92
+ "deep_analyse",
93
+ "deep_analyse_status",
94
+ "deep_analyse_cancel",
92
95
  "quote_engine_eval",
93
96
  "predict_human_move",
94
97
  "prepare_opponent",
@@ -508,6 +511,11 @@ const TOOLS = [
508
511
  maximum: 100,
509
512
  description: "Lc0 contempt bias. Signed 0-100 strength (same scale as the web UI's ContemptStrength slider — server multiplies by 8 to get the internal cp bias). 0 = objective (default). Positive favours White, negative favours Black. Typical: ±15 light nudge, ±30-60 real fighting play, ±80-100 maximum steer. Not applied to Stockfish. See engine_usage_primer for when to use.",
510
513
  },
514
+ engines: {
515
+ type: "array",
516
+ items: { type: "string", enum: ["stockfish", "lc0"] },
517
+ description: "Which engines to run. Default = both. Use `[\"lc0\"]` to skip Stockfish (e.g. while a deep_analyse job is holding the SF slot on the same combo). Use `[\"stockfish\"]` when only the objective read matters. The skipped engine's field is omitted from the response.",
518
+ },
511
519
  },
512
520
  },
513
521
  },
@@ -763,6 +771,56 @@ const TOOLS = [
763
771
  required: ["job_id"],
764
772
  },
765
773
  },
774
+ {
775
+ name: "deep_analyse",
776
+ description: "Start a long Stockfish think on a single position (up to 5 min movetime). Returns a `job_id` immediately; poll `deep_analyse_status(job_id)` for the result, cancel with `deep_analyse_cancel(job_id)`. Runs SF only — Lc0 doesn't benefit from long thinks past a handful of seconds — and **holds only the SF engine slot on the combo, so `cloud_analyse(..., engines: [\"lc0\"])` stays available for other work in parallel**.\n\n" +
777
+ "Use this when a specific critical position deserves depth — a novelty candidate, a hairy tactical shot, a difficult endgame — and you want Stockfish at depth 35+ rather than the ~depth 22 you get from a 2s cloud_analyse. Movetime is in ms; typical: 30_000-60_000 for 'careful check', 120_000-300_000 for 'find the truth'.\n\n" +
778
+ "Result shape when done matches cloud_analyse's Stockfish leg (depth, top-N candidates with scoreCp/mate, best move, PV). Auto-stores the eval on `file_id`+`node_id` when both are supplied, same as cloud_analyse.",
779
+ inputSchema: {
780
+ type: "object",
781
+ properties: {
782
+ file_id: { type: "string", description: "Prep file id. Combine with `node_id` to derive FEN from the tree AND persist the result on the node's ceoEval." },
783
+ node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`/`moves`." },
784
+ fen: { type: "string", description: "Position as FEN. Only used if `node_id` is not set." },
785
+ moves: { type: "string", description: "Optional SAN moves on top of `fen`. Only used if `node_id` is not set." },
786
+ movetime_ms: {
787
+ type: "integer",
788
+ minimum: 5_000,
789
+ maximum: 300_000,
790
+ description: "Think time in ms. Default 60_000 (1 min). Max 300_000 (5 min).",
791
+ },
792
+ multipv: {
793
+ type: "integer",
794
+ minimum: 1,
795
+ maximum: 10,
796
+ description: "Number of candidate lines (default 3).",
797
+ },
798
+ },
799
+ },
800
+ },
801
+ {
802
+ name: "deep_analyse_status",
803
+ description: "Poll a deep_analyse job. Response: `{ status: 'running' | 'done' | 'cancelled' | 'error' | 'not_found', elapsed_ms, movetime_ms, result?, error? }`. `result` shape when done: `{ engine, depth, timeMs, bestMove, lines: [{rank, depth, scoreCp?, mate?, pv, nodes?}] }` — the SF leg of a cloud_analyse response.\n\n" +
804
+ "Poll cadence: every ~15-30s for long thinks; there's no penalty for polling more often but the engine progresses at its own pace.",
805
+ inputSchema: {
806
+ type: "object",
807
+ properties: {
808
+ job_id: { type: "string", description: "Job id from `deep_analyse`." },
809
+ },
810
+ required: ["job_id"],
811
+ },
812
+ },
813
+ {
814
+ name: "deep_analyse_cancel",
815
+ description: "Ask a running deep_analyse job to stop early. The engine returns whatever it's found so far as the final result. Useful when a partial result at depth 25 is enough and you don't want to wait for depth 40. Idempotent for already-finished jobs.",
816
+ inputSchema: {
817
+ type: "object",
818
+ properties: {
819
+ job_id: { type: "string", description: "Job id from `deep_analyse`." },
820
+ },
821
+ required: ["job_id"],
822
+ },
823
+ },
766
824
  {
767
825
  name: "quote_engine_eval",
768
826
  description: "Return the stored engine eval for a node, or null if that node was never analysed. **Call this before writing prose or NAGs that quote engine numbers** — if it returns null, you have no measurement to cite. Do NOT infer an eval for the node from siblings or children; either analyse it (cloud_analyse with node_id) or omit the number from your prose.\n\n" +
@@ -1230,6 +1288,141 @@ function autoEvaluateCancel(args) {
1230
1288
  // inside runEvalJob, after the final flush persists progress.
1231
1289
  return { status: "cancelling", evaluated_so_far: job.evaluated };
1232
1290
  }
1291
+ const deepJobs = new Map();
1292
+ const DEEP_JOB_TTL_MS = 15 * 60 * 1000;
1293
+ function newDeepJobId() {
1294
+ const rand = Math.random().toString(16).slice(2, 8);
1295
+ return `deep_${Date.now().toString(16)}${rand}`;
1296
+ }
1297
+ function reapExpiredDeepJobs() {
1298
+ const now = Date.now();
1299
+ for (const [k, j] of deepJobs) {
1300
+ if (j.finishedAt && now - j.finishedAt > DEEP_JOB_TTL_MS) {
1301
+ deepJobs.delete(k);
1302
+ }
1303
+ }
1304
+ }
1305
+ async function deepAnalyseStart(args) {
1306
+ reapExpiredDeepJobs();
1307
+ const resolved = await resolveFromNodeOrFen(args);
1308
+ const fen = resolved.fen;
1309
+ const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 60_000;
1310
+ const multipv = typeof args.multipv === "number" ? args.multipv : 3;
1311
+ const jobId = newDeepJobId();
1312
+ const job = {
1313
+ id: jobId,
1314
+ status: "running",
1315
+ fileHandle: resolved.file,
1316
+ fen,
1317
+ movetimeMs,
1318
+ multipv,
1319
+ startedAt: Date.now(),
1320
+ cancelController: new AbortController(),
1321
+ };
1322
+ deepJobs.set(jobId, job);
1323
+ // Kick off the long HTTP call unawaited — resolves when the backend
1324
+ // returns the SF snapshot. authedRequest is a plain fetch under the
1325
+ // hood; abort signal flows via cancelController.
1326
+ void runDeepJob(job).catch(err => {
1327
+ job.status = "error";
1328
+ job.error = err instanceof Error ? err.message : String(err);
1329
+ job.finishedAt = Date.now();
1330
+ });
1331
+ return {
1332
+ job_id: jobId,
1333
+ status: "running",
1334
+ movetime_ms: movetimeMs,
1335
+ fen,
1336
+ };
1337
+ }
1338
+ async function runDeepJob(job) {
1339
+ const body = {
1340
+ fen: job.fen,
1341
+ movetime_ms: job.movetimeMs,
1342
+ multipv: job.multipv,
1343
+ engines: ["stockfish"],
1344
+ };
1345
+ let raw;
1346
+ try {
1347
+ // TODO(future): plumb an AbortSignal through authedRequest for
1348
+ // real mid-flight cancellation. For now, cancel just marks the
1349
+ // job so the caller stops polling; the backend still runs the
1350
+ // engine to completion and the result is stored on the job
1351
+ // record but flagged cancelled.
1352
+ raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
1353
+ }
1354
+ catch (err) {
1355
+ job.status = "error";
1356
+ job.error = err instanceof Error ? err.message : String(err);
1357
+ job.finishedAt = Date.now();
1358
+ return;
1359
+ }
1360
+ const converted = convertCloudSnapshotResponse(raw, job.fen);
1361
+ const sf = converted.stockfish;
1362
+ if (job.cancelController.signal.aborted) {
1363
+ job.status = "cancelled";
1364
+ }
1365
+ else {
1366
+ job.status = "done";
1367
+ }
1368
+ job.result = sf ?? null;
1369
+ job.finishedAt = Date.now();
1370
+ // Same node-persistence as cloud_analyse: if the caller anchored on
1371
+ // file_id+node_id, store the SF-only eval as the node's ceoEval so
1372
+ // quote_engine_eval can cite it later. We build a StoredEval that has
1373
+ // only the sf leg — no Lc0 was run.
1374
+ if (job.fileHandle && sf) {
1375
+ const ev = analysisToStoredEval({ stockfish: sf }, job.fen);
1376
+ if (ev) {
1377
+ try {
1378
+ await storeEvalOnNode(job.fileHandle, ev);
1379
+ }
1380
+ catch {
1381
+ // best-effort — the analysis result is what the LLM asked for
1382
+ }
1383
+ }
1384
+ }
1385
+ }
1386
+ function deepAnalyseStatus(args) {
1387
+ reapExpiredDeepJobs();
1388
+ const jobId = String(args.job_id || "").trim();
1389
+ if (!jobId)
1390
+ throw new Error("`job_id` is required");
1391
+ const job = deepJobs.get(jobId);
1392
+ if (!job) {
1393
+ return {
1394
+ status: "not_found",
1395
+ note: "Job unknown — expired (kept ~15 min after completion), never existed, or the MCP process restarted.",
1396
+ };
1397
+ }
1398
+ return {
1399
+ job_id: job.id,
1400
+ status: job.status,
1401
+ movetime_ms: job.movetimeMs,
1402
+ elapsed_ms: (job.finishedAt ?? Date.now()) - job.startedAt,
1403
+ fen: job.fen,
1404
+ result: job.result,
1405
+ error: job.error,
1406
+ started_at_ms: job.startedAt,
1407
+ finished_at_ms: job.finishedAt,
1408
+ };
1409
+ }
1410
+ function deepAnalyseCancel(args) {
1411
+ const jobId = String(args.job_id || "").trim();
1412
+ if (!jobId)
1413
+ throw new Error("`job_id` is required");
1414
+ const job = deepJobs.get(jobId);
1415
+ if (!job)
1416
+ return { status: "not_found" };
1417
+ if (job.status !== "running") {
1418
+ return { status: job.status, note: "Job already finished; nothing to cancel." };
1419
+ }
1420
+ job.cancelController.abort();
1421
+ // Status flips to "cancelled" when runDeepJob observes the abort on
1422
+ // completion. Backend keeps churning until movetime elapses (mid-
1423
+ // flight abort of the HTTP call is a follow-up).
1424
+ return { status: "cancelling", elapsed_ms: Date.now() - job.startedAt };
1425
+ }
1233
1426
  // Walk chess.js-free: resolve a path against a tree, throw if invalid.
1234
1427
  function pathIntoTree(root, path) {
1235
1428
  let cur = root;
@@ -1756,6 +1949,8 @@ async function callToolInner(name, args) {
1756
1949
  body.multipv = args.multipv;
1757
1950
  if (typeof args.contempt === "number")
1758
1951
  body.contempt = args.contempt;
1952
+ if (Array.isArray(args.engines))
1953
+ body.engines = args.engines;
1759
1954
  const raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
1760
1955
  const converted = convertCloudSnapshotResponse(raw, fen);
1761
1956
  // Node-addressed calls: persist the result on the node's ceoEval
@@ -1771,6 +1966,12 @@ async function callToolInner(name, args) {
1771
1966
  }
1772
1967
  return converted;
1773
1968
  }
1969
+ case "deep_analyse":
1970
+ return deepAnalyseStart(args);
1971
+ case "deep_analyse_status":
1972
+ return deepAnalyseStatus(args);
1973
+ case "deep_analyse_cancel":
1974
+ return deepAnalyseCancel(args);
1774
1975
  case "list_prep_files":
1775
1976
  return authedRequest("GET", "/api/agent/prep-files");
1776
1977
  case "search_prep_files":
@@ -103,6 +103,31 @@ The gap (1.4 - 0.2 = 1.2) is roughly the value of the tempo Black has to spend d
103
103
 
104
104
  **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.
105
105
 
106
+ ## Deep Stockfish thinks: `deep_analyse`
107
+
108
+ `cloud_analyse` runs both engines with a movetime cap of 10 s — deliberately fast because most opening-tree questions are answered in 2–3 s. For **one specific critical position** where you want depth 35+ instead of the usual depth 22, use `deep_analyse`:
109
+
110
+ - SF-only (Lc0 saturates in a handful of seconds — no benefit past ~5 s of movetime).
111
+ - Movetime up to 5 min.
112
+ - Async: returns a `job_id` immediately, poll `deep_analyse_status(job_id)`, cancel with `deep_analyse_cancel(job_id)` if you decide partial depth is enough.
113
+ - **Holds only the Stockfish slot on the combo.** Lc0 remains callable for other positions via `cloud_analyse({ engines: ["lc0"], … })` while the deep think runs. Use that during the wait — walk other branches, sanity-check candidates.
114
+
115
+ Typical movetimes:
116
+ - `30_000 – 60_000` (30–60 s) — careful check on a candidate move
117
+ - `120_000 – 300_000` (2–5 min) — "find the truth" on a novelty, tactical shot, or difficult endgame
118
+
119
+ Only use it when you actually need the depth. Regular `cloud_analyse` handles the ~5–10 branches of a typical prep walk perfectly well.
120
+
121
+ ## Splitting engines on `cloud_analyse`
122
+
123
+ `cloud_analyse` accepts an `engines` list to run only one leg:
124
+
125
+ - `engines: ["lc0"]` — Lc0 only, useful while a `deep_analyse` is holding the Stockfish slot.
126
+ - `engines: ["stockfish"]` — SF only, when only the objective read matters and you want to skip the Lc0 latency.
127
+ - Default (omitted) — both engines. This is what you want for real prep decisions.
128
+
129
+ The skipped engine's field is omitted from the response (not present as an empty object).
130
+
106
131
  ## Worked example
107
132
 
108
133
  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.33.1",
3
+ "version": "0.34.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": {