echelongraph-mcp 2.3.2 → 2.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,7 +17,7 @@ API call a tool needs to answer.
17
17
 
18
18
  | Tool | What it does |
19
19
  |---|---|
20
- | `cve_summary` | Counts of active CVEs by severity band, the count with no severity band from any source (`summary.none`, sent again as `summary.unscored`: CVEs not yet scored, not a rating of None), and when the feed was last updated. |
20
+ | `cve_summary` | Counts of active CVEs by severity band, the count with no severity band from any source (`summary.none`, sent again as `summary.unscored`: CVEs not yet scored, not a rating of None), the same CVEs counted by NVD's severity label, as provenance (`summary.nvd_critical` to `summary.nvd_none`), the rejected (withdrawn) records outside the total (`summary.rejected`), and when the feed was last updated. |
21
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
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. |
@@ -26,6 +26,30 @@ API call a tool needs to answer.
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 `cve_summary` reads its counts
30
+
31
+ `summary.critical`, `summary.high`, `summary.medium` and `summary.low` count the active CVEs by
32
+ severity band, and `summary.total` counts them all. `summary.none` counts the active CVEs with no
33
+ severity band from any source: CVEs not yet scored, not CVEs rated None. The answer sends the same
34
+ count again as `summary.unscored`.
35
+
36
+ `summary.nvd_critical`, `summary.nvd_high`, `summary.nvd_medium`, `summary.nvd_low` and
37
+ `summary.nvd_none` count the same active CVEs a second time, by NVD's CVSS severity label (v3.x,
38
+ else v4.0). Before NVD's record arrives, or where it gives none, a pre-NVD label from the CVE.org
39
+ record or a GitHub advisory can stand in. They are provenance, never EchelonGraph's severity band. `summary.nvd_none` counts the active CVEs with no
40
+ Critical, High, Medium or Low label there, and many of those carry an NVD CVSS v2 score instead, so
41
+ it is neither a count of CVEs rated None nor the count of CVEs with no severity, which is
42
+ `summary.none`.
43
+
44
+ `summary.rejected` counts the CVE records rejected (withdrawn) by their numbering authority.
45
+ `summary.total` and the other counts leave them out, and they are withdrawn records, never
46
+ vulnerabilities.
47
+
48
+ The tool relays the API's JSON as it was sent. When `summary.none`, `summary.nvd_none` or
49
+ `summary.rejected` is above zero, the note says what it counts, with the number from the answer.
50
+ The note says the five NVD counts add up to `summary.total` only when the answer's do, and says
51
+ nothing about a field the answer does not carry.
52
+
29
53
  ### How `get_cve` and `search_cves` read the EchelonGraph score
30
54
 
31
55
  `echelongraph_score` (0 to 10), its band `echelongraph_severity` and the risk priority
package/dist/index.js CHANGED
@@ -406,6 +406,46 @@ function summaryNoneNote(d) {
406
406
  const same = unscored === none ? ` summary.unscored (${unscored}) is the same count under its own name.` : "";
407
407
  return ` summary.none (${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.${same} Report them as not yet scored, not as CVEs rated None.`;
408
408
  }
