llm-chess-mcp 0.4.0 → 0.4.3
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 +96 -2
- package/dist/chess.js +17 -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 +341 -14
- package/dist/intents.js +15 -10
- package/dist/maia3/inference.js +6 -1
- package/dist/services.js +2 -2
- package/dist/tool-meta.js +2 -2
- package/dist/tool-result.js +12 -4
- package/dist/tool-schemas.js +6 -1
- package/dist/tools/analysis.js +17 -9
- 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 +62 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -98,6 +98,80 @@ does not provide authentication or TLS; use a trusted network or an
|
|
|
98
98
|
authenticated reverse proxy when exposing it beyond localhost. Origin values
|
|
99
99
|
are validated when present, but the server does not emit browser CORS headers.
|
|
100
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
|
+
|
|
101
175
|
### Export Maia3 to ONNX (build-time only)
|
|
102
176
|
|
|
103
177
|
This step needs Python + PyTorch once. It downloads the Maia3 checkpoint, verifies
|
|
@@ -205,7 +279,7 @@ codex mcp add llm-chess-mcp --command npx --args -y llm-chess-mcp --env LICHESS_
|
|
|
205
279
|
| `game_legal_moves` | All legal moves with metadata |
|
|
206
280
|
| `game_pgn` | Export the game as PGN |
|
|
207
281
|
| `game_import_pgn` | Import a PGN into a new game |
|
|
208
|
-
| `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 |
|
|
209
283
|
| `human_move_distribution` | Maia3 human-move probabilities at a target Elo |
|
|
210
284
|
| `move_evaluate` | Score one or more moves + cpLoss + classification |
|
|
211
285
|
| `move_candidates` | **Primary tool**: unified candidates (objective + human + opening) |
|
|
@@ -233,6 +307,9 @@ human-readable summary and must not be parsed as data.
|
|
|
233
307
|
`best / excellent / good / inaccuracy / mistake / blunder`.
|
|
234
308
|
- `maia3Prob` is a **human-likelihood**, not move quality. A high-probability move
|
|
235
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.
|
|
236
313
|
|
|
237
314
|
## Candidate structure
|
|
238
315
|
|
|
@@ -292,10 +369,27 @@ rejected:
|
|
|
292
369
|
|
|
293
370
|
## Runtime limits
|
|
294
371
|
|
|
295
|
-
- Up to 1,000
|
|
372
|
+
- Up to 1,000 games are retained per process; idle games expire after one hour.
|
|
296
373
|
- `move_evaluate` accepts at most 10 moves per call.
|
|
297
374
|
- Imported PGNs are limited to 1 MiB and 4,096 plies.
|
|
298
375
|
- Stockfish accepts up to 32 active or queued analyses.
|
|
376
|
+
- HTTP retains at most 64 MCP sessions; sessions with no POST activity expire
|
|
377
|
+
after 30 minutes.
|
|
378
|
+
- HTTP accepts bodies up to 2 MiB. It permits 16 concurrent POSTs and downstream
|
|
379
|
+
compute/network jobs process-wide, with two of each per session. Work keeps
|
|
380
|
+
its slot after a raw disconnect until it settles. HTTP also caps connections
|
|
381
|
+
at 128 and applies bounded header, upload, socket, and keep-alive timeouts.
|
|
382
|
+
|
|
383
|
+
Programmatic users can override the HTTP limits through `HttpServerOptions`.
|
|
384
|
+
These safeguards do not replace public-edge quotas: a public deployment must
|
|
385
|
+
still enforce request, connection, and authentication limits at the reverse
|
|
386
|
+
proxy.
|
|
387
|
+
|
|
388
|
+
MCP cancellation notifications, session deletion, and server shutdown propagate
|
|
389
|
+
to Stockfish, Maia, and Lichess work. Stockfish stops safely at its UCI queue
|
|
390
|
+
boundary; Lichess fetch and retry waits abort immediately. ONNX Runtime cannot
|
|
391
|
+
interrupt an inference already executing, so Maia discards its result after the
|
|
392
|
+
native call returns. A raw HTTP disconnect alone is not a cancellation signal.
|
|
299
393
|
|
|
300
394
|
## Intents
|
|
301
395
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|