@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.
- package/dist/index.js +186 -3
- 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
|
-
|
|
544
|
-
|
|
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