llm-chess-mcp 0.1.1 → 0.1.2

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
@@ -1,6 +1,12 @@
1
1
  # llm-chess-mcp
2
2
 
3
- An MCP server for truly enjoying chess games with your LLM. It adds a feature that, rather than simply making the best move, allows it to think and judge on its own and make moves. the MCP server handles all the computation (Stockfish evaluation, Maia3 human-move prediction, Lichess opening statistics).
3
+ An MCP chess runtime that lets LLMs play, analyze, and adapt their strength
4
+ without outsourcing every decision to an engine.
5
+
6
+ Rather than returning a single best move, it exposes objective strength
7
+ (Stockfish), human move likelihood (Maia3), and real-game statistics (Lichess)
8
+ so the LLM can choose how it wants to play. The LLM does the strategy and
9
+ judgment; the MCP server handles all the computation.
4
10
 
5
11
  ## Engines
6
12
 
@@ -10,8 +16,8 @@ An MCP server for truly enjoying chess games with your LLM. It adds a feature th
10
16
  | **Maia3** (ONNX) | Human-like move probabilities conditioned on Elo | In-process (`onnxruntime-node`) |
11
17
  | **Lichess explorer** | Real human game statistics | HTTP (needs token) |
12
18
 
13
- Everything runs inside the Node process — no separate engine binaries or Python
14
- runtime needed at deploy time. Maia3 is exported to ONNX once at build time.
19
+ Everything runs inside the Node process — no external engine process or Python
20
+ runtime is required at deploy time. Maia3 is exported to ONNX once at build time.
15
21
 
16
22
  ## Install
17
23
 
@@ -135,32 +141,93 @@ codex mcp add llm-chess-mcp --command npx --args -y llm-chess-mcp --env LICHESS_
135
141
  |---|---|
136
142
  | `create_game` | Create a game (optionally from a FEN), returns `game_id` |
137
143
  | `delete_game` | Delete a game and free its session |
138
- | `game_state` | Authoritative state: FEN, turn, check/mate/draw flags, history, last move, castling |
139
- | `game_state_ascii` | ASCII board diagram |
140
- | `game_play_move` | Play a move (SAN or UCI) — the only mutating tool |
144
+ | `game_state` | Authoritative state: FEN, turn, revision, check/mate/draw flags, history, last move, castling (optional ASCII) |
145
+ | `game_play_move` | Play a move (SAN or UCI) — the only mutating tool, with stale-position guard |
141
146
  | `game_legal_moves` | All legal moves with metadata |
142
147
  | `game_pgn` | Export the game as PGN |
143
148
  | `game_import_pgn` | Import a PGN into a new game |
144
- | `position_analyze` | Stockfish multipv lines (cp/mate + PV) |
149
+ | `position_analyze` | Stockfish multipv lines (cp/mate/WDL + PV), `analysis_level` preset |
145
150
  | `human_move_distribution` | Maia3 human-move probabilities at a target Elo |
146
- | `move_evaluate` | Score a move + cpLoss + classification |
147
- | `move_candidates` | Unified candidates (SF eval + cpLoss + Maia3 prob + Lichess stats) |
148
- | `move_candidates_by_intent` | Candidates ranked for a strategic intent |
151
+ | `move_evaluate` | Score one or more moves + cpLoss + classification |
152
+ | `move_candidates` | **Primary tool**: unified candidates (objective + human + opening) |
153
+ | `move_candidates_by_intent` | Convenience layer: candidates ranked for a strategic intent |
149
154
  | `opening_explorer` | Lichess human game statistics |
150
155
 
151
156
  ## Score conventions
152
157
 
153
158
  - Stockfish scores are **side-to-move perspective**: positive cp = side to move is
154
- better; `mate N` = side to move mates in N.
155
- - `move_evaluate` reports the score **from the mover's perspective** (negated after
156
- the move), plus `cpLoss` (centipawns lost vs the best move) and a classification:
159
+ better; `mate N` = side to move mates in N. `wdl` is `[win, draw, loss]` in
160
+ permille for the side to move.
161
+ - `move_candidates` gives `moverCp` (the mover's perspective higher is better
162
+ for the player choosing the move) and `whiteCp` (fixed white perspective) so
163
+ the sign never flips on you.
164
+ - `move_evaluate` reports the score **from the mover's perspective**, plus `cpLoss`
165
+ (centipawns lost vs the best move) and a classification:
157
166
  `best / excellent / good / inaccuracy / mistake / blunder`.
158
167
  - `maia3Prob` is a **human-likelihood**, not move quality. A high-probability move
159
168
  can still be objectively bad.
160
169
 
