@chessceo/mcp 0.3.1 → 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.
Files changed (3) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.js +163 -1
  3. package/package.json +1 -1
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 chess.ceo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to grant persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
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
@@ -121,6 +174,29 @@ const TOOLS = [
121
174
  required: ["fen"],
122
175
  },
123
176
  },
177
+ {
178
+ name: "analyse",
179
+ description: "Short Stockfish evaluation at a position. Returns the top-N candidate moves with score (centipawns from side-to-move POV, positive = advantage; or mate distance) and the principal variation for each. Defaults: 2s think time, top-3 lines. PV moves come back in UCI notation (e2e4, not e4). Use this to sanity-check candidate lines from get_position_stats or get_player_preparation — human game frequency tells you what people play, engine evaluation tells you what's actually good.",
180
+ inputSchema: {
181
+ type: "object",
182
+ properties: {
183
+ fen: { type: "string", description: "FEN of the position to analyse." },
184
+ movetime_ms: {
185
+ type: "integer",
186
+ minimum: 100,
187
+ maximum: 10000,
188
+ description: "Think time in milliseconds (default 2000).",
189
+ },
190
+ multipv: {
191
+ type: "integer",
192
+ minimum: 1,
193
+ maximum: 10,
194
+ description: "Number of candidate lines to return (default 3).",
195
+ },
196
+ },
197
+ required: ["fen"],
198
+ },
199
+ },
124
200
  {
125
201
  name: "get_head_to_head",
126
202
  description: "Complete head-to-head record between two players. Includes overall and per-colour W/D/L (from player A's perspective), splits by time control, most-played openings between them, first / last meeting, average game length, and the game list.",
@@ -162,6 +238,62 @@ const TOOLS = [
162
238
  required: ["fide_id"],
163
239
  },
164
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
+ },
165
297
  {
166
298
  name: "prep_snapshot",
167
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).",
@@ -212,6 +344,14 @@ async function callTool(name, args) {
212
344
  limit: typeof args.limit === "number" ? args.limit : 20,
213
345
  sort: "relevance",
214
346
  });
347
+ case "analyse": {
348
+ const params = { fen: String(args.fen) };
349
+ if (typeof args.movetime_ms === "number")
350
+ params.movetime_ms = args.movetime_ms;
351
+ if (typeof args.multipv === "number")
352
+ params.multipv = args.multipv;
353
+ return get("/api/chess/database/analyse", params);
354
+ }
215
355
  case "get_head_to_head":
216
356
  return get("/api/chess/players/h2h", {
217
357
  a: Number(args.fide_id_a),
@@ -226,6 +366,22 @@ async function callTool(name, args) {
226
366
  case "list_player_live_tournaments":
227
367
  // Note: snake_case fide_id, unlike the prep endpoints. Documented quirk.
228
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
+ }
229
385
  case "prep_snapshot": {
230
386
  const me = Number(args.fide_id_me);
231
387
  const opp = Number(args.fide_id_opponent);
@@ -510,7 +666,13 @@ else if (transportKind === "http" || transportKind === "streamable-http") {
510
666
  res.on("close", () => transport.close());
511
667
  await server.connect(transport);
512
668
  const body = req.method === "POST" ? await readBody(req) : undefined;
513
- await transport.handleRequest(req, res, body);
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
+ });
514
676
  }
515
677
  catch (err) {
516
678
  if (!res.headersSent) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chessceo/mcp",
3
- "version": "0.3.1",
3
+ "version": "0.5.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": {