echelongraph-mcp 3.0.2 → 3.1.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/dist/http.js CHANGED
@@ -36,8 +36,9 @@ import http from "node:http";
36
36
  import http2 from "node:http2";
37
37
  import v8 from "node:v8";
38
38
  import { createMcpHandler } from "@modelcontextprotocol/server";
39
- import { forwardToken, markHttpEntrypoint, requestSettled, SYNTHETIC_UA_FAMILY, withClient } from "./runtime.js";
40
- import { Admission, BODY_TOO_LARGE_CODE, BUSY_CODE, DEFAULT_ADMISSION_WAIT_MS, DEFAULT_LARGE_BODY_SLOTS, DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_IN_FLIGHT, DEFAULT_MAX_SBOM_BODY_BYTES, DEFAULT_MAX_WAITING, DEFAULT_RATE_LIMIT_PER_MIN, LARGE_BODY_TARGETS_TEXT, Slots, FixedWindowLimiter, ORIGIN_REFUSED_CODE, RATE_LIMITED_CODE, bodyFacts, handlerErrorClass, protocolErrorCode, originVerdict, resolveClient, revisionLabel, rpcError, isLargeBodyTarget, isListenStream, largeBodyAdmissible, uaFamily, } from "./httpPolicy.js";
39
+ import { forwardToken, markHttpEntrypoint, onUpstreamFailure, requestLabels, requestSettled, SYNTHETIC_UA_FAMILY, withClient } from "./runtime.js";
40
+ import { Admission, apiRoute, BODY_TOO_LARGE_CODE, BUSY_CODE, DEFAULT_ADMISSION_WAIT_MS, DEFAULT_LARGE_BODY_SLOTS, DEFAULT_LARGE_BODY_WAIT_MS, DEFAULT_LARGE_BODY_WAITERS, DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_IN_FLIGHT, DEFAULT_MAX_SBOM_BODY_BYTES, DEFAULT_MAX_WAITING, DEFAULT_RATE_LIMIT_PER_MIN, LARGE_BODY_TARGETS_TEXT, Slots, FixedWindowLimiter, ORIGIN_REFUSED_CODE, RATE_LIMITED_CODE, bodyFacts, handlerErrorClass, httpMethodLabel, protocolErrorCode, originVerdict, resolveClient, revisionLabel, rpcError, rstCodeLabel, isLargeBodyTarget, isListenStream, uaFamily, upstreamSeverity, } from "./httpPolicy.js";
41
+ import { bodyReader, header } from "./readBody.js";
41
42
  // Must run before index.ts is evaluated: it is how index.ts knows not to start stdio.
42
43
  markHttpEntrypoint();
43
44
  const { createServer, NAME, VERSION, SHOWN_BASE, TIMEOUT_MS } = await import("./index.js");
