@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.
- package/README.md +122 -0
- package/dist/index.js +253 -5
- 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 {
|
|
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
|
-
|
|
228
|
-
|
|
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