llm-chess-mcp 0.4.1 → 0.4.4
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 +6 -3
- package/dist/chess.js +17 -0
- package/dist/explorer.js +123 -94
- package/dist/games.js +41 -15
- package/dist/http.js +100 -98
- package/dist/intents.js +28 -28
- package/dist/services.js +14 -5
- package/dist/tool-meta.js +2 -2
- package/dist/tool-names.js +15 -0
- package/dist/tool-schemas.js +6 -1
- package/dist/tools/analysis.js +10 -8
- package/dist/tools/candidates.js +29 -45
- package/dist/tools/explorer.js +1 -3
- package/dist/tools/game.js +7 -8
- package/docs/architecture.md +4 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -279,7 +279,7 @@ codex mcp add llm-chess-mcp --command npx --args -y llm-chess-mcp --env LICHESS_
|
|
|
279
279
|
| `game_legal_moves` | All legal moves with metadata |
|
|
280
280
|
| `game_pgn` | Export the game as PGN |
|
|
281
281
|
| `game_import_pgn` | Import a PGN into a new game |
|
|
282
|
-
| `position_analyze` | Stockfish multipv lines (cp/mate/WDL + PV), `analysis_level` preset |
|
|
282
|
+
| `position_analyze` | Stockfish multipv lines (cp/mate/WDL + UCI/SAN PV), `analysis_level` preset |
|
|
283
283
|
| `human_move_distribution` | Maia3 human-move probabilities at a target Elo |
|
|
284
284
|
| `move_evaluate` | Score one or more moves + cpLoss + classification |
|
|
285
285
|
| `move_candidates` | **Primary tool**: unified candidates (objective + human + opening) |
|
|
@@ -307,6 +307,9 @@ human-readable summary and must not be parsed as data.
|
|
|
307
307
|
`best / excellent / good / inaccuracy / mistake / blunder`.
|
|
308
308
|
- `maia3Prob` is a **human-likelihood**, not move quality. A high-probability move
|
|
309
309
|
can still be objectively bad.
|
|
310
|
+
- Analysis continuations return `pv` in UCI and the same legal prefix in
|
|
311
|
+
`pvSan` as SAN. If an engine line contains an invalid move, `pvSan` stops
|
|
312
|
+
before it while the original `pv` remains unchanged.
|
|
310
313
|
|
|
311
314
|
## Candidate structure
|
|
312
315
|
|
|
@@ -370,8 +373,8 @@ rejected:
|
|
|
370
373
|
- `move_evaluate` accepts at most 10 moves per call.
|
|
371
374
|
- Imported PGNs are limited to 1 MiB and 4,096 plies.
|
|
372
375
|
- Stockfish accepts up to 32 active or queued analyses.
|
|
373
|
-
- HTTP retains at most 64 MCP sessions; sessions with no
|
|
374
|
-
after 30 minutes.
|
|
376
|
+
- HTTP retains at most 64 MCP sessions; sessions with no active request expire
|
|
377
|
+
after 30 minutes. An open GET/SSE stream keeps its session active.
|
|
375
378
|
- HTTP accepts bodies up to 2 MiB. It permits 16 concurrent POSTs and downstream
|
|
376
379
|
compute/network jobs process-wide, with two of each per session. Work keeps
|
|
377
380
|
its slot after a raw disconnect until it settles. HTTP also caps connections
|
package/dist/chess.js
CHANGED
|
@@ -81,3 +81,20 @@ export function parseMove(chess, move) {
|
|
|
81
81
|
export function playParsedMove(chess, move) {
|
|
82
82
|
return chess.move(moveDescriptor(move));
|
|
83
83
|
}
|
|
84
|
+
export function pvToSan(chess, pv) {
|
|
85
|
+
const copy = new Chess(chess.fen());
|
|
86
|
+
const san = [];
|
|
87
|
+
for (const uci of pv) {
|
|
88
|
+
try {
|
|
89
|
+
const move = parseMove(copy, uci);
|
|
90
|
+
san.push(move.san);
|
|
91
|
+
playParsedMove(copy, move);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (!(error instanceof ChessError))
|
|
95
|
+
throw error;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return san;
|
|
100
|
+
}
|
package/dist/explorer.js
CHANGED
|
@@ -128,7 +128,7 @@ async function sleepWithSignal(sleep, ms, signal) {
|
|
|
128
128
|
});
|
|
129
129
|
});
|
|
130
130
|
}
|
|
131
|
-
|
|
131
|
+
function setupExplorerRequest(chess, db, speeds, ratings, options) {
|
|
132
132
|
const callerSignal = options.signal;
|
|
133
133
|
throwIfAborted(callerSignal);
|
|
134
134
|
const token = options.token ?? process.env.LICHESS_TOKEN ?? "";
|
|
@@ -145,120 +145,149 @@ export async function openingExplorer(chess, db, speeds, ratings, options = {})
|
|
|
145
145
|
if (db === "masters" && (speeds.length > 0 || ratings.length > 0)) {
|
|
146
146
|
throw error("invalid_input");
|
|
147
147
|
}
|
|
148
|
-
const request = options.fetch ?? globalThis.fetch;
|
|
149
|
-
const sleep = options.sleep ??
|
|
150
|
-
((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
151
|
-
const timeout = options.timeout ?? ((ms) => AbortSignal.timeout(ms));
|
|
152
|
-
const now = options.now ?? Date.now;
|
|
153
|
-
const deadline = now() + EXPLORER_TOTAL_TIMEOUT_MS;
|
|
154
148
|
const params = new URLSearchParams();
|
|
155
149
|
params.set("fen", chess.fen());
|
|
156
150
|
if (speeds.length)
|
|
157
151
|
params.set("speeds", speeds.join(","));
|
|
158
152
|
if (ratings.length)
|
|
159
153
|
params.set("ratings", ratings.join(","));
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
154
|
+
const now = options.now ?? Date.now;
|
|
155
|
+
return {
|
|
156
|
+
callerSignal,
|
|
157
|
+
db,
|
|
158
|
+
deadline: now() + EXPLORER_TOTAL_TIMEOUT_MS,
|
|
159
|
+
legalMoves: new Map(chess.moves({ verbose: true }).map((move) => [move.lan, move.san])),
|
|
160
|
+
now,
|
|
161
|
+
request: options.fetch ?? globalThis.fetch,
|
|
162
|
+
sleep: options.sleep ??
|
|
163
|
+
((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
|
|
164
|
+
timeout: options.timeout ?? ((ms) => AbortSignal.timeout(ms)),
|
|
165
|
+
token,
|
|
166
|
+
url: `${BASE}/${db}?${params}`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
async function attemptRequest(setup) {
|
|
170
|
+
const { callerSignal, deadline, now, request, timeout, token, url } = setup;
|
|
171
|
+
throwIfAborted(callerSignal);
|
|
172
|
+
const remaining = deadline - now();
|
|
173
|
+
if (remaining <= 0)
|
|
174
|
+
throw error("timeout");
|
|
175
|
+
const attemptSignal = timeout(Math.max(1, Math.min(EXPLORER_ATTEMPT_TIMEOUT_MS, remaining)));
|
|
176
|
+
const signal = AbortSignal.any(callerSignal ? [callerSignal, attemptSignal] : [attemptSignal]);
|
|
177
|
+
let response;
|
|
178
|
+
try {
|
|
179
|
+
response = await request(url, {
|
|
180
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
181
|
+
signal,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
164
185
|
throwIfAborted(callerSignal);
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
186
|
+
return { error: error(signal.aborted ? "timeout" : "network") };
|
|
187
|
+
}
|
|
188
|
+
throwIfAborted(callerSignal);
|
|
189
|
+
if (response.ok)
|
|
190
|
+
return { response, signal };
|
|
191
|
+
const kind = response.status === 401 || response.status === 403
|
|
192
|
+
? "auth"
|
|
193
|
+
: response.status === 429
|
|
194
|
+
? "rate_limited"
|
|
195
|
+
: response.status >= 500 && response.status <= 599
|
|
196
|
+
? "upstream"
|
|
197
|
+
: "http";
|
|
198
|
+
return {
|
|
199
|
+
error: error(kind, response.status),
|
|
200
|
+
retryAfter: response.headers.get("retry-after"),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
async function normalizeSuccessfulResponse(response, signal, setup) {
|
|
204
|
+
let body;
|
|
205
|
+
try {
|
|
206
|
+
body = await response.json();
|
|
207
|
+
}
|
|
208
|
+
catch (cause) {
|
|
209
|
+
throwIfAborted(setup.callerSignal);
|
|
210
|
+
if (signal.aborted || cause instanceof TypeError) {
|
|
211
|
+
throw error(signal.aborted ? "timeout" : "network");
|
|
176
212
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
213
|
+
throw error("invalid_response");
|
|
214
|
+
}
|
|
215
|
+
throwIfAborted(setup.callerSignal);
|
|
216
|
+
const parsed = responseSchema.safeParse(body);
|
|
217
|
+
if (!parsed.success)
|
|
218
|
+
throw error("invalid_response");
|
|
219
|
+
const data = parsed.data;
|
|
220
|
+
const ucis = new Set();
|
|
221
|
+
let white = 0;
|
|
222
|
+
let draws = 0;
|
|
223
|
+
let black = 0;
|
|
224
|
+
for (const move of data.moves) {
|
|
225
|
+
if (ucis.has(move.uci) || setup.legalMoves.get(move.uci) !== move.san) {
|
|
226
|
+
throw error("invalid_response");
|
|
185
227
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
228
|
+
ucis.add(move.uci);
|
|
229
|
+
white += move.white;
|
|
230
|
+
draws += move.draws;
|
|
231
|
+
black += move.black;
|
|
232
|
+
}
|
|
233
|
+
if (white > data.white || draws > data.draws || black > data.black) {
|
|
234
|
+
throw error("invalid_response");
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
db: setup.db,
|
|
238
|
+
white: data.white,
|
|
239
|
+
draws: data.draws,
|
|
240
|
+
black: data.black,
|
|
241
|
+
moves: data.moves.map((move) => ({
|
|
242
|
+
uci: move.uci,
|
|
243
|
+
san: move.san,
|
|
244
|
+
white: move.white,
|
|
245
|
+
draws: move.draws,
|
|
246
|
+
black: move.black,
|
|
247
|
+
count: move.white + move.draws + move.black,
|
|
248
|
+
averageRating: move.averageRating ?? null,
|
|
249
|
+
})),
|
|
250
|
+
opening: data.opening ?? null,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
async function retryAfterNetworkFailure(setup) {
|
|
254
|
+
const delay = Math.min(EXPLORER_DEFAULT_RETRY_DELAY_MS, Math.max(0, setup.deadline - setup.now()));
|
|
255
|
+
await sleepWithSignal(setup.sleep, delay, setup.callerSignal);
|
|
256
|
+
}
|
|
257
|
+
export async function openingExplorer(chess, db, speeds, ratings, options = {}) {
|
|
258
|
+
const setup = setupExplorerRequest(chess, db, speeds, ratings, options);
|
|
259
|
+
let lastError = error("network");
|
|
260
|
+
for (let attempt = 0; attempt < EXPLORER_MAX_ATTEMPTS; attempt += 1) {
|
|
261
|
+
const attemptResult = await attemptRequest(setup);
|
|
262
|
+
if ("error" in attemptResult) {
|
|
263
|
+
lastError = attemptResult.error;
|
|
264
|
+
if (!isRetryable(lastError.kind) || attempt + 1 >= EXPLORER_MAX_ATTEMPTS) {
|
|
197
265
|
throw lastError;
|
|
198
266
|
}
|
|
199
|
-
|
|
200
|
-
|
|
267
|
+
if (attemptResult.retryAfter === undefined) {
|
|
268
|
+
await retryAfterNetworkFailure(setup);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const delay = retryAfterMs(attemptResult.retryAfter, setup.now());
|
|
272
|
+
const retryBudget = Math.max(0, setup.deadline - setup.now());
|
|
201
273
|
if (delay > EXPLORER_MAX_RETRY_DELAY_MS || delay >= retryBudget) {
|
|
202
274
|
throw lastError;
|
|
203
275
|
}
|
|
204
|
-
await sleepWithSignal(sleep, delay, callerSignal);
|
|
276
|
+
await sleepWithSignal(setup.sleep, delay, setup.callerSignal);
|
|
205
277
|
continue;
|
|
206
278
|
}
|
|
207
|
-
let body;
|
|
208
279
|
try {
|
|
209
|
-
|
|
280
|
+
return await normalizeSuccessfulResponse(attemptResult.response, attemptResult.signal, setup);
|
|
210
281
|
}
|
|
211
282
|
catch (cause) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
lastError = error(signal.aborted ? "timeout" : "network");
|
|
215
|
-
if (attempt + 1 < EXPLORER_MAX_ATTEMPTS) {
|
|
216
|
-
const delay = Math.min(EXPLORER_DEFAULT_RETRY_DELAY_MS, Math.max(0, deadline - now()));
|
|
217
|
-
await sleepWithSignal(sleep, delay, callerSignal);
|
|
218
|
-
continue;
|
|
219
|
-
}
|
|
220
|
-
throw lastError;
|
|
283
|
+
if (!(cause instanceof ExplorerError) || !isRetryable(cause.kind)) {
|
|
284
|
+
throw cause;
|
|
221
285
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (!parsed.success)
|
|
227
|
-
throw error("invalid_response");
|
|
228
|
-
const data = parsed.data;
|
|
229
|
-
const ucis = new Set();
|
|
230
|
-
let white = 0;
|
|
231
|
-
let draws = 0;
|
|
232
|
-
let black = 0;
|
|
233
|
-
for (const move of data.moves) {
|
|
234
|
-
if (ucis.has(move.uci) ||
|
|
235
|
-
legalMoves.get(move.uci) !== move.san) {
|
|
236
|
-
throw error("invalid_response");
|
|
237
|
-
}
|
|
238
|
-
ucis.add(move.uci);
|
|
239
|
-
white += move.white;
|
|
240
|
-
draws += move.draws;
|
|
241
|
-
black += move.black;
|
|
242
|
-
}
|
|
243
|
-
if (white > data.white || draws > data.draws || black > data.black) {
|
|
244
|
-
throw error("invalid_response");
|
|
286
|
+
lastError = cause;
|
|
287
|
+
if (attempt + 1 >= EXPLORER_MAX_ATTEMPTS)
|
|
288
|
+
throw lastError;
|
|
289
|
+
await retryAfterNetworkFailure(setup);
|
|
245
290
|
}
|
|
246
|
-
return {
|
|
247
|
-
db,
|
|
248
|
-
white: data.white,
|
|
249
|
-
draws: data.draws,
|
|
250
|
-
black: data.black,
|
|
251
|
-
moves: data.moves.map((move) => ({
|
|
252
|
-
uci: move.uci,
|
|
253
|
-
san: move.san,
|
|
254
|
-
white: move.white,
|
|
255
|
-
draws: move.draws,
|
|
256
|
-
black: move.black,
|
|
257
|
-
count: move.white + move.draws + move.black,
|
|
258
|
-
averageRating: move.averageRating ?? null,
|
|
259
|
-
})),
|
|
260
|
-
opening: data.opening ?? null,
|
|
261
|
-
};
|
|
262
291
|
}
|
|
263
292
|
throw lastError;
|
|
264
293
|
}
|
package/dist/games.js
CHANGED
|
@@ -1,8 +1,29 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { Chess } from "chess.js";
|
|
3
|
+
import { playParsedMove, snapshotChess } from "./chess.js";
|
|
3
4
|
import { ChessError } from "./errors.js";
|
|
4
5
|
export const MAX_GAMES = 1_000;
|
|
5
6
|
export const GAME_TTL_MS = 60 * 60 * 1_000;
|
|
7
|
+
function cloneChess(chess) {
|
|
8
|
+
const copy = snapshotChess(chess);
|
|
9
|
+
for (const [key, value] of Object.entries(chess.getHeaders())) {
|
|
10
|
+
copy.setHeader(key, value);
|
|
11
|
+
}
|
|
12
|
+
const comments = new Map(chess.getComments().map(({ fen, comment }) => [fen, comment]));
|
|
13
|
+
const reversed = [];
|
|
14
|
+
while (true) {
|
|
15
|
+
const comment = comments.get(copy.fen());
|
|
16
|
+
if (comment !== undefined)
|
|
17
|
+
copy.setComment(comment);
|
|
18
|
+
const move = copy.undo();
|
|
19
|
+
if (!move)
|
|
20
|
+
break;
|
|
21
|
+
reversed.push(move);
|
|
22
|
+
}
|
|
23
|
+
for (const move of reversed.reverse())
|
|
24
|
+
playParsedMove(copy, move);
|
|
25
|
+
return copy;
|
|
26
|
+
}
|
|
6
27
|
export class GameStore {
|
|
7
28
|
maxGames;
|
|
8
29
|
idleTtlMs;
|
|
@@ -44,10 +65,28 @@ export class GameStore {
|
|
|
44
65
|
if (this.games.has(id)) {
|
|
45
66
|
throw new ChessError("GAME_ID_COLLISION", `game ID already exists: ${id}`);
|
|
46
67
|
}
|
|
47
|
-
this.games.set(id, {
|
|
68
|
+
this.games.set(id, {
|
|
69
|
+
chess: cloneChess(chess),
|
|
70
|
+
createdAt: now,
|
|
71
|
+
lastAccessedAt: now,
|
|
72
|
+
revision: 0,
|
|
73
|
+
});
|
|
48
74
|
return id;
|
|
49
75
|
}
|
|
50
|
-
|
|
76
|
+
getSnapshot(id) {
|
|
77
|
+
const game = this.getLiveGame(id);
|
|
78
|
+
return { chess: cloneChess(game.chess), revision: game.revision };
|
|
79
|
+
}
|
|
80
|
+
applyMove(id, expectedRevision, move) {
|
|
81
|
+
const game = this.getLiveGame(id);
|
|
82
|
+
if (expectedRevision !== game.revision) {
|
|
83
|
+
throw new ChessError("STALE_POSITION", `position changed: expected revision ${expectedRevision}, current ${game.revision}`);
|
|
84
|
+
}
|
|
85
|
+
playParsedMove(game.chess, move);
|
|
86
|
+
game.revision += 1;
|
|
87
|
+
return { chess: cloneChess(game.chess), revision: game.revision };
|
|
88
|
+
}
|
|
89
|
+
getLiveGame(id) {
|
|
51
90
|
const game = this.games.get(id);
|
|
52
91
|
if (!game)
|
|
53
92
|
throw new ChessError("GAME_NOT_FOUND", `game not found: ${id}`);
|
|
@@ -59,11 +98,6 @@ export class GameStore {
|
|
|
59
98
|
game.lastAccessedAt = now;
|
|
60
99
|
return game;
|
|
61
100
|
}
|
|
62
|
-
bumpRevision(id) {
|
|
63
|
-
const game = this.getGame(id);
|
|
64
|
-
game.revision += 1;
|
|
65
|
-
return game.revision;
|
|
66
|
-
}
|
|
67
101
|
deleteGame(id) {
|
|
68
102
|
this.cleanupGames();
|
|
69
103
|
return this.games.delete(id);
|
|
@@ -81,11 +115,3 @@ export class GameStore {
|
|
|
81
115
|
}
|
|
82
116
|
}
|
|
83
117
|
export const defaultGameStore = new GameStore();
|
|
84
|
-
export const cleanupGames = (now) => defaultGameStore.cleanupGames(now);
|
|
85
|
-
export const createGame = (fen) => defaultGameStore.createGame(fen);
|
|
86
|
-
export const createGameFromChess = (chess) => defaultGameStore.createGameFromChess(chess);
|
|
87
|
-
export const getGame = (id) => defaultGameStore.getGame(id);
|
|
88
|
-
export const bumpRevision = (id) => defaultGameStore.bumpRevision(id);
|
|
89
|
-
export const deleteGame = (id) => defaultGameStore.deleteGame(id);
|
|
90
|
-
export const listGames = () => defaultGameStore.listGames();
|
|
91
|
-
export const gameCount = () => defaultGameStore.gameCount();
|
package/dist/http.js
CHANGED
|
@@ -166,6 +166,18 @@ function hasUnsupportedContentEncoding(req) {
|
|
|
166
166
|
return (contentEncoding !== undefined &&
|
|
167
167
|
(typeof contentEncoding !== "string" || contentEncoding.trim().toLowerCase() !== "identity"));
|
|
168
168
|
}
|
|
169
|
+
async function parsePostBody(req, limit, timeoutMs) {
|
|
170
|
+
const body = await readPostBody(req, limit, timeoutMs);
|
|
171
|
+
if (!body.ok)
|
|
172
|
+
return body;
|
|
173
|
+
if (!isJsonContentType(req)) {
|
|
174
|
+
return { ok: false, status: 415, message: "Content-Type must be application/json" };
|
|
175
|
+
}
|
|
176
|
+
if (hasUnsupportedContentEncoding(req)) {
|
|
177
|
+
return { ok: false, status: 415, message: "Content-Encoding must be identity" };
|
|
178
|
+
}
|
|
179
|
+
return body;
|
|
180
|
+
}
|
|
169
181
|
const UNABORTABLE_SIGNAL = new AbortController().signal;
|
|
170
182
|
function scopedServices(services, run) {
|
|
171
183
|
return {
|
|
@@ -222,6 +234,17 @@ export async function serveHttp(options = {}, services = defaultAppServices) {
|
|
|
222
234
|
.filter(([, session]) => session.activeRequests === 0 && now - session.lastUsedAt >= limits.sessionIdleTtlMs)
|
|
223
235
|
.map(([id, session]) => closeSession(id, session)));
|
|
224
236
|
};
|
|
237
|
+
const withActiveSession = async (session, work) => {
|
|
238
|
+
session.activeRequests += 1;
|
|
239
|
+
session.lastUsedAt = Date.now();
|
|
240
|
+
try {
|
|
241
|
+
return await work();
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
session.activeRequests -= 1;
|
|
245
|
+
session.lastUsedAt = Date.now();
|
|
246
|
+
}
|
|
247
|
+
};
|
|
225
248
|
const releasePost = (session) => {
|
|
226
249
|
activePosts -= 1;
|
|
227
250
|
if (session)
|
|
@@ -290,33 +313,22 @@ export async function serveHttp(options = {}, services = defaultAppServices) {
|
|
|
290
313
|
closeWithError(req, res, 503, "server request limit reached", { "retry-after": "1" });
|
|
291
314
|
return;
|
|
292
315
|
}
|
|
293
|
-
session.activeRequests += 1;
|
|
294
|
-
session.lastUsedAt = Date.now();
|
|
295
316
|
try {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
}
|
|
305
|
-
if (hasUnsupportedContentEncoding(req)) {
|
|
306
|
-
closeWithError(req, res, 415, "Content-Encoding must be identity");
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
await session.transport.handleRequest(req, res, body.value);
|
|
317
|
+
await withActiveSession(session, async () => {
|
|
318
|
+
const body = await parsePostBody(req, limits.maxRequestBodyBytes, limits.requestTimeoutMs);
|
|
319
|
+
if (!body.ok) {
|
|
320
|
+
closeWithError(req, res, body.status, body.message);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
await session.transport.handleRequest(req, res, body.value);
|
|
324
|
+
});
|
|
310
325
|
}
|
|
311
326
|
finally {
|
|
312
|
-
session.activeRequests -= 1;
|
|
313
|
-
session.lastUsedAt = Date.now();
|
|
314
327
|
releasePost(session);
|
|
315
328
|
}
|
|
316
329
|
return;
|
|
317
330
|
}
|
|
318
|
-
session
|
|
319
|
-
await session.transport.handleRequest(req, res);
|
|
331
|
+
await withActiveSession(session, () => session.transport.handleRequest(req, res));
|
|
320
332
|
return;
|
|
321
333
|
}
|
|
322
334
|
if (req.method !== "POST") {
|
|
@@ -328,90 +340,80 @@ export async function serveHttp(options = {}, services = defaultAppServices) {
|
|
|
328
340
|
closeWithError(req, res, 503, "server request limit reached", { "retry-after": "1" });
|
|
329
341
|
return;
|
|
330
342
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
343
|
+
try {
|
|
344
|
+
const body = await parsePostBody(req, limits.maxRequestBodyBytes, limits.requestTimeoutMs);
|
|
345
|
+
if (!body.ok) {
|
|
346
|
+
closeWithError(req, res, body.status, body.message);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (!isInitializeRequest(body.value)) {
|
|
350
|
+
closeWithError(req, res, 400, "MCP session initialization required");
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
await reapExpiredSessions();
|
|
354
|
+
if (closing) {
|
|
355
|
+
closeWithError(req, res, 503, "server is shutting down", { "retry-after": "1" });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (sessions.size + pendingInitializations >= limits.maxSessions) {
|
|
359
|
+
closeWithError(req, res, 503, "MCP session limit reached", { "retry-after": "1" });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
pendingInitializations += 1;
|
|
363
|
+
let initializedId;
|
|
364
|
+
let session;
|
|
365
|
+
let hasReservation = true;
|
|
366
|
+
const transport = new NodeStreamableHTTPServerTransport({
|
|
367
|
+
sessionIdGenerator: randomUUID,
|
|
368
|
+
onsessioninitialized: (newId) => {
|
|
369
|
+
initializedId = newId;
|
|
370
|
+
initializing.delete(session);
|
|
371
|
+
if (hasReservation) {
|
|
372
|
+
hasReservation = false;
|
|
373
|
+
pendingInitializations -= 1;
|
|
374
|
+
}
|
|
375
|
+
session.lastUsedAt = Date.now();
|
|
376
|
+
sessions.set(newId, session);
|
|
377
|
+
},
|
|
378
|
+
onsessionclosed: (closedId) => {
|
|
379
|
+
const current = sessions.get(closedId);
|
|
380
|
+
if (current === session)
|
|
381
|
+
sessions.delete(closedId);
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
const abort = new AbortController();
|
|
385
|
+
const mcp = buildServer(scopedServices(services, workAdmission.session(abort.signal)));
|
|
386
|
+
session = {
|
|
387
|
+
server: mcp,
|
|
388
|
+
transport,
|
|
389
|
+
abort,
|
|
390
|
+
lastUsedAt: Date.now(),
|
|
391
|
+
activeRequests: 0,
|
|
392
|
+
activePosts: 0,
|
|
393
|
+
};
|
|
394
|
+
initializing.add(session);
|
|
395
|
+
transport.onclose = () => {
|
|
396
|
+
session.abort.abort(new DOMException("MCP session closed", "AbortError"));
|
|
397
|
+
if (initializedId && sessions.get(initializedId) === session) {
|
|
398
|
+
sessions.delete(initializedId);
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
try {
|
|
402
|
+
await withActiveSession(session, async () => {
|
|
403
|
+
await mcp.connect(transport);
|
|
404
|
+
await transport.handleRequest(req, res, body.value);
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
finally {
|
|
371
408
|
initializing.delete(session);
|
|
372
|
-
if (hasReservation)
|
|
373
|
-
hasReservation = false;
|
|
409
|
+
if (hasReservation)
|
|
374
410
|
pendingInitializations -= 1;
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
sessions.set(newId, session);
|
|
378
|
-
},
|
|
379
|
-
onsessionclosed: (closedId) => {
|
|
380
|
-
const current = sessions.get(closedId);
|
|
381
|
-
if (current === session)
|
|
382
|
-
sessions.delete(closedId);
|
|
383
|
-
},
|
|
384
|
-
});
|
|
385
|
-
const abort = new AbortController();
|
|
386
|
-
const mcp = buildServer(scopedServices(services, workAdmission.session(abort.signal)));
|
|
387
|
-
session = {
|
|
388
|
-
server: mcp,
|
|
389
|
-
transport,
|
|
390
|
-
abort,
|
|
391
|
-
lastUsedAt: Date.now(),
|
|
392
|
-
activeRequests: 1,
|
|
393
|
-
activePosts: 0,
|
|
394
|
-
};
|
|
395
|
-
initializing.add(session);
|
|
396
|
-
transport.onclose = () => {
|
|
397
|
-
session.abort.abort(new DOMException("MCP session closed", "AbortError"));
|
|
398
|
-
if (initializedId && sessions.get(initializedId) === session) {
|
|
399
|
-
sessions.delete(initializedId);
|
|
411
|
+
if (!initializedId)
|
|
412
|
+
await mcp.close();
|
|
400
413
|
}
|
|
401
|
-
};
|
|
402
|
-
try {
|
|
403
|
-
await mcp.connect(transport);
|
|
404
|
-
await transport.handleRequest(req, res, body.value);
|
|
405
414
|
}
|
|
406
415
|
finally {
|
|
407
|
-
session.activeRequests -= 1;
|
|
408
|
-
session.lastUsedAt = Date.now();
|
|
409
|
-
initializing.delete(session);
|
|
410
416
|
releasePost(undefined);
|
|
411
|
-
if (hasReservation)
|
|
412
|
-
pendingInitializations -= 1;
|
|
413
|
-
if (!initializedId)
|
|
414
|
-
await mcp.close();
|
|
415
417
|
}
|
|
416
418
|
};
|
|
417
419
|
const headerTimers = new WeakMap();
|
package/dist/intents.js
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import { stockfish } from "./engines/stockfish.js";
|
|
2
|
-
import { humanMoveDistribution } from "./maia3/inference.js";
|
|
3
|
-
import { ExplorerError, openingExplorer, explorerEnabled } from "./explorer.js";
|
|
4
1
|
import { toEval, evalToCp } from "./eval.js";
|
|
5
2
|
function softmax(values, temperature) {
|
|
6
3
|
const scaled = values.map((v) => v / temperature);
|
|
@@ -122,31 +119,34 @@ export function candidateSetFromData(chess, elo, sfLines, maiaMoves, lichessResu
|
|
|
122
119
|
moveSensitivity: computeMoveSensitivity(normalizedSfLines.map(({ line }) => line)),
|
|
123
120
|
};
|
|
124
121
|
}
|
|
125
|
-
export
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
122
|
+
export function createCandidateComputation(dependencies) {
|
|
123
|
+
return {
|
|
124
|
+
computeCandidates: async (chess, elo, sfDepth, sfMultipv, maiaTopN, lichess, signal) => {
|
|
125
|
+
const [sfLines, maiaMoves, lichessResult] = await Promise.all([
|
|
126
|
+
dependencies.analyze(chess.fen(), sfDepth, sfMultipv, signal),
|
|
127
|
+
dependencies.humanMoveDistribution(chess, elo, elo, maiaTopN, signal),
|
|
128
|
+
lichess && dependencies.explorerEnabled()
|
|
129
|
+
? dependencies
|
|
130
|
+
.openingExplorer(chess, lichess.db, lichess.speeds, lichess.ratings, signal)
|
|
131
|
+
.then(explorerCandidateData)
|
|
132
|
+
.catch((error) => {
|
|
133
|
+
signal?.throwIfAborted();
|
|
134
|
+
return {
|
|
135
|
+
status: "unavailable",
|
|
136
|
+
reason: dependencies.explorerFailureReason(error),
|
|
137
|
+
totalGames: null,
|
|
138
|
+
moves: [],
|
|
139
|
+
};
|
|
140
|
+
})
|
|
141
|
+
: Promise.resolve({
|
|
142
|
+
status: "disabled",
|
|
143
|
+
totalGames: null,
|
|
144
|
+
moves: [],
|
|
145
|
+
}),
|
|
146
|
+
]);
|
|
147
|
+
return candidateSetFromData(chess, elo, sfLines, maiaMoves, lichessResult);
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
150
|
}
|
|
151
151
|
function winMargin(c) {
|
|
152
152
|
const wdl = c.objective.wdl;
|
package/dist/services.js
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import { stockfish } from "./engines/stockfish.js";
|
|
2
|
-
import { explorerEnabled, openingExplorer, } from "./explorer.js";
|
|
2
|
+
import { ExplorerError, explorerEnabled, openingExplorer, } from "./explorer.js";
|
|
3
3
|
import { defaultGameStore } from "./games.js";
|
|
4
|
-
import {
|
|
4
|
+
import { createCandidateComputation, rankByIntent } from "./intents.js";
|
|
5
5
|
import { humanMoveDistribution } from "./maia3/inference.js";
|
|
6
|
+
const analyze = (fen, depth, multipv, signal) => stockfish.analyze(fen, depth, multipv, signal);
|
|
7
|
+
const openExplorer = (chess, db, speeds, ratings, signal) => openingExplorer(chess, db, speeds, ratings, signal === undefined ? {} : { signal });
|
|
8
|
+
const candidateComputation = createCandidateComputation({
|
|
9
|
+
analyze,
|
|
10
|
+
humanMoveDistribution,
|
|
11
|
+
explorerEnabled,
|
|
12
|
+
openingExplorer: openExplorer,
|
|
13
|
+
explorerFailureReason: (error) => error instanceof ExplorerError ? error.reason : "upstream",
|
|
14
|
+
});
|
|
6
15
|
export const defaultAppServices = {
|
|
7
16
|
games: defaultGameStore,
|
|
8
|
-
analyze
|
|
17
|
+
analyze,
|
|
9
18
|
quit: () => stockfish.quit(),
|
|
10
19
|
humanMoveDistribution,
|
|
11
20
|
explorerEnabled,
|
|
12
|
-
openingExplorer:
|
|
13
|
-
computeCandidates,
|
|
21
|
+
openingExplorer: openExplorer,
|
|
22
|
+
computeCandidates: candidateComputation.computeCandidates,
|
|
14
23
|
rankByIntent,
|
|
15
24
|
};
|
package/dist/tool-meta.js
CHANGED
|
@@ -46,7 +46,7 @@ export const TOOL_META = {
|
|
|
46
46
|
},
|
|
47
47
|
position_analyze: {
|
|
48
48
|
title: "Analyze Chess Position",
|
|
49
|
-
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.",
|
|
49
|
+
description: "Run Stockfish on the current position and return the top engine lines (multipv) as UCI pv and SAN pvSan. 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.",
|
|
50
50
|
annotations: readOnly(),
|
|
51
51
|
},
|
|
52
52
|
human_move_distribution: {
|
|
@@ -56,7 +56,7 @@ export const TOOL_META = {
|
|
|
56
56
|
},
|
|
57
57
|
move_evaluate: {
|
|
58
58
|
title: "Evaluate Chess Moves",
|
|
59
|
-
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,
|
|
59
|
+
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, a classification (best/excellent/good/inaccuracy/mistake/blunder), and the continuation as UCI pv and SAN pvSan.",
|
|
60
60
|
annotations: readOnly(),
|
|
61
61
|
},
|
|
62
62
|
move_candidates: {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const TOOL_NAMES = [
|
|
2
|
+
"create_game",
|
|
3
|
+
"delete_game",
|
|
4
|
+
"game_import_pgn",
|
|
5
|
+
"game_legal_moves",
|
|
6
|
+
"game_pgn",
|
|
7
|
+
"game_play_move",
|
|
8
|
+
"game_state",
|
|
9
|
+
"human_move_distribution",
|
|
10
|
+
"move_candidates",
|
|
11
|
+
"move_candidates_by_intent",
|
|
12
|
+
"move_evaluate",
|
|
13
|
+
"opening_explorer",
|
|
14
|
+
"position_analyze",
|
|
15
|
+
];
|
package/dist/tool-schemas.js
CHANGED
|
@@ -45,6 +45,10 @@ export const SfLineSchema = z.strictObject({
|
|
|
45
45
|
wdl: wdl.nullable(),
|
|
46
46
|
pv: z.array(z.string()),
|
|
47
47
|
});
|
|
48
|
+
export const AnalysisLineSchema = z.strictObject({
|
|
49
|
+
...SfLineSchema.shape,
|
|
50
|
+
pvSan: z.array(z.string()),
|
|
51
|
+
});
|
|
48
52
|
export const OpeningStatsSchema = z.strictObject({
|
|
49
53
|
status: z.enum(["available", "no_data", "unavailable", "disabled"]),
|
|
50
54
|
reason: z.enum(EXPLORER_ERROR_KINDS).optional(),
|
|
@@ -121,7 +125,7 @@ export const PositionAnalyzeOutputSchema = z.strictObject({
|
|
|
121
125
|
turn: color,
|
|
122
126
|
revision,
|
|
123
127
|
analysis_level: z.enum(["fast", "normal", "deep"]),
|
|
124
|
-
lines: z.array(
|
|
128
|
+
lines: z.array(AnalysisLineSchema),
|
|
125
129
|
});
|
|
126
130
|
export const Maia3MoveSchema = z.strictObject({
|
|
127
131
|
uci: z.string(),
|
|
@@ -155,6 +159,7 @@ const moveEvaluation = z.strictObject({
|
|
|
155
159
|
.enum(["best", "excellent", "good", "inaccuracy", "mistake", "blunder"])
|
|
156
160
|
.nullable(),
|
|
157
161
|
pv: z.array(z.string()),
|
|
162
|
+
pvSan: z.array(z.string()),
|
|
158
163
|
});
|
|
159
164
|
export const MoveEvaluateOutputSchema = z.strictObject({
|
|
160
165
|
game_id: z.string(),
|
package/dist/tools/analysis.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { drawResult, parseMove, playParsedMove, snapshotChess, } from "../chess.js";
|
|
1
|
+
import { drawResult, parseMove, playParsedMove, pvToSan, snapshotChess, } from "../chess.js";
|
|
2
2
|
import { ANALYSIS_PRESETS, classifyCpLoss, evalToCp, negateEval, toEval, } from "../eval.js";
|
|
3
3
|
import { TOOL_INPUT_SCHEMAS } from "../tool-inputs.js";
|
|
4
4
|
import { TOOL_META } from "../tool-meta.js";
|
|
@@ -10,8 +10,7 @@ export function registerAnalysisTools(server, services) {
|
|
|
10
10
|
inputSchema: TOOL_INPUT_SCHEMAS.position_analyze,
|
|
11
11
|
outputSchema: TOOL_OUTPUT_SCHEMAS.position_analyze,
|
|
12
12
|
}, safeHandler(TOOL_INPUT_SCHEMAS.position_analyze, async ({ game_id, analysis_level, depth, multipv }, signal) => {
|
|
13
|
-
const { chess
|
|
14
|
-
const chess = snapshotChess(live);
|
|
13
|
+
const { chess, revision } = services.games.getSnapshot(game_id);
|
|
15
14
|
const preset = ANALYSIS_PRESETS[analysis_level];
|
|
16
15
|
const d = depth ?? preset.depth;
|
|
17
16
|
const mpv = multipv ?? preset.multipv;
|
|
@@ -28,6 +27,7 @@ export function registerAnalysisTools(server, services) {
|
|
|
28
27
|
scoreMate: line.scoreMate,
|
|
29
28
|
wdl: line.wdl,
|
|
30
29
|
pv: line.pv,
|
|
30
|
+
pvSan: pvToSan(chess, line.pv),
|
|
31
31
|
})),
|
|
32
32
|
};
|
|
33
33
|
return toolResult(payload, `Analyzed game ${game_id} at revision ${revision}; ${payload.lines.length} lines`);
|
|
@@ -37,8 +37,7 @@ export function registerAnalysisTools(server, services) {
|
|
|
37
37
|
inputSchema: TOOL_INPUT_SCHEMAS.human_move_distribution,
|
|
38
38
|
outputSchema: TOOL_OUTPUT_SCHEMAS.human_move_distribution,
|
|
39
39
|
}, safeHandler(TOOL_INPUT_SCHEMAS.human_move_distribution, async ({ game_id, elo, oppo_elo, top_n }, signal) => {
|
|
40
|
-
const { chess
|
|
41
|
-
const chess = snapshotChess(live);
|
|
40
|
+
const { chess, revision } = services.games.getSnapshot(game_id);
|
|
42
41
|
const opponentElo = oppo_elo ?? elo;
|
|
43
42
|
const moves = await services.humanMoveDistribution(chess, elo, opponentElo, top_n, signal);
|
|
44
43
|
const payload = {
|
|
@@ -55,8 +54,7 @@ export function registerAnalysisTools(server, services) {
|
|
|
55
54
|
inputSchema: TOOL_INPUT_SCHEMAS.move_evaluate,
|
|
56
55
|
outputSchema: TOOL_OUTPUT_SCHEMAS.move_evaluate,
|
|
57
56
|
}, safeHandler(TOOL_INPUT_SCHEMAS.move_evaluate, async ({ game_id, move, depth }, signal) => {
|
|
58
|
-
const { chess
|
|
59
|
-
const chess = snapshotChess(live);
|
|
57
|
+
const { chess, revision } = services.games.getSnapshot(game_id);
|
|
60
58
|
const moves = Array.isArray(move) ? move : [move];
|
|
61
59
|
const beforeLines = await services.analyze(chess.fen(), depth, 1, signal);
|
|
62
60
|
signal.throwIfAborted();
|
|
@@ -80,6 +78,7 @@ export function registerAnalysisTools(server, services) {
|
|
|
80
78
|
cpLoss: null,
|
|
81
79
|
classification: "best",
|
|
82
80
|
pv: [],
|
|
81
|
+
pvSan: [],
|
|
83
82
|
});
|
|
84
83
|
continue;
|
|
85
84
|
}
|
|
@@ -98,6 +97,7 @@ export function registerAnalysisTools(server, services) {
|
|
|
98
97
|
? classifyCpLoss(cpLoss)
|
|
99
98
|
: null,
|
|
100
99
|
pv: [],
|
|
100
|
+
pvSan: [],
|
|
101
101
|
});
|
|
102
102
|
continue;
|
|
103
103
|
}
|
|
@@ -108,6 +108,7 @@ export function registerAnalysisTools(server, services) {
|
|
|
108
108
|
const moverEval = afterEval ? negateEval(afterEval) : null;
|
|
109
109
|
const afterCp = moverEval ? evalToCp(moverEval) : null;
|
|
110
110
|
const cpLoss = afterCp !== null && beforeCp !== null ? beforeCp - afterCp : null;
|
|
111
|
+
const pv = after?.pv ?? [];
|
|
111
112
|
results.push({
|
|
112
113
|
move: parsed.san,
|
|
113
114
|
uci: parsed.lan,
|
|
@@ -119,7 +120,8 @@ export function registerAnalysisTools(server, services) {
|
|
|
119
120
|
classification: cpLoss !== null
|
|
120
121
|
? classifyCpLoss(cpLoss)
|
|
121
122
|
: null,
|
|
122
|
-
pv
|
|
123
|
+
pv,
|
|
124
|
+
pvSan: pvToSan(copy, pv),
|
|
123
125
|
});
|
|
124
126
|
}
|
|
125
127
|
return toolResult({ game_id, revision, results }, `Evaluated ${results.length} move${results.length === 1 ? "" : "s"} in game ${game_id}`);
|
package/dist/tools/candidates.js
CHANGED
|
@@ -1,66 +1,50 @@
|
|
|
1
|
-
import { snapshotChess } from "../chess.js";
|
|
2
1
|
import { ANALYSIS_PRESETS } from "../eval.js";
|
|
3
2
|
import { TOOL_INPUT_SCHEMAS } from "../tool-inputs.js";
|
|
4
3
|
import { TOOL_META } from "../tool-meta.js";
|
|
5
4
|
import { safeHandler, toolResult } from "../tool-result.js";
|
|
6
5
|
import { TOOL_OUTPUT_SCHEMAS } from "../tool-schemas.js";
|
|
6
|
+
async function candidatePayload(services, { game_id, elo, analysis_level, sf_depth, sf_multipv, maia_top_n, lichess_db, lichess_speeds, lichess_ratings, }, signal) {
|
|
7
|
+
const { chess, revision } = services.games.getSnapshot(game_id);
|
|
8
|
+
const preset = ANALYSIS_PRESETS[analysis_level];
|
|
9
|
+
const { candidates, moveSensitivity } = await services.computeCandidates(chess, elo, sf_depth ?? preset.depth, sf_multipv ?? preset.multipv, maia_top_n, {
|
|
10
|
+
db: lichess_db,
|
|
11
|
+
speeds: lichess_speeds,
|
|
12
|
+
ratings: lichess_ratings,
|
|
13
|
+
}, signal);
|
|
14
|
+
signal.throwIfAborted();
|
|
15
|
+
return {
|
|
16
|
+
game_id,
|
|
17
|
+
revision,
|
|
18
|
+
fen: chess.fen(),
|
|
19
|
+
turn: chess.turn(),
|
|
20
|
+
elo,
|
|
21
|
+
analysis_level,
|
|
22
|
+
moveSensitivity,
|
|
23
|
+
candidates,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
7
26
|
export function registerCandidateTools(server, services) {
|
|
8
27
|
const moveCandidatesSchema = TOOL_INPUT_SCHEMAS.move_candidates;
|
|
9
28
|
server.registerTool("move_candidates", {
|
|
10
29
|
...TOOL_META.move_candidates,
|
|
11
30
|
inputSchema: moveCandidatesSchema,
|
|
12
31
|
outputSchema: TOOL_OUTPUT_SCHEMAS.move_candidates,
|
|
13
|
-
}, safeHandler(moveCandidatesSchema, async (
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
const preset = ANALYSIS_PRESETS[analysis_level];
|
|
17
|
-
const depth = sf_depth ?? preset.depth;
|
|
18
|
-
const multipv = sf_multipv ?? preset.multipv;
|
|
19
|
-
const { candidates, moveSensitivity } = await services.computeCandidates(chess, elo, depth, multipv, maia_top_n, {
|
|
20
|
-
db: lichess_db,
|
|
21
|
-
speeds: lichess_speeds,
|
|
22
|
-
ratings: lichess_ratings,
|
|
23
|
-
}, signal);
|
|
24
|
-
signal.throwIfAborted();
|
|
25
|
-
return toolResult({
|
|
26
|
-
game_id,
|
|
27
|
-
revision,
|
|
28
|
-
fen: chess.fen(),
|
|
29
|
-
turn: chess.turn(),
|
|
30
|
-
elo,
|
|
31
|
-
analysis_level,
|
|
32
|
-
moveSensitivity,
|
|
33
|
-
candidates,
|
|
34
|
-
}, `${candidates.length} candidates for game ${game_id} at revision ${revision}`);
|
|
32
|
+
}, safeHandler(moveCandidatesSchema, async (input, signal) => {
|
|
33
|
+
const payload = await candidatePayload(services, input, signal);
|
|
34
|
+
return toolResult(payload, `${payload.candidates.length} candidates for game ${payload.game_id} at revision ${payload.revision}`);
|
|
35
35
|
}));
|
|
36
36
|
const byIntentSchema = TOOL_INPUT_SCHEMAS.move_candidates_by_intent;
|
|
37
37
|
server.registerTool("move_candidates_by_intent", {
|
|
38
38
|
...TOOL_META.move_candidates_by_intent,
|
|
39
39
|
inputSchema: byIntentSchema,
|
|
40
40
|
outputSchema: TOOL_OUTPUT_SCHEMAS.move_candidates_by_intent,
|
|
41
|
-
}, safeHandler(byIntentSchema, async ({
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const preset = ANALYSIS_PRESETS[analysis_level];
|
|
45
|
-
const depth = sf_depth ?? preset.depth;
|
|
46
|
-
const multipv = sf_multipv ?? preset.multipv;
|
|
47
|
-
const { candidates, moveSensitivity } = await services.computeCandidates(chess, elo, depth, multipv, maia_top_n, {
|
|
48
|
-
db: lichess_db,
|
|
49
|
-
speeds: lichess_speeds,
|
|
50
|
-
ratings: lichess_ratings,
|
|
51
|
-
}, signal);
|
|
52
|
-
signal.throwIfAborted();
|
|
53
|
-
const ranked = services.rankByIntent(candidates, intent);
|
|
41
|
+
}, safeHandler(byIntentSchema, async ({ intent, ...input }, signal) => {
|
|
42
|
+
const payload = await candidatePayload(services, input, signal);
|
|
43
|
+
const candidates = services.rankByIntent(payload.candidates, intent);
|
|
54
44
|
return toolResult({
|
|
55
|
-
|
|
56
|
-
revision,
|
|
57
|
-
fen: chess.fen(),
|
|
58
|
-
turn: chess.turn(),
|
|
45
|
+
...payload,
|
|
59
46
|
intent,
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
moveSensitivity,
|
|
63
|
-
candidates: ranked,
|
|
64
|
-
}, `${ranked.length} ${intent} candidates for game ${game_id} at revision ${revision}`);
|
|
47
|
+
candidates,
|
|
48
|
+
}, `${candidates.length} ${intent} candidates for game ${payload.game_id} at revision ${payload.revision}`);
|
|
65
49
|
}));
|
|
66
50
|
}
|
package/dist/tools/explorer.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { snapshotChess } from "../chess.js";
|
|
2
1
|
import { ChessError } from "../errors.js";
|
|
3
2
|
import { OpeningExplorerInputSchema } from "../tool-inputs.js";
|
|
4
3
|
import { TOOL_META } from "../tool-meta.js";
|
|
@@ -13,8 +12,7 @@ export function registerExplorerTool(server, services) {
|
|
|
13
12
|
if (!services.explorerEnabled()) {
|
|
14
13
|
throw new ChessError("LICHESS_DISABLED", "LICHESS_TOKEN not set; opening explorer is disabled");
|
|
15
14
|
}
|
|
16
|
-
const { chess
|
|
17
|
-
const chess = snapshotChess(live);
|
|
15
|
+
const { chess, revision } = services.games.getSnapshot(game_id);
|
|
18
16
|
const result = await services.openingExplorer(chess, db, speeds, ratings, signal);
|
|
19
17
|
return toolResult({ game_id, revision, ...result }, `Lichess ${db} returned ${result.moves.length} moves for game ${game_id}`);
|
|
20
18
|
}));
|
package/dist/tools/game.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseImportedPgn, parseMove,
|
|
1
|
+
import { parseImportedPgn, parseMove, stateOf } from "../chess.js";
|
|
2
2
|
import { ChessError } from "../errors.js";
|
|
3
3
|
import { TOOL_INPUT_SCHEMAS } from "../tool-inputs.js";
|
|
4
4
|
import { TOOL_META } from "../tool-meta.js";
|
|
@@ -39,7 +39,7 @@ export function registerGameTools(server, services) {
|
|
|
39
39
|
inputSchema: TOOL_INPUT_SCHEMAS.game_state,
|
|
40
40
|
outputSchema: TOOL_OUTPUT_SCHEMAS.game_state,
|
|
41
41
|
}, safeHandler(TOOL_INPUT_SCHEMAS.game_state, async ({ game_id, include_ascii }) => {
|
|
42
|
-
const { chess, revision } = services.games.
|
|
42
|
+
const { chess, revision } = services.games.getSnapshot(game_id);
|
|
43
43
|
const state = stateOf(chess, revision);
|
|
44
44
|
return toolResult({
|
|
45
45
|
game_id,
|
|
@@ -52,7 +52,7 @@ export function registerGameTools(server, services) {
|
|
|
52
52
|
inputSchema: TOOL_INPUT_SCHEMAS.game_play_move,
|
|
53
53
|
outputSchema: TOOL_OUTPUT_SCHEMAS.game_play_move,
|
|
54
54
|
}, safeHandler(TOOL_INPUT_SCHEMAS.game_play_move, async ({ game_id, move, expected_revision }, signal) => {
|
|
55
|
-
const { chess, revision } = services.games.
|
|
55
|
+
const { chess, revision } = services.games.getSnapshot(game_id);
|
|
56
56
|
if (expected_revision !== revision) {
|
|
57
57
|
throw new ChessError("STALE_POSITION", `position changed: expected revision ${expected_revision}, current ${revision}`);
|
|
58
58
|
}
|
|
@@ -61,16 +61,15 @@ export function registerGameTools(server, services) {
|
|
|
61
61
|
}
|
|
62
62
|
const parsed = parseMove(chess, move);
|
|
63
63
|
signal.throwIfAborted();
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return toolResult({ game_id, move: parsed.san, ...stateOf(chess, newRevision) }, `Played ${parsed.san} in game ${game_id}; revision ${newRevision}`);
|
|
64
|
+
const { chess: next, revision: newRevision } = services.games.applyMove(game_id, expected_revision, parsed);
|
|
65
|
+
return toolResult({ game_id, move: parsed.san, ...stateOf(next, newRevision) }, `Played ${parsed.san} in game ${game_id}; revision ${newRevision}`);
|
|
67
66
|
}));
|
|
68
67
|
server.registerTool("game_legal_moves", {
|
|
69
68
|
...TOOL_META.game_legal_moves,
|
|
70
69
|
inputSchema: TOOL_INPUT_SCHEMAS.game_legal_moves,
|
|
71
70
|
outputSchema: TOOL_OUTPUT_SCHEMAS.game_legal_moves,
|
|
72
71
|
}, safeHandler(TOOL_INPUT_SCHEMAS.game_legal_moves, async ({ game_id }, signal) => {
|
|
73
|
-
const { chess, revision } = services.games.
|
|
72
|
+
const { chess, revision } = services.games.getSnapshot(game_id);
|
|
74
73
|
const moves = [];
|
|
75
74
|
for (const move of chess.moves({ verbose: true })) {
|
|
76
75
|
signal.throwIfAborted();
|
|
@@ -93,7 +92,7 @@ export function registerGameTools(server, services) {
|
|
|
93
92
|
inputSchema: TOOL_INPUT_SCHEMAS.game_pgn,
|
|
94
93
|
outputSchema: TOOL_OUTPUT_SCHEMAS.game_pgn,
|
|
95
94
|
}, safeHandler(TOOL_INPUT_SCHEMAS.game_pgn, async ({ game_id }) => {
|
|
96
|
-
const { chess, revision } = services.games.
|
|
95
|
+
const { chess, revision } = services.games.getSnapshot(game_id);
|
|
97
96
|
return toolResult({ game_id, revision, pgn: chess.pgn() }, `Exported PGN for game ${game_id} at revision ${revision}`);
|
|
98
97
|
}));
|
|
99
98
|
server.registerTool("game_import_pgn", {
|
package/docs/architecture.md
CHANGED
|
@@ -112,10 +112,10 @@ a 2 MiB byte cap, initialization reserves one of 64 session slots atomically,
|
|
|
112
112
|
and only 16 POSTs process-wide or two per session may run concurrently. The same
|
|
113
113
|
limits independently bound downstream compute and network jobs. A job retains
|
|
114
114
|
its slot after the HTTP response or socket closes and releases it only when the
|
|
115
|
-
service promise settles.
|
|
116
|
-
their process-shared games. GET SSE streams
|
|
117
|
-
|
|
118
|
-
keep-alive limits are enforced by the Node listener.
|
|
115
|
+
service promise settles. Sessions with no active request expire after 30 minutes
|
|
116
|
+
without deleting their process-shared games. Open GET SSE streams keep their
|
|
117
|
+
session active without consuming POST permits. Header, upload, connection,
|
|
118
|
+
socket, and keep-alive limits are enforced by the Node listener.
|
|
119
119
|
|
|
120
120
|
The server has no MCP OAuth endpoints, OAuth discovery metadata, bearer-token
|
|
121
121
|
validation, or browser CORS support. A reverse proxy may implement its own
|