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/CHANGELOG.md +676 -5
- package/README.md +79 -84
- package/dist/http.js +77 -84
- package/dist/httpPolicy.js +206 -14
- package/dist/index.js +489 -49
- package/dist/inputBounds.js +38 -0
- package/dist/logLines.js +86 -0
- package/dist/prompts.js +122 -23
- package/dist/readBody.js +140 -0
- package/dist/requiredText.js +23 -3
- package/dist/resources.js +3 -3
- package/dist/runtime.js +66 -1
- package/dist/schemaRefusal.js +147 -0
- package/dist/textBudget.js +826 -76
- package/dist/tools/check_affected.js +19 -6
- package/dist/tools/check_sbom.js +223 -25
- package/dist/tools/cve_intel.js +145 -35
- package/dist/tools/cve_remediation.js +101 -19
- package/dist/tools/epss_history.js +41 -5
- package/dist/tools/match_batch.js +57 -10
- package/dist/tools/packageRecords.js +58 -0
- package/dist/tools/scan_manifest.js +13 -4
- package/dist/tools/vendor_advisories.js +27 -4
- package/docs/exposure-radar.md +4 -0
- package/docs/rate-limits.md +9 -5
- package/docs/structured-results.md +23 -3
- package/docs/tools.md +139 -6
- package/package.json +3 -3
- package/server.json +2 -2
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
|
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
|
|
279
|
-
//
|
|
280
|
-
//
|
|
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
|
-
|
|
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),
|
package/dist/httpPolicy.js
CHANGED
|
@@ -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
|
|
233
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
393
|
-
//
|
|
394
|
-
// the SDK's keepalives
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|