@chessceo/mcp 0.33.1 → 0.35.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
@@ -8,7 +8,8 @@
8
8
  // No API key, no auth, no state; the API's own rate limits apply.
9
9
  import { createServer as createHttpServer } from "node:http";
10
10
  import { AsyncLocalStorage } from "node:async_hooks";
11
- import { readFileSync } from "node:fs";
11
+ import { spawn } from "node:child_process";
12
+ import { readFileSync, existsSync } from "node:fs";
12
13
  import { fileURLToPath } from "node:url";
13
14
  import { dirname, join } from "node:path";
14
15
  import { Chess } from "chess.js";
@@ -89,6 +90,10 @@ const AUTHED_TOOLS = new Set([
89
90
  "auto_evaluate",
90
91
  "auto_evaluate_status",
91
92
  "auto_evaluate_cancel",
93
+ "deep_analyse",
94
+ "deep_analyse_status",
95
+ "deep_analyse_cancel",
96
+ "find_position_in_courses",
92
97
  "quote_engine_eval",
93
98
  "predict_human_move",
94
99
  "prepare_opponent",
@@ -508,6 +513,11 @@ const TOOLS = [
508
513
  maximum: 100,
509
514
  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
515
  },
516
+ engines: {
517
+ type: "array",
518
+ items: { type: "string", enum: ["stockfish", "lc0"] },
519
+ 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.",
520
+ },
511
521
  },
512
522
  },
513
523
  },
@@ -763,6 +773,77 @@ const TOOLS = [
763
773
  required: ["job_id"],
764
774
  },
765
775
  },
