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/CHANGELOG.md +254 -0
- package/README.md +169 -35
- package/dist/http.js +111 -33
- package/dist/httpPolicy.js +160 -4
- package/dist/index.js +240 -66
- package/dist/prompts.js +60 -10
- package/dist/resources.js +62 -11
- package/dist/runtime.js +26 -4
- package/dist/textBudget.js +254 -0
- package/dist/tools/check_affected.js +64 -2
- package/dist/tools/check_sbom.js +105 -9
- package/dist/tools/cve_intel.js +2 -1
- package/dist/tools/epss_history.js +2 -1
- package/dist/tools/get_cwe.js +11 -2
- package/dist/tools/kev_recent.js +26 -2
- package/dist/tools/vendor_advisories.js +206 -17
- package/package.json +2 -2
- package/server.json +2 -2
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 |
|
|
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
|
|
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
|
-
|
|
199
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
494
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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`.
|
|
699
|
-
|
|
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
|
|
720
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return {
|
|
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
|
|
205
|
-
|
|
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:
|
|
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
|
-
|
|
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 = () => {
|