170
+ ## Candidate structure
171
+
172
+ `move_candidates` returns each candidate with three independent facets:
173
+
174
+ ```json
175
+ {
176
+ "uci": "g1f3",
177
+ "san": "Nf3",
178
+ "objective": { "rank": 1, "moverCp": 55, "whiteCp": 55, "cpLoss": 0, "moverMate": null, "wdl": [153, 844, 3] },
179
+ "human": { "maia3Prob": 0.62, "selfElo": 1500, "opponentElo": 1500 },
180
+ "opening": { "status": "available", "games": 18421, "frequency": 0.31 }
181
+ }
182
+ ```
183
+
184
+ - `objective` — Stockfish: engine strength, never conflated with human-likeness.
185
+ `moverCp` is from the mover's perspective (higher = better for the chooser).
186
+ - `human` — Maia3 conditional probability at a target Elo.
187
+ - `opening` — Lichess empirical frequency (a different signal from Maia3).
188
+
189
+ `opening.status` is `available`, `no_data` (API ok but no games in this
190
+ position), `unavailable` (timeout/429/401), or `disabled` (no token).
191
+ Stockfish + Maia3 results are always returned regardless.
192
+
193
+ `move_candidates` also returns `moveSensitivity`, describing how sharply the
194
+ evaluation changes across the top engine lines:
195
+
196
+ ```json
197
+ { "moveSensitivity": { "level": "high", "topMoveSpreadCp": 245 } }
198
+ ```
199
+
200
+ `level` is `low` (<80cp spread), `medium` (80–200cp), or `high` (≥200cp). High
201
+ sensitivity means choosing among plausible alternatives can materially change
202
+ the evaluation — useful for deciding whether to ease off or play precisely.
203
+
204
+ ## Analysis levels
205
+
206
+ Stockfish tools accept an `analysis_level` preset instead of raw UCI knobs:
207
+
208
+ | Level | Depth | MultiPV |
209
+ |---|---|---|
210
+ | `fast` | 8 | 5 |
211
+ | `normal` | 15 | 8 |
212
+ | `deep` | 22 | 10 |
213
+
214
+ Explicit `depth`/`multipv` overrides are still available for advanced use.
215
+
216
+ ## Stale-position guard
217
+
218
+ Every state read returns a `revision`. `game_play_move` **requires**
219
+ `expected_revision`; if the game has advanced since your last read, the move is
220
+ rejected:
221
+
222
+ ```json
223
+ { "error": { "code": "STALE_POSITION", "message": "position changed: expected revision 2, current 3" } }
224
+ ```
225
+
161
226
  ## Intents
162
227
 
163
- `move_candidates_by_intent` ranks candidates for a chosen intent:
228
+ `move_candidates_by_intent` ranks candidates for a chosen intent. It is a
229
+ convenience layer over `move_candidates`; the fixed thresholds below are
230
+ heuristic defaults, not the source of truth:
164
231
 
165
232
  | Intent | Meaning |
166
233
  |---|---|
@@ -168,16 +235,40 @@ codex mcp add llm-chess-mcp --command npx --args -y llm-chess-mcp --env LICHESS_
168
235
  | `strong` | Engine-strong but human-plausible |
169
236
  | `natural` | Most human-typical at the target Elo |
170
237
  | `balanced` | Blend of strength and human-likeness |
171
- | `ease_off` | Slightly weaker (−30..−80cp) but human |
172
- | `give_chance` | Clearly weaker (−80..−150cp), gives the opponent chances |
238
+ | `ease_off` | Human-plausible moves that modestly reduce advantage without changing the expected result |
239
+ | `give_chance` | Human-plausible inaccuracies that meaningfully improve the opponent's chances |
240
+
241
+ This tool ranks candidates but does not choose a move. Use the returned signals
242
+ and conversation context to make the final decision — do not map user skill
243
+ mechanically to an intent.
173
244
 
174
245
  ## Example flow
175
246
 
247
+ The normal play loop is three calls:
248
+
176
249
  1. `create_game` → `game_id`
177
- 2. `position_analyze` to see the objective best lines
178
- 3. `human_move_distribution` to see what a human of a given Elo would play
179
- 4. `move_candidates_by_intent` with the intent that fits the situation
180
- 5. `game_play_move` to commit the chosen move
250
+ 2. `move_candidates` pick a move
251
+ 3. `game_play_move` (with `expected_revision`) commit it
252
+
253
+ Go deeper only when you need to:
254
+
255
+ - `position_analyze` — objective best lines
256
+ - `human_move_distribution` — what a human of a given Elo would play
257
+ - `opening_explorer` — real-game statistics
258
+ - `move_evaluate` — score a specific move (or compare several)
259
+
260
+ ## Maia3 ONNX verification
261
+
262
+ The exported ONNX model is regression-tested against the upstream Maia3
263
+ implementation across fixed positions and Elo pairs:
264
+
265
+ ```bash
266
+ .venv-maia3/bin/python scripts/verify_maia3.py --model 5m
267
+ ```
268
+
269
+ It checks top-1/top-k move agreement and max probability error to detect
270
+ export/runtime regressions. The bundled `maia3-5m.onnx` passes with 100% top-1
271
+ and top-5 agreement and max probability error < 1e-4.
181
272
 
182
273
  ## License & attribution
183
274
 
@@ -10,6 +10,12 @@ function parseScore(token) {
10
10
  return { cp: null, mate: Number(token.slice(4)) };
11
11
  return { cp: null, mate: null };
12
12
  }
13
+ function parseWdl(line) {
14
+ const m = line.match(/ wdl (\d+) (\d+) (\d+)/);
15
+ if (!m)
16
+ return null;
17
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
18
+ }
13
19
  export class Stockfish {
14
20
  engine = null;
15
21
  ready = null;
@@ -84,6 +90,7 @@ export class Stockfish {
84
90
  multipv: n,
85
91
  scoreCp: score.cp,
86
92
  scoreMate: score.mate,
93
+ wdl: parseWdl(line),
87
94
  pv: pv ? pv[1].split(" ") : [],
88
95
  });
89
96
  }
@@ -95,6 +102,7 @@ export class Stockfish {
95
102
  };
96
103
  engine.sendCommand("position fen " + fen);
97
104
  engine.sendCommand(`setoption name MultiPV value ${multipv}`);
105
+ engine.sendCommand("setoption name UCI_ShowWDL value true");
98
106
  engine.sendCommand(`go depth ${depth}`);