409
+ // ── summary.nvd_*, NVD's label, and summary.rejected (#2641) ──
410
+ //
411
+ // core-backend cve/store.go Summary sends a second histogram beside the first: nvd_critical,
412
+ // nvd_high, nvd_medium, nvd_low and nvd_none bucket the same active CVEs on the records' own
413
+ // `severity` column instead of effectiveSeverityExpr. That column holds NVD's CVSS label (v3.1, else
414
+ // v3.0, else v4.0: cve/poller.go). Before NVD's record arrives it holds a pre-NVD label, the CVE.org
415
+ // record's (cve/mitre/convert.go) or a GitHub advisory's (vendoradv SeedStubCVEFromAdvisory), and
416
+ // where NVD's record gives none, CISA's Vulnrichment v4 label can fill it (vulnrichment/backfill.go).
417
+ // nvd_none counts every active CVE whose label there is none of CRITICAL, HIGH, MEDIUM or LOW.
418
+ // CVESummary's own comment says to render it "as provenance, never as a rating", and nvd_none is
419
+ // large by design: #1107 measured that the feed holds NVD's own CVSS v2 score for 72,359 of them.
420
+ // So nvd_none is not a count of CVEs rated None, and not the count of CVEs with no severity, which
421
+ // is summary.none. rejected is COUNT(*) of the rows whose vuln_status is REJECTED: records withdrawn
422
+ // by their numbering authority, outside the active rows that total and both histograms count.
423
+ // Through 2.3.3 the tool relayed all six fields with no word about any of them, and a model handed
424
+ // nvd_none 75,962 beside a note about summary.none 3,408 (production, 2026-09-30) reports "75,962
425
+ // CVEs rated None". The JSON is relayed as sent. As for summary.none, the note says what nvd_none
426
+ // and rejected count, with the answer's own numbers, whenever each is above zero (a zero has no CVE
427
+ // in it to misreport); it says the five nvd_ counts add up to summary.total only when the answer's
428
+ // do; and an answer without the fields (an API older than them) gets no sentence about them.
429
+ const NVD_HISTOGRAM = ["nvd_critical", "nvd_high", "nvd_medium", "nvd_low", "nvd_none"];
430
+ function summaryNVDNote(d) {
431
+ const nvdNone = numAt(d, "summary", "nvd_none");
432
+ if (nvdNone === undefined || nvdNone <= 0)
433
+ return "";
434
+ const counts = NVD_HISTOGRAM.map((k) => numAt(d, "summary", k));
435
+ const total = numAt(d, "summary", "total");
436
+ const present = NVD_HISTOGRAM.filter((_, i) => counts[i] !== undefined).map((k) => `summary.${k}`);
437
+ const adds = total !== undefined && counts.every((n) => n !== undefined) && counts.reduce((a, n) => a + (n ?? 0), 0) === total;
438
+ const histogram = adds
439
+ ? ` ${listed(present)} add up to summary.total (${total}): the same active CVEs, counted by NVD's severity label as provenance, not by EchelonGraph's severity.`
440
+ : ` ${listed(present)} ${present.length === 1 ? "counts" : "count"} active CVEs by NVD's severity label, as provenance, not by EchelonGraph's severity.`;
441
+ return `${histogram} summary.nvd_none (${nvdNone}) is not a count of CVEs rated None, nor of CVEs with no severity: it counts the active CVEs with no Critical, High, Medium or Low CVSS label from NVD (v3.x, else v4.0) or a pre-NVD record, many of them with an NVD CVSS v2 score instead. Report them as CVEs without an NVD severity label, not as CVEs rated None or as CVEs not yet scored.`;
442
+ }
443
+ function summaryRejectedNote(d) {
444
+ const rejected = numAt(d, "summary", "rejected");
445
+ if (rejected === undefined || rejected <= 0)
446
+ return "";
447
+ return ` summary.rejected (${rejected}) counts CVE records rejected (withdrawn) by their numbering authority, not active CVEs: report them as withdrawn records, never as vulnerabilities.`;
448
+ }
409
449
  async function cveSummary() {
410
450
  const tool = "cve_summary";
411
451
  try {
@@ -428,15 +468,16 @@ async function cveSummary() {
428
468
  ],
429
469
  };
430
470
  const total = numAt(r.data, "summary", "total");
431
- const none = summaryNoneNote(r.data);
471
+ // What summary.none (#2610), the NVD histogram and summary.rejected (#2641) count.
472
+ const labels = `${summaryNoneNote(r.data)}${summaryNVDNote(r.data)}${summaryRejectedNote(r.data)}`;
432
473
  if (total === undefined)
433
- return succeeded(r.data, `${head}${none}`, env);
474
+ return succeeded(r.data, `${head}${labels}`, env);
434
475
  if (total === 0) {
435
- return succeeded(r.data, `${head} The feed reports 0 active CVEs — a measured empty result (we looked and found nothing), not a lookup failure.${none}`, env);
476
+ return succeeded(r.data, `${head} The feed reports 0 active CVEs — a measured empty result (we looked and found nothing), not a lookup failure.${labels}`, env);
436
477
  }
437
478
  // A stamp that is not a real instant stays in the JSON and is not repeated as prose.
438
479
  const stamp = realInstant(updated) ? ` (last updated ${updated})` : "";
439
- return succeeded(r.data, `${head} The feed holds ${total} active CVEs${stamp}.${none}`, env);
480
+ return succeeded(r.data, `${head} The feed holds ${total} active CVEs${stamp}.${labels}`, env);
440
481
  }
