echelongraph-mcp 2.6.5 → 2.6.7

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 CHANGED
@@ -6,6 +6,146 @@ with a provenance attestation by the release workflow of
6
6
  [github.com/echelongraph/echelongraph-mcp](https://github.com/echelongraph/echelongraph-mcp),
7
7
  from the commit tagged `v<version>`.
8
8
 
9
+ ## 2.6.7 — 2026-10-05
10
+
11
+ - Tool descriptions now state facts only: no reporting instructions to the model and no references
12
+ to other tools, for the Anthropic MCP Directory policy ("Tool descriptions contain no
13
+ instructions about model behavior, other tools, or external instruction sources, and no hidden
14
+ or encoded text"). Tool behavior, results and their notes are unchanged. Reworded:
15
+ - get_cve, search_cves: "The note labels each such CVE NOT YET SCORED: report it that way, never
16
+ as a score of 0." now reads "The note labels each such CVE NOT YET SCORED, which is not a score
17
+ of 0."
18
+ - cve_summary: summary.rejected's "report them as withdrawn records, never as vulnerabilities"
19
+ now reads "they are withdrawn records, none of them a vulnerability"; summary.none's "report
20
+ those CVEs as not yet scored, not as CVEs rated None" now reads "and names those CVEs not yet
21
+ scored, not CVEs rated None".
22
+ - get_vendor_advisory: "(the vendor and vendor_advisory_id fields of a row from
23
+ search_vendor_advisories or vendor_advisories_for_cve)" now reads "(the vendor and
24
+ vendor_advisory_id of a vendor advisory row)".
25
+ - exposure_radar: "and cve_exposure says per CVE whether the radar tracks it" now reads "and this
26
+ answer does not say whether the radar tracks that CVE: tracking is answered per CVE".
27
+ - epss_history: "Never interpolate it into a daily series." now reads "A daily series
28
+ interpolated from it holds values EchelonGraph never recorded, so the series is never a daily
29
+ series."
30
+ - check_affected: "Read assessed before count: … a count of 0 there must never be reported as
31
+ not affected" now reads "count depends on assessed: … a count of 0 there is not a finding of
32
+ not affected"; the outputSchema description of assessed, "Read first.", now reads "count
33
+ depends on it.".
34
+ - vendor_advisories_for_cve, get_vendor_advisory, search_vendor_advisories: the outputSchema
35
+ description of withdrawn, "report it as withdrawn", now reads "so it is a withdrawn advisory,
36
+ not a current one".
37
+ - test/description-policy.test.mjs lists the tools over stdio and fails if any title, description
38
+ or input/output schema description names another tool, matches a model-directed imperative
39
+ pattern, or carries zero-width, bidi or control characters or a base64-looking run.
40
+
41
+ ## 2.6.6 — 2026-10-05
42
+
43
+ - Correction to 2.6.5 (#2775). Its entry says: "On the hosted endpoint it is the client going away
44
+ (its own timeout, a closed tab): every API call of that request is cancelled". That was not true
45
+ of the hosted endpoint. Cloud Run does not pass a client's disconnect on to a container it
46
+ speaks HTTP/1.1 to ("When you use HTTP/1.1 on Cloud Run, client disconnect events are not
47
+ propagated to the Cloud Run container",
48
+ <https://docs.cloud.google.com/run/docs/troubleshooting>). In production on 2026-10-05, four
49
+ `check_sbom` calls had their client abort after 0.4 s or 1.5 s. Each was logged `status` 200
50
+ with `client_closed` false and ran to its end, 2.0 to 5.3 s. Together they held both large-body
51
+ slots, so a large body sent 1 s later was answered 503. The same page says HTTP/2 does pass the
52
+ disconnect on. So `dist/http.js` now serves HTTP/2 cleartext (h2c) when `MCP_H2C=1`, and the
53
+ hosted deployment sets it together with Cloud Run's `--use-http2`. With that flag, Cloud Run
54
+ speaks only h2c to the container. The server acts on a reset stream the moment it arrives, with
55
+ any error code including `NO_ERROR`: the API calls are cancelled, `check_sbom` sends no further
56
+ batch and ends a `Retry-After` wait, the large-body slot is freed, and the `mcp_request` line
57
+ says `status` 499 with `client_closed` true. What Cloud Run forwards is only partly measured.
58
+ On test deployments with `--use-http2` on 2026-10-05, ordinary calls, sessions and `/health`
59
+ were all answered over h2c. A client that stopped reading a streamed answer had its stream reset
60
+ by Cloud Run 15 s in (`rst_code` 2), and that was logged 499. A client that aborts mid-call
61
+ before any answer was not observed, because every probe call was answered first, so whether
62
+ Cloud Run forwards that reset at once is not yet shown. Until it is, an abandoned call may still
63
+ run to its end, a `check_sbom` call for at most its 50-second budget, holding its slot. A new line, `mcp_client_gone`,
64
+ records when the server learned the client went: `after_ms` from the request's start, whether
65
+ the answer had started (`answer_started`), and an HTTP/2 stream's `rst_code`. It holds nothing
66
+ of the request. Run yourself, `dist/http.js` speaks HTTP/1.1 as before. With `MCP_H2C=1` it
67
+ speaks h2c with prior knowledge only, and an HTTP/1.1 request gets no answer.
68
+ - README, "Privacy: what is sent where": the hosted endpoint's paragraph now also says what the API logs
69
+ of the calls it makes for you (#2797): each under your address, with its URL path (for a CVE, CWE or
70
+ advisory lookup, the ID), and not its query string, the header-carried terms or which purls. The
71
+ privacy policy's new Section 11, https://echelongraph.io/privacy#hosted-mcp-endpoint , says the same.
72
+ - `exposure_radar` relays and labels `mcp_servers.withheld_opted_out` (#2822): how many hostnames
73
+ whose owner opted out of scanning the API left out of every other `mcp_servers` number. Such a
74
+ hostname is not checked again while the opt-out stands, so its last verdict cannot be re-checked;
75
+ the API now withholds it from the MCP-server counts as its unparameterised AI-exposure answer
76
+ already did. An answer without the field, from an API that predates it, counted those hostnames,
77
+ and the note says so instead of reading the absence as 0; a value that is not a whole number is
78
+ left out and named.
79
+ - Claude Desktop in one click (#2815). From this release on, every GitHub release of
80
+ [github.com/echelongraph/echelongraph-mcp](https://github.com/echelongraph/echelongraph-mcp/releases)
81
+ carries `echelongraph-mcp.mcpb`, a Claude Desktop extension: open it and choose Install. It needs
82
+ no Node.js on your machine, since Claude Desktop runs it with the Node.js it ships, and no JSON to edit.
83
+ The README's Claude Desktop section links it as "Install in Claude Desktop", ahead of the custom
84
+ connector and the `npx` setup. The release workflow builds it from the release tag, the same tag
85
+ the npm package is published from: `listings/mcpb/build.sh` validates the manifest with the pinned
86
+ MCPB CLI and packs. Then, on a separate read-only runner that never ran the MCPB CLI,
87
+ `listings/mcpb/check-bundle.mjs` refuses a bundle that holds a top-level file `build.sh` does not
88
+ stage, a symlink, or a file where `build.sh` stages a directory; that lacks the icon or entry point
89
+ its manifest names, or `node_modules/`; whose `package.json`, `LICENSE`, `README.md` or `dist/` is not the tested npm
90
+ tarball's, byte for byte (a file changed, missing or added); whose `node_modules/` adds or changes
91
+ a file a production install from the lockfile does not have; that is at another version; or whose
92
+ own server, started with `PATH`, `HOME` and `ECHELONGRAPH_API_BASE` only (none of the caller's
93
+ `NODE_PATH`, `NODE_OPTIONS` or other variables), does not list the
94
+ manifest's 14 tools with their titles. npm publishes only after that check passes, so a manifest
95
+ or bundle defect stops the release before npm has the version. A separate job, which runs no
96
+ dependency code, signs the checked bundle with a SLSA build-provenance attestation (Sigstore,
97
+ keyless), attaches it and its Sigstore bundle to the release, each on its own, and checks the
98
+ file the release serves with `gh attestation verify`. To check a download yourself:
99
+ `gh attestation verify echelongraph-mcp.mcpb --repo echelongraph/echelongraph-mcp`.
100
+ Releases up to 2.6.5 carry no bundle.
101
+ - Repository only, not in the package: `listings/mcpb/check-bundle.mjs` and
102
+ `test/mcpb-release.test.mjs`; `test/mcp-stdio-client.mjs` takes a `cwd`.
103
+ - `sbom_review` reads the advisory interval in `check_sbom`'s `match_reason` by its closing
104
+ bracket, without the hedge of 2.6.5 and earlier. `[A, B)` is a fixed bound: B, the first version the advisory
105
+ records as not affected, is given as the fixed version (`[A, ∞)` names none). `[A, B] (B is the
106
+ last affected version, not a fix)` is an OSV `last_affected` bound: B is still affected and the
107
+ range records no fix, so neither B nor any version at or below it is given as a fixed version.
108
+ The bracket alone decides: a cut text clips `match_reason` to 200 characters, which can drop the
109
+ words after it for a long package name, and where it is clipped before the closing bracket
110
+ (`[A, B…`), no fixed version is taken from that `match_reason`.
111
+ Through 2.6.5 core-backend rendered both bounds as `[A, B)`, so the prompt gave B only as "the
112
+ end of that interval"; core-backend now renders a `last_affected` bound closed (#2830). This
113
+ wording assumes the API serves the new rendering: it is released after core-backend is deployed.
114
+ - `cve_intel` relays `fixed_branches` on each `affected_packages` row: every affected range of the
115
+ package on record, each with `introduced` and `fixed` (the range's fix) or, where no fix is on
116
+ record, `last_affected` (never in a field named fixed), and with `advisory_id` (the OSV record that
117
+ published the range) and `source` (the loader's label for that record), from core-backend's
118
+ `/api/v1/public/cves/:id/enrichment` since #2817. Ranges that name their record are in the order
119
+ the record lists them; ranges loaded before the record was stored (both null) are oldest first.
120
+ `fixed_branches` is null where a package's ranges were never loaded, and `[]` where no version
121
+ range is on record (none in the advisory, only commit ranges, or more than are stored), which is
122
+ not a finding that no fix exists. `fixed_version` is one range's fix per package,
123
+ kept for compatibility: for CVE-2021-44228 production served log4j-core 2.12.2 alone (2026-10-04),
124
+ the fix for 2.4 to 2.12.1, older than an affected 2.14.1, while the advisory
125
+ (GHSA-jfh8-c2jp-5v3q) gives three ranges, fixed in 2.3.1, 2.12.2 and 2.15.0. The description says
126
+ how to pick the range for an installed version, and the note counts the rows with more than one
127
+ range, and those with no range on record (`fixed_branches` null, or `[]` beside a `fixed_version`),
128
+ where `fixed_version` is all the answer holds. An API older than #2817 is said to be one (#2817).
129
+ - `triage_cve`'s patch line gives each affected range with its fix from `fixed_branches`, and
130
+ `fixed_version` only for a package without them; `sbom_review` gives a component the fix of the
131
+ `fixed_branches` range that holds its installed version (log4j-core 2.14.1: 2.15.0), after the
132
+ advisory interval in `check_sbom`'s `match_reason`, and `fixed_version` only for a package without
133
+ `fixed_branches` (#2817).
134
+ - `vendor_advisories_for_cve`, `search_vendor_advisories` and `get_vendor_advisory` relay each
135
+ vendor's `held_since`, which the coverage API now serves: the start of the window EchelonGraph has
136
+ read the vendor's advisories from, before which an advisory may not be held.
137
+ `vendors_not_fully_held` reads it, not the earliest held advisory. For a vendor with no history read
138
+ the earliest held advisory is not where its window begins: Cisco's poller first read the advisories
139
+ Cisco had changed in the last 90 days, Red Hat's and GitHub's the last 7, so an older advisory the
140
+ vendor revised later is held while the ones published beside it are not. Cisco's earliest held
141
+ advisory is dated 2024-10-23 and Red Hat's 2009-02-04, so CVE-2025-20188 left out Cisco and
142
+ CVE-2014-0160 left out Red Hat, and their empty answers read as the vendor having published nothing;
143
+ both are listed now. GitHub is now listed too for any CVE ID year that begins before the start of
144
+ its first poll's 7-day window (`held_since`), not only for years up to that of its earliest held
145
+ advisory (2017). Against an API without `held_since`, a vendor is taken as held from its earliest
146
+ advisory only when its history read is complete, and otherwise listed, its window written "not known
147
+ to be without a gap" (#2803).
148
+
9
149
  ## 2.6.5 — 2026-10-05
10
150
 
11
151
  2.6.4 was tagged and never published: its Release run failed `test/http-memory.test.mjs` on Node 24,
package/README.md CHANGED
@@ -34,21 +34,32 @@ API call a tool needs to answer.
34
34
  ## Quick start
35
35
 
36
36
  The local server runs through `npx` and needs Node.js 20 or later; nothing is installed
37
- 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
38
38
  CVE-2023-44487 actively exploited, and how many exposed services does EchelonGraph's radar
39
39
  have on record for it?"*
40
40
 
41
41
  ### Claude Desktop
42
42
 
43
- Two ways in. The first needs nothing installed; the second runs the server on your machine.
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`.
44
45
 
45
- **1. Custom connector (no install).** Claude Desktop uses your claude.ai account's connectors:
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:
46
57
  Customize → Connectors → **+ Add** → **Add custom connector**. Name it `EchelonGraph`, paste
47
58
  `https://mcp.echelongraph.io/mcp`, choose **No sign in**, and add it. No Node.js, no PATH and no
48
59
  JSON to edit. A connector added on claude.ai shows up in Claude Desktop too, and the plan rules
49
60
  are claude.ai's (see "Remote (no install)" below).
50
61
 
51
- **2. Local server through `npx`.**
62
+ **3. Local server through `npx`.**
52
63
 
53
64
  1. **Check Node.js.** In Terminal (macOS) or PowerShell (Windows), run `node -v`. It must print
54
65
  `v20` or later. If it prints an older version or `command not found`, install the LTS release
@@ -228,7 +239,7 @@ The example questions are ones a client can answer with that tool alone.
228
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?* |
229
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?* |
230
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.* |
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?* |
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?* |
232
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?* |
233
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?* |
234
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.* |
@@ -238,6 +249,18 @@ The three vendor-advisory tools relay `vendor_published_at` (the vendor's date),
238
249
  (when EchelonGraph first recorded the advisory) and `withdrawn` (the vendor rescinded it; the note
239
250
  names each one, and the search leaves them out).
240
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
+
241
264
  Every figure is as fresh as the schedule that refreshes it. The CVE feed is polled from its
242
265
  sources on a schedule; each radar refreshes on its own.
243
266
 
@@ -443,8 +466,9 @@ feed, matched by hostname pattern; no Shodan data is used. Each is checked by Ec
443
466
  identified MCP probe: a `server/discover` request, and `initialize` only if that is refused. It
444
467
  never sends `tools/call`. The answer is counts and timestamps only, with no hostname, version or
445
468
  server name, and the tool repeats no string it carries but its timestamps. Each hostname is
446
- counted once, by its latest verdict on record, and EchelonGraph's own control servers are left
447
- 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.
448
472
 
449
473
  | Field | What it counts |
450
474
  |---|---|
@@ -454,6 +478,7 @@ out. The unit is a hostname, not an ip:port service.
454
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`. |
455
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). |
456
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. |
457
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. |
458
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. |
459
484
  | `mcp_servers.enabled` | Whether the API reports the radar running: true when a check completed within 45 minutes of its answer. |
@@ -544,10 +569,10 @@ never "not affected", and each figure cites its `measured_at`. A prompt makes no
544
569
 
545
570
  | Prompt | Arguments | What it asks the model to do |
546
571
  |---|---|---|
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. |
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. |
548
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. |
549
574
  | `am_i_affected` | `product`, or `ecosystem` and `package`; `version` | `check_affected`, read `assessed` before `count`, with the not-assessed wording. |
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. |
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. |
551
576
 
552
577
  ## Resources
553
578
 
@@ -631,7 +656,7 @@ pretty-printed and whole. The same answer is always cut the same way. The `cve:/
631
656
  resource cuts its `data` as `get_cve`'s first text block is cut, and says so in a note starting
632
657
  `DATA CUT`. The prompts ask only for what the text keeps: `kev_weekly_brief` reads `kev_recent`
633
658
  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`.
659
+ and `sbom_review` reads fixed versions from `cve_intel`, the fix of each affected range in `fixed_branches`.
635
660
 
636
661
  ## Structured results
637
662
 
@@ -768,7 +793,11 @@ only those to the API, and neither logs nor keeps the document. To keep the docu
768
793
  machine, run the npm package, or pass `check_sbom` the purls. When a client passes the
769
794
  per-client limit it logs that client's address key. It passes your client's public address to
770
795
  the API, in a header the API trusts only with a token it checks, so the API's rate limit counts
771
- 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.
772
801
 
773
802
  EchelonGraph's privacy policy: <https://echelongraph.io/privacy>.
774
803
 
@@ -808,6 +837,16 @@ instance serves at most 64 requests at once; the next waits up to 10 seconds for
808
837
  and is then answered the same way. A `subscriptions/listen` stream, which stays open until its
809
838
  client leaves, is not one of the 64.
810
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.
849
+
811
850
  ## Configuration
812
851
 
813
852
  The npm package (stdio):
@@ -835,6 +874,7 @@ It reads the three variables above and:
835
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`. |
836
875
  | `MCP_MAX_IN_FLIGHT` | `64` | How many requests it serves at once, not counting `subscriptions/listen` streams; the next, its body read, waits. |
837
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. |
838
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. |
839
879
 
840
880
  ## Develop
package/dist/http.js CHANGED
@@ -13,6 +13,17 @@
13
13
  // GET /health liveness. Not /healthz: Cloud Run's front end reserves paths ending in "z"
14
14
  // and answers them itself, so a /healthz would never reach this process.
15
15
  //
16
+ // HTTP/1.1 by default; HTTP/2 cleartext (h2c, prior knowledge) ONLY when MCP_H2C=1 (#2775). Cloud
17
+ // Run tells a container that its client went away only over HTTP/2 or a WebSocket ("When you use
18
+ // HTTP/1.1 on Cloud Run, client disconnect events are not propagated to the Cloud Run container",
19
+ // https://docs.cloud.google.com/run/docs/troubleshooting): over HTTP/1.1 its front end reads the
20
+ // whole answer itself, so an abandoned call ran to its end, holding its large-body slot. With
21
+ // `--use-http2` Cloud Run sends the container h2c and nothing else, and a client that goes resets
22
+ // its stream, which this server acts on at once (serveMcp). But on a canary of 2026-10-05 that reset
23
+ // came only when the answer was written, 15 and 34 s in, not when the client went:
24
+ // mcp_client_gone (after_ms, answer_started) is how production shows which. The flag and
25
+ // MCP_H2C=1 go together (deploy-all.sh mcp-remote): either one alone answers no request at all.
26
+ //
16
27
  // No auth: the endpoint serves only public data (MCP makes authorization optional), and the
17
28
  // server's instructions say what it is. The edge rules — Origin policy, per-client limit, body
18
29
  // cap, what is logged — are in httpPolicy.ts, each with its reasoning.
@@ -22,6 +33,7 @@
22
33
  // waf/mcpforward.go, #2212). MCP_REQUIRE_FORWARD_TOKEN=1 (set by deploy-all.sh) makes a missing
23
34
  // token fatal at start-up, because without it every remote user would share one API budget.
24
35
  import http from "node:http";
36
+ import http2 from "node:http2";
25
37
  import v8 from "node:v8";
26
38
  import { createMcpHandler } from "@modelcontextprotocol/server";
27
39
  import { forwardToken, markHttpEntrypoint, requestSettled, SYNTHETIC_UA_FAMILY, withClient } from "./runtime.js";
@@ -49,6 +61,9 @@ const ADMISSION_WAIT_MS = positiveInt(process.env.MCP_ADMISSION_WAIT_MS, DEFAULT
49
61
  // A large body's wait for a slot is not queued: the client is told to come back. Nor is a
50
62
  // request that waited ADMISSION_WAIT_MS for admission.
51
63
  const BUSY_RETRY_AFTER_SEC = 5;
64
+ // #2775: serve h2c instead of HTTP/1.1 (see the top of this file). Exactly "1": anything else is
65
+ // HTTP/1.1, the default for a local run and for every test that does not ask.
66
+ const H2C = process.env.MCP_H2C === "1";
52
67
  const MCP_PATH = "/mcp";
53
68
  const HEALTH_PATH = "/health";
54
69
  // One JSON object per line on stdout: Cloud Logging reads `severity` and `message`.
@@ -87,8 +102,17 @@ const PREFLIGHT_HEADERS = {
87
102
  "Access-Control-Max-Age": "600",
88
103
  };
89
104
  const HOP_BY_HOP = new Set(["host", "connection", "content-length", "transfer-encoding", "keep-alive", "upgrade", "expect"]);
105
+ // HTTP/2 has no connection-specific headers (RFC 9113 8.2.2): Node drops them from an h2 answer
106
+ // with a warning, so they are never set on one. A refusal's "Connection: close" (the rest of the
107
+ // body is discarded unread) is the stream's own end there.
108
+ const CONNECTION_SPECIFIC = new Set(["connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade"]);
109
+ function forProtocol(res, headers) {
110
+ if (!(res instanceof http2.Http2ServerResponse))
111
+ return headers;
112
+ return Object.fromEntries(Object.entries(headers).filter(([k]) => !CONNECTION_SPECIFIC.has(k.toLowerCase())));
113
+ }
90
114
  function send(res, status, body, headers = {}) {
91
- res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers });
115
+ res.writeHead(status, forProtocol(res, { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers }));
92
116
  res.end(body);
93
117
  }
94
118
  function readBody(req) {
@@ -133,8 +157,14 @@ function readBody(req) {
133
157
  chunks.push(chunk);
134
158
  });
135
159
  req.on("end", () => {
160
+ if (done)
161
+ return;
162
+ done = true;
163
+ resolve({ body: Buffer.concat(chunks), slot });
164
+ });
165
+ req.on("close", () => {
136
166
  if (!done)
137
- resolve({ body: Buffer.concat(chunks), slot });
167
+ stop("gone");
138
168
  });
139
169
  req.on("error", (e) => {
140
170
  if (done)
@@ -179,11 +209,35 @@ async function serveMcp(req, res, pathname) {
179
209
  // #2775: the client has gone when the response closes before it has finished. That aborts the
180
210
  // request: its wait for admission, the SDK's handling of it (which cancels the tool's call), and
181
211
  // its API calls (runtime.ts requestSignal).
212
+ // Over h2c the response of a reset stream closes marked finished (node:http2's compatibility
213
+ // API ends it), so there the stream itself is watched, from the moment the reset arrives:
214
+ // 'aborted' (a reset before the answer was written, any error code) and a 'close' before this
215
+ // server ended its answer (a reset with NO_ERROR emits no 'aborted').
216
+ // mcp_client_gone says WHEN this server learned it: after_ms from the request's start, and
217
+ // whether its answer had started (answer_started). On 2026-10-05 a canary behind Cloud Run's
218
+ // --use-http2 logged its aborted calls 499 only when their answers were written, 15 and 34 s
219
+ // in: this line tells a reset that arrived then from one that arrived early and was not acted on
220
+ // (the access line's duration_ms is when the call actually stopped).
182
221
  const gone = new AbortController();
222
+ const t0 = Date.now();
223
+ const h2stream = res.stream;
224
+ const clientWent = () => {
225
+ if (gone.signal.aborted)
226
+ return;
227
+ gone.abort();
228
+ log("INFO", "mcp_client_gone", { after_ms: Date.now() - t0, answer_started: res.headersSent, rst_code: h2stream?.rstCode ?? null });
229
+ };
183
230
  res.on("close", () => {
184
- if (!res.writableFinished)
185
- gone.abort();
231
+ if (!res.writableFinished || h2stream?.aborted)
232
+ clientWent();
186
233
  });
234
+ if (h2stream) {
235
+ h2stream.once("aborted", clientWent);
236
+ h2stream.once("close", () => {
237
+ if (!res.writableEnded)
238
+ clientWent();
239
+ });
240
+ }
187
241
  // The body is read BEFORE the request waits for admission (#2773), so a client that uploads
188
242
  // slowly holds its socket, and a large body its large-body slot, as before admission existed,
189
243
  // never one of the MAX_IN_FLIGHT places every other request needs. What a waiting request holds
@@ -203,6 +257,8 @@ async function serveMcp(req, res, pathname) {
203
257
  return { status: 499, refused: "client_closed", clientClosed: true };
204
258
  throw e;
205
259
  }
260
+ if (read === "gone")
261
+ return { status: 499, refused: "client_closed", clientClosed: true };
206
262
  if (read === "too_large")
207
263
  return tooLarge(res, cors);
208
264
  if (read === "busy") {
@@ -259,7 +315,8 @@ async function serveMcp(req, res, pathname) {
259
315
  async function relay(req, res, pathname, method, cors, client, body, gone, work) {
260
316
  const headers = new Headers();
261
317
  for (const [k, v] of Object.entries(req.headers)) {
262
- if (v === undefined || HOP_BY_HOP.has(k))
318
+ // ":method", ":path", ":authority", ":scheme": an h2 request's pseudo-headers, which a Request cannot carry.
319
+ if (v === undefined || HOP_BY_HOP.has(k) || k.startsWith(":"))
263
320
  continue;
264
321
  for (const one of Array.isArray(v) ? v : [v])
265
322
  headers.append(k, one);
@@ -279,7 +336,7 @@ async function relay(req, res, pathname, method, cors, client, body, gone, work)
279
336
  response.headers.forEach((v, k) => {
280
337
  out[k] = v;
281
338
  });
282
- res.writeHead(response.status, out);
339
+ res.writeHead(response.status, forProtocol(res, out));
283
340
  if (response.body) {
284
341
  for await (const chunk of response.body)
285
342
  res.write(chunk);
@@ -290,7 +347,7 @@ async function relay(req, res, pathname, method, cors, client, body, gone, work)
290
347
  return { status: 499, body, clientPublic, clientClosed: true };
291
348
  return { status: response.status, body, clientPublic };
292
349
  }
293
- const server = http.createServer((req, res) => {
350
+ function onRequest(req, res) {
294
351
  const start = process.hrtime.bigint();
295
352
  // The path only: a query string is never read, kept or logged.
296
353
  let pathname = "/";
@@ -341,7 +398,15 @@ const server = http.createServer((req, res) => {
341
398
  res.end();
342
399
  finish({ status: 500 });
343
400
  });
344
- });
401
+ }
402
+ // #2775: with MCP_H2C=1, node:http2's compatibility API, whose request and response carry the
403
+ // IncomingMessage / ServerResponse members used above. Where they differ the code says so:
404
+ // pseudo-headers (relay), connection-specific headers (forProtocol), a stream reset mid-upload
405
+ // (readBody "gone"). A stream reset after that closes the response unfinished, which aborts
406
+ // `gone` in serveMcp exactly as a closed HTTP/1.1 socket does.
407
+ const server = H2C
408
+ ? http2.createServer((req, res) => onRequest(req, res))
409
+ : http.createServer(onRequest);
345
410
  // #2773: the heap V8 may grow to, and the memory the container allows (0 when none is known). A
346
411
  // heap allowed past the container's memory is collected only when the kernel kills the instance:
347
412
  // Dockerfile.http caps it, and this says when a process was started without that cap. It can say
@@ -362,6 +427,7 @@ server.listen(PORT, () => {
362
427
  const addr = server.address();
363
428
  log("INFO", "mcp_remote_listening", {
364
429
  port: typeof addr === "object" && addr ? addr.port : PORT,
430
+ protocol: H2C ? "h2c" : "http/1.1",
365
431
  name: NAME,
366
432
  version: VERSION,
367
433
  api_base: SHOWN_BASE,
package/dist/index.js CHANGED
@@ -1567,8 +1567,9 @@ function readShadowAI(sa) {
1567
1567
  //
1568
1568
  // core-backend/internal/aiexposure (handler.go MCPPublicCounts, store.go): the AI-exposure
1569
1569
  // radar's MCP rows, one per hostname, each counted once by its latest verdict on record, with
1570
- // EchelonGraph's own control servers left out and counted in own_controls_excluded. Counts,
1571
- // timestamps and one constant (service "mcp"); no hostname, version or server name.
1570
+ // EchelonGraph's own control servers left out and counted in own_controls_excluded, and every
1571
+ // hostname whose owner opted out of scanning left out and counted in withheld_opted_out (#2822).
1572
+ // Counts, timestamps and one constant (service "mcp"); no hostname, version or server name.
1572
1573
  // total protected + pending_readjudication + not_assessed
1573
1574
  // protected the /mcp endpoint answered 401/403 and its RFC 9728 protected-resource
1574
1575
  // metadata validated (prm.go); prm_via divides exactly these rows by
@@ -1581,6 +1582,12 @@ function readShadowAI(sa) {
1581
1582
  // (a challenge recorded before #2312), no_http_answer, not_identified_as_mcp
1582
1583
  // era, transport divide every counted row; a row written before #2314 recorded an era
1583
1584
  // is not_measured (era.go says what each value rests on)
1585
+ // withheld_opted_out hostnames a verified scan opt-out covered when the radar last reached
1586
+ // them (store.go opted_out_at, #2363): not re-checked while it stands, so
1587
+ // their last verdict is frozen, and in no other count (#2822). The unparameterised
1588
+ // answer withholds the same rows under the same key. An API older than
1589
+ // #2822 carries no such field and counted those rows; the note says so
1590
+ // rather than reading the absence as 0.
1584
1591
  // window from/to: the oldest and newest last-check time over the counted rows
1585
1592
  // enabled, last_run_at exactly as the unparameterised answer computes them: the AI-exposure
1586
1593
  // radar's fleet-wide liveness and last completed check, over every AI
@@ -1636,7 +1643,7 @@ const MCP_PARTITIONS = {
1636
1643
  };
1637
1644
  const MCP_PARTITION_NAMES = Object.keys(MCP_PARTITIONS);
1638
1645
  const MCP_INSTANTS = ["last_run_at", "counted_at"];
1639
- const MCP_KNOWN = new Set(["service", ...MCP_HEADLINE, ...MCP_PARTITION_NAMES, "own_controls_excluded", "window", "enabled", ...MCP_INSTANTS]);
1646
+ const MCP_KNOWN = new Set(["service", ...MCP_HEADLINE, ...MCP_PARTITION_NAMES, "own_controls_excluded", "withheld_opted_out", "window", "enabled", ...MCP_INSTANTS]);
1640
1647
  // What each challenge reason code says (prm.go prmReasonCodes), for the note.
1641
1648
  const MCP_CHALLENGE_MEANS = {
1642
1649
  resource_mismatch: "the document's resource is absent or not identical to the server's identifier",
@@ -1704,6 +1711,12 @@ function readMCPServers(src) {
1704
1711
  data.own_controls_excluded = src.own_controls_excluded;
1705
1712
  else
1706
1713
  unusable.push("own_controls_excluded");
1714
+ // An API older than #2822 carries no withheld_opted_out, and counted those rows (the note says
1715
+ // so); one that carries it in another shape has it left out and named.
1716
+ if (isWhole(src.withheld_opted_out))
1717
+ data.withheld_opted_out = src.withheld_opted_out;
1718
+ else if (src.withheld_opted_out !== undefined)
1719
+ unusable.push("withheld_opted_out");
1707
1720
  // window: from and to, both real instants, from not after to. With no row counted the API has
1708
1721
  // no window to give (null, or from and to null), and none is relayed.
1709
1722
  const w = src.window;
@@ -1752,6 +1765,12 @@ function readMCPServers(src) {
1752
1765
  if (data.own_controls_excluded !== undefined) {
1753
1766
  sentences.push(`mcp_servers.own_controls_excluded (${n("own_controls_excluded")}) counts EchelonGraph's own control servers, which are left out of every other mcp_servers number.`);
1754
1767
  }
1768
+ if (data.withheld_opted_out !== undefined) {
1769
+ sentences.push(`mcp_servers.withheld_opted_out (${n("withheld_opted_out")}) counts hostnames whose owner opted out of EchelonGraph's scanning: they are not checked again while the opt-out stands, so their last verdict cannot be re-checked, and they are left out of every other mcp_servers number.`);
1770
+ }
1771
+ else if (src.withheld_opted_out === undefined) {
1772
+ sentences.push("The answer does not say how many hostnames whose owner opted out of scanning it withheld (mcp_servers.withheld_opted_out), so its counts may include such a hostname's last verdict, which is not re-checked while the opt-out stands.");
1773
+ }
1755
1774
  if (data.window) {
1756
1775
  sentences.push(`mcp_servers.window says when the verdicts counted were last checked: the oldest at ${from} (mcp_servers.window.from) and the newest at ${to} (mcp_servers.window.to), so the counts are each hostname's latest verdict, not one sweep at one time.`);
1757
1776
  }
@@ -1777,11 +1796,11 @@ function readMCPServers(src) {
1777
1796
  return { data, sentences };
1778
1797
  }
1779
1798
  const EXPOSURE_RADAR_DESCRIPTION = `Aggregate totals from EchelonGraph's internet-exposure radars, each refreshed on its own schedule: internet-facing services running actively-exploited (CISA-KEV) CVEs, plus the ransomware-linked subset, derived from Shodan data; 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 (not a pure read: on Redis it names its client, and on ClickHouse its query is recorded in the server's query log); leaked credentials sampled from public GitHub push events; and shadow AI services found through Certificate Transparency logs and Shodan and then checked by EchelonGraph's identified probes. It also gives MCP-server counts: hostnames named like an MCP server in EchelonGraph's own Certificate Transparency feed (no Shodan data), each counted once by its latest verdict from EchelonGraph's identified MCP probe, which never sends tools/call. Every number in the result is labelled here and in the result's note by what it counts; a field this version cannot label is left out and named in the note. ` +
1780
- `kev_exposure: kev_exposure.distinct_hosts counts distinct ip:port services, not machines (a machine answering on two ports counts twice), with at least one CISA-KEV-listed CVE on record, and kev_exposure.ransomware_hosts those with a ransomware-linked one; kev_exposure.kev_cves_exposed and kev_exposure.ransomware_cves count distinct CVEs with at least one such service; kev_exposure.correlations counts service×CVE pairs, not services, so a service with three KEV CVEs counts three times. kev_exposure.top_products, kev_exposure.top_countries and kev_exposure.top_cves rank up to 12 products, 10 countries and 12 CVEs by those services; kev_exposure.top_cves[].cvss_v3_score and kev_exposure.top_cves[].epss_score are the highest CVSS v3 base score and EPSS probability recorded on that CVE's observations. kev_exposure.trend counts service×CVE pairs still on record by the week in which each was first recorded, over 12 weeks, so earlier weeks read low. kev_exposure.newest_kev lists the 15 CVEs that EchelonGraph's CVE records most recently mark as CISA-KEV-listed, each with an exposure_state: exposed, where kev_exposure.newest_kev[].exposed_hosts counts distinct ip:port services on record with it; or not_assessed, where the API's answer holds no measurement for that CVE (its 0 is not one) and no count is relayed, and cve_exposure says per CVE whether the radar tracks it. kev_exposure.newest_kev[].cvss_v3_score and kev_exposure.newest_kev[].epss_score are the CVE record's CVSS v3 base score and EPSS probability. ` +
1799
+ `kev_exposure: kev_exposure.distinct_hosts counts distinct ip:port services, not machines (a machine answering on two ports counts twice), with at least one CISA-KEV-listed CVE on record, and kev_exposure.ransomware_hosts those with a ransomware-linked one; kev_exposure.kev_cves_exposed and kev_exposure.ransomware_cves count distinct CVEs with at least one such service; kev_exposure.correlations counts service×CVE pairs, not services, so a service with three KEV CVEs counts three times. kev_exposure.top_products, kev_exposure.top_countries and kev_exposure.top_cves rank up to 12 products, 10 countries and 12 CVEs by those services; kev_exposure.top_cves[].cvss_v3_score and kev_exposure.top_cves[].epss_score are the highest CVSS v3 base score and EPSS probability recorded on that CVE's observations. kev_exposure.trend counts service×CVE pairs still on record by the week in which each was first recorded, over 12 weeks, so earlier weeks read low. kev_exposure.newest_kev lists the 15 CVEs that EchelonGraph's CVE records most recently mark as CISA-KEV-listed, each with an exposure_state: exposed, where kev_exposure.newest_kev[].exposed_hosts counts distinct ip:port services on record with it; or not_assessed, where the API's answer holds no measurement for that CVE (its 0 is not one) and no count is relayed, and this answer does not say whether the radar tracks that CVE: tracking is answered per CVE. kev_exposure.newest_kev[].cvss_v3_score and kev_exposure.newest_kev[].epss_score are the CVE record's CVSS v3 base score and EPSS probability. ` +
1781
1800
  `exposed_databases: exposed_databases.distinct_hosts counts distinct ip:port services the check confirmed answering without authentication, and exposed_databases.engines the distinct engine types among them; exposed_databases.top_engines and exposed_databases.top_countries rank up to 15 engines and 10 countries by those services. exposed_databases.pii_likely and exposed_databases.pci_likely count services whose schema names (never record values) pass a high-confidence, precision-first gate for personal or payment-card data, so a service outside them is not shown to hold no such data. exposed_databases.window.from and exposed_databases.window.to are timestamps, not counts: when EchelonGraph's check last confirmed the oldest and the newest of the services counted, a span of checks made at different times; with a counted service that has no such time on record there is no window. ` +
1782
1801
  `leaked_credentials: leaked_credentials.total counts (repository, secret) pairs, not distinct secrets, so one secret in three repositories counts three times; leaked_credentials.distinct_secrets counts each secret once and leaked_credentials.distinct_repos counts repositories; leaked_credentials.top_providers and leaked_credentials.top_types rank up to 15 providers and secret types by those pairs. None is validated: each is a credential-shaped string that passed EchelonGraph's filters, at most structurally checked and never tested against its provider. leaked_credentials.window.from and leaked_credentials.window.to are timestamps, not counts: when EchelonGraph's detector last found the oldest and the newest of the pairs counted. Each of those three radars also carries generated_at, when the API computed its totals, and, when the API can tell, last_run_at. kev_exposure.last_run_at, exposed_databases.last_run_at and leaked_credentials.last_run_at are timestamps, not counts: each is when that radar last completed a check, a cycle whose reads succeeded, among them a Shodan search (for exposed_databases, or its LeakIX fallback) that answered at least one query, or for leaked_credentials a read of the public GitHub event stream. A cycle that read nothing does not move it, and it is not the time of every record a radar's numbers count, which cover everything still on record, not only what the last check found. A radar whose answer carries no last_run_at has none in the result, and the note says nothing about it. ` +
1783
1802
  `shadow_ai: the result is regrouped by what each number counts. shadow_ai.confirmed_exposed counts services EchelonGraph's probes found answering without an authentication gate (liveness active, or rechecking during a re-check): confirmed_exposed.total is the sum of confirmed_exposed.by_category, and confirmed_exposed.last_24h counts those first recorded in the last 24 h. Of the shadow_ai numbers, only confirmed_exposed counts exposed services. confirmed_exposed.window.from and confirmed_exposed.window.to are timestamps, not counts: when EchelonGraph's verifier ran the probe that last decided the oldest and the newest of those services, a span of probes made at different times. shadow_ai.observed counts every Certificate Transparency or Shodan observation on record, whatever its verification state: its numbers are observed, not exposed. They are observed.total; observed.by_category (the same observations by category); observed.last_24h (those first recorded in the last 24 h); observed.trend_30d (observations per UTC day over the last 30 days); and observed.top_products, observed.top_countries and observed.top_issuers (up to ten products, countries and issuers ranked by observations, where an issuer is the certificate's CA for a Certificate Transparency observation and the hosting operator Shodan reports for a Shodan one). shadow_ai.authentication counts observations by probe outcome: authentication.observed where a probe observed an authentication gate (a 401/403, a login page or an auth marker), and authentication.not_determined where the service answered but no probe could tell. Neither authentication count is part of confirmed_exposed, and observed.total minus confirmed_exposed.total is not a count of secured services. The shadow-AI poller block carries only running and last_run_at: last_run_at is when the radar's leader last completed a Certificate Transparency (crt.sh) cycle, and its running is true only when that was within ${SHADOW_AI_SILENT_AFTER} of the answer. ` +
1784
- `mcp_servers: counts and timestamps only, no hostname. mcp_servers.total counts hostnames the AI-exposure radar has checked for an MCP server, each once by its latest verdict on record, and not every one is an MCP server; EchelonGraph's own control servers are left out, and mcp_servers.own_controls_excluded counts them. mcp_servers.total is mcp_servers.protected plus mcp_servers.pending_readjudication plus mcp_servers.not_assessed. mcp_servers.protected counts hostnames whose /mcp endpoint asked for credentials and whose OAuth protected-resource metadata validated under RFC 9728; mcp_servers.prm_via divides them by where that document was found: mcp_servers.prm_via.header, mcp_servers.prm_via.wellknown_path and mcp_servers.prm_via.wellknown_root. mcp_servers.pending_readjudication counts verdicts of a rule since replaced, not yet re-checked. mcp_servers.not_assessed counts the rest, whose protection the radar could not assess (not assessed does not mean unprotected), and mcp_servers.not_assessed_by_reason puts each in one bucket. mcp_servers.not_assessed_by_reason.identified_no_challenge holds servers that identified themselves as MCP servers and did not ask for credentials at the handshake. That is normal in MCP: authorization is optional in the spec, and a server can enforce it at tools/call instead, which EchelonGraph never sends, so this bucket is not a finding of exposure. ${MCP_CHALLENGE_REASON_FIELDS} hold endpoints that asked for credentials but whose RFC 9728 metadata did not validate, by why, so none of them is shown to lack protection; mcp_servers.not_assessed_by_reason.challenge_unadjudicated holds endpoints that asked for credentials before that check existed, not yet re-checked. mcp_servers.not_assessed_by_reason.no_http_answer holds hostnames that gave no HTTP answer, and mcp_servers.not_assessed_by_reason.not_identified_as_mcp hostnames whose HTTP answer identified no MCP server. mcp_servers.era divides every counted hostname by protocol era: mcp_servers.era.legacy; mcp_servers.era.dual, a lower bound; mcp_servers.era.modern, not proven modern-only; mcp_servers.era.unknown; and mcp_servers.era.not_measured, recorded before the era probe and not re-checked since. mcp_servers.transport divides them by transport: mcp_servers.transport.streamable_http, mcp_servers.transport.legacy_sse, mcp_servers.transport.unknown and mcp_servers.transport.not_measured. mcp_servers.window.from and mcp_servers.window.to are when the oldest and the newest of the verdicts counted were last checked. mcp_servers.last_run_at is a timestamp, not a count: when the AI-exposure radar, which checks other AI services too, last completed a check; mcp_servers.enabled is whether one completed within ${MCP_ENABLED_WITHIN} of the answer, and mcp_servers.counted_at is when the API read the counts. A partition whose buckets do not add up to the count it divides is left out and named; an answer that does not say it is the MCP-server counts, or whose mcp_servers.total, mcp_servers.protected, mcp_servers.pending_readjudication and mcp_servers.not_assessed are missing or contradict each other, is a failure. ${SHODAN_OWNERSHIP}`;
1803
+ `mcp_servers: counts and timestamps only, no hostname. mcp_servers.total counts hostnames the AI-exposure radar has checked for an MCP server, each once by its latest verdict on record, and not every one is an MCP server; EchelonGraph's own control servers are left out, and mcp_servers.own_controls_excluded counts them; hostnames whose owner opted out of scanning are not checked again while the opt-out stands, so their last verdict is left out too, and mcp_servers.withheld_opted_out counts them. mcp_servers.total is mcp_servers.protected plus mcp_servers.pending_readjudication plus mcp_servers.not_assessed. mcp_servers.protected counts hostnames whose /mcp endpoint asked for credentials and whose OAuth protected-resource metadata validated under RFC 9728; mcp_servers.prm_via divides them by where that document was found: mcp_servers.prm_via.header, mcp_servers.prm_via.wellknown_path and mcp_servers.prm_via.wellknown_root. mcp_servers.pending_readjudication counts verdicts of a rule since replaced, not yet re-checked. mcp_servers.not_assessed counts the rest, whose protection the radar could not assess (not assessed does not mean unprotected), and mcp_servers.not_assessed_by_reason puts each in one bucket. mcp_servers.not_assessed_by_reason.identified_no_challenge holds servers that identified themselves as MCP servers and did not ask for credentials at the handshake. That is normal in MCP: authorization is optional in the spec, and a server can enforce it at tools/call instead, which EchelonGraph never sends, so this bucket is not a finding of exposure. ${MCP_CHALLENGE_REASON_FIELDS} hold endpoints that asked for credentials but whose RFC 9728 metadata did not validate, by why, so none of them is shown to lack protection; mcp_servers.not_assessed_by_reason.challenge_unadjudicated holds endpoints that asked for credentials before that check existed, not yet re-checked. mcp_servers.not_assessed_by_reason.no_http_answer holds hostnames that gave no HTTP answer, and mcp_servers.not_assessed_by_reason.not_identified_as_mcp hostnames whose HTTP answer identified no MCP server. mcp_servers.era divides every counted hostname by protocol era: mcp_servers.era.legacy; mcp_servers.era.dual, a lower bound; mcp_servers.era.modern, not proven modern-only; mcp_servers.era.unknown; and mcp_servers.era.not_measured, recorded before the era probe and not re-checked since. mcp_servers.transport divides them by transport: mcp_servers.transport.streamable_http, mcp_servers.transport.legacy_sse, mcp_servers.transport.unknown and mcp_servers.transport.not_measured. mcp_servers.window.from and mcp_servers.window.to are when the oldest and the newest of the verdicts counted were last checked. mcp_servers.last_run_at is a timestamp, not a count: when the AI-exposure radar, which checks other AI services too, last completed a check; mcp_servers.enabled is whether one completed within ${MCP_ENABLED_WITHIN} of the answer, and mcp_servers.counted_at is when the API read the counts. A partition whose buckets do not add up to the count it divides is left out and named; an answer that does not say it is the MCP-server counts, or whose mcp_servers.total, mcp_servers.protected, mcp_servers.pending_readjudication and mcp_servers.not_assessed are missing or contradict each other, is a failure. ${SHODAN_OWNERSHIP}`;
1785
1804
  // exposure_radar's envelope. Every radar answered, but no stats answer says when the services
1786
1805
  // or records it counts were observed: each count covers everything that radar still holds on
1787
1806
  // record, and the answer carries only generated_at (when the API computed the totals) and
@@ -2106,6 +2125,7 @@ const MCPServersData = z.strictObject({
2106
2125
  ...Object.fromEntries(MCP_HEADLINE.map((k) => [k, Whole])),
2107
2126
  ...Object.fromEntries(MCP_PARTITION_NAMES.map((k) => [k, z.strictObject(Object.fromEntries(MCP_PARTITIONS[k].buckets.map((b) => [b, Whole]))).optional()])),
2108
2127
  own_controls_excluded: Whole.optional(),
2128
+ withheld_opted_out: Whole.optional(),
2109
2129
  window: z.strictObject({ from: Instant, to: Instant }).optional(),
2110
2130
  enabled: z.boolean().optional(),
2111
2131
  ...Object.fromEntries(MCP_INSTANTS.map((k) => [k, Instant.optional()])),
@@ -2176,17 +2196,17 @@ const FEED_ENVELOPE = "Its structured result carries state (measured), measured_
2176
2196
  const CVE_SUMMARY_ENVELOPE = "Its structured result carries state (measured), measured_at, method, coverage, freshness (null: the feed serves no poll-completion time) and notes, with data equal to the API's JSON less each poller field (or the whole poller) the note names as left out; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.";
2177
2197
  // #2610: what cve_summary says summary.none is (summaryNoneNote), one constant string for the same
2178
2198
  // reason as SCORE_ASSESSED_DESCRIPTION below.
2179
- const SUMMARY_NONE_DESCRIPTION = "summary.none is not a severity rating of None: it counts the active CVEs with no severity band from any source, that is, CVEs not yet scored, and the answer may carry the same count again as summary.unscored. Whenever summary.none is above zero the note says so: report those CVEs as not yet scored, not as CVEs rated None.";
2199
+ const SUMMARY_NONE_DESCRIPTION = "summary.none is not a severity rating of None: it counts the active CVEs with no severity band from any source, that is, CVEs not yet scored, and the answer may carry the same count again as summary.unscored. Whenever summary.none is above zero the note says so, and names those CVEs not yet scored, not CVEs rated None.";
2180
2200
  // #2641: what cve_summary says the NVD histogram and summary.rejected are (summaryNVDNote,
2181
2201
  // summaryRejectedNote), constant strings for the same reason. Neither uses the words "any", "not",
2182
2202
  // "rating", "scored", "source" or "yet": the site's #2610 control (marketing-site
2183
2203
  // lib/mcpToolClaims.test.ts) holds that exactly those words of the /pulse/mcp cve_summary row are
2184
2204
  // backed by SUMMARY_NONE_DESCRIPTION alone, and fails if another sentence backs one.
2185
2205
  const SUMMARY_NVD_DESCRIPTION = "summary.nvd_critical, summary.nvd_high, summary.nvd_medium, summary.nvd_low and summary.nvd_none count the same active CVEs as summary.total by NVD's CVSS severity label (v3.x, else v4.0; before NVD's record arrives, or where it gives none, a pre-NVD label from the CVE.org record or a GitHub advisory can stand in): provenance, never EchelonGraph's severity band. summary.nvd_none counts the active CVEs with no Critical, High, Medium or Low label there, CVEs NVD never labelled under CVSS v3 among them, and many of those carry an NVD CVSS v2 score instead: it is neither a count of CVEs rated None nor the count of CVEs with no severity, which is summary.none. Whenever summary.nvd_none is above zero the note says what it counts, with its count.";
2186
- const SUMMARY_REJECTED_DESCRIPTION = "summary.rejected counts the CVE records rejected (withdrawn) by their numbering authority, which summary.total and the other counts above leave out: report them as withdrawn records, never as vulnerabilities.";
2206
+ const SUMMARY_REJECTED_DESCRIPTION = "summary.rejected counts the CVE records rejected (withdrawn) by their numbering authority, which summary.total and the other counts above leave out: they are withdrawn records, none of them a vulnerability.";
2187
2207
  // #2535: what search_cves and get_cve say about score_assessed, one constant string, since the
2188
2208
  // site's tool-claims check (marketing-site lib/mcpToolClaims.test.ts) folds only constant strings.
2189
- const SCORE_ASSESSED_DESCRIPTION = "echelongraph_score, echelongraph_severity and echelongraph_risk are EchelonGraph's score only when score_assessed is true. With score_assessed false the CVE is NOT YET SCORED, not scored 0: any of those three it carries (0, NONE, 0) is a placeholder, not a rating, and does not mean the CVE is harmless; the API may leave them out instead, score_confidence is NONE, and score_unassessed_reason says why (a rejected record, withdrawn by its numbering authority, is never scored, and the note says NOT SCORED). The note labels each such CVE NOT YET SCORED: report it that way, never as a score of 0. An answer with no score_assessed (an API older than that field) does not say whether the CVE was scored, the note says so, and a 0 there is not a rating either.";
2209
+ const SCORE_ASSESSED_DESCRIPTION = "echelongraph_score, echelongraph_severity and echelongraph_risk are EchelonGraph's score only when score_assessed is true. With score_assessed false the CVE is NOT YET SCORED, not scored 0: any of those three it carries (0, NONE, 0) is a placeholder, not a rating, and does not mean the CVE is harmless; the API may leave them out instead, score_confidence is NONE, and score_unassessed_reason says why (a rejected record, withdrawn by its numbering authority, is never scored, and the note says NOT SCORED). The note labels each such CVE NOT YET SCORED, which is not a score of 0. An answer with no score_assessed (an API older than that field) does not say whether the CVE was scored, the note says so, and a 0 there is not a rating either.";
2190
2210
  // The shared helpers a tool in src/tools/ is handed (#2718), so its file holds its own logic.
2191
2211
  const TOOL_KIT = { api, succeeded, failed, badInput, crashed, checked, okHead, envelopeSchema, annotations: ANNOTATIONS, cveIdArg: CVE_ID_ARG, instant: Instant };
2192
2212
  // One server, built per connection by serveStdio for whichever era the client opens with, and
package/dist/prompts.js CHANGED
@@ -36,14 +36,17 @@ const text = (t) => ({ messages: [{ role: "user", content: { type: "text", text:
36
36
  // Its Patch line (#2817): cve_intel's fixed_version is one value per (CVE, ecosystem, package), the
37
37
  // advisory's last range's (core-backend cve/advisory.go UpsertAffectedPackages), and triage_cve has
38
38
  // no installed version to hold it against: for CVE-2021-44228 production's cve_intel gives
39
- // log4j-core 2.12.2 (2026-10-04, 2.6.3), below an affected 2.14.1. So the line gives it as
40
- // what cve_intel records for the package, never as the fix for every affected branch, and sends
41
- // the reader to get_cve's references tagged Vendor Advisory or Patch for a branch's fix.
39
+ // log4j-core 2.12.2 (2026-10-04, 2.6.3), below an affected 2.14.1. Since #2817 each
40
+ // affected_packages row also carries fixed_branches, every range with its fix (core-backend
41
+ // cve/fixed_branches.go), so the line gives those, range by range, and gives fixed_version only
42
+ // where a row has no fixed_branches, as what cve_intel records for the package, never as the fix
43
+ // for every affected branch, sending the reader to get_cve's references tagged Vendor Advisory or
44
+ // Patch for a branch's fix.
42
45
  export function triageCveText(cve) {
43
46
  return [
44
47
  `Triage ${cve} using the EchelonGraph MCP tools. Call them in this order, each with cve_id ${cve}:`,
45
48
  "1. get_cve: the record. Read score_assessed before echelongraph_score (false: not yet scored, which is not a score of 0); read severity, cvss_v3_score, epss_score and epss_percentile, and the CISA-KEV fields kev_listed, kev_added_date, kev_due_date and kev_ransomware.",
46
- "2. cve_intel: weaknesses (cwes), public exploit code (exploits and exploits_total; verified_status is the label stored with each reference, not a guarantee that it works, and an empty list is not evidence that no exploit exists), affected_packages and fixed_versions.",
49
+ "2. cve_intel: weaknesses (cwes), public exploit code (exploits and exploits_total; verified_status is the label stored with each reference, not a guarantee that it works, and an empty list is not evidence that no exploit exists), affected_packages (each package's affected ranges with their fixes in fixed_branches) and fixed_versions.",
47
50
  "3. vendor_advisories_for_cve: the vendor advisories that name it. Call get_vendor_advisory for one whose remediation you need in full.",
48
51
  "4. epss_history: how its EPSS score changed, one point per recorded change (series_kind change_only): before series_starts_at a missing point means not recorded, not unchanged.",
49
52
  "5. cve_exposure: internet exposure on record. exposure_state says what the count is; exposed_hosts counts distinct ip:port services, not machines; the result is not_assessed with measured_at null, so present any count as undated and on record, and exposure_state not_assessed as not assessed.",
@@ -52,7 +55,7 @@ export function triageCveText(cve) {
52
55
  "- Exploited? CISA-KEV listing and its date, known ransomware use, and public exploit code on record.",
53
56
  "- Likelihood: EPSS score and percentile, and how it has moved.",
54
57
  "- Reachable? exposure_state and the count on record, or not assessed.",
55
- `- Patch: fixed versions and vendor advisories; where the results hold none, write "none on record", which is not a finding that no fix exists. cve_intel keeps one fixed_version per ecosystem and package, its advisory's last range's, which need not be the fix for every affected version of the package: give each as the fixed version cve_intel records for that package, never as the fix for every affected branch or the version every install should move to, and for the fix on a given branch point to get_cve's references tagged Vendor Advisory or Patch, where it has them, and to the vendor advisories.`,
58
+ `- Patch: fixed versions and vendor advisories; where the results hold none, write "none on record", which is not a finding that no fix exists. For each affected package whose cve_intel row carries fixed_branches, give every range with its fix: from introduced ("0" is the first version) up to its fixed, or up to and including last_affected, where no fix is on record for that range; never one range's fix as the fix for every affected branch or the version every install should move to. cve_intel keeps one fixed_version per ecosystem and package, its advisory's last range's as a rule, which need not be the fix for every affected version of the package: where a row has no fixed_branches (null or absent), give fixed_version as the fixed version cve_intel records for that package, never as the fix for every affected branch, and for the fix on a given branch point to get_cve's references tagged Vendor Advisory or Patch, where it has them, and to the vendor advisories.`,
56
59
  "- Deadline: get_cve's kev_due_date when the CVE is KEV-listed (CISA's due date for US federal civilian agencies); otherwise write that the results give no CISA deadline.",
57
60
  "- Decision: patch now, schedule, or monitor, with a one-line reason drawn from the lines above.",
58
61
  ].join("\n");
@@ -90,17 +93,25 @@ export function amIAffectedText(a) {
90
93
  // rebuilds them from the document, from the position check_sbom's note gives, in the order it
91
94
  // gives (check_sbom.ts READ_ORDER).
92
95
  //
93
- // Fixed versions (#2799 review, #2817). cve_intel's fixed_version is one value per (CVE, ecosystem,
96
+ // Fixed versions (#2799 review, #2817, #2830). cve_intel's fixed_version is one value per (CVE, ecosystem,
94
97
  // package), the advisory's last range's (core-backend cve/advisory.go UpsertAffectedPackages), so
95
98
  // it need not be the fix for the installed version's branch: for CVE-2021-44228, production's
96
99
  // cve_intel gives log4j-core 2.12.2 (2026-10-04, 2.6.3), older than an installed 2.14.1, and
97
100
  // check_sbom's matches for 2.14.1 say it "is listed verbatim in the advisory's affected versions",
98
101
  // which names no fix. So a fixed version is given only where it is strictly greater than the
99
102
  // installed version, and otherwise the line says the results give none for the component's branch
100
- // and points to the advisory. The advisory interval in a match's match_reason, "[A, B)" (core-backend
101
- // cve/pkgmatch.go evaluateRegistryRow), is preferred where the text keeps it (its first cut level;
102
- // a Juice Shop SBOM of 50 components is cut past it); B is the interval's fixed bound, or its
103
- // last_affected one, which the text does not tell apart, so B is given as the interval's end.
103
+ // and points to the advisory. The advisory interval in a match's match_reason (core-backend
104
+ // cve/pkgmatch.go renderInterval) is preferred where the text keeps it (its first cut level;
105
+ // a Juice Shop SBOM of 50 components is cut past it). Since #2830 its closing bracket carries the
106
+ // bound's meaning: "[A, B)" is a fixed bound, B the first version not affected, so B is given as
107
+ // the fix; "[A, B] (B is the last affected version, not a fix)" is an OSV last_affected bound,
108
+ // B still affected, so B (or any version at or below it) is never given as a fix; "[A, ∞)" has no
109
+ // end on record; a text clipped before the closing bracket ("[A, B…") gives no fix from it.
110
+ // Through 2.6.5 core-backend rendered a last_affected bound as "[A, B)" too, and the prompt
111
+ // hedged B as "the end of that interval". Since #2817 cve_intel's affected_packages rows carry
112
+ // fixed_branches, every range with its fix, so after the match_reason interval the rule reads the
113
+ // range that holds the installed version (2.14.1: [2.13.0, 2.15.0), so 2.15.0), and falls back to
114
+ // fixed_version only for a row without fixed_branches.
104
115
  // A cut text keeps index, purl, verdict, not_assessed_reason and cve_ids on a row, not its
105
116
  // ecosystem, package or version, so the model reads those from the purl, as core-backend's
106
117
  // matcher does (cve/matchbatch.go purlNameVersion, shared/pkg/purlosv): step 2 lists every type
@@ -113,11 +124,11 @@ export function sbomReviewText(sbom) {
113
124
  "Review the SBOM below for known vulnerabilities, using the EchelonGraph MCP tools.",
114
125
  `1. Call check_sbom with sbom set to the document below, as given. It reads the document's purls and checks up to ${MAX_PURLS} distinct purls per call, sending them in batches of ${MAX_COMPONENTS}; a document with more is refused, not truncated. If it is refused, say why from the result and do not guess at the components. If coverage.not_sent is above 0, the API's rate limit or the call's time budget stopped it early: say how many purls were not sent and coverage.not_sent_reason, and that they are not checked (never clean). After a minute, call check_sbom once more with purls set to the purls not sent, and merge what it returns. data.not_sent_purls lists them; where the note says the first text block cuts not_sent_purls to its first entries, rebuild the list from the document instead, as the note says: the document's distinct purls from the position the note gives on, counted in the order it gives.`,
115
126
  "2. Read each component's ecosystem, package and version from its purl, which every row of data.results in the text carries (the leanest cut of the text leaves out the row's own ecosystem, package and version), as check_sbom maps them. A purl is pkg:type/namespace/name@version?qualifiers, each part percent-decoded (%40 is @), its type and distro qualifier in any case. The version is what follows the last @, up to any ? or #. The ecosystem follows from the type: npm is npm, pypi PyPI, maven Maven, golang Go, cargo crates.io, gem RubyGems, nuget NuGet, composer Packagist, hex Hex, pub Pub, swift SwiftURL, hackage Hackage, cran CRAN, bitnami Bitnami, conan ConanCenter and githubact GitHub Actions. For deb, apk, alpine and rpm it is the release the distro qualifier names, which must be one of these three kinds: distro=debian-N is Debian:N, the major release alone (debian-12 and debian-12.5 are Debian:12); distro=ubuntu-V is Ubuntu:V as given (ubuntu-22.04 is Ubuntu:22.04); distro=alpine-X.Y is Alpine:vX.Y, cut to major.minor (alpine-3.20 and alpine-3.20.10 are Alpine:v3.20; alpine-3 names none). For any other type, and a deb, apk, alpine or rpm purl without such a qualifier, the ecosystem is unknown: check_sbom does not check the component (not_assessed, never clean), and the fix list gives no version for it. The package is namespace:name for maven (pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1 is the package org.apache.logging.log4j:log4j-core in Maven, at version 2.14.1), namespace/name for npm, golang, composer, swift and githubact, and the name alone for every other type (pkg:deb/debian/openssl@3.0.15-1~deb12u1?distro=debian-12 is openssl in Debian:12).",
116
- "3. For each distinct cve_id on a component whose verdict is affected, call get_cve, to read kev_listed, epss_score, score_assessed, echelongraph_score and cvss_v3_score, and cve_intel, to read fixed_version from its affected_packages and fixed_versions rows for the component's ecosystem and package (package_name). If there are more than 30 distinct CVEs, do this for 30 of them and list the rest as not looked up, unranked.",
127
+ "3. For each distinct cve_id on a component whose verdict is affected, call get_cve, to read kev_listed, epss_score, score_assessed, echelongraph_score and cvss_v3_score, and cve_intel, to read fixed_branches and fixed_version from its affected_packages rows, and fixed_version from its fixed_versions rows, for the component's ecosystem and package (package_name). If there are more than 30 distinct CVEs, do this for 30 of them and list the rest as not looked up, unranked.",
117
128
  ENVELOPE_RULES,
118
129
  "Read each component's verdict: not_affected is the one clean verdict; undetermined and not_assessed are not clean, and not_assessed_reason says why (for example distro_release_unknown: a deb, apk or rpm purl without a distro qualifier, which EchelonGraph does not guess).",
119
130
  "Then write a prioritised fix list of the affected components. State the ordering keys, in this order: 1. CISA-KEV listed (kev_listed true) first; 2. then EPSS score, highest first; 3. then echelongraph_score where score_assessed is true, else cvss_v3_score, highest first. A CVE with no value for a key sorts after those with one, and its line says which key was missing.",
120
- "For each line give the component (purl), its cve_ids, the key values used, and for each CVE a fixed version where the results support one, by this rule. Never give a fixed version that is not strictly greater than the component's installed version (the version in its purl), compared in the ecosystem's version order: release numbers part by part, as numbers, not as text (2.9.1 is below 2.12.2, and 2.12.2 is below 2.14.1); where you are not sure of the order (pre-releases, qualifiers and distro revisions have their own rules), say so and do not give it. Prefer the match's match_reason where the text keeps it: \"falls inside the advisory interval [A, B)\" says the advisory's range that holds the installed version ends at B, which match_reason does not say is a fixed version rather than the range's last affected version, so give B as the end of that interval, quoting it (∞: the range has no end on record). Otherwise give cve_intel's fixed_version for the component's ecosystem and package (or the recorded fix a match_reason names, the same value): cve_intel keeps one fixed_version per package, its advisory's last range's, which need not be the fix on the component's branch, so say so beside it. Where no version meets the rule (\"listed verbatim in the advisory's affected versions\" names none), write that the results give no fixed version for the component's branch, which is not a finding that no fix exists, and point to the CVE's advisory for it: get_cve's references tagged Vendor Advisory or Patch, where it has them.",
131
+ "For each line give the component (purl), its cve_ids, the key values used, and for each CVE a fixed version where the results support one, by this rule. Never give a fixed version that is not strictly greater than the component's installed version (the version in its purl), compared in the ecosystem's version order: release numbers part by part, as numbers, not as text (2.9.1 is below 2.12.2, and 2.12.2 is below 2.14.1); where you are not sure of the order (pre-releases, qualifiers and distro revisions have their own rules), say so and do not give it. Prefer the match's match_reason where the text keeps it: \"falls inside the advisory interval [A, B)\", closed by a parenthesis, says B is the advisory's fixed version for the range that holds the installed version, so give B as the fixed version, quoting it (\"[A, ∞)\": the range has no end on record and names no fixed version). \"falls inside the advisory interval [A, B]\", closed by a square bracket, says B is the range's last affected version, still affected, and the range records no fix (core-backend adds \"(B is the last affected version, not a fix)\", which a cut text can clip, so the bracket alone decides): never give B, or any version at or below it, as a fixed version. Where the text is cut before the interval's closing bracket (\"[A, B…\", neither \")\" nor \"]\"), it does not say which bound B is: give no fixed version from that match_reason. Otherwise, where cve_intel's affected_packages row for the component's ecosystem and package carries fixed_branches, give the fixed of the range that holds the installed version: at or above its introduced (\"0\" is the first version) and below its fixed, or at or below its last_affected, in the same version order; a range with last_affected and no fixed has no fix on record, and a version in no range is given no fixed version from them; where fixed_branches is there, fixed_version is not used. Where it is not (null or absent), give cve_intel's fixed_version for the component's ecosystem and package (or the recorded fix a match_reason names, the same value): cve_intel keeps one fixed_version per package, its advisory's last range's, which need not be the fix on the component's branch, so say so beside it. Where no version meets the rule (\"listed verbatim in the advisory's affected versions\" names none), write that the results give no fixed version for the component's branch, which is not a finding that no fix exists, and point to the CVE's advisory for it: get_cve's references tagged Vendor Advisory or Patch, where it has them.",
121
132
  "After the list, give data.summary's counts, and list the undetermined and not_assessed components with their reasons as not checked, never as clean.",
122
133
  "Where the note says that rows of data.results are left out of the first text block, list the components its rows carry, say how many affected components the text leaves out (data.summary.affected, less those listed), and give the undetermined and not_assessed components it leaves out as data.summary's counts, by not_assessed_reason, as not checked, never as clean.",
123
134
  "",
@@ -89,7 +89,7 @@ const DATA = z.looseObject({
89
89
  ecosystem: opt(z.string()),
90
90
  ecosystem_recognised: opt(z.boolean()),
91
91
  package: opt(z.string()),
92
- assessed: opt(z.boolean()).describe("Read first. true: the lookup evaluated this component. false: it did not, and count 0 says nothing about whether this version is affected."),
92
+ assessed: opt(z.boolean()).describe("count depends on it. true: the lookup evaluated this component. false: it did not, and count 0 says nothing about whether this version is affected."),
93
93
  not_assessed_reason: opt(REASON),
94
94
  match_layer: opt(z.string()),
95
95
  cve_ids: opt(z.array(z.string())),
@@ -126,7 +126,7 @@ const COVERAGE = z.strictObject({
126
126
  // Constant strings, so marketing-site lib/mcpToolClaims.test.ts can fold them should it read
127
127
  // this file.
128
128
  export const CHECK_AFFECTED_TITLE = "Am I affected? (product or package at a version)";
129
- export const CHECK_AFFECTED_DESCRIPTION = "Whether a product or package at a given version is affected by known CVEs, from the same matcher as echelongraph.io/am-i-affected. Two lookup paths. The CPE path takes product (the NVD CPE product token, such as openssl or nginx) and version, and returns the CVEs whose NVD CPE match criteria name that product with a version range that includes the version; it matches the token across vendors, so each match names the vendor NVD asserts (cpe_vendor) and whether that vendor was verified (vendor_unknown). The registry path takes ecosystem (npm, PyPI, Maven and other OSV ecosystem names), package and version, and decides each OSV advisory record EchelonGraph holds for that package as affected, not affected or undetermined. Read assessed before count: assessed false means the lookup did not evaluate this component, not_assessed_reason says why (product_not_in_cpe_corpus, package_not_cpe_nameable, candidate_load_pending, candidate_window_truncated, package_not_in_advisory_corpus, no_decidable_advisory), the structured result's state is not_assessed, and a count of 0 there must never be reported as not affected. An advisory whose version range cannot be decided at this version is reported as undetermined (undetermined_count, and up to 50 of them in undetermined), never as safe. The answer also carries match_layer (which path answered), capped (the match list stopped at its cap), candidates_capped (not every candidate CVE was loaded), excluded_count (CPE candidates suppressed by the vendor or platform gate), not_affected_count (advisories decided in your favour) and degraded (the lookup ran out of time). Each match carries cve_id, kev_listed, ransomware, epss_score, effective_score, effective_severity and score_assessed (false: EchelonGraph has not scored the CVE yet, so its echelongraph_score is withheld). Product, version, ecosystem and package travel in request headers, never in the URL. Its structured result carries state (measured only when the answer says assessed true), measured_at (null), method (which matcher answered), coverage (assessed and not_assessed_reason first, then the lookup path and the counts above), freshness (null) and notes, with data equal to the API's JSON." +
129
+ export const CHECK_AFFECTED_DESCRIPTION = "Whether a product or package at a given version is affected by known CVEs, from the same matcher as echelongraph.io/am-i-affected. Two lookup paths. The CPE path takes product (the NVD CPE product token, such as openssl or nginx) and version, and returns the CVEs whose NVD CPE match criteria name that product with a version range that includes the version; it matches the token across vendors, so each match names the vendor NVD asserts (cpe_vendor) and whether that vendor was verified (vendor_unknown). The registry path takes ecosystem (npm, PyPI, Maven and other OSV ecosystem names), package and version, and decides each OSV advisory record EchelonGraph holds for that package as affected, not affected or undetermined. count depends on assessed: assessed false means the lookup did not evaluate this component, not_assessed_reason says why (product_not_in_cpe_corpus, package_not_cpe_nameable, candidate_load_pending, candidate_window_truncated, package_not_in_advisory_corpus, no_decidable_advisory), the structured result's state is not_assessed, and a count of 0 there is not a finding of not affected. An advisory whose version range cannot be decided at this version is reported as undetermined (undetermined_count, and up to 50 of them in undetermined), never as safe. The answer also carries match_layer (which path answered), capped (the match list stopped at its cap), candidates_capped (not every candidate CVE was loaded), excluded_count (CPE candidates suppressed by the vendor or platform gate), not_affected_count (advisories decided in your favour) and degraded (the lookup ran out of time). Each match carries cve_id, kev_listed, ransomware, epss_score, effective_score, effective_severity and score_assessed (false: EchelonGraph has not scored the CVE yet, so its echelongraph_score is withheld). Product, version, ecosystem and package travel in request headers, never in the URL. Its structured result carries state (measured only when the answer says assessed true), measured_at (null), method (which matcher answered), coverage (assessed and not_assessed_reason first, then the lookup path and the counts above), freshness (null) and notes, with data equal to the API's JSON." +
130
130
  " " +
131
131
  TEXT_BUDGET_DESCRIPTION +
132
132
  " Cut, the excluded and undetermined samples keep their first 10 entries, each its cve_id and reason, cve_ids keeps its first 10 (each match carries its cve_id), and each match keeps fewer fields, cve_id, kev_listed, ransomware, epss_score, effective_score and score_assessed at least, so that every match stays in the text.";
@@ -172,7 +172,8 @@ function matchesNote(matches) {
172
172
  // description and match_reason, so openssl 3.0.0 (71 matches) was 156,058 characters of text and
173
173
  // linux_kernel 5.10.0 (200, the API's cap) 364,408. Past DATA_TEXT_BUDGET each match in the first
174
174
  // text block keeps its scores, flags, vendor attribution and match_reason (for a registry match,
175
- // the advisory interval its version falls inside, which names the fixed version) with the
175
+ // the advisory interval its version falls inside: "[A, B)" names the fixed version B, "[A, B]" the
176
+ // last affected one, #2830) with the
176
177
  // description cut to 200 characters; then the same without the description; then only the fields
177
178
  // the description promises; then, for a list at the cap, without cpe_vendor and vendor_unknown;
178
179
  // and last without effective_severity, the band of effective_score. kev_listed, ransomware,
@@ -63,7 +63,8 @@ export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGr
63
63
  // a CVE with its description, so the 50-component Juice Shop document was 112,820 characters of
64
64
  // text, and 2,000 purls about 1,200,000. Past DATA_TEXT_BUDGET each row in the first text block
65
65
  // keeps its verdict, counts and cve_ids, and each match its scores, flags and match_reason (the
66
- // advisory interval the version falls inside, which names the fixed version) without the
66
+ // advisory interval the version falls inside: "[A, B)" names the fixed version B, "[A, B]" the last
67
+ // affected one, #2830) without the
67
68
  // description; then the same without match_reason; then only index, purl, verdict,
68
69
  // not_assessed_reason and cve_ids. When rows must still go, the clean ones (not_affected) leave
69
70
  // first, then the not_assessed ones, both counted in data.summary and the note, so the affected
@@ -24,6 +24,15 @@
24
24
  // EXPLOIT_NUCLEI_ENABLED, EXPLOIT_POCGITHUB_ENABLED; core-backend cmd/server/main.go). The answer
25
25
  // does not say which ran, so no text here says a source is polled: it names the sources the data
26
26
  // model holds, and says coverage depends on which pollers run.
27
+ //
28
+ // Fixed versions (#2817). /enrichment keeps one affected_packages row per (CVE, ecosystem,
29
+ // package), and its fixed_version is one range's fix: the last range's as the advisory was read
30
+ // (core-backend cve/backfill_osv_packages.go summariseAffected). For CVE-2021-44228 production
31
+ // served log4j-core 2.12.2 alone (2026-10-04), the fix for 2.4-2.12.1, below an affected 2.14.1.
32
+ // Since #2817 each row also carries fixed_branches, the fix for every range (core-backend
33
+ // cve/fixed_branches.go, from the version_intervals the matcher evaluates): this tool relays it as
34
+ // sent, its description says how to pick the range for an installed version, and the note counts
35
+ // the rows where fixed_version is not the whole answer.
27
36
  import * as z from "zod";
28
37
  import { ANNOTATIONS, CVE_ID, NOT_A_FINDING, SHOWN_BASE, api, badInput, boolAt, checked, crashed, envelopeSchema, failed, failure, field, numAt, okHead, opt, strAt, succeeded, } from "../index.js";
29
38
  import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
@@ -60,7 +69,8 @@ const API_ROW_LIMIT = { exploits: 10, affected_packages: 50, fixed_versions: 200
60
69
  const TIMELINE_RELAYED = 20;
61
70
  const CVE_INTEL_METHOD = "EchelonGraph's per-CVE enrichment (GET /api/v1/public/cves/:id/enrichment). cwes: CWE classifications stored from NVD, GitHub (GHSA) and CVE.org records, each with the source that gave it, named from the MITRE CWE catalog EchelonGraph embeds. exploits: public exploit references from EchelonGraph's cve_exploits records, whose data model holds Metasploit modules (metasploit), Exploit-DB entries (exploit_db), nuclei templates (nuclei_template), GitHub proof-of-concept repositories (github_poc) and vendor proofs of concept (vendor_poc), filled by a curated seed and by one poller per automated source, each run only where it is enabled, so which sources are current depends on the deployment. affected_packages and fixed_versions: package-ecosystem advisory data, each row naming its source. timeline: EchelonGraph's enrichment history for the CVE, newest first.";
62
71
  const VERIFIED_STATUS_DESCRIPTION = "verified_status is the label stored with each reference: verified for a Metasploit module, for an Exploit-DB entry Exploit-DB marks verified, and for curated seed rows marked so; reported for a public artefact nothing has confirmed works (nuclei templates, GitHub proofs of concept, unverified Exploit-DB entries); unconfirmed where a curated row says so. It is a label from the source, not a guarantee that the exploit works against a given system.";
63
- export const CVE_INTEL_DESCRIPTION = `Weakness, public exploit code, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment. Returns cwes (each cwe_id with its name and source), exploits (each with kind, source_name, source_url, first_seen_at and verified_status; at most 10, verified first) with exploits_total (every reference on record), exploits_capped (true when exploits lists fewer than exploits_total), exploits_by_kind and exploits_by_status, affected_packages (ecosystem, package_name, version_range, fixed_version), fixed_versions (ecosystem, package_name, vulnerable_range, fixed_version) and timeline (the newest enrichment-history rows, with timeline_total). ${VERIFIED_STATUS_DESCRIPTION} An empty exploits list is not evidence that no public exploit exists: it covers only the sources EchelonGraph ingests, and which of them are polled depends on the deployment. A section the API could not read is named in coverage.sections_failed and left out of data, never relayed as an empty list. Vendor advisories, patches, generated summaries, trending signals and historical incidents are not relayed. Pass a CVE ID like CVE-2021-44228. Its structured result carries state (measured), measured_at (null: each row carries its own time), method, coverage (the sections relayed, failed and left out, and per-list counts), freshness (null) and notes, with data, which the first text block holds whole up to 30,000 characters; the result's last text block repeats it without data and without the note's sentences (the text block before it), with which notes ends. ${TEXT_BUDGET_DESCRIPTION}`;
72
+ const FIXED_BRANCHES_DESCRIPTION = 'fixed_branches lists each affected range of the package on record: introduced, and fixed (the range\'s fix) or last_affected (its last affected version, where no fix is on record), each null when absent, with advisory_id (the OSV record that published the range) and source (the loader\'s label for that record), both null on a row loaded before they were stored. Ranges with an advisory_id are in the order their record lists them (records in id order); ranges without one are oldest first. To pick the range for an installed version, compare in the ecosystem\'s version order (release numbers part by part, as numbers: 2.4 is below 2.13.0): the version is in a range when it is at or above introduced ("0" is the first version) and below fixed, or at or below last_affected; that range\'s fixed is the fix for it, and a version in no range is outside every range on record. fixed_branches is null where the package\'s ranges were never loaded, and [] where no version range is on record (the advisory gives none, only commit ranges, or more than are stored), which is not a finding that no fix exists: fixed_version can still name one. fixed_version is one range\'s fix, kept for compatibility (one per package: the range the loader kept, the advisory\'s last as a rule), not the fix for every affected range: for CVE-2021-44228, its advisory gives log4j-core three ranges, and fixed_version 2.12.2 is the fix for the one from 2.4, while the range holding 2.14.1 (from 2.13.0) is fixed in 2.15.0.';
73
+ export const CVE_INTEL_DESCRIPTION = `Weakness, public exploit code, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment. Returns cwes (each cwe_id with its name and source), exploits (each with kind, source_name, source_url, first_seen_at and verified_status; at most 10, verified first) with exploits_total (every reference on record), exploits_capped (true when exploits lists fewer than exploits_total), exploits_by_kind and exploits_by_status, affected_packages (ecosystem, package_name, version_range, fixed_version, fixed_branches), fixed_versions (ecosystem, package_name, vulnerable_range, fixed_version: one row per fixed version, with the vulnerable_range it fixes where the source gives one) and timeline (the newest enrichment-history rows, with timeline_total). ${FIXED_BRANCHES_DESCRIPTION} ${VERIFIED_STATUS_DESCRIPTION} An empty exploits list is not evidence that no public exploit exists: it covers only the sources EchelonGraph ingests, and which of them are polled depends on the deployment. A section the API could not read is named in coverage.sections_failed and left out of data, never relayed as an empty list. Vendor advisories, patches, generated summaries, trending signals and historical incidents are not relayed. Pass a CVE ID like CVE-2021-44228. Its structured result carries state (measured), measured_at (null: each row carries its own time), method, coverage (the sections relayed, failed and left out, and per-list counts), freshness (null) and notes, with data, which the first text block holds whole up to 30,000 characters; the result's last text block repeats it without data and without the note's sentences (the text block before it), with which notes ends. ${TEXT_BUDGET_DESCRIPTION}`;
64
74
  // ── outputSchema ──
65
75
  const Rows = (shape) => z.array(z.looseObject(shape));
66
76
  const CountMap = z.record(z.string(), z.number());
@@ -88,7 +98,18 @@ function schemas() {
88
98
  ecosystem: opt(z.string()),
89
99
  package_name: opt(z.string()),
90
100
  version_range: opt(z.string()),
91
- fixed_version: opt(z.string()),
101
+ fixed_version: opt(z.string()).describe("One range's fix, kept for compatibility; not the fix for every affected range: read fixed_branches."),
102
+ fixed_branches: z
103
+ .array(z.looseObject({
104
+ introduced: opt(z.string()).describe('The range\'s lower bound, inclusive; "0" is the first version.'),
105
+ fixed: opt(z.string()).describe("The range's fix: the first version past it. null where no fix is on record."),
106
+ last_affected: opt(z.string()).describe("The range's last affected version, inclusive, where no fix is on record; never a fix."),
107
+ advisory_id: opt(z.string()).describe("The OSV record that published the range (GHSA-…, PYSEC-…, CVE-…); null on a row loaded before it was stored."),
108
+ source: opt(z.string()).describe("The loader's label for that record (osv_bulk, osv); null on a row loaded before it was stored."),
109
+ }))
110
+ .nullable()
111
+ .optional()
112
+ .describe(FIXED_BRANCHES_DESCRIPTION),
92
113
  dependents_count: opt(z.number()),
93
114
  source: opt(z.string()),
94
115
  }).optional(),
@@ -170,6 +191,50 @@ function listSentence(name, rows) {
170
191
  const cut = rows.length >= limit ? `, the most the API returns per CVE, so there may be more` : "";
171
192
  return `${name} lists ${rows.length} row${rows.length === 1 ? "" : "s"}${cut}.`;
172
193
  }
194
+ // #2817: where fixed_version is not the whole answer. A row with more than one range in
195
+ // fixed_branches has a fix per range; a row with fixed_branches null has only fixed_version on
196
+ // record, and so does a row with fixed_branches [] and a fixed_version (no version range on
197
+ // record: none in the advisory, only commit ranges, or more than core-backend stores); an answer
198
+ // whose rows carry no fixed_branches is an API older than #2817.
199
+ function branchSentences(rows) {
200
+ if (rows.length === 0)
201
+ return [];
202
+ const named = (r, i) => {
203
+ const eco = strAt(r, "ecosystem");
204
+ const pkg = strAt(r, "package_name") ?? `affected_packages[${i}]`;
205
+ return eco ? `${pkg} (${eco})` : pkg;
206
+ };
207
+ const withField = rows.filter((r) => field(r, "fixed_branches") !== undefined);
208
+ if (withField.length === 0) {
209
+ return [
210
+ "No affected_packages row carries fixed_branches (an API older than that field): each row's fixed_version is one range's fix, which need not be the fix for a given installed version.",
211
+ ];
212
+ }
213
+ const out = [];
214
+ const multi = [];
215
+ const unknown = [];
216
+ const noRange = [];
217
+ rows.forEach((r, i) => {
218
+ const fb = field(r, "fixed_branches");
219
+ if (Array.isArray(fb) && fb.length > 1)
220
+ multi.push(named(r, i));
221
+ if (fb === null)
222
+ unknown.push(named(r, i));
223
+ if (Array.isArray(fb) && fb.length === 0 && strAt(r, "fixed_version"))
224
+ noRange.push(named(r, i));
225
+ });
226
+ const few = (xs) => (xs.length <= 3 ? listed(xs) : `${xs.slice(0, 3).join(", ")} and ${xs.length - 3} more`);
227
+ if (multi.length) {
228
+ out.push(`${multi.length} affected_packages row${multi.length === 1 ? " lists" : "s list"} more than one affected range in fixed_branches (${few(multi)}): there, fixed_version is one range's fix, and an installed version's fix is the fixed of the range that holds it.`);
229
+ }
230
+ if (unknown.length) {
231
+ out.push(`${unknown.length} affected_packages row${unknown.length === 1 ? " carries" : "s carry"} fixed_branches null (${few(unknown)}): ${unknown.length === 1 ? "its" : "their"} ranges are not on record, so fixed_version, one range's fix, is all the answer holds, and it need not be the fix for a given installed version.`);
232
+ }
233
+ if (noRange.length) {
234
+ out.push(`${noRange.length} affected_packages row${noRange.length === 1 ? " carries" : "s carry"} fixed_branches [] and a fixed_version (${few(noRange)}): no version range is on record there, which is not a finding that no fix exists, so fixed_version, one range's fix, is all the answer holds, and it need not be the fix for a given installed version.`);
235
+ }
236
+ return out;
237
+ }
173
238
  // ── The tool ──
174
239
  export async function cveIntel(cve_id) {
175
240
  const id = cve_id.trim();
@@ -218,6 +283,8 @@ export async function cveIntel(cve_id) {
218
283
  const xs = rows(k);
219
284
  if (xs)
220
285
  said.push(listSentence(k, xs));
286
+ if (xs && k === "affected_packages")
287
+ said.push(...branchSentences(xs));
221
288
  }
222
289
  const timeline = rows("timeline");
223
290
  const apiTimeline = field(d, "timeline");
@@ -6,7 +6,7 @@ export const EPSS_HISTORY_METHOD = "EchelonGraph's record of FIRST's EPSS scores
6
6
  const CHANGE_ONLY = "series_kind is change_only: each point is a change EchelonGraph recorded, at the time it recorded it, and a day with no point is not a recorded value, so the series must not be read or redrawn as a daily series; no point here is interpolated.";
7
7
  const BEFORE_FIRST = "The value in force before a CVE's first point is not in the series.";
8
8
  const NO_FRESHNESS = "freshness is null: the answer does not carry the time of the EPSS fetcher's last completed run.";
9
- export const EPSS_HISTORY_DESCRIPTION = `How one CVE's EPSS score (FIRST's exploit-prediction probability, 0 to 1, with its percentile) has changed, as EchelonGraph recorded it: points, oldest first, each with at, epss_score and epss_percentile; current, the record's value now (epss_score, epss_percentile, epss_updated_at); series_kind, always change_only; series_starts_at, when EchelonGraph began recording EPSS changes for any CVE; history_rows, points_truncated and latest_point_matches_current. Pass a CVE ID like CVE-2023-44487. The series is change-only: a point is a recorded change, and a day without a point is not a recorded value. Never interpolate it into a daily series. Before series_starts_at nothing was recorded, so a missing point there means not recorded, not unchanged, and the value in force before a CVE's first point is not in the series. latest_point_matches_current false means a change is missing from the series. Its structured result carries state (measured), measured_at (current.epss_updated_at: when EchelonGraph last wrote a changed value, not FIRST's score date), method, coverage (series_kind, series_starts_at, points, points_truncated), freshness (null) and notes, with data equal to the API's JSON; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends. ${TEXT_BUDGET_DESCRIPTION}`;
9
+ export const EPSS_HISTORY_DESCRIPTION = `How one CVE's EPSS score (FIRST's exploit-prediction probability, 0 to 1, with its percentile) has changed, as EchelonGraph recorded it: points, oldest first, each with at, epss_score and epss_percentile; current, the record's value now (epss_score, epss_percentile, epss_updated_at); series_kind, always change_only; series_starts_at, when EchelonGraph began recording EPSS changes for any CVE; history_rows, points_truncated and latest_point_matches_current. Pass a CVE ID like CVE-2023-44487. The series is change-only: a point is a recorded change, and a day without a point is not a recorded value. A daily series interpolated from it holds values EchelonGraph never recorded, so the series is never a daily series. Before series_starts_at nothing was recorded, so a missing point there means not recorded, not unchanged, and the value in force before a CVE's first point is not in the series. latest_point_matches_current false means a change is missing from the series. Its structured result carries state (measured), measured_at (current.epss_updated_at: when EchelonGraph last wrote a changed value, not FIRST's score date), method, coverage (series_kind, series_starts_at, points, points_truncated), freshness (null) and notes, with data equal to the API's JSON; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends. ${TEXT_BUDGET_DESCRIPTION}`;
10
10
  // The API's JSON. points, series_kind and current are required: an answer without them is not
11
11
  // this series, and a series_kind other than change_only would make every note above wrong, so
12
12
  // either is a failure (checked), never relayed.
@@ -36,11 +36,21 @@
36
36
  // CVE-2024-21412, which both vendors did publish advisories for. core-backend now reads those
37
37
  // histories back (vendoradv backfill.go) and serves each vendor's window at
38
38
  // /api/v1/public/vendor-advisories/coverage (coverage.go): advisories held, the earliest and latest
39
- // vendor_published_at among them, and history_backfill. All three tools read it beside their own
40
- // request and relay it in coverage, and the note says when a CVE's ID year begins before a
41
- // vendor's earliest held advisory, or a vendor's history is still being read, so an empty answer
42
- // is never read as "the vendor published none". The window is context, not the answer: when it
43
- // cannot be read (an API older than the route, a failure), the answer is relayed as before, with
39
+ // vendor_published_at among them, held_since and history_backfill. All three tools read it beside
40
+ // their own request and relay it in coverage, and the note says when a CVE's ID year begins before
41
+ // the date a vendor is held from, or a vendor's history is still being read, so an empty answer
42
+ // is never read as "the vendor published none".
43
+ //
44
+ // held_since, not the earliest held advisory, is where a vendor's window begins (#2803). A vendor
45
+ // with no history read (history_backfill not_supported) is held from its poller's first poll on,
46
+ // and Cisco's and Red Hat's first polls read only what the vendor had CHANGED lately: an old
47
+ // advisory the vendor revised since is held, so the earliest held advisory reached back to 2024
48
+ // for Cisco and 2009 for Red Hat while the advisories published beside it are not held, and
49
+ // CVE-2025-20188 read Cisco as fully held for 2025. coverage.go serves held_since as the start of
50
+ // the window EchelonGraph has read the vendor's advisories from; an API older than that field gives none, and a vendor is
51
+ // then taken as held from its earliest advisory only when its history read is complete.
52
+ //
53
+ // The window is context, not the answer: when it cannot be read (an API older than the route, a failure), the answer is relayed as before, with
44
54
  // the windows null and a note saying they are unknown, never failed. The coverage request carries
45
55
  // nothing typed: a fixed path, no query, no search header.
46
56
  //
@@ -113,6 +123,7 @@ function windowsOf(d) {
113
123
  advisories: num(r, "advisories") ?? null,
114
124
  earliest_vendor_published_at: instantOrNull(r, "earliest_vendor_published_at"),
115
125
  latest_vendor_published_at: instantOrNull(r, "latest_vendor_published_at"),
126
+ held_since: instantOrNull(r, "held_since"),
116
127
  history_backfill: str(r, "history_backfill") ?? null,
117
128
  },
118
129
  ];
@@ -120,30 +131,48 @@ function windowsOf(d) {
120
131
  }
121
132
  const day = (instant) => instant.slice(0, 10);
122
133
  const backfilling = (w) => w.history_backfill !== null && BACKFILLING.has(w.history_backfill);
134
+ // #2803: the start of the window a vendor's advisories have been read from: held_since as the API
135
+ // gives it; from an API without it, the earliest held advisory when the vendor's history read is
136
+ // complete (that is the archive's start), else unknown (null).
137
+ const heldFrom = (w) => w.held_since ?? (w.history_backfill === "complete" ? w.earliest_vendor_published_at : null);
138
+ // Whether a vendor's window starts on or before 1 January of a CVE ID's year.
139
+ const heldFromYear = (w, yearStart) => {
140
+ const from = heldFrom(w);
141
+ return from !== null && Date.parse(from) <= yearStart;
142
+ };
123
143
  // What a note says of one window: what is held, and the history read when it is not done.
124
144
  const windowItem = (w, heldOnly) => {
145
+ const from = heldFrom(w);
146
+ const to = w.latest_vendor_published_at ? ` to ${day(w.latest_vendor_published_at)}` : "";
125
147
  const held = w.earliest_vendor_published_at === null
126
148
  ? "none held"
127
- : `held ${heldOnly ? "only " : ""}from ${day(w.earliest_vendor_published_at)}${w.latest_vendor_published_at ? ` to ${day(w.latest_vendor_published_at)}` : ""}`;
149
+ : from === null
150
+ ? `held from ${day(w.earliest_vendor_published_at)}${to}, not known to be without a gap`
151
+ : `held ${heldOnly ? "only " : ""}from ${day(from)}${to}${day(w.earliest_vendor_published_at) < day(from) ? `, and before that only in part, the earliest held dated ${day(w.earliest_vendor_published_at)}` : ""}`;
128
152
  return `${w.vendor} (${held}${backfilling(w) ? `; history_backfill ${w.history_backfill}` : ""})`;
129
153
  };
130
154
  // The sentence for windows that could not be read: no "none here" is a finding about a vendor.
131
155
  const windowsUnknown = (why) => ` The vendors' coverage windows could not be read (${why}), so this answer cannot say from what date each vendor's advisories are held: no advisory here from a vendor is not a finding that it published none.`;
132
156
  // vendor_advisories_for_cve: the vendors with no advisory in the answer that may have published
133
- // one EchelonGraph does not hold: none held at all, the earliest held dated after 1 January of the
134
- // year in the CVE ID, or a history still being read.
157
+ // one EchelonGraph does not hold: none held at all, held (held_since) only from after 1 January of
158
+ // the year in the CVE ID or from a date not known, or a history still being read. Never the
159
+ // earliest held advisory: for a vendor with no history read it can be years before held_since
160
+ // (#2803).
135
161
  function notFullyHeld(windows, rows, year) {
136
162
  const answered = new Set(rows.map((r) => str(r, "vendor")).filter((v) => v !== undefined));
137
163
  const yearStart = Date.UTC(year, 0, 1);
138
- return windows.filter((w) => !answered.has(w.vendor) && (w.earliest_vendor_published_at === null || Date.parse(w.earliest_vendor_published_at) > yearStart || backfilling(w)));
164
+ return windows.filter((w) => !answered.has(w.vendor) && (w.earliest_vendor_published_at === null || !heldFromYear(w, yearStart) || backfilling(w)));
139
165
  }
140
166
  function notFullyHeldNote(cve, year, missing) {
141
167
  if (!missing.length)
142
168
  return "";
143
169
  const k = missing.length;
144
170
  const yearStart = Date.UTC(year, 0, 1);
145
- const items = missing.map((w) => windowItem(w, w.earliest_vendor_published_at !== null && Date.parse(w.earliest_vendor_published_at) > yearStart));
146
- return ` ${cve} is a ${year} CVE ID, and EchelonGraph may not hold every advisory ${plural(k, "1 vendor", `${k} vendors`)} published for it (vendors_not_fully_held): ${items.join("; ")}. An advisory a vendor published before the earliest one EchelonGraph holds is not held, and while history_backfill is in_progress or not_started the vendor's older advisories are still being read, so no advisory here from ${plural(k, "that vendor", "those vendors")} is not a finding that ${plural(k, "it", "they")} published none.`;
171
+ const items = missing.map((w) => {
172
+ const from = heldFrom(w);
173
+ return windowItem(w, from !== null && Date.parse(from) > yearStart);
174
+ });
175
+ return ` ${cve} is a ${year} CVE ID, and EchelonGraph may not hold every advisory ${plural(k, "1 vendor", `${k} vendors`)} published for it (vendors_not_fully_held): ${items.join("; ")}. An advisory a vendor published before the date EchelonGraph holds that vendor from (held_since) may not be held, even when an older one is, and while history_backfill is in_progress or not_started the vendor's older advisories are still being read, so no advisory here from ${plural(k, "that vendor", "those vendors")} is not a finding that ${plural(k, "it", "they")} published none.`;
147
176
  }
148
177
  // search_vendor_advisories and get_vendor_advisory: the vendors whose history is still being read.
149
178
  function backfillingNote(windows) {
@@ -158,7 +187,7 @@ const opt = (t) => t.nullable().optional();
158
187
  const DATE_FIELDS = {
159
188
  vendor_published_at: opt(z.string()).describe("The date the vendor gives for the advisory."),
160
189
  our_first_seen_at: opt(z.string()).describe("When EchelonGraph first recorded the advisory; not the vendor's date."),
161
- withdrawn: opt(z.boolean()).describe("true: the vendor withdrew (rescinded) this advisory; report it as withdrawn."),
190
+ withdrawn: opt(z.boolean()).describe("true: the vendor withdrew (rescinded) this advisory, so it is a withdrawn advisory, not a current one."),
162
191
  };
163
192
  const AdvisoryRow = z.looseObject({
164
193
  advisory_id: opt(z.string()),
@@ -179,8 +208,10 @@ const DATES_DESCRIPTION = "Each advisory carries vendor_published_at (the vendor
179
208
  const ENVELOPE_DESCRIPTION = "Its structured result carries state (measured), measured_at, method, coverage, freshness (null: the answer carries no poll-completion time) and notes, with data equal to the API's JSON; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.";
180
209
  // #2729: what a vendor's window is.
181
210
  const HISTORY_BACKFILL_DESCRIPTION = "history_backfill: complete (the vendor's published history has been read back as far as its source goes), in_progress or not_started (it is still being read, so the vendor's older advisories are not all held yet), or not_supported (EchelonGraph has no history read for that vendor, so what it holds is what the vendor's feed has carried)";
182
- const WINDOW_DESCRIPTION = `vendor, advisories (how many EchelonGraph holds from that vendor), earliest_vendor_published_at and latest_vendor_published_at (the earliest and latest vendor_published_at among them, null when none is held) and ${HISTORY_BACKFILL_DESCRIPTION}`;
183
- const NOT_HELD_DESCRIPTION = "An advisory a vendor published before its earliest_vendor_published_at is not held, so no advisory from a vendor is not a finding that it published none.";
211
+ // #2803: where a vendor's window begins.
212
+ const HELD_SINCE_DESCRIPTION = "held_since (the start of the window EchelonGraph has read that vendor's advisories from, before which an advisory may not be held; null when none is held or the API does not say; for a vendor with no history read it is when its feed was first read from, and can be years after earliest_vendor_published_at, since an older advisory is held when the vendor revised it later, not the ones published beside it)";
213
+ const WINDOW_DESCRIPTION = `vendor, advisories (how many EchelonGraph holds from that vendor), earliest_vendor_published_at and latest_vendor_published_at (the earliest and latest vendor_published_at among them, null when none is held), ${HELD_SINCE_DESCRIPTION} and ${HISTORY_BACKFILL_DESCRIPTION}`;
214
+ const NOT_HELD_DESCRIPTION = "An advisory a vendor published before its held_since may not be held, even when its earliest_vendor_published_at is older, so no advisory from a vendor is not a finding that it published none.";
184
215
  // #2728: how a search matches, and its capped total.
185
216
  const SEARCH_MATCH_DESCRIPTION = "A query of 3 or more characters is matched case-insensitively as a substring of each advisory's title, description, vendor name, vendor_advisory_id, affected_products and cve_ids (search_match substring). A query of 1 or 2 characters matches whole words only (search_match word): it must equal, ignoring case, a whole word (a run of letters and digits) of one of those, so xz finds xz-utils but not xzibit, and a 1- or 2-character query with any other character, such as c#, matches nothing.";
186
217
  const TOTAL_CAPPED_DESCRIPTION = "A search counts at most 1,000 matches: past that, total is 1000 and total_capped is true, which means 1,000 or more (the note writes 1,000+), never exactly 1,000; paging past it still works. Without a query, total is the exact count and total_capped is false.";
@@ -191,8 +222,12 @@ const VendorWindowSchema = z.strictObject({
191
222
  earliest_vendor_published_at: z
192
223
  .string()
193
224
  .nullable()
194
- .describe("The earliest vendor_published_at among them; null when none is held. An advisory this vendor published before it is not held."),
225
+ .describe("The earliest vendor_published_at among them; null when none is held. Not where the window begins: see held_since."),
195
226
  latest_vendor_published_at: z.string().nullable().describe("The latest vendor_published_at among them; null when none is held."),
227
+ held_since: z
228
+ .string()
229
+ .nullable()
230
+ .describe("The start of the window EchelonGraph has read this vendor's advisories from; null when none is held or the API does not say. An advisory this vendor published before it may not be held, even when earliest_vendor_published_at is older."),
196
231
  // The four values the API sends today, and any later one as a string: a new state must not turn
197
232
  // the answer into a failure.
198
233
  history_backfill: z
@@ -233,7 +268,7 @@ export function registerVendorAdvisoryTools(server, kit) {
233
268
  vendors_not_fully_held: z
234
269
  .array(z.string())
235
270
  .nullable()
236
- .describe("The vendors with no advisory in this answer that may have published one EchelonGraph does not hold: none held, the earliest held dated after 1 January of cve_year, or history_backfill in_progress or not_started. No advisory from them is not a finding that they published none. null when the windows could not be read."),
271
+ .describe("The vendors with no advisory in this answer that may have published one EchelonGraph does not hold: none held, held_since after 1 January of cve_year or not known, or history_backfill in_progress or not_started. No advisory from them is not a finding that they published none. null when the windows could not be read."),
237
272
  }),
238
273
  freshness: null,
239
274
  });
@@ -473,14 +508,14 @@ export function registerVendorAdvisoryTools(server, kit) {
473
508
  }
474
509
  server.registerTool("vendor_advisories_for_cve", {
475
510
  title: "Vendor advisories for one CVE",
476
- description: `The vendor-published advisories that name one CVE, newest first, at most 20: for each, vendor, vendor_display_name, vendor_advisory_id, title, severity and cvss_v3_score where the vendor gives them. Covers the vendor feeds EchelonGraph polls, for example Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks and GitHub GHSA; an empty answer means that none of the advisories EchelonGraph holds from those feeds names the CVE, not that no vendor published one (see vendor_windows and vendors_not_fully_held). ${DATES_DESCRIPTION} Pass a CVE ID like CVE-2024-21412. coverage gives returned, cap and at_cap (true: the answer is full, so there may be more); cve_year, the year in the CVE ID; vendor_windows, each vendor's window in what EchelonGraph holds: ${WINDOW_DESCRIPTION}; and vendors_not_fully_held, the vendors with no advisory in the answer of which EchelonGraph holds none, whose earliest held advisory is dated after 1 January of cve_year, or whose history is still being read, which the note names. ${NOT_HELD_DESCRIPTION} vendor_windows and vendors_not_fully_held are null when the windows could not be read, and the note says so. measured_at is null. ${ENVELOPE_DESCRIPTION} ${TEXT_BUDGET_DESCRIPTION}${ROW_CUT_DESCRIPTION}.`,
511
+ description: `The vendor-published advisories that name one CVE, newest first, at most 20: for each, vendor, vendor_display_name, vendor_advisory_id, title, severity and cvss_v3_score where the vendor gives them. Covers the vendor feeds EchelonGraph polls, for example Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks and GitHub GHSA; an empty answer means that none of the advisories EchelonGraph holds from those feeds names the CVE, not that no vendor published one (see vendor_windows and vendors_not_fully_held). ${DATES_DESCRIPTION} Pass a CVE ID like CVE-2024-21412. coverage gives returned, cap and at_cap (true: the answer is full, so there may be more); cve_year, the year in the CVE ID; vendor_windows, each vendor's window in what EchelonGraph holds: ${WINDOW_DESCRIPTION}; and vendors_not_fully_held, the vendors with no advisory in the answer of which EchelonGraph holds none, whose held_since is after 1 January of cve_year or not known, or whose history is still being read, which the note names. ${NOT_HELD_DESCRIPTION} vendor_windows and vendors_not_fully_held are null when the windows could not be read, and the note says so. measured_at is null. ${ENVELOPE_DESCRIPTION} ${TEXT_BUDGET_DESCRIPTION}${ROW_CUT_DESCRIPTION}.`,
477
512
  inputSchema: z.object({ cve_id: z.string().describe("a CVE ID, e.g. CVE-2024-21412") }),
478
513
  outputSchema: FOR_CVE_OUTPUT,
479
514
  annotations: kit.annotations,
480
515
  }, async ({ cve_id }) => kit.checked("vendor_advisories_for_cve", FOR_CVE_OUTPUT, await forCVE(cve_id)));
481
516
  server.registerTool("get_vendor_advisory", {
482
517
  title: "Vendor advisory detail",
483
- description: `One vendor advisory in full, by vendor and the vendor's advisory ID (the vendor and vendor_advisory_id fields of a row from search_vendor_advisories or vendor_advisories_for_cve): title, description, severity, cvss_v3_score, cve_ids and known_cve_ids (those with a record in EchelonGraph's CVE feed), affected_products, remediation, references, vendor_modified_at, and withdrawn_at and withdrawn_reason when the vendor withdrew it. ${DATES_DESCRIPTION} coverage.vendor_window is that vendor's window in what EchelonGraph holds: ${WINDOW_DESCRIPTION}; null when the windows could not be read or carry none for that vendor. measured_at is our_first_seen_at. ${ENVELOPE_DESCRIPTION} ${TEXT_BUDGET_DESCRIPTION}`,
518
+ description: `One vendor advisory in full, by vendor and the vendor's advisory ID (the vendor and vendor_advisory_id of a vendor advisory row): title, description, severity, cvss_v3_score, cve_ids and known_cve_ids (those with a record in EchelonGraph's CVE feed), affected_products, remediation, references, vendor_modified_at, and withdrawn_at and withdrawn_reason when the vendor withdrew it. ${DATES_DESCRIPTION} coverage.vendor_window is that vendor's window in what EchelonGraph holds: ${WINDOW_DESCRIPTION}; null when the windows could not be read or carry none for that vendor. measured_at is our_first_seen_at. ${ENVELOPE_DESCRIPTION} ${TEXT_BUDGET_DESCRIPTION}`,
484
519
  inputSchema: z.object({
485
520
  vendor: z.string().describe("the vendor slug, e.g. microsoft, redhat, github"),
486
521
  advisory_id: z.string().describe("the vendor's advisory ID, e.g. RHSA-2024:1234 or GHSA-xxxx-xxxx-xxxx"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echelongraph-mcp",
3
- "version": "2.6.5",
3
+ "version": "2.6.7",
4
4
  "mcpName": "io.echelongraph/echelongraph-mcp",
5
5
  "description": "MCP server for CVE lookups: EchelonGraph scores, CISA KEV, EPSS, CWE, exploits, version and SBOM checks, vendor advisories, and per-CVE exposure derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan). Keyless.",
6
6
  "type": "module",
package/server.json CHANGED
@@ -17,7 +17,7 @@
17
17
  ]
18
18
  }
19
19
  ],
20
- "version": "2.6.5",
20
+ "version": "2.6.7",
21
21
  "remotes": [
22
22
  {
23
23
  "type": "streamable-http",
@@ -28,7 +28,7 @@
28
28
  {
29
29
  "registryType": "npm",
30
30
  "identifier": "echelongraph-mcp",
31
- "version": "2.6.5",
31
+ "version": "2.6.7",
32
32
  "transport": {
33
33
  "type": "stdio"
34
34
  },