@chessceo/mcp 0.34.0 → 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";
@@ -92,6 +93,7 @@ const AUTHED_TOOLS = new Set([
92
93
  "deep_analyse",
93
94
  "deep_analyse_status",
94
95
  "deep_analyse_cancel",
96
+ "find_position_in_courses",
95
97
  "quote_engine_eval",
96
98
  "predict_human_move",
97
99
  "prepare_opponent",
@@ -821,6 +823,27 @@ const TOOLS = [
821
823
  required: ["job_id"],
822
824
  },
823
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
+ },
824
847
  {
825
848
  name: "quote_engine_eval",
826
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" +
@@ -1423,6 +1446,87 @@ function deepAnalyseCancel(args) {
1423
1446
  // flight abort of the HTTP call is a follow-up).
1424
1447
  return { status: "cancelling", elapsed_ms: Date.now() - job.startedAt };
1425
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
+ }
1426
1530
  // Walk chess.js-free: resolve a path against a tree, throw if invalid.
1427
1531
  function pathIntoTree(root, path) {
1428
1532
  let cur = root;
@@ -1972,6 +2076,8 @@ async function callToolInner(name, args) {
1972
2076
  return deepAnalyseStatus(args);
1973
2077
  case "deep_analyse_cancel":
1974
2078
  return deepAnalyseCancel(args);
2079
+ case "find_position_in_courses":
2080
+ return findPositionInCourses(args);
1975
2081
  case "list_prep_files":
1976
2082
  return authedRequest("GET", "/api/agent/prep-files");
1977
2083
  case "search_prep_files":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chessceo/mcp",
3
- "version": "0.34.0",
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()