echelongraph-mcp 2.6.2 → 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/README.md CHANGED
@@ -28,6 +28,8 @@ API call a tool needs to answer.
28
28
  Registry: `io.echelongraph/echelongraph-mcp` · Docs: <https://echelongraph.io/pulse/mcp>
29
29
  - Source: <https://github.com/echelongraph/echelongraph-mcp> · Changes:
30
30
  [CHANGELOG.md](CHANGELOG.md) · Security: [SECURITY.md](SECURITY.md)
31
+ - Also listed on: [Smithery](https://smithery.ai/servers/echelongraph/echelongraph-mcp) ·
32
+ [Glama](https://glama.ai/mcp/servers/echelongraph/echelongraph-mcp)
31
33
 
32
34
  ## Quick start
33
35
 
@@ -38,20 +40,83 @@ have on record for it?"*
38
40
 
39
41
  ### Claude Desktop
40
42
 
41
- Settings → Developer → Edit Config opens `claude_desktop_config.json`
42
- (macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`; Windows:
43
- `%APPDATA%\Claude\claude_desktop_config.json`). Add:
43
+ Two ways in. The first needs nothing installed; the second runs the server on your machine.
44
+
45
+ **1. Custom connector (no install).** Claude Desktop uses your claude.ai account's connectors:
46
+ Customize → Connectors → **+ Add** → **Add custom connector**. Name it `EchelonGraph`, paste
47
+ `https://mcp.echelongraph.io/mcp`, choose **No sign in**, and add it. No Node.js, no PATH and no
48
+ JSON to edit. A connector added on claude.ai shows up in Claude Desktop too, and the plan rules
49
+ are claude.ai's (see "Remote (no install)" below).
50
+
51
+ **2. Local server through `npx`.**
52
+
53
+ 1. **Check Node.js.** In Terminal (macOS) or PowerShell (Windows), run `node -v`. It must print
54
+ `v20` or later. If it prints an older version or `command not found`, install the LTS release
55
+ from <https://nodejs.org>, or on macOS with Homebrew run `brew install node`.
56
+ 2. **Open the config file in a plain-text editor.** Settings → Developer → Edit Config shows
57
+ `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`;
58
+ Windows: `%APPDATA%\Claude\claude_desktop_config.json`). Open it in a plain-text editor such as
59
+ VS Code, Notepad or `nano`. TextEdit on macOS turns `"` into curly quotes (`“ ”`) when Smart
60
+ Quotes is on, and the JSON then breaks without an error: turn it off under Edit → Substitutions
61
+ → Smart Quotes, or use another editor.
62
+ 3. **Merge, do not replace.** The file usually exists already and holds Claude Desktop's own
63
+ settings (for example a `"preferences"` object). Keep everything in it. Add an `"mcpServers"`
64
+ key at the top level, next to the keys already there; if `"mcpServers"` is already there, add
65
+ the `"echelongraph"` entry inside it. Mind the comma between entries. The result looks like
66
+ this, with your own settings where `preferences` is:
67
+
68
+ ```json
69
+ {
70
+ "preferences": {
71
+ "…": "the settings already in your file stay as they are"
72
+ },
73
+ "mcpServers": {
74
+ "echelongraph": {
75
+ "command": "npx",
76
+ "args": ["-y", "echelongraph-mcp"]
77
+ }
78
+ }
79
+ }
80
+ ```
81
+
82
+ To check that the file parses, run
83
+ `node -e "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'))" "<path to the file>"`:
84
+ no output means valid JSON.
85
+ 4. **Quit fully, then reopen.** Closing the window leaves the old config loaded. On macOS choose
86
+ Claude → Quit Claude (⌘Q); on Windows right-click the Claude icon in the system tray and choose
87
+ Quit. The first start can take longer while `npx` downloads the package.
88
+ 5. **Check that it worked.** Settings → Developer lists `echelongraph` as running, and the tools
89
+ menu in the chat box lists EchelonGraph's 14 tools. Then ask the question at the top of this
90
+ section.
91
+
92
+ #### Troubleshooting Claude Desktop
93
+
94
+ - **`spawn npx ENOENT`** (or the server shows as failed). On macOS, an app opened from the Dock
95
+ does not get your shell's PATH, so Claude Desktop cannot find `npx` where Homebrew (Apple
96
+ silicon: `/opt/homebrew/bin`; Intel: `/usr/local/bin`) or nvm put it. Run `which npx` in
97
+ Terminal, then give Claude Desktop that absolute path as `"command"`, and the folder it is in as
98
+ `PATH` under `"env"` (`npx` starts `node` from that same folder). With Homebrew on Apple silicon:
44
99
 
45
- ```json
46
- {
47
- "mcpServers": {
48
- "echelongraph": {
49
- "command": "npx",
50
- "args": ["-y", "echelongraph-mcp"]
100
+ ```json
101
+ {
102
+ "mcpServers": {
103
+ "echelongraph": {
104
+ "command": "/opt/homebrew/bin/npx",
105
+ "args": ["-y", "echelongraph-mcp"],
106
+ "env": { "PATH": "/opt/homebrew/bin:/usr/bin:/bin" }
107
+ }
51
108
  }
52
109
  }
53
- }
54
- ```
110
+ ```
111
+
112
+ With nvm, use the full path `which npx` prints (JSON does not expand `~`). Then quit fully and
113
+ reopen. On Windows, install Node.js with the installer from nodejs.org, which adds `npx` to the
114
+ system PATH, then quit Claude Desktop fully and reopen it.
115
+ - **The server does not appear at all.** The file is not valid JSON (a missing comma, or curly
116
+ quotes), or the app was not fully quit: see steps 2 to 4.
117
+ - **Logs.** macOS: `~/Library/Logs/Claude/mcp-server-echelongraph.log` (this server's output) and
118
+ `~/Library/Logs/Claude/mcp.log` (connections). Windows: `%APPDATA%\Claude\logs\`, with the same
119
+ file names.
55
120
 
56
121
  ### Claude Code
57
122
 
@@ -155,14 +220,14 @@ The example questions are ones a client can answer with that tool alone.
155
220
  | Tool | Title | What it answers | Example question |
156
221
  |---|---|---|---|
157
222
  | `cve_summary` | CVE feed summary | Counts of active CVEs by severity band, the count with no severity band from any source (`summary.none`, sent again as `summary.unscored`: CVEs not yet scored, not a rating of None), the same CVEs counted by NVD's severity label, as provenance (`summary.nvd_critical` to `summary.nvd_none`), the rejected (withdrawn) records outside the total (`summary.rejected`), and when the feed was last updated. | *How many critical CVEs does the feed hold, and when was it last updated?* |
158
- | `search_cves` | Search CVEs | Search/filter CVEs (severity, min CVSS, text, sort) with EchelonGraph scores and `score_assessed`; the note names each row not yet scored. | *Find critical CVEs that mention Tomcat with a CVSS of 9 or more.* |
159
- | `get_cve` | CVE detail | Full record for one CVE: CVSS v3 and (when scored) v4, the EchelonGraph score and its confidence, whether EchelonGraph has scored it (`score_assessed`), EPSS, CISA-KEV status and known ransomware use, the GitHub GHSA id, references, and its published, modified and `updated_at` times. | *What are CVE-2024-3094's CVSS, EPSS and CISA-KEV status?* |
223
+ | `search_cves` | Search CVEs | Search/filter CVEs (severity, min CVSS, text, sort) with EchelonGraph scores and `score_assessed`; page with `limit` and `offset`; the note names each row not yet scored. | *Find critical CVEs that mention Tomcat with a CVSS of 9 or more.* |
224
+ | `get_cve` | CVE detail | One CVE's record: CVSS v3 and (when scored) v4, the EchelonGraph score and its confidence, whether EchelonGraph has scored it (`score_assessed`), EPSS, CISA-KEV status and known ransomware use, the GitHub GHSA id, references, and its published, modified and `updated_at` times. | *What are CVE-2024-3094's CVSS, EPSS and CISA-KEV status?* |
160
225
  | `cve_exposure` | Internet exposure for one CVE | Internet-exposure footprint for a CVE: exposed service count (distinct ip:port, the `exposed_hosts` field) + country/product breakdown, from the KEV-exposure radar. | *How many exposed services does EchelonGraph's radar have on record for CVE-2023-44487?* |
161
226
  | `exposure_radar` | Exposure radar totals | Aggregate totals across the exposure radars: services running CISA-KEV CVEs; unauthenticated data stores and observability UIs, found through Shodan (LeakIX when Shodan query credits run low) and then confirmed by EchelonGraph's own identified check, which is not a pure read (on Redis it names its client; on ClickHouse its query lands in the server's query log); leaked credentials; shadow AI; and MCP servers found in EchelonGraph's own Certificate Transparency feed, by RFC 9728 verdict, protocol era and transport. Every number is labelled by what it counts, and a field the tool cannot label is left out and named. | *How many unauthenticated data stores has EchelonGraph's radar confirmed?* |
162
227
  | `kev_recent` | Recent CISA KEV additions | The CVEs CISA has added to its Known Exploited Vulnerabilities catalog, newest first (`kev_added_date`), from EchelonGraph's copy of the catalog, polled from CISA every 5 minutes: due date, vendor, product, known ransomware use, EchelonGraph's severity, CVSS, EPSS and `eg_kev_tier`, and `our_first_seen_kev`. Filter by date range, ransomware and vendor; page with `limit` and `next_cursor`. Dated by `last_successful_fetch_at`, our last successful fetch of CISA's feed; the filters travel as request headers, never in the URL. | *Which CVEs has CISA added to KEV since 1 September, and which have known ransomware use?* |
163
228
  | `epss_history` | EPSS change history for one CVE | How one CVE's EPSS score has changed, as EchelonGraph recorded it: one point per recorded change (`series_kind` `change_only`), never a daily series, with the value now and `series_starts_at`, when recording began; before it a missing point means not recorded, not unchanged. | *How has CVE-2023-44487's EPSS score changed, as EchelonGraph recorded it?* |
164
229
  | `check_affected` | Am I affected? (product or package at a version) | Whether a product (its NVD CPE product token) or a registry package (`ecosystem` and `package`) at a given version is affected by known CVEs, from the matcher behind echelongraph.io/am-i-affected: `assessed` first (false: not evaluated, with `not_assessed_reason`, and a count of 0 then is not "not affected"), the matching CVEs with `kev_listed`, `ransomware`, `epss_score`, `effective_score` and `score_assessed`, and advisories it cannot decide counted as `undetermined_count`, never as safe. What you look up travels in request headers, never in the URL. | *Is openssl 3.0.0 affected by known CVEs? Is lodash 4.17.15 on npm?* |
165
- | `check_sbom` | Check an SBOM against the advisory corpus | A dependency list checked against EchelonGraph's advisory corpus (OSV.dev records), one verdict per component: `affected`, `not_affected`, `undetermined` or `not_assessed`, each with its `not_assessed_reason`. Pass up to 2,000 distinct purls, or a CycloneDX JSON or SPDX JSON document: the purls are read from it by the MCP server (on your machine when it runs from npm) and only they are sent to the API, in POST bodies of at most 200 each, one after another; the document is not. When the API's per-component rate limit answers 429, the tool waits the `Retry-After` it names, within 50 seconds per call; past that it answers what it has, with the purls not sent counted in `coverage.not_sent`, the reason in `coverage.not_sent_reason`, and the list in `data.not_sent_purls`, never dropped silently. A deb, apk or rpm purl without a `distro` qualifier naming its release is not assessed (`distro_release_unknown`): EchelonGraph does not guess a release. Only `not_affected` is clean. No ranking, no score. | *Check this CycloneDX SBOM against the advisory corpus.* |
230
+ | `check_sbom` | Check an SBOM against the advisory corpus | A dependency list checked against EchelonGraph's advisory corpus (OSV.dev records), one verdict per component: `affected`, `not_affected`, `undetermined` or `not_assessed`, each with its `not_assessed_reason`. Pass up to 2,000 distinct purls, or a CycloneDX JSON or SPDX JSON document: the purls are read from it by the MCP server and only they are sent to the API, in POST bodies of at most 200 each, one after another; the document itself is not sent on. Run from npm, the server is on your machine; over the hosted endpoint (`mcp.echelongraph.io`) it is EchelonGraph's, and the document is the request body. When the API's per-component rate limit answers 429, the tool waits the `Retry-After` it names, within 50 seconds per call; past that it answers what it has, with the purls not sent counted in `coverage.not_sent`, the reason in `coverage.not_sent_reason`, and the list in `data.not_sent_purls`, never dropped silently. A deb, apk or rpm purl without a `distro` qualifier naming its release is not assessed (`distro_release_unknown`): EchelonGraph does not guess a release. Only `not_affected` is clean. No ranking, no score. | *Check this CycloneDX SBOM against the advisory corpus.* |
166
231
  | `cve_intel` | CVE weakness, exploits and packages | Weakness, public exploit code, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment: `cwes`, `exploits` (at most 10, verified first) with `exploits_total`, `exploits_capped`, `exploits_by_kind` and `exploits_by_status`, `affected_packages`, `fixed_versions` and `timeline`. `verified_status` is the label stored with each reference, not a guarantee that the exploit works. An empty `exploits` list is not evidence that no public exploit exists; a section the API could not read is named in `coverage.sections_failed`, never relayed as an empty list. | *Is there public exploit code for CVE-2021-44228, and which versions fix it?* |
167
232
  | `get_cwe` | CWE and its CVEs | One CWE (weakness class) and the CVEs classified under it: `name` and `description` from the MITRE CWE catalog EchelonGraph embeds, `total`, and one page of 50 `cves`, ordered as `order` states (CISA-KEV-listed first, then EchelonGraph score). A `total` of 0 says that no CVE in EchelonGraph's feed is classified under that CWE, not that none exists. | *Which CVEs are classified under CWE-79, CISA-KEV-listed first?* |
168
233
  | `vendor_advisories_for_cve` | Vendor advisories for one CVE | The vendor-published advisories (Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks, GitHub GHSA and the other feeds EchelonGraph polls) that name one CVE, newest first, at most 20. | *Which vendor advisories name CVE-2024-3400?* |
@@ -195,8 +260,17 @@ it is neither a count of CVEs rated None nor the count of CVEs with no severity,
195
260
  `summary.total` and the other counts leave them out, and they are withdrawn records, never
196
261
  vulnerabilities.
197
262
 
198
- The tool relays the API's JSON as it was sent. When `summary.none`, `summary.nvd_none` or
199
- `summary.rejected` is above zero, the note says what it counts, with the number from the answer.
263
+ `poller` holds the in-memory counters of the NVD poller of the one API instance that answered,
264
+ counted since that instance last started and zeroed on every restart: they describe that instance,
265
+ never the feed's size, intake, reliability or freshness, and the note says so whenever the answer
266
+ carries `poller`. A `poller` field the answer sends in a JSON type other than the one the
267
+ outputSchema describes is left out and named in the note, and a `poller` that is neither a JSON
268
+ object nor null is left out whole; `summary` is relayed either way, so a change to that diagnostic
269
+ block never withholds the counts.
270
+
271
+ The tool relays the API's JSON as it was sent, less a `poller` field left out as above. When
272
+ `summary.none`, `summary.nvd_none` or `summary.rejected` is above zero, the note says what it
273
+ counts, with the number from the answer.
200
274
  The note says the five NVD counts add up to `summary.total` only when the answer's do, and says
201
275
  nothing about a field the answer does not carry.
202
276
 
@@ -312,6 +386,7 @@ A `newest_kev` row's `exposure_state` says whether its count is a measurement:
312
386
  | `exposed_databases.engines` | Distinct engine types among them (for example `redis` or `grafana`). |
313
387
  | `exposed_databases.top_engines`, `exposed_databases.top_countries` | Up to 15 engines and 10 countries, ranked by those services. |
314
388
  | `exposed_databases.pii_likely`, `exposed_databases.pci_likely` | Services whose schema names pass a high-confidence gate for personal data, or for payment-card data. The names are index, database, table or field names in the Shodan or LeakIX banner or in the check's response, never record values; the gate passes one unambiguous term (such as `ssn` or `cardholder`) or two distinct indicators. It is a precision-first schema gate, not a census: a service whose names do not pass it is not counted, whatever it holds, so the other services are not shown to hold no such data. |
389
+ | `exposed_databases.window.from`, `exposed_databases.window.to` | Timestamps, not counts: when EchelonGraph's check last confirmed the oldest and the newest of the services counted answering without authentication, a span of checks made at different times. Relayed only when every counted service has such a time; otherwise the note says how many do not. |
315
390
  | `exposed_databases.last_run_at` | A timestamp, not a count: when the radar last completed a check, a cycle in which its Shodan search, or the LeakIX fallback, answered at least one query (others may have failed), its list of services due for a re-check was read, and the scan opt-out register could be consulted. A cycle that searched nothing, or whose reads failed, does not move it. |
316
391
 
317
392
  #### `leaked_credentials`
@@ -322,6 +397,7 @@ A `newest_kev` row's `exposure_state` says whether its count is a measurement:
322
397
  | `leaked_credentials.distinct_secrets` | Each secret once. |
323
398
  | `leaked_credentials.distinct_repos` | Public GitHub repositories with at least one. |
324
399
  | `leaked_credentials.top_providers`, `leaked_credentials.top_types` | Up to 15 providers and secret types, ranked by (repository, secret) pairs. |
400
+ | `leaked_credentials.window.from`, `leaked_credentials.window.to` | Timestamps, not counts: when EchelonGraph's detector last found the oldest and the newest of the (repository, secret) pairs counted in a public commit. |
325
401
  | `leaked_credentials.last_run_at` | A timestamp, not a count: when the radar last completed a check, a cycle that read the public GitHub event stream (fetches of some of the commits it lists may have failed). A cycle whose read of that stream failed does not move it. |
326
402
 
327
403
  None of them is validated. Each is a credential-shaped string in a public commit that passed
@@ -339,6 +415,7 @@ labels each one. Only `shadow_ai.confirmed_exposed` counts exposed services.
339
415
  | `confirmed_exposed.total` | Confirmed exposed: services EchelonGraph's probes found answering without an authentication gate (liveness `active`, or `rechecking` during a re-check). It is the sum of `confirmed_exposed.by_category`. |
340
416
  | `confirmed_exposed.by_category` | The same services, by category. |
341
417
  | `confirmed_exposed.last_24h` | Confirmed-exposed services first recorded in the last 24 hours. |
418
+ | `confirmed_exposed.window.from`, `confirmed_exposed.window.to` | Timestamps, not counts: when EchelonGraph's verifier ran the probe that last decided the oldest and the newest of the confirmed-exposed services, a span of probes made at different times. Relayed only when every one has such a time. |
342
419
  | `observed.total` | Every Certificate Transparency or Shodan observation on record, whatever its verification state: observed, not exposed. |
343
420
  | `observed.by_category` | The same observations, by category. |
344
421
  | `observed.last_24h` | Observations first recorded in the last 24 hours. |
@@ -467,31 +544,34 @@ never "not affected", and each figure cites its `measured_at`. A prompt makes no
467
544
 
468
545
  | Prompt | Arguments | What it asks the model to do |
469
546
  |---|---|---|
470
- | `triage_cve` | `cve_id` | `get_cve`, `cve_intel`, `vendor_advisories_for_cve`, `epss_history` and `cve_exposure` (and `kev_recent` for the due date when the CVE is KEV-listed), then a decision template: exploited, likelihood, reachable, patch, deadline, decision. |
471
- | `kev_weekly_brief` | `days` (1 to 365, default 7) | Work out the start date from `days`, page through `kev_recent` from it, and write a brief grouped by vendor with ransomware flags and due dates. |
547
+ | `triage_cve` | `cve_id` | `get_cve` (with CISA's due date, `kev_due_date`, when the CVE is KEV-listed), `cve_intel`, `vendor_advisories_for_cve`, `epss_history` and `cve_exposure`, then a decision template: exploited, likelihood, reachable, patch, deadline, decision. The patch line gives `cve_intel`'s `fixed_version` as what it records for the package, one per package (the advisory's last range's), never as the fix for every affected branch, and points to `get_cve`'s references tagged Vendor Advisory or Patch for a branch's fix. |
548
+ | `kev_weekly_brief` | `days` (1 to 365, default 7) | Work out the start date from `days`, page through `kev_recent` from it 50 rows a page, and write a brief grouped by vendor with ransomware flags and due dates. |
472
549
  | `am_i_affected` | `product`, or `ecosystem` and `package`; `version` | `check_affected`, read `assessed` before `count`, with the not-assessed wording. |
473
- | `sbom_review` | `sbom` (CycloneDX or SPDX JSON text, up to 5,000,000 characters) | Pass the document to `check_sbom`, look up each affected CVE with `get_cve`, and write a fix list ordered by CISA-KEV listing, then EPSS, then score, each key stated. |
550
+ | `sbom_review` | `sbom` (CycloneDX or SPDX JSON text, up to 5,000,000 characters) | Pass the document to `check_sbom` (and, once more, the purls a rate limit or the time budget left unsent: `data.not_sent_purls`, or, where the text cuts that list, the document's purls from the position and in the order its note gives), read each component's ecosystem, package and version from its purl, as `check_sbom` maps them (every purl type it maps, and `deb`, `apk`, `alpine` and `rpm` by a `distro` qualifier naming a Debian, Ubuntu or Alpine release; any other is unknown, and given no fixed version), look up each affected CVE with `get_cve` and `cve_intel`, and write a fix list ordered by CISA-KEV listing, then EPSS, then score, each key stated. A fixed version is given where it is above the component's installed version: the advisory interval in `check_sbom`'s `match_reason` first, else `cve_intel`'s `fixed_version`, which is one per package (the advisory's last range's) and need not be the fix on the component's branch; otherwise the line says the results give no fixed version for that branch and points to the CVE's advisory. |
474
551
 
475
552
  ## Resources
476
553
 
477
554
  | Resource | What it holds |
478
555
  |---|---|
479
556
  | `echelongraph://methodology` | What each envelope field and `state` value means, and how each tool measures (Markdown). |
480
- | `echelongraph://sources` | What the API reports about its feeds when the resource is read: the NVD poller's interval and last poll (of the instance that answered, from `/api/v1/public/cves/summary`) and the CISA KEV catalog's last successful fetch and method (from `/api/v1/public/kev/recent`). It names the feeds no endpoint reports a schedule for, and states none for them (JSON). |
481
- | `cve://{cve_id}` | One CVE as `get_cve` returns it: the structured result, envelope and record, as JSON. An ID that is not a CVE ID is refused before any request; a CVE the API has no record of is not found. |
557
+ | `echelongraph://sources` | What the API reports about its feeds when the resource is read: the NVD poller's `interval`, `last_poll_at`, `poll_count` and `poll_errors`, all of the one instance that answered (from `/api/v1/public/cves/summary`; the two counts are that instance's since it last started, zeroed on every restart, never the feed's reliability), and the CISA KEV catalog's last successful fetch and method (from `/api/v1/public/kev/recent`). It names the feeds no endpoint reports a schedule for, and states none for them (JSON). |
558
+ | `cve://{cve_id}` | One CVE as `get_cve` returns it: the structured result, envelope and record, as JSON. Past 30,000 characters of JSON, `data` is cut as `get_cve`'s first text block is, and a note starting `DATA CUT` says what the cut leaves out; `get_cve`'s `structuredContent.data` holds the record whole. An ID that is not a CVE ID is refused before any request; a CVE the API has no record of is not found. |
482
559
 
483
560
  ## What a result means
484
561
 
485
562
  Every tool answers in one of two shapes, so a model reading the result cannot mistake an
486
563
  outage for an all-clear:
487
564
 
488
- - **Success** — the first text block is the API's JSON verbatim; the second is a one-line
565
+ - **Success** — the first text block is the API's JSON verbatim (cut to fit 30,000 characters
566
+ when it is longer: see "Long answers" below); the second is a one-line
489
567
  note saying the call succeeded, which base URL answered, and what it found; the third is
