@chessceo/mcp 0.43.0 → 0.46.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.
@@ -0,0 +1,181 @@
1
+ // Shared engine-response helpers used across cloud_analyse, auto_evaluate,
2
+ // deep_analyse, and get_position_stats. All PGN-shape data comes back
3
+ // from the backend in UCI ("g1f3") — this module converts to SAN, extracts
4
+ // stored evals from the raw JSON, and derives the eval → NAG mapping.
5
+ //
6
+ // Extracted from src/index.ts in v0.44 as part of the file split.
7
+ import { Chess } from "chess.js";
8
+ import { authedRequest } from "../http.js";
9
+ // Convert a UCI move sequence into SAN by walking it move-by-move on
10
+ // chess.js from the given starting FEN. LLMs reason far better in SAN
11
+ // ("Nf3", "Bxc4") than UCI ("g1f3", "b5c4"), and matches how prep
12
+ // discussion is written in the real world. If a move fails to parse
13
+ // (illegal from the current position — bug or truncated PV), we
14
+ // truncate cleanly rather than throwing so the response still carries
15
+ // what we could convert.
16
+ export function uciLineToSAN(startFen, uciMoves) {
17
+ const board = new Chess(startFen);
18
+ const out = [];
19
+ for (const uci of uciMoves) {
20
+ if (uci.length < 4)
21
+ break;
22
+ try {
23
+ const move = board.move({
24
+ from: uci.slice(0, 2),
25
+ to: uci.slice(2, 4),
26
+ promotion: uci.length >= 5 ? uci[4] : undefined,
27
+ });
28
+ if (!move)
29
+ break;
30
+ out.push(move.san);
31
+ }
32
+ catch {
33
+ break;
34
+ }
35
+ }
36
+ return out;
37
+ }
38
+ export function uciMoveToSAN(startFen, uci) {
39
+ if (!uci || uci.length < 4)
40
+ return uci;
41
+ const board = new Chess(startFen);
42
+ try {
43
+ const move = board.move({
44
+ from: uci.slice(0, 2),
45
+ to: uci.slice(2, 4),
46
+ promotion: uci.length >= 5 ? uci[4] : undefined,
47
+ });
48
+ return move ? move.san : uci;
49
+ }
50
+ catch {
51
+ return uci;
52
+ }
53
+ }
54
+ // Fetch a compact cloud eval for `fen`. Returns null on any error — no
55
+ // running combo instance, engine failure, network timeout. Callers
56
+ // attach the result to their response as `.eval` so the LLM has the
57
+ // stockfish + lc0 read without a separate tool call.
58
+ export async function fetchCompactEval(fen) {
59
+ try {
60
+ const raw = await authedRequest("POST", "/api/agent/cloud-engines/analyse", { fen, movetime_ms: 1500, multipv: 1 });
61
+ const converted = convertCloudSnapshotResponse(raw, fen);
62
+ const stored = analysisToStoredEval(converted);
63
+ return storedEvalToCompact(stored, converted);
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ // Trim every PV in a converted cloud-analyse response to `maxPlies`
70
+ // and mark each trimmed line with `pv_truncated: true` so the LLM
71
+ // sees what happened. Applied ONLY to cloud_analyse (short synchronous
72
+ // snapshot); deep_analyse is the explicit "give me the deep line"
73
+ // tool and keeps its full PV.
74
+ export function capPvsInResponse(converted, maxPlies) {
75
+ if (!converted || typeof converted !== "object")
76
+ return;
77
+ const r = converted;
78
+ for (const eng of [r.stockfish, r.lc0]) {
79
+ if (!eng || !Array.isArray(eng.lines))
80
+ continue;
81
+ for (const line of eng.lines) {
82
+ if (Array.isArray(line.pv) && line.pv.length > maxPlies) {
83
+ line.pv = line.pv.slice(0, maxPlies);
84
+ line.pv_truncated = true;
85
+ }
86
+ }
87
+ }
88
+ converted.pv_max_plies = maxPlies;
89
+ }
90
+ export function convertCloudSnapshotResponse(raw, startFen) {
91
+ if (!raw || typeof raw !== "object")
92
+ return raw;
93
+ const r = raw;
94
+ for (const eng of [r.stockfish, r.lc0]) {
95
+ if (!eng)
96
+ continue;
97
+ if (Array.isArray(eng.lines)) {
98
+ for (const line of eng.lines) {
99
+ if (Array.isArray(line.pv))
100
+ line.pv = uciLineToSAN(startFen, line.pv);
101
+ }
102
+ }
103
+ if (typeof eng.bestMove === "string")
104
+ eng.bestMove = uciMoveToSAN(startFen, eng.bestMove);
105
+ }
106
+ return raw;
107
+ }
108
+ export function analysisToStoredEval(analysis) {
109
+ if (!analysis || typeof analysis !== "object")
110
+ return null;
111
+ const r = analysis;
112
+ // Backend returns White-POV cp/mate (engine-ws flips in ParseInfo
113
+ // based on side-to-move, cloud_snapshot passes through). Pure
114
+ // pass-through here — a previous sign-flip on black-to-move was
115
+ // wrong and silently inverted every Black-to-move stored eval.
116
+ const engineEval = (block) => {
117
+ const line = block?.lines?.[0];
118
+ if (!line)
119
+ return undefined;
120
+ const depth = line.depth ?? block?.depth;
121
+ if (typeof line.mate === "number")
122
+ return { mate: line.mate, depth };
123
+ if (typeof line.scoreCp === "number")
124
+ return { cp: line.scoreCp, depth };
125
+ return undefined;
126
+ };
127
+ const sf = engineEval(r.stockfish);
128
+ const lc0 = engineEval(r.lc0);
129
+ if (!sf && !lc0)
130
+ return null;
131
+ const ev = {};
132
+ if (sf)
133
+ ev.sf = sf;
134
+ if (lc0)
135
+ ev.lc0 = lc0;
136
+ ev.nag = nagFromCp(sf?.cp, sf?.mate) ?? nagFromCp(lc0?.cp, lc0?.mate) ?? undefined;
137
+ return ev;
138
+ }
139
+ export function nagFromCp(cp, mate) {
140
+ let effective;
141
+ if (typeof mate === "number")
142
+ effective = mate > 0 ? 10000 : -10000;
143
+ else if (typeof cp === "number")
144
+ effective = cp;
145
+ else
146
+ return null;
147
+ const abs = Math.abs(effective);
148
+ if (abs < 25)
149
+ return "$10";
150
+ if (abs < 60)
151
+ return effective > 0 ? "$14" : "$15";
152
+ if (abs < 130)
153
+ return effective > 0 ? "$16" : "$17";
154
+ return effective > 0 ? "$18" : "$19";
155
+ }
156
+ // Adapter for the compact eval attached to live query responses. Same
157
+ // derivation logic; different output shape (needs the .nag + summary
158
+ // used by get_position_stats / prep_snapshot).
159
+ export function storedEvalToCompact(ev, analysis) {
160
+ if (!ev)
161
+ return null;
162
+ const a = analysis;
163
+ const compact = { nag: ev.nag ?? null };
164
+ if (ev.sf) {
165
+ compact.stockfish = {
166
+ cp: ev.sf.cp,
167
+ mate: ev.sf.mate,
168
+ bestMove: a.stockfish?.bestMove,
169
+ pv: a.stockfish?.lines?.[0]?.pv,
170
+ };
171
+ }
172
+ if (ev.lc0) {
173
+ compact.lc0 = {
174
+ cp: ev.lc0.cp,
175
+ mate: ev.lc0.mate,
176
+ bestMove: a.lc0?.bestMove,
177
+ pv: a.lc0?.lines?.[0]?.pv,
178
+ };
179
+ }
180
+ return compact;
181
+ }
@@ -0,0 +1,167 @@
1
+ // Subprocess wrappers for two Python-backed helpers:
2
+ // - fenfind / readpgn — polyglot Zobrist search over the user's
3
+ // Chessable / PGN course library (the `find_position_in_courses`
4
+ // and `read_course_at_position` tools).
5
+ // - sf_eval — spawns local stockfish, parses its `eval` verbose
6
+ // output (the eval-terms leg of `describe_position`).
7
+ //
8
+ // Both live outside the pure-TS MCP because they reuse Python code
9
+ // (python-chess for polyglot hashing / PGN parsing; the local
10
+ // stockfish binary for eval terms) that porting to TS would just
11
+ // duplicate.
12
+ //
13
+ // Extracted from index.ts in v0.44 as part of the file split.
14
+ import { spawn } from "node:child_process";
15
+ import { existsSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { resolveFromNodeOrFen } from "./analysis/file_handle.js";
19
+ // Path to sf_eval helper (spawns local stockfish, parses its `eval`
20
+ // verbose output). SF_EVAL_PATH env overrides the bundled tools/sf_eval/
21
+ // directory.
22
+ const SF_EVAL_SCRIPT = (() => {
23
+ const envPath = process.env.SF_EVAL_PATH?.trim();
24
+ if (envPath && existsSync(join(envPath, "sf_eval")))
25
+ return join(envPath, "sf_eval");
26
+ const here = dirname(fileURLToPath(import.meta.url));
27
+ const bundled = join(here, "..", "tools", "sf_eval", "sf_eval");
28
+ return existsSync(bundled) ? bundled : null;
29
+ })();
30
+ const SF_EVAL_TIMEOUT_MS = 12_000;
31
+ export async function runSfEval(fen) {
32
+ if (!SF_EVAL_SCRIPT) {
33
+ return {
34
+ found: false,
35
+ error: "sf_eval script not bundled; set SF_EVAL_PATH or install tools/sf_eval/",
36
+ };
37
+ }
38
+ const stdout = await new Promise((resolve, reject) => {
39
+ const p = spawn(SF_EVAL_SCRIPT, ["--fen", fen], { stdio: ["ignore", "pipe", "pipe"] });
40
+ let out = "";
41
+ let err = "";
42
+ p.stdout.on("data", d => { out += d.toString("utf8"); });
43
+ p.stderr.on("data", d => { err += d.toString("utf8"); });
44
+ const to = setTimeout(() => {
45
+ try {
46
+ p.kill("SIGTERM");
47
+ }
48
+ catch { /* already dead */ }
49
+ reject(new Error(`sf_eval timed out after ${SF_EVAL_TIMEOUT_MS}ms`));
50
+ }, SF_EVAL_TIMEOUT_MS);
51
+ p.on("error", e => { clearTimeout(to); reject(e); });
52
+ p.on("close", code => {
53
+ clearTimeout(to);
54
+ if (code !== 0)
55
+ reject(new Error(`sf_eval exited ${code}: ${err.slice(0, 500)}`));
56
+ else
57
+ resolve(out);
58
+ });
59
+ });
60
+ try {
61
+ return JSON.parse(stdout);
62
+ }
63
+ catch (e) {
64
+ throw new Error(`sf_eval returned non-JSON output (${e instanceof Error ? e.message : String(e)}): ${stdout.slice(0, 300)}`);
65
+ }
66
+ }
67
+ // Path resolution order (`FENFIND_PATH` env var overrides):
68
+ // 1. $FENFIND_PATH/fenfind
69
+ // 2. <package-root>/tools/fenfind/fenfind (ships with the npm package)
70
+ // The bash wrapper picks a python interpreter with python-chess
71
+ // available (venv at $here/.venv/bin/python preferred, then falls back
72
+ // to system python3). DB path is resolved inside fenfind.py itself
73
+ // (FENFIND_DB env, then ~/positions.db).
74
+ const FENFIND_DIR = (() => {
75
+ const envPath = process.env.FENFIND_PATH?.trim();
76
+ if (envPath && existsSync(join(envPath, "fenfind")))
77
+ return envPath;
78
+ const here = dirname(fileURLToPath(import.meta.url));
79
+ const bundled = join(here, "..", "tools", "fenfind");
80
+ return existsSync(join(bundled, "fenfind")) ? bundled : null;
81
+ })();
82
+ // Cap on how long we let the subprocess run. SQLite hash lookup returns
83
+ // sub-second; PGN read from a course file is O(chapter size) and rarely
84
+ // exceeds a second. 15s is a stuck-process backstop, not a real limit.
85
+ const FENFIND_TIMEOUT_MS = 15_000;
86
+ async function runFenfindScript(scriptName, args) {
87
+ if (!FENFIND_DIR) {
88
+ throw new Error("fenfind index not installed — set FENFIND_PATH or install the tools/fenfind bundle");
89
+ }
90
+ const script = join(FENFIND_DIR, scriptName);
91
+ return new Promise((resolve, reject) => {
92
+ const p = spawn(script, args, { stdio: ["ignore", "pipe", "pipe"] });
93
+ let out = "";
94
+ let err = "";
95
+ p.stdout.on("data", d => { out += d.toString("utf8"); });
96
+ p.stderr.on("data", d => { err += d.toString("utf8"); });
97
+ const to = setTimeout(() => {
98
+ try {
99
+ p.kill("SIGTERM");
100
+ }
101
+ catch { /* already dead */ }
102
+ reject(new Error(`${scriptName} timed out after ${FENFIND_TIMEOUT_MS}ms`));
103
+ }, FENFIND_TIMEOUT_MS);
104
+ p.on("error", e => { clearTimeout(to); reject(e); });
105
+ p.on("close", code => {
106
+ clearTimeout(to);
107
+ if (code !== 0)
108
+ reject(new Error(`${scriptName} exited ${code}: ${err.slice(0, 500)}`));
109
+ else
110
+ resolve(out);
111
+ });
112
+ });
113
+ }
114
+ function parseFenfindJson(scriptName, stdout) {
115
+ try {
116
+ return JSON.parse(stdout);
117
+ }
118
+ catch (e) {
119
+ throw new Error(`${scriptName} returned non-JSON output (${e instanceof Error ? e.message : String(e)}): ${stdout.slice(0, 300)}`);
120
+ }
121
+ }
122
+ export async function findPositionInCourses(args) {
123
+ if (!FENFIND_DIR) {
124
+ return {
125
+ status: "not_available",
126
+ 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.",
127
+ };
128
+ }
129
+ const resolved = await resolveFromNodeOrFen(args);
130
+ const cliArgs = [resolved.fen, "--json"];
131
+ if (typeof args.sort === "string" && (args.sort === "recency" || args.sort === "notes")) {
132
+ cliArgs.push("--sort", args.sort);
133
+ }
134
+ if (args.include_games)
135
+ cliArgs.push("--games");
136
+ if (args.chapters_mode)
137
+ cliArgs.push("--chapters");
138
+ if (typeof args.min_notes_chars === "number")
139
+ cliArgs.push("--min", String(args.min_notes_chars));
140
+ if (typeof args.limit === "number")
141
+ cliArgs.push("-n", String(args.limit));
142
+ const stdout = await runFenfindScript("fenfind", cliArgs);
143
+ return parseFenfindJson("fenfind", stdout);
144
+ }
145
+ export async function readCourseAtPosition(args) {
146
+ if (!FENFIND_DIR) {
147
+ return {
148
+ status: "not_available",
149
+ note: "fenfind index not installed on this server. Set FENFIND_PATH env var to the directory containing the `fenfind`/`readpgn` scripts and positions.db.",
150
+ };
151
+ }
152
+ const fileId = typeof args.course_file_id === "number" ? args.course_file_id : Number(args.course_file_id);
153
+ if (!Number.isFinite(fileId) || fileId <= 0) {
154
+ throw new Error("`course_file_id` is required — pass the value from a find_position_in_courses hit");
155
+ }
156
+ const cliArgs = ["--file-id", String(fileId)];
157
+ if (typeof args.fen === "string" && args.fen.trim() !== "")
158
+ cliArgs.push("--fen", args.fen.trim());
159
+ if (typeof args.moves === "string" && args.moves.trim() !== "")
160
+ cliArgs.push("--moves", args.moves.trim());
161
+ if (typeof args.chapter === "string" && args.chapter.trim() !== "")
162
+ cliArgs.push("--chapter", args.chapter.trim());
163
+ if (typeof args.max_plies_below === "number")
164
+ cliArgs.push("--max-plies-below", String(args.max_plies_below));
165
+ const stdout = await runFenfindScript("readpgn", cliArgs);
166
+ return parseFenfindJson("readpgn", stdout);
167
+ }
package/dist/http.js ADDED
@@ -0,0 +1,150 @@
1
+ // HTTP + PGN I/O layer for chessceo-mcp. Auth resolution, raw
2
+ // `authedRequest` / `get`, response envelope helpers, and the
3
+ // small typed wrappers every prep-file tool composes over
4
+ // (`fetchGame` / `saveGame` / `createGame` / `deleteGame` /
5
+ // `restoreGame`).
6
+ //
7
+ // Two auth flavours coexist:
8
+ // - Anonymous GETs (players, positions, prep, live) — no auth.
9
+ // - Authed tools (cloud engines, prep files) — `Authorization: Bearer mcp_...`.
10
+ //
11
+ // The token comes from one of two sources:
12
+ // - stdio: `CHESSCEO_TOKEN` env var, set by the MCP host config. Bare
13
+ // `mcp_...` — we prepend the `Bearer ` scheme when building the header.
14
+ // - streamable-http: the caller's `Authorization` header, forwarded
15
+ // per-request via AsyncLocalStorage so tool handlers can see it even
16
+ // though the MCP SDK's request handler doesn't know about HTTP.
17
+ //
18
+ // LLM-facing "prep file id" is always "<collection_id>:<game_id>" —
19
+ // `makeFileId` to compose from a listing, `splitFileId` at the HTTP edge.
20
+ // This is what makes every prep-file tool keep taking a single `id`
21
+ // argument despite the backend paths needing both pieces.
22
+ import { AsyncLocalStorage } from "node:async_hooks";
23
+ const BASE = process.env.CHESSCEO_BASE_URL ?? "https://chess.ceo";
24
+ const UA = `chessceo-mcp/${process.env.npm_package_version ?? "0.1.0"} (+https://chess.ceo)`;
25
+ export const PGN_BASE = "/api/agent/pgns";
26
+ // Streamable-HTTP transport binds the caller's Authorization header
27
+ // into this context so per-request tool handlers can pick it up.
28
+ // Stdio callers don't set this — they hit the env-var branch below.
29
+ export const authContext = new AsyncLocalStorage();
30
+ export function resolveAuthHeader() {
31
+ const store = authContext.getStore();
32
+ if (store?.authHeader)
33
+ return store.authHeader;
34
+ const env = process.env.CHESSCEO_TOKEN?.trim();
35
+ if (!env)
36
+ return undefined;
37
+ return env.toLowerCase().startsWith("bearer ") ? env : `Bearer ${env}`;
38
+ }
39
+ export async function get(path, params) {
40
+ const url = new URL(path, BASE);
41
+ for (const [k, v] of Object.entries(params)) {
42
+ if (v !== undefined && v !== null && v !== "")
43
+ url.searchParams.set(k, String(v));
44
+ }
45
+ const res = await fetch(url, { headers: { "User-Agent": UA, "Accept": "application/json" } });
46
+ if (!res.ok) {
47
+ // Bubble up the ProblemDetail body when the API returns one — LLM can
48
+ // then correct the query (e.g. wrong fideId) rather than retry blind.
49
+ let body;
50
+ try {
51
+ body = await res.text();
52
+ }
53
+ catch {
54
+ body = "";
55
+ }
56
+ throw new Error(`chess.ceo ${res.status}: ${body.slice(0, 500)}`);
57
+ }
58
+ return res.json();
59
+ }
60
+ // authedRequest is the shared code path for POST/GET/DELETE calls that need
61
+ // an MCP token. Missing-token errors are surfaced early with a message the
62
+ // LLM can act on (either configure CHESSCEO_TOKEN or generate a token in
63
+ // user settings) rather than a generic 401 from the backend.
64
+ export async function authedRequest(method, path, body) {
65
+ const auth = resolveAuthHeader();
66
+ if (!auth) {
67
+ throw new Error("No MCP token available. Set CHESSCEO_TOKEN env (stdio mode) or pass an " +
68
+ "Authorization: Bearer mcp_... header (streamable-http mode). Generate " +
69
+ "a token at chess.ceo → user settings → MCP tokens.");
70
+ }
71
+ const url = new URL(path, BASE);
72
+ const headers = {
73
+ "User-Agent": UA,
74
+ "Accept": "application/json",
75
+ "Authorization": auth,
76
+ };
77
+ const init = { method, headers };
78
+ if (body !== undefined) {
79
+ headers["Content-Type"] = "application/json";
80
+ init.body = JSON.stringify(body);
81
+ }
82
+ const res = await fetch(url, init);
83
+ if (res.status === 204)
84
+ return null;
85
+ const text = await res.text();
86
+ if (!res.ok) {
87
+ throw new Error(`chess.ceo ${res.status}: ${text.slice(0, 500)}`);
88
+ }
89
+ return text.length ? JSON.parse(text) : null;
90
+ }
91
+ // Browser handlers wrap successful responses as
92
+ // { success: true, message: "...", data: <actual thing> }
93
+ // via handlers.RespondSuccess. Peel that off; leave anything without
94
+ // the envelope unchanged (some endpoints return raw bodies).
95
+ export function unwrap(raw) {
96
+ if (raw &&
97
+ typeof raw === "object" &&
98
+ "data" in raw &&
99
+ raw.success === true) {
100
+ return raw.data;
101
+ }
102
+ return raw;
103
+ }
104
+ export function makeFileId(collectionId, gameId) {
105
+ return `${collectionId}:${gameId}`;
106
+ }
107
+ export function splitFileId(id) {
108
+ const idx = id.indexOf(":");
109
+ if (idx <= 0 || idx === id.length - 1) {
110
+ throw new Error(`invalid prep file id "${id}" — expected "<collection_id>:<game_id>" ` +
111
+ `(get one from list_prep_files, search_prep_files, find_position_in_files, ` +
112
+ `or the create_prep_file return value)`);
113
+ }
114
+ return { collectionId: id.slice(0, idx), gameId: id.slice(idx + 1) };
115
+ }
116
+ // GET one game by composite id. Throws on 404.
117
+ export async function fetchGame(id) {
118
+ const { collectionId, gameId } = splitFileId(id);
119
+ const raw = await authedRequest("GET", `${PGN_BASE}/${encodeURIComponent(collectionId)}/games/${encodeURIComponent(gameId)}`);
120
+ const g = unwrap(raw);
121
+ if (!g || typeof g.pgnContent !== "string") {
122
+ throw new Error("prep file missing pgnContent");
123
+ }
124
+ return g;
125
+ }
126
+ // PUT the game body. Returns the saved game (new version).
127
+ export async function saveGame(id, pgn, expectedVersion) {
128
+ const { collectionId, gameId } = splitFileId(id);
129
+ const body = { pgnContent: pgn };
130
+ if (typeof expectedVersion === "number")
131
+ body.baseVersion = expectedVersion;
132
+ const raw = await authedRequest("PUT", `${PGN_BASE}/${encodeURIComponent(collectionId)}/games/${encodeURIComponent(gameId)}`, body);
133
+ return unwrap(raw);
134
+ }
135
+ // POST a new game into the given collection. Returns the created game.
136
+ export async function createGame(collectionId, pgn) {
137
+ const raw = await authedRequest("POST", `${PGN_BASE}/${encodeURIComponent(collectionId)}/games`, { pgnContent: pgn });
138
+ return unwrap(raw);
139
+ }
140
+ // DELETE (soft) a game by composite id.
141
+ export async function deleteGame(id) {
142
+ const { collectionId, gameId } = splitFileId(id);
143
+ await authedRequest("DELETE", `${PGN_BASE}/${encodeURIComponent(collectionId)}/games/${encodeURIComponent(gameId)}`);
144
+ }
145
+ // Restore a previously soft-deleted game. Symmetric with deleteGame.
146
+ export async function restoreGame(id) {
147
+ const { collectionId, gameId } = splitFileId(id);
148
+ const raw = await authedRequest("POST", `${PGN_BASE}/${encodeURIComponent(collectionId)}/games/${encodeURIComponent(gameId)}/restore`);
149
+ return unwrap(raw);
150
+ }