99
107
  stopTimer = setTimeout(() => {
100
108
  engine.sendCommand("stop");
package/dist/eval.js CHANGED
@@ -1,9 +1,22 @@
1
- export function scoreToCp(line) {
1
+ export function toEval(line) {
2
2
  if (line.scoreMate !== null) {
3
- const sign = line.scoreMate >= 0 ? 1 : -1;
4
- return sign * (10000 - Math.abs(line.scoreMate) * 100);
3
+ return { type: "mate", plies: line.scoreMate };
5
4
  }
6
- return line.scoreCp ?? 0;
5
+ if (line.scoreCp !== null) {
6
+ return { type: "cp", value: line.scoreCp };
7
+ }
8
+ return null;
9
+ }
10
+ export function evalToCp(e) {
11
+ if (e.type === "cp")
12
+ return e.value;
13
+ const sign = e.plies >= 0 ? 1 : -1;
14
+ return sign * (10000 - Math.abs(e.plies) * 100);
15
+ }
16
+ export function negateEval(e) {
17
+ if (e.type === "cp")
18
+ return { type: "cp", value: -e.value };
19
+ return { type: "mate", plies: -e.plies };
7
20
  }
8
21
  export const CLASSIFICATION = {
9
22
  best: 0,
@@ -25,3 +38,8 @@ export function classifyCpLoss(cpLoss) {
25
38
  return "mistake";
26
39
  return "blunder";
27
40
  }
41
+ export const ANALYSIS_PRESETS = {
42
+ fast: { depth: 8, multipv: 5 },
43
+ normal: { depth: 15, multipv: 8 },
44
+ deep: { depth: 22, multipv: 10 },
45
+ };
package/dist/games.js CHANGED
@@ -5,12 +5,12 @@ const games = new Map();
5
5
  export function createGame(fen) {
6
6
  const id = randomUUID();
7
7
  const chess = fen ? new Chess(fen) : new Chess();
8
- games.set(id, { chess, createdAt: Date.now() });
8
+ games.set(id, { chess, createdAt: Date.now(), revision: 0 });
9
9
  return id;
10
10
  }
11
11
  export function createGameFromChess(chess) {
12
12
  const id = randomUUID();
13
- games.set(id, { chess, createdAt: Date.now() });
13
+ games.set(id, { chess, createdAt: Date.now(), revision: 0 });
14
14
  return id;
15
15
  }
16
16
  export function getGame(id) {
@@ -19,6 +19,13 @@ export function getGame(id) {
19
19
  throw new ChessError("GAME_NOT_FOUND", `game not found: ${id}`);
20
20
  return g;
21
21
  }
22
+ export function bumpRevision(id) {
23
+ const g = games.get(id);
24
+ if (!g)
25
+ throw new ChessError("GAME_NOT_FOUND", `game not found: ${id}`);
26
+ g.revision += 1;
27
+ return g.revision;
28
+ }
22
29
  export function deleteGame(id) {
23
30
  return games.delete(id);
24
31
  }
package/dist/index.js CHANGED
@@ -4,12 +4,12 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
4
4
  import * as z from "zod/v4";
5
5
  import { Chess } from "chess.js";
6
6
  import { loadEnv } from "./env.js";
7
- import { createGame, createGameFromChess, getGame, deleteGame } from "./games.js";
7
+ import { createGame, createGameFromChess, getGame, deleteGame, bumpRevision, } from "./games.js";
8
8
  import { stockfish } from "./engines/stockfish.js";
9
9
  import { humanMoveDistribution } from "./maia3/inference.js";
10
10
  import { openingExplorer, explorerEnabled } from "./explorer.js";
11
11
  import { computeCandidates, rankByIntent } from "./intents.js";
12
- import { scoreToCp, classifyCpLoss } from "./eval.js";
12
+ import { toEval, evalToCp, negateEval, classifyCpLoss, ANALYSIS_PRESETS } from "./eval.js";
13
13
  import { ChessError } from "./errors.js";
14
14
  const INTENTS = [
15
15
  "best",
@@ -22,11 +22,12 @@ const INTENTS = [
22
22
  function text(data) {
23
23
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
24
24
  }
25
- function stateOf(chess) {
25
+ function stateOf(chess, revision) {
26
26
  const last = chess.history({ verbose: true }).at(-1);
27
27
  return {
28
28
  fen: chess.fen(),
29
29
  turn: chess.turn(),
30
+ revision,
30
31
  isCheck: chess.isCheck(),
31
32
  isCheckmate: chess.isCheckmate(),
32
33
  isStalemate: chess.isStalemate(),
@@ -86,7 +87,7 @@ export function buildServer() {
86
87
  catch {
87
88
  throw new ChessError("INVALID_FEN", "invalid FEN");
88
89
  }
89
- return text({ game_id: id });
90
+ return text({ game_id: id, revision: 0 });
90
91
  }));
91
92
  server.registerTool("delete_game", {
92
93
  description: "Delete a game and free its session.",
@@ -98,30 +99,39 @@ export function buildServer() {
98
99
  return text({ game_id, deleted: true });
99
100
  }));
100
101
  server.registerTool("game_state", {
101
- description: "Return the authoritative state of a game: FEN, turn, check/mate/draw flags, move history, last move, castling rights. Use this instead of remembering the board.",
102
- inputSchema: z.object({ game_id: z.string() }),
103
- }, wrap(async ({ game_id }) => {
104
- const { chess } = getGame(game_id);
105
- return text({ game_id, ...stateOf(chess) });
106
- }));
107
- server.registerTool("game_state_ascii", {
108
- description: "Return an ASCII diagram of the current board.",
109
- inputSchema: z.object({ game_id: z.string() }),
110
- }, wrap(async ({ game_id }) => {
111
- const { chess } = getGame(game_id);
112
- return text({ game_id, board: chess.ascii() });
102
+ description: "Return the authoritative state of a game: FEN, turn, revision, check/mate/draw flags, move history, last move, castling rights. Use this instead of remembering the board. Set include_ascii=true to also get a board diagram.",
103
+ inputSchema: z.object({
104
+ game_id: z.string(),
105
+ include_ascii: z.boolean().default(false),
106
+ }),
107
+ }, wrap(async ({ game_id, include_ascii }) => {
108
+ const { chess, revision } = getGame(game_id);
109
+ const state = stateOf(chess, revision);
110
+ return text({
111
+ game_id,
112
+ ...state,
113
+ ...(include_ascii ? { board: chess.ascii() } : {}),
114
+ });
113
115
  }));
114
116
  server.registerTool("game_play_move", {
115
- description: "Play a move (SAN like 'e4' or UCI like 'e2e4') and return the resulting state. This is the ONLY tool that mutates the game. Illegal moves are rejected and the state is unchanged.",
116
- inputSchema: z.object({ game_id: z.string(), move: z.string() }),
117
- }, wrap(async ({ game_id, move }) => {
118
- const { chess } = getGame(game_id);
117
+ description: "Play a move (SAN like 'e4' or UCI like 'e2e4') and return the resulting state. This is the ONLY tool that mutates the game. expected_revision is required: pass the revision from your most recent game_state/move_candidates read. If the game has advanced since then, the move is rejected with STALE_POSITION.",
118
+ inputSchema: z.object({
119
+ game_id: z.string(),
120
+ move: z.string(),
121
+ expected_revision: z.number().int().min(0),
122
+ }),
123
+ }, wrap(async ({ game_id, move, expected_revision }) => {
124
+ const { chess, revision } = getGame(game_id);
125
+ if (expected_revision !== revision) {
126
+ throw new ChessError("STALE_POSITION", `position changed: expected revision ${expected_revision}, current ${revision}`);
127
+ }
119
128
  if (chess.isGameOver()) {
120
129
  throw new ChessError("GAME_OVER", "game is already over");
121
130
  }
122
131
  const m = parseMove(chess, move);
123
132
  chess.move({ from: m.from, to: m.to, promotion: m.promotion });
124
- return text({ game_id, move: m.san, ...stateOf(chess) });
133
+ const newRevision = bumpRevision(game_id);
134
+ return text({ game_id, move: m.san, ...stateOf(chess, newRevision) });
125
135
  }));
126
136
  server.registerTool("game_legal_moves", {
127
137
  description: "List all legal moves in the current position (SAN, UCI, piece, capture, promotion).",
@@ -142,23 +152,30 @@ export function buildServer() {
142
152
  return text({ game_id, count: moves.length, moves });
143
153
  }));
144
154
  server.registerTool("position_analyze", {
145
- description: "Run Stockfish on the current position and return the top engine lines (multipv). Scores are from the side-to-move perspective: positive cp = side to move is better; mate N = side to move mates in N. Does NOT mutate the game.",
155
+ description: "Run Stockfish on the current position and return the top engine lines (multipv). Scores are from the side-to-move perspective: positive cp = side to move is better; mate N = side to move mates in N. wdl is [win, draw, loss] in permille for the side to move. Use analysis_level (fast/normal/deep) or explicit depth/multipv. Does NOT mutate the game.",
146
156
  inputSchema: z.object({
147
157
  game_id: z.string(),
148
- depth: z.number().int().min(1).max(30).default(15),
149
- multipv: z.number().int().min(1).max(10).default(3),
158
+ analysis_level: z.enum(["fast", "normal", "deep"]).default("normal"),
159
+ depth: z.number().int().min(1).max(30).optional(),
160
+ multipv: z.number().int().min(1).max(10).optional(),
150
161
  }),
151
- }, wrap(async ({ game_id, depth, multipv }) => {
152
- const { chess } = getGame(game_id);
153
- const lines = await stockfish.analyze(chess.fen(), depth, multipv);
162
+ }, wrap(async ({ game_id, analysis_level, depth, multipv }) => {
163
+ const { chess, revision } = getGame(game_id);
164
+ const preset = ANALYSIS_PRESETS[analysis_level];
165
+ const d = depth ?? preset.depth;
166
+ const mpv = multipv ?? preset.multipv;
167
+ const lines = await stockfish.analyze(chess.fen(), d, mpv);
154
168
  return text({
155
169
  game_id,
156
170
  fen: chess.fen(),
157
171
  turn: chess.turn(),
172
+ revision,
173
+ analysis_level,
158
174
  lines: lines.map((l) => ({
159
175
  multipv: l.multipv,
160
176
  scoreCp: l.scoreCp,
161
177
  scoreMate: l.scoreMate,
178
+ wdl: l.wdl,
162
179
  pv: l.pv,
163
180
  })),
164
181
  });
@@ -172,109 +189,140 @@ export function buildServer() {
172
189
  top_n: z.number().int().min(1).max(20).default(5),
173
190
  }),
174
191
  }, wrap(async ({ game_id, elo, oppo_elo, top_n }) => {
175
- const { chess } = getGame(game_id);
192
+ const { chess, revision } = getGame(game_id);
176
193
  const moves = await humanMoveDistribution(chess, elo, oppo_elo ?? elo, top_n);
177
- return text({ game_id, elo, oppo_elo: oppo_elo ?? elo, moves });
194
+ return text({ game_id, elo, oppo_elo: oppo_elo ?? elo, revision, moves });
178
195
  }));
179
196
  server.registerTool("move_evaluate", {
180
- description: "Evaluate a specific move with Stockfish without mutating the game. Returns the score after the move (from the mover's perspective) plus cpLoss vs the best move and a classification (best/excellent/good/inaccuracy/mistake/blunder).",
197
+ description: "Evaluate one or more moves with Stockfish without mutating the game. Pass a single move string or an array of moves to compare. Returns, for each move, the score after the move (from the mover's perspective), cpLoss vs the best move, and a classification (best/excellent/good/inaccuracy/mistake/blunder).",
181
198
  inputSchema: z.object({
182
199
  game_id: z.string(),
183
- move: z.string(),
200
+ move: z.union([z.string(), z.array(z.string())]),
184
201
  depth: z.number().int().min(1).max(30).default(15),
185
202
  }),
186
203
  }, wrap(async ({ game_id, move, depth }) => {
187
- const { chess } = getGame(game_id);
188
- const m = parseMove(chess, move);
189
- const copy = new Chess(chess.fen());
190
- copy.move({ from: m.from, to: m.to, promotion: m.promotion });
191
- if (copy.isCheckmate()) {
192
- return text({
193
- game_id,
194
- move: m.san,
195
- uci: m.lan,
196
- result: "checkmate",
197
- scoreCp: null,
198
- scoreMate: 0,
199
- bestCp: null,
200
- cpLoss: null,
201
- classification: "best",
202
- pv: [],
203
- });
204
- }
205
- if (copy.isStalemate()) {
206
- return text({
207
- game_id,
204
+ const { chess, revision } = getGame(game_id);
205
+ const moves = Array.isArray(move) ? move : [move];
206
+ const beforeLines = await stockfish.analyze(chess.fen(), depth, 1);
207
+ const before = beforeLines[0];
208
+ const beforeEval = before ? toEval(before) : null;
209
+ const beforeCp = beforeEval ? evalToCp(beforeEval) : null;
210
+ const results = [];
211
+ for (const mv of moves) {
212
+ const m = parseMove(chess, mv);
213
+ const copy = new Chess(chess.fen());
214
+ copy.move({ from: m.from, to: m.to, promotion: m.promotion });
215
+ if (copy.isCheckmate()) {
216
+ results.push({
217
+ move: m.san,
218
+ uci: m.lan,
219
+ result: "checkmate",
220
+ scoreCp: null,
221
+ scoreMate: 0,
222
+ bestCp: beforeCp,
223
+ cpLoss: null,
224
+ classification: "best",
225
+ pv: [],
226
+ });
227
+ continue;
228
+ }
229
+ if (copy.isStalemate()) {
230
+ results.push({
231
+ move: m.san,
232
+ uci: m.lan,
233
+ result: "stalemate",
234
+ scoreCp: 0,
235
+ scoreMate: null,
236
+ bestCp: beforeCp,
237
+ cpLoss: null,
238
+ classification: null,
239
+ pv: [],
240
+ });
241
+ continue;
242
+ }
243
+ const afterLines = await stockfish.analyze(copy.fen(), depth, 1);
244
+ const after = afterLines[0];
245
+ const afterEval = after ? toEval(after) : null;
246
+ const moverEval = afterEval ? negateEval(afterEval) : null;
247
+ const afterCp = moverEval ? evalToCp(moverEval) : null;
248
+ const cpLoss = afterCp !== null && beforeCp !== null ? beforeCp - afterCp : null;
249
+ results.push({
208
250
  move: m.san,
209
251
  uci: m.lan,
210
- result: "stalemate",
211
- scoreCp: 0,
212
- scoreMate: null,
213
- bestCp: null,
214
- cpLoss: null,
215
- classification: null,
216
- pv: [],
252
+ result: "ongoing",
253
+ scoreCp: afterCp,
254
+ scoreMate: moverEval?.type === "mate" ? moverEval.plies : null,
255
+ bestCp: beforeCp,
256
+ cpLoss,
257
+ classification: cpLoss !== null ? classifyCpLoss(cpLoss) : null,
258
+ pv: after?.pv ?? [],
217
259
  });
218
260
  }
219
- const [afterLines, beforeLines] = await Promise.all([
220
- stockfish.analyze(copy.fen(), depth, 1),
221
- stockfish.analyze(chess.fen(), depth, 1),
222
- ]);
223
- const after = afterLines[0];
224
- const before = beforeLines[0];
225
- // Stockfish scores are side-to-move perspective. After the move the side
226
- // to move is the opponent, so negate to get the mover's perspective.
227
- const afterCp = after ? -scoreToCp(after) : null;
228
- const beforeCp = before ? scoreToCp(before) : null;
229
- const cpLoss = afterCp !== null && beforeCp !== null ? beforeCp - afterCp : null;
230
- return text({
231
- game_id,
232
- move: m.san,
233
- uci: m.lan,
234
- result: "ongoing",
235
- scoreCp: afterCp,
236
- scoreMate: after?.scoreMate != null ? -after.scoreMate : null,
237
- bestCp: beforeCp,
238
- cpLoss,
239
- classification: cpLoss !== null ? classifyCpLoss(cpLoss) : null,
240
- pv: after?.pv ?? [],
241
- });
261
+ const payload = { game_id, revision, results };
262
+ return text(moves.length === 1 ? { ...payload, ...results[0] } : payload);
242
263
  }));
243
264
  server.registerTool("move_candidates", {
244
- description: "Combine Stockfish evaluation, Maia3 human probability, and Lichess human statistics into a unified candidate list. Each candidate has sfCp (side-to-move perspective), cpLoss (vs best), maia3Prob (human likelihood, NOT quality), and lichess (real game counts). Use this before choosing a move; the final choice is yours.",
265
+ description: "The primary move-selection tool. Combine Stockfish objective evaluation (moverCp, whiteCp, cpLoss, mate, WDL), Maia3 human probability, and Lichess real-game statistics into a unified candidate list. moverCp is from the mover's perspective: higher = better for the player choosing the move. Use this before choosing a move; the final choice is yours.",
245
266
  inputSchema: z.object({
246
267
  game_id: z.string(),
247
268
  elo: z.number().int().min(600).max(2600).default(1500),
248
- sf_depth: z.number().int().min(1).max(30).default(15),
249
- sf_multipv: z.number().int().min(1).max(10).default(5),
269
+ analysis_level: z.enum(["fast", "normal", "deep"]).default("normal"),
270
+ sf_depth: z.number().int().min(1).max(30).optional(),
271
+ sf_multipv: z.number().int().min(1).max(10).optional(),
250
272
  maia_top_n: z.number().int().min(1).max(20).default(5),
251
273
  lichess_db: z.enum(["lichess", "masters"]).default("lichess"),
252
274
  lichess_speeds: z.array(z.string()).default([]),
253
275
  lichess_ratings: z.array(z.number().int()).default([]),
254
276
  }),
255
- }, wrap(async ({ game_id, elo, sf_depth, sf_multipv, maia_top_n, lichess_db, lichess_speeds, lichess_ratings, }) => {
256
- const { chess } = getGame(game_id);
257
- const candidates = await computeCandidates(chess, elo, sf_depth, sf_multipv, maia_top_n, { db: lichess_db, speeds: lichess_speeds, ratings: lichess_ratings });
258
- return text({ game_id, elo, candidates });
277
+ }, wrap(async ({ game_id, elo, analysis_level, sf_depth, sf_multipv, maia_top_n, lichess_db, lichess_speeds, lichess_ratings, }) => {
278
+ const { chess, revision } = getGame(game_id);
279
+ const preset = ANALYSIS_PRESETS[analysis_level];
280
+ const d = sf_depth ?? preset.depth;
281
+ const mpv = sf_multipv ?? preset.multipv;
282
+ const { candidates, moveSensitivity } = await computeCandidates(chess, elo, d, mpv, maia_top_n, { db: lichess_db, speeds: lichess_speeds, ratings: lichess_ratings });
283
+ return text({
284
+ game_id,
285
+ revision,
286
+ fen: chess.fen(),
287
+ turn: chess.turn(),
288
+ elo,
289
+ analysis_level,
290
+ moveSensitivity,
291
+ candidates,
292
+ });
259
293
  }));
260
294
  server.registerTool("move_candidates_by_intent", {
261
- description: "Return candidate moves ranked for a given strategic intent. intents: best (strongest engine move), strong (engine-strong but human-plausible), natural (most human-typical), balanced (blend of strength and human-likeness), ease_off (slightly weaker but human), give_chance (clearly weaker, gives opponent chances).",
295
+ description: "Convenience layer over move_candidates: rank candidates for a strategic intent. This tool RANKS candidates but does NOT choose a move — use the returned signals and conversation context to make the final decision. Do not map user skill mechanically to an intent. intents: best (strongest engine move), strong (engine-strong but human-plausible), natural (most human-typical), balanced (blend of strength and human-likeness), ease_off (human-plausible moves that modestly reduce advantage without changing the expected result), give_chance (human-plausible inaccuracies that meaningfully improve the opponent's chances).",
262
296
  inputSchema: z.object({
263
297
  game_id: z.string(),
264
298
  intent: z.enum(INTENTS),
265
299
  elo: z.number().int().min(600).max(2600).default(1500),
266
- sf_depth: z.number().int().min(1).max(30).default(15),
267
- sf_multipv: z.number().int().min(1).max(10).default(8),
300
+ analysis_level: z.enum(["fast", "normal", "deep"]).default("normal"),
301
+ sf_depth: z.number().int().min(1).max(30).optional(),
302
+ sf_multipv: z.number().int().min(1).max(10).optional(),
268
303
  maia_top_n: z.number().int().min(1).max(20).default(10),
269
304
  lichess_db: z.enum(["lichess", "masters"]).default("lichess"),
270
305
  lichess_speeds: z.array(z.string()).default([]),
271
306
  lichess_ratings: z.array(z.number().int()).default([]),
272
307
  }),
273
- }, wrap(async ({ game_id, intent, elo, sf_depth, sf_multipv, maia_top_n, lichess_db, lichess_speeds, lichess_ratings, }) => {
274
- const { chess } = getGame(game_id);
275
- const candidates = await computeCandidates(chess, elo, sf_depth, sf_multipv, maia_top_n, { db: lichess_db, speeds: lichess_speeds, ratings: lichess_ratings });
308
+ }, wrap(async ({ game_id, intent, elo, analysis_level, sf_depth, sf_multipv, maia_top_n, lichess_db, lichess_speeds, lichess_ratings, }) => {
309
+ const { chess, revision } = getGame(game_id);
310
+ const preset = ANALYSIS_PRESETS[analysis_level];
311
+ const d = sf_depth ?? preset.depth;
312
+ const mpv = sf_multipv ?? preset.multipv;
313
+ const { candidates, moveSensitivity } = await computeCandidates(chess, elo, d, mpv, maia_top_n, { db: lichess_db, speeds: lichess_speeds, ratings: lichess_ratings });
276
314
  const ranked = rankByIntent(candidates, intent);
277
- return text({ game_id, intent, elo, candidates: ranked });
315
+ return text({
316
+ game_id,
317
+ revision,
318
+ fen: chess.fen(),
319
+ turn: chess.turn(),
320
+ intent,
321
+ elo,
322
+ analysis_level,
323
+ moveSensitivity,
324
+ candidates: ranked,
325
+ });
278
326
  }));
279
327
  server.registerTool("opening_explorer", {
280
328
  description: "Query the Lichess opening explorer for real human game statistics in the current position (requires LICHESS_TOKEN).",
@@ -312,7 +360,7 @@ export function buildServer() {
312
360
  throw new ChessError("INVALID_PGN", "invalid or illegal PGN");
313
361
  }
314
362
  const id = createGameFromChess(chess);
315
- return text({ game_id: id, ...stateOf(chess) });
363
+ return text({ game_id: id, ...stateOf(chess, 0) });
316
364
  }));
317
365
  return server;
318
366
  }
package/dist/intents.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { stockfish } from "./engines/stockfish.js";
2
2
  import { humanMoveDistribution } from "./maia3/inference.js";
3
3
  import { openingExplorer, explorerEnabled } from "./explorer.js";
4
- import { scoreToCp } from "./eval.js";
4
+ import { toEval, evalToCp } from "./eval.js";
5
5
  function softmax(values, temperature) {
6
6
  const scaled = values.map((v) => v / temperature);
7
7
  const max = Math.max(...scaled);
@@ -9,22 +9,65 @@ function softmax(values, temperature) {
9
9
  const sum = exps.reduce((a, b) => a + b, 0);
10
10
  return exps.map((e) => e / sum);
11
11
  }
12
+ function toSan(chess, uci) {
13
+ const m = chess.moves({ verbose: true }).find((x) => x.lan === uci);
14
+ return m ? m.san : uci;
15
+ }
16
+ function objectiveFromLine(line, turn, bestCp) {
17
+ if (!line) {
18
+ return {
19
+ rank: null,
20
+ moverCp: null,
21
+ whiteCp: null,
22
+ cpLoss: null,
23
+ moverMate: null,
24
+ whiteMate: null,
25
+ wdl: null,
26
+ };
27
+ }
28
+ const e = toEval(line);
29
+ const cp = e ? evalToCp(e) : null;
30
+ const whiteCp = cp !== null ? (turn === "w" ? cp : -cp) : null;
31
+ const mate = line.scoreMate;
32
+ const whiteMate = mate !== null ? (turn === "w" ? mate : -mate) : null;
33
+ return {
34
+ rank: line.multipv,
35
+ moverCp: cp,
36
+ whiteCp,
37
+ cpLoss: cp !== null && bestCp !== null ? bestCp - cp : null,
38
+ moverMate: mate,
39
+ whiteMate,
40
+ wdl: line.wdl,
41
+ };
42
+ }
12
43
  export async function computeCandidates(chess, elo, sfDepth, sfMultipv, maiaTopN, lichess) {
13
- const [sfLines, maiaMoves, lichessMoves] = await Promise.all([
44
+ const [sfLines, maiaMoves, lichessResult] = await Promise.all([
14
45
  stockfish.analyze(chess.fen(), sfDepth, sfMultipv),
15
46
  humanMoveDistribution(chess, elo, elo, maiaTopN),
16
47
  lichess && explorerEnabled()
17
48
  ? openingExplorer(chess, lichess.db, lichess.speeds, lichess.ratings)
18
- .then((r) => r.moves)
19
- .catch(() => [])
20
- : Promise.resolve([]),
49
+ .then((r) => ({
50
+ status: (r.moves.length > 0 ? "available" : "no_data"),
51
+ moves: r.moves,
52
+ }))
53
+ .catch((e) => ({
54
+ status: "unavailable",
55
+ reason: e instanceof Error ? e.message : String(e),
56
+ moves: [],
57
+ }))
58
+ : Promise.resolve({
59
+ status: "disabled",
60
+ moves: [],
61
+ }),
21
62
  ]);
63
+ const turn = chess.turn();
22
64
  const maiaByUci = new Map(maiaMoves.map((m) => [m.uci, m.prob]));
23
65
  const sfByUci = new Map(sfLines.map((l) => [l.pv[0], l]));
24
- const lichessByUci = new Map(lichessMoves.map((m) => [m.uci, m]));
66
+ const lichessByUci = new Map(lichessResult.moves.map((m) => [m.uci, m]));
25
67
  const bestCp = sfLines.length
26
- ? Math.max(...sfLines.map((l) => scoreToCp(l)))
27
- : 0;
68
+ ? Math.max(...sfLines.map((l) => evalToCp(toEval(l))))
69
+ : null;
70
+ const totalGames = lichessResult.moves.reduce((a, m) => a + m.count, 0);
28
71
  const ucis = new Set([
29
72
  ...sfByUci.keys(),
30
73
  ...maiaByUci.keys(),
@@ -33,58 +76,107 @@ export async function computeCandidates(chess, elo, sfDepth, sfMultipv, maiaTopN
33
76
  const candidates = [];
34
77
  for (const uci of ucis) {
35
78
  const sf = sfByUci.get(uci);
36
- const cp = sf ? scoreToCp(sf) : null;
79
+ const lc = lichessByUci.get(uci);
80
+ const opening = lc
81
+ ? {
82
+ status: lichessResult.status,
83
+ games: lc.count,
84
+ frequency: totalGames > 0 ? lc.count / totalGames : null,
85
+ white: lc.white,
86
+ draws: lc.draws,
87
+ black: lc.black,
88
+ averageRating: lc.averageRating,
89
+ }
90
+ : {
91
+ status: lichessResult.status,
92
+ reason: lichessResult.status === "unavailable" ? lichessResult.reason : undefined,
93
+ games: null,
94
+ frequency: null,
95
+ white: null,
96
+ draws: null,
97
+ black: null,
98
+ averageRating: null,
99
+ };
37
100
  candidates.push({
38
101
  uci,
39
102
  san: toSan(chess, uci),
40
- sfCp: cp,
41
- cpLoss: cp !== null ? bestCp - cp : null,
42
- maia3Prob: maiaByUci.get(uci) ?? null,
43
- lichess: lichessByUci.get(uci) ?? null,
103
+ objective: objectiveFromLine(sf, turn, bestCp),
104
+ human: {
105
+ maia3Prob: maiaByUci.get(uci) ?? null,
106
+ selfElo: elo,
107
+ opponentElo: elo,
108
+ },
109
+ opening,
44
110
  });
45
111
  }
46
- return candidates;
112
+ return { candidates, moveSensitivity: computeMoveSensitivity(sfLines) };
47
113
  }
48
- function toSan(chess, uci) {
49
- const m = chess.moves({ verbose: true }).find((x) => x.lan === uci);
50
- return m ? m.san : uci;
114
+ function winMargin(c) {
115
+ const wdl = c.objective.wdl;
116
+ if (!wdl)
117
+ return null;
118
+ return wdl[0] - wdl[2];
119
+ }
120
+ export function computeMoveSensitivity(sfLines) {
121
+ const cps = sfLines
122
+ .map((l) => toEval(l))
123
+ .filter((e) => e !== null)
124
+ .map((e) => evalToCp(e));
125
+ if (cps.length < 2) {
126
+ return { level: "low", topMoveSpreadCp: null };
127
+ }
128
+ const spread = Math.max(...cps) - Math.min(...cps);
129
+ const level = spread >= 200 ? "high" : spread >= 80 ? "medium" : "low";
130
+ return { level, topMoveSpreadCp: spread };
51
131
  }
52
132
  export function rankByIntent(candidates, intent) {
53
- const withSf = candidates.filter((c) => c.sfCp !== null);
54
- const bestCp = withSf.length ? Math.max(...withSf.map((c) => c.sfCp)) : 0;
55
- const sfProbs = intent === "balanced"
56
- ? softmax(candidates.map((x) => x.sfCp ?? -1000), 100)
57
- : null;
133
+ const withSf = candidates.filter((c) => c.objective.moverCp !== null);
134
+ const bestMargin = withSf.length
135
+ ? Math.max(...withSf.map((c) => winMargin(c) ?? -Infinity))
136
+ : 0;
58
137
  const scored = candidates.map((c) => {
59
138
  let score;
60
139
  switch (intent) {
61
140
  case "best":
62
- score = c.sfCp ?? -Infinity;
141
+ score = c.objective.moverCp ?? -Infinity;
63
142
  break;
64
143
  case "strong": {
65
- const sf = c.sfCp ?? -Infinity;
66
- const human = c.maia3Prob ?? 0;
144
+ const sf = c.objective.moverCp ?? -Infinity;
145
+ const human = c.human.maia3Prob ?? 0;
67
146
  score = human > 0 ? sf : -Infinity;
68
147
  break;
69
148
  }
70
149
  case "natural":
71
- score = c.maia3Prob ?? 0;
150
+ score = c.human.maia3Prob ?? 0;
72
151
  break;
73
152
  case "balanced": {
153
+ const sfProbs = softmax(candidates.map((x) => x.objective.moverCp ?? -1000), 100);
74
154
  const idx = candidates.indexOf(c);
75
- score = 0.5 * sfProbs[idx] + 0.5 * (c.maia3Prob ?? 0);
155
+ score = 0.5 * sfProbs[idx] + 0.5 * (c.human.maia3Prob ?? 0);
76
156
  break;
77
157
  }
78
158
  case "ease_off": {
79
- const gap = bestCp - (c.sfCp ?? -Infinity);
80
- const inRange = gap >= 30 && gap <= 80;
81
- score = inRange ? c.maia3Prob ?? 0 : -Infinity;
159
+ const margin = winMargin(c);
160
+ const human = c.human.maia3Prob ?? 0;
161
+ if (margin === null || human === 0) {
162
+ score = -Infinity;
163
+ break;
164
+ }
165
+ const drop = bestMargin - margin;
166
+ const modest = drop >= 15 && drop <= 50 && margin > 0;
167
+ score = modest ? human : -Infinity;
82
168
  break;
83
169
  }
84
170
  case "give_chance": {
85
- const gap = bestCp - (c.sfCp ?? -Infinity);
86
- const inRange = gap >= 80 && gap <= 150;
87
- score = inRange ? c.maia3Prob ?? 0 : -Infinity;
171
+ const margin = winMargin(c);
172
+ const human = c.human.maia3Prob ?? 0;
173
+ if (margin === null || human === 0) {
174
+ score = -Infinity;
175
+ break;
176
+ }
177
+ const drop = bestMargin - margin;
178
+ const meaningful = drop >= 50 && drop <= 150;
179
+ score = meaningful ? human : -Infinity;
88
180
  break;
89
181
  }
90
182
  }
@@ -95,9 +187,9 @@ export function rankByIntent(candidates, intent) {
95
187
  .sort((a, b) => b.score - a.score)
96
188
  .map((x) => x.c);
97
189
  if (ranked.length === 0 && (intent === "ease_off" || intent === "give_chance")) {
98
- const human = candidates.filter((c) => c.maia3Prob !== null);
190
+ const human = candidates.filter((c) => c.human.maia3Prob !== null && c.objective.moverCp !== null);
99
191
  return human
100
- .sort((a, b) => (a.sfCp ?? -Infinity) - (b.sfCp ?? -Infinity))
192
+ .sort((a, b) => (a.objective.moverCp ?? -Infinity) - (b.objective.moverCp ?? -Infinity))
101
193
  .slice(0, 5);
102
194
  }
103
195
  return ranked;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-chess-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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",