490
568
  the structured result as JSON, less what the first two already say (below). When the
491
569
  feed genuinely holds nothing for the query the note says so in words ("we looked and
492
570
  found nothing … not a lookup failure"), because a measured zero is a measurement. The
493
- exception to verbatim is `exposure_radar`: each radar is cut to the fields listed above, and
494
- each `kev_exposure.newest_kev` row gains an `exposure_state`. Its `shadow_ai` counts are
571
+ exceptions to verbatim are `cve_summary`, which leaves out a `poller` field the answer sends
572
+ in a JSON type other than its outputSchema's (or a `poller` that is neither a JSON object nor
573
+ null) and names it in the note, and `exposure_radar`: each radar is cut to the fields listed
574
+ above, and each `kev_exposure.newest_kev` row gains an `exposure_state`. Its `shadow_ai` counts are
495
575
  regrouped as above, and its `poller` block carries only `running` and `last_run_at`, or is dropped when it
496
576
  carries no real completion time. The block's other fields describe the server instance that
497
577
  answered, not the radar. Its `last_run_at` is when the radar's leader last
@@ -515,6 +595,44 @@ what an earlier block already says, so a client that passes only `content` to th
515
595
  sees how the answer was measured. The first text block (the API's JSON, or the failure) and the
516
596
  note after it are where 1.x put them.
517
597
 
598
+
599
+ ### Long answers
600
+
601
+ A model pays for every character of text it reads, and some clients cap what they pass on:
602
+ Claude Code warns past 10,000 tokens of tool output and, past 25,000, saves the result to a
603
+ file and hands the model its path instead. Through 2.6.2 the first text block was the API's JSON
604
+ whatever its size: on production's answers of 2026-10-04 a default `search_cves` page for
605
+ "openssl" was 133,300 characters of text, `get_cve` for CVE-2021-44228 80,897, `check_affected`
606
+ for the Linux kernel at 5.10.0 364,408, `kev_recent` at its largest page 105,925 and
607
+ `search_vendor_advisories` at its largest page 103,147.
608
+
609
+ So when the API's JSON is longer than 30,000 characters, the first text block holds it cut to
610
+ fit, and `structuredContent`'s `data` still holds it whole. First the same JSON is laid out with
611
+ each row on a line of its own, which drops no character of the answer but the indentation. If
612
+ that is still too long, a tool that returns a list of rows cuts each row to the fields it names,
613
+ most kept first (`search_cves` keeps `cve_id`, `severity`, `cvss_v3_score`, `echelongraph_score`,
614
+ `score_assessed`, `epss_score`, `kev_listed` and the first 200 characters of the description,
615
+ among others), with every long string ending in "…". A list beside the rows is cut before any
616
+ row field the first level keeps, and before any row leaves the text: `check_affected`'s
617
+ `excluded` and `undetermined` samples keep their first 10 entries, each its `cve_id` and
618
+ `reason`, and its `cve_ids` (each match's `cve_id`, in order) its first 10, so that every match
619
+ stays in the text, each with at least `cve_id`, `kev_listed`, `ransomware`, `epss_score`,
620
+ `effective_score` and `score_assessed`, and `check_sbom`'s `not_sent_purls` keeps its first 10, the note saying from
621
+ which position of the input the purls not sent run, and in what order the input's purls are
622
+ counted, so that a second call can send them without reading the list. When even the leanest cut is too long, rows are left out of the text
623
+ (`check_sbom` leaves out the `not_affected` rows first, then the `not_assessed` ones), and the
624
+ note says how to read them: the `offset` to call next, or a smaller `limit`. Any other answer
625
+ keeps the first entries of each of its lists (`get_cve`'s `cpe_match` and `references`). The note
626
+ then ends with one sentence starting `TEXT CUT` that says what the first text block leaves out
627
+ and which tool returns a row (`get_cve`, `get_vendor_advisory`, `check_affected`, and
628
+ `cve_intel` a CVE's fixed versions): whole in that tool's `structuredContent.data`, since its own
629
+ first text block is cut past 30,000 characters too. An answer that fits is sent as before,
630
+ pretty-printed and whole. The same answer is always cut the same way. The `cve://{cve_id}`
631
+ resource cuts its `data` as `get_cve`'s first text block is cut, and says so in a note starting
632
+ `DATA CUT`. The prompts ask only for what the text keeps: `kev_weekly_brief` reads `kev_recent`
633
+ 50 rows a page, which the text holds whole; `triage_cve` reads CISA's due date from `get_cve`;
634
+ and `sbom_review` reads fixed versions from `cve_intel`.
635
+
518
636
  ## Structured results
519
637
 
520
638
  Since 2.0.0 every result, success or failure, carries `structuredContent`, and every tool
@@ -530,7 +648,7 @@ declares its shape as an `outputSchema` in `tools/list`, with a title and the an
530
648
  | `coverage` | What the answer covers, where the tool can say: `in_scope` for `cve_exposure`, the list's own account of its count for `search_cves`, the radars that answered for `exposure_radar`, and `assessed`, `not_assessed_reason`, the lookup path and the match counts for `check_affected`. |
531
649
  | `freshness` | The producing radar's last completed check (`last_run_at`), where the API serves one; `null` where it serves none. |
532
650
  | `notes` | The caveats, one sentence each: what the envelope itself needs saying, then the note from the text block. |
533
- | `data` | On a success only: the same JSON as the first text block. |
651
+ | `data` | On a success only: the API's JSON, whole: the first text block carries the same JSON, or, past 30,000 characters, a cut of it that the note names (`TEXT CUT`). |
534
652
  | `error` | On a failure only: `kind`, `path`, `status` and `message`. |
535
653
 
536
654
  The last text block of every result is this structured result serialized as JSON, less what
@@ -541,8 +659,8 @@ note's sentences, which are the text block just before it (on a failure, the mes
541
659
  with which `notes` ends: its `notes` holds only the sentences the envelope adds about itself,
542
660
  and is left out when there are none. It leaves out `method` too where that note quotes it verbatim, as
543
661
  `cve_exposure`'s does ("Method: …"). So the text blocks together carry the whole structured
544
- result, and cannot disagree with it. Before 2.2.0 this block repeated the whole note, so every
545
- note was sent twice.
662
+ result, but for what a `TEXT CUT` leaves out of `data` (see "Long answers"), and cannot disagree
663
+ with it. Before 2.2.0 this block repeated the whole note, so every note was sent twice.
546
664
 
547
665
  | `state` | What it means |
548
666
  |---|---|
@@ -574,7 +692,8 @@ a note says so. Per tool:
574
692
  answer gives one time at which what it counts was observed, so no count is presented as a
575
693
  dated measurement. `mcp_servers` dates its verdicts only by a window, `mcp_servers.window.from`
576
694
  to `mcp_servers.window.to`, over checks made at different times, which is not one observation
577
- time. Its numbers keep the labels above. `freshness` holds each radar's `last_run_at` where the
695
+ time; so, since #2438, do `exposed_databases.window`, `leaked_credentials.window` and
696
+ `shadow_ai.confirmed_exposed.window`. `kev_exposure` gives no observation time at all. Its numbers keep the labels above. `freshness` holds each radar's `last_run_at` where the
578
697
  API serves one, for `shadow_ai` also `running`, and for `mcp_servers` also `enabled`.
579
698
  - `check_affected` is `measured` only when the answer says `assessed` true, and `not_assessed`
580
699
  when it says false or does not say. Its `measured_at` and `freshness` are `null`: a match answer
@@ -586,7 +705,9 @@ The CVE feed tools' `freshness` is `null`: the feed's answers carry no time at w
586
705
  pollers last completed a poll for the feed as a whole.
587
706
 
588
707
  A success whose fields do not fit the tool's `outputSchema` (a field of a type the schema does
589
- not allow) is returned as a failure with `error.kind` `unexpected_shape`, never relayed. A
708
+ not allow) is returned as a failure with `error.kind` `unexpected_shape`, never relayed, with one
709
+ exception: a `cve_summary` `poller` field of another JSON type is left out of `data` and named in
710
+ the note, and the rest of the answer is relayed, `summary` with it. A
590
711
  field the API adds later is still relayed by every tool that relays the API's JSON as data, and
591
712
  `exposure_radar` and `cve_intel`, which relay a selection, leave it out and name what they leave out.
592
713
 
@@ -626,7 +747,7 @@ Where each tool's input travels:
626
747
  | Input | Where it is sent |
627
748
  |---|---|
628
749
  | A CVE ID (`get_cve`, `cve_exposure`, `epss_history`, `cve_intel`, `vendor_advisories_for_cve`), a CWE ID and page (`get_cwe`), a vendor and advisory ID (`get_vendor_advisory`) | The URL path. |
629
- | `search_cves`: `search` | The `X-EG-Search` request header, never the URL. Its severity, minimum CVSS, sort and limit go in the query string. |
750
+ | `search_cves`: `search` | The `X-EG-Search` request header, never the URL. Its severity, minimum CVSS, sort, limit and offset go in the query string. |
630
751
  | `search_vendor_advisories`: `query` | The `X-EG-Advisory-Search` request header, never the URL. Its vendor, severity, CVE filter, limit and offset go in the query string. |
631
752
  | `check_affected`: `product`, `version`, `ecosystem`, `package` | The `X-EG-Product`, `X-EG-Version`, `X-EG-Ecosystem` and `X-EG-Package` request headers, never the URL. |
632
753
  | `kev_recent`: `since`, `until`, `ransomware`, `vendor`, `limit`, `cursor` | The `X-EG-Since`, `X-EG-Until`, `X-EG-Ransomware`, `X-EG-Vendor`, `X-EG-Limit` and `X-EG-Cursor` request headers, never the URL. |
@@ -682,7 +803,10 @@ JSON-RPC error (code `-32029`) that names the limit and the seconds to wait. A r
682
803
  64 KiB is refused (HTTP 413, code `-32600`), except a `tools/call` of `check_sbom` or a
683
804
  `prompts/get` of `sbom_review`, which may be up to 6 MiB: enough for a CycloneDX or SPDX document
684
805
  at the tool's 5,000,000-character cap. Each server instance reads at most two such large bodies at
685
- once; a third is answered HTTP 503 with `Retry-After` and a JSON-RPC error (code `-32030`).
806
+ once; a third is answered HTTP 503 with `Retry-After` and a JSON-RPC error (code `-32030`). Each
807
+ instance serves at most 64 requests at once; the next waits up to 10 seconds for one to finish,
808
+ and is then answered the same way. A `subscriptions/listen` stream, which stays open until its
809
+ client leaves, is not one of the 64.
686
810
 
687
811
  ## Configuration
688
812
 
@@ -695,8 +819,12 @@ The npm package (stdio):
695
819
  | `ECHELONGRAPH_MCP_UA` | unset | One product token (for example `my-monitor/1.0`) put ahead of this package's own User-Agent, so automated callers such as monitors are told apart from people using the server. A value that is not a single token is ignored. |
696
820
 
697
821
  The HTTP entrypoint, `dist/http.js` (`npm run start:http`), serves the same tools over
698
- Streamable HTTP on `POST /mcp`, with a liveness check on `GET /health`. It reads the three
699
- variables above and:
822
+ Streamable HTTP on `POST /mcp`, with a liveness check on `GET /health`. In a container with
823
+ little memory, start it with V8's heap capped below the container's limit, as EchelonGraph's
824
+ hosted deployment does at half of 256 MiB (`node --max-old-space-size=128 --max-semi-space-size=16 dist/http.js`): Node
825
+ otherwise sizes its heap from the memory the system reports, which in a container can be the
826
+ host's, and the container is then killed before V8 collects.
827
+ It reads the three variables above and:
700
828
 
701
829
  | Env var | Default | Purpose |
702
830
  |---|---|---|
@@ -705,6 +833,8 @@ variables above and:
705
833
  | `MCP_MAX_BODY_BYTES` | `65536` | The largest request body it reads, except for an SBOM (below). |
706
834
  | `MCP_MAX_SBOM_BODY_BYTES` | `6291456` | The largest body of a `tools/call` of `check_sbom` or a `prompts/get` of `sbom_review`. |
707
835
  | `MCP_LARGE_BODY_SLOTS` | `2` | How many bodies over `MCP_MAX_BODY_BYTES` it reads and serves at once; past that, 503 with `Retry-After`. |
836
+ | `MCP_MAX_IN_FLIGHT` | `64` | How many requests it serves at once, not counting `subscriptions/listen` streams; the next, its body read, waits. |
837
+ | `MCP_ADMISSION_WAIT_MS` | `10000` | How long a request waits to be served before it is answered 503 with `Retry-After`. |
708
838
  | `ECHELONGRAPH_FORWARD_TOKEN`, `ECHELONGRAPH_FORWARD_HOST`, `MCP_REQUIRE_FORWARD_TOKEN` | unset | EchelonGraph's hosted deployment only: the token with which it names each client to the API. Leave them unset when you run it yourself; the API then counts every request as coming from your server. |
709
839
 
710
840
  ## Develop
@@ -716,8 +846,12 @@ npm test # build, then the behavioural suite over both protocol eras,
716
846
  npm run smoke # spawn the server + call cve_exposure against the production API
717
847
  ```
718
848
 
719
- `npm run smoke` calls the production API with this package's User-Agent, so its requests are
720
- counted as external MCP adoption. `npm test` never leaves the machine.
849
+ `npm run smoke` calls the production API with this package's User-Agent. Run from our own
850
+ workspace, whose egress address the API recognises as ours, its requests are counted as our own
851
+ traffic and appear in no adoption count. Run from any other machine, they are counted as external
852
+ MCP adoption unless you set `ECHELONGRAPH_MCP_UA` (for example
853
+ `ECHELONGRAPH_MCP_UA=my-smoke/1.0 npm run smoke`), which files them under that token's family
854
+ instead. `npm test` never leaves the machine.
721
855
 
722
856
  `npm test` runs the suite once over a 2026-07-28 `server/discover` and once over a 2025-06-18
723
857
  `initialize`, plus the era tests. To run it against an installed package rather than `dist/`,
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 = () => {