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 +15 -16
- package/dist/engines/stockfish.js +7 -3
- package/dist/eval.js +2 -1
- package/dist/intents.js +12 -7
- package/dist/tools/analysis.js +5 -2
- package/docs/architecture.md +122 -0
- package/package.json +6 -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,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
|
|
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
|
-
##
|
|
319
|
+
## Package verification
|
|
322
320
|
|
|
323
|
-
|
|
324
|
-
|
|
321
|
+
Package artifacts are verified locally; this project intentionally has no
|
|
322
|
+
hosted CI workflow.
|
|
325
323
|
|
|
326
|
-
|
|
327
|
-
install
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
: {
|
|
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/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,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.
|
|
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
|
},
|