echelongraph-mcp 2.6.6 → 2.6.8
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 +53 -0
- package/README.md +12 -0
- package/dist/index.js +27 -7
- package/dist/tools/check_affected.js +2 -2
- package/dist/tools/epss_history.js +1 -1
- package/dist/tools/vendor_advisories.js +2 -2
- package/package.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,59 @@ 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.8 — 2026-10-05
|
|
10
|
+
|
|
11
|
+
- get_cve and search_cves now say what `patch_available` means, and describe `patch_evidence`
|
|
12
|
+
(#2861). Through 2.6.7 neither tool described `patch_available`, and core-backend set it only
|
|
13
|
+
from Ubuntu and Red Hat patch records, so a `false` read as "no patch": measured in production on
|
|
14
|
+
2026-10-05, 101,725 CVEs read `false` while EchelonGraph held evidence of a fix for them, 48,145
|
|
15
|
+
with a package `fixed_version` and 71,705 with an NVD reference tagged "Patch" (CVE-2017-5651,
|
|
16
|
+
Apache Tomcat, among them, with two package rows carrying a `fixed_version`). core-backend now
|
|
17
|
+
sets `patch_available` true whenever it holds evidence of a fix, and serves `patch_evidence`, the
|
|
18
|
+
list of its sources, strongest first: `vendor_patch` (a vendor patch on record, from Ubuntu or
|
|
19
|
+
Red Hat), `fixed_version` (a package fix version, or the fixed bound of an affected version
|
|
20
|
+
range) and `nvd_patch_reference` (an NVD reference tagged Patch). Both descriptions, and the
|
|
21
|
+
outputSchema descriptions of both fields, now state: `patch_available` is true when EchelonGraph
|
|
22
|
+
holds evidence of a fix; `false` means no fix evidence is on record, which is not a finding that
|
|
23
|
+
no fix exists; once true it is never set back to false, so it can be true beside an empty
|
|
24
|
+
`patch_evidence`; and an answer with no `patch_evidence` (an API older than that field) does not
|
|
25
|
+
name the evidence. Both fields are relayed as the API sends them; a `patch_evidence` label the API
|
|
26
|
+
adds later is relayed, not a failed call.
|
|
27
|
+
- search_cves: a page cut to fit the text keeps `patch_evidence` beside `patch_available` in each
|
|
28
|
+
row at the first cut level.
|
|
29
|
+
|
|
30
|
+
## 2.6.7 — 2026-10-05
|
|
31
|
+
|
|
32
|
+
- Tool descriptions now state facts only: no reporting instructions to the model and no references
|
|
33
|
+
to other tools, for the Anthropic MCP Directory policy ("Tool descriptions contain no
|
|
34
|
+
instructions about model behavior, other tools, or external instruction sources, and no hidden
|
|
35
|
+
or encoded text"). Tool behavior, results and their notes are unchanged. Reworded:
|
|
36
|
+
- get_cve, search_cves: "The note labels each such CVE NOT YET SCORED: report it that way, never
|
|
37
|
+
as a score of 0." now reads "The note labels each such CVE NOT YET SCORED, which is not a score
|
|
38
|
+
of 0."
|
|
39
|
+
- cve_summary: summary.rejected's "report them as withdrawn records, never as vulnerabilities"
|
|
40
|
+
now reads "they are withdrawn records, none of them a vulnerability"; summary.none's "report
|
|
41
|
+
those CVEs as not yet scored, not as CVEs rated None" now reads "and names those CVEs not yet
|
|
42
|
+
scored, not CVEs rated None".
|
|
43
|
+
- get_vendor_advisory: "(the vendor and vendor_advisory_id fields of a row from
|
|
44
|
+
search_vendor_advisories or vendor_advisories_for_cve)" now reads "(the vendor and
|
|
45
|
+
vendor_advisory_id of a vendor advisory row)".
|
|
46
|
+
- exposure_radar: "and cve_exposure says per CVE whether the radar tracks it" now reads "and this
|
|
47
|
+
answer does not say whether the radar tracks that CVE: tracking is answered per CVE".
|
|
48
|
+
- epss_history: "Never interpolate it into a daily series." now reads "A daily series
|
|
49
|
+
interpolated from it holds values EchelonGraph never recorded, so the series is never a daily
|
|
50
|
+
series."
|
|
51
|
+
- check_affected: "Read assessed before count: … a count of 0 there must never be reported as
|
|
52
|
+
not affected" now reads "count depends on assessed: … a count of 0 there is not a finding of
|
|
53
|
+
not affected"; the outputSchema description of assessed, "Read first.", now reads "count
|
|
54
|
+
depends on it.".
|
|
55
|
+
- vendor_advisories_for_cve, get_vendor_advisory, search_vendor_advisories: the outputSchema
|
|
56
|
+
description of withdrawn, "report it as withdrawn", now reads "so it is a withdrawn advisory,
|
|
57
|
+
not a current one".
|
|
58
|
+
- test/description-policy.test.mjs lists the tools over stdio and fails if any title, description
|
|
59
|
+
or input/output schema description names another tool, matches a model-directed imperative
|
|
60
|
+
pattern, or carries zero-width, bidi or control characters or a base64-looking run.
|
|
61
|
+
|
|
9
62
|
## 2.6.6 — 2026-10-05
|
|
10
63
|
|
|
11
64
|
- Correction to 2.6.5 (#2775). Its entry says: "On the hosted endpoint it is the client going away
|
package/README.md
CHANGED
|
@@ -317,6 +317,18 @@ Both tools relay the API's JSON as it was sent, and the note says what the score
|
|
|
317
317
|
| `score_assessed: false` | **NOT YET SCORED**, tagged "(score_assessed: false)": report the CVE as not yet scored, never as a zero or low score. Each of `echelongraph_score`, `echelongraph_severity` and `echelongraph_risk` it carries is named as a placeholder. A rejected record is **NOT SCORED** instead. |
|
|
318
318
|
| no `score_assessed` field | The answer does not say whether the CVE was scored (an API older than the field), and a zero `echelongraph_score` in it is not a rating. |
|
|
319
319
|
|
|
320
|
+
### What `patch_available` says
|
|
321
|
+
|
|
322
|
+
`patch_available` is `true` when EchelonGraph holds evidence of a fix for the CVE, and
|
|
323
|
+
`patch_evidence` lists the sources of that evidence, strongest first: `vendor_patch` (a vendor
|
|
324
|
+
patch on record, from Ubuntu or Red Hat), `fixed_version` (a fixed version recorded for an
|
|
325
|
+
affected package, or the fixed bound of an affected version range) and `nvd_patch_reference` (an
|
|
326
|
+
NVD reference tagged Patch). `false` means no fix evidence is on record, which is **not** a
|
|
327
|
+
finding that no fix exists. Once `true`, `patch_available` is not set back to `false`, so it can be
|
|
328
|
+
`true` beside an empty `patch_evidence`. An answer with no `patch_evidence` (an API older than that
|
|
329
|
+
field) does not name the evidence, and a `false` there is not a finding that no fix exists either.
|
|
330
|
+
Both tools relay the two fields as the API sends them.
|
|
331
|
+
|
|
320
332
|
### How `cve_exposure` counts
|
|
321
333
|
|
|
322
334
|
Every 12 hours, when Shodan query credits allow, the KEV-exposure radar runs one Shodan query
|
package/dist/index.js
CHANGED
|
@@ -652,7 +652,7 @@ const SEARCH_CVES_TEXT = {
|
|
|
652
652
|
rows: "cves",
|
|
653
653
|
levels: [
|
|
654
654
|
{
|
|
655
|
-
keep: ["cve_id", "description", "severity", "cvss_v3_score", "echelongraph_score", "echelongraph_severity", "echelongraph_risk", "score_assessed", "score_unassessed_reason", "epss_score", "kev_listed", "kev_ransomware", "published", "vuln_status", "patch_available", "exploit_poc_available"],
|
|
655
|
+
keep: ["cve_id", "description", "severity", "cvss_v3_score", "echelongraph_score", "echelongraph_severity", "echelongraph_risk", "score_assessed", "score_unassessed_reason", "epss_score", "kev_listed", "kev_ransomware", "published", "vuln_status", "patch_available", "patch_evidence", "exploit_poc_available"],
|
|
656
656
|
clip: 200,
|
|
657
657
|
},
|
|
658
658
|
{ keep: ["cve_id", "description", "severity", "cvss_v3_score", "echelongraph_score", "score_assessed", "score_unassessed_reason", "epss_score", "kev_listed", "published"], clip: 100 },
|
|
@@ -1796,7 +1796,7 @@ function readMCPServers(src) {
|
|
|
1796
1796
|
return { data, sentences };
|
|
1797
1797
|
}
|
|
1798
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. ` +
|
|
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
|
|
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. ` +
|
|
1800
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. ` +
|
|
1801
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. ` +
|
|
1802
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. ` +
|
|
@@ -1946,6 +1946,23 @@ export const opt = (t) => t.nullable().optional();
|
|
|
1946
1946
|
// tool-claims check folds only constant strings).
|
|
1947
1947
|
const CPE_MATCH_DESCRIPTION = "The record's CPE match criteria as one flat list, every configuration's cpeMatch entries together: the AND/OR operator and negate of each configuration and node, versionStartExcluding and matchCriteriaId are not in it, so a CPE that is only a platform a product runs on reads as one more entry.";
|
|
1948
1948
|
const CPE_CONFIGURATIONS_DESCRIPTION = "NVD's configurations array as NVD sent it: each configuration and node with its operator (AND, OR) and negate, and each cpeMatch with criteria, vulnerable, versionStartIncluding, versionStartExcluding, versionEndIncluding, versionEndExcluding and matchCriteriaId. A cpeMatch with vulnerable false inside an AND configuration is the platform the vulnerable product runs on, not an affected product. Absent: EchelonGraph holds no stored NVD configuration for the CVE, which is not a finding that no product is affected.";
|
|
1949
|
+
// #2861: what patch_available and patch_evidence are, as core-backend serves them since 2d8bb9ce
|
|
1950
|
+
// (core-backend/internal/cve/patch_evidence.go states the rule). Through that commit
|
|
1951
|
+
// patch_available was EXISTS(a cve_patches row), fed only by Ubuntu and Red Hat, so 101,725 CVEs
|
|
1952
|
+
// read false while EchelonGraph held a package fixed_version or an NVD Patch-tagged reference for
|
|
1953
|
+
// them (CVE-2017-5651 among them). patch_available is now true exactly when the evidence set is
|
|
1954
|
+
// non-empty, and only ever set true. So false is "no fix evidence on record", never "no fix
|
|
1955
|
+
// exists", and an answer without patch_evidence (an API older than it) says nothing about why.
|
|
1956
|
+
// One constant string each (the site's tool-claims check folds only constant strings).
|
|
1957
|
+
const PATCH_EVIDENCE_LABELS = ["vendor_patch", "fixed_version", "nvd_patch_reference"];
|
|
1958
|
+
const PATCH_EVIDENCE_SOURCES = "vendor_patch (a vendor patch on record, from Ubuntu or Red Hat), fixed_version (a fixed version recorded for an affected package, or the fixed bound of an affected version range) and nvd_patch_reference (an NVD reference tagged Patch)";
|
|
1959
|
+
const PATCH_AVAILABLE_DESCRIPTION = "patch_available is true when EchelonGraph holds evidence of a fix for the CVE, and patch_evidence names the sources of that evidence, strongest first: " +
|
|
1960
|
+
PATCH_EVIDENCE_SOURCES +
|
|
1961
|
+
". patch_available false means no fix evidence is on record, which is not a finding that no fix exists. patch_evidence lists the evidence found when EchelonGraph last computed it, and is empty where none was found; patch_available, once true, is never set back to false, so it can be true beside an empty patch_evidence. An answer with no patch_evidence (an API older than that field) does not name the evidence, and a false there is not a finding that no fix exists either.";
|
|
1962
|
+
const PATCH_AVAILABLE_FIELD = "true: EchelonGraph holds evidence of a fix for the CVE, its sources named in patch_evidence. false: no fix evidence is on record, which is not a finding that no fix exists. Once true it is never set back to false.";
|
|
1963
|
+
const PATCH_EVIDENCE_FIELD = "The sources of the fix evidence EchelonGraph holds for the CVE, strongest first: " +
|
|
1964
|
+
PATCH_EVIDENCE_SOURCES +
|
|
1965
|
+
". Empty where none was found when EchelonGraph last computed it. Absent (an API older than the field): the answer does not name the evidence.";
|
|
1949
1966
|
const CVERecord = z.looseObject({
|
|
1950
1967
|
cve_id: opt(z.string()),
|
|
1951
1968
|
description: opt(z.string()),
|
|
@@ -1966,6 +1983,9 @@ const CVERecord = z.looseObject({
|
|
|
1966
1983
|
kev_ransomware: opt(z.boolean()),
|
|
1967
1984
|
kev_added_date: opt(z.string()),
|
|
1968
1985
|
ghsa_id: opt(z.string()),
|
|
1986
|
+
// #2861: fix evidence. A label the API adds later is admitted, never a failed call.
|
|
1987
|
+
patch_available: opt(z.boolean()).describe(PATCH_AVAILABLE_FIELD),
|
|
1988
|
+
patch_evidence: opt(z.array(z.union([z.enum(PATCH_EVIDENCE_LABELS), z.string()]))).describe(PATCH_EVIDENCE_FIELD),
|
|
1969
1989
|
references: z.unknown().optional(),
|
|
1970
1990
|
// #2720: NVD's configurations, losslessly, beside the flat list (see cpeConfigurationsNote).
|
|
1971
1991
|
cpe_match: z.unknown().optional().describe(CPE_MATCH_DESCRIPTION),
|
|
@@ -2196,17 +2216,17 @@ const FEED_ENVELOPE = "Its structured result carries state (measured), measured_
|
|
|
2196
2216
|
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.";
|
|
2197
2217
|
// #2610: what cve_summary says summary.none is (summaryNoneNote), one constant string for the same
|
|
2198
2218
|
// reason as SCORE_ASSESSED_DESCRIPTION below.
|
|
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
|
|
2219
|
+
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.";
|
|
2200
2220
|
// #2641: what cve_summary says the NVD histogram and summary.rejected are (summaryNVDNote,
|
|
2201
2221
|
// summaryRejectedNote), constant strings for the same reason. Neither uses the words "any", "not",
|
|
2202
2222
|
// "rating", "scored", "source" or "yet": the site's #2610 control (marketing-site
|
|
2203
2223
|
// lib/mcpToolClaims.test.ts) holds that exactly those words of the /pulse/mcp cve_summary row are
|
|
2204
2224
|
// backed by SUMMARY_NONE_DESCRIPTION alone, and fails if another sentence backs one.
|
|
2205
2225
|
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.";
|
|
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:
|
|
2226
|
+
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.";
|
|
2207
2227
|
// #2535: what search_cves and get_cve say about score_assessed, one constant string, since the
|
|
2208
2228
|
// site's tool-claims check (marketing-site lib/mcpToolClaims.test.ts) folds only constant strings.
|
|
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
|
|
2229
|
+
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.";
|
|
2210
2230
|
// The shared helpers a tool in src/tools/ is handed (#2718), so its file holds its own logic.
|
|
2211
2231
|
const TOOL_KIT = { api, succeeded, failed, badInput, crashed, checked, okHead, envelopeSchema, annotations: ANNOTATIONS, cveIdArg: CVE_ID_ARG, instant: Instant };
|
|
2212
2232
|
// One server, built per connection by serveStdio for whichever era the client opens with, and
|
|
@@ -2222,7 +2242,7 @@ export function createServer() {
|
|
|
2222
2242
|
}, async () => checked("cve_summary", CVE_SUMMARY_OUTPUT, await cveSummary()));
|
|
2223
2243
|
server.registerTool("search_cves", {
|
|
2224
2244
|
title: "Search CVEs",
|
|
2225
|
-
description: `Search/list CVEs from EchelonGraph's CVE feed (NVD + MITRE-CNA pre-NVD + CISA-KEV + EPSS + GitHub GHSA, each polled on a schedule). Filter by severity, minimum CVSS, free text, and sort; page with limit and offset. Returns cves, each with cve_id, severity, cvss_v3_score, echelongraph_score and score_assessed (whether EchelonGraph has scored it), epss_score and kev_listed where the record has them, and the list's total, total_counted (false: the matches were not counted, so total is not a count), total_is_lower_bound (true: at least total), search_relaxed (true: a phrase was relaxed to all of its words), limit and offset. ${SCORE_ASSESSED_DESCRIPTION} ${FEED_ENVELOPE} coverage repeats total, total_counted, total_is_lower_bound, search_relaxed, limit and offset, and gives returned, the rows in this page. ${TEXT_BUDGET_DESCRIPTION} Cut, each row keeps at least the fields named above and the first 200 characters of its description (100 on a page too long for that), or rows are left out and the note gives the offset to call next
|
|
2245
|
+
description: `Search/list CVEs from EchelonGraph's CVE feed (NVD + MITRE-CNA pre-NVD + CISA-KEV + EPSS + GitHub GHSA, each polled on a schedule). Filter by severity, minimum CVSS, free text, and sort; page with limit and offset. Returns cves, each with cve_id, severity, cvss_v3_score, echelongraph_score and score_assessed (whether EchelonGraph has scored it), epss_score and kev_listed where the record has them, and the list's total, total_counted (false: the matches were not counted, so total is not a count), total_is_lower_bound (true: at least total), search_relaxed (true: a phrase was relaxed to all of its words), limit and offset. ${SCORE_ASSESSED_DESCRIPTION} ${FEED_ENVELOPE} coverage repeats total, total_counted, total_is_lower_bound, search_relaxed, limit and offset, and gives returned, the rows in this page. ${TEXT_BUDGET_DESCRIPTION} Cut, each row keeps at least the fields named above and the first 200 characters of its description (100 on a page too long for that), or rows are left out and the note gives the offset to call next. ${PATCH_AVAILABLE_DESCRIPTION}`,
|
|
2226
2246
|
inputSchema: z.object({
|
|
2227
2247
|
search: z.string().optional().describe("free-text search (product, vendor, or keyword, e.g. 'tomcat')"),
|
|
2228
2248
|
severity: z.enum(["CRITICAL", "HIGH", "MEDIUM", "LOW"]).optional().describe("filter to one severity"),
|
|
@@ -2237,7 +2257,7 @@ export function createServer() {
|
|
|
2237
2257
|
}, async (a) => checked("search_cves", SEARCH_CVES_OUTPUT, await searchCVEs(a)));
|
|
2238
2258
|
server.registerTool("get_cve", {
|
|
2239
2259
|
title: "CVE detail",
|
|
2240
|
-
description: `One CVE's record: description, severity, cvss_v3_score, cvss_v4_score and cvss_v4_severity (when the record has a CVSS v4 score), echelongraph_score and echelongraph_severity with score_confidence (EchelonGraph's multi-source score) and score_assessed (whether EchelonGraph has scored it), epss_score and epss_percentile, CISA-KEV status (kev_listed, kev_added_date, and kev_ransomware for known ransomware-campaign use), ghsa_id (GitHub GHSA), references, cpe_match (the CPE criteria as one flat list) and cpe_configurations (NVD's configurations as NVD sent them, with each AND/OR operator, negate, versionStartExcluding and matchCriteriaId; absent where EchelonGraph has stored none, which is not a finding that no product is affected), published, modified and updated_at; each field only where the record has it. Pass a CVE ID like CVE-2023-44487. ${SCORE_ASSESSED_DESCRIPTION} ${FEED_ENVELOPE} ${TEXT_BUDGET_DESCRIPTION} Cut, each list in the record keeps its first entries, and the note names each list cut with its full length.`,
|
|
2260
|
+
description: `One CVE's record: description, severity, cvss_v3_score, cvss_v4_score and cvss_v4_severity (when the record has a CVSS v4 score), echelongraph_score and echelongraph_severity with score_confidence (EchelonGraph's multi-source score) and score_assessed (whether EchelonGraph has scored it), epss_score and epss_percentile, CISA-KEV status (kev_listed, kev_added_date, and kev_ransomware for known ransomware-campaign use), ghsa_id (GitHub GHSA), patch_available and patch_evidence (whether EchelonGraph holds evidence of a fix, and its sources), references, cpe_match (the CPE criteria as one flat list) and cpe_configurations (NVD's configurations as NVD sent them, with each AND/OR operator, negate, versionStartExcluding and matchCriteriaId; absent where EchelonGraph has stored none, which is not a finding that no product is affected), published, modified and updated_at; each field only where the record has it. Pass a CVE ID like CVE-2023-44487. ${SCORE_ASSESSED_DESCRIPTION} ${PATCH_AVAILABLE_DESCRIPTION} ${FEED_ENVELOPE} ${TEXT_BUDGET_DESCRIPTION} Cut, each list in the record keeps its first entries, and the note names each list cut with its full length.`,
|
|
2241
2261
|
inputSchema: z.object({ cve_id: CVE_ID_ARG }),
|
|
2242
2262
|
outputSchema: GET_CVE_OUTPUT,
|
|
2243
2263
|
annotations: ANNOTATIONS,
|
|
@@ -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("
|
|
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.
|
|
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.";
|
|
@@ -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.
|
|
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.
|
|
@@ -187,7 +187,7 @@ const opt = (t) => t.nullable().optional();
|
|
|
187
187
|
const DATE_FIELDS = {
|
|
188
188
|
vendor_published_at: opt(z.string()).describe("The date the vendor gives for the advisory."),
|
|
189
189
|
our_first_seen_at: opt(z.string()).describe("When EchelonGraph first recorded the advisory; not the vendor's date."),
|
|
190
|
-
withdrawn: opt(z.boolean()).describe("true: the vendor withdrew (rescinded) this advisory
|
|
190
|
+
withdrawn: opt(z.boolean()).describe("true: the vendor withdrew (rescinded) this advisory, so it is a withdrawn advisory, not a current one."),
|
|
191
191
|
};
|
|
192
192
|
const AdvisoryRow = z.looseObject({
|
|
193
193
|
advisory_id: opt(z.string()),
|
|
@@ -515,7 +515,7 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
515
515
|
}, async ({ cve_id }) => kit.checked("vendor_advisories_for_cve", FOR_CVE_OUTPUT, await forCVE(cve_id)));
|
|
516
516
|
server.registerTool("get_vendor_advisory", {
|
|
517
517
|
title: "Vendor advisory detail",
|
|
518
|
-
description: `One vendor advisory in full, by vendor and the vendor's advisory ID (the vendor and vendor_advisory_id
|
|
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}`,
|
|
519
519
|
inputSchema: z.object({
|
|
520
520
|
vendor: z.string().describe("the vendor slug, e.g. microsoft, redhat, github"),
|
|
521
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.
|
|
3
|
+
"version": "2.6.8",
|
|
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.
|
|
20
|
+
"version": "2.6.8",
|
|
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.
|
|
31
|
+
"version": "2.6.8",
|
|
32
32
|
"transport": {
|
|
33
33
|
"type": "stdio"
|
|
34
34
|
},
|