@chessceo/mcp 0.4.0 → 0.6.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 (2) hide show
  1. package/dist/index.js +186 -3
  2. 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,47 @@ 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
+ // Tools that require an MCP token — cloud engine tools operate on the
32
+ // user's rented instances so we can't service them anonymously. The
33
+ // streamable-http transport uses this list to decide whether to trigger
34
+ // the OAuth discovery flow via 401 + WWW-Authenticate before the SDK
35
+ // gets a chance to handle the call.
36
+ const AUTHED_TOOLS = new Set([
37
+ "start_cloud_engine",
38
+ "list_cloud_engines",
39
+ "stop_cloud_engine",
40
+ "cloud_analyse",
41
+ ]);
42
+ function isAuthedToolCall(body) {
43
+ if (!body || typeof body !== "object")
44
+ return false;
45
+ const b = body;
46
+ if (b.method !== "tools/call")
47
+ return false;
48
+ const name = b.params?.name;
49
+ return typeof name === "string" && AUTHED_TOOLS.has(name);
50
+ }
51
+ function resolveAuthHeader() {
52
+ const store = authContext.getStore();
53
+ if (store?.authHeader)
54
+ return store.authHeader;
55
+ const env = process.env.CHESSCEO_TOKEN?.trim();
56
+ if (!env)
57
+ return undefined;
58
+ return env.toLowerCase().startsWith("bearer ") ? env : `Bearer ${env}`;
59
+ }
18
60
  async function get(path, params) {
19
61
  const url = new URL(path, BASE);
20
62
  for (const [k, v] of Object.entries(params)) {
@@ -36,6 +78,37 @@ async function get(path, params) {
36
78
  }
37
79
  return res.json();
38
80
  }
81
+ // authedRequest is the shared code path for POST/GET/DELETE calls that need
82
+ // an MCP token. Missing-token errors are surfaced early with a message the
83
+ // LLM can act on (either configure CHESSCEO_TOKEN or generate a token in
84
+ // user settings) rather than a generic 401 from the backend.
85
+ async function authedRequest(method, path, body) {
86
+ const auth = resolveAuthHeader();
87
+ if (!auth) {
88
+ throw new Error("No MCP token available. Set CHESSCEO_TOKEN env (stdio mode) or pass an " +
89
+ "Authorization: Bearer mcp_... header (streamable-http mode). Generate " +
90
+ "a token at chess.ceo → user settings → MCP tokens.");
91
+ }
92
+ const url = new URL(path, BASE);
93
+ const headers = {
94
+ "User-Agent": UA,
95
+ "Accept": "application/json",
96
+ "Authorization": auth,
97
+ };
98
+ const init = { method, headers };
99
+ if (body !== undefined) {
100
+ headers["Content-Type"] = "application/json";
101
+ init.body = JSON.stringify(body);
102
+ }
103
+ const res = await fetch(url, init);
104
+ if (res.status === 204)
105
+ return null;
106
+ const text = await res.text();
107
+ if (!res.ok) {
108
+ throw new Error(`chess.ceo ${res.status}: ${text.slice(0, 500)}`);
109
+ }
110
+ return text.length ? JSON.parse(text) : null;
111
+ }
39
112
  // ── Tool definitions ───────────────────────────────────────────────
40
113
  //
41
114
  // Descriptions are written for the LLM, not humans — they should hint
@@ -185,6 +258,62 @@ const TOOLS = [
185
258
  required: ["fide_id"],
186
259
  },
187
260
  },
