echelongraph-mcp 2.3.0 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -2
- package/dist/index.js +75 -10
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -18,14 +18,34 @@ API call a tool needs to answer.
|
|
|
18
18
|
| Tool | What it does |
|
|
19
19
|
|---|---|
|
|
20
20
|
| `cve_summary` | Counts of active CVEs by severity, and when the feed was last updated. |
|
|
21
|
-
| `search_cves` | Search/filter CVEs (severity, min CVSS, text, sort) with EchelonGraph scores. |
|
|
22
|
-
| `get_cve` | Full record for one CVE: CVSS v3 and (when scored) v4, the EchelonGraph score and its confidence, EPSS, CISA-KEV status and known ransomware use, the GitHub GHSA id, references, and its published, modified and `updated_at` times. |
|
|
21
|
+
| `search_cves` | Search/filter CVEs (severity, min CVSS, text, sort) with EchelonGraph scores and `score_assessed`; the note names each row not yet scored. |
|
|
22
|
+
| `get_cve` | Full record for one CVE: CVSS v3 and (when scored) v4, the EchelonGraph score and its confidence, whether EchelonGraph has scored it (`score_assessed`), EPSS, CISA-KEV status and known ransomware use, the GitHub GHSA id, references, and its published, modified and `updated_at` times. |
|
|
23
23
|
| `cve_exposure` | Internet-exposure footprint for a CVE: exposed service count (distinct ip:port, the `exposed_hosts` field) + country/product breakdown, from the KEV-exposure radar. |
|
|
24
24
|
| `exposure_radar` | Aggregate totals across the exposure radars: services running CISA-KEV CVEs; unauthenticated data stores and observability UIs, found through Shodan (LeakIX when Shodan query credits run low) and then confirmed by EchelonGraph's own identified check, which is not a pure read (on Redis it names its client; on ClickHouse its query lands in the server's query log); leaked credentials; shadow AI; and MCP servers found in EchelonGraph's own Certificate Transparency feed, by RFC 9728 verdict, protocol era and transport. Every number is labelled by what it counts, and a field the tool cannot label is left out and named. |
|
|
25
25
|
|
|
26
26
|
Every figure is as fresh as the schedule that refreshes it. The CVE feed is polled from its
|
|
27
27
|
sources on a schedule; each radar refreshes on its own.
|
|
28
28
|
|
|
29
|
+
### How `get_cve` and `search_cves` read the EchelonGraph score
|
|
30
|
+
|
|
31
|
+
`echelongraph_score` (0 to 10), its band `echelongraph_severity` and the risk priority
|
|
32
|
+
`echelongraph_risk` (0 to 100) are EchelonGraph's score only when the record's
|
|
33
|
+
`score_assessed` is `true`. A CVE EchelonGraph has not scored carries `score_assessed: false`,
|
|
34
|
+
and it is **not yet scored**, not scored 0. The API leaves those three fields out on it, or (an
|
|
35
|
+
API before that change) sends `0`, `NONE` and `0` as placeholders, which are not a rating and do
|
|
36
|
+
not mean the CVE is harmless. Its `score_confidence` is `NONE`, and `score_unassessed_reason`
|
|
37
|
+
says why: no source has yet published severity data EchelonGraph can score, or the record was
|
|
38
|
+
rejected (withdrawn) by its numbering authority, and a rejected record is never scored.
|
|
39
|
+
|
|
40
|
+
Both tools relay the API's JSON as it was sent, and the note says what the score is, for
|
|
41
|
+
`get_cve` per CVE and for `search_cves` per row, naming each CVE it is about:
|
|
42
|
+
|
|
43
|
+
| API answer | What the note says |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `score_assessed: true` | Nothing more: the score is a score. |
|
|
46
|
+
| `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. |
|
|
47
|
+
| 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. |
|
|
48
|
+
|
|
29
49
|
### How `cve_exposure` counts
|
|
30
50
|
|
|
31
51
|
Every 12 hours, when Shodan query credits allow, the KEV-exposure radar runs one Shodan query
|
package/dist/index.js
CHANGED
|
@@ -332,6 +332,60 @@ const realInstant = (s) => s !== undefined && Date.parse(s) > 0;
|
|
|
332
332
|
// Stats, in memory), and is relayed in data but not presented as the feed's freshness.
|
|
333
333
|
const CVE_FEED_METHOD = "EchelonGraph's CVE Pulse feed: CVE records compiled from NVD, MITRE-CNA pre-NVD records, CISA-KEV, EPSS and GitHub GHSA, each polled from its source on a schedule.";
|
|
334
334
|
const NO_FEED_FRESHNESS = "freshness is null: the CVE feed's answer carries no time at which its pollers last completed a poll for the feed as a whole.";
|
|
335
|
+
function scoreStateOf(rec) {
|
|
336
|
+
const v = boolAt(rec, "score_assessed");
|
|
337
|
+
return v === true ? "assessed" : v === false ? "not_assessed" : "unstated";
|
|
338
|
+
}
|
|
339
|
+
const scoreRejected = (rec) => strAt(rec, "score_unassessed_reason") === "rejected";
|
|
340
|
+
// The fields that hold a placeholder, not a score, on a CVE that is not scored.
|
|
341
|
+
const PLACEHOLDER_FIELDS = ["echelongraph_score", "echelongraph_severity", "echelongraph_risk"];
|
|
342
|
+
// No sentence below writes a digit: a note that says "0" beside "score" is the defect itself.
|
|
343
|
+
const UNSTATED_SCORE = "echelongraph_score is EchelonGraph's score only when score_assessed is true, so a zero echelongraph_score there is not a rating: it can be the placeholder a CVE not yet scored carries.";
|
|
344
|
+
// get_cve's sentences about the one CVE's score, each starting with a space; none when the
|
|
345
|
+
// answer says the CVE is scored.
|
|
346
|
+
function cveScoreNote(cve, rec) {
|
|
347
|
+
const state = scoreStateOf(rec);
|
|
348
|
+
if (state === "assessed")
|
|
349
|
+
return "";
|
|
350
|
+
if (state === "unstated") {
|
|
351
|
+
return ` The answer does not say whether EchelonGraph has scored ${cve}: it carries no score_assessed (an API older than that field sends none). ${UNSTATED_SCORE}`;
|
|
352
|
+
}
|
|
353
|
+
const rejected = scoreRejected(rec);
|
|
354
|
+
const reportAs = rejected ? "not scored" : "not yet scored";
|
|
355
|
+
const head = rejected
|
|
356
|
+
? ` (score_assessed: false) NOT SCORED: the record of ${cve} was rejected (withdrawn) by its numbering authority (score_unassessed_reason rejected), and EchelonGraph does not score a withdrawn record, so the record holds no EchelonGraph score for it.`
|
|
357
|
+
: ` (score_assessed: false) NOT YET SCORED: EchelonGraph has not yet scored ${cve}, so the record holds no EchelonGraph score for it, and the absence of one does not mean ${cve} is harmless.`;
|
|
358
|
+
const why = strAt(rec, "score_unassessed_reason") === "no_signal" ? " score_unassessed_reason is no_signal: no source has yet published severity data EchelonGraph can score." : "";
|
|
359
|
+
const carried = PLACEHOLDER_FIELDS.filter((k) => field(rec, k) !== undefined && field(rec, k) !== null);
|
|
360
|
+
const placeholders = carried.length
|
|
361
|
+
? ` ${listed(carried)} ${carried.length === 1 ? "is a placeholder" : "are placeholders"}, not a score: report ${cve} as ${reportAs}, and do not repeat ${carried.length === 1 ? "that value" : "those values"} as its score.`
|
|
362
|
+
: ` Report ${cve} as ${reportAs}, not as a zero or low score.`;
|
|
363
|
+
return `${head}${why}${placeholders}`;
|
|
364
|
+
}
|
|
365
|
+
// search_cves' sentences about its rows' scores, naming each row a sentence is about, each
|
|
366
|
+
// starting with a space; none when every row says it is scored.
|
|
367
|
+
function rowsScoreNote(d) {
|
|
368
|
+
const rows = field(d, "cves");
|
|
369
|
+
if (!Array.isArray(rows) || rows.length === 0)
|
|
370
|
+
return "";
|
|
371
|
+
const n = rows.length;
|
|
372
|
+
const ids = (pred) => rows.flatMap((r, i) => (pred(r) ? [strAt(r, "cve_id") ?? `cves[${i}]`] : []));
|
|
373
|
+
const notYet = ids((r) => scoreStateOf(r) === "not_assessed" && !scoreRejected(r));
|
|
374
|
+
const rejected = ids((r) => scoreStateOf(r) === "not_assessed" && scoreRejected(r));
|
|
375
|
+
const unstated = ids((r) => scoreStateOf(r) === "unstated");
|
|
376
|
+
const of = (k) => (k < n ? `${k} of the ${n} CVEs in this page` : n === 1 ? "the 1 CVE in this page" : `all ${n} CVEs in this page`);
|
|
377
|
+
const out = [];
|
|
378
|
+
if (notYet.length) {
|
|
379
|
+
out.push(`(score_assessed: false) NOT YET SCORED: ${of(notYet.length)} ${be(notYet.length)} not yet scored by EchelonGraph: ${listed(notYet)}. For each, echelongraph_score, echelongraph_severity and echelongraph_risk, where its row carries them, are placeholders, not a score, and the absence of a score does not mean the CVE is harmless: report each as not yet scored, not as a zero or low score.`);
|
|
380
|
+
}
|
|
381
|
+
if (rejected.length) {
|
|
382
|
+
out.push(`(score_assessed: false) NOT SCORED: ${of(rejected.length)} ${rejected.length === 1 ? "has a record" : "have records"} rejected (withdrawn) by the numbering authority (score_unassessed_reason rejected), which EchelonGraph does not score: ${listed(rejected)}. For each, echelongraph_score, echelongraph_severity and echelongraph_risk, where its row carries them, are placeholders, not a score: report each as not scored.`);
|
|
383
|
+
}
|
|
384
|
+
if (unstated.length) {
|
|
385
|
+
out.push(`The answer does not say whether EchelonGraph has scored ${of(unstated.length)}: ${listed(unstated)} (no score_assessed in ${unstated.length === 1 ? "its row" : "their rows"}; an API older than that field sends none). ${UNSTATED_SCORE}`);
|
|
386
|
+
}
|
|
387
|
+
return out.map((s) => ` ${s}`).join("");
|
|
388
|
+
}
|
|
335
389
|
async function cveSummary() {
|
|
336
390
|
const tool = "cve_summary";
|
|
337
391
|
try {
|
|
@@ -411,21 +465,24 @@ async function searchCVEs(a) {
|
|
|
411
465
|
const relaxedNote = relaxed === true
|
|
412
466
|
? " The API relaxed the search phrase to all of its words (search_relaxed is true), so these rows are a superset of the rows that match the phrase itself."
|
|
413
467
|
: "";
|
|
468
|
+
// Then what each row's score is (#2535): empty when every row says it is scored, or there
|
|
469
|
+
// are no rows.
|
|
470
|
+
const tail = `${relaxedNote}${rowsScoreNote(r.data)}`;
|
|
414
471
|
// A total the API did not count is not a count: the page's own rows are all that is known.
|
|
415
472
|
if (counted === false) {
|
|
416
473
|
const empty = shown === 0 && (offset ?? 0) === 0;
|
|
417
474
|
return succeeded(r.data, empty
|
|
418
|
-
? `${head} The query returned 0 CVEs — a measured empty result: EchelonGraph was queried successfully and nothing matched these filters (we looked and found nothing). This is not a lookup failure.${
|
|
419
|
-
: `${head} The API did not count the matches (total_counted is false), so its total is not a count${shown === undefined ? "" : `; ${shown} returned in this page`}.${
|
|
475
|
+
? `${head} The query returned 0 CVEs — a measured empty result: EchelonGraph was queried successfully and nothing matched these filters (we looked and found nothing). This is not a lookup failure.${tail}`
|
|
476
|
+
: `${head} The API did not count the matches (total_counted is false), so its total is not a count${shown === undefined ? "" : `; ${shown} returned in this page`}.${tail}`, env);
|
|
420
477
|
}
|
|
421
478
|
const total = numAt(r.data, "total") ?? shown;
|
|
422
479
|
if (total === undefined)
|
|
423
|
-
return succeeded(r.data, `${head}${
|
|
480
|
+
return succeeded(r.data, `${head}${tail}`, env);
|
|
424
481
|
if (total === 0 && floor !== true) {
|
|
425
|
-
return succeeded(r.data, `${head} The query matched 0 CVEs — a measured empty result: EchelonGraph was queried successfully and nothing matched these filters (we looked and found nothing). This is not a lookup failure.${
|
|
482
|
+
return succeeded(r.data, `${head} The query matched 0 CVEs — a measured empty result: EchelonGraph was queried successfully and nothing matched these filters (we looked and found nothing). This is not a lookup failure.${tail}`, env);
|
|
426
483
|
}
|
|
427
484
|
const matched = floor === true ? `at least ${total} CVEs (total_is_lower_bound is true: the count stopped at that floor)` : `${total} CVEs`;
|
|
428
|
-
return succeeded(r.data, `${head} The query matched ${matched}${shown === undefined ? "" : `; ${shown} returned in this page`}.${
|
|
485
|
+
return succeeded(r.data, `${head} The query matched ${matched}${shown === undefined ? "" : `; ${shown} returned in this page`}.${tail}`, env);
|
|
429
486
|
}
|
|
430
487
|
catch (e) {
|
|
431
488
|
return crashed(tool, e);
|
|
@@ -441,7 +498,8 @@ async function getCVE(cve_id) {
|
|
|
441
498
|
if (!r.ok)
|
|
442
499
|
return failed(tool, r);
|
|
443
500
|
const written = strAt(r.data, "updated_at");
|
|
444
|
-
|
|
501
|
+
const cve = strAt(r.data, "cve_id") ?? id;
|
|
502
|
+
return succeeded(r.data, `${okHead(tool, r.status)} Returned the record for ${cve}.${cveScoreNote(cve, r.data)}`, {
|
|
445
503
|
state: "measured",
|
|
446
504
|
measured_at: instantOrNull(written),
|
|
447
505
|
method: CVE_FEED_METHOD,
|
|
@@ -1567,9 +1625,13 @@ const CVERecord = z.looseObject({
|
|
|
1567
1625
|
cvss_v3_score: opt(z.number()),
|
|
1568
1626
|
cvss_v4_score: opt(z.number()),
|
|
1569
1627
|
cvss_v4_severity: opt(z.string()),
|
|
1570
|
-
|
|
1571
|
-
|
|
1628
|
+
// #2535: the score is a score only beside score_assessed true (see cveScoreNote).
|
|
1629
|
+
echelongraph_score: opt(z.number()).describe("EchelonGraph's 0-10 score for the CVE: a score only when score_assessed is true. With score_assessed false it is a placeholder, not a rating."),
|
|
1630
|
+
echelongraph_severity: opt(z.string()).describe("The severity band of echelongraph_score: a rating only when score_assessed is true. With score_assessed false it is a placeholder (NONE)."),
|
|
1631
|
+
echelongraph_risk: opt(z.number()).describe("EchelonGraph's 0-100 risk priority, fused from the score, exploitation and automatability: a rating only when score_assessed is true. With score_assessed false it is a placeholder."),
|
|
1572
1632
|
score_confidence: opt(z.string()),
|
|
1633
|
+
score_assessed: opt(z.boolean()).describe("Whether EchelonGraph has scored the CVE. true: echelongraph_score, echelongraph_severity and echelongraph_risk are its score. false: the CVE is NOT YET SCORED, not scored 0, and any of those three it carries is a placeholder, not a rating; the API may leave them out. Absent (an API older than the field): the answer does not say whether the CVE was scored."),
|
|
1634
|
+
score_unassessed_reason: opt(z.string()).describe("Why score_assessed is false: no_signal, no source has yet published severity data EchelonGraph can score; or rejected, the record was withdrawn by its numbering authority and is never scored. Absent when the CVE is scored."),
|
|
1573
1635
|
epss_score: opt(z.number()),
|
|
1574
1636
|
epss_percentile: opt(z.number()),
|
|
1575
1637
|
kev_listed: opt(z.boolean()),
|
|
@@ -1756,6 +1818,9 @@ const ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint
|
|
|
1756
1818
|
const CVE_ID_ARG = z.string().describe("a CVE ID, e.g. CVE-2023-44487");
|
|
1757
1819
|
// What each description says about its structured result, naming only fields its schema holds.
|
|
1758
1820
|
const FEED_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; 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.";
|
|
1821
|
+
// #2535: what search_cves and get_cve say about score_assessed, one constant string, since the
|
|
1822
|
+
// site's tool-claims check (marketing-site lib/mcpToolClaims.test.ts) folds only constant strings.
|
|
1823
|
+
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.";
|
|
1759
1824
|
// One server, built per connection by serveStdio for whichever era the client opens with.
|
|
1760
1825
|
// tools/list answers in registration order, which is the order below.
|
|
1761
1826
|
function createServer() {
|
|
@@ -1768,7 +1833,7 @@ function createServer() {
|
|
|
1768
1833
|
}, async () => checked("cve_summary", CVE_SUMMARY_OUTPUT, await cveSummary()));
|
|
1769
1834
|
server.registerTool("search_cves", {
|
|
1770
1835
|
title: "Search CVEs",
|
|
1771
|
-
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. Returns cves, each with cve_id, severity, cvss_v3_score, echelongraph_score, 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. ${FEED_ENVELOPE} coverage repeats total, total_counted, total_is_lower_bound, search_relaxed, limit and offset, and gives returned, the rows in this page.`,
|
|
1836
|
+
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. 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.`,
|
|
1772
1837
|
inputSchema: z.object({
|
|
1773
1838
|
search: z.string().optional().describe("free-text search (product, vendor, or keyword, e.g. 'tomcat')"),
|
|
1774
1839
|
severity: z.enum(["CRITICAL", "HIGH", "MEDIUM", "LOW"]).optional().describe("filter to one severity"),
|
|
@@ -1781,7 +1846,7 @@ function createServer() {
|
|
|
1781
1846
|
}, async (a) => checked("search_cves", SEARCH_CVES_OUTPUT, await searchCVEs(a)));
|
|
1782
1847
|
server.registerTool("get_cve", {
|
|
1783
1848
|
title: "CVE detail",
|
|
1784
|
-
description: `Full record for one CVE: 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), 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, published, modified and updated_at; each field only where the record has it. Pass a CVE ID like CVE-2023-44487. ${FEED_ENVELOPE}`,
|
|
1849
|
+
description: `Full record for one CVE: 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, 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}`,
|
|
1785
1850
|
inputSchema: z.object({ cve_id: CVE_ID_ARG }),
|
|
1786
1851
|
outputSchema: GET_CVE_OUTPUT,
|
|
1787
1852
|
annotations: ANNOTATIONS,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "echelongraph-mcp",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"mcpName": "io.echelongraph/echelongraph-mcp",
|
|
5
5
|
"description": "EchelonGraph CVE and internet-exposure data as an MCP server: CVEs with EchelonGraph multi-source scores, CISA-KEV and EPSS, plus a per-CVE internet-exposure footprint derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan). Free and keyless, for Claude, Cursor and any MCP client.",
|
|
6
6
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
"url": "https://github.com/echelongraph/echelongraph-mcp",
|
|
9
9
|
"source": "github"
|
|
10
10
|
},
|
|
11
|
-
"version": "2.3.
|
|
11
|
+
"version": "2.3.1",
|
|
12
12
|
"packages": [
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"identifier": "echelongraph-mcp",
|
|
16
|
-
"version": "2.3.
|
|
16
|
+
"version": "2.3.1",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
19
19
|
},
|