776
+ {
777
+ name: "deep_analyse",
778
+ 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" +
779
+ "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" +
780
+ "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.",
781
+ inputSchema: {
782
+ type: "object",
783
+ properties: {
784
+ 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." },
785
+ node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`/`moves`." },
786
+ fen: { type: "string", description: "Position as FEN. Only used if `node_id` is not set." },
787
+ moves: { type: "string", description: "Optional SAN moves on top of `fen`. Only used if `node_id` is not set." },
788
+ movetime_ms: {
789
+ type: "integer",
790
+ minimum: 5_000,
791
+ maximum: 300_000,
792
+ description: "Think time in ms. Default 60_000 (1 min). Max 300_000 (5 min).",
793
+ },
794
+ multipv: {
795
+ type: "integer",
796
+ minimum: 1,
797
+ maximum: 10,
798
+ description: "Number of candidate lines (default 3).",
799
+ },
800
+ },
801
+ },
802
+ },
803
+ {
804
+ name: "deep_analyse_status",
805
+ 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" +
806
+ "Poll cadence: every ~15-30s for long thinks; there's no penalty for polling more often but the engine progresses at its own pace.",
807
+ inputSchema: {
808
+ type: "object",
809
+ properties: {
810
+ job_id: { type: "string", description: "Job id from `deep_analyse`." },
811
+ },
812
+ required: ["job_id"],
813
+ },
814
+ },
815
+ {
816
+ name: "deep_analyse_cancel",
817
+ 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.",
818
+ inputSchema: {
819
+ type: "object",
820
+ properties: {
821
+ job_id: { type: "string", description: "Job id from `deep_analyse`." },
822
+ },
823
+ required: ["job_id"],
824
+ },
825
+ },
826
+ {
827
+ name: "find_position_in_courses",
828
+ description: "Look up which of the USER's own Chessable / PGN courses cover a position, ranked by how much ANNOTATED material sits below that position in each course. This is the LLM's window into what the user has personally studied — not a general database. Complements engine + big-DB tools: the general database says what the world plays, this says what the user has literature on.\n\n" +
829
+ "Ranking: files/chapters where the position sits at a branching point with lots of text under it rank first. A chapter that merely passes through the position on its way somewhere else ranks last, which is the whole point — the LLM wants to point the user at where the material actually explains this specific position.\n\n" +
830
+ "Use this to: cite specific chapters the user already owns when recommending an opening decision, cross-check whether the user has coverage of a rare line, find author overlap when the user asks 'what do I have on this?'\n\n" +
831
+ "Returns: `{fen, found, total_occurrences, excluded: {game_db_hits, unmapped_files, thin_entries_below_min, min_notes_chars}, hits: [{course, file, author, chapter, line, ply, notes_chars, subtree_moves}], truncated}`. `notes_chars` is the total characters of commentary in the subtree rooted at this occurrence — the primary rank signal. `ply` tells you how deep in the chapter's game tree this position sits (small ply = near the chapter's start).\n\n" +
832
+ "Not available if the fenfind index isn't installed on the server — response includes a clear note in that case.",
833
+ inputSchema: {
834
+ type: "object",
835
+ properties: {
836
+ file_id: { type: "string", description: "Prep file id. Combine with `node_id` to derive FEN from the tree." },
837
+ node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`/`moves`." },
838
+ fen: { type: "string", description: "Starting position as FEN. Only used if `node_id` is not set." },
839
+ moves: { type: "string", description: "Optional SAN moves on top of `fen` (or startpos). Only used if `node_id` is not set." },
840
+ include_games: { type: "boolean", description: "Include hits from game-database PGNs (player headers instead of course/chapter titles). Default false — those are noise for course-lookup." },
841
+ chapters_mode: { type: "boolean", description: "Return every chapter separately rather than best-per-course. Default false. Useful when a course has multiple chapters covering the same position." },
842
+ min_notes_chars: { type: "number", description: "Minimum notes_chars per hit to be included. Default 400 (~a paragraph of prose). Set to 0 to see every occurrence." },
843
+ limit: { type: "integer", description: "Max hits to return (default 25)." },
844
+ },
845
+ },
846
+ },
766
847
  {
767
848
  name: "quote_engine_eval",
768
849
  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 +1311,222 @@ function autoEvaluateCancel(args) {
1230
1311
  // inside runEvalJob, after the final flush persists progress.
1231
1312
  return { status: "cancelling", evaluated_so_far: job.evaluated };
1232
1313
  }
1314
+ const deepJobs = new Map();
1315
+ const DEEP_JOB_TTL_MS = 15 * 60 * 1000;
1316
+ function newDeepJobId() {
1317
+ const rand = Math.random().toString(16).slice(2, 8);
1318
+ return `deep_${Date.now().toString(16)}${rand}`;
1319
+ }
1320
+ function reapExpiredDeepJobs() {
1321
+ const now = Date.now();
1322
+ for (const [k, j] of deepJobs) {
1323
+ if (j.finishedAt && now - j.finishedAt > DEEP_JOB_TTL_MS) {
1324
+ deepJobs.delete(k);
1325
+ }
1326
+ }
1327
+ }
1328
+ async function deepAnalyseStart(args) {
1329
+ reapExpiredDeepJobs();
1330
+ const resolved = await resolveFromNodeOrFen(args);
1331
+ const fen = resolved.fen;
1332
+ const movetimeMs = typeof args.movetime_ms === "number" ? args.movetime_ms : 60_000;
1333
+ const multipv = typeof args.multipv === "number" ? args.multipv : 3;
1334
+ const jobId = newDeepJobId();
1335
+ const job = {
1336
+ id: jobId,
1337
+ status: "running",
1338
+ fileHandle: resolved.file,
1339
+ fen,
1340
+ movetimeMs,
1341
+ multipv,
1342
+ startedAt: Date.now(),
1343
+ cancelController: new AbortController(),
1344
+ };
1345
+ deepJobs.set(jobId, job);
1346
+ // Kick off the long HTTP call unawaited — resolves when the backend
1347
+ // returns the SF snapshot. authedRequest is a plain fetch under the
1348
+ // hood; abort signal flows via cancelController.
1349
+ void runDeepJob(job).catch(err => {
1350
+ job.status = "error";
1351
+ job.error = err instanceof Error ? err.message : String(err);
1352
+ job.finishedAt = Date.now();
1353
+ });
1354
+ return {
1355
+ job_id: jobId,
1356
+ status: "running",
1357
+ movetime_ms: movetimeMs,
1358
+ fen,
1359
+ };
1360
+ }
1361
+ async function runDeepJob(job) {
1362
+ const body = {
1363
+ fen: job.fen,
1364
+ movetime_ms: job.movetimeMs,
1365
+ multipv: job.multipv,
1366
+ engines: ["stockfish"],
1367
+ };
1368
+ let raw;
1369
+ try {
1370
+ // TODO(future): plumb an AbortSignal through authedRequest for
1371
+ // real mid-flight cancellation. For now, cancel just marks the
1372
+ // job so the caller stops polling; the backend still runs the
1373
+ // engine to completion and the result is stored on the job
1374
+ // record but flagged cancelled.
1375
+ raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
1376
+ }
1377
+ catch (err) {
1378
+ job.status = "error";
1379
+ job.error = err instanceof Error ? err.message : String(err);
1380
+ job.finishedAt = Date.now();
1381
+ return;
1382
+ }
1383
+ const converted = convertCloudSnapshotResponse(raw, job.fen);
1384
+ const sf = converted.stockfish;
1385
+ if (job.cancelController.signal.aborted) {
1386
+ job.status = "cancelled";
1387
+ }
1388
+ else {
1389
+ job.status = "done";
1390
+ }
1391
+ job.result = sf ?? null;
1392
+ job.finishedAt = Date.now();
1393
+ // Same node-persistence as cloud_analyse: if the caller anchored on
1394
+ // file_id+node_id, store the SF-only eval as the node's ceoEval so
1395
+ // quote_engine_eval can cite it later. We build a StoredEval that has
1396
+ // only the sf leg — no Lc0 was run.
1397
+ if (job.fileHandle && sf) {
1398
+ const ev = analysisToStoredEval({ stockfish: sf }, job.fen);
1399
+ if (ev) {
1400
+ try {
1401
+ await storeEvalOnNode(job.fileHandle, ev);
1402
+ }
1403
+ catch {
1404
+ // best-effort — the analysis result is what the LLM asked for
1405
+ }
1406
+ }
1407
+ }
1408
+ }
1409
+ function deepAnalyseStatus(args) {
1410
+ reapExpiredDeepJobs();
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 {
1417
+ status: "not_found",
1418
+ note: "Job unknown — expired (kept ~15 min after completion), never existed, or the MCP process restarted.",
1419
+ };
1420
+ }
1421
+ return {
1422
+ job_id: job.id,
1423
+ status: job.status,
1424
+ movetime_ms: job.movetimeMs,
1425
+ elapsed_ms: (job.finishedAt ?? Date.now()) - job.startedAt,
1426
+ fen: job.fen,
1427
+ result: job.result,
1428
+ error: job.error,
1429
+ started_at_ms: job.startedAt,
1430
+ finished_at_ms: job.finishedAt,
1431
+ };
1432
+ }
1433
+ function deepAnalyseCancel(args) {
1434
+ const jobId = String(args.job_id || "").trim();
1435
+ if (!jobId)
1436
+ throw new Error("`job_id` is required");
1437
+ const job = deepJobs.get(jobId);
1438
+ if (!job)
1439
+ return { status: "not_found" };
1440
+ if (job.status !== "running") {
1441
+ return { status: job.status, note: "Job already finished; nothing to cancel." };
1442
+ }
1443
+ job.cancelController.abort();
1444
+ // Status flips to "cancelled" when runDeepJob observes the abort on
1445
+ // completion. Backend keeps churning until movetime elapses (mid-
1446
+ // flight abort of the HTTP call is a follow-up).
1447
+ return { status: "cancelling", elapsed_ms: Date.now() - job.startedAt };
1448
+ }
1449
+ // ── find_position_in_courses: fenfind subprocess wrapper ───────────
1450
+ //
1451
+ // fenfind is a small python tool that indexes chess PGN files by
1452
+ // polyglot Zobrist hash. Given a position it returns which of the
1453
+ // user's Chessable / PGN files cover it, ranked by how much annotated
1454
+ // material sits below that position in each course. Runs as a
1455
+ // subprocess of the MCP so we can reuse python-chess's polyglot
1456
+ // hashing (matching the pre-built positions.db) instead of porting
1457
+ // the hash function to TS.
1458
+ //
1459
+ // Path resolution order (`FENFIND_PATH` env var overrides):
1460
+ // 1. $FENFIND_PATH/fenfind
1461
+ // 2. <package-root>/tools/fenfind/fenfind (ships with the npm package)
1462
+ // The bash wrapper picks a python interpreter with python-chess
1463
+ // available (venv at $here/.venv/bin/python preferred, then falls back
1464
+ // to system python3). DB path is resolved inside fenfind.py itself
1465
+ // (FENFIND_DB env, then ~/positions.db).
1466
+ const FENFIND_SCRIPT = (() => {
1467
+ const envPath = process.env.FENFIND_PATH?.trim();
1468
+ if (envPath) {
1469
+ const p = join(envPath, "fenfind");
1470
+ return existsSync(p) ? p : null;
1471
+ }
1472
+ const here = dirname(fileURLToPath(import.meta.url));
1473
+ const bundled = join(here, "..", "tools", "fenfind", "fenfind");
1474
+ return existsSync(bundled) ? bundled : null;
1475
+ })();
1476
+ // Cap on how long we let fenfind run — SQLite query with a hash index
1477
+ // should return in well under a second, but a stuck subprocess
1478
+ // shouldn't pin the MCP handler.
1479
+ const FENFIND_TIMEOUT_MS = 15_000;
1480
+ async function findPositionInCourses(args) {
1481
+ if (!FENFIND_SCRIPT) {
1482
+ return {
1483
+ status: "not_available",
1484
+ note: "fenfind index not installed on this server. Set FENFIND_PATH env var to the directory containing the `fenfind` script and positions.db, or install the tools/fenfind bundle shipped in the npm package.",
1485
+ };
1486
+ }
1487
+ const resolved = await resolveFromNodeOrFen(args);
1488
+ const cliArgs = [resolved.fen, "--json"];
1489
+ if (args.include_games)
1490
+ cliArgs.push("--games");
1491
+ if (args.chapters_mode)
1492
+ cliArgs.push("--chapters");
1493
+ if (typeof args.min_notes_chars === "number")
1494
+ cliArgs.push("--min", String(args.min_notes_chars));
1495
+ if (typeof args.limit === "number")
1496
+ cliArgs.push("-n", String(args.limit));
1497
+ const stdout = await new Promise((resolve, reject) => {
1498
+ const p = spawn(FENFIND_SCRIPT, cliArgs, {
1499
+ stdio: ["ignore", "pipe", "pipe"],
1500
+ });
1501
+ let out = "";
1502
+ let err = "";
1503
+ p.stdout.on("data", d => { out += d.toString("utf8"); });
1504
+ p.stderr.on("data", d => { err += d.toString("utf8"); });
1505
+ const to = setTimeout(() => {
1506
+ try {
1507
+ p.kill("SIGTERM");
1508
+ }
1509
+ catch { /* already dead */ }
1510
+ reject(new Error(`fenfind timed out after ${FENFIND_TIMEOUT_MS}ms`));
1511
+ }, FENFIND_TIMEOUT_MS);
1512
+ p.on("error", e => { clearTimeout(to); reject(e); });
1513
+ p.on("close", code => {
1514
+ clearTimeout(to);
1515
+ if (code !== 0) {
1516
+ reject(new Error(`fenfind exited ${code}: ${err.slice(0, 500)}`));
1517
+ }
1518
+ else {
1519
+ resolve(out);
1520
+ }
1521
+ });
1522
+ });
1523
+ try {
1524
+ return JSON.parse(stdout);
1525
+ }
1526
+ catch (e) {
1527
+ throw new Error(`fenfind returned non-JSON output (${e instanceof Error ? e.message : String(e)}): ${stdout.slice(0, 300)}`);
1528
+ }
1529
+ }
1233
1530
  // Walk chess.js-free: resolve a path against a tree, throw if invalid.