@@ -54,22 +55,45 @@ const RATE_LIMIT_PER_MIN = positiveInt(process.env.MCP_RATE_LIMIT_PER_MIN, DEFAU
54
55
  const MAX_BODY_BYTES = positiveInt(process.env.MCP_MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
55
56
  const MAX_SBOM_BODY_BYTES = Math.max(MAX_BODY_BYTES, positiveInt(process.env.MCP_MAX_SBOM_BODY_BYTES, DEFAULT_MAX_SBOM_BODY_BYTES));
56
57
  const LARGE_BODY_SLOTS = positiveInt(process.env.MCP_LARGE_BODY_SLOTS, DEFAULT_LARGE_BODY_SLOTS);
58
+ // #2775: how long a large body that finds every slot busy waits for one (at most DEFAULT_LARGE_BODY_WAITERS wait).
59
+ const LARGE_BODY_WAIT_MS = positiveInt(process.env.MCP_LARGE_BODY_WAIT_MS, DEFAULT_LARGE_BODY_WAIT_MS);
57
60
  // The requests served at once, and how long the next may wait (#2773): httpPolicy.ts "Requests
58
61
  // in flight per instance".
59
62
  const MAX_IN_FLIGHT = positiveInt(process.env.MCP_MAX_IN_FLIGHT, DEFAULT_MAX_IN_FLIGHT);
60
63
  const ADMISSION_WAIT_MS = positiveInt(process.env.MCP_ADMISSION_WAIT_MS, DEFAULT_ADMISSION_WAIT_MS);
61
- // A large body's wait for a slot is not queued: the client is told to come back. Nor is a
62
- // request that waited ADMISSION_WAIT_MS for admission.
64
+ // A large body that waited LARGE_BODY_WAIT_MS for a slot (or found two waiting already), and a
65
+ // request that waited ADMISSION_WAIT_MS for admission, are told to come back.
63
66
  const BUSY_RETRY_AFTER_SEC = 5;
64
67
  // #2775: serve h2c instead of HTTP/1.1 (see the top of this file). Exactly "1": anything else is
65
68
  // HTTP/1.1, the default for a local run and for every test that does not ask.
66
69
  const H2C = process.env.MCP_H2C === "1";
67
70
  const MCP_PATH = "/mcp";
68
71
  const HEALTH_PATH = "/health";
69
- // One JSON object per line on stdout: Cloud Logging reads `severity` and `message`.
70
- function log(severity, message, fields = {}) {
72
+ // One JSON object per line on stdout: Cloud Logging reads `severity` and `message`. #2863: the message is one of
73
+ // logLines.ts LOG_LINES and the fields are exactly that line's keys, or this does not compile: a new line or a new
74
+ // key is added there, where what each line carries is reviewed against /privacy Section 11 and the README.
75
+ function log(severity, message, fields) {
71
76
  process.stdout.write(`${JSON.stringify({ severity, message, ...fields })}\n`);
72
77
  }
78
+ // #2948: one line per API call that failed (runtime.ts upstreamFailed, from index.ts api()), so an
79
+ // answer that says "could not be reached" is on our record too, with its cause. The route is a
80
+ // template (httpPolicy.ts apiRoute): never the path, which can hold what was looked up, nor the
81
+ // query string. tool and rpc_method are the request's bodyFacts (a shape-checked tool, a method from
82
+ // httpPolicy.ts MCP_METHODS or "(other)"); synthetic says the request was the production synthetic's (its User-Agent family). WARNING, except the API's own
83
+ // answer about the input (httpPolicy.ts upstreamSeverity). Written before the request's own
84
+ // mcp_request line, which still says status 200: the JSON-RPC call itself succeeded.
85
+ onUpstreamFailure((f) => {
86
+ const route = apiRoute(f.path);
87
+ log(upstreamSeverity(f.failureClass, f.status, route), "mcp_upstream_failed", {
88
+ tool: f.tool,
89
+ rpc_method: f.rpcMethod,
90
+ route,
91
+ failure_class: f.failureClass,
92
+ status: f.status,
93
+ after_ms: f.afterMs,
94
+ synthetic: f.synthetic,
95
+ });
96
+ });
73
97
  if (process.env.MCP_REQUIRE_FORWARD_TOKEN === "1" && !forwardToken()) {
74
98
  log("ERROR", "mcp_remote_refusing_to_start", {
75
99
  reason: "MCP_REQUIRE_FORWARD_TOKEN=1 but ECHELONGRAPH_FORWARD_TOKEN is empty: the API would count every remote user as this one service (#2212)",
@@ -85,10 +109,22 @@ const handler = createMcpHandler(createServer, {
85
109
  maxRequestBodySize: MAX_SBOM_BODY_BYTES,
86
110
  // Never the message: an SDK error message can quote what the client sent. A fixed label and
87
111
  // the JSON-RPC code instead, so each one can be told apart (#2923).
88
- onerror: (e) => log("WARNING", "mcp_handler_error", { error_name: e.name, error_class: handlerErrorClass(e), error_code: protocolErrorCode(e) }),
112
+ // #2923: the error by a fixed class and its code, never its message; and the request it arose in by its fixed labels,
113
+ // read from the request's scope (runtime.ts requestLabels): its JSON-RPC method (MCP_METHODS or "(other)"), its
114
+ // protocol version.
115
+ onerror: (e) => {
116
+ const labels = requestLabels();
117
+ log("WARNING", "mcp_handler_error", {
118
+ error_name: e.name,
119
+ error_class: handlerErrorClass(e),
120
+ error_code: protocolErrorCode(e),
121
+ rpc_method: labels.rpcMethod,
122
+ protocol_version: labels.protocolVersion,
123
+ });
124
+ },
89
125
  });
90
126
  const limiter = new FixedWindowLimiter(RATE_LIMIT_PER_MIN);
91
- const largeBodies = new Slots(LARGE_BODY_SLOTS);
127
+ const largeBodies = new Slots(LARGE_BODY_SLOTS, DEFAULT_LARGE_BODY_WAITERS);
92
128
  const admission = new Admission(MAX_IN_FLIGHT, DEFAULT_MAX_WAITING);
93
129
  // CORS for a browser client on an https Origin (the Origin policy has already admitted it).
94
130
  // A wildcard is safe here because nothing is credentialed: no cookies, no auth.
@@ -116,76 +152,19 @@ function send(res, status, body, headers = {}) {
116
152
  res.writeHead(status, forProtocol(res, { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers }));
117
153
  res.end(body);
118
154
  }
119
- function readBody(req) {
120
- const mayBeLarge = largeBodyAdmissible(header(req, "mcp-method"), header(req, "mcp-name"));
121
- const cap = mayBeLarge ? MAX_SBOM_BODY_BYTES : MAX_BODY_BYTES;
122
- const declared = Number(req.headers["content-length"]);
123
- if (Number.isFinite(declared) && declared > cap) {
124
- req.resume();
125
- return Promise.resolve("too_large");
126
- }
127
- let slot = false;
128
- if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
129
- if (!largeBodies.tryAcquire()) {
130
- req.resume();
131
- return Promise.resolve("busy");
132
- }
133
- slot = true;
134
- }
135
- return new Promise((resolve, reject) => {
136
- const chunks = [];
137
- let size = 0;
138
- let done = false;
139
- const stop = (r) => {
140
- done = true;
141
- chunks.length = 0;
142
- if (slot)
143
- largeBodies.release();
144
- slot = false;
145
- resolve(r);
146
- };
147
- req.on("data", (chunk) => {
148
- if (done)
149
- return;
150
- size += chunk.length;
151
- if (size > cap)
152
- return stop("too_large");
153
- if (size > MAX_BODY_BYTES && !slot) {
154
- if (!largeBodies.tryAcquire())
155
- return stop("busy");
156
- slot = true;
157
- }
158
- chunks.push(chunk);
159
- });
160
- req.on("end", () => {
161
- if (done)
162
- return;
163
- done = true;
164
- resolve({ body: Buffer.concat(chunks), slot });
165
- });
166
- req.on("close", () => {
167
- if (!done)
168
- stop("gone");
169
- });
170
- req.on("error", (e) => {
171
- if (done)
172
- return;
173
- done = true;
174
- if (slot)
175
- largeBodies.release();
176
- reject(e);
177
- });
178
- });
179
- }
180
- function header(req, name) {
181
- const v = req.headers[name];
182
- return Array.isArray(v) ? v.join(", ") : v;
183
- }
155
+ // The body is read by readBody.ts, over this instance's large-body slots and caps.
156
+ const readBody = bodyReader(largeBodies, { maxBodyBytes: MAX_BODY_BYTES, maxSbomBodyBytes: MAX_SBOM_BODY_BYTES, largeBodyWaitMs: LARGE_BODY_WAIT_MS });
184
157
  function tooLarge(res, cors) {
185
158
  send(res, 413, rpcError(BODY_TOO_LARGE_CODE, `Request body too large: at most ${MAX_BODY_BYTES} bytes, or ${MAX_SBOM_BODY_BYTES} bytes for ${LARGE_BODY_TARGETS_TEXT}. A larger SBOM or lockfile can be passed to check_sbom as its purls.`, { max_bytes: MAX_BODY_BYTES, max_bytes_sbom: MAX_SBOM_BODY_BYTES }), { ...cors, Connection: "close" });
186
159
  return { status: 413, refused: "body_size" };
187
160
  }
161
+ // #2775: the access line's slot_wait_ms is how long the request's large body waited for a slot, whatever came of it.
188
162
  async function serveMcp(req, res, pathname) {
163
+ const timing = { slotWaitMs: 0 };
164
+ const o = await serveMcpTimed(req, res, pathname, timing);
165
+ return { ...o, slotWaitMs: timing.slotWaitMs };
166
+ }
167
+ async function serveMcpTimed(req, res, pathname, timing) {
189
168
  const method = req.method ?? "GET";
190
169
  const origin = req.headers.origin;
191
170
  const verdict = originVerdict(Array.isArray(origin) ? origin.join(", ") : origin);
@@ -218,7 +197,8 @@ async function serveMcp(req, res, pathname) {
218
197
  // whether its answer had started (answer_started). On 2026-10-05 a canary behind Cloud Run's
219
198
  // --use-http2 logged its aborted calls 499 only when their answers were written, 15 and 34 s
220
199
  // in: this line tells a reset that arrived then from one that arrived early and was not acted on
221
- // (the access line's duration_ms is when the call actually stopped).
200
+ // (the access line's duration_ms is when the call actually stopped). rst_code is the stream's reset code when it is
201
+ // one HTTP/2 defines (0 to 13), else "(other)" (httpPolicy.ts rstCodeLabel): under h2c the client chooses it.
222
202
  const gone = new AbortController();
223
203
  const t0 = Date.now();
224
204
  const h2stream = res.stream;
@@ -226,7 +206,7 @@ async function serveMcp(req, res, pathname) {
226
206
  if (gone.signal.aborted)
227
207
  return;
228
208
  gone.abort();
229
- log("INFO", "mcp_client_gone", { after_ms: Date.now() - t0, answer_started: res.headersSent, rst_code: h2stream?.rstCode ?? null });
209
+ log("INFO", "mcp_client_gone", { after_ms: Date.now() - t0, answer_started: res.headersSent, rst_code: rstCodeLabel(h2stream?.rstCode) });
230
210
  };
231
211
  res.on("close", () => {
232
212
  if (!res.writableFinished || h2stream?.aborted)
@@ -250,7 +230,7 @@ async function serveMcp(req, res, pathname) {
250
230
  if (method === "POST") {
251
231
  let read;
252
232
  try {
253
- read = await readBody(req);
233
+ read = await readBody(req, gone.signal, timing);
254
234
  }
255
235
  catch (e) {
256
236
  // The request stream fails only when its connection does: the client went mid-upload.
@@ -275,12 +255,13 @@ async function serveMcp(req, res, pathname) {
275
255
  // is held to that.
276
256
  if (body.length > MAX_BODY_BYTES && !isLargeBodyTarget(facts))
277
257
  return { ...tooLarge(res, cors), body, facts };
278
- // A 2026-era subscriptions/listen is a stream open until its client leaves, carrying only
279
- // keepalives here: it takes no place and holds no work, or 64 idle ones would leave every
280
- // other request waiting for a 503 (#2773; httpPolicy.ts isListenStream says what bounds it).
258
+ // A 2026-era subscriptions/listen is a stream that does no work here (since SDK 2.2.0 it ends
259
+ // right after its acknowledgement; through 2.1.0 it stayed open until its client left): it
260
+ // takes no place and holds no work, or 64 idle ones would leave every other request waiting
261
+ // for a 503 (#2773; httpPolicy.ts isListenStream says what bounds it).
281
262
  const standard = { protocolVersion: header(req, "mcp-protocol-version"), mcpMethod: header(req, "mcp-method"), mcpName: header(req, "mcp-name") };
282
263
  if (isListenStream(facts, body, standard))
283
- return { ...(await relay(req, res, pathname, method, cors, client, body, gone.signal)), facts };
264
+ return { ...(await relay(req, res, pathname, method, cors, client, body, facts, gone.signal)), facts };
284
265
  }
285
266
  // #2773: at most MAX_IN_FLIGHT requests are served at once; the next waits here.
286
267
  const waitStart = Date.now();
@@ -298,7 +279,7 @@ async function serveMcp(req, res, pathname) {
298
279
  // large-body slot are held until all of it has settled, not only until the answer is written.
299
280
  const work = new Set();
300
281
  try {
301
- return { ...(await relay(req, res, pathname, method, cors, client, body, gone.signal, work)), facts, ...admitted };
282
+ return { ...(await relay(req, res, pathname, method, cors, client, body, facts, gone.signal, work)), facts, ...admitted };
302
283
  }
303
284
  finally {
304
285
  // Every tool call stops on `gone` (#2775), so this wait is long only if one does not.
@@ -313,7 +294,7 @@ async function serveMcp(req, res, pathname) {
313
294
  }
314
295
  // Hands one admitted request (or a listen stream, which is not admitted and passes no `work`) to
315
296
  // the SDK's handler and streams its answer back.
316
- async function relay(req, res, pathname, method, cors, client, body, gone, work) {
297
+ async function relay(req, res, pathname, method, cors, client, body, facts, gone, work) {
317
298
  const headers = new Headers();
318
299
  for (const [k, v] of Object.entries(req.headers)) {
319
300
  // ":method", ":path", ":authority", ":scheme": an h2 request's pseudo-headers, which a Request cannot carry.
@@ -332,7 +313,17 @@ async function relay(req, res, pathname, method, cors, client, body, gone, work)
332
313
  // carry the synthetic's fixed token (runtime.ts upstreamUserAgent), so they are never counted
333
314
  // as hosted adoption.
334
315
  const synthetic = uaFamily(req.headers["user-agent"]) === SYNTHETIC_UA_FAMILY;
335
- const response = await withClient(client.forwardAddress, () => handler.fetch(request), { synthetic, signal: gone, work });
316
+ // #2948: the method and tool name a failed API call's line names (runtime.ts upstreamFailed).
317
+ // #2923: and the protocol-version label a handler error's line names (runtime.ts requestLabels).
318
+ const protocolVersion = revisionLabel(req.headers["mcp-protocol-version"]);
319
+ const response = await withClient(client.forwardAddress, () => handler.fetch(request), {
320
+ synthetic,
321
+ signal: gone,
322
+ work,
323
+ rpcMethod: facts?.rpc_method,
324
+ tool: facts?.tool,
325
+ protocolVersion,
326
+ });
336
327
  const out = { ...cors };
337
328
  response.headers.forEach((v, k) => {
338
329
  out[k] = v;
@@ -362,7 +353,7 @@ function onRequest(req, res) {
362
353
  const finish = (o) => {
363
354
  const facts = o.facts ?? (o.body ? bodyFacts(o.body) : { rpc_method: "(none)" });
364
355
  log("INFO", "mcp_request", {
365
- method,
356
+ method: httpMethodLabel(method),
366
357
  path: pathname === MCP_PATH || pathname === HEALTH_PATH ? pathname : "(other)",
367
358
  status: o.status,
368
359
  duration_ms: Number((process.hrtime.bigint() - start) / 1000000n),
@@ -377,6 +368,7 @@ function onRequest(req, res) {
377
368
  client_closed: o.clientClosed === true,
378
369
  in_flight: o.inFlight ?? 0,
379
370
  queued_ms: o.queuedMs ?? 0,
371
+ slot_wait_ms: o.slotWaitMs ?? 0,
380
372
  });
381
373
  };
382
374
  if (pathname === HEALTH_PATH) {
@@ -438,6 +430,7 @@ server.listen(PORT, () => {
438
430
  max_body_bytes: MAX_BODY_BYTES,
439
431
  max_sbom_body_bytes: MAX_SBOM_BODY_BYTES,
440
432
  large_body_slots: LARGE_BODY_SLOTS,
433
+ large_body_wait_ms: LARGE_BODY_WAIT_MS,
441
434
  max_in_flight: MAX_IN_FLIGHT,
442
435
  admission_wait_ms: ADMISSION_WAIT_MS,
443
436
  heap_limit_mb: Math.round(heapLimit / MiB),
@@ -229,14 +229,21 @@ export class FixedWindowLimiter {
229
229
  // 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
230
230
  // all, also with V8's old space held to 64 MB: ~59 MB of 256 MiB left for everything else, so
231
231
  // not a third. So a body past 64 KiB must hold one of DEFAULT_LARGE_BODY_SLOTS (2) slots per
232
- // instance for as long as it is read, waits for a place (below) and is served; with none free it
233
- // is answered 503 with Retry-After, never queued. The per-client limit still counts each large
232
+ // instance for as long as it is read, waits for a place (below) and is served. With none free it
233
+ // waits up to DEFAULT_LARGE_BODY_WAIT_MS for one, at most DEFAULT_LARGE_BODY_WAITERS at once, and
234
+ // is answered 503 with Retry-After when its wait runs out, or at once when that many already wait
235
+ // (#2775, decided 2026-10-18: behind the Google Frontend a client's close reaches the container too
236
+ // late to free the slot of the call it abandoned, so its retry must be able to wait out that call;
237
+ // a waiter holds at most the 64 KiB read before it needed the slot, so two of them change nothing
238
+ // of the memory bound). The per-client limit still counts each large
234
239
  // request as one request, before its body is read. That budget measured the two bodies alone, not
235
240
  // the requests served beside them, which were enough to kill the instance (#2773): "Requests in
236
241
  // flight per instance" below bounds those and records what each costs.
237
242
  export const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
238
243
  export const DEFAULT_MAX_SBOM_BODY_BYTES = 6 * 1024 * 1024;
239
244
  export const DEFAULT_LARGE_BODY_SLOTS = 2;
245
+ export const DEFAULT_LARGE_BODY_WAIT_MS = 15_000;
246
+ export const DEFAULT_LARGE_BODY_WAITERS = 2;
240
247
  /** The requests whose body may pass DEFAULT_MAX_BODY_BYTES: JSON-RPC method → the tools or prompts it may name.
241
248
  * #2835: scan_manifest takes a project's lockfiles as text, up to the same 5,000,000 characters as
242
249
  * check_sbom's document (scan_manifest.ts MAX_MANIFEST_CHARS), so it is admitted the same way. */
@@ -259,12 +266,17 @@ export function isLargeBodyTarget(facts) {
259
266
  const name = facts.rpc_method === "tools/call" ? facts.tool : facts.rpc_method === "prompts/get" ? facts.prompt : undefined;
260
267
  return name !== undefined && targetsOf(facts.rpc_method).includes(name);
261
268
  }
262
- /** A counting semaphore that never waits: tryAcquire answers at once. */
269
+ /** A counting semaphore: tryAcquire answers at once; acquire (#2775) waits up to waitMs for a slot, first come first
270
+ * served, with at most maxWaiting waiting (past that it answers false at once). release hands a freed slot straight
271
+ * to the first waiter. */
263
272
  export class Slots {
264
273
  max;
274
+ maxWaiting;
265
275
  used = 0;
266
- constructor(max) {
276
+ queue = [];
277
+ constructor(max, maxWaiting = 0) {
267
278
  this.max = max;
279
+ this.maxWaiting = maxWaiting;
268
280
  }
269
281
  tryAcquire() {
270
282
  if (this.used >= this.max)
@@ -272,13 +284,50 @@ export class Slots {
272
284
  this.used++;
273
285
  return true;
274
286
  }
287
+ /** Whether acquire(waitMs) would wait for a slot, rather than answer at once (one is free, or none may wait). */
288
+ wouldWait(waitMs) {
289
+ return this.used >= this.max && waitMs > 0 && this.queue.length < this.maxWaiting;
290
+ }
291
+ acquire(waitMs, signal) {
292
+ if (this.tryAcquire())
293
+ return Promise.resolve(true);
294
+ if (signal?.aborted || waitMs <= 0 || this.queue.length >= this.maxWaiting)
295
+ return Promise.resolve(false);
296
+ return new Promise((resolve) => {
297
+ // admit is called by release(), which hands its slot straight over (used is unchanged).
298
+ const admit = () => {
299
+ done();
300
+ resolve(true);
301
+ };
302
+ const giveUp = () => {
303
+ const i = this.queue.indexOf(admit);
304
+ if (i >= 0)
305
+ this.queue.splice(i, 1);
306
+ done();
307
+ resolve(false);
308
+ };
309
+ const timer = setTimeout(giveUp, waitMs);
310
+ const done = () => {
311
+ clearTimeout(timer);
312
+ signal?.removeEventListener("abort", giveUp);
313
+ };
314
+ signal?.addEventListener("abort", giveUp, { once: true });
315
+ this.queue.push(admit);
316
+ });
317
+ }
275
318
  release() {
276
- if (this.used > 0)
319
+ const next = this.queue.shift();
320
+ if (next)
321
+ next();
322
+ else if (this.used > 0)
277
323
  this.used--;
278
324
  }
279
325
  get inUse() {
280
326
  return this.used;
281
327
  }
328
+ get waiting() {
329
+ return this.queue.length;
330
+ }
282
331
  }
283
332
  // ── Requests in flight per instance (#2773) ──
284
333
  //
@@ -389,14 +438,19 @@ export class Admission {
389
438
  // ── The request that is a stream: subscriptions/listen ──
390
439
  //
391
440
  // A 2026-07-28 client opens subscriptions/listen to hear of list changes, and the SDK answers it
392
- // with an SSE stream that stays open until the client leaves (or the server closes). This server
393
- // declares no listChanged (index.ts), so the stream carries the acknowledgement and then only
394
- // the SDK's keepalives: no work, no API call. Admitted like any other request, it held its place
441
+ // with an SSE stream. Through @modelcontextprotocol/server 2.1.0 (echelongraph-mcp 3.1.0) the
442
+ // stream stayed open until the client left (or the server closed), carrying the acknowledgement
443
+ // and then only the SDK's keepalives, since this server declares no listChanged (index.ts): no
444
+ // work, no API call. Since SDK 2.2.0 (its #2651; 3.1.1, #3345) a stream that honours none of the
445
+ // notification types asked for ends right after the acknowledgement, with a resultType "complete"
446
+ // result, so here every listen ends at once (test/http.test.mjs holds both). The rest of this
447
+ // comment is why a listen still takes no place: a later listChanged: true would hold it open again.
448
+ // Admitted like any other request, it held its place
395
449
  // for as long as it was open, so 64 idle listeners (under the per-client limit) left every
396
450
  // tools/call, initialize and server/discover on the instance waiting 10 s for a 503 (#2773). So it
397
451
  // is relayed without admission and without a work hold. What bounds the streams instead: the
398
452
  // per-client limit, which counts each one when it opens; the SDK's own cap of
399
- // DEFAULT_MAX_SUBSCRIPTIONS (1024 in @modelcontextprotocol/server 2.1.0) open per process, past
453
+ // DEFAULT_MAX_SUBSCRIPTIONS (1024 in @modelcontextprotocol/server 2.1.0 and 2.3.0) open per process, past
400
454
  // which it answers JSON-RPC -32603 "Subscription limit reached" at once; and on Cloud Run the
401
455
  // instance's 250 requests at once and its 60 s request timeout, which ends every stream
402
456
  // (deploy-all.sh SVC_CONCURRENCY, --timeout). Measured 2026-10-04 on dist/http.js, Node 22.22,
@@ -437,11 +491,54 @@ export function isListenStream(facts, body, headers) {
437
491
  //
438
492
  // The access line carries no body, no query string, no argument and no client address: only
439
493
  // values from small closed sets or shapes, so nothing a client types reaches the log (#1983).
440
- const RPC_METHOD = /^[a-z][a-z/_]{0,39}$/i;
494
+ // #2923's review (nit): rpc_method is a label from a closed set, never the client's own string. It is the method
495
+ // when MCP defines it for a client to send, and "(other)" for any other string, however well shaped: the SDK's
496
+ // ClientRequestSchema and ClientNotificationSchema (@modelcontextprotocol/core 2.1.0, which the server bundles), and
497
+ // the 2025-11-25 task methods the SDK defines beside them. test/http-policy.test.mjs reads both unions from the
498
+ // SDK, so an SDK upgrade that adds a client method fails there until it is listed here.
499
+ export const MCP_METHODS = new Set([
500
+ // ClientRequestSchema
501
+ "ping",
502
+ "initialize",
503
+ "server/discover",
504
+ "completion/complete",
505
+ "logging/setLevel",
506
+ "prompts/get",
507
+ "prompts/list",
508
+ "resources/list",
509
+ "resources/templates/list",
510
+ "resources/read",
511
+ "resources/subscribe",
512
+ "resources/unsubscribe",
513
+ "subscriptions/listen",
514
+ "tools/call",
515
+ "tools/list",
516
+ // ClientNotificationSchema
517
+ "notifications/cancelled",
518
+ "notifications/progress",
519
+ "notifications/initialized",
520
+ "notifications/roots/list_changed",
521
+ // The task methods (GetTaskRequestSchema, GetTaskPayloadRequestSchema, ListTasksRequestSchema,
522
+ // CancelTaskRequestSchema, TaskStatusNotificationSchema)
523
+ "tasks/get",
524
+ "tasks/result",
525
+ "tasks/list",
526
+ "tasks/cancel",
527
+ "notifications/tasks/status",
528
+ ]);
529
+ // 3.1.0's final review (low): the access line's `method` is a label from a closed set too. Over HTTP/1.1 Node's parser
530
+ // admits only its own method list, but over h2c (production's protocol) the :method pseudo-header is whatever token the
531
+ // client sent, and it reached the log verbatim ("JOHN.DOE-SSN-123-45-6789", an 8,000-character token). It is one of
532
+ // these nine, as sent (case and all), else "(other)".
533
+ export const HTTP_METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "CONNECT", "TRACE"]);
534
+ /** The request's HTTP method when it is one of HTTP_METHODS, else "(other)"; never the client's own token. */
535
+ export function httpMethodLabel(m) {
536
+ return m !== undefined && HTTP_METHODS.has(m) ? m : "(other)";
537
+ }
441
538
  const TOOL_NAME = /^[a-z][a-z0-9_]{0,39}$/;
442
539
  const REVISION = /^\d{4}-\d{2}-\d{2}$/;
443
540
  const UA_FAMILY = /^[A-Za-z0-9._-]{1,40}$/;
444
- /** The JSON-RPC method and, for tools/call, the tool name (for prompts/get, the prompt name), shape-checked; never anything else from the body. */
541
+ /** The JSON-RPC method, one of MCP_METHODS or "(other)", and, for tools/call, the tool name (for prompts/get, the prompt name), shape-checked; never anything else from the body. */
445
542
  export function bodyFacts(body) {
446
543
  if (!body || body.length === 0)
447
544
  return { rpc_method: "(none)" };
@@ -459,7 +556,7 @@ export function bodyFacts(body) {
459
556
  const m = parsed.method;
460
557
  if (typeof m !== "string")
461
558
  return { rpc_method: "(response)" };
462
- if (!RPC_METHOD.test(m))
559
+ if (!MCP_METHODS.has(m))
463
560
  return { rpc_method: "(other)" };
464
561
  if (m !== "tools/call" && m !== "prompts/get")
465
562
  return { rpc_method: m };
@@ -467,6 +564,16 @@ export function bodyFacts(body) {
467
564
  const shaped = typeof name === "string" && TOOL_NAME.test(name) ? name : "(other)";
468
565
  return m === "tools/call" ? { rpc_method: m, tool: shaped } : { rpc_method: m, prompt: shaped };
469
566
  }
567
+ /** HTTP/2's error codes (RFC 9113 §7): NO_ERROR (0x0) to HTTP_1_1_REQUIRED (0xd). */
568
+ export const H2_MAX_ERROR_CODE = 0xd;
569
+ /** An HTTP/2 stream's reset code as mcp_client_gone logs it: a code RFC 9113 defines, else "(other)"; null when the
570
+ * request came over HTTP/1.1 or its stream was not reset. Under direct h2c the code is the client's own RST_STREAM,
571
+ * any 32-bit number it chose (3.1.0's final panel: a reset with 0x7a71c0de logged rst_code 2054275294). */
572
+ export function rstCodeLabel(code) {
573
+ if (code === undefined || code === null)
574
+ return null;
575
+ return Number.isInteger(code) && code >= 0 && code <= H2_MAX_ERROR_CODE ? code : "(other)";
576
+ }
470
577
  export function revisionLabel(v) {
471
578
  if (v === undefined)
472
579
  return "(none)";
@@ -521,10 +628,36 @@ const HANDLER_ERROR_CLASSES = [
521
628
  ["Failed to encode result", "encode_result_failed"],
522
629
  ["Failed to send", "send_failed"],
523
630
  ];
631
+ // #2923, 2026-10-18: a live read since 3.0.2 found one line with error_class "other" and error_code -32022. The
632
+ // bundled SDK (@modelcontextprotocol/server 2.1.0) raises -32022 on these paths:
633
+ // - createMcpHandler serveModern: an UnsupportedProtocolVersionError, "Unsupported protocol version: <requested>"
634
+ // (index.mjs:1334), for a modern request naming an unsupported revision or none ("unknown");
635
+ // - modernOnlyStrictRejection: a ProtocolError, "Unsupported protocol version: the request did not name a protocol
636
+ // version" (src-D-y6h4N7.mjs:5313), reported as "Rejected 2025-era request on a modern-only endpoint" (a
637
+ // legacy: "reject" endpoint only; this one serves the 2025 era);
638
+ // - Protocol._onrequest's era mismatch, answered with -32022 (src-D-y6h4N7.mjs:6467) and reported as "Era mismatch";
639
+ // - ProtocolError.fromError (src-D-y6h4N7.mjs:3614): a peer's -32022 error response, rebuilt with the PEER's own
640
+ // message, which no prefix can know. That is how "other" with -32022 is reached.
641
+ // So a message no prefix knows is classified by its JSON-RPC code where the code names a cause (HANDLER_ERROR_CODES):
642
+ // -32022 is unsupported_protocol_version whatever its message, and "other" with -32022 cannot occur.
643
+ const HANDLER_ERROR_CODES = new Map([
644
+ [-32022, "unsupported_protocol_version"],
645
+ [-32021, "missing_client_capability"],
646
+ [-32020, "header_mismatch"],
647
+ [-32700, "parse_error"],
648
+ [-32600, "invalid_request"],
649
+ [-32601, "method_not_found"],
650
+ [-32602, "invalid_params"],
651
+ ]);
524
652
  export function handlerErrorClass(e) {
525
- const message = e instanceof Error ? e.message : "";
653
+ // A thrown non-Error (an object with a message) is read as an Error would be; its message is still never logged.
654
+ const m = e?.message;
655
+ const message = typeof m === "string" ? m : "";
526
656
  const hit = HANDLER_ERROR_CLASSES.find(([prefix]) => message.startsWith(prefix));
527
- return hit ? hit[1] : "other";
657
+ if (hit)
658
+ return hit[1];
659
+ const code = protocolErrorCode(e);
660
+ return (code !== undefined && HANDLER_ERROR_CODES.get(code)) || "other";
528
661
  }
529
662
  export function protocolErrorCode(e) {
530
663
  const code = e?.code;
@@ -540,3 +673,62 @@ export function classifyHandlerError(e) {
540
673
  out.error_code = code;
541
674
  return out;
542
675
  }
676
+ // ── The upstream-failure line (#2948) ──
677
+ //
678
+ // mcp_upstream_failed (http.ts) names the API call that failed by its ROUTE, never its path: a
679
+ // lookup's path holds what was looked up (a CVE or CWE ID, a vendor and an advisory ID), and a
680
+ // search's query string its filters (#1983). The route is one of the fixed templates below, each
681
+ // core-backend's own name for it (the route field of its http_request line, router.go), so the two
682
+ // lines can be read side by side: Fiber names a group's "/" with its trailing slash, so the two list
683
+ // routes end in "/" although the server calls them without it (#2948's review; marketing-site
684
+ // lib/privacyMcpEndpoint.test.ts holds every template to router.go). A path is matched whole, its query string dropped first, and only
685
+ // the template that matched comes out: "(other)" when none does, "(none)" when no path is at hand
686
+ // (an answer checked() refused against the tool's schema names none). The tool name (shape-checked)
687
+ // and the JSON-RPC method (MCP_METHODS or "(other)") on the line are bodyFacts' own; the class,
688
+ // status and duration are the server's. Nothing a client typed reaches it.
689
+ const API_ROUTES = [
690
+ [/^\/api\/v1\/public\/cves\/?$/, "/api/v1/public/cves/"],
691
+ [/^\/api\/v1\/public\/cves\/summary$/, "/api/v1/public/cves/summary"],
692
+ [/^\/api\/v1\/public\/cves\/match$/, "/api/v1/public/cves/match"],
693
+ [/^\/api\/v1\/public\/cves\/match\/batch$/, "/api/v1/public/cves/match/batch"],
694
+ [/^\/api\/v1\/public\/cves\/[^/]+$/, "/api/v1/public/cves/:id"],
695
+ [/^\/api\/v1\/public\/cves\/[^/]+\/enrichment$/, "/api/v1/public/cves/:id/enrichment"],
696
+ [/^\/api\/v1\/public\/cves\/[^/]+\/epss-history$/, "/api/v1/public/cves/:id/epss-history"],
697
+ [/^\/api\/v1\/public\/cves\/[^/]+\/remediation$/, "/api/v1/public/cves/:id/remediation"],
698
+ [/^\/api\/v1\/public\/cwes\/[^/]+$/, "/api/v1/public/cwes/:cwe_id"],
699
+ [/^\/api\/v1\/public\/kev\/recent$/, "/api/v1/public/kev/recent"],
700
+ [/^\/api\/v1\/public\/kev-exposure\/stats$/, "/api/v1/public/kev-exposure/stats"],
701
+ [/^\/api\/v1\/public\/kev-exposure\/cve\/[^/]+$/, "/api/v1/public/kev-exposure/cve/:id"],
702
+ [/^\/api\/v1\/public\/exposed-databases\/stats$/, "/api/v1/public/exposed-databases/stats"],
703
+ [/^\/api\/v1\/public\/leaked-credentials\/stats$/, "/api/v1/public/leaked-credentials/stats"],
704
+ [/^\/api\/v1\/public\/shadow-ai-radar\/stats$/, "/api/v1/public/shadow-ai-radar/stats"],
705
+ [/^\/api\/v1\/public\/ai-exposure\/stats$/, "/api/v1/public/ai-exposure/stats"],
706
+ [/^\/api\/v1\/public\/vendor-advisories\/?$/, "/api/v1/public/vendor-advisories/"],
707
+ [/^\/api\/v1\/public\/vendor-advisories\/coverage$/, "/api/v1/public/vendor-advisories/coverage"],
708
+ [/^\/api\/v1\/public\/vendor-advisories\/by-cve\/[^/]+$/, "/api/v1/public/vendor-advisories/by-cve/:cve_id"],
709
+ [/^\/api\/v1\/public\/vendor-advisories\/[^/]+\/[^/]+$/, "/api/v1/public/vendor-advisories/:vendor/:advisory_id"],
710
+ ];
711
+ /** Every route template the upstream-failure line can name, in match order. */
712
+ export const API_ROUTE_TEMPLATES = API_ROUTES.map(([, route]) => route);
713
+ /** The route template of an API path (its query string dropped): one of API_ROUTE_TEMPLATES, "(other)", or "(none)" for no path. */
714
+ export function apiRoute(path) {
715
+ if (path === null)
716
+ return "(none)";
717
+ const p = path.split(/[?#]/, 1)[0];
718
+ const hit = API_ROUTES.find(([re]) => re.test(p));
719
+ return hit ? hit[1] : "(other)";
720
+ }
721
+ // The severity. The API's own answer about the input is no outage: a 400 (it refused the input,
722
+ // which the tool reports as invalid_input) or a 404 on a route that names a record (no such CVE,
723
+ // CWE or advisory: the tool reports no record). Those are INFO, so the log metric, which counts
724
+ // WARNING, and its alert see only failures. A 404 on a route that names none (a list, the summary, a
725
+ // radar's stats) means the route is gone, and is WARNING with every other failure.
726
+ // #2948's review: output_schema (the tool's own outputSchema refused the answer) is this server's fault, not an
727
+ // upstream failure: ERROR, which the upstream metric (WARNING lines) does not count.
728
+ export function upstreamSeverity(failureClass, status, route) {
729
+ if (failureClass === "output_schema")
730
+ return "ERROR";
731
+ if (failureClass === "http" && (status === 400 || (status === 404 && route.includes("/:"))))
732
+ return "INFO";
733
+ return "WARNING";
734
+ }