261
+ {
262
+ name: "start_cloud_engine",
263
+ 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.",
264
+ inputSchema: {
265
+ type: "object",
266
+ properties: {
267
+ machine_type: {
268
+ type: "string",
269
+ 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.",
270
+ },
271
+ },
272
+ required: ["machine_type"],
273
+ },
274
+ },
275
+ {
276
+ name: "list_cloud_engines",
277
+ 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.",
278
+ inputSchema: { type: "object", properties: {} },
279
+ },
280
+ {
281
+ name: "stop_cloud_engine",
282
+ description: "Destroy a running cloud engine. Billing stops immediately. Use the contract_id from `list_cloud_engines` — don't guess.",
283
+ inputSchema: {
284
+ type: "object",
285
+ properties: {
286
+ contract_id: {
287
+ type: "string",
288
+ description: "Instance contract_id, from list_cloud_engines.",
289
+ },
290
+ },
291
+ required: ["contract_id"],
292
+ },
293
+ },
294
+ {
295
+ name: "cloud_analyse",
296
+ 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.",
297
+ inputSchema: {
298
+ type: "object",
299
+ properties: {
300
+ fen: { type: "string", description: "FEN of the position to analyse." },
301
+ movetime_ms: {
302
+ type: "integer",
303
+ minimum: 100,
304
+ maximum: 10000,
305
+ description: "Think time in milliseconds (default 2000).",
306
+ },
307
+ multipv: {
308
+ type: "integer",
309
+ minimum: 1,
310
+ maximum: 10,
311
+ description: "Number of candidate lines per engine (default 3).",
312
+ },
313
+ },
314
+ required: ["fen"],
315
+ },
316
+ },
188
317
  {
189
318
  name: "prep_snapshot",
190
319
  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 +386,22 @@ async function callTool(name, args) {
257
386
  case "list_player_live_tournaments":
258
387
  // Note: snake_case fide_id, unlike the prep endpoints. Documented quirk.
259
388
  return get("/api/chess/live/player", { fide_id: Number(args.fide_id) });
389
+ case "start_cloud_engine":
390
+ return authedRequest("POST", "/api/agent/cloud-engines", {
391
+ machineType: String(args.machine_type),
392
+ });
393
+ case "list_cloud_engines":
394
+ return authedRequest("GET", "/api/agent/cloud-engines");
395
+ case "stop_cloud_engine":
396
+ return authedRequest("DELETE", `/api/agent/cloud-engines/${encodeURIComponent(String(args.contract_id))}`);
397
+ case "cloud_analyse": {
398
+ const body = { fen: String(args.fen) };
399
+ if (typeof args.movetime_ms === "number")
400
+ body.movetime_ms = args.movetime_ms;
401
+ if (typeof args.multipv === "number")
402
+ body.multipv = args.multipv;
403
+ return authedRequest("POST", "/api/agent/cloud-engines/analyse", body);
404
+ }
260
405
  case "prep_snapshot": {
261
406
  const me = Number(args.fide_id_me);
262
407
  const opp = Number(args.fide_id_opponent);
@@ -524,14 +669,48 @@ else if (transportKind === "http" || transportKind === "streamable-http") {
524
669
  res.end("ok\n");
525
670
  return;
526
671
  }
527
- // Everything else must hit the MCP path.
528
672
  const urlPath = (req.url ?? "").split("?")[0];
673
+ // RFC 9728 protected-resource metadata. MCP hosts (claude.ai, ChatGPT)
674
+ // fetch this after receiving a 401 with WWW-Authenticate below; it
675
+ // points them at chess.ceo's OAuth 2.1 authorization server, which
676
+ // handles registration (DCR), consent, and token issuance.
677
+ if (req.method === "GET" && urlPath === "/.well-known/oauth-protected-resource") {
678
+ res.statusCode = 200;
679
+ res.setHeader("Content-Type", "application/json");
680
+ res.setHeader("Cache-Control", "public, max-age=3600");
681
+ res.end(JSON.stringify({
682
+ resource: "https://mcp.chess.ceo/mcp",
683
+ authorization_servers: ["https://chess.ceo"],
684
+ scopes_supported: ["agent"],
685
+ bearer_methods_supported: ["header"],
686
+ }));
687
+ return;
688
+ }
689
+ // Everything else must hit the MCP path.
529
690
  if (urlPath !== path) {
530
691
  res.statusCode = 404;
531
692
  res.end();
532
693
  return;
533
694
  }
534
695
  try {
696
+ const body = req.method === "POST" ? await readBody(req) : undefined;
697
+ const authHeader = req.headers["authorization"];
698
+ const authHeaderStr = Array.isArray(authHeader) ? authHeader[0] : authHeader;
699
+ // If the caller is invoking an authed tool without a token, respond
700
+ // with 401 + WWW-Authenticate pointing at RFC 9728 metadata BEFORE
701
+ // handing off to the MCP SDK — MCP hosts (claude.ai, ChatGPT) look
702
+ // for this header at the HTTP layer to auto-start OAuth discovery.
703
+ if (!authHeaderStr && isAuthedToolCall(body)) {
704
+ res.statusCode = 401;
705
+ res.setHeader("WWW-Authenticate", `Bearer realm="chess.ceo", resource_metadata="https://mcp.chess.ceo/.well-known/oauth-protected-resource"`);
706
+ res.setHeader("Content-Type", "application/json");
707
+ res.end(JSON.stringify({
708
+ jsonrpc: "2.0",
709
+ error: { code: -32001, message: "authentication required" },
710
+ id: body?.id ?? null,
711
+ }));
712
+ return;
713
+ }
535
714
  // Stateless: one transport per request, no session store. Simpler,
536
715
  // scales trivially, matches how claude.ai / ChatGPT connectors call.
537
716
  const transport = new StreamableHTTPServerTransport({
@@ -540,8 +719,12 @@ else if (transportKind === "http" || transportKind === "streamable-http") {
540
719
  });
541
720
  res.on("close", () => transport.close());
542
721
  await server.connect(transport);
543
- const body = req.method === "POST" ? await readBody(req) : undefined;
544
- await transport.handleRequest(req, res, body);
722
+ // Forward the caller's Authorization header down to tool handlers so
723
+ // they can attach it when calling authenticated backend endpoints.
724
+ // AsyncLocalStorage survives every await inside the tool handler.
725
+ await authContext.run({ authHeader: authHeaderStr }, async () => {
726
+ await transport.handleRequest(req, res, body);
727
+ });
545
728
  }
546
729
  catch (err) {
547
730
  if (!res.headersSent) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chessceo/mcp",
3
- "version": "0.4.0",
3
+ "version": "0.6.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": {