@chessceo/mcp 0.1.0 → 0.3.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/README.md +122 -0
  2. package/dist/index.js +253 -5
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -51,6 +51,17 @@ Similar `~/.cursor/mcp.json`:
51
51
  }
52
52
  ```
53
53
 
54
+ ## Install (Claude Code)
55
+
56
+ This repo is also a [Claude Code plugin marketplace](https://code.claude.com/docs/en/plugin-marketplaces), so you can add it directly:
57
+
58
+ ```
59
+ /plugin marketplace add chessceo/chessceo-mcp
60
+ /plugin install chessceo@chessceo
61
+ ```
62
+
63
+ Claude Code will pull the plugin from GitHub and wire the MCP server automatically. Enable "Sync automatically" in the marketplace UI if you want future updates fetched on push.
64
+
54
65
  ## Try it
55
66
 
56
67
  Ask your model:
@@ -60,6 +71,112 @@ Ask your model:
60
71
  - *"Are there any live tournaments right now with Hikaru Nakamura?"*
61
72
  - *"From the position after 1.e4 c5 2.Nf3 d6 3.d4 cxd4 4.Nxd4 Nf6 5.Nc3 a6, what's the top continuation across the whole database?"*
62
73
 
74
+ ## Prep workflow (built-in prompts)
75
+
76
+ For MCP hosts that show prompts in a slash-menu (Claude Desktop, Cursor, Claude Code), three pre-baked prompts are included so users get a proper preparation workflow without prompt-engineering their own:
77
+
78
+ | Prompt | Purpose |
79
+ |---|---|
80
+ | `prepare_for_game(me, opponent, my_color?, time_control?)` | Full pre-match workflow: resolves both players, weights games by recency + format (classical OTB > rapid/blitz > online), walks the opponent's repertoire looking for lines where they score under 40%, checks head-to-head, and delivers a concrete plan with the moves to steer toward the opponent's weak points. |
81
+ | `scout_player(player)` | Deep scouting report on one player — style, top openings, recent form, biggest wins and losses, recurring weaknesses. |
82
+ | `head_to_head_briefing(player_a, player_b)` | One-paragraph read on the history between two players — who has the edge, dominant openings, style clash, current form. |
83
+
84
+ Pick one from the host's slash-menu, fill in the arguments, and the model does the rest.
85
+
86
+ ## Remote MCP (chess.ceo-hosted)
87
+
88
+ You can also connect to chess.ceo's hosted instance and skip installing anything:
89
+
90
+ ```
91
+ https://mcp.chess.ceo/mcp
92
+ ```
93
+
94
+ In Claude Code:
95
+
96
+ ```
97
+ /plugin add-mcp url https://mcp.chess.ceo/mcp
98
+ ```
99
+
100
+ In Claude Desktop, edit `claude_desktop_config.json`:
101
+
102
+ ```json
103
+ {
104
+ "mcpServers": {
105
+ "chessceo": {
106
+ "url": "https://mcp.chess.ceo/mcp"
107
+ }
108
+ }
109
+ }
110
+ ```
111
+
112
+ Same 8 tools, same data, zero-install. Useful when the host can't spawn subprocesses (e.g. Claude.ai web, Claude mobile, ChatGPT connectors).
113
+
114
+ ## Self-host the HTTP transport
115
+
116
+ The same package can run as a persistent HTTP server, not just a stdio subprocess:
117
+
118
+ ```bash
119
+ chessceo-mcp --transport=http --http-port=8080 --http-host=127.0.0.1
120
+ ```
121
+
122
+ Flags (or the corresponding env vars):
123
+
124
+ | Flag | Env var | Default | Purpose |
125
+ |---|---|---|---|
126
+ | `--transport` | `MCP_TRANSPORT` | `stdio` | `stdio` or `http` |
127
+ | `--http-port` | `MCP_HTTP_PORT` | `8080` | Port to bind |
128
+ | `--http-host` | `MCP_HTTP_HOST` | `127.0.0.1` | Bind address |
129
+ | `--http-path` | `MCP_HTTP_PATH` | `/mcp` | Streamable-HTTP endpoint |
130
+
131
+ `GET /healthz` returns `200 ok\n` — wire it into your uptime monitor.
132
+
133
+ ### systemd unit (example)
134
+
135
+ ```ini
136
+ # /etc/systemd/system/chessceo-mcp.service
137
+ [Unit]
138
+ Description=chess.ceo MCP server (Streamable HTTP)
139
+ After=network.target
140
+
141
+ [Service]
142
+ Type=simple
143
+ User=www-data
144
+ Environment=NODE_ENV=production
145
+ Environment=MCP_TRANSPORT=http
146
+ Environment=MCP_HTTP_PORT=8127
147
+ Environment=MCP_HTTP_HOST=127.0.0.1
148
+ ExecStart=/usr/bin/npx -y @chessceo/mcp
149
+ Restart=on-failure
150
+ RestartSec=5
151
+
152
+ [Install]
153
+ WantedBy=multi-user.target
154
+ ```
155
+
156
+ ### nginx snippet (example)
157
+
158
+ ```nginx
159
+ server {
160
+ listen 443 ssl http2;
161
+ server_name mcp.chess.ceo;
162
+
163
+ ssl_certificate /etc/letsencrypt/live/mcp.chess.ceo/fullchain.pem;
164
+ ssl_certificate_key /etc/letsencrypt/live/mcp.chess.ceo/privkey.pem;
165
+
166
+ # Streamable HTTP is short JSON POSTs — no long-poll SSE required.
167
+ location /mcp {
168
+ proxy_pass http://127.0.0.1:8127/mcp;
169
+ proxy_http_version 1.1;
170
+ proxy_set_header Host $host;
171
+ proxy_set_header X-Real-IP $remote_addr;
172
+ proxy_buffering off; # streaming responses shouldn't be buffered
173
+ proxy_read_timeout 300s;
174
+ }
175
+
176
+ location = /healthz { proxy_pass http://127.0.0.1:8127/healthz; }
177
+ }
178
+ ```
179
+
63
180
  ## Development
64
181
 
65
182
  ```bash
@@ -68,11 +185,16 @@ cd chessceo-mcp
68
185
  npm install
69
186
  npm run build # tsc → dist/
70
187
  npm start # runs the server on stdio (for MCP hosts)
188
+
189
+ # or run the HTTP transport locally:
190
+ node dist/index.js --transport=http --http-port=8127
191
+ curl http://127.0.0.1:8127/healthz # should print "ok"
71
192
  ```
72
193
 
73
194
  Environment variable overrides:
74
195
 
75
196
  - `CHESSCEO_BASE_URL` — override the API base (default `https://chess.ceo`). Useful for testing against staging.
197
+ - MCP transport env vars — see the self-host table above.
76
198
 
77
199
  ## What's under the hood
78
200
 
package/dist/index.js CHANGED
@@ -6,9 +6,11 @@
6
6
  // Everything here is a thin wrapper around https://chess.ceo/api/chess/*
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
+ import { createServer as createHttpServer } from "node:http";
9
10
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
10
11
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
- import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
12
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
13
+ import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
12
14
  const BASE = process.env.CHESSCEO_BASE_URL ?? "https://chess.ceo";
13
15
  const UA = `chessceo-mcp/${process.env.npm_package_version ?? "0.1.0"} (+https://chess.ceo)`;
14
16
  // ── HTTP ────────────────────────────────────────────────────────────
@@ -69,7 +71,7 @@ const TOOLS = [
69
71
  },
70
72
  {
71
73
  name: "get_player_preparation",
72
- description: "For a given player, colour and starting position, return both the moves the player actually chose (frequency + win rate) and the underlying games. Position is specified either as a move sequence in SAN (`line`) or a raw FEN. Use `line` iteratively to walk the opening tree: call once with empty `line`, pick a move, call again with `line` extended by that move, etc.",
74
+ description: "For a given player, colour and starting position, return both the moves the player actually chose (frequency + win rate) and the underlying games. Position is specified either as a move sequence in SAN (`line`) or a raw FEN. Use `line` iteratively to walk the opening tree: call once with empty `line`, pick a move, call again with `line` extended by that move, etc. When preparing for a real game, weight recent games (last 12-24 months) more heavily than old ones, classical over-the-board > rapid/blitz > online, and look for variations the player scores poorly in (below ~40%) or plays with less variety (shallower prep).",
73
75
  inputSchema: {
74
76
  type: "object",
75
77
  properties: {
@@ -206,9 +208,133 @@ async function callTool(name, args) {
206
208
  throw new Error(`Unknown tool: ${name}`);
207
209
  }
208
210
  }
211
+ // ── Prompt templates ───────────────────────────────────────────────
212
+ //
213
+ // MCP prompts are pre-baked instructions the host surfaces in a slash-menu
214
+ // (Claude Desktop, Cursor, etc.). Users pick one and the templated content
215
+ // gets injected into the conversation. Perfect for workflows the LLM
216
+ // wouldn't reliably discover from tool descriptions alone — chess prep
217
+ // especially, where "call the right tools in the right order weighted by
218
+ // recency and format" is non-obvious.
219
+ const PROMPTS = [
220
+ {
221
+ name: "prepare_for_game",
222
+ description: "Prep workflow for an upcoming chess game. Walks both players' repertoires and identifies where the opponent is weakest.",
223
+ arguments: [
224
+ { name: "me", description: "Your name (or FIDE ID)", required: true },
225
+ { name: "opponent", description: "Opponent's name (or FIDE ID)", required: true },
226
+ { name: "my_color", description: "The color you'll be playing: 'white' or 'black'. Optional — if you don't know yet, ask.", required: false },
227
+ { name: "time_control", description: "Optional: 'classical', 'rapid', 'blitz'. Weights which of the opponent's games matter most.", required: false },
228
+ ],
229
+ },
230
+ {
231
+ name: "scout_player",
232
+ description: "Deep scouting report on one player. Their style, top openings, recent form, and where they've been beaten.",
233
+ arguments: [
234
+ { name: "player", description: "Player name or FIDE ID", required: true },
235
+ ],
236
+ },
237
+ {
238
+ name: "head_to_head_briefing",
239
+ description: "Briefing on the history between two players — who has the edge, what openings decide their meetings, style clash.",
240
+ arguments: [
241
+ { name: "player_a", description: "First player (name or FIDE ID)", required: true },
242
+ { name: "player_b", description: "Second player (name or FIDE ID)", required: true },
243
+ ],
244
+ },
245
+ ];
246
+ // The workflow text for prepare_for_game. Kept in one place so both the
247
+ // MCP prompt handler and the /prepare fallback can share it.
248
+ const PREP_WORKFLOW = (args) => {
249
+ const me = args.me ?? "the user";
250
+ const opp = args.opponent ?? "the opponent";
251
+ const color = args.my_color ? ` as ${args.my_color}` : "";
252
+ const tc = args.time_control ? ` in a ${args.time_control} game` : "";
253
+ return `You are preparing ${me} for a chess game against ${opp}${color}${tc}.
254
+
255
+ Preparation workflow — follow the steps in order and be explicit about which tools you called at each step:
256
+
257
+ 1. **Resolve both players.** Call \`search_player\` for "${me}" and "${opp}" to get their FIDE IDs. Confirm the identity — many players share names.
258
+
259
+ 2. **Understand the opponent.** Call \`get_player_profile\` for the opponent. Read out:
260
+ - Current classical / rapid / blitz ratings.
261
+ - Top openings as White and Black (from openingRepertoire).
262
+ - Career win / draw / loss splits — is the draw rate above ~40%? That's a stylistic hint (drawish opponents need to be unbalanced).
263
+ - Notable wins and worst losses — patterns?
264
+
265
+ 3. **Weight games by quality when interpreting the data.**
266
+ - Recent games (last 12-24 months) matter far more than old ones. Opening repertoires evolve.
267
+ - Classical over-the-board games are the strongest signal — that's what real preparation reveals.
268
+ - Rapid and blitz reveal what they play under time pressure but may include experiments.
269
+ - Online games are useful but noisier (blitz gambits, alt accounts).
270
+
271
+ 4. **Walk the opponent's repertoire against ${me}'s color.** Call \`get_player_preparation\` on the opponent for the color they'll have in this game. Iterate: start from move 1, pick the opponent's most-played reply, call again with \`line\` extended. Look for:
272
+ - **Weak lines**: variations where the opponent scores below 40% as their side.
273
+ - **Shallow lines**: openings the opponent has played only a few times — probably less deeply prepared.
274
+ - **Abandoned lines**: openings they used to play but stopped. Something went wrong; may not want to revisit.
275
+ - **Variety**: places where the opponent picks different moves game to game — those are branching points where they can't predict your prep.
276
+
277
+ 5. **Style considerations.**
278
+ - High draw rate → propose openings that unbalance early (Benoni, King's Indian, gambit lines).
279
+ - Sharp tactician → don't play their prepared attacks; steer toward quiet positional lines.
280
+ - Endgame strong → keep queens on and keep complications.
281
+
282
+ 6. **Cross-check head-to-head.** Call \`get_head_to_head\` on the two players. If they've met before, what openings decided those games? Anything the opponent showed only against ${me}?
283
+
284
+ 7. **Deliver a concrete plan.** Summarize:
285
+ - What the opponent will likely play on move 1 (with confidence level).
286
+ - The 2-3 branching points where the opponent is weakest for ${me}'s color.
287
+ - The concrete move sequence ${me} should aim for to steer into those positions.
288
+ - What to avoid — the opponent's strongest weapons.
289
+
290
+ Don't just dump data. Reason about it. Cite specific numbers (game counts, win rates, dates) so the user can trust your conclusions.`;
291
+ };
209
292
  // ── Server wiring ──────────────────────────────────────────────────
210
- const server = new Server({ name: "chessceo-mcp", version: process.env.npm_package_version ?? "0.1.0" }, { capabilities: { tools: {} } });
293
+ const server = new Server({ name: "chessceo-mcp", version: process.env.npm_package_version ?? "0.1.0" }, { capabilities: { tools: {}, prompts: {} } });
211
294
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
295
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: PROMPTS }));
296
+ server.setRequestHandler(GetPromptRequestSchema, async (req) => {
297
+ const { name, arguments: args } = req.params;
298
+ const promptArgs = {};
299
+ if (args)
300
+ for (const [k, v] of Object.entries(args))
301
+ promptArgs[k] = String(v);
302
+ let text;
303
+ switch (name) {
304
+ case "prepare_for_game":
305
+ text = PREP_WORKFLOW(promptArgs);
306
+ break;
307
+ case "scout_player": {
308
+ const p = promptArgs.player ?? "the player";
309
+ text = `Produce a scouting report on ${p}. Steps:
310
+ 1. \`search_player\` to get their FIDE ID.
311
+ 2. \`get_player_profile\` — pull rating history, career splits by color and time control, opening repertoire, opponent analysis, top events, notable wins and losses.
312
+ 3. Weight the data: recent (last 12-24 months) > older, classical OTB > rapid/blitz > online.
313
+ 4. \`get_player_preparation\` for both colors from the starting position to summarise their opening choices with actual frequencies and win rates.
314
+ 5. Deliver: current strength, characteristic openings, one-sentence style read, biggest wins, biggest losses / recurring weakness. Cite the numbers.`;
315
+ break;
316
+ }
317
+ case "head_to_head_briefing": {
318
+ const a = promptArgs.player_a ?? "player A";
319
+ const b = promptArgs.player_b ?? "player B";
320
+ text = `Briefing on the ${a} vs ${b} history. Steps:
321
+ 1. Resolve both FIDE IDs with \`search_player\`.
322
+ 2. \`get_head_to_head\` for the pair — pull overall + per-color W/D/L (from ${a}'s perspective), splits by time format, first / last meeting, most-played openings between them, average game length.
323
+ 3. Read the pattern: who has the edge, in which colour, in which time format. Which openings decide the meetings? Anything unusual — very drawish, very sharp, big rating gap?
324
+ 4. If either player is currently live in a tournament, note it with \`list_player_live_tournaments\`.
325
+ 5. Deliver a one-paragraph read: score, dominant openings, one-line style clash, current form.`;
326
+ break;
327
+ }
328
+ default:
329
+ throw new Error(`Unknown prompt: ${name}`);
330
+ }
331
+ return {
332
+ description: `chessceo prompt: ${name}`,
333
+ messages: [
334
+ { role: "user", content: { type: "text", text } },
335
+ ],
336
+ };
337
+ });
212
338
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
213
339
  const { name, arguments: args } = req.params;
