@chessceo/mcp 0.43.0 → 0.44.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/dist/tools.js ADDED
@@ -0,0 +1,893 @@
1
+ // Tool schemas for chessceo-mcp. Pure data — no runtime dependencies.
2
+ // Extracted from index.ts in v0.44 for readability; behaviour lives in
3
+ // the tool case handlers in index.ts (callToolInner switch).
4
+ //
5
+ // Descriptions are written for the LLM, not humans — they should hint
6
+ // at when to call the tool, what inputs mean, and what the response
7
+ // contains. Terse is fine; the LLM already reads the parameter names.
8
+ export const TOOLS = [
9
+ {
10
+ name: "search_player",
11
+ description: "Fuzzy name lookup for FIDE-rated chess players. Returns candidate matches with their FIDE ID, current rating, title (GM/IM/etc.), and country. Use this to resolve a plain-English name (e.g. 'Carlsen', 'Ding Liren') to the FIDE ID that every other tool needs.",
12
+ inputSchema: {
13
+ type: "object",
14
+ properties: {
15
+ name: {
16
+ type: "string",
17
+ description: "Player name or partial name. Case-insensitive, fuzzy.",
18
+ },
19
+ },
20
+ required: ["name"],
21
+ },
22
+ },
23
+ {
24
+ name: "get_player_profile",
25
+ description: "Full stats for one player: identity, monthly rating history, peak / trend stats, career W/D/L by color and time control, top-10 openings as White and Black, opponent analysis by rating bracket, notable wins and worst losses, top events with performance ratings. Often enough on its own for 'how strong is X, what do they play, who have they beaten'.",
26
+ inputSchema: {
27
+ type: "object",
28
+ properties: {
29
+ fide_id: {
30
+ type: "integer",
31
+ description: "FIDE ID from search_player.",
32
+ },
33
+ },
34
+ required: ["fide_id"],
35
+ },
36
+ },
37
+ {
38
+ name: "prepare_opponent",
39
+ description: "Create a prep SESSION combining games from one or more sources — FIDE database, Chess.com account, Lichess account — with optional filters (colour, date range, time control). Returns a session `token` you pass to `get_prep_position` to query stats at any position within that filtered corpus.\n\n" +
40
+ "This is the main opponent-prep tool. Use it whenever a user asks 'prep me against X' — call once with the right sources+filters, then walk the tree with `get_prep_position(session_token, ...)`. Sessions are cached on the server (list existing ones with `list_prep_sessions` to avoid rebuilding).\n\n" +
41
+ "SOURCES (1-10 per call, combined into one gameset):\n" +
42
+ "- `fide` — needs `fideId`. Optional filters: `color`, `startMonth`/`endMonth`, `timeControl` (`classical`|`rapid`|`blitz`), `excludeOnline`.\n" +
43
+ "- `chesscom` — needs `username`. Filters: `color`, `startMonth`/`endMonth` (**required** for chesscom/lichess), `timeControl`.\n" +
44
+ "- `lichess` — needs `username`. Same filters as chesscom; `timeControl` also accepts `bullet` on Lichess.\n\n" +
45
+ "Multi-source example: one player with both a FIDE ID and a Lichess account → two sources in one call, all their games combined into one session.\n\n" +
46
+ "GROUNDING: every claim about the opponent's repertoire must trace back to a `get_prep_position` call on this session. Don't assert 'they play sharply' or 'they hate isolated queen pawn' without pointing at actual game counts / win rates in the response. Prep is a two-player game — see `read_opening_prep_guide` before recommending an opening plan.",
47
+ inputSchema: {
48
+ type: "object",
49
+ properties: {
50
+ sources: {
51
+ type: "array",
52
+ minItems: 1,
53
+ maxItems: 10,
54
+ description: "1-10 game sources, all combined into one filtered session.",
55
+ items: {
56
+ type: "object",
57
+ properties: {
58
+ type: { type: "string", enum: ["fide", "chesscom", "lichess"], description: "Source type." },
59
+ fide_id: { type: "integer", description: "FIDE ID (required for type='fide')." },
60
+ username: { type: "string", description: "Platform username (required for type='chesscom'/'lichess')." },
61
+ color: { type: "string", enum: ["white", "black"], description: "Filter: only games where this side is played by the source player. Omit for both colours." },
62
+ start_month: { type: "string", pattern: "^\\d{4}/\\d{2}$", description: "Filter: games from this month onwards, format 'YYYY/MM'. **Required** for chesscom/lichess." },
63
+ end_month: { type: "string", pattern: "^\\d{4}/\\d{2}$", description: "Filter: games up to this month, format 'YYYY/MM'. **Required** for chesscom/lichess." },
64
+ time_control: { type: "string", enum: ["classical", "rapid", "blitz", "bullet"], description: "Filter: only this time control. `bullet` is Lichess-only." },
65
+ exclude_online: { type: "boolean", description: "FIDE-only: exclude online-flagged games (default false)." },
66
+ },
67
+ required: ["type"],
68
+ },
69
+ },
70
+ },
71
+ required: ["sources"],
72
+ },
73
+ },
74
+ {
75
+ name: "get_prep_position",
76
+ description: "Query one position within a prep session created by `prepare_opponent`. Returns move statistics (frequency + win rate + last-played date per move) plus the actual games played from that position, in one call.\n\n" +
77
+ "Position input: prefer `file_id`+`node_id` when inside a prep file (server derives FEN from the tree). Otherwise pass `fen`.\n\n" +
78
+ "AUTO-EVAL: if a cloud combo instance is running, the response includes `.eval` (Stockfish + Lc0 read at the position) so you don't need a separate cloud_analyse.\n\n" +
79
+ "Reading the response — CRITICAL:\n" +
80
+ "• Win % is one weight, not a verdict. Sample size matters (3 games at 66% is noise; 300 at 55% is signal).\n" +
81
+ "• Prep is symmetric information — both sides see the same history. Assume the opponent knows the weakness you spotted.\n" +
82
+ "• Recency > career. The last 12-24 months dominate — filter your session with `start_month` if the player's repertoire shifted.\n" +
83
+ "• Opponent will deviate early. Prep is a tree — cover the 2 most likely replies at each real branching point, not one 20-move line.\n\n" +
84
+ "For the full guide call `read_opening_prep_guide`.",
85
+ inputSchema: {
86
+ type: "object",
87
+ properties: {
88
+ session_token: { type: "string", description: "Session token from `prepare_opponent`." },
89
+ file_id: { type: "string", description: "Prep file id — combine with `node_id` for tree-addressed position lookup." },
90
+ node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`." },
91
+ fen: { type: "string", description: "Position as FEN. Only used if `node_id` is not set." },
92
+ limit: { type: "integer", minimum: 1, maximum: 50, description: "Games to return (default 10)." },
93
+ offset: { type: "integer", minimum: 0 },
94
+ },
95
+ required: ["session_token"],
96
+ },
97
+ },
98
+ {
99
+ name: "list_prep_sessions",
100
+ description: "List the caller's active prep sessions with their tokens and metadata. Call this BEFORE `prepare_opponent` to reuse an existing session instead of rebuilding — sessions cost real backend work for chesscom/lichess (downloading months of games), so re-using saves time. Response includes source description, game count, and creation time per session.",
101
+ inputSchema: { type: "object", properties: {} },
102
+ },
103
+ {
104
+ name: "delete_prep_session",
105
+ description: "Delete one prep session by token. Free-form cleanup — sessions do expire automatically, but this is useful when you're done with one or when you want to force a rebuild after upstream data changed.",
106
+ inputSchema: {
107
+ type: "object",
108
+ properties: {
109
+ session_token: { type: "string", description: "Token from `list_prep_sessions` or the response of `prepare_opponent`." },
110
+ },
111
+ required: ["session_token"],
112
+ },
113
+ },
114
+ {
115
+ name: "get_position_stats",
116
+ description: "Move statistics + example games at a position. Answers 'how often is 4.O-O vs 4.d3 played here and which scores better'.\n\n" +
117
+ "SOURCE (default: `gm-classical`) selects a pre-aggregated database shard:\n" +
118
+ "- `gm-classical` — GM classical games (both players ≥2500, real thinking-time). BEST for opening prep — every move is signal, avgElo ~2600 across all listed moves.\n" +
119
+ "- `main` — the whole 11.7M-game DB. Widest coverage but noisiest (includes 1000-Elo blunder-fests in the move stats). Use as fallback when gm-classical's totalCount is too small to be informative.\n\n" +
120
+ "Game movetext is trimmed to the moves AFTER the queried position (using each game's plyNumber). Saves ~70% of the bytes vs full movetext.\n\n" +
121
+ "AUTO-EVAL: if a cloud combo instance is running, the response includes `.eval` with a compact Stockfish + Lc0 read and the corresponding NAG. Do NOT fire cloud_analyse separately for the same FEN. When called with `file_id`+`node_id`, the eval is also auto-stored on that node's `ceoEval` — later readable via quote_engine_eval.",
122
+ inputSchema: {
123
+ type: "object",
124
+ properties: {
125
+ file_id: {
126
+ type: "string",
127
+ description: "Prep file id. **Prefer file_id+node_id over `fen`** when a prep file is open — the server derives the FEN from the tree.",
128
+ },
129
+ node_id: {
130
+ type: "string",
131
+ description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`/`moves`/`line`.",
132
+ },
133
+ fen: {
134
+ type: "string",
135
+ description: "Starting position as FEN. Combine with `moves`. Only used if `node_id` is not set.",
136
+ },
137
+ moves: {
138
+ type: "string",
139
+ description: "Optional SAN moves on top of `fen` (or startpos). Only used if `node_id` is not set.",
140
+ },
141
+ line: {
142
+ type: "string",
143
+ description: "Synonym for `moves` from startpos; kept for compatibility.",
144
+ },
145
+ limit: {
146
+ type: "integer",
147
+ minimum: 1,
148
+ maximum: 50,
149
+ description: "Number of example games to return (default 10).",
150
+ },
151
+ source: {
152
+ type: "string",
153
+ enum: ["gm-classical", "main"],
154
+ description: "Which database shard to query. Default `gm-classical`. Switch to `main` only when gm-classical's totalCount is too low.",
155
+ },
156
+ },
157
+ },
158
+ },
159
+ {
160
+ name: "describe_position",
161
+ description: "**CALL WHEN**: about to write ANY comment on a position that describes what's happening on the board — piece activity, structure, plans, weaknesses. This is the single biggest lever for prose quality in the whole system. Live audit: nodes where describe_position was called first produced comments grounded specifically in the position (correct piece squares, real pawn structure, actual weak squares); nodes where it wasn't produced generic pattern-matched prose that confidently named pieces on wrong squares. `set_comment` now emits a warning whenever a substantive comment lands on a node whose position was never grounded via describe_position this session — that warning is telling you to fix a class of hallucination that already showed up in your output. Cheap: chess-primitive analysis is instant, Stockfish leg is ~50-100 ms, no billing.\n\n" +
162
+ "Everything you need to understand a position in one call. Pieces get misplaced when reading a FEN, hanging pieces missed, 'the knight on d5' turns out to not exist.\n\n" +
163
+ "Returns three layers:\n\n" +
164
+ "**Board state** — piece placements per colour, material balance in pawn units, contested pieces (attackers + defenders), hanging pieces, checkers if in check, castling rights, en passant, side to move, full LEGAL MOVES list. Use `.legalMoves` when `add_move` rejects an illegal SAN.\n\n" +
165
+ "**Structural analysis** — chess-concept observations a human sees at a glance:\n" +
166
+ " • `pawnStructure.files` — each file `open`/`half_open_for_white`/`half_open_for_black`/`closed`. Half-open files are natural rook targets.\n" +
167
+ " • `pawnStructure.islands` — count per colour (more = weaker structure).\n" +
168
+ " • `pawnStructure.isolated` / `doubled` / `passed` / `backward` — structural weaknesses (and strengths, for passed).\n" +
169
+ " • `weakSquares` — holes in ranks 3-6 that no friendly pawn can ever attack. Prime real estate for enemy pieces.\n" +
170
+ " • `outposts` — friendly N/B on an enemy hole defended by own pawn. Classic strong squares.\n" +
171
+ " • `bishops` — per-bishop `good`/`mixed`/`bad` from own pawns on its colour. `bishops.pair` flags who has both.\n" +
172
+ " • `space` — squares controlled in the enemy half.\n\n" +
173
+ "**Engine eval terms** (`engineEvalTerms`) — Stockfish's classical eval decomposed into 13 named contributing terms (Material, Imbalance, Pawns, Knights, Bishops, Rooks, Queens, Mobility, King safety, Threats, Passed, Space, Winnable), each with white / black / total values in mg + eg. Stockfish's own answer to WHY the position stands the way it does.\n" +
174
+ " → **Primary use: the delta pattern.** Call `describe_position` on the position BEFORE and AFTER a candidate move, compare `engineEvalTerms` — the term with the biggest shift tells you WHAT the move changed (king safety collapsed → move exposed the king; mobility jumped → move improved coordination). Kim et al. NAACL 2025 showed this named-delta pattern roughly doubles LLM chess-commentary correctness vs a bare eval number.\n" +
175
+ " → Omitted from the response if Stockfish isn't installed on the server.\n\n" +
176
+ "Position input: prefer `file_id`+`node_id` if inside a prep file. Otherwise `fen`, `moves` from startpos, or `fen + moves`.",
177
+ inputSchema: {
178
+ type: "object",
179
+ properties: {
180
+ file_id: { type: "string", description: "Prep file id. When combined with `node_id`, describes that node's position." },
181
+ node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'." },
182
+ fen: { type: "string", description: "Starting position as FEN (defaults to startpos). Only used if `node_id` is not set." },
183
+ moves: { type: "string", description: "Optional SAN moves to apply on top of `fen`. Only used if `node_id` is not set." },
184
+ },
185
+ },
186
+ },
187
+ {
188
+ name: "predict_human_move",
189
+ description: "Neural net (ResNet-20x256) trained on real games. Always evaluated at **2850 vs 2850** (top-level play) — the rating is fixed on purpose, so cross-position comparisons stay apples-to-apples. Returns two signals — both useful, treat as independent:\n\n" +
190
+ "1. **Top-N most likely moves** (`moves: [{san, p}, ...]`) — what a top player will actually pick. Different question from engines: cloud_analyse says objectively best, this says what the human will play. If the human top move is a mistake, that's a real practical advantage.\n\n" +
191
+ "2. **`wdlWhitePov: {win, draw, loss}`** — game-outcome prediction, White POV. Directly comparable across positions: call on two positions, compare `draw` to find which line is drawier / more forcing. Two-line comparisons are how you answer 'must-win with Black, which of these openings gives more play'.\n\n" +
192
+ "Pass `prev_fens` (most recent first) when the position is mid-trade — without history the model treats it as quiet, which under-counts practical chances.\n\n" +
193
+ "Position input: prefer `file_id`+`node_id` when inside a prep file. Otherwise `fen`, `moves` from startpos, or `fen + moves`. ~1-2s per call. **Premium (or admin/moderator) only** — anonymous calls get 402.",
194
+ inputSchema: {
195
+ type: "object",
196
+ properties: {
197
+ file_id: { type: "string", description: "Prep file id. Combine with `node_id` to point at a tree node's position." },
198
+ node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'." },
199
+ fen: { type: "string", description: "Starting position as FEN. Only used if `node_id` is not set." },
200
+ moves: {
201
+ type: "string",
202
+ description: "Optional SAN moves to apply on top of `fen` (or startpos). Only used if `node_id` is not set.",
203
+ },
204
+ top: {
205
+ type: "integer",
206
+ minimum: 1,
207
+ maximum: 20,
208
+ description: "Number of top predicted moves to return (default 5).",
209
+ },
210
+ prev_fens: {
211
+ type: "array",
212
+ items: { type: "string" },
213
+ description: "Previous FEN(s), most recent first. Optional — omit for quiet-position analysis. Useful mid-trade so the model doesn't assume the position is stable.",
214
+ },
215
+ },
216
+ },
217
+ },
218
+ {
219
+ name: "get_head_to_head",
220
+ description: "Complete head-to-head record between two players. Includes overall and per-colour W/D/L (from player A's perspective), splits by time control, most-played openings between them, first / last meeting, average game length, and the game list.",
221
+ inputSchema: {
222
+ type: "object",
223
+ properties: {
224
+ fide_id_a: { type: "integer", description: "FIDE ID of player A (record is from A's perspective)." },
225
+ fide_id_b: { type: "integer", description: "FIDE ID of player B." },
226
+ limit: { type: "integer", minimum: 1, maximum: 10 },
227
+ offset: { type: "integer", minimum: 0 },
228
+ },
229
+ required: ["fide_id_a", "fide_id_b"],
230
+ },
231
+ },
232
+ {
233
+ name: "list_live_tournaments",
234
+ description: "Tournaments currently being broadcast live on chess.ceo. Use this when the user asks 'what's on right now' / 'live tournaments today'.",
235
+ inputSchema: { type: "object", properties: {} },
236
+ },
237
+ {
238
+ name: "list_tournament_players",
239
+ description: "Players participating in one live-broadcast tournament.",
240
+ inputSchema: {
241
+ type: "object",
242
+ properties: {
243
+ tour_id: { type: "string", description: "Tournament ID from list_live_tournaments." },
244
+ },
245
+ required: ["tour_id"],
246
+ },
247
+ },
248
+ {
249
+ name: "list_player_live_tournaments",
250
+ description: "Which currently-live broadcasts a given player is competing in. Use when the user asks 'is X playing anywhere right now'.",
251
+ inputSchema: {
252
+ type: "object",
253
+ properties: {
254
+ fide_id: { type: "integer", description: "FIDE ID from search_player." },
255
+ },
256
+ required: ["fide_id"],
257
+ },
258
+ },
259
+ {
260
+ name: "list_cloud_machine_options",
261
+ description: "Returns the catalog of combo cloud-engine machine types the user can start (SKU, human display name, cost per hour, availability). ALWAYS call this before start_cloud_engine — SKU strings like 'rtx-5090-64' do not match the display names ('Stockfish 32 CPUs + Lc0 1× RTX 5090') and are NOT guessable. Present the user the display names + prices; pass the SKU to start_cloud_engine.",
262
+ inputSchema: { type: "object", properties: {} },
263
+ },
264
+ {
265
+ name: "start_cloud_engine",
266
+ description: "Rent a combo GPU instance (Stockfish + Lc0 in the same container) on the user's chess.ceo account. Real money — billed per second while running.\n\n" +
267
+ "CRITICAL: `machine_type` must be an exact SKU from `list_cloud_machine_options` (e.g. 'rtx-5090-64', NOT 'rtx-5090'). Guessing SKUs will fail. Call list_cloud_machine_options first, show the user the display names + prices, get their confirmation, then pass the SKU here.\n\n" +
268
+ "Use list_cloud_engines first to check if the user already has one running; don't start a second combo unless the user asked for it. Requires an MCP token with agent access.",
269
+ inputSchema: {
270
+ type: "object",
271
+ properties: {
272
+ machine_type: {
273
+ type: "string",
274
+ description: "SKU from list_cloud_machine_options (e.g. 'rtx-5090-64', 'rtx-5090-dual-64'). MUST be the exact SKU, not the display name and not a guess.",
275
+ },
276
+ },
277
+ required: ["machine_type"],
278
+ },
279
+ },
280
+ {
281
+ name: "list_cloud_engines",
282
+ description: "List the user's currently running cloud engines. Use before starting a new one, or to find the contract_id for stop_cloud_engine. `cloud_analyse` auto-picks the only running combo, so listing is only necessary when the user might have zero or several.",
283
+ inputSchema: { type: "object", properties: {} },
284
+ },
285
+ {
286
+ name: "stop_cloud_engine",
287
+ description: "Destroy a running cloud engine. Billing stops immediately. Use the contract_id from `list_cloud_engines` — don't guess.",
288
+ inputSchema: {
289
+ type: "object",
290
+ properties: {
291
+ contract_id: {
292
+ type: "string",
293
+ description: "Instance contract_id, from list_cloud_engines.",
294
+ },
295
+ },
296
+ required: ["contract_id"],
297
+ },
298
+ },
299
+ {
300
+ name: "cloud_analyse",
301
+ description: "Runs a synchronous ~2s analysis on the user's running combo instance and returns both Stockfish and Lc0's final read for the FEN — depth, top-N candidate moves with scores (**scoreCp is White-POV centipawns**: +20 = White is +0.20 pawns better regardless of whose turn it is; matches the sign convention used everywhere else in this MCP, including the stored ceoEval). Mate is White-POV plies-to-mate (+5 = White mates in 5). Also returns each engine's principal variation.\n\n" +
302
+ "GROUNDING: every claim you make about a position must trace back to actual engine output from a call in THIS session. Don't invent evaluations, don't name 'best moves' you haven't seen the engine list, don't fabricate variations that 'look plausible.' Compute is cheap — call this 5-10 times while walking a tree rather than pattern-matching from your training data. When you don't have data for the position, either run the tool or say so; don't fill the gap with chess prose the user can't distinguish from measured output.\n\n" +
303
+ "Auto-picks the caller's only running combo instance; errors clearly if there are zero (start one first with start_cloud_engine) or more than one (destroy the extras first).\n\n" +
304
+ "How to read the response:\n" +
305
+ "• Stockfish is objective truth — trust it for 'does this line hold?' 'is there a tactic?' 'is this endgame drawn?' A Stockfish 0.00 means 'objectively equal', NOT 'trivial draw' — one side can still be much harder to play in practice.\n" +
306
+ "• Lc0 is practical eval — trust it for 'which side is easier?' 'which candidate is best when Stockfish shows several as equal?' Lc0 sees long-term positional factors Stockfish's fixed search can miss.\n" +
307
+ "• When they agree → high confidence. When they disagree → look at both scores and reason WHY (Stockfish sharply higher = tactic Lc0 missed; Lc0 higher = long-term positional edge past Stockfish's horizon). Never dismiss either — the disagreement is the signal.\n\n" +
308
+ "Contempt (`contempt`) skews Lc0 (only Lc0 — Stockfish always stays objective) toward White (positive) or Black (negative). Signed 0-100 strength — same scale as the web UI's ContemptStrength slider (the server multiplies by 8 to produce Lc0's internal cp bias). Typical values: ±15 for a light nudge, ±30-60 for real fighting play, ±80-100 for maximum steer. Use it to find non-objective 'practical' ideas or when the user needs to lean toward fighting/solid lines with a specific colour. Do NOT quote a contempt-biased eval as objective — cross-check with Stockfish.\n\n" +
309
+ "Also useful: pass `moves` on top of `fen` to explore a variation without computing FENs yourself (e.g. fen='<tabiya>', moves='b4 a5 c3'). And the flip-side-to-move threat check documented in the guide is a great free trick.\n\n" +
310
+ "**PVs are capped at 6 plies by default (3 full moves), and lines that got truncated are marked with `pv_truncated: true`.** This is deliberate: the tail of a PV is where the engine's confidence collapses, AND pasting a long PV into `add_line` as if it were prepared repertoire is the #1 documented anti-pattern of this MCP — a 15-move PV is one line of engine output through positions where both sides had real choices, not a repertoire. To see further, don't raise `pv_max_plies`; instead, walk the tree one branch at a time with a fresh `cloud_analyse` at each position where the opponent has real alternatives — that's what makes it prep instead of pasted output. Only raise the cap when you're verifying a forcing sequence (a mate, a forced tactical resolution), not to build lines.\n\n" +
311
+ "For the full guide including worked examples, call the `read_engine_usage_guide` tool.\n\n" +
312
+ "Not for casual questions — this costs real money per second. Use `get_position_stats` for anything that doesn't require deep prep.\n\n" +
313
+ "**When called with `file_id`+`node_id` (preferred inside a prep file), the resulting eval is auto-stored on that node's `ceoEval` — you can then quote it with quote_engine_eval on any later call.** This is what makes engine attribution trustworthy: prose that says 'engines say X on node Y' can only be true if a call was actually made against node_id=Y.",
314
+ inputSchema: {
315
+ type: "object",
316
+ properties: {
317
+ file_id: { type: "string", description: "Prep file id. **Prefer file_id+node_id over fen** when inside a prep file — the FEN comes from the tree AND the result is stored on the node." },
318
+ node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`/`moves`." },
319
+ fen: { type: "string", description: "Starting position as FEN. Only used if `node_id` is not set." },
320
+ moves: {
321
+ type: "string",
322
+ description: "Optional SAN moves to apply on top of `fen` (or startpos). Only used if `node_id` is not set.",
323
+ },
324
+ movetime_ms: {
325
+ type: "integer",
326
+ minimum: 100,
327
+ maximum: 10000,
328
+ description: "Think time in milliseconds (default 2000).",
329
+ },
330
+ stockfish_multipv: {
331
+ type: "integer",
332
+ minimum: 1,
333
+ maximum: 10,
334
+ description: "Stockfish candidate lines (default 2). Kept tight because each extra PV steals search bandwidth from the top choice — SF is the 'what's objectively best' leg, use a low multipv to keep it strong. Raise only when you specifically need SF's take on a wide range of candidates.",
335
+ },
336
+ lc0_multipv: {
337
+ type: "integer",
338
+ minimum: 1,
339
+ maximum: 10,
340
+ description: "Lc0 candidate lines (default 8). Kept wide because multipv doesn't degrade Lc0's strength the way it does Stockfish's — Lc0 is the 'find inspiration / explore practical tries' leg, use a high multipv to get a full slate of ideas.",
341
+ },
342
+ contempt: {
343
+ type: "integer",
344
+ minimum: -100,
345
+ maximum: 100,
346
+ description: "Lc0 contempt bias. Signed 0-100 strength (same scale as the web UI's ContemptStrength slider — server multiplies by 8 to get the internal cp bias). 0 = objective (default). Positive favours White, negative favours Black. Typical: ±15 light nudge, ±30-60 real fighting play, ±80-100 maximum steer. Not applied to Stockfish. See engine_usage_primer for when to use.",
347
+ },
348
+ engines: {
349
+ type: "array",
350
+ items: { type: "string", enum: ["stockfish", "lc0"] },
351
+ description: "Which engines to run. Default = both. Use `[\"lc0\"]` to skip Stockfish (e.g. while a deep_analyse job is holding the SF slot on the same combo). Use `[\"stockfish\"]` when only the objective read matters. The skipped engine's field is omitted from the response.",
352
+ },
353
+ pv_max_plies: {
354
+ type: "integer",
355
+ minimum: 1,
356
+ maximum: 40,
357
+ description: "Cap each returned PV to this many plies (default 6 = 3 full moves). PVs beyond ~6 plies are speculative and are the anti-pattern behind pasted-engine-line 'prep' — don't raise unless you're specifically checking a forcing tactic or verifying a mate. When a line was truncated, the response marks it with `pv_truncated: true`.",
358
+ },
359
+ },
360
+ },
361
+ },
362
+ {
363
+ name: "list_collections",
364
+ description: "List the user's own PGN collections — every collection they've created, not just prep files. Response: `{collections: [{id, title, icon, folder_path, game_count, updated_at, position_search_enabled}]}`.\n\n" +
365
+ "**Call this before create_prep_file** — the LLM must pick a collection to write into (no default landing folder any more; the old hidden `/mcp` collection was retired in v0.43). Also useful when the user asks a position-shaped question — LLM can then run find_position_in_files to see which of these collections already covers the position.\n\n" +
366
+ "Encrypted collections (client-side-encrypted PGN) are excluded — the server can't read their contents, so they'd be dead weight on this surface.",
367
+ inputSchema: { type: "object", properties: {} },
368
+ },
369
+ {
370
+ name: "list_prep_files",
371
+ description: "List the games (prep files) inside one of the user's collections. **Requires `collection_id`** — call `list_collections` first if you don't have one. Returns id (composite `<collection_id>:<game_id>`, opaque to the LLM — pass as-is to read_prep_file / mutation tools), PGN header fields, updated_at.\n\n" +
372
+ "For cross-collection discovery use `search_prep_files` (text) or `find_position_in_files` (position); list_prep_files is the browse-one-collection tool.",
373
+ inputSchema: {
374
+ type: "object",
375
+ properties: {
376
+ collection_id: { type: "string", description: "Collection id from list_collections. Required." },
377
+ },
378
+ required: ["collection_id"],
379
+ },
380
+ },
381
+ {
382
+ name: "search_prep_files",
383
+ description: "Text search over the user's prep files ACROSS ALL their collections (matches PGN headers, comments, and content). Use when you know a keyword — e.g. search_prep_files(query='Firouzja') or search_prep_files(query='Najdorf'). Cheaper than paging list_collections + list_prep_files to find one file by name.",
384
+ inputSchema: {
385
+ type: "object",
386
+ properties: {
387
+ query: { type: "string", description: "Free-text query (opponent name, opening name, event keyword)." },
388
+ },
389
+ required: ["query"],
390
+ },
391
+ },
392
+ {
393
+ name: "find_position_in_files",
394
+ description: "Position search across every one of the user's EDITABLE prep files (all their non-encrypted collections). Given a FEN, returns which of the user's files reach that exact position (or a transposition of it — matched by zobrist hash, so move-order variants are found automatically). Recency-sorted.\n\n" +
395
+ "Distinct from `find_position_in_courses`: courses are READ-ONLY reference material (Chessable PGNs, downloaded backups); this searches the user's OWN editable prep. Common workflow: user asks about a position → call this first to see if their existing prep covers it → if yes, extend that file; if no, consider whether to start new prep.\n\n" +
396
+ "Position input: `file_id`+`node_id` (from an already-open prep file), or `fen`, or `moves` from startpos, or `fen`+`moves`.",
397
+ inputSchema: {
398
+ type: "object",
399
+ properties: {
400
+ file_id: { type: "string", description: "Prep file id. With `node_id`, derives FEN from the tree." },
401
+ node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'." },
402
+ fen: { type: "string", description: "Position as FEN. Only used if `file_id`/`node_id` not set." },
403
+ moves: { type: "string", description: "SAN moves from startpos (or on top of fen)." },
404
+ line: { type: "string", description: "Alias for moves." },
405
+ },
406
+ },
407
+ },
408
+ {
409
+ name: "read_prep_file",
410
+ description: "Read one prep file. Response always includes `id`, `version`, `tags`. The tree/PGN part is controlled by `view` and `node_id`/`max_depth` — large files (500+ nodes) can otherwise blow the LLM's token limit.\n\n" +
411
+ "**Views** (pick the smallest one that answers your question):\n" +
412
+ " • `compact` (default) — per-node: `id`, `san`, `ply`, `nags`, `comment`, `ceoEval`, `children`. Drops `fen` and `annotations`. Typical size: ~120 chars/node vs ~330 in `full`.\n" +
413
+ " • `full` — everything (`fen`, `annotations` too). Use when you actually need the FEN inline or want to inspect arrows/highlights. On a 500+-node file this can exceed token limits.\n" +
414
+ " • `spine` — mainline only (children[0] recursively). Great for a 'what does this repertoire cover' summary.\n" +
415
+ " • `pgn` — subtree as raw PGN text (comments, NAGs, [%cal] arrows all preserved). Useful for sanity-checking formatting against reference material.\n\n" +
416
+ "**Node addressing.** Every node has a stable `id` — root is `'r'`, every other node is an 8-hex-char content hash of parent-id + SAN. Sibling insertions, deletions, variation promotions never shift ids. Pass as `node_id` (or `parent_id` for add_move / add_line) to every mutation and engine/DB tool.\n\n" +
417
+ "**Scoping.** `node_id` starts the tree from a subtree root (default `'r'`). `max_depth` caps the tree at that many plies below the anchor (default unlimited). Use both to drill into a specific branch without dumping the whole file — the LLM never needs to see the full 800-node tree at once.\n\n" +
418
+ "For querying the tree without reading it (\"which nodes have no ceoEval?\", \"give me the mainline spine\") call `list_nodes` — cheaper than parsing a full read.\n\n" +
419
+ "**Every engine/DB tool accepts `file_id`+`node_id`** (get_position_stats, cloud_analyse, describe_position, predict_human_move, prep_snapshot, get_prep_position, quote_engine_eval). Use it whenever a file is open — the server derives the FEN from the tree, so you can't 'analyse the wrong position' by mis-typing a FEN.",
420
+ inputSchema: {
421
+ type: "object",
422
+ properties: {
423
+ id: { type: "string", description: "Prep file id, from list_prep_files or search_prep_files." },
424
+ view: { type: "string", enum: ["compact", "full", "spine", "pgn"], description: "Response shape. Default `compact` — drops fen + annotations to keep token count sane. See tool description for when to use each." },
425
+ node_id: { type: "string", description: "Subtree root (default `'r'` = whole file)." },
426
+ max_depth: { type: "integer", minimum: 0, description: "Cap the returned tree at this many plies below `node_id`. Omit for unlimited." },
427
+ },
428
+ required: ["id"],
429
+ },
430
+ },
431
+ {
432
+ name: "list_nodes",
433
+ description: "Cheap tree queries without reading the whole file. Returns only the node ids matching the filter (plus san, ply, and any filter-specific bits), so the LLM can find what it needs in ~KBs instead of MBs.\n\n" +
434
+ "Filters:\n" +
435
+ " • `missing_eval` — nodes without a stored `ceoEval`. Use before `auto_evaluate` to know how much work is left, or to target a small batch.\n" +
436
+ " • `has_comment` — nodes with a text comment. Use to audit what's been annotated.\n" +
437
+ " • `has_annotations` — nodes with arrows or highlighted squares.\n" +
438
+ " • `mainline` — the spine (children[0] recursively). Use for a compact 'what does the repertoire cover' view.\n" +
439
+ " • `novelties` — nodes carrying the `$146` NAG.\n" +
440
+ " • `leaves` — nodes with no children (variation endpoints). Useful for finding lines that need continuation.\n" +
441
+ " • `transpositions` — nodes that share their position with at least one other node in the same file (piece placement + side to move + castling rights match). Response includes `transposes_to: [node_id, …]` per hit so you can see the partners without a second call. Use this BEFORE auto_evaluate on a large branch to see where analysis will double up, and BEFORE writing prose to know which nodes can share commentary via 'transposes to line X'.\n" +
442
+ " • `all` — every node id. Use only when you really need the whole list.\n\n" +
443
+ "Response: `{ file_id, filter, count, nodes: [{node_id, san, ply, ...}] }`. `...` is filter-specific — e.g. `has_comment` includes the first 80 chars of the comment; `transpositions` includes `transposes_to`; `missing_eval` includes nothing extra (just the addressing).",
444
+ inputSchema: {
445
+ type: "object",
446
+ properties: {
447
+ id: { type: "string", description: "Prep file id." },
448
+ filter: { type: "string", enum: ["missing_eval", "has_comment", "has_annotations", "mainline", "novelties", "leaves", "transpositions", "all"], description: "Which nodes to list." },
449
+ node_id: { type: "string", description: "Subtree root (default `'r'` = whole file)." },
450
+ max_depth: { type: "integer", minimum: 0, description: "Cap the walk at this many plies below `node_id`. Omit for unlimited." },
451
+ },
452
+ required: ["id", "filter"],
453
+ },
454
+ },
455
+ {
456
+ name: "list_transpositions",
457
+ description: "Group every position in a prep file that appears more than once — the same piece placement + side-to-move + castling rights reached by different move orders. Chess move orders diverge and re-converge constantly (1.d4 Nf6 2.c4 e6 3.Nc3 vs 1.c4 e6 2.Nc3 Nf6 3.d4 land on the same position); if you analyse both branches independently or write the same commentary twice, you're wasting engine time and inviting inconsistency.\n\n" +
458
+ "Call this BEFORE `auto_evaluate` on a big subtree to see how much work will actually be new, and BEFORE writing prose to know which nodes can share a comment or should point at each other with 'transposes to line X'.\n\n" +
459
+ "Note: engine evals auto-propagate — when `cloud_analyse({file_id, node_id})` stores `ceoEval` on a node, it also stamps every transposition of that position in the same file (see the response's `also_stored_on`). And `auto_evaluate({only_missing: true})` naturally skips the twin because it now has an eval. So detection is cheap AND propagation is automatic; this tool is for prose planning and one-shot audits, not for gating engine work.\n\n" +
460
+ "Response: `{ file_id, group_count, node_count, groups: [{ position_key, size, node_ids, sans }] }`. `position_key` is the 3-field FEN prefix used as the match key; `size` is how many nodes share it; `sans` are the moves that led to each occurrence (parallel with `node_ids`, DFS order — first entry is the earliest/mainline-preferred occurrence). Only groups with size ≥ 2 are returned; sorted by size descending.",
461
+ inputSchema: {
462
+ type: "object",
463
+ properties: {
464
+ id: { type: "string", description: "Prep file id." },
465
+ },
466
+ required: ["id"],
467
+ },
468
+ },
469
+ {
470
+ name: "create_prep_file",
471
+ description: "Create a new (empty) prep file in the specified collection. `name` becomes the Event PGN tag. You then extend it with mutation tools (add_move, set_comment, …).\n\n" +
472
+ "**collection_id is REQUIRED** — call `list_collections` first to pick where it lives. There is no default landing folder any more (v0.43: the old hidden `/mcp` collection was removed; prep files now live wherever the user organizes them).\n\n" +
473
+ "**Duplicate-check first.** Call `search_prep_files(query=<opponent / opening keyword>)` OR `find_position_in_files(fen=...)` before creating — a second 'Prep vs Firouzja' file when one already exists is a common LLM failure mode. If a file already covers the topic, extend that one instead.\n\n" +
474
+ "Response: `{ok, id, collection_id, version}` — `id` is a composite you pass to every other prep-file tool as `id` or `file_id`.",
475
+ inputSchema: {
476
+ type: "object",
477
+ properties: {
478
+ collection_id: {
479
+ type: "string",
480
+ description: "Collection id from list_collections. Required — this is where the new file lands.",
481
+ },
482
+ name: {
483
+ type: "string",
484
+ description: "User-facing name — becomes the [Event] tag. Example: 'Prep vs Firouzja (Black) 2026-07-23'.",
485
+ },
486
+ },
487
+ required: ["collection_id", "name"],
488
+ },
489
+ },
490
+ {
491
+ name: "delete_prep_file",
492
+ description: "Soft-delete a prep file. Fully reversible: the file lands in the user's recycle bin, from which either the user (via app UI) OR you (via `restore_prep_file`) can bring it back. Permanent delete is intentionally NOT exposed on this surface — the user has to permanent-delete from the app themselves. Rare — usually you extend or edit instead.",
493
+ inputSchema: {
494
+ type: "object",
495
+ properties: {
496
+ id: { type: "string", description: "Prep file id." },
497
+ },
498
+ required: ["id"],
499
+ },
500
+ },
501
+ {
502
+ name: "restore_prep_file",
503
+ description: "Restore a previously soft-deleted prep file. Undo the `delete_prep_file` call — the file comes back with a fresh `game_number` (its slot in the collection is not preserved) and its full history intact. Use when you realize mid-session you shouldn't have deleted something; the LLM can fix its own mistake without asking the user to open the app.",
504
+ inputSchema: {
505
+ type: "object",
506
+ properties: {
507
+ id: { type: "string", description: "Prep file id (composite `<collection_id>:<game_id>`). Same id `delete_prep_file` was called with." },
508
+ },
509
+ required: ["id"],
510
+ },
511
+ },
512
+ {
513
+ name: "add_move",
514
+ description: "Append a move as a new child of the node identified by `parent_id`. If the parent already has children, the new move becomes a variation (appended at the end); use promote_variation afterwards to make it the mainline. SAN is validated against the position — illegal moves are rejected with a clear error.\n\n" +
515
+ "Auto-saves. Returns `{node_id, version}` — the id of the new node (pass this to follow-up set_comment / set_nags / etc.) and the new file version for optimistic locking. **Node ids are content-derived and stable** — sibling insertions, deletions, and promotions do NOT change any other node's id.",
516
+ inputSchema: {
517
+ type: "object",
518
+ properties: {
519
+ id: { type: "string", description: "Prep file id." },
520
+ parent_id: { type: "string", description: "Node id of the parent (the position the move is played FROM). Root id is 'r'." },
521
+ san: { type: "string", description: "The move in SAN notation (e.g. 'Nf3', 'exd5', 'O-O', 'Qxf7+')." },
522
+ expected_version: { type: "integer", description: "Optimistic-lock check; pass the `version` from your last read." },
523
+ },
524
+ required: ["id", "parent_id", "san"],
525
+ },
526
+ },
527
+ {
528
+ name: "add_line",
529
+ description: "Append a linear sequence of moves under `parent_id`. Each SAN in the list becomes the mainline child of the previous — one call instead of N add_move calls for a straight variation. If the parent already has other children, this whole line is appended as a variation (promote_variation the first move if you want it as the mainline).\n\n" +
530
+ "**Anti-pattern: pasting an engine PV as a single long `add_line`.** Real prep is a tree, not a line. Almost every position along a variation has more than one plausible move — pasting a 12+-ply engine PV without branching at those points is the #1 documented failure mode of this MCP: it produces a page that reads as prep but ignores every decision the opponent actually gets to make. Long unbranched lines get a warning field in the response starting at ~9 plies and a strong warning at 14+ plies. Rule of thumb: if you added ≥8 plies in one call, at least half of them should have branched. Genuine exceptions exist (forced mates, obligated exchange sequences) — in those cases add a comment naming what makes the sequence forced (`{Every move here is forced by the mate threat.}`), so the reader knows it's forced by chess, not by LLM laziness.\n\n" +
531
+ "Auto-saves. Returns `{node_id, line: [{node_id, san}, ...], version}` — `node_id` is the last (leaf) node's id, `line` is every node created in order so you can address any of them next. When long-and-linear, also includes `warning: \"...\"`.",
532
+ inputSchema: {
533
+ type: "object",
534
+ properties: {
535
+ id: { type: "string", description: "Prep file id." },
536
+ parent_id: { type: "string", description: "Node id of the parent to build from. Root is 'r'." },
537
+ sans: { type: "array", items: { type: "string" }, minItems: 1, description: "SAN moves in order, e.g. ['e4','e5','Nf3','Nc6','Bb5']." },
538
+ expected_version: { type: "integer" },
539
+ },
540
+ required: ["id", "parent_id", "sans"],
541
+ },
542
+ },
543
+ {
544
+ name: "set_comment",
545
+ description: "Set (or clear, with empty string) the text comment on the node identified by `node_id`. Comments are for plans, prep-signal, and interpretation the app can't derive — NOT for describing moves that should be variations instead. Auto-saves.\n\n" +
546
+ "**Two guardrails fire in the response as `warnings: [...]`:**\n" +
547
+ " 1. **Content scan** — comments containing spread lists (`≈50, ≈42, …`), raw centipawn values (`≈−60`, `+0.35`, `at depth 24`), or roster restatement (`146 GM games — Nakamura, …`) are all restating what the app already renders. The warning names the fix (set the NAG and drop the number; label the character not the numbers; cite a specific game instead of a count).\n" +
548
+ " 2. **Ungrounded prose** — substantive comments (≥40 chars) on a node whose position was never passed to `describe_position` this session are prone to hallucinated structural claims (piece on wrong square, invented captures, misidentified pawn structure). Call `describe_position` with `file_id`+`node_id` BEFORE writing prose about the position; the same node's warning clears once the position is described.",
549
+ inputSchema: {
550
+ type: "object",
551
+ properties: {
552
+ id: { type: "string", description: "Prep file id." },
553
+ node_id: { type: "string", description: "Node id from read_prep_file / add_move." },
554
+ comment: { type: "string", description: "New comment text. Empty string clears." },
555
+ expected_version: { type: "integer" },
556
+ },
557
+ required: ["id", "node_id", "comment"],
558
+ },
559
+ },
560
+ {
561
+ name: "set_nags",
562
+ description: "Replace the list of NAGs on the node identified by `node_id`. Empty array clears them. NAGs are your EDITORIAL call — see read_pgn_authoring_guide for the discipline (novelty $146, sharp choice $5, decisive $18/$19, etc.). Do NOT set $10 '=' on every equal position; that's board noise. Auto-saves.",
563
+ inputSchema: {
564
+ type: "object",
565
+ properties: {
566
+ id: { type: "string" },
567
+ node_id: { type: "string" },
568
+ nags: { type: "array", items: { type: "string", pattern: "^\\$\\d+$" }, description: "NAG list, e.g. ['$14'] or ['$146', '$44']." },
569
+ expected_version: { type: "integer" },
570
+ },
571
+ required: ["id", "node_id", "nags"],
572
+ },
573
+ },
574
+ {
575
+ name: "set_annotations",
576
+ description: "Replace the visual annotations (arrows + coloured squares) on the node identified by `node_id`. Passing empty arrays clears them.\n\n" +
577
+ "Colours: green, red, yellow, light-blue, dark-blue, orange. Keep it LIGHT: 1-3 arrows and 2-3 squares per move maximum. Twenty arrows is noise, not signal. Auto-saves.",
578
+ inputSchema: {
579
+ type: "object",
580
+ properties: {
581
+ id: { type: "string" },
582
+ node_id: { type: "string" },
583
+ arrows: {
584
+ type: "array",
585
+ items: {
586
+ type: "object",
587
+ properties: {
588
+ color: { type: "string", enum: ["green", "red", "yellow", "light-blue", "dark-blue", "orange"] },
589
+ from: { type: "string", pattern: "^[a-h][1-8]$" },
590
+ to: { type: "string", pattern: "^[a-h][1-8]$" },
591
+ },
592
+ required: ["color", "from", "to"],
593
+ },
594
+ },
595
+ highlights: {
596
+ type: "array",
597
+ items: {
598
+ type: "object",
599
+ properties: {
600
+ color: { type: "string", enum: ["green", "red", "yellow", "light-blue", "dark-blue", "orange"] },
601
+ square: { type: "string", pattern: "^[a-h][1-8]$" },
602
+ },
603
+ required: ["color", "square"],
604
+ },
605
+ },
606
+ expected_version: { type: "integer" },
607
+ },
608
+ required: ["id", "node_id"],
609
+ },
610
+ },
611
+ {
612
+ name: "delete_subtree",
613
+ description: "Delete the node identified by `node_id` and all its descendants. Refuses to delete the root. Auto-saves.",
614
+ inputSchema: {
615
+ type: "object",
616
+ properties: {
617
+ id: { type: "string" },
618
+ node_id: { type: "string" },
619
+ expected_version: { type: "integer" },
620
+ },
621
+ required: ["id", "node_id"],
622
+ },
623
+ },
624
+ {
625
+ name: "promote_variation",
626
+ description: "Make the node identified by `node_id` its parent's mainline (children[0]), demoting the current mainline (and any other siblings) into variation order. Silently no-op if already the mainline. Auto-saves.",
627
+ inputSchema: {
628
+ type: "object",
629
+ properties: {
630
+ id: { type: "string" },
631
+ node_id: { type: "string", description: "Node id of the variation to promote. Cannot be root." },
632
+ expected_version: { type: "integer" },
633
+ },
634
+ required: ["id", "node_id"],
635
+ },
636
+ },
637
+ {
638
+ name: "apply_mutations",
639
+ description: "Batch: apply a list of mutations in one call. One load-parse-mutate-export-save cycle for N ops, so building a 100-move repertoire costs one HTTP round-trip and one save instead of 100. This is the RIGHT way to build a file — use single mutations only for surgical follow-up edits.\n\n" +
640
+ "Each mutation is `{op, node_id | parent_id, ...args}` where `op` is one of: add_move, add_line, set_comment, set_nags, set_annotations, delete_subtree, promote_variation, set_tag. Same arg shape as the individual tools. Ops apply in order; because node ids are content-derived (hash of parent_id + san), a node created by an early op has a deterministic id you can reference in later ops in the same batch.\n\n" +
641
+ "Any op error aborts the batch (nothing saved). Response is `{ok, results: [{node_id, line?}], version}` — one entry per op with the id it landed on (add_line also returns the full line array).",
642
+ inputSchema: {
643
+ type: "object",
644
+ properties: {
645
+ id: { type: "string" },
646
+ expected_version: { type: "integer" },
647
+ mutations: {
648
+ type: "array",
649
+ minItems: 1,
650
+ items: {
651
+ type: "object",
652
+ properties: {
653
+ op: { type: "string", enum: ["add_move", "add_line", "set_comment", "set_nags", "set_annotations", "delete_subtree", "promote_variation", "set_tag"] },
654
+ node_id: { type: "string" },
655
+ parent_id: { type: "string" },
656
+ san: { type: "string" },
657
+ sans: { type: "array", items: { type: "string" } },
658
+ comment: { type: "string" },
659
+ nags: { type: "array", items: { type: "string", pattern: "^\\$\\d+$" } },
660
+ arrows: { type: "array" },
661
+ highlights: { type: "array" },
662
+ key: { type: "string" },
663
+ value: { type: "string" },
664
+ },
665
+ required: ["op"],
666
+ },
667
+ },
668
+ },
669
+ required: ["id", "mutations"],
670
+ },
671
+ },
672
+ {
673
+ name: "auto_evaluate",
674
+ description: "Walk the tree from `node_id` (default `'r'` = whole file) and populate the persistent `ceoEval` on every descendant via cloud_analyse. Requires a running cloud combo instance.\n\n" +
675
+ "**Async job — returns immediately.** Response: `{ job_id, target_count, status: 'running', estimated_seconds }`. Then poll `auto_evaluate_status(job_id)` until `done: true`. Cancel a run with `auto_evaluate_cancel(job_id)` — partial progress is preserved. Do useful other work between polls (write more of the tree, walk the opponent's repertoire) — the engine runs in the background.\n\n" +
676
+ "Progress is checkpointed to the prep file every 8 successfully-evaluated nodes, so a cancel / crash / MCP restart mid-run leaves the tree partially populated rather than losing everything. On MCP restart the job record disappears; re-run auto_evaluate and `only_missing=true` naturally skips what was already saved.\n\n" +
677
+ "**Does NOT set visible NAGs.** NAG placement is your call, not the engine's — an opening tree full of 0.00 positions doesn't need a `$10` (=) glyph on every move. Use quote_engine_eval on individual nodes before writing prose that references engine numbers.\n\n" +
678
+ "Costs real money — one cloud_analyse per node. A 200-node walk at default movetime is ~5 min of engine time (calls serialise on the per-combo semaphore in the backend).",
679
+ inputSchema: {
680
+ type: "object",
681
+ properties: {
682
+ id: { type: "string" },
683
+ node_id: { type: "string", description: "Subtree root (default 'r' = whole file)." },
684
+ only_missing: { type: "boolean", description: "Skip nodes that already carry a stored ceoEval (default true)." },
685
+ movetime_ms: { type: "integer", minimum: 500, maximum: 5000, description: "Per-node cloud_analyse think time (default 1500)." },
686
+ },
687
+ required: ["id"],
688
+ },
689
+ },
690
+ {
691
+ name: "auto_evaluate_status",
692
+ description: "Poll the status of an auto_evaluate job. Response: `{ status: 'running' | 'done' | 'cancelled' | 'error' | 'not_found', target_count, evaluated, errored, remaining, done, error?, version? }`. When `status: 'not_found'` the job either expired (kept ~15 min after completion), never existed, or the MCP restarted since it was created — re-run auto_evaluate.\n\n" +
693
+ "Typical poll cadence: every 3-5 s for small walks, every 10-30 s for large ones. Don't hammer — status is a pure in-memory read but polling doesn't speed the engine up.",
694
+ inputSchema: {
695
+ type: "object",
696
+ properties: {
697
+ job_id: { type: "string", description: "Job id from the `auto_evaluate` response." },
698
+ },
699
+ required: ["job_id"],
700
+ },
701
+ },
702
+ {
703
+ name: "auto_evaluate_cancel",
704
+ description: "Ask a running auto_evaluate job to stop as soon as its current node finishes. Whatever progress was completed before cancellation is durably saved (checkpoint on cancel). Idempotent — cancelling an already-finished job is a no-op with a clear note in the response.",
705
+ inputSchema: {
706
+ type: "object",
707
+ properties: {
708
+ job_id: { type: "string", description: "Job id from the `auto_evaluate` response." },
709
+ },
710
+ required: ["job_id"],
711
+ },
712
+ },
713
+ {
714
+ name: "deep_analyse",
715
+ description: "Start a long Stockfish think on a single position (up to 5 min movetime). Returns a `job_id` immediately; poll `deep_analyse_status(job_id)` for the result, cancel with `deep_analyse_cancel(job_id)`. Runs SF only — Lc0 doesn't benefit from long thinks past a handful of seconds — and **holds only the SF engine slot on the combo, so `cloud_analyse(..., engines: [\"lc0\"])` stays available for other work in parallel**.\n\n" +
716
+ "Use this when a specific critical position deserves depth — a novelty candidate, a hairy tactical shot, a difficult endgame — and you want Stockfish at depth 35+ rather than the ~depth 22 you get from a 2s cloud_analyse. Movetime is in ms; typical: 30_000-60_000 for 'careful check', 120_000-300_000 for 'find the truth'.\n\n" +
717
+ "Result shape when done matches cloud_analyse's Stockfish leg (depth, top-N candidates with scoreCp/mate, best move, PV). Auto-stores the eval on `file_id`+`node_id` when both are supplied, same as cloud_analyse.",
718
+ inputSchema: {
719
+ type: "object",
720
+ properties: {
721
+ file_id: { type: "string", description: "Prep file id. Combine with `node_id` to derive FEN from the tree AND persist the result on the node's ceoEval." },
722
+ node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`/`moves`." },
723
+ fen: { type: "string", description: "Position as FEN. Only used if `node_id` is not set." },
724
+ moves: { type: "string", description: "Optional SAN moves on top of `fen`. Only used if `node_id` is not set." },
725
+ movetime_ms: {
726
+ type: "integer",
727
+ minimum: 5_000,
728
+ maximum: 300_000,
729
+ description: "Think time in ms. Default 60_000 (1 min). Max 300_000 (5 min).",
730
+ },
731
+ multipv: {
732
+ type: "integer",
733
+ minimum: 1,
734
+ maximum: 10,
735
+ description: "Number of candidate lines (default 2). Stockfish gets weaker as multipv grows — each extra PV steals search bandwidth from the top choice — so keep this low unless you specifically want to see several candidates ranked deep.",
736
+ },
737
+ },
738
+ },
739
+ },
740
+ {
741
+ name: "deep_analyse_status",
742
+ description: "Poll a deep_analyse job. Response: `{ status: 'running' | 'done' | 'cancelled' | 'error' | 'not_found', elapsed_ms, movetime_ms, result?, error? }`. `result` shape when done: `{ engine, depth, timeMs, bestMove, lines: [{rank, depth, scoreCp?, mate?, pv, nodes?}] }` — the SF leg of a cloud_analyse response.\n\n" +
743
+ "Poll cadence: every ~15-30s for long thinks; there's no penalty for polling more often but the engine progresses at its own pace.",
744
+ inputSchema: {
745
+ type: "object",
746
+ properties: {
747
+ job_id: { type: "string", description: "Job id from `deep_analyse`." },
748
+ },
749
+ required: ["job_id"],
750
+ },
751
+ },
752
+ {
753
+ name: "deep_analyse_cancel",
754
+ description: "Ask a running deep_analyse job to stop early. The engine returns whatever it's found so far as the final result. Useful when a partial result at depth 25 is enough and you don't want to wait for depth 40. Idempotent for already-finished jobs.",
755
+ inputSchema: {
756
+ type: "object",
757
+ properties: {
758
+ job_id: { type: "string", description: "Job id from `deep_analyse`." },
759
+ },
760
+ required: ["job_id"],
761
+ },
762
+ },
763
+ {
764
+ name: "find_position_in_courses",
765
+ description: "Look up which of the USER's own Chessable / PGN courses cover a position. This is the LLM's window into what the user has personally studied — not a general database. Two-step: `find_position_in_courses` returns metadata (course, chapter, author, updated_at, notes_chars, `course_file_id`); `read_course_at_position` fetches the actual commentary + variations from a specific hit.\n\n" +
766
+ "**Read multiple hits, not just the top one.** A search commonly returns 3-10 courses covering the same position. Different authors recommend different moves, weight lines differently, and disagree about which sidelines matter — that disagreement is exactly the information you want. Default assumption: read the top 3-5 hits by recency, more if the position is critical (novelty candidate, main-line trunk, sharp tactical junction). Reading only the first hit gives you one author's opinion; reading five gives you the actual state of theory as your user's library sees it.\n\n" +
767
+ "Use it as a reference library, not memory. Query patterns:\n" +
768
+ " • 'Does my chosen line have coverage?' → search from the position, read multiple hits, see whether the field agrees on the main response.\n" +
769
+ " • 'What do opposite-colour repertoires recommend against this move?' → search, then read every hit whose author/course maps to the other side.\n" +
770
+ " • 'Has anyone tried my novelty before?' → search the position, if hits exist read all of them (a novelty that appears in ONE 2019 course is still a novelty to serious opponents; a novelty covered by three 2025 courses is not).\n" +
771
+ " • 'What are the main disagreements between authors?' → read the top 3-5 hits, diff the recommended moves against each other; if two Chessable authors branch differently at move 8, that's a decision point worth annotating in your own file.\n\n" +
772
+ "Default sort is `recency` (most-recently-updated file first — theory shifts, 10-year-old material is less trustworthy than 2-month-old). Switch to `notes` when you specifically want the deepest annotated chapter regardless of age.\n\n" +
773
+ "Returns: `{fen, found, total_occurrences, sort, excluded, hits: [{course_file_id, course, file, author, chapter, line, ply, notes_chars, subtree_moves, updated_at}], truncated}`. Pass `course_file_id` to `read_course_at_position` to actually see the material — and pass it more than once, on the top few hits, not just the first one.\n\n" +
774
+ "Not available if the fenfind index isn't installed on the server — response includes a clear note in that case.",
775
+ inputSchema: {
776
+ type: "object",
777
+ properties: {
778
+ file_id: { type: "string", description: "Prep file id. Combine with `node_id` to derive FEN from the tree." },
779
+ node_id: { type: "string", description: "Node id inside `file_id`. Root is 'r'. When set, overrides `fen`/`moves`." },
780
+ fen: { type: "string", description: "Starting position as FEN. Only used if `node_id` is not set." },
781
+ moves: { type: "string", description: "Optional SAN moves on top of `fen` (or startpos). Only used if `node_id` is not set." },
782
+ sort: { type: "string", enum: ["recency", "notes"], description: "Ranking. `recency` (default) = most-recently-updated file first. `notes` = deepest annotation first regardless of age." },
783
+ include_games: { type: "boolean", description: "Include hits from game-database PGNs (player headers instead of course/chapter titles). Default false — those are noise for course-lookup." },
784
+ chapters_mode: { type: "boolean", description: "Return every chapter separately rather than best-per-course. Default false. Useful when a course has multiple chapters covering the same position." },
785
+ min_notes_chars: { type: "number", description: "Minimum notes_chars per hit to be included. Default 400 (~a paragraph of prose). Set to 0 to see every occurrence." },
786
+ limit: { type: "integer", description: "Max hits to return (default 25)." },
787
+ },
788
+ },
789
+ },
790
+ {
791
+ name: "read_course_at_position",
792
+ description: "Read the actual commentary + variations from a course file at a specific position. Second half of the find→read pair — `find_position_in_courses` returns metadata; this returns the material itself.\n\n" +
793
+ "Response includes the subtree as PGN (comments, NAGs, `[%cal]`/`[%csl]` arrows all preserved), plus the moves-to-position and chapter metadata. Depth-capped by `max_plies_below` (default 20) to keep responses small — widen when you want to see deeper analysis, or call with a different `fen` to jump to another position in the same file.\n\n" +
794
+ "**Called once per search is a smell.** When `find_position_in_courses` returned 5 hits and you only read the first, you have 1 author's view of the position, not a survey. Read the top 3-5 hits by default; compare their recommendations and disagreements — that comparison is the value the user's library provides over your training data.\n\n" +
795
+ "Usage patterns:\n" +
796
+ " • Read what an author says about a specific position → pass `course_file_id` from a find hit + the FEN.\n" +
797
+ " • Explore a chapter from move 1 → pass `course_file_id` + `chapter`, no FEN.\n" +
798
+ " • Skim deeper into a branch you're interested in → same file/chapter, wider `max_plies_below`.\n" +
799
+ " • **Compare how multiple authors annotate the same position → several calls with different `course_file_id`s (this is the common case, not the exception).** If the top hits recommend different moves, that's a decision point worth annotating with the disagreement itself.",
800
+ inputSchema: {
801
+ type: "object",
802
+ properties: {
803
+ course_file_id: { type: "integer", description: "File id from a `find_position_in_courses` hit (`course_file_id` field)." },
804
+ fen: { type: "string", description: "Position to walk to (matched by polyglot Zobrist hash, so move-order transpositions work). Omit to return the chapter from move 1." },
805
+ moves: { type: "string", description: "Alternative to `fen`: SAN moves from startpos." },
806
+ chapter: { type: "string", description: "Substring match on chapter title (the White header in the PGN). Omit to auto-pick the first chapter containing the position; supply when a course has multiple chapters and you want a specific one." },
807
+ max_plies_below: { type: "integer", minimum: 0, maximum: 200, description: "How many plies of subtree to include below the target position. Default 20. Cap 200." },
808
+ },
809
+ required: ["course_file_id"],
810
+ },
811
+ },
812
+ {
813
+ name: "quote_engine_eval",
814
+ description: "Return the stored engine eval for a node, or null if that node was never analysed. **Call this before writing prose or NAGs that quote engine numbers** — if it returns null, you have no measurement to cite. Do NOT infer an eval for the node from siblings or children; either analyse it (cloud_analyse with node_id) or omit the number from your prose.\n\n" +
815
+ "Response: `{ ceoEval: { sf: {cp, depth}, lc0: {cp, depth}, nag } | null }`. `cp` is White-POV centipawns as an integer (+20 = +0.20). `nag` is the threshold-derived glyph as a SUGGESTION — promote to a visible NAG via set_nags only when a glyph on that move carries editorial signal.",
816
+ inputSchema: {
817
+ type: "object",
818
+ properties: {
819
+ id: { type: "string", description: "Prep file id." },
820
+ node_id: { type: "string", description: "Node id whose stored eval you want to quote." },
821
+ },
822
+ required: ["id", "node_id"],
823
+ },
824
+ },
825
+ {
826
+ name: "set_tag",
827
+ description: "Set or clear a game-level PGN tag (Event, Site, Date, White, Black, Result, or any custom tag). Passing empty string removes the tag. Auto-saves.",
828
+ inputSchema: {
829
+ type: "object",
830
+ properties: {
831
+ id: { type: "string" },
832
+ key: { type: "string", description: "Tag key, e.g. 'Event', 'White', 'Date'." },
833
+ value: { type: "string", description: "Tag value. Empty string removes the tag." },
834
+ expected_version: { type: "integer" },
835
+ },
836
+ required: ["id", "key", "value"],
837
+ },
838
+ },
839
+ {
840
+ name: "read_engine_usage_guide",
841
+ description: "Returns the full chess.ceo engine-usage guide: when to trust Stockfish (objective truth) vs Lc0 (practical eval), how to read disagreements between them, and how to use Lc0 contempt to find non-objective 'practical' ideas. Call this ONCE per session before running expensive `cloud_analyse` calls or when the user asks WHY the engines gave certain scores. Same content is also available as the `engine_usage_primer` prompt (for clients that surface prompts as slash commands), but many clients do not expose prompts to the model — this tool works everywhere.",
842
+ inputSchema: { type: "object", properties: {} },
843
+ },
844
+ {
845
+ name: "read_opening_prep_guide",
846
+ description: "**CALL WHEN**: the user asks about OPENING PREPARATION — 'prep me against X', 'what should I play vs the Najdorf', 'help me build a repertoire against 1.e4', 'walk this opponent's Sveshnikov'. This guide is chess-and-analysis philosophy, not storage semantics.\n\n" +
847
+ "Covers: why win% is one weight not a verdict, why prep is a two-player game with symmetric information (opponent sees your history too), how sample size and recency change the reading, when 'revealed weaknesses' are actionable vs already patched, how to choose between the GM-classical DB and the main DB, when to combine chesscom/lichess sources with FIDE, the three chess.com profile shapes (consistent / eclectic / split-personality), the reversed-colours scarcity trick, how to calibrate surprise (rare secondary lines inside the existing repertoire, not big first-move switches).\n\n" +
848
+ "Different tool: `read_prep_files_guide` covers the FILE STORAGE feature (how to list/create/save prep files) — call that only when about to manipulate files, not for opening questions.",
849
+ inputSchema: { type: "object", properties: {} },
850
+ },
851
+ {
852
+ name: "read_prep_files_guide",
853
+ description: "**CALL WHEN**: you're about to CREATE, LIST, SAVE, or DELETE a prep file — the persistent file storage feature. Not for opening prep philosophy (that's `read_opening_prep_guide`) and not for how to write PGN (that's `read_pgn_authoring_guide`).\n\n" +
854
+ "Covers: the AI Prep folder, when to list vs search vs create (avoid duplicate 'Prep vs Firouzja' files), optimistic locking with `version`, naming conventions for the [Event] tag, node-id addressing basics.",
855
+ inputSchema: { type: "object", properties: {} },
856
+ },
857
+ {
858
+ name: "read_pgn_authoring_guide",
859
+ description: "Returns the guide on how to write correct, useful PGN — mainline discipline, variations as moves (never prose describing moves), NAG symbols including novelty ($146), unclear ($13), compensation ($44) and the standard set, ChessBase arrow/coloured-square syntax ([%cal] / [%csl]), and common pitfalls the parser will reject. Call this ONCE per session before any save_prep_file call, or any time you're producing PGN output for the user.",
860
+ inputSchema: { type: "object", properties: {} },
861
+ },
862
+ {
863
+ name: "read_example_prep_files",
864
+ description: "**CALL WHEN**: about to write ANY prose commentary in a prep file, ever. Even one comment. Even one variation. This is not optional and not once-per-project — call it early in the session and read the examples before your first `set_comment` or `apply_mutations` batch that includes comments. Log analysis showed <5% of sessions call this despite it being the single biggest quality lift documented in this MCP; that's the mistake this description is trying to fix.\n\n" +
865
+ "Why: `read_pgn_authoring_guide` tells you the rules in prose. These files show you the *sound* of them applied by a strong human coach — comment density (short and load-bearing, not verbose), how citations look in-line (`WeiYi-Svidler` not `\"Svidler's choice at the FIDE World Blitz Team, June 2026\"`), when `$146` / `$3` / `$44` earn their place, when a bare `[%csl Rf7]` says everything a sentence would say. LLMs default to florid, restate-what's-visible commentary; reading these once inoculates against that.\n\n" +
866
+ "Two files bundled with the MCP (not the user's own): one general opening overview (Italian Fried Liver, both sides, 1600+ audience) and one one-sided repertoire (Najdorf 6.f4 for White, 2200+ audience). Response: `{ overview: <pgn>, repertoire: <pgn> }` — raw PGN with comments, arrows, NAGs, stored evals intact.",
867
+ inputSchema: { type: "object", properties: {} },
868
+ },
869
+ {
870
+ name: "prep_snapshot",
871
+ description: "One call, three parallel fetches at the same position: opponent's stats on their side, your stats on your side, and the 11.7M-game general database at that position. Use this while walking the opening tree — one round trip instead of three separate calls, and you can compare the three views directly (e.g. opponent has 2 games here but the general DB has 8k → prep candidate).\n\n" +
872
+ "AUTO-EVAL: if a cloud combo instance is running, the response includes a top-level `.eval` (Stockfish + Lc0 read at the shared position) so you get four signals in one call. Do NOT fire cloud_analyse separately for the same FEN.",
873
+ inputSchema: {
874
+ type: "object",
875
+ properties: {
876
+ fide_id_me: { type: "integer", description: "Your FIDE ID." },
877
+ fide_id_opponent: { type: "integer", description: "Opponent's FIDE ID." },
878
+ my_color: { type: "string", enum: ["white", "black"], description: "The colour YOU will play." },
879
+ file_id: { type: "string", description: "Prep file id. **Prefer file_id+node_id** when inside a prep file." },
880
+ node_id: { type: "string", description: "Node id inside `file_id`. When set, overrides `line`/`fen`." },
881
+ line: {
882
+ type: "string",
883
+ description: "Move sequence in SAN, space-separated. Empty = starting position. Only used if `node_id` is not set.",
884
+ },
885
+ fen: {
886
+ type: "string",
887
+ description: "Alternative to line — raw FEN of the target position. Only used if `node_id` is not set.",
888
+ },
889
+ },
890
+ required: ["fide_id_me", "fide_id_opponent", "my_color"],
891
+ },
892
+ },
893
+ ];