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 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 and 0.1.x migration
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
- ## Releases
347
+ ## Package verification
322
348
 
323
- Releases are verified locally; this project intentionally has no hosted CI
324
- release workflow.
349
+ Package artifacts are verified locally; this project intentionally has no
350
+ hosted CI workflow.
325
351
 
326
- For `0.2.0`, run `pnpm release:check`, pack the tarball, and smoke-test a clean
327
- install of that tarball with `llm-chess-mcp`. Publish only after that succeeds.
328
- Use the same local gate and clean-install smoke test before promoting the proven
329
- `0.2.x` release process to `1.0.0`.
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
- : { cp: null, mate: null };
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
- return sign * (10000 - Math.abs(e.plies) * 100);
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
- const entry = process.argv[1];
12
- if (entry && import.meta.url === pathToFileURL(realpathSync(entry)).href) {
13
- const handle = serveStdio(() => buildServer());
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 ??= Promise.all([defaultAppServices.quit(), handle.close()]).then(() => undefined));
16
- const onClose = () => {
17
- void close().catch((error) => console.error("shutdown failed", error));
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.stdin.once("end", onClose);
20
- process.stdin.once("close", onClose);
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
- if (uci !== undefined)
57
- sfByUci.set(uci, line);
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 evals = sfLines
61
- .map(toEval)
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 { candidates, moveSensitivity: computeMoveSensitivity(sfLines) };
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([
@@ -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: null,
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.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",