214
340
  try {
@@ -224,5 +350,127 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
224
350
  };
225
351
  }
226
352
  });
227
- const transport = new StdioServerTransport();
228
- await server.connect(transport);
353
+ // ── Transport selection ────────────────────────────────────────────
354
+ //
355
+ // Two modes:
356
+ // stdio (default) — local subprocess, host spawns via npx / config.
357
+ // Every existing Claude Desktop / Cursor / Claude Code
358
+ // install of this package uses stdio.
359
+ // http (--transport=http --http-port=8080)
360
+ // — remote MCP over Streamable HTTP. Bind to a port,
361
+ // expose /mcp, users add the URL to their host
362
+ // instead of running npx. This is what
363
+ // claude.ai / mobile / other zero-install hosts
364
+ // need. Stateless mode: each request creates a
365
+ // fresh transport + response, no session
366
+ // persistence, safe to scale horizontally.
367
+ const argv = process.argv.slice(2);
368
+ const arg = (name, def) => {
369
+ const i = argv.findIndex((a) => a === `--${name}` || a.startsWith(`--${name}=`));
370
+ if (i < 0)
371
+ return def;
372
+ const cur = argv[i];
373
+ return cur.includes("=") ? cur.split("=").slice(1).join("=") : argv[i + 1];
374
+ };
375
+ const transportKind = (arg("transport", process.env.MCP_TRANSPORT ?? "stdio") ?? "stdio").toLowerCase();
376
+ if (transportKind === "stdio") {
377
+ await server.connect(new StdioServerTransport());
378
+ }
379
+ else if (transportKind === "http" || transportKind === "streamable-http") {
380
+ const port = Number(arg("http-port", process.env.MCP_HTTP_PORT ?? "8080"));
381
+ const host = arg("http-host", process.env.MCP_HTTP_HOST ?? "127.0.0.1") ?? "127.0.0.1";
382
+ const path = arg("http-path", process.env.MCP_HTTP_PATH ?? "/mcp") ?? "/mcp";
383
+ // Read a JSON body off req into memory. Bodies are tiny (JSON-RPC), so
384
+ // no streaming needed; guard against absurd payloads with a hard cap.
385
+ const MAX_BODY = 1_048_576; // 1 MB
386
+ const readBody = (req) => new Promise((resolve, reject) => {
387
+ const chunks = [];
388
+ let total = 0;
389
+ req.on("data", (c) => {
390
+ total += c.length;
391
+ if (total > MAX_BODY) {
392
+ req.destroy();
393
+ reject(new Error("body too large"));
394
+ return;
395
+ }
396
+ chunks.push(c);
397
+ });
398
+ req.on("end", () => {
399
+ try {
400
+ const text = Buffer.concat(chunks).toString("utf8");
401
+ resolve(text.length === 0 ? undefined : JSON.parse(text));
402
+ }
403
+ catch (e) {
404
+ reject(e);
405
+ }
406
+ });
407
+ req.on("error", reject);
408
+ });
409
+ const httpServer = createHttpServer(async (req, res) => {
410
+ // Basic CORS so browser-based MCP hosts can call us cross-origin.
411
+ res.setHeader("Access-Control-Allow-Origin", "*");
412
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
413
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Mcp-Session-Id, Last-Event-ID");
414
+ res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
415
+ if (req.method === "OPTIONS") {
416
+ res.statusCode = 204;
417
+ res.end();
418
+ return;
419
+ }
420
+ // Liveness — cheap health check for load balancers / uptime monitors.
421
+ if (req.method === "GET" && (req.url === "/healthz" || req.url === "/health")) {
422
+ res.statusCode = 200;
423
+ res.setHeader("Content-Type", "text/plain");
424
+ res.end("ok\n");
425
+ return;
426
+ }
427
+ // Everything else must hit the MCP path.
428
+ const urlPath = (req.url ?? "").split("?")[0];
429
+ if (urlPath !== path) {
430
+ res.statusCode = 404;
431
+ res.end();
432
+ return;
433
+ }
434
+ try {
435
+ // Stateless: one transport per request, no session store. Simpler,
436
+ // scales trivially, matches how claude.ai / ChatGPT connectors call.
437
+ const transport = new StreamableHTTPServerTransport({
438
+ sessionIdGenerator: undefined,
439
+ enableJsonResponse: true,
440
+ });
441
+ res.on("close", () => transport.close());
442
+ await server.connect(transport);
443
+ const body = req.method === "POST" ? await readBody(req) : undefined;
444
+ await transport.handleRequest(req, res, body);
445
+ }
446
+ catch (err) {
447
+ if (!res.headersSent) {
448
+ res.statusCode = 500;
449
+ res.setHeader("Content-Type", "application/json");
450
+ res.end(JSON.stringify({
451
+ jsonrpc: "2.0",
452
+ error: {
453
+ code: -32603,
454
+ message: err instanceof Error ? err.message : String(err),
455
+ },
456
+ id: null,
457
+ }));
458
+ }
459
+ }
460
+ });
461
+ httpServer.listen(port, host, () => {
462
+ console.error(`chessceo-mcp: streamable-http on http://${host}:${port}${path}`);
463
+ });
464
+ // Graceful shutdown so `systemctl stop` doesn't leak connections.
465
+ const shutdown = (sig) => {
466
+ console.error(`chessceo-mcp: ${sig} received, closing`);
467
+ httpServer.close(() => process.exit(0));
468
+ setTimeout(() => process.exit(1), 5000).unref();
469
+ };
470
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
471
+ process.on("SIGINT", () => shutdown("SIGINT"));
472
+ }
473
+ else {
474
+ console.error(`chessceo-mcp: unknown --transport '${transportKind}' (expected stdio or http)`);
475
+ process.exit(2);
476
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chessceo/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.3.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": {