llm-chess-mcp 0.3.1 → 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 +28 -0
- package/dist/cli.js +107 -0
- package/dist/http.js +157 -0
- package/dist/index.js +44 -8
- package/docs/architecture.md +14 -11
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -70,6 +70,34 @@ pnpm test:package
|
|
|
70
70
|
`pnpm test:live` queries Lichess only when `LICHESS_TOKEN` is set; otherwise it
|
|
71
71
|
skips without making a network request.
|
|
72
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
|
+
|
|
73
101
|
### Export Maia3 to ONNX (build-time only)
|
|
74
102
|
|
|
75
103
|
This step needs Python + PyTorch once. It downloads the Maia3 checkpoint, verifies
|
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
|
+
}
|
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/docs/architecture.md
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
# Architecture
|
|
2
2
|
|
|
3
|
-
`llm-chess-mcp` is a stateful MCP server over stdio. It owns
|
|
4
|
-
and exposes deterministic tool contracts; Stockfish, Maia3,
|
|
5
|
-
independent signals without changing a game unless
|
|
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.
|
|
6
7
|
|
|
7
8
|
## Runtime boundary
|
|
8
9
|
|
|
9
10
|
`src/index.ts` is the executable boundary. It loads environment configuration,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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.
|
|
13
16
|
|
|
14
17
|
The server is assembled from injected `AppServices`, not from tool-level global
|
|
15
18
|
lookups. Production constructs one service set for the process; tests pass
|
|
@@ -17,11 +20,11 @@ small fakes or controlled implementations. This keeps transport registration
|
|
|
17
20
|
separate from engine startup, network I/O, time, and storage.
|
|
18
21
|
|
|
19
22
|
```text
|
|
20
|
-
stdio
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
stdio --------> entrypoint -> buildServer(AppServices) -> tool modules
|
|
24
|
+
Streamable HTTP --^ |-> GameStore
|
|
25
|
+
|-> Stockfish service
|
|
26
|
+
|-> Maia service
|
|
27
|
+
`-> Lichess explorer
|
|
25
28
|
```
|
|
26
29
|
|
|
27
30
|
The tool modules have narrow ownership:
|
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",
|
|
@@ -26,7 +26,9 @@
|
|
|
26
26
|
"contract:update": "pnpm build && node scripts/tool-contract.mjs --write",
|
|
27
27
|
"contract:check": "pnpm build && node scripts/tool-contract.mjs",
|
|
28
28
|
"dev": "tsx src/index.ts",
|
|
29
|
+
"dev:http": "tsx src/index.ts --transport http",
|
|
29
30
|
"start": "node dist/index.js",
|
|
31
|
+
"start:http": "node dist/index.js --transport http",
|
|
30
32
|
"test:unit": "tsx --test tests/*.test.ts",
|
|
31
33
|
"test:integration": "tsx --test tests/integration/*.test.ts",
|
|
32
34
|
"test:e2e": "pnpm build && tsx --test tests/e2e/*.test.ts",
|
|
@@ -41,6 +43,7 @@
|
|
|
41
43
|
"export:maia3": "python scripts/export_maia3.py"
|
|
42
44
|
},
|
|
43
45
|
"dependencies": {
|
|
46
|
+
"@modelcontextprotocol/node": "^2.0.0",
|
|
44
47
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
45
48
|
"chess.js": "^1.4.0",
|
|
46
49
|
"onnxruntime-node": "^1.27.0",
|