echelongraph-mcp 2.6.2 → 2.6.5
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/CHANGELOG.md +254 -0
- package/README.md +169 -35
- package/dist/http.js +111 -33
- package/dist/httpPolicy.js +160 -4
- package/dist/index.js +240 -66
- package/dist/prompts.js +60 -10
- package/dist/resources.js +62 -11
- package/dist/runtime.js +26 -4
- package/dist/textBudget.js +254 -0
- package/dist/tools/check_affected.js +64 -2
- package/dist/tools/check_sbom.js +105 -9
- package/dist/tools/cve_intel.js +2 -1
- package/dist/tools/epss_history.js +2 -1
- package/dist/tools/get_cwe.js +11 -2
- package/dist/tools/kev_recent.js +26 -2
- package/dist/tools/vendor_advisories.js +206 -17
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/httpPolicy.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// tested on its own: who the client is, which Origins are served, the per-client limit, and the
|
|
3
3
|
// JSON-RPC error bodies the edge answers with.
|
|
4
4
|
import { isIP } from "node:net";
|
|
5
|
+
import { classifyInboundRequest } from "@modelcontextprotocol/server";
|
|
5
6
|
// ── JSON-RPC errors from the edge ──
|
|
6
7
|
//
|
|
7
8
|
// Every refusal at the edge is a JSON-RPC error object, never an HTML page and never a bare
|
|
@@ -10,7 +11,7 @@ import { isIP } from "node:net";
|
|
|
10
11
|
export const ORIGIN_REFUSED_CODE = -32000; // the SDK's own code for an Origin refusal (originValidationResponse)
|
|
11
12
|
export const RATE_LIMITED_CODE = -32029; // implementation-defined server error range (-32000..-32099); "429"
|
|
12
13
|
export const BODY_TOO_LARGE_CODE = -32600; // Invalid Request
|
|
13
|
-
export const BUSY_CODE = -32030; // implementation-defined server error range; "503"
|
|
14
|
+
export const BUSY_CODE = -32030; // implementation-defined server error range; "503": no large-body slot free (#2747), or the instance full (#2773)
|
|
14
15
|
export function rpcError(code, message, data) {
|
|
15
16
|
return JSON.stringify({ jsonrpc: "2.0", id: null, error: { code, message, ...(data ? { data } : {}) } });
|
|
16
17
|
}
|
|
@@ -227,9 +228,11 @@ export class FixedWindowLimiter {
|
|
|
227
228
|
// 2,000-purl ~2 MB body +62 MB; one 5.47 MB body +76 MB; two 5.47 MB bodies at once 209 MB in
|
|
228
229
|
// all, also with V8's old space held to 64 MB: ~59 MB of 256 MiB left for everything else, so
|
|
229
230
|
// not a third. So a body past 64 KiB must hold one of DEFAULT_LARGE_BODY_SLOTS (2) slots per
|
|
230
|
-
// instance for as long as it is read and served; with none free it
|
|
231
|
-
// Retry-After, never queued. The per-client limit still counts each large
|
|
232
|
-
// request, before its body is read.
|
|
231
|
+
// instance for as long as it is read, waits for a place (below) and is served; with none free it
|
|
232
|
+
// is answered 503 with Retry-After, never queued. The per-client limit still counts each large
|
|
233
|
+
// request as one request, before its body is read. That budget measured the two bodies alone, not
|
|
234
|
+
// the requests served beside them, which were enough to kill the instance (#2773): "Requests in
|
|
235
|
+
// flight per instance" below bounds those and records what each costs.
|
|
233
236
|
export const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
|
|
234
237
|
export const DEFAULT_MAX_SBOM_BODY_BYTES = 6 * 1024 * 1024;
|
|
235
238
|
export const DEFAULT_LARGE_BODY_SLOTS = 2;
|
|
@@ -273,6 +276,159 @@ export class Slots {
|
|
|
273
276
|
return this.used;
|
|
274
277
|
}
|
|
275
278
|
}
|
|
279
|
+
// ── Requests in flight per instance (#2773) ──
|
|
280
|
+
//
|
|
281
|
+
// The slots bound the large bodies, not the requests served beside them. Cloud Run sends one
|
|
282
|
+
// instance up to 250 requests at once (deploy-all.sh SVC_CONCURRENCY) into 256 MiB. Measured
|
|
283
|
+
// 2026-10-04 on dist/http.js, Node 22.22, in a 256 MiB memory cgroup, against a stub API
|
|
284
|
+
// answering in 1.5 s, two 4,999,913-character check_sbom documents in flight plus N tools/call
|
|
285
|
+
// cve_summary at once, 2025-era (answered over SSE, the costlier era): N=100 peaked at 226 MB,
|
|
286
|
+
// N=125 at 259 MB (96%), N=200 and N=250 were OOM-killed; with no document, N=200 peaked at
|
|
287
|
+
// 240 MB and N=250 was OOM-killed.
|
|
288
|
+
//
|
|
289
|
+
// Two bounds hold that load (test/http-memory.test.mjs drives the real server with it; what each
|
|
290
|
+
// case does without each bound is below):
|
|
291
|
+
// - V8's heap. In that sandbox (a cgroup v1 on a 16 GB host) Node 22 sized V8's heap from the
|
|
292
|
+
// host's memory, not the cgroup's (heap_size_limit 8,195 MiB in 256 MiB), so V8 left the
|
|
293
|
+
// garbage of the requests served (~1 MB each) uncollected until the kernel killed the
|
|
294
|
+
// instance. So the image caps V8's old space at 128 MiB (Dockerfile.http), and V8 collects
|
|
295
|
+
// first. On Cloud Run this is not verified: an instance that shows Node only its 256 MiB gets
|
|
296
|
+
// a default heap near the cap already, and then the cap changes little. Its start-up line says
|
|
297
|
+
// which (heap_limit_mb; memory_limit_mb, null where Node is told no limit).
|
|
298
|
+
// - The live set. Under the cap, what must fit is what is live at once, and 250 requests at once
|
|
299
|
+
// do not: with every answer held until all 250 were inside the instance, V8 aborted
|
|
300
|
+
// ("JavaScript heap out of memory"). So an instance serves at most DEFAULT_MAX_IN_FLIGHT /mcp
|
|
301
|
+
// requests at once, large ones included (they hold a slot as well), whatever its heap.
|
|
302
|
+
// A request's body is read before it waits for a place, so a client that uploads slowly holds its
|
|
303
|
+
// socket (and a large body its slot), never a place. It then waits, first come first served, up
|
|
304
|
+
// to DEFAULT_ADMISSION_WAIT_MS for one to finish, and is then answered 503 with Retry-After: it
|
|
305
|
+
// waits rather than being refused at once because Cloud Run still routes up to 250 to the
|
|
306
|
+
// instance and counts a waiting request toward scaling out. A waiting request costs its socket and
|
|
307
|
+
// its body: at most 64 KiB, or a large body that holds a slot. At most DEFAULT_MAX_WAITING wait;
|
|
308
|
+
// past that, 503 at once. A request counts until its answer is written AND its work has stopped: a
|
|
309
|
+
// check_sbom call whose client has gone keeps its place until it stops (#2775). /health is never
|
|
310
|
+
// held, and nor is a subscriptions/listen stream (isListenStream below).
|
|
311
|
+
//
|
|
312
|
+
// With both, in that cgroup, the two documents at the API, then 248 cve_summary sent at once:
|
|
313
|
+
// - the API answering in 1.5 s: all 250 answered 200, 64 served at once and the last waiting
|
|
314
|
+
// ~9 s (one core here serves about 28 a second, so on a slower one the last of such a burst
|
|
315
|
+
// wait past DEFAULT_ADMISSION_WAIT_MS and are answered 503), cgroup peak 221-225 MB of 268 MB
|
|
316
|
+
// (82-84%). Without the heap cap: OOM-killed in three runs of four, the fourth at 248 MB.
|
|
317
|
+
// Without the admission: V8 aborted, four of four.
|
|
318
|
+
// - every answer held until all 250 were inside the instance at once: 64 served and answered
|
|
319
|
+
// 200, the other 186 answered 503 after waiting 10 s, peak 201-208 MB (75-77%), and 204-206 MB
|
|
320
|
+
// without the heap cap. Without the admission: V8 aborted, two of two.
|
|
321
|
+
// What this does not bound is a request whose ANSWER is large: a check_sbom call of 2,000 purls
|
|
322
|
+
// fits a 64 KiB body, and with production-shaped rows (~2.2 KB each) one peaks at +77 MB; beside
|
|
323
|
+
// the two documents, 8 at once peaked at 84%, and 16 at once exhausted the 128 MiB heap. Bounding
|
|
324
|
+
// that needs a weight per call (its purls), not a count of requests.
|
|
325
|
+
export const DEFAULT_MAX_IN_FLIGHT = 64;
|
|
326
|
+
export const DEFAULT_ADMISSION_WAIT_MS = 10_000;
|
|
327
|
+
export const DEFAULT_MAX_WAITING = 250;
|
|
328
|
+
/**
|
|
329
|
+
* A counting semaphore whose callers may wait, in arrival order, for a bounded time. acquire
|
|
330
|
+
* resolves true once admitted (the caller then calls release() exactly once), or false when the
|
|
331
|
+
* wait timed out, `signal` aborted, or maxWaiting callers were already waiting.
|
|
332
|
+
*/
|
|
333
|
+
export class Admission {
|
|
334
|
+
max;
|
|
335
|
+
maxWaiting;
|
|
336
|
+
used = 0;
|
|
337
|
+
queue = [];
|
|
338
|
+
constructor(max, maxWaiting) {
|
|
339
|
+
this.max = max;
|
|
340
|
+
this.maxWaiting = maxWaiting;
|
|
341
|
+
}
|
|
342
|
+
acquire(waitMs, signal) {
|
|
343
|
+
if (this.used < this.max && this.queue.length === 0) {
|
|
344
|
+
this.used++;
|
|
345
|
+
return Promise.resolve(true);
|
|
346
|
+
}
|
|
347
|
+
if (signal?.aborted || waitMs <= 0 || this.queue.length >= this.maxWaiting)
|
|
348
|
+
return Promise.resolve(false);
|
|
349
|
+
return new Promise((resolve) => {
|
|
350
|
+
// admit is called by release(), which hands its unit straight over (used is unchanged).
|
|
351
|
+
const admit = () => {
|
|
352
|
+
done();
|
|
353
|
+
resolve(true);
|
|
354
|
+
};
|
|
355
|
+
const giveUp = () => {
|
|
356
|
+
const i = this.queue.indexOf(admit);
|
|
357
|
+
if (i >= 0)
|
|
358
|
+
this.queue.splice(i, 1);
|
|
359
|
+
done();
|
|
360
|
+
resolve(false);
|
|
361
|
+
};
|
|
362
|
+
const timer = setTimeout(giveUp, waitMs);
|
|
363
|
+
const done = () => {
|
|
364
|
+
clearTimeout(timer);
|
|
365
|
+
signal?.removeEventListener("abort", giveUp);
|
|
366
|
+
};
|
|
367
|
+
signal?.addEventListener("abort", giveUp, { once: true });
|
|
368
|
+
this.queue.push(admit);
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
release() {
|
|
372
|
+
const next = this.queue.shift();
|
|
373
|
+
if (next)
|
|
374
|
+
next();
|
|
375
|
+
else if (this.used > 0)
|
|
376
|
+
this.used--;
|
|
377
|
+
}
|
|
378
|
+
get inUse() {
|
|
379
|
+
return this.used;
|
|
380
|
+
}
|
|
381
|
+
get waiting() {
|
|
382
|
+
return this.queue.length;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// ── The request that is a stream: subscriptions/listen ──
|
|
386
|
+
//
|
|
387
|
+
// A 2026-07-28 client opens subscriptions/listen to hear of list changes, and the SDK answers it
|
|
388
|
+
// with an SSE stream that stays open until the client leaves (or the server closes). This server
|
|
389
|
+
// declares no listChanged (index.ts), so the stream carries the acknowledgement and then only
|
|
390
|
+
// the SDK's keepalives: no work, no API call. Admitted like any other request, it held its place
|
|
391
|
+
// for as long as it was open, so 64 idle listeners (under the per-client limit) left every
|
|
392
|
+
// tools/call, initialize and server/discover on the instance waiting 10 s for a 503 (#2773). So it
|
|
393
|
+
// is relayed without admission and without a work hold. What bounds the streams instead: the
|
|
394
|
+
// per-client limit, which counts each one when it opens; the SDK's own cap of
|
|
395
|
+
// DEFAULT_MAX_SUBSCRIPTIONS (1024 in @modelcontextprotocol/server 2.1.0) open per process, past
|
|
396
|
+
// which it answers JSON-RPC -32603 "Subscription limit reached" at once; and on Cloud Run the
|
|
397
|
+
// instance's 250 requests at once and its 60 s request timeout, which ends every stream
|
|
398
|
+
// (deploy-all.sh SVC_CONCURRENCY, --timeout). Measured 2026-10-04 on dist/http.js, Node 22.22,
|
|
399
|
+
// started as Dockerfile.http starts it, in a 256 MiB memory cgroup, with every place taken (two
|
|
400
|
+
// maximum check_sbom documents and 62 cve_summary held at the API): beside 186 open listen
|
|
401
|
+
// streams (Cloud Run's 250 at once) the cgroup peaked at 209-218 MB of 268 MB, and beside 1,024
|
|
402
|
+
// (the SDK's cap) at 224-225 MB, never OOM-killed. Within Cloud Run's 250, a listen stream
|
|
403
|
+
// takes the instance's concurrency that a waiting request would, and Cloud Run scales out.
|
|
404
|
+
//
|
|
405
|
+
// It is the only such request. GET and DELETE /mcp (the 2025 session stream and its end) are
|
|
406
|
+
// answered 405 at once by the SDK's stateless legacy fallback, a 2025-era POST's SSE answer ends
|
|
407
|
+
// with its result, and the server registers no task or resource subscription. Which request is a
|
|
408
|
+
// listen stream is decided by the SDK's own routing step (classifyInboundRequest, what
|
|
409
|
+
// createMcpHandler runs), so the two cannot disagree: a 2025-era body naming the method goes to
|
|
410
|
+
// the legacy fallback, which answers it at once, and it waits for a place like any request.
|
|
411
|
+
export const LISTEN_METHOD = "subscriptions/listen";
|
|
412
|
+
/** Whether a read POST body is one the SDK serves as a subscriptions/listen stream: see the section comment. */
|
|
413
|
+
export function isListenStream(facts, body, headers) {
|
|
414
|
+
if (facts.rpc_method !== LISTEN_METHOD)
|
|
415
|
+
return false;
|
|
416
|
+
let parsed;
|
|
417
|
+
try {
|
|
418
|
+
parsed = JSON.parse(body.toString("utf8"));
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
const route = classifyInboundRequest({
|
|
424
|
+
httpMethod: "POST",
|
|
425
|
+
protocolVersionHeader: headers.protocolVersion,
|
|
426
|
+
mcpMethodHeader: headers.mcpMethod,
|
|
427
|
+
mcpNameHeader: headers.mcpName,
|
|
428
|
+
body: parsed,
|
|
429
|
+
});
|
|
430
|
+
return route.kind === "modern" && route.messageKind === "request" && route.message.method === LISTEN_METHOD;
|
|
431
|
+
}
|
|
276
432
|
// ── Bounded access-log fields ──
|
|
277
433
|
//
|
|
278
434
|
// The access line carries no body, no query string, no argument and no client address: only
|