echelongraph-mcp 2.6.3 → 2.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,30 +28,106 @@ 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
 
34
36
  The local server runs through `npx` and needs Node.js 20 or later; nothing is installed
35
- globally. Add one of the blocks below to your client, restart it, then ask: *"Is
37
+ globally. Claude Desktop's one-click extension, below, needs neither. Add one of the blocks below to your client, restart it, then ask: *"Is
36
38
  CVE-2023-44487 actively exploited, and how many exposed services does EchelonGraph's radar
37
39
  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
+ Three ways in. The first two need no Node.js: a one-click extension that runs the server on your
44
+ machine, and a custom connector to the hosted endpoint. The third runs the server through `npx`.
45
+
46
+ **1. [Install in Claude Desktop](https://github.com/echelongraph/echelongraph-mcp/releases/latest/download/echelongraph-mcp.mcpb)
47
+ (one click, no Node.js).** The link downloads `echelongraph-mcp.mcpb`, a Claude Desktop extension.
48
+ Open it, or in Claude Desktop choose Settings → Extensions → Advanced settings → Install Extension
49
+ and pick it, then choose **Install**. Claude Desktop runs the server with the Node.js it ships and
50
+ writes its own configuration: no PATH and no JSON to edit. To update, install the next release's
51
+ bundle the same way. Every new release on GitHub carries the bundle (releases up to 2.6.5 do not),
52
+ built from the release's tag by the workflow that publishes the npm package, and signed with a SLSA
53
+ build-provenance attestation. To check a download:
54
+ `gh attestation verify echelongraph-mcp.mcpb --repo echelongraph/echelongraph-mcp`.
55
+
56
+ **2. Custom connector (no install).** Claude Desktop uses your claude.ai account's connectors:
57
+ Customize → Connectors → **+ Add** → **Add custom connector**. Name it `EchelonGraph`, paste
58
+ `https://mcp.echelongraph.io/mcp`, choose **No sign in**, and add it. No Node.js, no PATH and no
59
+ JSON to edit. A connector added on claude.ai shows up in Claude Desktop too, and the plan rules
60
+ are claude.ai's (see "Remote (no install)" below).
61
+
62
+ **3. Local server through `npx`.**
63
+
64
+ 1. **Check Node.js.** In Terminal (macOS) or PowerShell (Windows), run `node -v`. It must print
65
+ `v20` or later. If it prints an older version or `command not found`, install the LTS release
66
+ from <https://nodejs.org>, or on macOS with Homebrew run `brew install node`.
67
+ 2. **Open the config file in a plain-text editor.** Settings → Developer → Edit Config shows
68
+ `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`;
69
+ Windows: `%APPDATA%\Claude\claude_desktop_config.json`). Open it in a plain-text editor such as
70
+ VS Code, Notepad or `nano`. TextEdit on macOS turns `"` into curly quotes (`“ ”`) when Smart
71
+ Quotes is on, and the JSON then breaks without an error: turn it off under Edit → Substitutions
72
+ → Smart Quotes, or use another editor.
73
+ 3. **Merge, do not replace.** The file usually exists already and holds Claude Desktop's own
74
+ settings (for example a `"preferences"` object). Keep everything in it. Add an `"mcpServers"`
75
+ key at the top level, next to the keys already there; if `"mcpServers"` is already there, add
76
+ the `"echelongraph"` entry inside it. Mind the comma between entries. The result looks like
77
+ this, with your own settings where `preferences` is:
78
+
79
+ ```json
80
+ {
81
+ "preferences": {
82
+ "…": "the settings already in your file stay as they are"
83
+ },
84
+ "mcpServers": {
85
+ "echelongraph": {
86
+ "command": "npx",
87
+ "args": ["-y", "echelongraph-mcp"]
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ To check that the file parses, run
94
+ `node -e "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'))" "<path to the file>"`:
95
+ no output means valid JSON.
96
+ 4. **Quit fully, then reopen.** Closing the window leaves the old config loaded. On macOS choose
97
+ Claude → Quit Claude (⌘Q); on Windows right-click the Claude icon in the system tray and choose
98
+ Quit. The first start can take longer while `npx` downloads the package.
99
+ 5. **Check that it worked.** Settings → Developer lists `echelongraph` as running, and the tools
100
+ menu in the chat box lists EchelonGraph's 14 tools. Then ask the question at the top of this
101
+ section.
102
+
103
+ #### Troubleshooting Claude Desktop
104
+
105
+ - **`spawn npx ENOENT`** (or the server shows as failed). On macOS, an app opened from the Dock
106
+ does not get your shell's PATH, so Claude Desktop cannot find `npx` where Homebrew (Apple
107
+ silicon: `/opt/homebrew/bin`; Intel: `/usr/local/bin`) or nvm put it. Run `which npx` in
108
+ Terminal, then give Claude Desktop that absolute path as `"command"`, and the folder it is in as
109
+ `PATH` under `"env"` (`npx` starts `node` from that same folder). With Homebrew on Apple silicon:
44
110
 
45
- ```json
46
- {
47
- "mcpServers": {
48
- "echelongraph": {
49
- "command": "npx",
50
- "args": ["-y", "echelongraph-mcp"]
111
+ ```json
112
+ {
113
+ "mcpServers": {
114
+ "echelongraph": {
115
+ "command": "/opt/homebrew/bin/npx",
116
+ "args": ["-y", "echelongraph-mcp"],
117
+ "env": { "PATH": "/opt/homebrew/bin:/usr/bin:/bin" }
118
+ }
51
119
  }
52
120
  }
53
- }
54
- ```
121
+ ```
122
+
123
+ With nvm, use the full path `which npx` prints (JSON does not expand `~`). Then quit fully and
124
+ reopen. On Windows, install Node.js with the installer from nodejs.org, which adds `npx` to the
125
+ system PATH, then quit Claude Desktop fully and reopen it.
126
+ - **The server does not appear at all.** The file is not valid JSON (a missing comma, or curly
127
+ quotes), or the app was not fully quit: see steps 2 to 4.
128
+ - **Logs.** macOS: `~/Library/Logs/Claude/mcp-server-echelongraph.log` (this server's output) and
129
+ `~/Library/Logs/Claude/mcp.log` (connections). Windows: `%APPDATA%\Claude\logs\`, with the same
130
+ file names.
55
131
 
56
132
  ### Claude Code
57
133
 
@@ -156,14 +232,14 @@ The example questions are ones a client can answer with that tool alone.
156
232
  |---|---|---|---|
157
233
  | `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
234
  | `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.* |
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?* |
235
+ | `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
236
  | `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
237
  | `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
238
  | `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
239
  | `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
240
  | `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.* |
166
- | `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?* |
241
+ | `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.* |
242
+ | `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`. Each `affected_packages` row carries `fixed_branches`, every affected range of the package with its fix (`introduced`, and `fixed` or, where no fix is on record, `last_affected`), each naming the OSV record that published it (`advisory_id`, `source`) and in that record's order: an installed version's fix is the `fixed` of the range that holds it, compared in the ecosystem's version order. `fixed_version` is one range's fix, kept for compatibility, not the fix for every range (CVE-2021-44228's log4j-core: 2.12.2, the fix for 2.4 to 2.12.1, while 2.14.1's range is fixed in 2.15.0); `fixed_branches` is null where a package's ranges were never loaded, and `[]` where no version range is on record, which is not a finding that no fix exists. `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
243
  | `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
244
  | `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?* |
169
245
  | `get_vendor_advisory` | Vendor advisory detail | One vendor advisory in full: description, severity, `cve_ids` and the subset with a CVE record here (`known_cve_ids`), `affected_products`, `remediation` and `references`. | *Show one of those advisories in full: affected products, remediation and references.* |
@@ -173,6 +249,18 @@ The three vendor-advisory tools relay `vendor_published_at` (the vendor's date),
173
249
  (when EchelonGraph first recorded the advisory) and `withdrawn` (the vendor rescinded it; the note
174
250
  names each one, and the search leaves them out).
175
251
 
252
+ Each also relays every vendor's window in `coverage` (`vendor_windows`, or `vendor_window` for the
253
+ detail): the advisories held, the earliest and latest `vendor_published_at` among them,
254
+ `held_since` and `history_backfill`. `held_since` is the start of the window EchelonGraph has read the
255
+ vendor's advisories from: one published before it may not be held. For a vendor with no history read (`history_backfill`
256
+ `not_supported`) it can be years after the earliest advisory held: Cisco's poller first read the
257
+ advisories Cisco had changed in the last 90 days, Red Hat's and GitHub's the last 7, so an older
258
+ advisory the vendor revised later is held while the ones published beside it are not.
259
+ `vendor_advisories_for_cve` lists in `vendors_not_fully_held` each vendor with no advisory in the
260
+ answer of which EchelonGraph holds none, whose `held_since` is after 1 January of the CVE ID's year
261
+ or not known, or whose history is still being read: no advisory from such a vendor is not a finding
262
+ that it published none.
263
+
176
264
  Every figure is as fresh as the schedule that refreshes it. The CVE feed is polled from its
177
265
  sources on a schedule; each radar refreshes on its own.
178
266
 
@@ -195,8 +283,17 @@ it is neither a count of CVEs rated None nor the count of CVEs with no severity,
195
283
  `summary.total` and the other counts leave them out, and they are withdrawn records, never
196
284
  vulnerabilities.
197
285
 
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.
286
+ `poller` holds the in-memory counters of the NVD poller of the one API instance that answered,
287
+ counted since that instance last started and zeroed on every restart: they describe that instance,
288
+ never the feed's size, intake, reliability or freshness, and the note says so whenever the answer
289
+ carries `poller`. A `poller` field the answer sends in a JSON type other than the one the
290
+ outputSchema describes is left out and named in the note, and a `poller` that is neither a JSON
291
+ object nor null is left out whole; `summary` is relayed either way, so a change to that diagnostic
292
+ block never withholds the counts.
293
+
294
+ The tool relays the API's JSON as it was sent, less a `poller` field left out as above. When
295
+ `summary.none`, `summary.nvd_none` or `summary.rejected` is above zero, the note says what it
296
+ counts, with the number from the answer.
200
297
  The note says the five NVD counts add up to `summary.total` only when the answer's do, and says
201
298
  nothing about a field the answer does not carry.
202
299
 
@@ -369,8 +466,9 @@ feed, matched by hostname pattern; no Shodan data is used. Each is checked by Ec
369
466
  identified MCP probe: a `server/discover` request, and `initialize` only if that is refused. It
370
467
  never sends `tools/call`. The answer is counts and timestamps only, with no hostname, version or
371
468
  server name, and the tool repeats no string it carries but its timestamps. Each hostname is
372
- counted once, by its latest verdict on record, and EchelonGraph's own control servers are left
373
- out. The unit is a hostname, not an ip:port service.
469
+ counted once, by its latest verdict on record. EchelonGraph's own control servers are left
470
+ out, and so is every hostname whose owner opted out of scanning, since it is not checked again
471
+ while the opt-out stands and its last verdict cannot be re-checked. The unit is a hostname, not an ip:port service.
374
472
 
375
473
  | Field | What it counts |
376
474
  |---|---|
@@ -380,6 +478,7 @@ out. The unit is a hostname, not an ip:port service.
380
478
  | `mcp_servers.pending_readjudication` | Verdicts decided by a rule EchelonGraph has since replaced and not yet re-checked under the current rules. They are in neither `mcp_servers.protected` nor `mcp_servers.not_assessed`. |
381
479
  | `mcp_servers.not_assessed` | The rest, whose protection the radar could not assess. Not assessed does not mean unprotected. `mcp_servers.not_assessed_by_reason` puts each of them in exactly one bucket (below). |
382
480
  | `mcp_servers.own_controls_excluded` | EchelonGraph's own control servers, left out of every other `mcp_servers` number. |
481
+ | `mcp_servers.withheld_opted_out` | Hostnames whose owner opted out of scanning, left out of every other `mcp_servers` number. Such a hostname is not checked again while the opt-out stands, so its last verdict cannot be re-checked; the API's unparameterised answer withholds the same hostnames under the same name. An answer that does not carry this field (an API that predates it) counted them, and the note says so. |
383
482
  | `mcp_servers.window.from`, `mcp_servers.window.to` | Timestamps, not counts: when the oldest and the newest of the verdicts counted were last checked. The counts are each hostname's latest verdict, not one sweep at one time. With no verdict counted there is no window. |
384
483
  | `mcp_servers.last_run_at` | A timestamp, not a count: when the AI-exposure radar, which checks other AI services as well as MCP servers, last completed a check (a cycle whose reads succeeded and whose scan opt-out register answered). It is not the time of every verdict counted. |
385
484
  | `mcp_servers.enabled` | Whether the API reports the radar running: true when a check completed within 45 minutes of its answer. |
@@ -470,18 +569,18 @@ never "not affected", and each figure cites its `measured_at`. A prompt makes no
470
569
 
471
570
  | Prompt | Arguments | What it asks the model to do |
472
571
  |---|---|---|
473
- | `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. |
474
- | `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. |
572
+ | `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 each affected range with its fix from `cve_intel`'s `fixed_branches`; where a package has none, it gives `fixed_version` as what `cve_intel` records for the package, one per package (the advisory's last range's as a rule), 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. |
573
+ | `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. |
475
574
  | `am_i_affected` | `product`, or `ecosystem` and `package`; `version` | `check_affected`, read `assessed` before `count`, with the not-assessed wording. |
476
- | `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. |
575
+ | `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 (B of `[A, B)`, a fixed bound; the B of `[A, B]`, read by its closing bracket alone, is the last affected version, still affected, and neither it nor any version at or below it is given; an interval clipped before its closing bracket gives none), else the `fixed` of the `cve_intel` `fixed_branches` range that holds the installed version, else, for a package without `fixed_branches`, `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. |
477
576
 
478
577
  ## Resources
479
578
 
480
579
  | Resource | What it holds |
481
580
  |---|---|
482
581
  | `echelongraph://methodology` | What each envelope field and `state` value means, and how each tool measures (Markdown). |
483
- | `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). |
484
- | `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. |
582
+ | `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). |
583
+ | `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. |
485
584
 
486
585
  ## What a result means
487
586
 
@@ -494,8 +593,10 @@ outage for an all-clear:
494
593
  the structured result as JSON, less what the first two already say (below). When the
495
594
  feed genuinely holds nothing for the query the note says so in words ("we looked and
496
595
  found nothing … not a lookup failure"), because a measured zero is a measurement. The
497
- exception to verbatim is `exposure_radar`: each radar is cut to the fields listed above, and
498
- each `kev_exposure.newest_kev` row gains an `exposure_state`. Its `shadow_ai` counts are
596
+ exceptions to verbatim are `cve_summary`, which leaves out a `poller` field the answer sends
597
+ in a JSON type other than its outputSchema's (or a `poller` that is neither a JSON object nor
598
+ null) and names it in the note, and `exposure_radar`: each radar is cut to the fields listed
599
+ above, and each `kev_exposure.newest_kev` row gains an `exposure_state`. Its `shadow_ai` counts are
499
600
  regrouped as above, and its `poller` block carries only `running` and `last_run_at`, or is dropped when it
500
601
  carries no real completion time. The block's other fields describe the server instance that
501
602
  answered, not the radar. Its `last_run_at` is when the radar's leader last
@@ -538,17 +639,24 @@ most kept first (`search_cves` keeps `cve_id`, `severity`, `cvss_v3_score`, `ech
538
639
  `score_assessed`, `epss_score`, `kev_listed` and the first 200 characters of the description,
539
640
  among others), with every long string ending in "…". A list beside the rows is cut before any
540
641
  row field the first level keeps, and before any row leaves the text: `check_affected`'s
541
- `excluded` and `undetermined` samples keep each entry's `cve_id` and `reason`, and its `cve_ids`
542
- (each match's `cve_id`, in order) its first 10, so that every match stays in the text, and `check_sbom`'s `not_sent_purls` keeps its first 10, the note saying from
543
- which position of the input the purls not sent run, so that a second call can send them without
544
- reading the list. When even the leanest cut is too long, rows are left out of the text
642
+ `excluded` and `undetermined` samples keep their first 10 entries, each its `cve_id` and
643
+ `reason`, and its `cve_ids` (each match's `cve_id`, in order) its first 10, so that every match
644
+ stays in the text, each with at least `cve_id`, `kev_listed`, `ransomware`, `epss_score`,
645
+ `effective_score` and `score_assessed`, and `check_sbom`'s `not_sent_purls` keeps its first 10, the note saying from
646
+ which position of the input the purls not sent run, and in what order the input's purls are
647
+ 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
545
648
  (`check_sbom` leaves out the `not_affected` rows first, then the `not_assessed` ones), and the
546
649
  note says how to read them: the `offset` to call next, or a smaller `limit`. Any other answer
547
650
  keeps the first entries of each of its lists (`get_cve`'s `cpe_match` and `references`). The note
548
651
  then ends with one sentence starting `TEXT CUT` that says what the first text block leaves out
549
- and which tool returns a row whole (`get_cve`, `get_vendor_advisory`, `check_affected`, and
550
- `cve_intel` a CVE's fixed versions). An answer that fits is sent as before, pretty-printed and
551
- whole. The same answer is always cut the same way.
652
+ and which tool returns a row (`get_cve`, `get_vendor_advisory`, `check_affected`, and
653
+ `cve_intel` a CVE's fixed versions): whole in that tool's `structuredContent.data`, since its own
654
+ first text block is cut past 30,000 characters too. An answer that fits is sent as before,
655
+ pretty-printed and whole. The same answer is always cut the same way. The `cve://{cve_id}`
656
+ resource cuts its `data` as `get_cve`'s first text block is cut, and says so in a note starting
657
+ `DATA CUT`. The prompts ask only for what the text keeps: `kev_weekly_brief` reads `kev_recent`
658
+ 50 rows a page, which the text holds whole; `triage_cve` reads CISA's due date from `get_cve`;
659
+ and `sbom_review` reads fixed versions from `cve_intel`, the fix of each affected range in `fixed_branches`.
552
660
 
553
661
  ## Structured results
554
662
 
@@ -576,8 +684,8 @@ note's sentences, which are the text block just before it (on a failure, the mes
576
684
  with which `notes` ends: its `notes` holds only the sentences the envelope adds about itself,
577
685
  and is left out when there are none. It leaves out `method` too where that note quotes it verbatim, as
578
686
  `cve_exposure`'s does ("Method: …"). So the text blocks together carry the whole structured
579
- result, and cannot disagree with it. Before 2.2.0 this block repeated the whole note, so every
580
- note was sent twice.
687
+ result, but for what a `TEXT CUT` leaves out of `data` (see "Long answers"), and cannot disagree
688
+ with it. Before 2.2.0 this block repeated the whole note, so every note was sent twice.
581
689
 
582
690
  | `state` | What it means |
583
691
  |---|---|
@@ -622,7 +730,9 @@ The CVE feed tools' `freshness` is `null`: the feed's answers carry no time at w
622
730
  pollers last completed a poll for the feed as a whole.
623
731
 
624
732
  A success whose fields do not fit the tool's `outputSchema` (a field of a type the schema does
625
- not allow) is returned as a failure with `error.kind` `unexpected_shape`, never relayed. A
733
+ not allow) is returned as a failure with `error.kind` `unexpected_shape`, never relayed, with one
734
+ exception: a `cve_summary` `poller` field of another JSON type is left out of `data` and named in
735
+ the note, and the rest of the answer is relayed, `summary` with it. A
626
736
  field the API adds later is still relayed by every tool that relays the API's JSON as data, and
627
737
  `exposure_radar` and `cve_intel`, which relay a selection, leave it out and name what they leave out.
628
738
 
@@ -683,7 +793,11 @@ only those to the API, and neither logs nor keeps the document. To keep the docu
683
793
  machine, run the npm package, or pass `check_sbom` the purls. When a client passes the
684
794
  per-client limit it logs that client's address key. It passes your client's public address to
685
795
  the API, in a header the API trusts only with a token it checks, so the API's rate limit counts
686
- you and not the hosted service.
796
+ you and not the hosted service. The API's access line then records each of those calls under
797
+ your address (an IPv6 address by its /64) with its URL path: for a CVE, CWE or advisory lookup,
798
+ the ID you asked about. It does not record the query string or the header-carried terms above
799
+ (for `search_cves`, whether a term was given and its length, not the term), and of `check_sbom`'s
800
+ purls it records how many, not which.
687
801
 
688
802
  EchelonGraph's privacy policy: <https://echelongraph.io/privacy>.
689
803
 
@@ -718,7 +832,20 @@ JSON-RPC error (code `-32029`) that names the limit and the seconds to wait. A r
718
832
  64 KiB is refused (HTTP 413, code `-32600`), except a `tools/call` of `check_sbom` or a
719
833
  `prompts/get` of `sbom_review`, which may be up to 6 MiB: enough for a CycloneDX or SPDX document
720
834
  at the tool's 5,000,000-character cap. Each server instance reads at most two such large bodies at
721
- once; a third is answered HTTP 503 with `Retry-After` and a JSON-RPC error (code `-32030`).
835
+ once; a third is answered HTTP 503 with `Retry-After` and a JSON-RPC error (code `-32030`). Each
836
+ instance serves at most 64 requests at once; the next waits up to 10 seconds for one to finish,
837
+ and is then answered the same way. A `subscriptions/listen` stream, which stays open until its
838
+ client leaves, is not one of the 64.
839
+
840
+ A call whose client goes away (its own timeout, a closed tab) is stopped only once the server
841
+ learns of it. Its API calls are then cancelled, `check_sbom` sends no further batch, and its
842
+ large-body slot is freed. Over HTTP/1.1, Cloud Run does not pass a client's disconnect on to the
843
+ server at all (<https://docs.cloud.google.com/run/docs/troubleshooting>). The hosted endpoint
844
+ speaks HTTP/2 to Cloud Run, where the same page says a disconnect is passed on. Measured on
845
+ 2026-10-05, Cloud Run reset the stream of a client that stopped reading its answer 15 s in, and the
846
+ call was logged as a client that closed. Whether a client that aborts mid-call is passed on at once
847
+ has not been observed yet. Until it is, an abandoned `check_sbom` call may run for up to its
848
+ 50-second budget and hold its slot that long.
722
849
 
723
850
  ## Configuration
724
851
 
@@ -731,8 +858,12 @@ The npm package (stdio):
731
858
  | `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. |
732
859
 
733
860
  The HTTP entrypoint, `dist/http.js` (`npm run start:http`), serves the same tools over
734
- Streamable HTTP on `POST /mcp`, with a liveness check on `GET /health`. It reads the three
735
- variables above and:
861
+ Streamable HTTP on `POST /mcp`, with a liveness check on `GET /health`. In a container with
862
+ little memory, start it with V8's heap capped below the container's limit, as EchelonGraph's
863
+ hosted deployment does at half of 256 MiB (`node --max-old-space-size=128 --max-semi-space-size=16 dist/http.js`): Node
864
+ otherwise sizes its heap from the memory the system reports, which in a container can be the
865
+ host's, and the container is then killed before V8 collects.
866
+ It reads the three variables above and:
736
867
 
737
868
  | Env var | Default | Purpose |
738
869
  |---|---|---|
@@ -741,6 +872,9 @@ variables above and:
741
872
  | `MCP_MAX_BODY_BYTES` | `65536` | The largest request body it reads, except for an SBOM (below). |
742
873
  | `MCP_MAX_SBOM_BODY_BYTES` | `6291456` | The largest body of a `tools/call` of `check_sbom` or a `prompts/get` of `sbom_review`. |
743
874
  | `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`. |
875
+ | `MCP_MAX_IN_FLIGHT` | `64` | How many requests it serves at once, not counting `subscriptions/listen` streams; the next, its body read, waits. |
876
+ | `MCP_ADMISSION_WAIT_MS` | `10000` | How long a request waits to be served before it is answered 503 with `Retry-After`. |
877
+ | `MCP_H2C` | unset | `1`: serve HTTP/2 cleartext (h2c, prior knowledge) instead of HTTP/1.1, as Cloud Run's `--use-http2` requires; an HTTP/1.1 request then gets no answer. Over h2c, a stream the client resets stops its call. |
744
878
  | `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. |
745
879
 
746
880
  ## Develop