1234
1531
  function pathIntoTree(root, path) {
1235
1532
  let cur = root;
@@ -1756,6 +2053,8 @@ async function callToolInner(name, args) {
1756
2053
  body.multipv = args.multipv;
1757
2054
  if (typeof args.contempt === "number")
1758
2055
  body.contempt = args.contempt;
2056
+ if (Array.isArray(args.engines))
2057
+ body.engines = args.engines;
1759
2058
  const raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
1760
2059
  const converted = convertCloudSnapshotResponse(raw, fen);
1761
2060
  // Node-addressed calls: persist the result on the node's ceoEval
@@ -1771,6 +2070,14 @@ async function callToolInner(name, args) {
1771
2070
  }
1772
2071
  return converted;
1773
2072
  }
2073
+ case "deep_analyse":
2074
+ return deepAnalyseStart(args);
2075
+ case "deep_analyse_status":
2076
+ return deepAnalyseStatus(args);
2077
+ case "deep_analyse_cancel":
2078
+ return deepAnalyseCancel(args);
2079
+ case "find_position_in_courses":
2080
+ return findPositionInCourses(args);
1774
2081
  case "list_prep_files":
1775
2082
  return authedRequest("GET", "/api/agent/prep-files");
1776
2083
  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.35.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": {
@@ -10,7 +10,8 @@
10
10
  "files": [
11
11
  "dist",
12
12
  "docs",
13
- "README.md"
13
+ "README.md",
14
+ "tools"
14
15
  ],
15
16
  "scripts": {
16
17
  "build": "tsc",
@@ -0,0 +1,51 @@
1
+ # fenfind — find which of your PGN files cover a position
2
+
3
+ Small python tool the MCP shells out to for the `find_position_in_courses`
4
+ tool. Given a FEN it looks up every course/chapter that reaches that
5
+ position, ranked by how much annotated material sits below it (comment
6
+ chars in the subtree). Uses polyglot Zobrist hashing so the FEN → hash
7
+ map matches the pre-built `positions.db` index.
8
+
9
+ ## Files
10
+
11
+ | File | Purpose |
12
+ |------|---------|
13
+ | `fenfind` | bash wrapper; picks a python with `python-chess` installed |
14
+ | `fenfind.py` | the actual query tool (`--json` for MCP consumption) |
15
+ | `_INDEXER.py` | one-off script that builds `positions.db` from a folder of PGNs (kept out of the npm package via `.npmignore`) |
16
+
17
+ ## Runtime dependencies
18
+
19
+ - **python-chess** — either at `./venv/bin/python` (the wrapper's preferred path) or system-wide `python3`.
20
+ Install locally: `python3 -m venv .venv && .venv/bin/pip install python-chess`.
21
+ - **positions.db** — the pre-built index. Resolved via, in order:
22
+ 1. `$FENFIND_DB` env var (absolute path).
23
+ 2. `<script-dir>/positions.db`
24
+ 3. `~/chess/positions.db`
25
+ 4. `~/positions.db`
26
+
27
+ The DB is a runtime asset and never ships in the npm package. Put it at
28
+ one of the paths above on any host that runs the MCP.
29
+
30
+ ## MCP integration
31
+
32
+ `src/index.ts` resolves the script via, in order:
33
+ 1. `$FENFIND_PATH` env var (directory containing the `fenfind` script).
34
+ 2. `<package-root>/tools/fenfind/fenfind` (this directory, shipped in the
35
+ npm package).
36
+
37
+ If neither path exists, the `find_position_in_courses` tool returns a
38
+ clear `status: "not_available"` response with the note above rather
39
+ than erroring.
40
+
41
+ ## Rebuilding the index
42
+
43
+ ```
44
+ python3 _INDEXER.py # expects PGNs at /home/lucas/chess/raw and a
45
+ # course-mapping CSV at /home/lucas/chess/chessable/_originals.csv
46
+ # — edit the paths at the top of the file for
47
+ # your own layout.
48
+ ```
49
+
50
+ Full re-index of ~2k PGN files is a few minutes; produces the ~2 GB
51
+ positions.db in place.
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bash
2
+ # Run fenfind.py with whichever interpreter has python-chess available.
3
+ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4
+ for py in "$here/.venv/bin/python" "$HOME/chess/.venv/bin/python" "$HOME/.venv/bin/python" python3; do
5
+ if "$py" -c 'import chess' 2>/dev/null; then
6
+ exec "$py" "$here/fenfind.py" "$@"
7
+ fi
8
+ done
9
+ echo "fenfind: python-chess not found. Install it, e.g.:" >&2
10
+ echo " python3 -m venv \"$here/.venv\" && \"$here/.venv/bin/pip\" install python-chess" >&2
11
+ exit 1
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env python3
2
+ """Find which courses/chapters cover a position.
3
+
4
+ fenfind.py "<FEN>" look up a position
5
+ fenfind.py -m "1.e4 c5 2.Nf3 d6" give moves instead of a FEN
6
+ fenfind.py -m "..." --courses only files mapped to a known course
7
+ fenfind.py -m "..." --all don't hide thin entries
8
+ fenfind.py -m "..." --chapters list every chapter, not best-per-file
9
+ fenfind.py -m "..." --min 500 custom "worth reading" threshold (note chars)
10
+
11
+ Ranking: entries are sorted by how much ANNOTATED material sits below the
12
+ position at that point -- i.e. words of explanation first, moves second. A
13
+ chapter that merely passes through the position on its way somewhere else
14
+ ranks last, which is the whole point.
15
+ """
16
+ import chess, chess.polyglot as zbl, sqlite3, argparse, re, os, json, sys
17
+
18
+ HERE = os.path.dirname(os.path.abspath(__file__))
19
+ DB = os.environ.get('FENFIND_DB') or next(
20
+ (p for p in (os.path.join(HERE, 'positions.db'),
21
+ os.path.expanduser('~/chess/positions.db'),
22
+ os.path.expanduser('~/positions.db')) if os.path.exists(p)),
23
+ os.path.join(HERE, 'positions.db'))
24
+
25
+ def board_from_moves(s):
26
+ b = chess.Board()
27
+ for tok in re.sub(r'\d+\.(\.\.)?', ' ', s).split():
28
+ if tok in ('*', '1-0', '0-1', '1/2-1/2'):
29
+ continue
30
+ try:
31
+ b.push_san(tok)
32
+ except Exception:
33
+ raise SystemExit(f"illegal/unparsable move: {tok}")
34
+ return b
35
+
36
+ def looks_like_gamedb(chapter, line):
37
+ """Game collections put player names in the header; courses put chapter titles.
38
+ 'Surname, Firstname' in both slots is the giveaway."""
39
+ return bool(re.match(r'^[A-Z][\w.\'-]+,\s*[A-Z]', chapter or '')) and \
40
+ bool(re.match(r'^[A-Z][\w.\'-]+,\s*[A-Z]', line or ''))
41
+
42
+ def main():
43
+ ap = argparse.ArgumentParser()
44
+ ap.add_argument('pos')
45
+ ap.add_argument('-m', '--moves', action='store_true')
46
+ ap.add_argument('--all', action='store_true')
47
+ ap.add_argument('--courses', action='store_true')
48
+ ap.add_argument('--chapters', action='store_true')
49
+ ap.add_argument('--games', action='store_true', help='include game-database hits')
50
+ ap.add_argument('--min', type=float, default=400)
51
+ ap.add_argument('-n', type=int, default=25)
52
+ ap.add_argument('--json', action='store_true', help='emit structured JSON on stdout instead of the text table (used by the MCP wrapper)')
53
+ a = ap.parse_args()
54
+
55
+ b = board_from_moves(a.pos) if a.moves else chess.Board(
56
+ a.pos if len(a.pos.split()) >= 6 else a.pos + ' 0 1')
57
+ z = zbl.zobrist_hash(b)
58
+ if z >= (1 << 63):
59
+ z -= (1 << 64)
60
+
61
+ con = sqlite3.connect(DB); con.row_factory = sqlite3.Row
62
+ rows = [dict(r) for r in con.execute(
63
+ """SELECT f.name,f.course,f.author,o.chapter,o.line,o.ply,o.subtree,o.chars
64
+ FROM occ o JOIN files f ON f.id=o.file_id WHERE o.z=?""", (z,))]
65
+ if not rows:
66
+ if a.json:
67
+ json.dump({"fen": b.fen(), "found": False, "total_occurrences": 0, "hits": []}, sys.stdout)
68
+ print()
69
+ return
70
+ print("position not found in the collection"); return
71
+ total = len(rows)
72
+
73
+ ngame = 0
74
+ if not a.games:
75
+ keep = [r for r in rows if not looks_like_gamedb(r['chapter'], r['line'])]
76
+ ngame = len(rows) - len(keep); rows = keep
77
+ ncourse = 0
78
+ if a.courses:
79
+ keep = [r for r in rows if r['course']]
80
+ ncourse = len(rows) - len(keep); rows = keep
81
+
82
+ # collapse duplicate FILES: "Foo.pgn", "Foo (2).pgn" and re-exports of the same
83
+ # course produce byte-identical hits -- keep one, remember how many copies.
84
+ def norm_file(n):
85
+ return re.sub(r'\s*\(\d+\)(?=\.pgn$)', '', n).lower()
86
+ for r in rows:
87
+ r['key'] = (r['course'] or norm_file(r['name']))
88
+
89
+ if a.chapters:
90
+ groups = {}
91
+ for r in rows:
92
+ k = (r['key'], r['chapter'])
93
+ if k not in groups or r['chars'] > groups[k]['chars']:
94
+ groups[k] = r
95
+ else:
96
+ groups = {} # best chapter per course/file
97
+ for r in rows:
98
+ k = r['key']
99
+ if k not in groups or (r['chars'], r['subtree']) > (groups[k]['chars'], groups[k]['subtree']):
100
+ groups[k] = r
101
+
102
+ hits = sorted(groups.values(), key=lambda r: (-r['chars'], -r['subtree']))
103
+ shown = hits if a.all else [h for h in hits if h['chars'] >= a.min]
104
+ thin = len(hits) - len(shown)
105
+
106
+ if a.json:
107
+ # Structured output for the MCP wrapper. Field names spelled out
108
+ # so the LLM reader doesn't need to know the SQLite column names.
109
+ payload = {
110
+ "fen": b.fen(),
111
+ "found": True,
112
+ "total_occurrences": total,
113
+ "excluded": {
114
+ "game_db_hits": ngame,
115
+ "unmapped_files": ncourse,
116
+ "thin_entries_below_min": thin,
117
+ "min_notes_chars": a.min,
118
+ },
119
+ "hits": [
120
+ {
121
+ "course": h['course'] or None,
122
+ "file": h['name'],
123
+ "author": h['author'] or None,
124
+ "chapter": h['chapter'] or None,
125
+ "line": h['line'] or None,
126
+ "ply": h['ply'],
127
+ "notes_chars": h['chars'],
128
+ "subtree_moves": h['subtree'],
129
+ }
130
+ for h in shown[:a.n]
131
+ ],
132
+ "truncated": max(0, len(shown) - a.n),
133
+ }
134
+ json.dump(payload, sys.stdout, ensure_ascii=False)
135
+ print()
136
+ return
137
+
138
+ print(f"\nposition: {b.fen()}")
139
+ note = [f"{total:,} indexed occurrences"]
140
+ if ngame: note.append(f"{ngame:,} game-db hits excluded (--games to keep)")
141
+ if ncourse: note.append(f"{ncourse:,} unmapped files excluded")
142
+ note.append(f"{len(hits):,} distinct {'chapters' if a.chapters else 'courses'}")
143
+ if thin: note.append(f"{thin:,} thin entries hidden (--all to show)")
144
+ print(" " + "\n ".join(note) + "\n")
145
+
146
+ print(f"{'notes':>7} {'moves':>6} {'ply':>4} course")
147
+ print("-" * 96)
148
+ for h in shown[:a.n]:
149
+ title = h['course'] or h['name'][:-4]
150
+ who = f" [{h['author']}]" if h['author'] else ''
151
+ print(f"{h['chars']:>7} {h['subtree']:>6} {h['ply']:>4} {title[:70]}{who}")
152
+ ch = h['chapter'] or '?'
153
+ extra = f" | {h['line']}" if h['line'] and h['line'] != ch else ''
154
+ print(f"{'':>19} ch: {ch}{extra}")
155
+ if len(shown) > a.n:
156
+ print(f"\n... {len(shown)-a.n:,} more (-n to raise the limit)")
157
+
158
+ main()