@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.
- package/dist/analysis/auto.js +291 -0
- package/dist/analysis/deep.js +157 -0
- package/dist/analysis/file_handle.js +156 -0
- package/dist/analysis/response.js +181 -0
- package/dist/courses.js +167 -0
- package/dist/http.js +150 -0
- package/dist/index.js +49 -2707
- package/dist/pgn/exporter.js +13 -2
- package/dist/pgn/parser.js +46 -7
- package/dist/pgn/paths.js +37 -14
- package/dist/pgn/types.js +8 -5
- package/dist/prep/library.js +92 -0
- package/dist/prep/mutations.js +170 -0
- package/dist/prep/read.js +242 -0
- package/dist/prompts.js +42 -0
- package/dist/response_transforms.js +169 -0
- package/dist/tools.js +893 -0
- package/dist/warnings.js +121 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/courses.js
ADDED
|
@@ -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
|
+
}
|