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/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
- // A large body's wait for a slot is not queued: the client is told to come back.
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
- resolve({ body: Buffer.concat(chunks), slot });
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
- let body;
173
- let facts;
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
- if (method === "POST") {
176
- const read = await readBody(req);
177
- if (read === "too_large")
178
- return tooLarge(res, cors);
179
- if (read === "busy") {
180
- 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" });
181
- log("WARNING", "mcp_large_body_busy", { slots: LARGE_BODY_SLOTS });
182
- return { status: 503, refused: "large_body_busy" };
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
- body = read.body;
185
- slot = read.slot;
186
- facts = bodyFacts(body);
187
- // Past the general cap, the body itself must be what the headers (if any) said: one of
188
- // LARGE_BODY_TARGETS. A 2025-era client sends no Mcp-Method, so this is where its large body
189
- // is held to that.
190
- if (body.length > MAX_BODY_BYTES && !isLargeBodyTarget(facts)) {
191
- if (slot)
192
- largeBodies.release();
193
- return { ...tooLarge(res, cors), body, facts };
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 to the SDK's handler and streams its answer back.
205
- async function relay(req, res, pathname, method, cors, client, body) {
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
- if (v === undefined || HOP_BY_HOP.has(k))
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: abort.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
- return { status: response.status, body, clientPublic: client.forwardAddress !== undefined };
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
- const server = http.createServer((req, res) => {
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 = () => {
@@ -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", no large-body slot free (#2747)
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 is answered 503 with
231
- // Retry-After, never queued. The per-client limit still counts each large request as one
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