llm-chess-mcp 0.3.0 → 0.3.1

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,13 @@ 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
+
69
73
  ### Export Maia3 to ONNX (build-time only)
70
74
 
71
75
  This step needs Python + PyTorch once. It downloads the Maia3 checkpoint, verifies
@@ -180,7 +184,7 @@ codex mcp add llm-chess-mcp --command npx --args -y llm-chess-mcp --env LICHESS_
180
184
  | `move_candidates_by_intent` | Convenience layer: candidates ranked for a strategic intent |
181
185
  | `opening_explorer` | Lichess human game statistics |
182
186
 
183
- ## Result format and 0.1.x migration
187
+ ## Result format
184
188
 
185
189
  `structuredContent` is the canonical successful result. Handler-level failures
186
190
  set `isError` and provide `structuredContent.error`. Input-schema failures are
@@ -188,12 +192,6 @@ generated by the MCP SDK before the handler and use its standard `isError` text
188
192
  result without `structuredContent`. Otherwise, `content` is only a short
189
193
  human-readable summary and must not be parsed as data.
190
194
 
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
195
  ## Score conventions
198
196
 
199
197
  - Stockfish scores are **side-to-move perspective**: positive cp = side to move is
@@ -318,15 +316,16 @@ It checks top-1/top-k move agreement and max probability error to detect
318
316
  export/runtime regressions. The bundled `maia3-5m.onnx` passes with 100% top-1
319
317
  and top-5 agreement and max probability error < 1e-4.
320
318
 
321
- ## Releases
319
+ ## Package verification
322
320
 
