echelongraph-mcp 3.0.2 → 3.1.0

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.
@@ -280,7 +260,7 @@ async function serveMcp(req, res, pathname) {
280
260
  // other request waiting for a 503 (#2773; httpPolicy.ts isListenStream says what bounds it).
281
261
  const standard = { protocolVersion: header(req, "mcp-protocol-version"), mcpMethod: header(req, "mcp-method"), mcpName: header(req, "mcp-name") };
282
262
  if (isListenStream(facts, body, standard))
283
- return { ...(await relay(req, res, pathname, method, cors, client, body, gone.signal)), facts };
263
+ return { ...(await relay(req, res, pathname, method, cors, client, body, facts, gone.signal)), facts };
284
264
  }
285
265
  // #2773: at most MAX_IN_FLIGHT requests are served at once; the next waits here.
286
266
  const waitStart = Date.now();
@@ -298,7 +278,7 @@ async function serveMcp(req, res, pathname) {
298
278
  // large-body slot are held until all of it has settled, not only until the answer is written.
299
279
  const work = new Set();
300
280
  try {
301
- return { ...(await relay(req, res, pathname, method, cors, client, body, gone.signal, work)), facts, ...admitted };
281
+ return { ...(await relay(req, res, pathname, method, cors, client, body, facts, gone.signal, work)), facts, ...admitted };
302
282
  }
303
283
  finally {
304
284
  // Every tool call stops on `gone` (#2775), so this wait is long only if one does not.
@@ -313,7 +293,7 @@ async function serveMcp(req, res, pathname) {
313
293
  }
314
294
  // Hands one admitted request (or a listen stream, which is not admitted and passes no `work`) to
315
295
  // the SDK's handler and streams its answer back.
316
- async function relay(req, res, pathname, method, cors, client, body, gone, work) {
296
+ async function relay(req, res, pathname, method, cors, client, body, facts, gone, work) {
317
297
  const headers = new Headers();
318
298
  for (const [k, v] of Object.entries(req.headers)) {
319
299
  // ":method", ":path", ":authority", ":scheme": an h2 request's pseudo-headers, which a Request cannot carry.
@@ -332,7 +312,17 @@ async function relay(req, res, pathname, method, cors, client, body, gone, work)
332
312
  // carry the synthetic's fixed token (runtime.ts upstreamUserAgent), so they are never counted
333
313
  // as hosted adoption.
334
314
  const synthetic = uaFamily(req.headers["user-agent"]) === SYNTHETIC_UA_FAMILY;
335
- const response = await withClient(client.forwardAddress, () => handler.fetch(request), { synthetic, signal: gone, work });
315
+ // #2948: the method and tool name a failed API call's line names (runtime.ts upstreamFailed).
316
+ // #2923: and the protocol-version label a handler error's line names (runtime.ts requestLabels).
317
+ const protocolVersion = revisionLabel(req.headers["mcp-protocol-version"]);
318
+ const response = await withClient(client.forwardAddress, () => handler.fetch(request), {
319
+ synthetic,
320
+ signal: gone,
321
+ work,
322
+ rpcMethod: facts?.rpc_method,
323
+ tool: facts?.tool,
324
+ protocolVersion,
325
+ });
336
326
  const out = { ...cors };
337
327
  response.headers.forEach((v, k) => {
338
328
  out[k] = v;
@@ -362,7 +352,7 @@ function onRequest(req, res) {
362
352
  const finish = (o) => {
363
353
  const facts = o.facts ?? (o.body ? bodyFacts(o.body) : { rpc_method: "(none)" });
364
354
  log("INFO", "mcp_request", {
365
- method,
355
+ method: httpMethodLabel(method),
366
356
  path: pathname === MCP_PATH || pathname === HEALTH_PATH ? pathname : "(other)",
367
357
  status: o.status,
368
358
  duration_ms: Number((process.hrtime.bigint() - start) / 1000000n),
@@ -377,6 +367,7 @@ function onRequest(req, res) {
377
367
  client_closed: o.clientClosed === true,
378
368
  in_flight: o.inFlight ?? 0,
379
369
  queued_ms: o.queuedMs ?? 0,
370
+ slot_wait_ms: o.slotWaitMs ?? 0,
380
371
  });
381
372
  };
382
373
  if (pathname === HEALTH_PATH) {
@@ -438,6 +429,7 @@ server.listen(PORT, () => {
438
429
  max_body_bytes: MAX_BODY_BYTES,
439
430
  max_sbom_body_bytes: MAX_SBOM_BODY_BYTES,
440
431
  large_body_slots: LARGE_BODY_SLOTS,
432
+ large_body_wait_ms: LARGE_BODY_WAIT_MS,
441
433
  max_in_flight: MAX_IN_FLIGHT,
442
434
  admission_wait_ms: ADMISSION_WAIT_MS,
443
435
  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
  //
@@ -437,11 +486,54 @@ export function isListenStream(facts, body, headers) {
437
486
  //
438
487
  // The access line carries no body, no query string, no argument and no client address: only
439
488
  // 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;
489
+ // #2923's review (nit): rpc_method is a label from a closed set, never the client's own string. It is the method
490
+ // when MCP defines it for a client to send, and "(other)" for any other string, however well shaped: the SDK's
491
+ // ClientRequestSchema and ClientNotificationSchema (@modelcontextprotocol/core 2.1.0, which the server bundles), and
492
+ // the 2025-11-25 task methods the SDK defines beside them. test/http-policy.test.mjs reads both unions from the
493
+ // SDK, so an SDK upgrade that adds a client method fails there until it is listed here.
494
+ export const MCP_METHODS = new Set([
495
+ // ClientRequestSchema
496
+ "ping",
497
+ "initialize",
498
+ "server/discover",
499
+ "completion/complete",
500
+ "logging/setLevel",
501
+ "prompts/get",
502
+ "prompts/list",
503
+ "resources/list",
504
+ "resources/templates/list",
505
+ "resources/read",
506
+ "resources/subscribe",
507
+ "resources/unsubscribe",
508
+ "subscriptions/listen",
509
+ "tools/call",
510
+ "tools/list",
511
+ // ClientNotificationSchema
512
+ "notifications/cancelled",
513
+ "notifications/progress",
514
+ "notifications/initialized",
515
+ "notifications/roots/list_changed",
516
+ // The task methods (GetTaskRequestSchema, GetTaskPayloadRequestSchema, ListTasksRequestSchema,
517
+ // CancelTaskRequestSchema, TaskStatusNotificationSchema)
518
+ "tasks/get",
519
+ "tasks/result",
520
+ "tasks/list",
521
+ "tasks/cancel",
522
+ "notifications/tasks/status",
523
+ ]);
524
+ // 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
525
+ // admits only its own method list, but over h2c (production's protocol) the :method pseudo-header is whatever token the
526
+ // client sent, and it reached the log verbatim ("JOHN.DOE-SSN-123-45-6789", an 8,000-character token). It is one of
527
+ // these nine, as sent (case and all), else "(other)".
528
+ export const HTTP_METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "CONNECT", "TRACE"]);
529
+ /** The request's HTTP method when it is one of HTTP_METHODS, else "(other)"; never the client's own token. */
530
+ export function httpMethodLabel(m) {
531
+ return m !== undefined && HTTP_METHODS.has(m) ? m : "(other)";
532
+ }
441
533
  const TOOL_NAME = /^[a-z][a-z0-9_]{0,39}$/;
442
534
  const REVISION = /^\d{4}-\d{2}-\d{2}$/;
443
535
  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. */
536
+ /** 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
537
  export function bodyFacts(body) {
446
538
  if (!body || body.length === 0)
447
539
  return { rpc_method: "(none)" };
@@ -459,7 +551,7 @@ export function bodyFacts(body) {
459
551
  const m = parsed.method;
460
552
  if (typeof m !== "string")
461
553
  return { rpc_method: "(response)" };
462
- if (!RPC_METHOD.test(m))
554
+ if (!MCP_METHODS.has(m))
463
555
  return { rpc_method: "(other)" };
464
556
  if (m !== "tools/call" && m !== "prompts/get")
465
557
  return { rpc_method: m };
@@ -467,6 +559,16 @@ export function bodyFacts(body) {
467
559
  const shaped = typeof name === "string" && TOOL_NAME.test(name) ? name : "(other)";
468
560
  return m === "tools/call" ? { rpc_method: m, tool: shaped } : { rpc_method: m, prompt: shaped };
469
561
  }
562
+ /** HTTP/2's error codes (RFC 9113 §7): NO_ERROR (0x0) to HTTP_1_1_REQUIRED (0xd). */
563
+ export const H2_MAX_ERROR_CODE = 0xd;
564
+ /** An HTTP/2 stream's reset code as mcp_client_gone logs it: a code RFC 9113 defines, else "(other)"; null when the
565
+ * request came over HTTP/1.1 or its stream was not reset. Under direct h2c the code is the client's own RST_STREAM,
566
+ * any 32-bit number it chose (3.1.0's final panel: a reset with 0x7a71c0de logged rst_code 2054275294). */
567
+ export function rstCodeLabel(code) {
568
+ if (code === undefined || code === null)
569
+ return null;
570
+ return Number.isInteger(code) && code >= 0 && code <= H2_MAX_ERROR_CODE ? code : "(other)";
571
+ }
470
572
  export function revisionLabel(v) {
471
573
  if (v === undefined)
472
574
  return "(none)";
@@ -521,10 +623,36 @@ const HANDLER_ERROR_CLASSES = [
521
623
  ["Failed to encode result", "encode_result_failed"],
522
624
  ["Failed to send", "send_failed"],
523
625
  ];
626
+ // #2923, 2026-10-18: a live read since 3.0.2 found one line with error_class "other" and error_code -32022. The
627
+ // bundled SDK (@modelcontextprotocol/server 2.1.0) raises -32022 on these paths:
628
+ // - createMcpHandler serveModern: an UnsupportedProtocolVersionError, "Unsupported protocol version: <requested>"
629
+ // (index.mjs:1334), for a modern request naming an unsupported revision or none ("unknown");
630
+ // - modernOnlyStrictRejection: a ProtocolError, "Unsupported protocol version: the request did not name a protocol
631
+ // version" (src-D-y6h4N7.mjs:5313), reported as "Rejected 2025-era request on a modern-only endpoint" (a
632
+ // legacy: "reject" endpoint only; this one serves the 2025 era);
633
+ // - Protocol._onrequest's era mismatch, answered with -32022 (src-D-y6h4N7.mjs:6467) and reported as "Era mismatch";
634
+ // - ProtocolError.fromError (src-D-y6h4N7.mjs:3614): a peer's -32022 error response, rebuilt with the PEER's own
635
+ // message, which no prefix can know. That is how "other" with -32022 is reached.
636
+ // So a message no prefix knows is classified by its JSON-RPC code where the code names a cause (HANDLER_ERROR_CODES):
637
+ // -32022 is unsupported_protocol_version whatever its message, and "other" with -32022 cannot occur.
638
+ const HANDLER_ERROR_CODES = new Map([
639
+ [-32022, "unsupported_protocol_version"],
640
+ [-32021, "missing_client_capability"],
641
+ [-32020, "header_mismatch"],
642
+ [-32700, "parse_error"],
643
+ [-32600, "invalid_request"],
644
+ [-32601, "method_not_found"],
645
+ [-32602, "invalid_params"],
646
+ ]);
524
647
  export function handlerErrorClass(e) {
525
- const message = e instanceof Error ? e.message : "";
648
+ // A thrown non-Error (an object with a message) is read as an Error would be; its message is still never logged.
649
+ const m = e?.message;
650
+ const message = typeof m === "string" ? m : "";
526
651
  const hit = HANDLER_ERROR_CLASSES.find(([prefix]) => message.startsWith(prefix));
527
- return hit ? hit[1] : "other";
652
+ if (hit)
653
+ return hit[1];
654
+ const code = protocolErrorCode(e);
655
+ return (code !== undefined && HANDLER_ERROR_CODES.get(code)) || "other";
528
656
  }
529
657
  export function protocolErrorCode(e) {
530
658
  const code = e?.code;
@@ -540,3 +668,62 @@ export function classifyHandlerError(e) {
540
668
  out.error_code = code;
541
669
  return out;
542
670
  }
671
+ // ── The upstream-failure line (#2948) ──
672
+ //
673
+ // mcp_upstream_failed (http.ts) names the API call that failed by its ROUTE, never its path: a
674
+ // lookup's path holds what was looked up (a CVE or CWE ID, a vendor and an advisory ID), and a
675
+ // search's query string its filters (#1983). The route is one of the fixed templates below, each
676
+ // core-backend's own name for it (the route field of its http_request line, router.go), so the two
677
+ // lines can be read side by side: Fiber names a group's "/" with its trailing slash, so the two list
678
+ // routes end in "/" although the server calls them without it (#2948's review; marketing-site
679
+ // lib/privacyMcpEndpoint.test.ts holds every template to router.go). A path is matched whole, its query string dropped first, and only
680
+ // the template that matched comes out: "(other)" when none does, "(none)" when no path is at hand
681
+ // (an answer checked() refused against the tool's schema names none). The tool name (shape-checked)
682
+ // and the JSON-RPC method (MCP_METHODS or "(other)") on the line are bodyFacts' own; the class,
683
+ // status and duration are the server's. Nothing a client typed reaches it.
684
+ const API_ROUTES = [
685
+ [/^\/api\/v1\/public\/cves\/?$/, "/api/v1/public/cves/"],
686
+ [/^\/api\/v1\/public\/cves\/summary$/, "/api/v1/public/cves/summary"],
687
+ [/^\/api\/v1\/public\/cves\/match$/, "/api/v1/public/cves/match"],
688
+ [/^\/api\/v1\/public\/cves\/match\/batch$/, "/api/v1/public/cves/match/batch"],
689
+ [/^\/api\/v1\/public\/cves\/[^/]+$/, "/api/v1/public/cves/:id"],
690
+ [/^\/api\/v1\/public\/cves\/[^/]+\/enrichment$/, "/api/v1/public/cves/:id/enrichment"],
691
+ [/^\/api\/v1\/public\/cves\/[^/]+\/epss-history$/, "/api/v1/public/cves/:id/epss-history"],
692
+ [/^\/api\/v1\/public\/cves\/[^/]+\/remediation$/, "/api/v1/public/cves/:id/remediation"],
693
+ [/^\/api\/v1\/public\/cwes\/[^/]+$/, "/api/v1/public/cwes/:cwe_id"],
694
+ [/^\/api\/v1\/public\/kev\/recent$/, "/api/v1/public/kev/recent"],
695
+ [/^\/api\/v1\/public\/kev-exposure\/stats$/, "/api/v1/public/kev-exposure/stats"],
696
+ [/^\/api\/v1\/public\/kev-exposure\/cve\/[^/]+$/, "/api/v1/public/kev-exposure/cve/:id"],
697
+ [/^\/api\/v1\/public\/exposed-databases\/stats$/, "/api/v1/public/exposed-databases/stats"],
698
+ [/^\/api\/v1\/public\/leaked-credentials\/stats$/, "/api/v1/public/leaked-credentials/stats"],
699
+ [/^\/api\/v1\/public\/shadow-ai-radar\/stats$/, "/api/v1/public/shadow-ai-radar/stats"],
700
+ [/^\/api\/v1\/public\/ai-exposure\/stats$/, "/api/v1/public/ai-exposure/stats"],
701
+ [/^\/api\/v1\/public\/vendor-advisories\/?$/, "/api/v1/public/vendor-advisories/"],
702
+ [/^\/api\/v1\/public\/vendor-advisories\/coverage$/, "/api/v1/public/vendor-advisories/coverage"],
703
+ [/^\/api\/v1\/public\/vendor-advisories\/by-cve\/[^/]+$/, "/api/v1/public/vendor-advisories/by-cve/:cve_id"],
704
+ [/^\/api\/v1\/public\/vendor-advisories\/[^/]+\/[^/]+$/, "/api/v1/public/vendor-advisories/:vendor/:advisory_id"],
705
+ ];
706
+ /** Every route template the upstream-failure line can name, in match order. */
707
+ export const API_ROUTE_TEMPLATES = API_ROUTES.map(([, route]) => route);
708
+ /** The route template of an API path (its query string dropped): one of API_ROUTE_TEMPLATES, "(other)", or "(none)" for no path. */
709
+ export function apiRoute(path) {
710
+ if (path === null)
711
+ return "(none)";
712
+ const p = path.split(/[?#]/, 1)[0];
713
+ const hit = API_ROUTES.find(([re]) => re.test(p));
714
+ return hit ? hit[1] : "(other)";
715
+ }
716
+ // The severity. The API's own answer about the input is no outage: a 400 (it refused the input,
717
+ // which the tool reports as invalid_input) or a 404 on a route that names a record (no such CVE,
718
+ // CWE or advisory: the tool reports no record). Those are INFO, so the log metric, which counts
719
+ // WARNING, and its alert see only failures. A 404 on a route that names none (a list, the summary, a
720
+ // radar's stats) means the route is gone, and is WARNING with every other failure.
721
+ // #2948's review: output_schema (the tool's own outputSchema refused the answer) is this server's fault, not an
722
+ // upstream failure: ERROR, which the upstream metric (WARNING lines) does not count.
723
+ export function upstreamSeverity(failureClass, status, route) {
724
+ if (failureClass === "output_schema")
725
+ return "ERROR";
726
+ if (failureClass === "http" && (status === 400 || (status === 404 && route.includes("/:"))))
727
+ return "INFO";
728
+ return "WARNING";
729
+ }