@chessceo/mcp 0.4.0 → 0.5.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 +132 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// endpoints — see the public contract at https://chess.ceo/llms.txt.
|
|
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
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
10
11
|
import { Chess } from "chess.js";
|
|
11
12
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
12
13
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -15,6 +16,27 @@ import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema
|
|
|
15
16
|
const BASE = process.env.CHESSCEO_BASE_URL ?? "https://chess.ceo";
|
|
16
17
|
const UA = `chessceo-mcp/${process.env.npm_package_version ?? "0.1.0"} (+https://chess.ceo)`;
|
|
17
18
|
// ── HTTP ────────────────────────────────────────────────────────────
|
|
19
|
+
//
|
|
20
|
+
// Two auth flavours coexist:
|
|
21
|
+
// - Anonymous GETs (players, positions, prep, live) — no auth.
|
|
22
|
+
// - Authed tools (cloud engines) — `Authorization: Bearer mcp_...`.
|
|
23
|
+
//
|
|
24
|
+
// The token comes from one of two sources:
|
|
25
|
+
// - stdio: `CHESSCEO_TOKEN` env var, set by the MCP host config. Bare
|
|
26
|
+
// `mcp_...` — we prepend the `Bearer ` scheme when building the header.
|
|
27
|
+
// - streamable-http: the caller's `Authorization` header, forwarded
|
|
28
|
+
// per-request via AsyncLocalStorage so tool handlers can see it even
|
|
29
|
+
// though the MCP SDK's request handler doesn't know about HTTP.
|
|
30
|
+
const authContext = new AsyncLocalStorage();
|
|
31
|
+
function resolveAuthHeader() {
|
|
32
|
+
const store = authContext.getStore();
|
|
33
|
+
if (store?.authHeader)
|
|
34
|
+
return store.authHeader;
|
|
35
|
+
const env = process.env.CHESSCEO_TOKEN?.trim();
|
|
36
|
+
if (!env)
|
|
37
|
+
return undefined;
|
|
38
|
+
return env.toLowerCase().startsWith("bearer ") ? env : `Bearer ${env}`;
|
|
39
|
+
}
|
|
18
40
|
async function get(path, params) {
|
|
19
41
|
const url = new URL(path, BASE);
|
|
20
42
|
for (const [k, v] of Object.entries(params)) {
|
|
@@ -36,6 +58,37 @@ async function get(path, params) {
|
|
|
36
58
|
}
|
|
37
59
|
return res.json();
|
|
38
60
|
}
|
|
61
|
+
// authedRequest is the shared code path for POST/GET/DELETE calls that need
|
|
62
|
+
// an MCP token. Missing-token errors are surfaced early with a message the
|
|
63
|
+
// LLM can act on (either configure CHESSCEO_TOKEN or generate a token in
|
|
64
|
+
// user settings) rather than a generic 401 from the backend.
|
|
65
|
+
async function authedRequest(method, path, body) {
|
|
66
|
+
const auth = resolveAuthHeader();
|
|
67
|
+
if (!auth) {
|
|
68
|
+
throw new Error("No MCP token available. Set CHESSCEO_TOKEN env (stdio mode) or pass an " +
|
|
69
|
+
"Authorization: Bearer mcp_... header (streamable-http mode). Generate " +
|
|
70
|
+
"a token at chess.ceo → user settings → MCP tokens.");
|
|
71
|
+
}
|
|
72
|
+
const url = new URL(path, BASE);
|
|
73
|
+
const headers = {
|
|
74
|
+
"User-Agent": UA,
|
|
75
|
+
"Accept": "application/json",
|
|
76
|
+
"Authorization": auth,
|
|
77
|
+
};
|
|
78
|
+
const init = { method, headers };
|
|
79
|
+
if (body !== undefined) {
|
|
80
|
+
headers["Content-Type"] = "application/json";
|
|
81
|
+
init.body = JSON.stringify(body);
|
|
82
|
+
}
|
|
83
|
+
const res = await fetch(url, init);
|
|
84
|
+
if (res.status === 204)
|
|
85
|
+
return null;
|
|
86
|
+
const text = await res.text();
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
throw new Error(`chess.ceo ${res.status}: ${text.slice(0, 500)}`);
|
|
89
|
+
}
|
|
90
|
+
return text.length ? JSON.parse(text) : null;
|
|
91
|
+
}
|
|
39
92
|
// ── Tool definitions ───────────────────────────────────────────────
|
|
40
93
|
//
|
|
41
94
|
// Descriptions are written for the LLM, not humans — they should hint
|
|
@@ -185,6 +238,62 @@ const TOOLS = [
|
|
|
185
238
|
required: ["fide_id"],
|
|
186
239
|
},
|
|
187
240
|
},
|
|
241
|
+
{
|
|
242
|
+
name: "start_cloud_engine",
|
|
243
|
+
description: "Rent a combo GPU instance (Stockfish + Lc0 in the same container) on the user's chess.ceo account. Real money — billed per second while running. Requires an MCP token with agent access. Use `list_cloud_engines` first to see if the user already has one running; don't start a second combo unless the user asked for it.",
|
|
244
|
+
inputSchema: {
|
|
245
|
+
type: "object",
|
|
246
|
+
properties: {
|
|
247
|
+
machine_type: {
|
|
248
|
+
type: "string",
|
|
249
|
+
description: "SKU picked from the user's cloud-engine options (e.g. 'rtx-5090', 'rtx-5090-dual'). Ask the user if you don't already know which one they want.",
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
required: ["machine_type"],
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
name: "list_cloud_engines",
|
|
257
|
+
description: "List the user's currently running cloud engines. Use before starting a new one, or to find the contract_id for stop_cloud_engine. `cloud_analyse` auto-picks the only running combo, so listing is only necessary when the user might have zero or several.",
|
|
258
|
+
inputSchema: { type: "object", properties: {} },
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
name: "stop_cloud_engine",
|
|
262
|
+
description: "Destroy a running cloud engine. Billing stops immediately. Use the contract_id from `list_cloud_engines` — don't guess.",
|
|
263
|
+
inputSchema: {
|
|
264
|
+
type: "object",
|
|
265
|
+
properties: {
|
|
266
|
+
contract_id: {
|
|
267
|
+
type: "string",
|
|
268
|
+
description: "Instance contract_id, from list_cloud_engines.",
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
required: ["contract_id"],
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
name: "cloud_analyse",
|
|
276
|
+
description: "Runs a synchronous ~2s analysis on the user's running combo instance and returns both Stockfish and Lc0's final read for the FEN — depth, top-N candidate moves with scores (centipawns from side-to-move POV or mate distance), and each engine's principal variation. Auto-picks the caller's only running combo instance; errors clearly if there are zero (start one first) or more than one (destroy the extras first). Much deeper than `analyse` (the CPU-only public endpoint) — use this when the user is genuinely doing prep work and has cloud engines running.",
|
|
277
|
+
inputSchema: {
|
|
278
|
+
type: "object",
|
|
279
|
+
properties: {
|
|
280
|
+
fen: { type: "string", description: "FEN of the position to analyse." },
|
|
281
|
+
movetime_ms: {
|
|
282
|
+
type: "integer",
|
|
283
|
+
minimum: 100,
|
|
284
|
+
maximum: 10000,
|
|
285
|
+
description: "Think time in milliseconds (default 2000).",
|
|
286
|
+
},
|
|
287
|
+
multipv: {
|
|
288
|
+
type: "integer",
|
|
289
|
+
minimum: 1,
|
|
290
|
+
maximum: 10,
|
|
291
|
+
description: "Number of candidate lines per engine (default 3).",
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
required: ["fen"],
|
|
295
|
+
},
|
|
296
|
+
},
|
|
188
297
|
{
|
|
189
298
|
name: "prep_snapshot",
|
|
190
299
|
description: "One call, three parallel fetches at the same position: opponent's stats on their side, your stats on your side, and the 11.7M-game general database at that position. Use this while walking the opening tree — one round trip instead of three separate calls, and you can compare the three views directly (e.g. opponent has 2 games here but the general DB has 8k → prep candidate).",
|
|
@@ -257,6 +366,22 @@ async function callTool(name, args) {
|
|
|
257
366
|
case "list_player_live_tournaments":
|
|
258
367
|
// Note: snake_case fide_id, unlike the prep endpoints. Documented quirk.
|
|
259
368
|
return get("/api/chess/live/player", { fide_id: Number(args.fide_id) });
|
|
369
|
+
case "start_cloud_engine":
|
|
370
|
+
return authedRequest("POST", "/api/agent/cloud-engines", {
|
|
371
|
+
machineType: String(args.machine_type),
|
|
372
|
+
});
|
|
373
|
+
case "list_cloud_engines":
|
|
374
|
+
return authedRequest("GET", "/api/agent/cloud-engines");
|
|
375
|
+
case "stop_cloud_engine":
|
|
376
|
+
return authedRequest("DELETE", `/api/agent/cloud-engines/${encodeURIComponent(String(args.contract_id))}`);
|
|
377
|
+
case "cloud_analyse": {
|
|
378
|
+
const body = { fen: String(args.fen) };
|
|
379
|
+
if (typeof args.movetime_ms === "number")
|
|
380
|
+
body.movetime_ms = args.movetime_ms;
|
|
381
|
+
if (typeof args.multipv === "number")
|
|
382
|
+
body.multipv = args.multipv;
|
|
383
|
+
return authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
|
|
384
|
+
}
|
|
260
385
|
case "prep_snapshot": {
|
|
261
386
|
const me = Number(args.fide_id_me);
|
|
262
387
|
const opp = Number(args.fide_id_opponent);
|
|
@@ -541,7 +666,13 @@ else if (transportKind === "http" || transportKind === "streamable-http") {
|
|
|
541
666
|
res.on("close", () => transport.close());
|
|
542
667
|
await server.connect(transport);
|
|
543
668
|
const body = req.method === "POST" ? await readBody(req) : undefined;
|
|
544
|
-
|
|
669
|
+
// Forward the caller's Authorization header down to tool handlers so
|
|
670
|
+
// they can attach it when calling authenticated backend endpoints.
|
|
671
|
+
// AsyncLocalStorage survives every await inside the tool handler.
|
|
672
|
+
const authHeader = req.headers["authorization"];
|
|
673
|
+
await authContext.run({ authHeader: Array.isArray(authHeader) ? authHeader[0] : authHeader }, async () => {
|
|
674
|
+
await transport.handleRequest(req, res, body);
|
|
675
|
+
});
|
|
545
676
|
}
|
|
546
677
|
catch (err) {
|
|
547
678
|
if (!res.headersSent) {
|
package/package.json
CHANGED