echelongraph-mcp 2.6.3 → 2.6.5

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
@@ -22,9 +22,10 @@
22
22
  // waf/mcpforward.go, #2212). MCP_REQUIRE_FORWARD_TOKEN=1 (set by deploy-all.sh) makes a missing
23
23
  // token fatal at start-up, because without it every remote user would share one API budget.
24
24
  import http from "node:http";
25
+ import v8 from "node:v8";
25
26
  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";
27
+ import { forwardToken, markHttpEntrypoint, requestSettled, SYNTHETIC_UA_FAMILY, withClient } from "./runtime.js";
28
+ 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
29
  // Must run before index.ts is evaluated: it is how index.ts knows not to start stdio.
29
30
  markHttpEntrypoint();
30
31
  const { createServer, NAME, VERSION, SHOWN_BASE, TIMEOUT_MS } = await import("./index.js");
@@ -41,7 +42,12 @@ const RATE_LIMIT_PER_MIN = positiveInt(process.env.MCP_RATE_LIMIT_PER_MIN, DEFAU
41
42
  const MAX_BODY_BYTES = positiveInt(process.env.MCP_MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
42
43
  const MAX_SBOM_BODY_BYTES = Math.max(MAX_BODY_BYTES, positiveInt(process.env.MCP_MAX_SBOM_BODY_BYTES, DEFAULT_MAX_SBOM_BODY_BYTES));
43
44
  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.
45
+ // The requests served at once, and how long the next may wait (#2773): httpPolicy.ts "Requests
46
+ // in flight per instance".
47
+ const MAX_IN_FLIGHT = positiveInt(process.env.MCP_MAX_IN_FLIGHT, DEFAULT_MAX_IN_FLIGHT);
48
+ const ADMISSION_WAIT_MS = positiveInt(process.env.MCP_ADMISSION_WAIT_MS, DEFAULT_ADMISSION_WAIT_MS);
49
+ // A large body's wait for a slot is not queued: the client is told to come back. Nor is a
50
+ // request that waited ADMISSION_WAIT_MS for admission.
45
51
  const BUSY_RETRY_AFTER_SEC = 5;
46
52
  const MCP_PATH = "/mcp";
47
53
  const HEALTH_PATH = "/health";
@@ -67,6 +73,7 @@ const handler = createMcpHandler(createServer, {
67
73
  });
68
74
  const limiter = new FixedWindowLimiter(RATE_LIMIT_PER_MIN);
69
75
  const largeBodies = new Slots(LARGE_BODY_SLOTS);
76
+ const admission = new Admission(MAX_IN_FLIGHT, DEFAULT_MAX_WAITING);
70
77
  // CORS for a browser client on an https Origin (the Origin policy has already admitted it).
71
78
  // A wildcard is safe here because nothing is credentialed: no cookies, no auth.
72
79
  const CORS_HEADERS = {
@@ -169,40 +176,87 @@ async function serveMcp(req, res, pathname) {
169
176
  log("WARNING", "mcp_rate_limited", { client: client.key, limit: RATE_LIMIT_PER_MIN });
170
177
  return { status: 429, throttled: true };
171
178
  }
172
- let body;
173
- let facts;
179
+ // #2775: the client has gone when the response closes before it has finished. That aborts the
180
+ // request: its wait for admission, the SDK's handling of it (which cancels the tool's call), and
181
+ // its API calls (runtime.ts requestSignal).
182
+ const gone = new AbortController();
183
+ res.on("close", () => {
184
+ if (!res.writableFinished)
185
+ gone.abort();
186
+ });
187
+ // The body is read BEFORE the request waits for admission (#2773), so a client that uploads
188
+ // slowly holds its socket, and a large body its large-body slot, as before admission existed,
189
+ // never one of the MAX_IN_FLIGHT places every other request needs. What a waiting request holds
190
+ // is its body: at most MAX_BODY_BYTES, or a large body that holds one of the slots.
174
191
  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" };
192
+ try {
193
+ let body;
194
+ let facts;
195
+ if (method === "POST") {
196
+ let read;
197
+ try {
198
+ read = await readBody(req);
199
+ }
200
+ catch (e) {
201
+ // The request stream fails only when its connection does: the client went mid-upload.
202
+ if (req.destroyed)
203
+ return { status: 499, refused: "client_closed", clientClosed: true };
204
+ throw e;
205
+ }
206
+ if (read === "too_large")
207
+ return tooLarge(res, cors);
208
+ if (read === "busy") {
209
+ 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" });
210
+ log("WARNING", "mcp_large_body_busy", { slots: LARGE_BODY_SLOTS });
211
+ return { status: 503, refused: "large_body_busy" };
212
+ }
213
+ body = read.body;
214
+ slot = read.slot;
215
+ facts = bodyFacts(body);
216
+ // Past the general cap, the body itself must be what the headers (if any) said: one of
217
+ // LARGE_BODY_TARGETS. A 2025-era client sends no Mcp-Method, so this is where its large body
218
+ // is held to that.
219
+ if (body.length > MAX_BODY_BYTES && !isLargeBodyTarget(facts))
220
+ return { ...tooLarge(res, cors), body, facts };
221
+ // A 2026-era subscriptions/listen is a stream open until its client leaves, carrying only
222
+ // keepalives here: it takes no place and holds no work, or 64 idle ones would leave every
223
+ // other request waiting for a 503 (#2773; httpPolicy.ts isListenStream says what bounds it).
224
+ const standard = { protocolVersion: header(req, "mcp-protocol-version"), mcpMethod: header(req, "mcp-method"), mcpName: header(req, "mcp-name") };
225
+ if (isListenStream(facts, body, standard))
226
+ return { ...(await relay(req, res, pathname, method, cors, client, body, gone.signal)), facts };
183
227
  }
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 };
228
+ // #2773: at most MAX_IN_FLIGHT requests are served at once; the next waits here.
229
+ const waitStart = Date.now();
230
+ if (!(await admission.acquire(ADMISSION_WAIT_MS, gone.signal))) {
231
+ const queuedMs = Date.now() - waitStart;
232
+ if (gone.signal.aborted)
233
+ return { status: 499, refused: "client_closed", clientClosed: true, body, facts, queuedMs };
234
+ req.resume();
235
+ 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" });
236
+ log("WARNING", "mcp_busy", { max_in_flight: MAX_IN_FLIGHT, waiting: admission.waiting });
237
+ return { status: 503, refused: "busy", body, facts, queuedMs };
238
+ }
239
+ const admitted = { inFlight: admission.inUse, queuedMs: Date.now() - waitStart };
240
+ // What is still running for this request (runtime.ts holdRequest): its admission and its
241
+ // large-body slot are held until all of it has settled, not only until the answer is written.
242
+ const work = new Set();
243
+ try {
244
+ return { ...(await relay(req, res, pathname, method, cors, client, body, gone.signal, work)), facts, ...admitted };
245
+ }
246
+ finally {
247
+ // Every tool call stops on `gone` (#2775), so this wait is long only if one does not.
248
+ await requestSettled(work);
249
+ admission.release();
194
250
  }
195
- }
196
- try {
197
- return { ...(await relay(req, res, pathname, method, cors, client, body)), facts };
198
251
  }
199
252
  finally {
200
253
  if (slot)
201
254
  largeBodies.release();
202
255
  }
203
256
  }
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) {
257
+ // Hands one admitted request (or a listen stream, which is not admitted and passes no `work`) to
258
+ // the SDK's handler and streams its answer back.
259
+ async function relay(req, res, pathname, method, cors, client, body, gone, work) {
206
260
  const headers = new Headers();
207
261
  for (const [k, v] of Object.entries(req.headers)) {
208
262
  if (v === undefined || HOP_BY_HOP.has(k))
@@ -210,19 +264,17 @@ async function relay(req, res, pathname, method, cors, client, body) {
210
264
  for (const one of Array.isArray(v) ? v : [v])
211
265
  headers.append(k, one);
212
266
  }
213
- const abort = new AbortController();
214
- res.on("close", () => abort.abort());
215
267
  const request = new Request(`http://localhost${pathname}`, {
216
268
  method,
217
269
  headers,
218
270
  body: body && body.length > 0 ? new Uint8Array(body) : undefined,
219
- signal: abort.signal,
271
+ signal: gone,
220
272
  });
221
273
  // #2737: the production synthetic names itself by its user-agent family; its API calls then
222
274
  // carry the synthetic's fixed token (runtime.ts upstreamUserAgent), so they are never counted
223
275
  // as hosted adoption.
224
276
  const synthetic = uaFamily(req.headers["user-agent"]) === SYNTHETIC_UA_FAMILY;
225
- const response = await withClient(client.forwardAddress, () => handler.fetch(request), { synthetic });
277
+ const response = await withClient(client.forwardAddress, () => handler.fetch(request), { synthetic, signal: gone, work });
226
278
  const out = { ...cors };
227
279
  response.headers.forEach((v, k) => {
228
280
  out[k] = v;
@@ -233,7 +285,10 @@ async function relay(req, res, pathname, method, cors, client, body) {
233
285
  res.write(chunk);
234
286
  }
235
287
  res.end();
236
- return { status: response.status, body, clientPublic: client.forwardAddress !== undefined };
288
+ const clientPublic = client.forwardAddress !== undefined;
289
+ if (gone.aborted)
290
+ return { status: 499, body, clientPublic, clientClosed: true };
291
+ return { status: response.status, body, clientPublic };
237
292
  }
238
293
  const server = http.createServer((req, res) => {
239
294
  const start = process.hrtime.bigint();
@@ -261,6 +316,9 @@ const server = http.createServer((req, res) => {
261
316
  refused: o.refused ?? "",
262
317
  ua_family: uaFamily(req.headers["user-agent"]),
263
318
  client_public: o.clientPublic === true,
319
+ client_closed: o.clientClosed === true,
320
+ in_flight: o.inFlight ?? 0,
321
+ queued_ms: o.queuedMs ?? 0,
264
322
  });
265
323
  };
266
324
  if (pathname === HEALTH_PATH) {
@@ -284,6 +342,22 @@ const server = http.createServer((req, res) => {
284
342
  finish({ status: 500 });
285
343
  });
286
344
  });
345
+ // #2773: the heap V8 may grow to, and the memory the container allows (0 when none is known). A
346
+ // heap allowed past the container's memory is collected only when the kernel kills the instance:
347
+ // Dockerfile.http caps it, and this says when a process was started without that cap. It can say
348
+ // so only where Node can read the container's limit (process.constrainedMemory(), from cgroups):
349
+ // where it cannot, memory_limit_mb is null, this never fires, and heap_limit_mb is what to read.
350
+ const MiB = 1024 * 1024;
351
+ const heapLimit = v8.getHeapStatistics().heap_size_limit;
352
+ const memoryLimit = process.constrainedMemory?.() ?? 0;
353
+ const memoryKnown = memoryLimit > 0 && memoryLimit < 2 ** 50;
354
+ if (memoryKnown && heapLimit > memoryLimit * 0.75) {
355
+ log("WARNING", "mcp_remote_heap_unbounded", {
356
+ heap_limit_mb: Math.round(heapLimit / MiB),
357
+ memory_limit_mb: Math.round(memoryLimit / MiB),
358
+ 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",
359
+ });
360
+ }
287
361
  server.listen(PORT, () => {
288
362
  const addr = server.address();
289
363
  log("INFO", "mcp_remote_listening", {
@@ -297,6 +371,10 @@ server.listen(PORT, () => {
297
371
  max_body_bytes: MAX_BODY_BYTES,
298
372
  max_sbom_body_bytes: MAX_SBOM_BODY_BYTES,
299
373
  large_body_slots: LARGE_BODY_SLOTS,
374
+ max_in_flight: MAX_IN_FLIGHT,
375
+ admission_wait_ms: ADMISSION_WAIT_MS,
376
+ heap_limit_mb: Math.round(heapLimit / MiB),
377
+ memory_limit_mb: memoryKnown ? Math.round(memoryLimit / MiB) : null,
300
378
  });
301
379
  });
302
380
  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