llm-chess-mcp 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -1
- package/dist/cli.js +107 -0
- package/dist/engines/stockfish.js +117 -26
- package/dist/explorer.js +35 -4
- package/dist/http-work.js +34 -0
- package/dist/http.js +484 -0
- package/dist/index.js +44 -8
- package/dist/intents.js +15 -10
- package/dist/maia3/inference.js +6 -1
- package/dist/services.js +2 -2
- package/dist/tool-result.js +12 -4
- package/dist/tools/analysis.js +10 -7
- package/dist/tools/candidates.js +6 -4
- package/dist/tools/explorer.js +2 -2
- package/dist/tools/game.js +24 -16
- package/docs/architecture.md +76 -15
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -70,6 +70,108 @@ pnpm test:package
|
|
|
70
70
|
`pnpm test:live` queries Lichess only when `LICHESS_TOKEN` is set; otherwise it
|
|
71
71
|
skips without making a network request.
|
|
72
72
|
|
|
73
|
+
## Transports
|
|
74
|
+
|
|
75
|
+
stdio remains the default transport and requires no flags. To expose a local
|
|
76
|
+
Streamable HTTP endpoint instead:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pnpm build
|
|
80
|
+
node dist/index.js --transport http
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The server listens on `http://127.0.0.1:3000/mcp` and supports Streamable HTTP
|
|
84
|
+
sessions, JSON responses, and SSE. The equivalent development command is
|
|
85
|
+
`pnpm dev:http`.
|
|
86
|
+
|
|
87
|
+
HTTP options:
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
--host <host> Bind host (default: 127.0.0.1)
|
|
91
|
+
--port <port> Listen port (default: 3000)
|
|
92
|
+
--path <path> Endpoint path (default: /mcp)
|
|
93
|
+
--allowed-host <host> Allowed Host/Origin hostname; repeat as needed
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Binding to `0.0.0.0` or `::` requires at least one `--allowed-host`. HTTP mode
|
|
97
|
+
does not provide authentication or TLS; use a trusted network or an
|
|
98
|
+
authenticated reverse proxy when exposing it beyond localhost. Origin values
|
|
99
|
+
are validated when present, but the server does not emit browser CORS headers.
|
|
100
|
+
|
|
101
|
+
### Reverse-proxy deployment
|
|
102
|
+
|
|
103
|
+
The HTTP server is intended to run behind a reverse proxy for any non-local
|
|
104
|
+
deployment. The proxy owns TLS termination, client authentication, external
|
|
105
|
+
rate/connection limits, and any future CORS policy. Bind this process to
|
|
106
|
+
localhost only; never expose its port directly through a firewall, container
|
|
107
|
+
port mapping, or load balancer.
|
|
108
|
+
|
|
109
|
+
For example, start the backend with the public hostname that Nginx will pass
|
|
110
|
+
through as `Host`:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
node dist/index.js --transport http --host 127.0.0.1 --port 3000 \
|
|
114
|
+
--allowed-host chess-mcp.example.com
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
This is a minimal Nginx layout. It assumes an identity-aware auth service is
|
|
118
|
+
available only on localhost at `127.0.0.1:4180`; configure that service and
|
|
119
|
+
the certificate paths for the deployment. The limits are examples, not a
|
|
120
|
+
substitute for capacity planning.
|
|
121
|
+
|
|
122
|
+
```nginx
|
|
123
|
+
limit_req_zone $binary_remote_addr zone=mcp_req:10m rate=5r/s;
|
|
124
|
+
limit_conn_zone $binary_remote_addr zone=mcp_conn:10m;
|
|
125
|
+
|
|
126
|
+
server {
|
|
127
|
+
listen 443 ssl;
|
|
128
|
+
server_name chess-mcp.example.com;
|
|
129
|
+
ssl_certificate /etc/ssl/certs/chess-mcp.pem;
|
|
130
|
+
ssl_certificate_key /etc/ssl/private/chess-mcp.key;
|
|
131
|
+
|
|
132
|
+
location = /_mcp_auth {
|
|
133
|
+
internal;
|
|
134
|
+
proxy_pass http://127.0.0.1:4180/auth;
|
|
135
|
+
proxy_pass_request_body off;
|
|
136
|
+
proxy_set_header Content-Length "";
|
|
137
|
+
proxy_set_header X-Original-Method $request_method;
|
|
138
|
+
proxy_set_header X-Original-URI $request_uri;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
location = /mcp {
|
|
142
|
+
auth_request /_mcp_auth;
|
|
143
|
+
limit_req zone=mcp_req burst=20 nodelay;
|
|
144
|
+
limit_conn mcp_conn 10;
|
|
145
|
+
client_max_body_size 2m;
|
|
146
|
+
|
|
147
|
+
proxy_pass http://127.0.0.1:3000;
|
|
148
|
+
proxy_http_version 1.1;
|
|
149
|
+
proxy_set_header Connection "";
|
|
150
|
+
proxy_set_header Host $host;
|
|
151
|
+
proxy_set_header X-Forwarded-Proto https;
|
|
152
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
153
|
+
proxy_set_header X-Forwarded-User "";
|
|
154
|
+
proxy_set_header X-Forwarded-Email "";
|
|
155
|
+
proxy_buffering off;
|
|
156
|
+
proxy_read_timeout 90s;
|
|
157
|
+
|
|
158
|
+
# Intentionally no Access-Control-Allow-* headers: browser CORS is unsupported.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The application does not trust forwarded identity headers and does not assign
|
|
164
|
+
games to authenticated users. All games in one process share one `GameStore`;
|
|
165
|
+
the opaque `game_id` is the capability to operate a game within the trusted
|
|
166
|
+
deployment, not an OAuth token or user identity. Do not disclose it across
|
|
167
|
+
trust boundaries.
|
|
168
|
+
|
|
169
|
+
This server does not implement MCP OAuth discovery, bearer-token validation,
|
|
170
|
+
or browser CORS. A proxy may authenticate access to the endpoint, but that is
|
|
171
|
+
deployment policy rather than an application-level identity or ownership
|
|
172
|
+
model. Browser clients are unsupported unless a proxy deliberately adds and
|
|
173
|
+
maintains the required CORS policy.
|
|
174
|
+
|
|
73
175
|
### Export Maia3 to ONNX (build-time only)
|
|
74
176
|
|
|
75
177
|
This step needs Python + PyTorch once. It downloads the Maia3 checkpoint, verifies
|
|
@@ -264,10 +366,27 @@ rejected:
|
|
|
264
366
|
|
|
265
367
|
## Runtime limits
|
|
266
368
|
|
|
267
|
-
- Up to 1,000
|
|
369
|
+
- Up to 1,000 games are retained per process; idle games expire after one hour.
|
|
268
370
|
- `move_evaluate` accepts at most 10 moves per call.
|
|
269
371
|
- Imported PGNs are limited to 1 MiB and 4,096 plies.
|
|
270
372
|
- Stockfish accepts up to 32 active or queued analyses.
|
|
373
|
+
- HTTP retains at most 64 MCP sessions; sessions with no POST activity expire
|
|
374
|
+
after 30 minutes.
|
|
375
|
+
- HTTP accepts bodies up to 2 MiB. It permits 16 concurrent POSTs and downstream
|
|
376
|
+
compute/network jobs process-wide, with two of each per session. Work keeps
|
|
377
|
+
its slot after a raw disconnect until it settles. HTTP also caps connections
|
|
378
|
+
at 128 and applies bounded header, upload, socket, and keep-alive timeouts.
|
|
379
|
+
|
|
380
|
+
Programmatic users can override the HTTP limits through `HttpServerOptions`.
|
|
381
|
+
These safeguards do not replace public-edge quotas: a public deployment must
|
|
382
|
+
still enforce request, connection, and authentication limits at the reverse
|
|
383
|
+
proxy.
|
|
384
|
+
|
|
385
|
+
MCP cancellation notifications, session deletion, and server shutdown propagate
|
|
386
|
+
to Stockfish, Maia, and Lichess work. Stockfish stops safely at its UCI queue
|
|
387
|
+
boundary; Lichess fetch and retry waits abort immediately. ONNX Runtime cannot
|
|
388
|
+
interrupt an inference already executing, so Maia discards its result after the
|
|
389
|
+
native call returns. A raw HTTP disconnect alone is not a cancellation signal.
|
|
271
390
|
|
|
272
391
|
## Intents
|
|
273
392
|
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export const HELP = `Usage: llm-chess-mcp [options]
|
|
2
|
+
|
|
3
|
+
Options:
|
|
4
|
+
--transport <stdio|http> Transport to use (default: stdio)
|
|
5
|
+
--http Shortcut for --transport http
|
|
6
|
+
--host <host> HTTP bind host (default: 127.0.0.1)
|
|
7
|
+
--port <port> HTTP listen port (default: 3000)
|
|
8
|
+
--path <path> HTTP endpoint path (default: /mcp)
|
|
9
|
+
--allowed-host <host> Allowed HTTP Host/Origin hostname (repeatable)
|
|
10
|
+
-h, --help Show this help
|
|
11
|
+
`;
|
|
12
|
+
function optionValue(args, index, option) {
|
|
13
|
+
const value = args[index + 1];
|
|
14
|
+
if (value === undefined || value.startsWith("--")) {
|
|
15
|
+
throw new Error(`${option} requires a value`);
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function splitOption(arg) {
|
|
20
|
+
const index = arg.indexOf("=");
|
|
21
|
+
return index === -1 ? null : [arg.slice(0, index), arg.slice(index + 1)];
|
|
22
|
+
}
|
|
23
|
+
export function parseCli(args) {
|
|
24
|
+
let transport = "stdio";
|
|
25
|
+
let host = "127.0.0.1";
|
|
26
|
+
let port = 3_000;
|
|
27
|
+
let path = "/mcp";
|
|
28
|
+
let help = false;
|
|
29
|
+
let hasHttpOption = false;
|
|
30
|
+
const allowedHosts = [];
|
|
31
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
32
|
+
const arg = args[index];
|
|
33
|
+
if (arg === undefined)
|
|
34
|
+
continue;
|
|
35
|
+
const pair = splitOption(arg);
|
|
36
|
+
const option = pair?.[0] ?? arg;
|
|
37
|
+
const inlineValue = pair?.[1];
|
|
38
|
+
const value = () => {
|
|
39
|
+
if (inlineValue !== undefined)
|
|
40
|
+
return inlineValue;
|
|
41
|
+
const next = optionValue(args, index, option);
|
|
42
|
+
index += 1;
|
|
43
|
+
return next;
|
|
44
|
+
};
|
|
45
|
+
switch (option) {
|
|
46
|
+
case "-h":
|
|
47
|
+
case "--help":
|
|
48
|
+
if (inlineValue !== undefined)
|
|
49
|
+
throw new Error(`${option} takes no value`);
|
|
50
|
+
help = true;
|
|
51
|
+
break;
|
|
52
|
+
case "--http":
|
|
53
|
+
if (inlineValue !== undefined)
|
|
54
|
+
throw new Error("--http takes no value");
|
|
55
|
+
transport = "http";
|
|
56
|
+
break;
|
|
57
|
+
case "--transport": {
|
|
58
|
+
const selected = value();
|
|
59
|
+
if (selected !== "stdio" && selected !== "http") {
|
|
60
|
+
throw new Error("--transport must be stdio or http");
|
|
61
|
+
}
|
|
62
|
+
transport = selected;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
case "--host":
|
|
66
|
+
host = value();
|
|
67
|
+
hasHttpOption = true;
|
|
68
|
+
break;
|
|
69
|
+
case "--port": {
|
|
70
|
+
const selected = value();
|
|
71
|
+
if (!/^\d+$/.test(selected))
|
|
72
|
+
throw new Error("--port must be an integer");
|
|
73
|
+
port = Number(selected);
|
|
74
|
+
hasHttpOption = true;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
case "--path":
|
|
78
|
+
path = value();
|
|
79
|
+
hasHttpOption = true;
|
|
80
|
+
break;
|
|
81
|
+
case "--allowed-host":
|
|
82
|
+
allowedHosts.push(value());
|
|
83
|
+
hasHttpOption = true;
|
|
84
|
+
break;
|
|
85
|
+
default:
|
|
86
|
+
throw new Error(`unknown option: ${option}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
90
|
+
throw new Error("--port must be between 1 and 65535");
|
|
91
|
+
}
|
|
92
|
+
if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
|
|
93
|
+
throw new Error("--path must be an absolute URL path without query or fragment");
|
|
94
|
+
}
|
|
95
|
+
if (!host || allowedHosts.some((value) => !value)) {
|
|
96
|
+
throw new Error("HTTP hostnames must not be empty");
|
|
97
|
+
}
|
|
98
|
+
if (transport === "stdio" && hasHttpOption) {
|
|
99
|
+
throw new Error("HTTP options require --transport http");
|
|
100
|
+
}
|
|
101
|
+
if (transport === "http" &&
|
|
102
|
+
(host === "0.0.0.0" || host === "::" || host === "[::]") &&
|
|
103
|
+
allowedHosts.length === 0) {
|
|
104
|
+
throw new Error("wildcard HTTP binding requires at least one --allowed-host");
|
|
105
|
+
}
|
|
106
|
+
return { transport, host, port, path, allowedHosts, help };
|
|
107
|
+
}
|
|
@@ -30,6 +30,9 @@ function loadStockfish() {
|
|
|
30
30
|
function asError(error) {
|
|
31
31
|
return error instanceof Error ? error : new Error(String(error));
|
|
32
32
|
}
|
|
33
|
+
function abortError(signal) {
|
|
34
|
+
return asError(signal.reason ?? "stockfish request cancelled");
|
|
35
|
+
}
|
|
33
36
|
function parseScore(token) {
|
|
34
37
|
if (token.startsWith("cp"))
|
|
35
38
|
return { cp: Number(token.slice(2)), mate: null };
|
|
@@ -178,34 +181,104 @@ export class Stockfish {
|
|
|
178
181
|
}
|
|
179
182
|
});
|
|
180
183
|
}
|
|
181
|
-
|
|
184
|
+
release(request) {
|
|
185
|
+
if (request.released)
|
|
186
|
+
return;
|
|
187
|
+
request.released = true;
|
|
188
|
+
this.queued--;
|
|
189
|
+
if (request.signal && request.abortListener) {
|
|
190
|
+
request.signal.removeEventListener("abort", request.abortListener);
|
|
191
|
+
}
|
|
192
|
+
request.abortListener = null;
|
|
193
|
+
}
|
|
194
|
+
enqueue(request, fn) {
|
|
182
195
|
if (this.queued >= this.maxQueue) {
|
|
183
|
-
return
|
|
196
|
+
return false;
|
|
184
197
|
}
|
|
185
198
|
this.queued++;
|
|
186
199
|
const run = this.queue.then(fn);
|
|
187
200
|
this.queue = run.then(() => { }, () => { });
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
});
|
|
201
|
+
void run.finally(() => this.release(request));
|
|
202
|
+
return true;
|
|
191
203
|
}
|
|
192
|
-
analyze(fen, depth, multipv) {
|
|
204
|
+
analyze(fen, depth, multipv, signal) {
|
|
205
|
+
if (signal?.aborted) {
|
|
206
|
+
return Promise.reject(abortError(signal));
|
|
207
|
+
}
|
|
193
208
|
const quitGeneration = this.quitGeneration;
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
209
|
+
let request;
|
|
210
|
+
const result = new Promise((resolve, reject) => {
|
|
211
|
+
request = {
|
|
212
|
+
cancelled: false,
|
|
213
|
+
cancellation: null,
|
|
214
|
+
started: false,
|
|
215
|
+
released: false,
|
|
216
|
+
signal,
|
|
217
|
+
abortListener: null,
|
|
218
|
+
stop: null,
|
|
219
|
+
resolve,
|
|
220
|
+
reject,
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
const cancel = () => {
|
|
224
|
+
if (request.cancelled)
|
|
225
|
+
return;
|
|
226
|
+
const error = request.signal
|
|
227
|
+
? abortError(request.signal)
|
|
228
|
+
: new Error("stockfish request cancelled");
|
|
229
|
+
request.cancelled = true;
|
|
230
|
+
request.cancellation = error;
|
|
231
|
+
if (request.started) {
|
|
232
|
+
request.stop?.(error);
|
|
197
233
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
234
|
+
else {
|
|
235
|
+
request.reject(error);
|
|
236
|
+
this.release(request);
|
|
201
237
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
238
|
+
};
|
|
239
|
+
request.abortListener = cancel;
|
|
240
|
+
if (!this.enqueue(request, async () => {
|
|
241
|
+
if (request.cancelled)
|
|
242
|
+
return;
|
|
243
|
+
request.started = true;
|
|
244
|
+
try {
|
|
245
|
+
if (quitGeneration !== this.quitGeneration) {
|
|
246
|
+
throw new Error("stockfish request cancelled");
|
|
247
|
+
}
|
|
248
|
+
await this.init();
|
|
249
|
+
if (request.cancelled || quitGeneration !== this.quitGeneration) {
|
|
250
|
+
if (!request.cancelled) {
|
|
251
|
+
throw new Error("stockfish request cancelled");
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const session = this.session;
|
|
256
|
+
if (!session)
|
|
257
|
+
throw new Error("stockfish unavailable after initialization");
|
|
258
|
+
const lines = await this.doAnalyze(session, fen, depth, multipv, (stop) => {
|
|
259
|
+
request.stop = stop;
|
|
260
|
+
});
|
|
261
|
+
if (!request.cancelled)
|
|
262
|
+
request.resolve(lines);
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
if (!request.cancelled)
|
|
266
|
+
request.reject(asError(error));
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
request.stop = null;
|
|
270
|
+
if (request.cancellation)
|
|
271
|
+
request.reject(request.cancellation);
|
|
272
|
+
}
|
|
273
|
+
})) {
|
|
274
|
+
return Promise.reject(new Error("stockfish queue full"));
|
|
275
|
+
}
|
|
276
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
277
|
+
if (signal?.aborted)
|
|
278
|
+
cancel();
|
|
279
|
+
return result;
|
|
207
280
|
}
|
|
208
|
-
doAnalyze(session, fen, depth, multipv) {
|
|
281
|
+
doAnalyze(session, fen, depth, multipv, setStop) {
|
|
209
282
|
return new Promise((resolve, reject) => {
|
|
210
283
|
const engine = session.engine;
|
|
211
284
|
if (!engine) {
|
|
@@ -214,6 +287,8 @@ export class Stockfish {
|
|
|
214
287
|
}
|
|
215
288
|
const byPv = new Map();
|
|
216
289
|
let settled = false;
|
|
290
|
+
let cancellation = null;
|
|
291
|
+
let stopSent = false;
|
|
217
292
|
let stopTimer = null;
|
|
218
293
|
let failTimer = null;
|
|
219
294
|
const cleanup = () => {
|
|
@@ -223,6 +298,7 @@ export class Stockfish {
|
|
|
223
298
|
clearTimeout(failTimer);
|
|
224
299
|
stopTimer = null;
|
|
225
300
|
failTimer = null;
|
|
301
|
+
setStop(null);
|
|
226
302
|
session.aborts.delete(abort);
|
|
227
303
|
if (engine.listener === listener)
|
|
228
304
|
engine.listener = null;
|
|
@@ -244,6 +320,23 @@ export class Stockfish {
|
|
|
244
320
|
reject(error);
|
|
245
321
|
};
|
|
246
322
|
const abort = (error) => fail(error, false);
|
|
323
|
+
const stop = (error, cancelled) => {
|
|
324
|
+
if (cancelled)
|
|
325
|
+
cancellation ??= error;
|
|
326
|
+
if (stopSent)
|
|
327
|
+
return;
|
|
328
|
+
stopSent = true;
|
|
329
|
+
if (stopTimer)
|
|
330
|
+
clearTimeout(stopTimer);
|
|
331
|
+
stopTimer = null;
|
|
332
|
+
failTimer = setTimeout(() => fail(cancellation ?? error, true), this.timeouts.stopGrace);
|
|
333
|
+
try {
|
|
334
|
+
engine.sendCommand("stop");
|
|
335
|
+
}
|
|
336
|
+
catch (sendError) {
|
|
337
|
+
fail(asError(sendError), true);
|
|
338
|
+
}
|
|
339
|
+
};
|
|
247
340
|
const listener = (line) => {
|
|
248
341
|
if (line.startsWith("info") && line.includes(" multipv ")) {
|
|
249
342
|
const multipv = line.match(/multipv (?<value>\d+)/)?.groups?.value;
|
|
@@ -268,19 +361,17 @@ export class Stockfish {
|
|
|
268
361
|
});
|
|
269
362
|
}
|
|
270
363
|
else if (line.startsWith("bestmove")) {
|
|
271
|
-
|
|
364
|
+
if (cancellation)
|
|
365
|
+
fail(cancellation, false);
|
|
366
|
+
else
|
|
367
|
+
succeed();
|
|
272
368
|
}
|
|
273
369
|
};
|
|
274
370
|
session.aborts.add(abort);
|
|
275
371
|
engine.listener = listener;
|
|
372
|
+
setStop((error) => stop(error, true));
|
|
276
373
|
stopTimer = setTimeout(() => {
|
|
277
|
-
|
|
278
|
-
try {
|
|
279
|
-
engine.sendCommand("stop");
|
|
280
|
-
}
|
|
281
|
-
catch (error) {
|
|
282
|
-
fail(asError(error), true);
|
|
283
|
-
}
|
|
374
|
+
stop(new Error("stockfish analyze timeout"), false);
|
|
284
375
|
}, this.timeouts.analyze);
|
|
285
376
|
try {
|
|
286
377
|
engine.sendCommand("position fen " + fen);
|
package/dist/explorer.js
CHANGED
|
@@ -105,7 +105,32 @@ function isRetryable(kind) {
|
|
|
105
105
|
kind === "rate_limited" ||
|
|
106
106
|
kind === "upstream");
|
|
107
107
|
}
|
|
108
|
+
function throwIfAborted(signal) {
|
|
109
|
+
signal?.throwIfAborted();
|
|
110
|
+
}
|
|
111
|
+
async function sleepWithSignal(sleep, ms, signal) {
|
|
112
|
+
throwIfAborted(signal);
|
|
113
|
+
if (!signal)
|
|
114
|
+
return sleep(ms);
|
|
115
|
+
await new Promise((resolve, reject) => {
|
|
116
|
+
const onAbort = () => {
|
|
117
|
+
cleanup();
|
|
118
|
+
reject(signal.reason);
|
|
119
|
+
};
|
|
120
|
+
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
121
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
122
|
+
sleep(ms).then(() => {
|
|
123
|
+
cleanup();
|
|
124
|
+
resolve();
|
|
125
|
+
}, (cause) => {
|
|
126
|
+
cleanup();
|
|
127
|
+
reject(cause);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
108
131
|
export async function openingExplorer(chess, db, speeds, ratings, options = {}) {
|
|
132
|
+
const callerSignal = options.signal;
|
|
133
|
+
throwIfAborted(callerSignal);
|
|
109
134
|
const token = options.token ?? process.env.LICHESS_TOKEN ?? "";
|
|
110
135
|
if (!token)
|
|
111
136
|
throw error("disabled");
|
|
@@ -136,10 +161,12 @@ export async function openingExplorer(chess, db, speeds, ratings, options = {})
|
|
|
136
161
|
const legalMoves = new Map(chess.moves({ verbose: true }).map((move) => [move.lan, move.san]));
|
|
137
162
|
let lastError = error("network");
|
|
138
163
|
for (let attempt = 0; attempt < EXPLORER_MAX_ATTEMPTS; attempt += 1) {
|
|
164
|
+
throwIfAborted(callerSignal);
|
|
139
165
|
const remaining = deadline - now();
|
|
140
166
|
if (remaining <= 0)
|
|
141
167
|
throw error("timeout");
|
|
142
|
-
const
|
|
168
|
+
const attemptSignal = timeout(Math.max(1, Math.min(EXPLORER_ATTEMPT_TIMEOUT_MS, remaining)));
|
|
169
|
+
const signal = AbortSignal.any(callerSignal ? [callerSignal, attemptSignal] : [attemptSignal]);
|
|
143
170
|
let response;
|
|
144
171
|
try {
|
|
145
172
|
response = await request(url, {
|
|
@@ -148,13 +175,15 @@ export async function openingExplorer(chess, db, speeds, ratings, options = {})
|
|
|
148
175
|
});
|
|
149
176
|
}
|
|
150
177
|
catch {
|
|
178
|
+
throwIfAborted(callerSignal);
|
|
151
179
|
lastError = error(signal.aborted ? "timeout" : "network");
|
|
152
180
|
if (attempt + 1 >= EXPLORER_MAX_ATTEMPTS)
|
|
153
181
|
throw lastError;
|
|
154
182
|
const delay = Math.min(EXPLORER_DEFAULT_RETRY_DELAY_MS, Math.max(0, deadline - now()));
|
|
155
|
-
await sleep
|
|
183
|
+
await sleepWithSignal(sleep, delay, callerSignal);
|
|
156
184
|
continue;
|
|
157
185
|
}
|
|
186
|
+
throwIfAborted(callerSignal);
|
|
158
187
|
if (!response.ok) {
|
|
159
188
|
const kind = response.status === 401 || response.status === 403
|
|
160
189
|
? "auth"
|
|
@@ -172,7 +201,7 @@ export async function openingExplorer(chess, db, speeds, ratings, options = {})
|
|
|
172
201
|
if (delay > EXPLORER_MAX_RETRY_DELAY_MS || delay >= retryBudget) {
|
|
173
202
|
throw lastError;
|
|
174
203
|
}
|
|
175
|
-
await sleep
|
|
204
|
+
await sleepWithSignal(sleep, delay, callerSignal);
|
|
176
205
|
continue;
|
|
177
206
|
}
|
|
178
207
|
let body;
|
|
@@ -180,17 +209,19 @@ export async function openingExplorer(chess, db, speeds, ratings, options = {})
|
|
|
180
209
|
body = await response.json();
|
|
181
210
|
}
|
|
182
211
|
catch (cause) {
|
|
212
|
+
throwIfAborted(callerSignal);
|
|
183
213
|
if (signal.aborted || cause instanceof TypeError) {
|
|
184
214
|
lastError = error(signal.aborted ? "timeout" : "network");
|
|
185
215
|
if (attempt + 1 < EXPLORER_MAX_ATTEMPTS) {
|
|
186
216
|
const delay = Math.min(EXPLORER_DEFAULT_RETRY_DELAY_MS, Math.max(0, deadline - now()));
|
|
187
|
-
await sleep
|
|
217
|
+
await sleepWithSignal(sleep, delay, callerSignal);
|
|
188
218
|
continue;
|
|
189
219
|
}
|
|
190
220
|
throw lastError;
|
|
191
221
|
}
|
|
192
222
|
throw error("invalid_response");
|
|
193
223
|
}
|
|
224
|
+
throwIfAborted(callerSignal);
|
|
194
225
|
const parsed = responseSchema.safeParse(body);
|
|
195
226
|
if (!parsed.success)
|
|
196
227
|
throw error("invalid_response");
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ChessError } from "./errors.js";
|
|
2
|
+
export class HttpWorkAdmission {
|
|
3
|
+
max;
|
|
4
|
+
maxPerSession;
|
|
5
|
+
#active = 0;
|
|
6
|
+
constructor(max, maxPerSession) {
|
|
7
|
+
this.max = max;
|
|
8
|
+
this.maxPerSession = maxPerSession;
|
|
9
|
+
}
|
|
10
|
+
session(lifecycle) {
|
|
11
|
+
let active = 0;
|
|
12
|
+
return async (request, work) => {
|
|
13
|
+
const signal = AbortSignal.any([request, lifecycle]);
|
|
14
|
+
signal.throwIfAborted();
|
|
15
|
+
if (active >= this.maxPerSession) {
|
|
16
|
+
throw new ChessError("SERVER_BUSY", "MCP session work limit reached");
|
|
17
|
+
}
|
|
18
|
+
if (this.#active >= this.max) {
|
|
19
|
+
throw new ChessError("SERVER_BUSY", "server work limit reached");
|
|
20
|
+
}
|
|
21
|
+
active += 1;
|
|
22
|
+
this.#active += 1;
|
|
23
|
+
try {
|
|
24
|
+
const result = await work(signal);
|
|
25
|
+
signal.throwIfAborted();
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
active -= 1;
|
|
30
|
+
this.#active -= 1;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|