323
- Releases are verified locally; this project intentionally has no hosted CI
324
- release workflow.
321
+ Package artifacts are verified locally; this project intentionally has no
322
+ hosted CI workflow.
325
323
 
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`.
324
+ Run `pnpm check` for the deterministic offline gate. Use `pnpm test:package` to
325
+ pack the project, install the tarball in a clean temporary directory, and run
326
+ the installed `llm-chess-mcp` binary against the real Stockfish and Maia
327
+ runtimes. `pnpm release:check` runs both checks plus the production dependency
328
+ audit and package manifest dry run.
330
329
 
331
330
  ## License & attribution
332
331
 
@@ -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/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,122 @@
1
+ # Architecture
2
+
3
+ `llm-chess-mcp` is a stateful MCP server over stdio. It owns chess-game state
4
+ and exposes deterministic tool contracts; Stockfish, Maia3, and Lichess add
5
+ independent signals without changing a game unless `game_play_move` succeeds.
6
+
7
+ ## Runtime boundary
8
+
9
+ `src/index.ts` is the executable boundary. It loads environment configuration,
10
+ creates the server through `buildServer`, and passes it to `serveStdio`. Stdout
11
+ is reserved for MCP JSON-RPC traffic; diagnostics belong on stderr. When stdin
12
+ closes, the entrypoint closes the transport and terminates Stockfish.
13
+
14
+ The server is assembled from injected `AppServices`, not from tool-level global
15
+ lookups. Production constructs one service set for the process; tests pass
16
+ small fakes or controlled implementations. This keeps transport registration
17
+ separate from engine startup, network I/O, time, and storage.
18
+
19
+ ```text
20
+ stdio -> entrypoint -> buildServer(AppServices) -> tool modules
21
+ |-> GameStore
22
+ |-> Stockfish service
23
+ |-> Maia service
24
+ `-> Lichess explorer
25
+ ```
26
+
27
+ The tool modules have narrow ownership:
28
+
29
+ | Module | Owns |
30
+ |---|---|
31
+ | `game` | session creation/deletion, state, legal moves, PGN, and the only game mutation |
32
+ | `analysis` | Stockfish analysis, Maia distributions, and per-move evaluation |
33
+ | `candidates` | joins objective, human, and opening facets; intent ranking |
34
+ | `explorer` | Lichess input validation, requests, retry policy, and response validation |
35
+
36
+ Tool modules validate inputs, take a game snapshot where needed, call services,
37
+ and adapt data to output schemas. They do not reach into another module's
38
+ storage or manage an engine session directly.
39
+
40
+ ## App services and game lifecycle
41
+
42
+ `AppServices` carries the application dependencies: a `GameStore`, Stockfish,
43
+ Maia inference, candidate computation, and the Lichess explorer. Dependencies
44
+ are interfaces at this boundary so tests can inject controlled services without
45
+ patching process globals. Clock and ID generation are injected into `GameStore`;
46
+ fetch, timeout, and sleep are injected at the explorer boundary.
47
+
48
+ `GameStore` owns `Chess` instances and their metadata:
49
+
50
+ 1. Creating a game assigns an opaque ID and revision `0`; importing a PGN also
51
+ creates a new game at revision `0`.
52
+ 2. Reads refresh `lastAccessedAt`. Idle games expire after one hour, cleanup
53
+ runs before store operations, and the store rejects creation once its
54
+ 1,000-session limit is reached.
55
+ 3. `game_play_move` compares `expected_revision` with the current revision,
56
+ makes a legal move only on equality, then increments the revision.
57
+ 4. Deleting a game removes its session. Expired and deleted IDs are no longer
58
+ valid.
59
+
60
+ All asynchronous readers clone the position first. The snapshot is rebuilt
61
+ from the initial position and move history, preserving history-dependent chess
62
+ rules such as threefold repetition. A long analysis therefore observes one FEN
63
+ and one revision even if a later request changes the live game.
64
+
65
+ ```text
66
+ read game -> snapshot + revision R -> async analysis -> result tagged R
67
+ \
68
+ play(expected_revision: R) -> mutate live game -> revision R + 1
69
+ ```
70
+
71
+ An analysis result is informational, not a lock. A caller must use the revision
72
+ it read when submitting `game_play_move`; a stale write returns
73
+ `STALE_POSITION` rather than applying a move to a different position.
74
+
75
+ ## Compute and network services
76
+
77
+ Stockfish is a single worker-backed engine, so its service serializes analysis
78
+ requests through a bounded queue (32 active or waiting requests). It lazily
79
+ initializes the configured packaged flavor, performs the UCI/ready handshake,
80
+ and gives each request an analysis timeout plus a stop grace period. Init,
81
+ handshake, or analysis failure invalidates and terminates the worker; a queued
82
+ later request initializes a fresh worker. Queue capacity fails fast, and
83
+ shutdown invalidates work from the old generation.
84
+
85
+ Maia runs in-process with the bundled ONNX model (5M by default). Its inference
86
+ session is lazy and shared after successful creation. For each snapshot it
87
+ tokenizes position history, supplies both Elo inputs, masks logits to legal
88
+ moves, mirrors black-to-move moves for the model vocabulary, and normalizes the
89
+ remaining logits. The output is human move likelihood, never an evaluation.
90
+
91
+ Lichess is optional and token-gated. The explorer validates speed/rating filters
92
+ locally and forbids filters for `masters`. Each request has a five-second
93
+ attempt timeout, at most two attempts, and a twelve-second overall budget.
94
+ Only timeouts, network failures, HTTP 429, and 5xx responses retry. `Retry-After`
95
+ is honored only when it fits the remaining budget and does not exceed two
96
+ seconds; authentication errors, other 4xx responses, invalid input, and
97
+ malformed responses fail without retry. Successful payloads are checked against
98
+ the legal moves of the snapshot before they can affect a candidate result.
99
+
100
+ ## Result and contract rules
101
+
102
+ Every registered tool declares an MCP output schema. On success its payload is
103
+ the canonical `structuredContent`; `content` is one short display summary and
104
+ is deliberately not a JSON data channel. Handler failures set `isError: true`
105
+ and return `{ error: { code, message } }` in `structuredContent`. SDK
106
+ input-schema failures happen before the handler and retain the SDK's standard
107
+ text-only error result.
108
+
109
+ Contract snapshots capture the externally visible tool list, descriptions,
110
+ annotations, and input/output schemas. Update them only as part of an
111
+ intentional contract change:
112
+
113
+ 1. Change the relevant input/output schema and tool adapter together.
114
+ 2. Update focused unit and stdio tests, then run `pnpm contract:update` to
115
+ regenerate the snapshot.
116
+ 3. Review the snapshot diff as an API diff: names, required fields, enum values,
117
+ nullability, and error codes are compatibility surface.
118
+ 4. Run `pnpm contract:check` and the full local gate before merging.
119
+
120
+ Do not regenerate a snapshot merely to make a failing check pass. If a change
121
+ is not intended to alter the public MCP contract, its snapshot must remain
122
+ 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.3.1",
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"
@@ -29,10 +30,13 @@
29
30
  "test:unit": "tsx --test tests/*.test.ts",
30
31
  "test:integration": "tsx --test tests/integration/*.test.ts",
31
32
  "test:e2e": "pnpm build && tsx --test tests/e2e/*.test.ts",
33
+ "test:package": "node scripts/package-smoke.mjs",
34
+ "test:stress": "tsx --test tests/stress/*.test.ts",
35
+ "test:live": "tsx --test tests/live/*.test.ts",
32
36
  "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
37
  "test": "pnpm test:unit && pnpm test:integration && pnpm test:e2e",
34
38
  "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",
39
+ "release:check": "pnpm check && pnpm test:package && pnpm audit --prod && npm pack --dry-run --ignore-scripts",
36
40
  "prepublishOnly": "pnpm check",
37
41
  "export:maia3": "python scripts/export_maia3.py"
38
42
  },