441
482
  catch (e) {
442
483
  return crashed(tool, e);
@@ -1664,6 +1705,8 @@ const CVERecord = z.looseObject({
1664
1705
  modified: opt(z.string()),
1665
1706
  updated_at: opt(z.string()),
1666
1707
  });
1708
+ // One of the NVD histogram's four labelled buckets (#2641).
1709
+ const nvdBand = (label) => opt(z.number()).describe(`Of the active CVEs total counts, those whose NVD CVSS severity label is ${label}: NVD's label, as provenance, never EchelonGraph's severity band.`);
1667
1710
  const CVE_SUMMARY_OUTPUT = envelopeSchema({
1668
1711
  data: z.looseObject({
1669
1712
  summary: z
@@ -1676,6 +1719,14 @@ const CVE_SUMMARY_OUTPUT = envelopeSchema({
1676
1719
  // #2610: the not-yet-scored bucket, not a rating (see summaryNoneNote).
1677
1720
  none: opt(z.number()).describe("The active CVEs with no severity band from any source: CVEs not yet scored, not a severity rating of None."),
1678
1721
  unscored: opt(z.number()).describe("The same count as none, under its own name."),
1722
+ // #2641: NVD's label, as provenance, not a rating; and the withdrawn records (see
1723
+ // summaryNVDNote and summaryRejectedNote).
1724
+ nvd_critical: nvdBand("Critical"),
1725
+ nvd_high: nvdBand("High"),
1726
+ nvd_medium: nvdBand("Medium"),
1727
+ nvd_low: nvdBand("Low"),
1728
+ nvd_none: opt(z.number()).describe("Of the active CVEs total counts, those with no Critical, High, Medium or Low NVD CVSS severity label, many of them with an NVD CVSS v2 score instead: neither a count of CVEs rated None nor the count of CVEs with no severity, which is none."),
1729
+ rejected: opt(z.number()).describe("The CVE records rejected (withdrawn) by their numbering authority, which total and every other count here leave out: withdrawn records, never vulnerabilities."),
1679
1730
  last_updated: opt(z.string()),
1680
1731
  })
1681
1732
  .optional(),
@@ -1844,6 +1895,13 @@ const FEED_ENVELOPE = "Its structured result carries state (measured), measured_
1844
1895
  // #2610: what cve_summary says summary.none is (summaryNoneNote), one constant string for the same
1845
1896
  // reason as SCORE_ASSESSED_DESCRIPTION below.
1846
1897
  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.";
1898
+ // #2641: what cve_summary says the NVD histogram and summary.rejected are (summaryNVDNote,
1899
+ // summaryRejectedNote), constant strings for the same reason. Neither uses the words "any", "not",
1900
+ // "rating", "scored", "source" or "yet": the site's #2610 control (marketing-site
1901
+ // lib/mcpToolClaims.test.ts) holds that exactly those words of the /pulse/mcp cve_summary row are
1902
+ // backed by SUMMARY_NONE_DESCRIPTION alone, and fails if another sentence backs one.
1903
+ 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.";
1904
+ 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.";
1847
1905
  // #2535: what search_cves and get_cve say about score_assessed, one constant string, since the
1848
1906
  // site's tool-claims check (marketing-site lib/mcpToolClaims.test.ts) folds only constant strings.
1849
1907
  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.";
@@ -1853,7 +1911,7 @@ function createServer() {
1853
1911
  const server = new McpServer({ name: NAME, version: VERSION }, { instructions: INSTRUCTIONS, capabilities: { tools: { listChanged: false } } });
1854
1912
  server.registerTool("cve_summary", {
1855
1913
  title: "CVE feed summary",
1856
- description: `Summary of EchelonGraph's CVE Pulse feed: summary.total active CVEs, their counts by severity band (summary.critical, summary.high, summary.medium, summary.low), the count with no band (summary.none), and summary.last_updated, the newest modification time among those records. ${SUMMARY_NONE_DESCRIPTION} The feed is polled from its sources on a schedule, so this is the state as of that update. ${FEED_ENVELOPE}`,
1914
+ description: `Summary of EchelonGraph's CVE Pulse feed: summary.total active CVEs, their counts by severity band (summary.critical, summary.high, summary.medium, summary.low), the count with no band (summary.none), and summary.last_updated, the newest modification time among those records. ${SUMMARY_NONE_DESCRIPTION} ${SUMMARY_NVD_DESCRIPTION} ${SUMMARY_REJECTED_DESCRIPTION} The feed is polled from its sources on a schedule, so this is the state as of that update. ${FEED_ENVELOPE}`,
1857
1915
  outputSchema: CVE_SUMMARY_OUTPUT,
1858
1916
  annotations: ANNOTATIONS,
1859
1917
  }, async () => checked("cve_summary", CVE_SUMMARY_OUTPUT, await cveSummary()));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echelongraph-mcp",
3
- "version": "2.3.2",
3
+ "version": "2.3.4",
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.2",
11
+ "version": "2.3.4",
12
12
  "packages": [
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "echelongraph-mcp",
16
- "version": "2.3.2",
16
+ "version": "2.3.4",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  },