llm-chess-mcp 0.3.0 → 0.4.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 +43 -16
- package/dist/cli.js +107 -0
- package/dist/engines/stockfish.js +7 -3
- package/dist/eval.js +2 -1
- package/dist/http.js +157 -0
- package/dist/index.js +44 -8
- package/dist/intents.js +12 -7
- package/dist/tools/analysis.js +5 -2
- package/docs/architecture.md +125 -0
- package/package.json +9 -2
package/README.md
CHANGED
|
@@ -54,8 +54,7 @@ the MCP transport tests. `pnpm check` runs the full local gate; use
|
|
|
54
54
|
|
|
55
55
|
### Maintainers
|
|
56
56
|
|
|
57
|
-
[Architecture](docs/architecture.md) describes runtime and service boundaries
|
|
58
|
-
[the changelog](CHANGELOG.md) records client-visible changes.
|
|
57
|
+
[Architecture](docs/architecture.md) describes runtime and service boundaries.
|
|
59
58
|
|
|
60
59
|
Local quality commands:
|
|
61
60
|
|
|
@@ -64,8 +63,41 @@ pnpm typecheck
|
|
|
64
63
|
pnpm test:coverage
|
|
65
64
|
pnpm contract:check
|
|
66
65
|
pnpm check
|
|
66
|
+
pnpm test:package
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
+
`pnpm test:stress` runs the short real-engine concurrency check.
|
|
70
|
+
`pnpm test:live` queries Lichess only when `LICHESS_TOKEN` is set; otherwise it
|
|
71
|
+
skips without making a network request.
|
|
72
|
+
|
|
73
|
+
## Transports
|
|
74
|
+
|
|
75
|
+
stdio remains the default transport and requires no flags. To expose a local
|
|
76
|
+
Streamable HTTP endpoint instead:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pnpm build
|
|
80
|
+
node dist/index.js --transport http
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The server listens on `http://127.0.0.1:3000/mcp` and supports Streamable HTTP
|
|
84
|
+
sessions, JSON responses, and SSE. The equivalent development command is
|
|
85
|
+
`pnpm dev:http`.
|
|
86
|
+
|
|
87
|
+
HTTP options:
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
--host <host> Bind host (default: 127.0.0.1)
|
|
91
|
+
--port <port> Listen port (default: 3000)
|
|
92
|
+
--path <path> Endpoint path (default: /mcp)
|
|
93
|
+
--allowed-host <host> Allowed Host/Origin hostname; repeat as needed
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Binding to `0.0.0.0` or `::` requires at least one `--allowed-host`. HTTP mode
|
|
97
|
+
does not provide authentication or TLS; use a trusted network or an
|
|
98
|
+
authenticated reverse proxy when exposing it beyond localhost. Origin values
|
|
99
|
+
are validated when present, but the server does not emit browser CORS headers.
|
|
100
|
+
|
|
69
101
|
### Export Maia3 to ONNX (build-time only)
|
|
70
102
|
|
|
71
103
|
This step needs Python + PyTorch once. It downloads the Maia3 checkpoint, verifies
|
|
@@ -180,7 +212,7 @@ codex mcp add llm-chess-mcp --command npx --args -y llm-chess-mcp --env LICHESS_
|
|
|
180
212
|
| `move_candidates_by_intent` | Convenience layer: candidates ranked for a strategic intent |
|
|
181
213
|
| `opening_explorer` | Lichess human game statistics |
|
|
182
214
|
|
|
183
|
-
## Result format
|
|
215
|
+
## Result format
|
|
184
216
|
|
|
185
217
|
`structuredContent` is the canonical successful result. Handler-level failures
|
|
186
218
|
set `isError` and provide `structuredContent.error`. Input-schema failures are
|
|
@@ -188,12 +220,6 @@ generated by the MCP SDK before the handler and use its standard `isError` text
|
|
|
188
220
|
result without `structuredContent`. Otherwise, `content` is only a short
|
|
189
221
|
human-readable summary and must not be parsed as data.
|
|
190
222
|
|
|
191
|
-
Clients upgrading from 0.1.x should stop parsing `content` and consume
|
|
192
|
-
`structuredContent` instead. Check `isError` and the structured error code when
|
|
193
|
-
a tool fails. `move_evaluate` now always returns
|
|
194
|
-
`{ game_id, revision, results }`; its former single-move top-level duplicates
|
|
195
|
-
are removed.
|
|
196
|
-
|
|
197
223
|
## Score conventions
|
|
198
224
|
|
|
199
225
|
- Stockfish scores are **side-to-move perspective**: positive cp = side to move is
|
|
@@ -318,15 +344,16 @@ It checks top-1/top-k move agreement and max probability error to detect
|
|
|
318
344
|
export/runtime regressions. The bundled `maia3-5m.onnx` passes with 100% top-1
|
|
319
345
|
and top-5 agreement and max probability error < 1e-4.
|
|
320
346
|
|
|
321
|
-
##
|
|
347
|
+
## Package verification
|
|
322
348
|
|
|
323
|
-
|
|
324
|
-
|
|
349
|
+
Package artifacts are verified locally; this project intentionally has no
|
|
350
|
+
hosted CI workflow.
|
|
325
351
|
|
|
326
|
-
|
|
327
|
-
install
|
|
328
|
-
|
|
329
|
-
|
|
352
|
+
Run `pnpm check` for the deterministic offline gate. Use `pnpm test:package` to
|
|
353
|
+
pack the project, install the tarball in a clean temporary directory, and run
|
|
354
|
+
the installed `llm-chess-mcp` binary against the real Stockfish and Maia
|
|
355
|
+
runtimes. `pnpm release:check` runs both checks plus the production dependency
|
|
356
|
+
audit and package manifest dry run.
|
|
330
357
|
|
|
331
358
|
## License & attribution
|
|
332
359
|
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export const HELP = `Usage: llm-chess-mcp [options]
|
|
2
|
+
|
|
3
|
+
Options:
|
|
4
|
+
--transport <stdio|http> Transport to use (default: stdio)
|
|
5
|
+
--http Shortcut for --transport http
|
|
6
|
+
--host <host> HTTP bind host (default: 127.0.0.1)
|
|
7
|
+
--port <port> HTTP listen port (default: 3000)
|
|
8
|
+
--path <path> HTTP endpoint path (default: /mcp)
|
|
9
|
+
--allowed-host <host> Allowed HTTP Host/Origin hostname (repeatable)
|
|
10
|
+
-h, --help Show this help
|
|
11
|
+
`;
|
|
12
|
+
function optionValue(args, index, option) {
|
|
13
|
+
const value = args[index + 1];
|
|
14
|
+
if (value === undefined || value.startsWith("--")) {
|
|
15
|
+
throw new Error(`${option} requires a value`);
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function splitOption(arg) {
|
|
20
|
+
const index = arg.indexOf("=");
|
|
21
|
+
return index === -1 ? null : [arg.slice(0, index), arg.slice(index + 1)];
|
|
22
|
+
}
|
|
23
|
+
export function parseCli(args) {
|
|
24
|
+
let transport = "stdio";
|
|
25
|
+
let host = "127.0.0.1";
|
|
26
|
+
let port = 3_000;
|
|
27
|
+
let path = "/mcp";
|
|
28
|
+
let help = false;
|
|
29
|
+
let hasHttpOption = false;
|
|
30
|
+
const allowedHosts = [];
|
|
31
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
32
|
+
const arg = args[index];
|
|
33
|
+
if (arg === undefined)
|
|
34
|
+
continue;
|
|
35
|
+
const pair = splitOption(arg);
|
|
36
|
+
const option = pair?.[0] ?? arg;
|
|
37
|
+
const inlineValue = pair?.[1];
|
|
38
|
+
const value = () => {
|
|
39
|
+
if (inlineValue !== undefined)
|
|
40
|
+
return inlineValue;
|
|
41
|
+
const next = optionValue(args, index, option);
|
|
42
|
+
index += 1;
|
|
43
|
+
return next;
|
|
44
|
+
};
|
|
45
|
+
switch (option) {
|
|
46
|
+
case "-h":
|
|
47
|
+
case "--help":
|
|
48
|
+
if (inlineValue !== undefined)
|
|
49
|
+
throw new Error(`${option} takes no value`);
|
|
50
|
+
help = true;
|
|
51
|
+
break;
|
|
52
|
+
case "--http":
|
|
53
|
+
if (inlineValue !== undefined)
|
|
54
|
+
throw new Error("--http takes no value");
|
|
55
|
+
transport = "http";
|
|
56
|
+
break;
|
|
57
|
+
case "--transport": {
|
|
58
|
+
const selected = value();
|
|
59
|
+
if (selected !== "stdio" && selected !== "http") {
|
|
60
|
+
throw new Error("--transport must be stdio or http");
|
|
61
|
+
}
|
|
62
|
+
transport = selected;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
case "--host":
|
|
66
|
+
host = value();
|
|
67
|
+
hasHttpOption = true;
|
|
68
|
+
break;
|
|
69
|
+
case "--port": {
|
|
70
|
+
const selected = value();
|
|
71
|
+
if (!/^\d+$/.test(selected))
|
|
72
|
+
throw new Error("--port must be an integer");
|
|
73
|
+
port = Number(selected);
|
|
74
|
+
hasHttpOption = true;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
case "--path":
|
|
78
|
+
path = value();
|
|
79
|
+
hasHttpOption = true;
|
|
80
|
+
break;
|
|
81
|
+
case "--allowed-host":
|
|
82
|
+
allowedHosts.push(value());
|
|
83
|
+
hasHttpOption = true;
|
|
84
|
+
break;
|
|
85
|
+
default:
|
|
86
|
+
throw new Error(`unknown option: ${option}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
90
|
+
throw new Error("--port must be between 1 and 65535");
|
|
91
|
+
}
|
|
92
|
+
if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
|
|
93
|
+
throw new Error("--path must be an absolute URL path without query or fragment");
|
|
94
|
+
}
|
|
95
|
+
if (!host || allowedHosts.some((value) => !value)) {
|
|
96
|
+
throw new Error("HTTP hostnames must not be empty");
|
|
97
|
+
}
|
|
98
|
+
if (transport === "stdio" && hasHttpOption) {
|
|
99
|
+
throw new Error("HTTP options require --transport http");
|
|
100
|
+
}
|
|
101
|
+
if (transport === "http" &&
|
|
102
|
+
(host === "0.0.0.0" || host === "::" || host === "[::]") &&
|
|
103
|
+
allowedHosts.length === 0) {
|
|
104
|
+
throw new Error("wildcard HTTP binding requires at least one --allowed-host");
|
|
105
|
+
}
|
|
106
|
+
return { transport, host, port, path, allowedHosts, help };
|
|
107
|
+
}
|
|
@@ -252,15 +252,19 @@ export class Stockfish {
|
|
|
252
252
|
const scoreToken = line.match(/ score (?<value>cp -?\d+|mate -?\d+)/)?.groups?.value;
|
|
253
253
|
const pv = line.match(/ pv (?<value>.+)$/)?.groups?.value;
|
|
254
254
|
const n = Number(multipv);
|
|
255
|
+
const previous = byPv.get(n);
|
|
255
256
|
const score = scoreToken
|
|
256
257
|
? parseScore(scoreToken)
|
|
257
|
-
: {
|
|
258
|
+
: {
|
|
259
|
+
cp: previous?.scoreCp ?? null,
|
|
260
|
+
mate: previous?.scoreMate ?? null,
|
|
261
|
+
};
|
|
258
262
|
byPv.set(n, {
|
|
259
263
|
multipv: n,
|
|
260
264
|
scoreCp: score.cp,
|
|
261
265
|
scoreMate: score.mate,
|
|
262
|
-
wdl: parseWdl(line),
|
|
263
|
-
pv: pv ? pv.split(" ") : [],
|
|
266
|
+
wdl: parseWdl(line) ?? previous?.wdl ?? null,
|
|
267
|
+
pv: pv ? pv.split(" ") : (previous?.pv ?? []),
|
|
264
268
|
});
|
|
265
269
|
}
|
|
266
270
|
else if (line.startsWith("bestmove")) {
|
package/dist/eval.js
CHANGED
|
@@ -11,7 +11,8 @@ export function evalToCp(e) {
|
|
|
11
11
|
if (e.type === "cp")
|
|
12
12
|
return e.value;
|
|
13
13
|
const sign = e.plies >= 0 ? 1 : -1;
|
|
14
|
-
|
|
14
|
+
const magnitude = Math.max(9_000, 10_000 - Math.abs(e.plies) * 100);
|
|
15
|
+
return sign * magnitude;
|
|
15
16
|
}
|
|
16
17
|
export function negateEval(e) {
|
|
17
18
|
if (e.type === "cp")
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { hostHeaderValidation, NodeStreamableHTTPServerTransport, originValidation, } from "@modelcontextprotocol/node";
|
|
4
|
+
import { buildServer } from "./server.js";
|
|
5
|
+
import { defaultAppServices } from "./services.js";
|
|
6
|
+
const LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
7
|
+
function isLocalHost(host) {
|
|
8
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
|
9
|
+
}
|
|
10
|
+
function jsonError(res, status, message) {
|
|
11
|
+
if (res.headersSent) {
|
|
12
|
+
res.destroy(new Error(message));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
16
|
+
res.end(JSON.stringify({
|
|
17
|
+
jsonrpc: "2.0",
|
|
18
|
+
error: { code: -32_000, message },
|
|
19
|
+
id: null,
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
function requestPath(req) {
|
|
23
|
+
try {
|
|
24
|
+
return new URL(req.url ?? "/", "http://localhost").pathname;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function sessionId(req) {
|
|
31
|
+
const value = req.headers["mcp-session-id"];
|
|
32
|
+
if (value === undefined)
|
|
33
|
+
return undefined;
|
|
34
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
35
|
+
}
|
|
36
|
+
function closeNodeServer(server) {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export async function serveHttp(options = {}, services = defaultAppServices) {
|
|
42
|
+
const host = options.host ?? "127.0.0.1";
|
|
43
|
+
const requestedPort = options.port ?? 3_000;
|
|
44
|
+
const path = options.path ?? "/mcp";
|
|
45
|
+
const wildcard = host === "0.0.0.0" || host === "::" || host === "[::]";
|
|
46
|
+
if (wildcard && options.allowedHosts === undefined) {
|
|
47
|
+
throw new Error("wildcard HTTP binding requires allowed hostnames");
|
|
48
|
+
}
|
|
49
|
+
const allowedHosts = [
|
|
50
|
+
...(options.allowedHosts ?? (isLocalHost(host) ? LOCAL_HOSTS : [host])),
|
|
51
|
+
];
|
|
52
|
+
if (!host || !Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65_535) {
|
|
53
|
+
throw new Error("invalid HTTP listen address");
|
|
54
|
+
}
|
|
55
|
+
if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
|
|
56
|
+
throw new Error("invalid HTTP endpoint path");
|
|
57
|
+
}
|
|
58
|
+
if (allowedHosts.length === 0 || allowedHosts.some((value) => !value)) {
|
|
59
|
+
throw new Error("at least one allowed HTTP hostname is required");
|
|
60
|
+
}
|
|
61
|
+
const sessions = new Map();
|
|
62
|
+
const validateHost = hostHeaderValidation(allowedHosts);
|
|
63
|
+
const validateOrigin = originValidation(allowedHosts);
|
|
64
|
+
let closing = false;
|
|
65
|
+
const closeSession = async (id, session) => {
|
|
66
|
+
if (sessions.get(id) !== session)
|
|
67
|
+
return;
|
|
68
|
+
sessions.delete(id);
|
|
69
|
+
await session.server.close();
|
|
70
|
+
};
|
|
71
|
+
const handle = async (req, res) => {
|
|
72
|
+
if (closing) {
|
|
73
|
+
jsonError(res, 503, "server is shutting down");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (requestPath(req) !== path) {
|
|
77
|
+
jsonError(res, 404, "MCP endpoint not found");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (!validateHost(req, res) || !validateOrigin(req, res))
|
|
81
|
+
return;
|
|
82
|
+
const id = sessionId(req);
|
|
83
|
+
if (id === null) {
|
|
84
|
+
jsonError(res, 400, "invalid MCP session ID");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (id !== undefined) {
|
|
88
|
+
const session = sessions.get(id);
|
|
89
|
+
if (!session) {
|
|
90
|
+
jsonError(res, 404, "MCP session not found");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
await session.transport.handleRequest(req, res);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
let initializedId;
|
|
97
|
+
let session;
|
|
98
|
+
const transport = new NodeStreamableHTTPServerTransport({
|
|
99
|
+
sessionIdGenerator: randomUUID,
|
|
100
|
+
onsessioninitialized: (newId) => {
|
|
101
|
+
initializedId = newId;
|
|
102
|
+
sessions.set(newId, session);
|
|
103
|
+
},
|
|
104
|
+
onsessionclosed: (closedId) => {
|
|
105
|
+
const current = sessions.get(closedId);
|
|
106
|
+
if (current)
|
|
107
|
+
void closeSession(closedId, current);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
const mcp = buildServer(services);
|
|
111
|
+
session = { server: mcp, transport };
|
|
112
|
+
transport.onclose = () => {
|
|
113
|
+
if (initializedId && sessions.get(initializedId) === session) {
|
|
114
|
+
sessions.delete(initializedId);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
try {
|
|
118
|
+
await mcp.connect(transport);
|
|
119
|
+
await transport.handleRequest(req, res);
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
if (!initializedId)
|
|
123
|
+
await mcp.close();
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const server = createServer((req, res) => {
|
|
127
|
+
void handle(req, res).catch((error) => {
|
|
128
|
+
console.error("HTTP request failed", error);
|
|
129
|
+
jsonError(res, 500, "internal server error");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
await new Promise((resolve, reject) => {
|
|
133
|
+
const onError = (error) => reject(error);
|
|
134
|
+
server.once("error", onError);
|
|
135
|
+
server.listen(requestedPort, host, () => {
|
|
136
|
+
server.off("error", onError);
|
|
137
|
+
resolve();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
server.on("error", (error) => console.error("HTTP server failed", error));
|
|
141
|
+
const address = server.address();
|
|
142
|
+
const port = address.port;
|
|
143
|
+
const displayHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
144
|
+
let shutdown;
|
|
145
|
+
return {
|
|
146
|
+
host,
|
|
147
|
+
port,
|
|
148
|
+
path,
|
|
149
|
+
url: `http://${displayHost}:${port}${path}`,
|
|
150
|
+
sessionCount: () => sessions.size,
|
|
151
|
+
close: () => (shutdown ??= (async () => {
|
|
152
|
+
closing = true;
|
|
153
|
+
await Promise.allSettled([...sessions.entries()].map(([id, session]) => closeSession(id, session)));
|
|
154
|
+
await closeNodeServer(server);
|
|
155
|
+
})()),
|
|
156
|
+
};
|
|
157
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -2,20 +2,56 @@
|
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
5
|
+
import { HELP, parseCli } from "./cli.js";
|
|
5
6
|
import { loadEnv } from "./env.js";
|
|
7
|
+
import { serveHttp } from "./http.js";
|
|
6
8
|
import { buildServer } from "./server.js";
|
|
7
9
|
import { defaultAppServices } from "./services.js";
|
|
8
10
|
export { buildServer } from "./server.js";
|
|
11
|
+
export { serveHttp } from "./http.js";
|
|
9
12
|
export { drawResult, MAX_EVALUATED_MOVES, MAX_PGN_BYTES, MAX_PGN_PLIES, parseImportedPgn, snapshotChess, } from "./chess.js";
|
|
10
13
|
loadEnv();
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
async function main() {
|
|
15
|
+
const options = parseCli(process.argv.slice(2));
|
|
16
|
+
if (options.help) {
|
|
17
|
+
process.stdout.write(HELP);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (options.transport === "stdio") {
|
|
21
|
+
const handle = serveStdio(() => buildServer());
|
|
22
|
+
let shutdown;
|
|
23
|
+
const close = () => (shutdown ??= Promise.all([defaultAppServices.quit(), handle.close()]).then(() => undefined));
|
|
24
|
+
const onClose = () => {
|
|
25
|
+
void close().catch((error) => console.error("shutdown failed", error));
|
|
26
|
+
};
|
|
27
|
+
process.stdin.once("end", onClose);
|
|
28
|
+
process.stdin.once("close", onClose);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const handle = await serveHttp({
|
|
32
|
+
host: options.host,
|
|
33
|
+
port: options.port,
|
|
34
|
+
path: options.path,
|
|
35
|
+
...(options.allowedHosts.length ? { allowedHosts: options.allowedHosts } : {}),
|
|
36
|
+
});
|
|
37
|
+
console.error(`llm-chess-mcp listening on ${handle.url}`);
|
|
14
38
|
let shutdown;
|
|
15
|
-
const close = () => (shutdown ??=
|
|
16
|
-
const
|
|
17
|
-
void close()
|
|
39
|
+
const close = () => (shutdown ??= handle.close().finally(() => defaultAppServices.quit()));
|
|
40
|
+
const onSignal = () => {
|
|
41
|
+
void close()
|
|
42
|
+
.then(() => process.exit(0))
|
|
43
|
+
.catch((error) => {
|
|
44
|
+
console.error("shutdown failed", error);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
});
|
|
18
47
|
};
|
|
19
|
-
process.
|
|
20
|
-
process.
|
|
48
|
+
process.once("SIGINT", onSignal);
|
|
49
|
+
process.once("SIGTERM", onSignal);
|
|
50
|
+
}
|
|
51
|
+
const entry = process.argv[1];
|
|
52
|
+
if (entry && import.meta.url === pathToFileURL(realpathSync(entry)).href) {
|
|
53
|
+
void main().catch((error) => {
|
|
54
|
+
console.error(error instanceof Error ? error.message : error);
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
});
|
|
21
57
|
}
|
package/dist/intents.js
CHANGED
|
@@ -50,16 +50,18 @@ export function explorerCandidateData(result) {
|
|
|
50
50
|
export function candidateSetFromData(chess, elo, sfLines, maiaMoves, lichessResult) {
|
|
51
51
|
const turn = chess.turn();
|
|
52
52
|
const maiaByUci = new Map(maiaMoves.map((move) => [move.uci, move.prob]));
|
|
53
|
+
const legalUcis = new Set(chess.moves({ verbose: true }).map((move) => move.lan));
|
|
53
54
|
const sfByUci = new Map();
|
|
54
55
|
for (const line of sfLines) {
|
|
55
56
|
const uci = line.pv[0];
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
const evaluation = toEval(line);
|
|
58
|
+
if (uci !== undefined && legalUcis.has(uci) && evaluation !== null) {
|
|
59
|
+
sfByUci.set(uci, { line, evaluation });
|
|
60
|
+
}
|
|
58
61
|
}
|
|
59
62
|
const lichessByUci = new Map(lichessResult.moves.map((move) => [move.uci, move]));
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
.filter((value) => value !== null);
|
|
63
|
+
const normalizedSfLines = [...sfByUci.values()];
|
|
64
|
+
const evals = normalizedSfLines.map(({ evaluation }) => evaluation);
|
|
63
65
|
const bestCp = evals.length
|
|
64
66
|
? Math.max(...evals.map((value) => evalToCp(value)))
|
|
65
67
|
: null;
|
|
@@ -71,7 +73,7 @@ export function candidateSetFromData(chess, elo, sfLines, maiaMoves, lichessResu
|
|
|
71
73
|
]);
|
|
72
74
|
const candidates = [];
|
|
73
75
|
for (const uci of ucis) {
|
|
74
|
-
const sf = sfByUci.get(uci);
|
|
76
|
+
const sf = sfByUci.get(uci)?.line;
|
|
75
77
|
const lichess = lichessByUci.get(uci);
|
|
76
78
|
let opening;
|
|
77
79
|
if (lichess) {
|
|
@@ -115,7 +117,10 @@ export function candidateSetFromData(chess, elo, sfLines, maiaMoves, lichessResu
|
|
|
115
117
|
opening,
|
|
116
118
|
});
|
|
117
119
|
}
|
|
118
|
-
return {
|
|
120
|
+
return {
|
|
121
|
+
candidates,
|
|
122
|
+
moveSensitivity: computeMoveSensitivity(normalizedSfLines.map(({ line }) => line)),
|
|
123
|
+
};
|
|
119
124
|
}
|
|
120
125
|
export async function computeCandidates(chess, elo, sfDepth, sfMultipv, maiaTopN, lichess) {
|
|
121
126
|
const [sfLines, maiaMoves, lichessResult] = await Promise.all([
|
package/dist/tools/analysis.js
CHANGED
|
@@ -83,6 +83,7 @@ export function registerAnalysisTools(server, services) {
|
|
|
83
83
|
}
|
|
84
84
|
const result = drawResult(copy);
|
|
85
85
|
if (result) {
|
|
86
|
+
const cpLoss = beforeCp;
|
|
86
87
|
results.push({
|
|
87
88
|
move: parsed.san,
|
|
88
89
|
uci: parsed.lan,
|
|
@@ -90,8 +91,10 @@ export function registerAnalysisTools(server, services) {
|
|
|
90
91
|
scoreCp: 0,
|
|
91
92
|
scoreMate: null,
|
|
92
93
|
bestCp: beforeCp,
|
|
93
|
-
cpLoss
|
|
94
|
-
classification: null
|
|
94
|
+
cpLoss,
|
|
95
|
+
classification: cpLoss !== null
|
|
96
|
+
? classifyCpLoss(cpLoss)
|
|
97
|
+
: null,
|
|
95
98
|
pv: [],
|
|
96
99
|
});
|
|
97
100
|
continue;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
`llm-chess-mcp` is a stateful MCP server over stdio or Streamable HTTP. It owns
|
|
4
|
+
chess-game state and exposes deterministic tool contracts; Stockfish, Maia3,
|
|
5
|
+
and Lichess add independent signals without changing a game unless
|
|
6
|
+
`game_play_move` succeeds.
|
|
7
|
+
|
|
8
|
+
## Runtime boundary
|
|
9
|
+
|
|
10
|
+
`src/index.ts` is the executable boundary. It loads environment configuration,
|
|
11
|
+
parses transport options, and creates servers through `buildServer`. stdio is
|
|
12
|
+
the default; HTTP mode binds an explicit endpoint and creates one MCP server per
|
|
13
|
+
Streamable HTTP session. All sessions share application services and game state.
|
|
14
|
+
Stdout is reserved for protocol traffic; diagnostics belong on stderr. Shutdown
|
|
15
|
+
closes active transports before terminating Stockfish.
|
|
16
|
+
|
|
17
|
+
The server is assembled from injected `AppServices`, not from tool-level global
|
|
18
|
+
lookups. Production constructs one service set for the process; tests pass
|
|
19
|
+
small fakes or controlled implementations. This keeps transport registration
|
|
20
|
+
separate from engine startup, network I/O, time, and storage.
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
stdio --------> entrypoint -> buildServer(AppServices) -> tool modules
|
|
24
|
+
Streamable HTTP --^ |-> GameStore
|
|
25
|
+
|-> Stockfish service
|
|
26
|
+
|-> Maia service
|
|
27
|
+
`-> Lichess explorer
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The tool modules have narrow ownership:
|
|
31
|
+
|
|
32
|
+
| Module | Owns |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `game` | session creation/deletion, state, legal moves, PGN, and the only game mutation |
|
|
35
|
+
| `analysis` | Stockfish analysis, Maia distributions, and per-move evaluation |
|
|
36
|
+
| `candidates` | joins objective, human, and opening facets; intent ranking |
|
|
37
|
+
| `explorer` | Lichess input validation, requests, retry policy, and response validation |
|
|
38
|
+
|
|
39
|
+
Tool modules validate inputs, take a game snapshot where needed, call services,
|
|
40
|
+
and adapt data to output schemas. They do not reach into another module's
|
|
41
|
+
storage or manage an engine session directly.
|
|
42
|
+
|
|
43
|
+
## App services and game lifecycle
|
|
44
|
+
|
|
45
|
+
`AppServices` carries the application dependencies: a `GameStore`, Stockfish,
|
|
46
|
+
Maia inference, candidate computation, and the Lichess explorer. Dependencies
|
|
47
|
+
are interfaces at this boundary so tests can inject controlled services without
|
|
48
|
+
patching process globals. Clock and ID generation are injected into `GameStore`;
|
|
49
|
+
fetch, timeout, and sleep are injected at the explorer boundary.
|
|
50
|
+
|
|
51
|
+
`GameStore` owns `Chess` instances and their metadata:
|
|
52
|
+
|
|
53
|
+
1. Creating a game assigns an opaque ID and revision `0`; importing a PGN also
|
|
54
|
+
creates a new game at revision `0`.
|
|
55
|
+
2. Reads refresh `lastAccessedAt`. Idle games expire after one hour, cleanup
|
|
56
|
+
runs before store operations, and the store rejects creation once its
|
|
57
|
+
1,000-session limit is reached.
|
|
58
|
+
3. `game_play_move` compares `expected_revision` with the current revision,
|
|
59
|
+
makes a legal move only on equality, then increments the revision.
|
|
60
|
+
4. Deleting a game removes its session. Expired and deleted IDs are no longer
|
|
61
|
+
valid.
|
|
62
|
+
|
|
63
|
+
All asynchronous readers clone the position first. The snapshot is rebuilt
|
|
64
|
+
from the initial position and move history, preserving history-dependent chess
|
|
65
|
+
rules such as threefold repetition. A long analysis therefore observes one FEN
|
|
66
|
+
and one revision even if a later request changes the live game.
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
read game -> snapshot + revision R -> async analysis -> result tagged R
|
|
70
|
+
\
|
|
71
|
+
play(expected_revision: R) -> mutate live game -> revision R + 1
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
An analysis result is informational, not a lock. A caller must use the revision
|
|
75
|
+
it read when submitting `game_play_move`; a stale write returns
|
|
76
|
+
`STALE_POSITION` rather than applying a move to a different position.
|
|
77
|
+
|
|
78
|
+
## Compute and network services
|
|
79
|
+
|
|
80
|
+
Stockfish is a single worker-backed engine, so its service serializes analysis
|
|
81
|
+
requests through a bounded queue (32 active or waiting requests). It lazily
|
|
82
|
+
initializes the configured packaged flavor, performs the UCI/ready handshake,
|
|
83
|
+
and gives each request an analysis timeout plus a stop grace period. Init,
|
|
84
|
+
handshake, or analysis failure invalidates and terminates the worker; a queued
|
|
85
|
+
later request initializes a fresh worker. Queue capacity fails fast, and
|
|
86
|
+
shutdown invalidates work from the old generation.
|
|
87
|
+
|
|
88
|
+
Maia runs in-process with the bundled ONNX model (5M by default). Its inference
|
|
89
|
+
session is lazy and shared after successful creation. For each snapshot it
|
|
90
|
+
tokenizes position history, supplies both Elo inputs, masks logits to legal
|
|
91
|
+
moves, mirrors black-to-move moves for the model vocabulary, and normalizes the
|
|
92
|
+
remaining logits. The output is human move likelihood, never an evaluation.
|
|
93
|
+
|
|
94
|
+
Lichess is optional and token-gated. The explorer validates speed/rating filters
|
|
95
|
+
locally and forbids filters for `masters`. Each request has a five-second
|
|
96
|
+
attempt timeout, at most two attempts, and a twelve-second overall budget.
|
|
97
|
+
Only timeouts, network failures, HTTP 429, and 5xx responses retry. `Retry-After`
|
|
98
|
+
is honored only when it fits the remaining budget and does not exceed two
|
|
99
|
+
seconds; authentication errors, other 4xx responses, invalid input, and
|
|
100
|
+
malformed responses fail without retry. Successful payloads are checked against
|
|
101
|
+
the legal moves of the snapshot before they can affect a candidate result.
|
|
102
|
+
|
|
103
|
+
## Result and contract rules
|
|
104
|
+
|
|
105
|
+
Every registered tool declares an MCP output schema. On success its payload is
|
|
106
|
+
the canonical `structuredContent`; `content` is one short display summary and
|
|
107
|
+
is deliberately not a JSON data channel. Handler failures set `isError: true`
|
|
108
|
+
and return `{ error: { code, message } }` in `structuredContent`. SDK
|
|
109
|
+
input-schema failures happen before the handler and retain the SDK's standard
|
|
110
|
+
text-only error result.
|
|
111
|
+
|
|
112
|
+
Contract snapshots capture the externally visible tool list, descriptions,
|
|
113
|
+
annotations, and input/output schemas. Update them only as part of an
|
|
114
|
+
intentional contract change:
|
|
115
|
+
|
|
116
|
+
1. Change the relevant input/output schema and tool adapter together.
|
|
117
|
+
2. Update focused unit and stdio tests, then run `pnpm contract:update` to
|
|
118
|
+
regenerate the snapshot.
|
|
119
|
+
3. Review the snapshot diff as an API diff: names, required fields, enum values,
|
|
120
|
+
nullability, and error codes are compatibility surface.
|
|
121
|
+
4. Run `pnpm contract:check` and the full local gate before merging.
|
|
122
|
+
|
|
123
|
+
Do not regenerate a snapshot merely to make a failing check pass. If a change
|
|
124
|
+
is not intended to alter the public MCP contract, its snapshot must remain
|
|
125
|
+
unchanged.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "llm-chess-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "MCP server that lets an LLM analyze, judge, and choose chess moves (Stockfish + Maia3 + Lichess)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "AGPL-3.0",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"files": [
|
|
15
15
|
"dist",
|
|
16
16
|
"models",
|
|
17
|
+
"docs",
|
|
17
18
|
"LICENSE",
|
|
18
19
|
"README.md",
|
|
19
20
|
".env.example"
|
|
@@ -25,18 +26,24 @@
|
|
|
25
26
|
"contract:update": "pnpm build && node scripts/tool-contract.mjs --write",
|
|
26
27
|
"contract:check": "pnpm build && node scripts/tool-contract.mjs",
|
|
27
28
|
"dev": "tsx src/index.ts",
|
|
29
|
+
"dev:http": "tsx src/index.ts --transport http",
|
|
28
30
|
"start": "node dist/index.js",
|
|
31
|
+
"start:http": "node dist/index.js --transport http",
|
|
29
32
|
"test:unit": "tsx --test tests/*.test.ts",
|
|
30
33
|
"test:integration": "tsx --test tests/integration/*.test.ts",
|
|
31
34
|
"test:e2e": "pnpm build && tsx --test tests/e2e/*.test.ts",
|
|
35
|
+
"test:package": "node scripts/package-smoke.mjs",
|
|
36
|
+
"test:stress": "tsx --test tests/stress/*.test.ts",
|
|
37
|
+
"test:live": "tsx --test tests/live/*.test.ts",
|
|
32
38
|
"test:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-include='src/**/*.ts' --test-coverage-lines=85 --test-coverage-branches=80 --test-coverage-functions=80 tests/*.test.ts tests/integration/*.test.ts",
|
|
33
39
|
"test": "pnpm test:unit && pnpm test:integration && pnpm test:e2e",
|
|
34
40
|
"check": "pnpm typecheck && pnpm typecheck:test && pnpm test && pnpm test:coverage && pnpm contract:check",
|
|
35
|
-
"release:check": "pnpm check && pnpm audit --prod && npm pack --dry-run --ignore-scripts",
|
|
41
|
+
"release:check": "pnpm check && pnpm test:package && pnpm audit --prod && npm pack --dry-run --ignore-scripts",
|
|
36
42
|
"prepublishOnly": "pnpm check",
|
|
37
43
|
"export:maia3": "python scripts/export_maia3.py"
|
|
38
44
|
},
|
|
39
45
|
"dependencies": {
|
|
46
|
+
"@modelcontextprotocol/node": "^2.0.0",
|
|
40
47
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
41
48
|
"chess.js": "^1.4.0",
|
|
42
49
|
"onnxruntime-node": "^1.27.0",
|