echelongraph-mcp 2.6.3 → 2.6.6
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 +299 -2
- package/README.md +174 -40
- package/dist/http.js +183 -39
- package/dist/httpPolicy.js +160 -4
- package/dist/index.js +150 -46
- package/dist/prompts.js +72 -11
- package/dist/resources.js +61 -12
- package/dist/runtime.js +26 -4
- package/dist/textBudget.js +11 -4
- package/dist/tools/check_affected.js +26 -16
- package/dist/tools/check_sbom.js +45 -9
- package/dist/tools/cve_intel.js +69 -2
- package/dist/tools/get_cwe.js +2 -2
- package/dist/tools/kev_recent.js +3 -2
- package/dist/tools/vendor_advisories.js +54 -17
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/http.js
CHANGED
|
@@ -13,6 +13,17 @@
|
|
|
13
13
|
// GET /health liveness. Not /healthz: Cloud Run's front end reserves paths ending in "z"
|
|
14
14
|
// and answers them itself, so a /healthz would never reach this process.
|
|
15
15
|
//
|
|
16
|
+
// HTTP/1.1 by default; HTTP/2 cleartext (h2c, prior knowledge) ONLY when MCP_H2C=1 (#2775). Cloud
|
|
17
|
+
// Run tells a container that its client went away only over HTTP/2 or a WebSocket ("When you use
|
|
18
|
+
// HTTP/1.1 on Cloud Run, client disconnect events are not propagated to the Cloud Run container",
|
|
19
|
+
// https://docs.cloud.google.com/run/docs/troubleshooting): over HTTP/1.1 its front end reads the
|
|
20
|
+
// whole answer itself, so an abandoned call ran to its end, holding its large-body slot. With
|
|
21
|
+
// `--use-http2` Cloud Run sends the container h2c and nothing else, and a client that goes resets
|
|
22
|
+
// its stream, which this server acts on at once (serveMcp). But on a canary of 2026-10-05 that reset
|
|
23
|
+
// came only when the answer was written, 15 and 34 s in, not when the client went:
|
|
24
|
+
// mcp_client_gone (after_ms, answer_started) is how production shows which. The flag and
|
|
25
|
+
// MCP_H2C=1 go together (deploy-all.sh mcp-remote): either one alone answers no request at all.
|
|
26
|
+
//
|
|
16
27
|
// No auth: the endpoint serves only public data (MCP makes authorization optional), and the
|
|
17
28
|
// server's instructions say what it is. The edge rules — Origin policy, per-client limit, body
|
|
18
29
|
// cap, what is logged — are in httpPolicy.ts, each with its reasoning.
|
|
@@ -22,9 +33,11 @@
|
|
|
22
33
|
// waf/mcpforward.go, #2212). MCP_REQUIRE_FORWARD_TOKEN=1 (set by deploy-all.sh) makes a missing
|
|
23
34
|
// token fatal at start-up, because without it every remote user would share one API budget.
|
|
24
35
|
import http from "node:http";
|
|
36
|
+
import http2 from "node:http2";
|
|
37
|
+
import v8 from "node:v8";
|
|
25
38
|
import { createMcpHandler } from "@modelcontextprotocol/server";
|
|
26
|
-
import { forwardToken, markHttpEntrypoint, SYNTHETIC_UA_FAMILY, withClient } from "./runtime.js";
|
|
27
|
-
import { BODY_TOO_LARGE_CODE, BUSY_CODE, DEFAULT_LARGE_BODY_SLOTS, DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_SBOM_BODY_BYTES, DEFAULT_RATE_LIMIT_PER_MIN, LARGE_BODY_TARGETS_TEXT, Slots, FixedWindowLimiter, ORIGIN_REFUSED_CODE, RATE_LIMITED_CODE, bodyFacts, originVerdict, resolveClient, revisionLabel, rpcError, isLargeBodyTarget, largeBodyAdmissible, uaFamily, } from "./httpPolicy.js";
|
|
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, originVerdict, resolveClient, revisionLabel, rpcError, isLargeBodyTarget, isListenStream, largeBodyAdmissible, uaFamily, } from "./httpPolicy.js";
|
|
28
41
|
// Must run before index.ts is evaluated: it is how index.ts knows not to start stdio.
|
|
29
42
|
markHttpEntrypoint();
|
|
30
43
|
const { createServer, NAME, VERSION, SHOWN_BASE, TIMEOUT_MS } = await import("./index.js");
|
|
@@ -41,8 +54,16 @@ const RATE_LIMIT_PER_MIN = positiveInt(process.env.MCP_RATE_LIMIT_PER_MIN, DEFAU
|
|
|
41
54
|
const MAX_BODY_BYTES = positiveInt(process.env.MCP_MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
|
|
42
55
|
const MAX_SBOM_BODY_BYTES = Math.max(MAX_BODY_BYTES, positiveInt(process.env.MCP_MAX_SBOM_BODY_BYTES, DEFAULT_MAX_SBOM_BODY_BYTES));
|
|
43
56
|
const LARGE_BODY_SLOTS = positiveInt(process.env.MCP_LARGE_BODY_SLOTS, DEFAULT_LARGE_BODY_SLOTS);
|
|
44
|
-
//
|
|
57
|
+
// The requests served at once, and how long the next may wait (#2773): httpPolicy.ts "Requests
|
|
58
|
+
// in flight per instance".
|
|
59
|
+
const MAX_IN_FLIGHT = positiveInt(process.env.MCP_MAX_IN_FLIGHT, DEFAULT_MAX_IN_FLIGHT);
|
|
60
|
+
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.
|
|
45
63
|
const BUSY_RETRY_AFTER_SEC = 5;
|
|
64
|
+
// #2775: serve h2c instead of HTTP/1.1 (see the top of this file). Exactly "1": anything else is
|
|
65
|
+
// HTTP/1.1, the default for a local run and for every test that does not ask.
|
|
66
|
+
const H2C = process.env.MCP_H2C === "1";
|
|
46
67
|
const MCP_PATH = "/mcp";
|
|
47
68
|
const HEALTH_PATH = "/health";
|
|
48
69
|
// One JSON object per line on stdout: Cloud Logging reads `severity` and `message`.
|
|
@@ -67,6 +88,7 @@ const handler = createMcpHandler(createServer, {
|
|
|
67
88
|
});
|
|
68
89
|
const limiter = new FixedWindowLimiter(RATE_LIMIT_PER_MIN);
|
|
69
90
|
const largeBodies = new Slots(LARGE_BODY_SLOTS);
|
|
91
|
+
const admission = new Admission(MAX_IN_FLIGHT, DEFAULT_MAX_WAITING);
|
|
70
92
|
// CORS for a browser client on an https Origin (the Origin policy has already admitted it).
|
|
71
93
|
// A wildcard is safe here because nothing is credentialed: no cookies, no auth.
|
|
72
94
|
const CORS_HEADERS = {
|
|
@@ -80,8 +102,17 @@ const PREFLIGHT_HEADERS = {
|
|
|
80
102
|
"Access-Control-Max-Age": "600",
|
|
81
103
|
};
|
|
82
104
|
const HOP_BY_HOP = new Set(["host", "connection", "content-length", "transfer-encoding", "keep-alive", "upgrade", "expect"]);
|
|
105
|
+
// HTTP/2 has no connection-specific headers (RFC 9113 8.2.2): Node drops them from an h2 answer
|
|
106
|
+
// with a warning, so they are never set on one. A refusal's "Connection: close" (the rest of the
|
|
107
|
+
// body is discarded unread) is the stream's own end there.
|
|
108
|
+
const CONNECTION_SPECIFIC = new Set(["connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade"]);
|
|
109
|
+
function forProtocol(res, headers) {
|
|
110
|
+
if (!(res instanceof http2.Http2ServerResponse))
|
|
111
|
+
return headers;
|
|
112
|
+
return Object.fromEntries(Object.entries(headers).filter(([k]) => !CONNECTION_SPECIFIC.has(k.toLowerCase())));
|
|
113
|
+
}
|
|
83
114
|
function send(res, status, body, headers = {}) {
|
|
84
|
-
res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers });
|
|
115
|
+
res.writeHead(status, forProtocol(res, { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers }));
|
|
85
116
|
res.end(body);
|
|
86
117
|
}
|
|
87
118
|
function readBody(req) {
|
|
@@ -126,8 +157,14 @@ function readBody(req) {
|
|
|
126
157
|
chunks.push(chunk);
|
|
127
158
|
});
|
|
128
159
|
req.on("end", () => {
|
|
160
|
+
if (done)
|
|
161
|
+
return;
|
|
162
|
+
done = true;
|
|
163
|
+
resolve({ body: Buffer.concat(chunks), slot });
|
|
164
|
+
});
|
|
165
|
+
req.on("close", () => {
|
|
129
166
|
if (!done)
|
|
130
|
-
|
|
167
|
+
stop("gone");
|
|
131
168
|
});
|
|
132
169
|
req.on("error", (e) => {
|
|
133
170
|
if (done)
|
|
@@ -169,73 +206,148 @@ async function serveMcp(req, res, pathname) {
|
|
|
169
206
|
log("WARNING", "mcp_rate_limited", { client: client.key, limit: RATE_LIMIT_PER_MIN });
|
|
170
207
|
return { status: 429, throttled: true };
|
|
171
208
|
}
|
|
172
|
-
|
|
173
|
-
|
|
209
|
+
// #2775: the client has gone when the response closes before it has finished. That aborts the
|
|
210
|
+
// request: its wait for admission, the SDK's handling of it (which cancels the tool's call), and
|
|
211
|
+
// its API calls (runtime.ts requestSignal).
|
|
212
|
+
// Over h2c the response of a reset stream closes marked finished (node:http2's compatibility
|
|
213
|
+
// API ends it), so there the stream itself is watched, from the moment the reset arrives:
|
|
214
|
+
// 'aborted' (a reset before the answer was written, any error code) and a 'close' before this
|
|
215
|
+
// server ended its answer (a reset with NO_ERROR emits no 'aborted').
|
|
216
|
+
// mcp_client_gone says WHEN this server learned it: after_ms from the request's start, and
|
|
217
|
+
// whether its answer had started (answer_started). On 2026-10-05 a canary behind Cloud Run's
|
|
218
|
+
// --use-http2 logged its aborted calls 499 only when their answers were written, 15 and 34 s
|
|
219
|
+
// in: this line tells a reset that arrived then from one that arrived early and was not acted on
|
|
220
|
+
// (the access line's duration_ms is when the call actually stopped).
|
|
221
|
+
const gone = new AbortController();
|
|
222
|
+
const t0 = Date.now();
|
|
223
|
+
const h2stream = res.stream;
|
|
224
|
+
const clientWent = () => {
|
|
225
|
+
if (gone.signal.aborted)
|
|
226
|
+
return;
|
|
227
|
+
gone.abort();
|
|
228
|
+
log("INFO", "mcp_client_gone", { after_ms: Date.now() - t0, answer_started: res.headersSent, rst_code: h2stream?.rstCode ?? null });
|
|
229
|
+
};
|
|
230
|
+
res.on("close", () => {
|
|
231
|
+
if (!res.writableFinished || h2stream?.aborted)
|
|
232
|
+
clientWent();
|
|
233
|
+
});
|
|
234
|
+
if (h2stream) {
|
|
235
|
+
h2stream.once("aborted", clientWent);
|
|
236
|
+
h2stream.once("close", () => {
|
|
237
|
+
if (!res.writableEnded)
|
|
238
|
+
clientWent();
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
// The body is read BEFORE the request waits for admission (#2773), so a client that uploads
|
|
242
|
+
// slowly holds its socket, and a large body its large-body slot, as before admission existed,
|
|
243
|
+
// never one of the MAX_IN_FLIGHT places every other request needs. What a waiting request holds
|
|
244
|
+
// is its body: at most MAX_BODY_BYTES, or a large body that holds one of the slots.
|
|
174
245
|
let slot = false;
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
246
|
+
try {
|
|
247
|
+
let body;
|
|
248
|
+
let facts;
|
|
249
|
+
if (method === "POST") {
|
|
250
|
+
let read;
|
|
251
|
+
try {
|
|
252
|
+
read = await readBody(req);
|
|
253
|
+
}
|
|
254
|
+
catch (e) {
|
|
255
|
+
// The request stream fails only when its connection does: the client went mid-upload.
|
|
256
|
+
if (req.destroyed)
|
|
257
|
+
return { status: 499, refused: "client_closed", clientClosed: true };
|
|
258
|
+
throw e;
|
|
259
|
+
}
|
|
260
|
+
if (read === "gone")
|
|
261
|
+
return { status: 499, refused: "client_closed", clientClosed: true };
|
|
262
|
+
if (read === "too_large")
|
|
263
|
+
return tooLarge(res, cors);
|
|
264
|
+
if (read === "busy") {
|
|
265
|
+
send(res, 503, rpcError(BUSY_CODE, `Server busy: this instance is already reading its ${LARGE_BODY_SLOTS} large request bodies at once. Retry after ${BUSY_RETRY_AFTER_SEC} s, or pass check_sbom the SBOM's purls.`, { retry_after_seconds: BUSY_RETRY_AFTER_SEC }), { ...cors, "Retry-After": String(BUSY_RETRY_AFTER_SEC), Connection: "close" });
|
|
266
|
+
log("WARNING", "mcp_large_body_busy", { slots: LARGE_BODY_SLOTS });
|
|
267
|
+
return { status: 503, refused: "large_body_busy" };
|
|
268
|
+
}
|
|
269
|
+
body = read.body;
|
|
270
|
+
slot = read.slot;
|
|
271
|
+
facts = bodyFacts(body);
|
|
272
|
+
// Past the general cap, the body itself must be what the headers (if any) said: one of
|
|
273
|
+
// LARGE_BODY_TARGETS. A 2025-era client sends no Mcp-Method, so this is where its large body
|
|
274
|
+
// is held to that.
|
|
275
|
+
if (body.length > MAX_BODY_BYTES && !isLargeBodyTarget(facts))
|
|
276
|
+
return { ...tooLarge(res, cors), body, facts };
|
|
277
|
+
// A 2026-era subscriptions/listen is a stream open until its client leaves, carrying only
|
|
278
|
+
// keepalives here: it takes no place and holds no work, or 64 idle ones would leave every
|
|
279
|
+
// other request waiting for a 503 (#2773; httpPolicy.ts isListenStream says what bounds it).
|
|
280
|
+
const standard = { protocolVersion: header(req, "mcp-protocol-version"), mcpMethod: header(req, "mcp-method"), mcpName: header(req, "mcp-name") };
|
|
281
|
+
if (isListenStream(facts, body, standard))
|
|
282
|
+
return { ...(await relay(req, res, pathname, method, cors, client, body, gone.signal)), facts };
|
|
183
283
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return {
|
|
284
|
+
// #2773: at most MAX_IN_FLIGHT requests are served at once; the next waits here.
|
|
285
|
+
const waitStart = Date.now();
|
|
286
|
+
if (!(await admission.acquire(ADMISSION_WAIT_MS, gone.signal))) {
|
|
287
|
+
const queuedMs = Date.now() - waitStart;
|
|
288
|
+
if (gone.signal.aborted)
|
|
289
|
+
return { status: 499, refused: "client_closed", clientClosed: true, body, facts, queuedMs };
|
|
290
|
+
req.resume();
|
|
291
|
+
send(res, 503, rpcError(BUSY_CODE, `Server busy: this instance is already serving its ${MAX_IN_FLIGHT} requests at once. Retry after ${BUSY_RETRY_AFTER_SEC} s.`, { retry_after_seconds: BUSY_RETRY_AFTER_SEC }), { ...cors, "Retry-After": String(BUSY_RETRY_AFTER_SEC), Connection: "close" });
|
|
292
|
+
log("WARNING", "mcp_busy", { max_in_flight: MAX_IN_FLIGHT, waiting: admission.waiting });
|
|
293
|
+
return { status: 503, refused: "busy", body, facts, queuedMs };
|
|
294
|
+
}
|
|
295
|
+
const admitted = { inFlight: admission.inUse, queuedMs: Date.now() - waitStart };
|
|
296
|
+
// What is still running for this request (runtime.ts holdRequest): its admission and its
|
|
297
|
+
// large-body slot are held until all of it has settled, not only until the answer is written.
|
|
298
|
+
const work = new Set();
|
|
299
|
+
try {
|
|
300
|
+
return { ...(await relay(req, res, pathname, method, cors, client, body, gone.signal, work)), facts, ...admitted };
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
// Every tool call stops on `gone` (#2775), so this wait is long only if one does not.
|
|
304
|
+
await requestSettled(work);
|
|
305
|
+
admission.release();
|
|
194
306
|
}
|
|
195
|
-
}
|
|
196
|
-
try {
|
|
197
|
-
return { ...(await relay(req, res, pathname, method, cors, client, body)), facts };
|
|
198
307
|
}
|
|
199
308
|
finally {
|
|
200
309
|
if (slot)
|
|
201
310
|
largeBodies.release();
|
|
202
311
|
}
|
|
203
312
|
}
|
|
204
|
-
// Hands one admitted request
|
|
205
|
-
|
|
313
|
+
// Hands one admitted request (or a listen stream, which is not admitted and passes no `work`) to
|
|
314
|
+
// the SDK's handler and streams its answer back.
|
|
315
|
+
async function relay(req, res, pathname, method, cors, client, body, gone, work) {
|
|
206
316
|
const headers = new Headers();
|
|
207
317
|
for (const [k, v] of Object.entries(req.headers)) {
|
|
208
|
-
|
|
318
|
+
// ":method", ":path", ":authority", ":scheme": an h2 request's pseudo-headers, which a Request cannot carry.
|
|
319
|
+
if (v === undefined || HOP_BY_HOP.has(k) || k.startsWith(":"))
|
|
209
320
|
continue;
|
|
210
321
|
for (const one of Array.isArray(v) ? v : [v])
|
|
211
322
|
headers.append(k, one);
|
|
212
323
|
}
|
|
213
|
-
const abort = new AbortController();
|
|
214
|
-
res.on("close", () => abort.abort());
|
|
215
324
|
const request = new Request(`http://localhost${pathname}`, {
|
|
216
325
|
method,
|
|
217
326
|
headers,
|
|
218
327
|
body: body && body.length > 0 ? new Uint8Array(body) : undefined,
|
|
219
|
-
signal:
|
|
328
|
+
signal: gone,
|
|
220
329
|
});
|
|
221
330
|
// #2737: the production synthetic names itself by its user-agent family; its API calls then
|
|
222
331
|
// carry the synthetic's fixed token (runtime.ts upstreamUserAgent), so they are never counted
|
|
223
332
|
// as hosted adoption.
|
|
224
333
|
const synthetic = uaFamily(req.headers["user-agent"]) === SYNTHETIC_UA_FAMILY;
|
|
225
|
-
const response = await withClient(client.forwardAddress, () => handler.fetch(request), { synthetic });
|
|
334
|
+
const response = await withClient(client.forwardAddress, () => handler.fetch(request), { synthetic, signal: gone, work });
|
|
226
335
|
const out = { ...cors };
|
|
227
336
|
response.headers.forEach((v, k) => {
|
|
228
337
|
out[k] = v;
|
|
229
338
|
});
|
|
230
|
-
res.writeHead(response.status, out);
|
|
339
|
+
res.writeHead(response.status, forProtocol(res, out));
|
|
231
340
|
if (response.body) {
|
|
232
341
|
for await (const chunk of response.body)
|
|
233
342
|
res.write(chunk);
|
|
234
343
|
}
|
|
235
344
|
res.end();
|
|
236
|
-
|
|
345
|
+
const clientPublic = client.forwardAddress !== undefined;
|
|
346
|
+
if (gone.aborted)
|
|
347
|
+
return { status: 499, body, clientPublic, clientClosed: true };
|
|
348
|
+
return { status: response.status, body, clientPublic };
|
|
237
349
|
}
|
|
238
|
-
|
|
350
|
+
function onRequest(req, res) {
|
|
239
351
|
const start = process.hrtime.bigint();
|
|
240
352
|
// The path only: a query string is never read, kept or logged.
|
|
241
353
|
let pathname = "/";
|
|
@@ -261,6 +373,9 @@ const server = http.createServer((req, res) => {
|
|
|
261
373
|
refused: o.refused ?? "",
|
|
262
374
|
ua_family: uaFamily(req.headers["user-agent"]),
|
|
263
375
|
client_public: o.clientPublic === true,
|
|
376
|
+
client_closed: o.clientClosed === true,
|
|
377
|
+
in_flight: o.inFlight ?? 0,
|
|
378
|
+
queued_ms: o.queuedMs ?? 0,
|
|
264
379
|
});
|
|
265
380
|
};
|
|
266
381
|
if (pathname === HEALTH_PATH) {
|
|
@@ -283,11 +398,36 @@ const server = http.createServer((req, res) => {
|
|
|
283
398
|
res.end();
|
|
284
399
|
finish({ status: 500 });
|
|
285
400
|
});
|
|
286
|
-
}
|
|
401
|
+
}
|
|
402
|
+
// #2775: with MCP_H2C=1, node:http2's compatibility API, whose request and response carry the
|
|
403
|
+
// IncomingMessage / ServerResponse members used above. Where they differ the code says so:
|
|
404
|
+
// pseudo-headers (relay), connection-specific headers (forProtocol), a stream reset mid-upload
|
|
405
|
+
// (readBody "gone"). A stream reset after that closes the response unfinished, which aborts
|
|
406
|
+
// `gone` in serveMcp exactly as a closed HTTP/1.1 socket does.
|
|
407
|
+
const server = H2C
|
|
408
|
+
? http2.createServer((req, res) => onRequest(req, res))
|
|
409
|
+
: http.createServer(onRequest);
|
|
410
|
+
// #2773: the heap V8 may grow to, and the memory the container allows (0 when none is known). A
|
|
411
|
+
// heap allowed past the container's memory is collected only when the kernel kills the instance:
|
|
412
|
+
// Dockerfile.http caps it, and this says when a process was started without that cap. It can say
|
|
413
|
+
// so only where Node can read the container's limit (process.constrainedMemory(), from cgroups):
|
|
414
|
+
// where it cannot, memory_limit_mb is null, this never fires, and heap_limit_mb is what to read.
|
|
415
|
+
const MiB = 1024 * 1024;
|
|
416
|
+
const heapLimit = v8.getHeapStatistics().heap_size_limit;
|
|
417
|
+
const memoryLimit = process.constrainedMemory?.() ?? 0;
|
|
418
|
+
const memoryKnown = memoryLimit > 0 && memoryLimit < 2 ** 50;
|
|
419
|
+
if (memoryKnown && heapLimit > memoryLimit * 0.75) {
|
|
420
|
+
log("WARNING", "mcp_remote_heap_unbounded", {
|
|
421
|
+
heap_limit_mb: Math.round(heapLimit / MiB),
|
|
422
|
+
memory_limit_mb: Math.round(memoryLimit / MiB),
|
|
423
|
+
reason: "V8 may grow its heap past the container's memory, and the kernel then kills the instance with every request on it (#2773): start http.js as Dockerfile.http does",
|
|
424
|
+
});
|
|
425
|
+
}
|
|
287
426
|
server.listen(PORT, () => {
|
|
288
427
|
const addr = server.address();
|
|
289
428
|
log("INFO", "mcp_remote_listening", {
|
|
290
429
|
port: typeof addr === "object" && addr ? addr.port : PORT,
|
|
430
|
+
protocol: H2C ? "h2c" : "http/1.1",
|
|
291
431
|
name: NAME,
|
|
292
432
|
version: VERSION,
|
|
293
433
|
api_base: SHOWN_BASE,
|
|
@@ -297,6 +437,10 @@ server.listen(PORT, () => {
|
|
|
297
437
|
max_body_bytes: MAX_BODY_BYTES,
|
|
298
438
|
max_sbom_body_bytes: MAX_SBOM_BODY_BYTES,
|
|
299
439
|
large_body_slots: LARGE_BODY_SLOTS,
|
|
440
|
+
max_in_flight: MAX_IN_FLIGHT,
|
|
441
|
+
admission_wait_ms: ADMISSION_WAIT_MS,
|
|
442
|
+
heap_limit_mb: Math.round(heapLimit / MiB),
|
|
443
|
+
memory_limit_mb: memoryKnown ? Math.round(memoryLimit / MiB) : null,
|
|
300
444
|
});
|
|
301
445
|
});
|
|
302
446
|
const shutdown = () => {
|
package/dist/httpPolicy.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// tested on its own: who the client is, which Origins are served, the per-client limit, and the
|
|
3
3
|
// JSON-RPC error bodies the edge answers with.
|
|
4
4
|
import { isIP } from "node:net";
|
|
5
|
+
import { classifyInboundRequest } from "@modelcontextprotocol/server";
|
|
5
6
|
// ── JSON-RPC errors from the edge ──
|
|
6
7
|
//
|
|
7
8
|
// Every refusal at the edge is a JSON-RPC error object, never an HTML page and never a bare
|
|
@@ -10,7 +11,7 @@ import { isIP } from "node:net";
|
|
|
10
11
|
export const ORIGIN_REFUSED_CODE = -32000; // the SDK's own code for an Origin refusal (originValidationResponse)
|
|
11
12
|
export const RATE_LIMITED_CODE = -32029; // implementation-defined server error range (-32000..-32099); "429"
|
|
12
13
|
export const BODY_TOO_LARGE_CODE = -32600; // Invalid Request
|
|
13
|
-
export const BUSY_CODE = -32030; // implementation-defined server error range; "503"
|
|
14
|
+
export const BUSY_CODE = -32030; // implementation-defined server error range; "503": no large-body slot free (#2747), or the instance full (#2773)
|
|
14
15
|
export function rpcError(code, message, data) {
|
|
15
16
|
return JSON.stringify({ jsonrpc: "2.0", id: null, error: { code, message, ...(data ? { data } : {}) } });
|
|
16
17
|
}
|
|
@@ -227,9 +228,11 @@ export class FixedWindowLimiter {
|
|
|
227
228
|
// 2,000-purl ~2 MB body +62 MB; one 5.47 MB body +76 MB; two 5.47 MB bodies at once 209 MB in
|
|
228
229
|
// all, also with V8's old space held to 64 MB: ~59 MB of 256 MiB left for everything else, so
|
|
229
230
|
// not a third. So a body past 64 KiB must hold one of DEFAULT_LARGE_BODY_SLOTS (2) slots per
|
|
230
|
-
// instance for as long as it is read and served; with none free it
|
|
231
|
-
// Retry-After, never queued. The per-client limit still counts each large
|
|
232
|
-
// request, before its body is read.
|
|
231
|
+
// instance for as long as it is read, waits for a place (below) and is served; with none free it
|
|
232
|
+
// is answered 503 with Retry-After, never queued. The per-client limit still counts each large
|
|
233
|
+
// request as one request, before its body is read. That budget measured the two bodies alone, not
|
|
234
|
+
// the requests served beside them, which were enough to kill the instance (#2773): "Requests in
|
|
235
|
+
// flight per instance" below bounds those and records what each costs.
|
|
233
236
|
export const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
|
|
234
237
|
export const DEFAULT_MAX_SBOM_BODY_BYTES = 6 * 1024 * 1024;
|
|
235
238
|
export const DEFAULT_LARGE_BODY_SLOTS = 2;
|
|
@@ -273,6 +276,159 @@ export class Slots {
|
|
|
273
276
|
return this.used;
|
|
274
277
|
}
|
|
275
278
|
}
|
|
279
|
+
// ── Requests in flight per instance (#2773) ──
|
|
280
|
+
//
|
|
281
|
+
// The slots bound the large bodies, not the requests served beside them. Cloud Run sends one
|
|
282
|
+
// instance up to 250 requests at once (deploy-all.sh SVC_CONCURRENCY) into 256 MiB. Measured
|
|
283
|
+
// 2026-10-04 on dist/http.js, Node 22.22, in a 256 MiB memory cgroup, against a stub API
|
|
284
|
+
// answering in 1.5 s, two 4,999,913-character check_sbom documents in flight plus N tools/call
|
|
285
|
+
// cve_summary at once, 2025-era (answered over SSE, the costlier era): N=100 peaked at 226 MB,
|
|
286
|
+
// N=125 at 259 MB (96%), N=200 and N=250 were OOM-killed; with no document, N=200 peaked at
|
|
287
|
+
// 240 MB and N=250 was OOM-killed.
|
|
288
|
+
//
|
|
289
|
+
// Two bounds hold that load (test/http-memory.test.mjs drives the real server with it; what each
|
|
290
|
+
// case does without each bound is below):
|
|
291
|
+
// - V8's heap. In that sandbox (a cgroup v1 on a 16 GB host) Node 22 sized V8's heap from the
|
|
292
|
+
// host's memory, not the cgroup's (heap_size_limit 8,195 MiB in 256 MiB), so V8 left the
|
|
293
|
+
// garbage of the requests served (~1 MB each) uncollected until the kernel killed the
|
|
294
|
+
// instance. So the image caps V8's old space at 128 MiB (Dockerfile.http), and V8 collects
|
|
295
|
+
// first. On Cloud Run this is not verified: an instance that shows Node only its 256 MiB gets
|
|
296
|
+
// a default heap near the cap already, and then the cap changes little. Its start-up line says
|
|
297
|
+
// which (heap_limit_mb; memory_limit_mb, null where Node is told no limit).
|
|
298
|
+
// - The live set. Under the cap, what must fit is what is live at once, and 250 requests at once
|
|
299
|
+
// do not: with every answer held until all 250 were inside the instance, V8 aborted
|
|
300
|
+
// ("JavaScript heap out of memory"). So an instance serves at most DEFAULT_MAX_IN_FLIGHT /mcp
|
|
301
|
+
// requests at once, large ones included (they hold a slot as well), whatever its heap.
|
|
302
|
+
// A request's body is read before it waits for a place, so a client that uploads slowly holds its
|
|
303
|
+
// socket (and a large body its slot), never a place. It then waits, first come first served, up
|
|
304
|
+
// to DEFAULT_ADMISSION_WAIT_MS for one to finish, and is then answered 503 with Retry-After: it
|
|
305
|
+
// waits rather than being refused at once because Cloud Run still routes up to 250 to the
|
|
306
|
+
// instance and counts a waiting request toward scaling out. A waiting request costs its socket and
|
|
307
|
+
// its body: at most 64 KiB, or a large body that holds a slot. At most DEFAULT_MAX_WAITING wait;
|
|
308
|
+
// past that, 503 at once. A request counts until its answer is written AND its work has stopped: a
|
|
309
|
+
// check_sbom call whose client has gone keeps its place until it stops (#2775). /health is never
|
|
310
|
+
// held, and nor is a subscriptions/listen stream (isListenStream below).
|
|
311
|
+
//
|
|
312
|
+
// With both, in that cgroup, the two documents at the API, then 248 cve_summary sent at once:
|
|
313
|
+
// - the API answering in 1.5 s: all 250 answered 200, 64 served at once and the last waiting
|
|
314
|
+
// ~9 s (one core here serves about 28 a second, so on a slower one the last of such a burst
|
|
315
|
+
// wait past DEFAULT_ADMISSION_WAIT_MS and are answered 503), cgroup peak 221-225 MB of 268 MB
|
|
316
|
+
// (82-84%). Without the heap cap: OOM-killed in three runs of four, the fourth at 248 MB.
|
|
317
|
+
// Without the admission: V8 aborted, four of four.
|
|
318
|
+
// - every answer held until all 250 were inside the instance at once: 64 served and answered
|
|
319
|
+
// 200, the other 186 answered 503 after waiting 10 s, peak 201-208 MB (75-77%), and 204-206 MB
|
|
320
|
+
// without the heap cap. Without the admission: V8 aborted, two of two.
|
|
321
|
+
// What this does not bound is a request whose ANSWER is large: a check_sbom call of 2,000 purls
|
|
322
|
+
// fits a 64 KiB body, and with production-shaped rows (~2.2 KB each) one peaks at +77 MB; beside
|
|
323
|
+
// the two documents, 8 at once peaked at 84%, and 16 at once exhausted the 128 MiB heap. Bounding
|
|
324
|
+
// that needs a weight per call (its purls), not a count of requests.
|
|
325
|
+
export const DEFAULT_MAX_IN_FLIGHT = 64;
|
|
326
|
+
export const DEFAULT_ADMISSION_WAIT_MS = 10_000;
|
|
327
|
+
export const DEFAULT_MAX_WAITING = 250;
|
|
328
|
+
/**
|
|
329
|
+
* A counting semaphore whose callers may wait, in arrival order, for a bounded time. acquire
|
|
330
|
+
* resolves true once admitted (the caller then calls release() exactly once), or false when the
|
|
331
|
+
* wait timed out, `signal` aborted, or maxWaiting callers were already waiting.
|
|
332
|
+
*/
|
|
333
|
+
export class Admission {
|
|
334
|
+
max;
|
|
335
|
+
maxWaiting;
|
|
336
|
+
used = 0;
|
|
337
|
+
queue = [];
|
|
338
|
+
constructor(max, maxWaiting) {
|
|
339
|
+
this.max = max;
|
|
340
|
+
this.maxWaiting = maxWaiting;
|
|
341
|
+
}
|
|
342
|
+
acquire(waitMs, signal) {
|
|
343
|
+
if (this.used < this.max && this.queue.length === 0) {
|
|
344
|
+
this.used++;
|
|
345
|
+
return Promise.resolve(true);
|
|
346
|
+
}
|
|
347
|
+
if (signal?.aborted || waitMs <= 0 || this.queue.length >= this.maxWaiting)
|
|
348
|
+
return Promise.resolve(false);
|
|
349
|
+
return new Promise((resolve) => {
|
|
350
|
+
// admit is called by release(), which hands its unit straight over (used is unchanged).
|
|
351
|
+
const admit = () => {
|
|
352
|
+
done();
|
|
353
|
+
resolve(true);
|
|
354
|
+
};
|
|
355
|
+
const giveUp = () => {
|
|
356
|
+
const i = this.queue.indexOf(admit);
|
|
357
|
+
if (i >= 0)
|
|
358
|
+
this.queue.splice(i, 1);
|
|
359
|
+
done();
|
|
360
|
+
resolve(false);
|
|
361
|
+
};
|
|
362
|
+
const timer = setTimeout(giveUp, waitMs);
|
|
363
|
+
const done = () => {
|
|
364
|
+
clearTimeout(timer);
|
|
365
|
+
signal?.removeEventListener("abort", giveUp);
|
|
366
|
+
};
|
|
367
|
+
signal?.addEventListener("abort", giveUp, { once: true });
|
|
368
|
+
this.queue.push(admit);
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
release() {
|
|
372
|
+
const next = this.queue.shift();
|
|
373
|
+
if (next)
|
|
374
|
+
next();
|
|
375
|
+
else if (this.used > 0)
|
|
376
|
+
this.used--;
|
|
377
|
+
}
|
|
378
|
+
get inUse() {
|
|
379
|
+
return this.used;
|
|
380
|
+
}
|
|
381
|
+
get waiting() {
|
|
382
|
+
return this.queue.length;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// ── The request that is a stream: subscriptions/listen ──
|
|
386
|
+
//
|
|
387
|
+
// A 2026-07-28 client opens subscriptions/listen to hear of list changes, and the SDK answers it
|
|
388
|
+
// with an SSE stream that stays open until the client leaves (or the server closes). This server
|
|
389
|
+
// declares no listChanged (index.ts), so the stream carries the acknowledgement and then only
|
|
390
|
+
// the SDK's keepalives: no work, no API call. Admitted like any other request, it held its place
|
|
391
|
+
// for as long as it was open, so 64 idle listeners (under the per-client limit) left every
|
|
392
|
+
// tools/call, initialize and server/discover on the instance waiting 10 s for a 503 (#2773). So it
|
|
393
|
+
// is relayed without admission and without a work hold. What bounds the streams instead: the
|
|
394
|
+
// per-client limit, which counts each one when it opens; the SDK's own cap of
|
|
395
|
+
// DEFAULT_MAX_SUBSCRIPTIONS (1024 in @modelcontextprotocol/server 2.1.0) open per process, past
|
|
396
|
+
// which it answers JSON-RPC -32603 "Subscription limit reached" at once; and on Cloud Run the
|
|
397
|
+
// instance's 250 requests at once and its 60 s request timeout, which ends every stream
|
|
398
|
+
// (deploy-all.sh SVC_CONCURRENCY, --timeout). Measured 2026-10-04 on dist/http.js, Node 22.22,
|
|
399
|
+
// started as Dockerfile.http starts it, in a 256 MiB memory cgroup, with every place taken (two
|
|
400
|
+
// maximum check_sbom documents and 62 cve_summary held at the API): beside 186 open listen
|
|
401
|
+
// streams (Cloud Run's 250 at once) the cgroup peaked at 209-218 MB of 268 MB, and beside 1,024
|
|
402
|
+
// (the SDK's cap) at 224-225 MB, never OOM-killed. Within Cloud Run's 250, a listen stream
|
|
403
|
+
// takes the instance's concurrency that a waiting request would, and Cloud Run scales out.
|
|
404
|
+
//
|
|
405
|
+
// It is the only such request. GET and DELETE /mcp (the 2025 session stream and its end) are
|
|
406
|
+
// answered 405 at once by the SDK's stateless legacy fallback, a 2025-era POST's SSE answer ends
|
|
407
|
+
// with its result, and the server registers no task or resource subscription. Which request is a
|
|
408
|
+
// listen stream is decided by the SDK's own routing step (classifyInboundRequest, what
|
|
409
|
+
// createMcpHandler runs), so the two cannot disagree: a 2025-era body naming the method goes to
|
|
410
|
+
// the legacy fallback, which answers it at once, and it waits for a place like any request.
|
|
411
|
+
export const LISTEN_METHOD = "subscriptions/listen";
|
|
412
|
+
/** Whether a read POST body is one the SDK serves as a subscriptions/listen stream: see the section comment. */
|
|
413
|
+
export function isListenStream(facts, body, headers) {
|
|
414
|
+
if (facts.rpc_method !== LISTEN_METHOD)
|
|
415
|
+
return false;
|
|
416
|
+
let parsed;
|
|
417
|
+
try {
|
|
418
|
+
parsed = JSON.parse(body.toString("utf8"));
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
const route = classifyInboundRequest({
|
|
424
|
+
httpMethod: "POST",
|
|
425
|
+
protocolVersionHeader: headers.protocolVersion,
|
|
426
|
+
mcpMethodHeader: headers.mcpMethod,
|
|
427
|
+
mcpNameHeader: headers.mcpName,
|
|
428
|
+
body: parsed,
|
|
429
|
+
});
|
|
430
|
+
return route.kind === "modern" && route.messageKind === "request" && route.message.method === LISTEN_METHOD;
|
|
431
|
+
}
|
|
276
432
|
// ── Bounded access-log fields ──
|
|
277
433
|
//
|
|
278
434
|
// The access line carries no body, no query string, no argument and no client address: only
|