echelongraph-mcp 2.5.0 → 2.6.0

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
@@ -1,11 +1,36 @@
1
1
  # Changelog
2
2
 
3
3
  Every version of `echelongraph-mcp` published to npm, newest first. Dates are the npm publish
4
- dates (UTC). Issue numbers refer to EchelonGraph's tracker. Since 2.3.3 each version is published
4
+ dates (UTC). Issue numbers (#NNNN) refer to EchelonGraph's internal issue tracker, which is not public: they are not issues of the public repository, and cannot be followed from it. Since 2.3.3 each version is published
5
5
  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.0 — 2026-10-04
10
+
11
+ - `check_sbom` checks up to 2,000 distinct purls per call, instead of refusing more than 200: it
12
+ sends them as consecutive batches of at most 200, merges the answers (rows in input order with
13
+ `index` counted across batches; summary counts and `not_assessed_by_reason` summed; `partial`
14
+ true when any batch's was), and waits out a 429's `Retry-After` within 50 seconds per call.
15
+ When a wait would pass that, or a batch after the first fails, it answers what it has, with the
16
+ purls not sent counted in `coverage.not_sent`, the reason in `coverage.not_sent_reason`, and
17
+ the list in `data.not_sent_purls`, never dropped silently. More than 2,000 is refused. New
18
+ coverage fields: `distinct_purls`, `batch_size`, `batches`, `batches_sent`, `not_sent`,
19
+ `not_sent_reason`, `rate_limit_waits`, `waited_ms`. The `sbom_review` prompt says so (#2734).
20
+ - `kev_recent` reads an RFC 3339 timestamp for `since` or `until` (such as the
21
+ `kev_added_date` `get_cve` returns, `2024-04-12T00:00:00Z`) as its UTC date; other text is still
22
+ refused. The `triage_cve` prompt's step 6 says to pass the date part of `kev_added_date` (#2736).
23
+ - README: the hosted endpoint, `https://mcp.echelongraph.io/mcp`, is described as in service,
24
+ no longer as being rolled out (#2739).
25
+ - This changelog's preamble says the issue numbers refer to EchelonGraph's internal tracker,
26
+ which is not public (#2740).
27
+
28
+ ## 2.5.1 — 2026-10-04
29
+
30
+ - `search_cves` sends its search text in the `X-EG-Search` request header instead of the URL
31
+ query string, so the text is kept out of request URLs, which the hosting platform's tracing
32
+ records (#1983). Severity, minimum CVSS, sort and limit stay in the query string.
33
+
9
34
  ## 2.5.0 — 2026-10-04
10
35
 
11
36
  - Four prompts (`triage_cve`, `kev_weekly_brief`, `am_i_affected`, `sbom_review`) and three
package/README.md CHANGED
@@ -111,9 +111,9 @@ The same 14 tools, four prompts and three resources are served over Streamable H
111
111
  https://mcp.echelongraph.io/mcp
112
112
  ```
113
113
 
114
- Keyless, no sign-in, stateless; both protocol eras. The hosted endpoint is being rolled out:
115
- until `https://mcp.echelongraph.io/health` answers `{"status":"ok",…}`, use the `npx` setup
116
- above.
114
+ Keyless, no sign-in, stateless; both protocol eras. The hosted endpoint is in service:
115
+ `https://mcp.echelongraph.io/health` answers `{"status":"ok","version":…}` with the package
116
+ version it runs.
117
117
 
118
118
  - **claude.ai** (Free, Pro, Max, Team and Enterprise plans):
119
119
  Customize → Connectors → **+ Add** → **Add custom connector**. Name it `EchelonGraph`, paste
@@ -162,7 +162,7 @@ The example questions are ones a client can answer with that tool alone.
162
162
  | `kev_recent` | Recent CISA KEV additions | The CVEs CISA has added to its Known Exploited Vulnerabilities catalog, newest first (`kev_added_date`), from EchelonGraph's copy of the catalog, polled from CISA every 5 minutes: due date, vendor, product, known ransomware use, EchelonGraph's severity, CVSS, EPSS and `eg_kev_tier`, and `our_first_seen_kev`. Filter by date range, ransomware and vendor; page with `limit` and `next_cursor`. Dated by `last_successful_fetch_at`, our last successful fetch of CISA's feed; the filters travel as request headers, never in the URL. | *Which CVEs has CISA added to KEV since 1 September, and which have known ransomware use?* |
163
163
  | `epss_history` | EPSS change history for one CVE | How one CVE's EPSS score has changed, as EchelonGraph recorded it: one point per recorded change (`series_kind` `change_only`), never a daily series, with the value now and `series_starts_at`, when recording began; before it a missing point means not recorded, not unchanged. | *How has CVE-2023-44487's EPSS score changed, as EchelonGraph recorded it?* |
164
164
  | `check_affected` | Am I affected? (product or package at a version) | Whether a product (its NVD CPE product token) or a registry package (`ecosystem` and `package`) at a given version is affected by known CVEs, from the matcher behind echelongraph.io/am-i-affected: `assessed` first (false: not evaluated, with `not_assessed_reason`, and a count of 0 then is not "not affected"), the matching CVEs with `kev_listed`, `ransomware`, `epss_score`, `effective_score` and `score_assessed`, and advisories it cannot decide counted as `undetermined_count`, never as safe. What you look up travels in request headers, never in the URL. | *Is openssl 3.0.0 affected by known CVEs? Is lodash 4.17.15 on npm?* |
165
- | `check_sbom` | Check an SBOM against the advisory corpus | A dependency list checked against EchelonGraph's advisory corpus (OSV.dev records), one verdict per component: `affected`, `not_affected`, `undetermined` or `not_assessed`, each with its `not_assessed_reason`. Pass up to 200 purls, or a CycloneDX JSON or SPDX JSON document: the purls are read from it on your machine and only they are sent, in a POST body; the document is not. 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.* |
165
+ | `check_sbom` | Check an SBOM against the advisory corpus | A dependency list checked against EchelonGraph's advisory corpus (OSV.dev records), one verdict per component: `affected`, `not_affected`, `undetermined` or `not_assessed`, each with its `not_assessed_reason`. Pass up to 2,000 distinct purls, or a CycloneDX JSON or SPDX JSON document: the purls are read from it on your machine and only they are sent, in POST bodies of at most 200 each, one after another; the document is not. When the API's per-component rate limit answers 429, the tool waits the `Retry-After` it names, within 50 seconds per call; past that it answers what it has, with the purls not sent counted in `coverage.not_sent`, the reason in `coverage.not_sent_reason`, and the list in `data.not_sent_purls`, never dropped silently. A deb, apk or rpm purl without a `distro` qualifier naming its release is not assessed (`distro_release_unknown`): EchelonGraph does not guess a release. Only `not_affected` is clean. No ranking, no score. | *Check this CycloneDX SBOM against the advisory corpus.* |
166
166
  | `cve_intel` | CVE weakness, exploits and packages | Weakness, public exploit code, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment: `cwes`, `exploits` (at most 10, verified first) with `exploits_total`, `exploits_capped`, `exploits_by_kind` and `exploits_by_status`, `affected_packages`, `fixed_versions` and `timeline`. `verified_status` is the label stored with each reference, not a guarantee that the exploit works. An empty `exploits` list is not evidence that no public exploit exists; a section the API could not read is named in `coverage.sections_failed`, never relayed as an empty list. | *Is there public exploit code for CVE-2021-44228, and which versions fix it?* |
167
167
  | `get_cwe` | CWE and its CVEs | One CWE (weakness class) and the CVEs classified under it: `name` and `description` from the MITRE CWE catalog EchelonGraph embeds, `total`, and one page of 50 `cves`, ordered as `order` states (CISA-KEV-listed first, then EchelonGraph score). A `total` of 0 says that no CVE in EchelonGraph's feed is classified under that CWE, not that none exists. | *Which CVEs are classified under CWE-79, CISA-KEV-listed first?* |
168
168
  | `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?* |
@@ -626,11 +626,11 @@ Where each tool's input travels:
626
626
  | Input | Where it is sent |
627
627
  |---|---|
628
628
  | A CVE ID (`get_cve`, `cve_exposure`, `epss_history`, `cve_intel`, `vendor_advisories_for_cve`), a CWE ID and page (`get_cwe`), a vendor and advisory ID (`get_vendor_advisory`) | The URL path. |
629
- | `search_cves`: the search text, severity, minimum CVSS, sort and limit | The URL query string. |
629
+ | `search_cves`: `search` | The `X-EG-Search` request header, never the URL. Its severity, minimum CVSS, sort and limit go in the query string. |
630
630
  | `search_vendor_advisories`: `query` | The `X-EG-Advisory-Search` request header, never the URL. Its vendor, severity, CVE filter, limit and offset go in the query string. |
631
631
  | `check_affected`: `product`, `version`, `ecosystem`, `package` | The `X-EG-Product`, `X-EG-Version`, `X-EG-Ecosystem` and `X-EG-Package` request headers, never the URL. |
632
632
  | `kev_recent`: `since`, `until`, `ransomware`, `vendor`, `limit`, `cursor` | The `X-EG-Since`, `X-EG-Until`, `X-EG-Ransomware`, `X-EG-Vendor`, `X-EG-Limit` and `X-EG-Cursor` request headers, never the URL. |
633
- | `check_sbom`: `purls`, or an `sbom` document | The purls, in a POST body. A CycloneDX or SPDX document is read on your machine and only the purls in it are sent; the document is not. |
633
+ | `check_sbom`: `purls`, or an `sbom` document | The purls, in POST bodies of at most 200 each. A CycloneDX or SPDX document is read on your machine and only the purls in it are sent; the document is not. |
634
634
  | `cve_summary`, `exposure_radar` | No input. |
635
635
 
636
636
  The API marks its answers to header-carried lookups `no-store`, so no shared cache keeps them,
@@ -663,7 +663,13 @@ Because the count is shared, heavy use of one path also uses up the lower ceilin
663
663
  that, 500 requests per second per caller is a burst cap on each serving instance, and
664
664
  `check_sbom`'s batch route is charged per component: 1,200 components a minute per caller.
665
665
  Over a limit the API answers 429 with `Retry-After`, and the tool returns a failure
666
- (`state` `failed`, `error.status` 429), never empty data. The API's own documentation of these
666
+ (`state` `failed`, `error.status` 429), never empty data. `check_sbom` is the exception: it
667
+ sends a long list as batches of 200, waits out a 429's `Retry-After` and sends the batch again,
668
+ within 50 seconds per call (MCP clients time a call out at about 60 s). From a fresh budget a
669
+ list of up to 1,200 distinct purls finishes in one call; a longer one (the cap is 2,000) is
670
+ checked in two calls a minute apart. When a wait would pass the 50 seconds, it
671
+ answers the batches it has as `measured` with `coverage.partial` true and the rest counted in
672
+ `coverage.not_sent` (`not_sent_reason` `time_budget`); only a 429 on the first batch is a failure. The API's own documentation of these
667
673
  limits: <https://echelongraph.io/pulse/api>.
668
674
 
669
675
  The hosted endpoint adds its own limit before the API's: 120 MCP requests a minute per client
package/dist/index.js CHANGED
@@ -154,6 +154,17 @@ function apiMessage(body) {
154
154
  }
155
155
  return snippet(body);
156
156
  }
157
+ // A Retry-After header as whole seconds from now: delta-seconds, or an HTTP-date (RFC 9110
158
+ // 10.2.3). undefined when absent or unreadable.
159
+ function retryAfterSeconds(v) {
160
+ const t = v?.trim();
161
+ if (!t)
162
+ return undefined;
163
+ if (/^\d{1,9}$/.test(t))
164
+ return Number(t);
165
+ const at = Date.parse(t);
166
+ return Number.isFinite(at) ? Math.max(0, Math.ceil((at - Date.now()) / 1000)) : undefined;
167
+ }
157
168
  // One request against the API, a GET unless init says otherwise. Never throws: every way the call
158
169
  // can fail comes back as a typed Failure so the tool renders it as an error result. A 2xx whose
159
170
  // body is not a JSON object (an SPA shell, an edge challenge page, a literal null) is a failure
@@ -190,8 +201,10 @@ export async function api(path, init) {
190
201
  catch (e) {
191
202
  return timedOut ? timeout() : { ok: false, kind: "network", path, ...m, detail: `reading the body failed: ${describeError(e)}` };
192
203
  }
193
- if (!res.ok)
194
- return { ok: false, kind: "http", path, ...m, status: res.status, detail: apiMessage(body) };
204
+ if (!res.ok) {
205
+ const retryAfter = retryAfterSeconds(res.headers.get("retry-after"));
206
+ return { ok: false, kind: "http", path, ...m, status: res.status, ...(retryAfter === undefined ? {} : { retryAfter }), detail: apiMessage(body) };
207
+ }
195
208
  let data;
196
209
  try {
197
210
  data = JSON.parse(body);
@@ -220,7 +233,7 @@ export const sentences = (t) => t
220
233
  // A timestamp worth relaying as one: see realInstant below.
221
234
  export const instantOrNull = (s) => (realInstant(s) ? s : null);
222
235
  // One sentence per failure kind: where we looked, for what, and what came back.
223
- function describeFailure(f) {
236
+ export function describeFailure(f) {
224
237
  const verb = f.method ?? "GET";
225
238
  const where = `from ${SHOWN_BASE} for ${verb} ${f.path}`;
226
239
  switch (f.kind) {
@@ -521,9 +534,10 @@ async function cveSummary() {
521
534
  async function searchCVEs(a) {
522
535
  const tool = "search_cves";
523
536
  try {
537
+ // #1983: the free-text search travels percent-encoded in X-EG-Search, never in the URL. Cloud
538
+ // Run keeps every request URL in its trace spans for 30 days and no request header; core-backend
539
+ // (cve/handler.go listSearchTerm) reads the header first and decodes it with url.PathUnescape.
524
540
  const q = new URLSearchParams();
525
- if (a.search)
526
- q.set("search", a.search);
527
541
  if (a.severity)
528
542
  q.set("severity", a.severity);
529
543
  if (a.min_cvss !== undefined)
@@ -531,7 +545,7 @@ async function searchCVEs(a) {
531
545
  if (a.sort)
532
546
  q.set("sort", a.sort);
533
547
  q.set("limit", String(a.limit ?? 20));
534
- const r = await api(`/api/v1/public/cves?${q.toString()}`);
548
+ const r = await api(`/api/v1/public/cves?${q.toString()}`, a.search ? { headers: { "X-EG-Search": encodeURIComponent(a.search) } } : undefined);
535
549
  if (!r.ok)
536
550
  return failed(tool, r);
537
551
  const head = okHead(tool, r.status);
@@ -2009,7 +2023,7 @@ export function createServer() {
2009
2023
  // #2716: its logic, schemas and description live in tools/check_affected.ts.
2010
2024
  registerCheckAffected(server, { api, succeeded, failed, badInput, crashed, checked, okHead, envelopeSchema, annotations: ANNOTATIONS });
2011
2025
  // #2721: tools/check_sbom.ts, on this file's api(), envelope and failure contract.
2012
- registerCheckSbom(server, { api, failed, badInput, crashed, checked, succeeded, okHead, envelopeSchema, annotations: ANNOTATIONS });
2026
+ registerCheckSbom(server, { api, failed, describeFailure, badInput, crashed, checked, succeeded, okHead, envelopeSchema, annotations: ANNOTATIONS });
2013
2027
  registerCveIntel(server);
2014
2028
  registerGetCwe(server);
2015
2029
  // #2719: vendor_advisories_for_cve, get_vendor_advisory, search_vendor_advisories.
package/dist/prompts.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as z from "zod";
2
- import { MAX_COMPONENTS, MAX_SBOM_CHARS } from "./tools/check_sbom.js";
2
+ import { MAX_COMPONENTS, MAX_PURLS, MAX_SBOM_CHARS } from "./tools/check_sbom.js";
3
3
  export const KEV_BRIEF_DEFAULT_DAYS = 7;
4
4
  export const KEV_BRIEF_MAX_DAYS = 365;
5
5
  // The envelope rules every prompt carries, word for word.
@@ -22,7 +22,7 @@ export function triageCveText(cve) {
22
22
  "3. vendor_advisories_for_cve: the vendor advisories that name it. Call get_vendor_advisory for one whose remediation you need in full.",
23
23
  "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.",
24
24
  "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.",
25
- `6. If get_cve says kev_listed is true: kev_recent with since and until both set to its kev_added_date, to read kev_due_date from the row for ${cve}.`,
25
+ `6. If get_cve says kev_listed is true: kev_recent with since and until both set to the date part (YYYY-MM-DD) of its kev_added_date (for 2024-04-12T00:00:00Z, 2024-04-12), to read kev_due_date from the row for ${cve}.`,
26
26
  ENVELOPE_RULES,
27
27
  "Then answer with this template, one line each, naming the tool and the measured_at behind every line:",
28
28
  "- Exploited? CISA-KEV listing and its date, known ransomware use, and public exploit code on record.",
@@ -64,7 +64,7 @@ export function amIAffectedText(a) {
64
64
  export function sbomReviewText(sbom) {
65
65
  return [
66
66
  "Review the SBOM below for known vulnerabilities, using the EchelonGraph MCP tools.",
67
- `1. Call check_sbom with sbom set to the document below, as given. It reads the document's purls and checks up to ${MAX_COMPONENTS} distinct purls per call; a document with more is refused, not truncated. If it is refused, say why from the result and do not guess at the components.`,
67
+ `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, list them as not checked (never as clean), and call check_sbom again with purls set to data.not_sent_purls once, after a minute, merging what it returns.`,
68
68
  "2. 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. If there are more than 30 distinct CVEs, do this for 30 of them and list the rest as not looked up, unranked.",
69
69
  ENVELOPE_RULES,
70
70
  "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).",
@@ -125,7 +125,7 @@ export function registerPrompts(server, kit) {
125
125
  }, (a) => text(amIAffectedText(a)));
126
126
  server.registerPrompt("sbom_review", {
127
127
  title: "SBOM review",
128
- description: `Review an SBOM (CycloneDX JSON or SPDX JSON text, up to ${MAX_SBOM_CHARS} characters): it is passed to check_sbom, which checks up to ${MAX_COMPONENTS} distinct purls per call, then get_cve for each affected CVE, and a prioritised fix list ordered by CISA-KEV listing, then EPSS, then score, each key stated. undetermined and not_assessed components are listed as not checked, never as clean.`,
128
+ description: `Review an SBOM (CycloneDX JSON or SPDX JSON text, up to ${MAX_SBOM_CHARS} characters): it is passed to check_sbom, which checks up to ${MAX_PURLS} distinct purls per call in batches of ${MAX_COMPONENTS}, then get_cve for each affected CVE, and a prioritised fix list ordered by CISA-KEV listing, then EPSS, then score, each key stated. undetermined and not_assessed components are listed as not checked, never as clean.`,
129
129
  argsSchema: z.object({
130
130
  sbom: z
131
131
  .string()
@@ -1,9 +1,31 @@
1
1
  import * as z from "zod";
2
2
  export const CHECK_SBOM = "check_sbom";
3
3
  export const BATCH_PATH = "/api/v1/public/cves/match/batch";
4
- // The backend's cap (cveBatchMaxComponents). A document with more is refused here, before any
5
- // request, and never truncated: a dropped component would read as a clean one.
4
+ // The backend's cap per request (cveBatchMaxComponents): the batch size. A longer list is sent
5
+ // as consecutive batches of at most this many, one after another (#2734).
6
6
  export const MAX_COMPONENTS = 200;
7
+ // The backend charges the batch route per component: 1,200 components a minute per caller
8
+ // (core-backend internal/waf/weighted.go, CVEMatchBatchComponentsPerWindow). Over it, it answers
9
+ // 429 with Retry-After and looks nothing up.
10
+ export const API_COMPONENTS_PER_MINUTE = 1200;
11
+ // The most distinct purls one call checks: two minutes of that budget. From a fresh budget, 2,000
12
+ // purls are 10 batches: six go at once, the API then asks for a wait of at most a minute, and the
13
+ // other four follow, and waiting out that minute would pass TIME_BUDGET_MS, so the call answers partial:
14
+ // the first 1,200 checked, the rest named for a second call. A list with
15
+ // more is refused before any request, never truncated: a dropped component would read as clean.
16
+ export const MAX_PURLS = 2000;
17
+ // How long one call may spend, waits for Retry-After included. When the next wait would pass it,
18
+ // the call stops and answers what it has, with the purls not sent counted in coverage.not_sent
19
+ // and listed in data.not_sent_purls, never dropped silently.
20
+ // 50 s, not more: MCP clients time a tools/call out (the TypeScript SDK's default request timeout is
21
+ // 60 s), and a call that outlives its client returns nothing at all — not even the partial answer
22
+ // below. From a fresh budget, 1,200 purls (6 batches) go at once; a larger list answers partial
23
+ // with the rest in data.not_sent_purls, for a call a minute later.
24
+ export const TIME_BUDGET_MS = 50_000;
25
+ // Retry-After 0 is waited this long; a 429 without Retry-After is not retried. At most MAX_WAITS
26
+ // waits per call.
27
+ const MIN_WAIT_MS = 1000;
28
+ const MAX_WAITS = 10;
7
29
  // The backend's per-purl cap (cveBatchMaxPurlLen).
8
30
  const MAX_PURL_LEN = 512;
9
31
  // The document's size cap, as JSON text. OWASP Juice Shop 11.1.2's full CycloneDX SBOM (840
@@ -22,7 +44,7 @@ const CLEAN_NOTE = "Only not_affected is a clean verdict, and only for the advis
22
44
  const MEASURED_AT_NOTE = "measured_at is null: the corpus rows are read through a cache whose maximum age the answer states (summary.corpus_cache_max_age_ms), so the answer gives no single time at which they were read.";
23
45
  const FRESHNESS_NOTE = "freshness is null: the answer carries no time at which the advisory corpus was last refreshed.";
24
46
  export const CHECK_SBOM_TITLE = "Check an SBOM against the advisory corpus";
25
- export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGraph's advisory corpus, one verdict per component. Pass purls (package URLs, up to 200) or sbom (a CycloneDX JSON or SPDX JSON document, as JSON text or as an object, up to 5,000,000 characters). The purls are read from the document on this machine and only they are sent, in the body of one POST, never in a URL; the document itself is not sent. A component without a purl is counted and not checked. Each purl is mapped to its OSV ecosystem, package name and version and matched against the affected version ranges EchelonGraph holds from OSV.dev advisory records; there is no ranking and no score. data.results holds one row per component sent, in order, with verdict (affected, not_affected, undetermined or not_assessed), assessed, not_assessed_reason, cve_ids, matches, count, not_affected_count and undetermined_count; data.summary counts the verdicts. not_affected is the only clean verdict. undetermined: advisories name the package but at least one could not be decided at this version and none matched. not_assessed: no verdict at all, because the package is not in the corpus, the purl type has no OSV ecosystem, a deb, apk or rpm purl carries no distro qualifier naming its release (EchelonGraph does not guess one), the version is missing, the lookup failed, or the batch's time budget ran out first (time_budget). Neither undetermined nor not_assessed is clean, and the note gives their counts. A list or document with more than 200 distinct purls is refused, not truncated: split it. Its structured result carries state (measured when at least one component got a verdict, else not_assessed), measured_at (null: the corpus is read through a cache, so no single read time exists), method, coverage (what the input held and what was sent), 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.";
47
+ export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGraph's advisory corpus, one verdict per component. Pass purls (package URLs, up to 2,000 distinct) or sbom (a CycloneDX JSON or SPDX JSON document, as JSON text or as an object, up to 5,000,000 characters). The purls are read from the document on this machine and only they are sent, in POST bodies of at most 200 purls each, one after another, never in a URL; the document itself is not sent. A component without a purl is counted and not checked. Each purl is mapped to its OSV ecosystem, package name and version and matched against the affected version ranges EchelonGraph holds from OSV.dev advisory records; there is no ranking and no score. data.results holds one row per component sent, in order (index counts across batches), with verdict (affected, not_affected, undetermined or not_assessed), assessed, not_assessed_reason, cve_ids, matches, count, not_affected_count and undetermined_count; data.summary counts the verdicts, summed over the batches (partial is true when any batch's was). not_affected is the only clean verdict. undetermined: advisories name the package but at least one could not be decided at this version and none matched. not_assessed: no verdict at all, because the package is not in the corpus, the purl type has no OSV ecosystem, a deb, apk or rpm purl carries no distro qualifier naming its release (EchelonGraph does not guess one), the version is missing, the lookup failed, or the batch's time budget ran out first (time_budget). Neither undetermined nor not_assessed is clean, and the note gives their counts. The API allows 1,200 components a minute per caller; when it answers 429 with Retry-After, the tool waits as asked and sends the batch again, within 50 seconds per call. When the next wait would pass that, or a batch after the first fails, the tool stops and answers what it has: coverage.not_sent counts the purls not sent and coverage.not_sent_reason says why (time_budget, rate_limited or request_failed), data.not_sent_purls lists them for a later call, and they are not checked and not clean. A list or document with more than 2,000 distinct purls is refused, not truncated: split it. Its structured result carries state (measured when at least one component got a verdict, else not_assessed), measured_at (null: the corpus is read through a cache, so no single read time exists), method, coverage (what the input held, what was sent in how many batches, and what was not sent and why), freshness (null) and notes, with data equal to the API's JSON (for more than one batch, the batches' answers merged); 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.";
26
48
  const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
27
49
  function dedupe(all) {
28
50
  const seen = new Set();
@@ -146,21 +168,31 @@ const count = (o, k) => {
146
168
  return typeof v === "number" && Number.isFinite(v) ? v : undefined;
147
169
  };
148
170
  const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
149
- function noteFor(head, x, d) {
171
+ function noteFor(head, x, d, g) {
150
172
  const s = field(d, "summary");
151
173
  const affected = count(s, "affected") ?? 0;
152
174
  const notAffected = count(s, "not_affected") ?? 0;
153
175
  const undetermined = count(s, "undetermined") ?? 0;
154
176
  const notAssessed = count(s, "not_assessed") ?? 0;
155
- const components = count(s, "components") ?? x.purls.length;
177
+ const components = count(s, "components") ?? g.sent;
156
178
  const out = [head];
157
179
  if (x.input === "purls") {
158
- out.push(`Sent ${plural(x.purls.length, "purl", "purls")}${x.duplicates_removed ? ` (${plural(x.duplicates_removed, "duplicate was", "duplicates were")} sent once)` : ""}.`);
180
+ out.push(`Sent ${plural(g.sent, "purl", "purls")}${x.duplicates_removed ? ` (${plural(x.duplicates_removed, "duplicate was", "duplicates were")} sent once)` : ""}.`);
159
181
  }
160
182
  else {
161
183
  const kind = x.input === "cyclonedx" ? "CycloneDX" : "SPDX";
162
184
  const unit = x.input === "cyclonedx" ? "component" : "package";
163
- out.push(`Read ${plural(x.components_in_document ?? 0, unit, `${unit}s`)} from the ${kind} document: ${x.with_purl} with a purl${x.without_purl ? `, and ${x.without_purl} without one, which ${x.without_purl === 1 ? "was" : "were"} not checked and ${x.without_purl === 1 ? "is" : "are"} not clean` : ""}${x.duplicates_removed ? `; ${plural(x.duplicates_removed, "duplicate purl was", "duplicate purls were")} sent once` : ""}; sent ${x.purls.length}.`);
185
+ out.push(`Read ${plural(x.components_in_document ?? 0, unit, `${unit}s`)} from the ${kind} document: ${x.with_purl} with a purl${x.without_purl ? `, and ${x.without_purl} without one, which ${x.without_purl === 1 ? "was" : "were"} not checked and ${x.without_purl === 1 ? "is" : "are"} not clean` : ""}${x.duplicates_removed ? `; ${plural(x.duplicates_removed, "duplicate purl was", "duplicate purls were")} sent once` : ""}; sent ${g.sent}.`);
186
+ }
187
+ if (g.batches > 1) {
188
+ out.push(`The ${x.purls.length} distinct purls make ${g.batches} batches of at most ${MAX_COMPONENTS}, sent one after another; the API answered ${g.batches_sent} of them, and data.summary sums those answers.`);
189
+ }
190
+ if (g.waits > 0) {
191
+ out.push(`The API's component budget (${API_COMPONENTS_PER_MINUTE} components a minute) ran out ${g.waits === 1 ? "once" : `${g.waits} times`}: the tool waited ${Math.round(g.waited_ms / 1000)} s in all, as its Retry-After asked, and sent the batch again.`);
192
+ }
193
+ const notSent = g.not_sent_purls.length;
194
+ if (notSent > 0) {
195
+ out.push(`${plural(notSent, "purl was", "purls were")} NOT sent (not_sent_reason ${g.not_sent_reason}: ${g.not_sent_detail}), so ${notSent === 1 ? "it is" : "they are"} not checked and not clean; data.not_sent_purls lists ${notSent === 1 ? "it" : "them"}: call check_sbom again with purls set to that list${g.not_sent_reason === "request_failed" ? "" : " after a minute"}.`);
164
196
  }
165
197
  out.push(`Of the ${components} components checked: ${affected} affected, ${notAffected} not affected, ${undetermined} undetermined, ${notAssessed} not assessed.`);
166
198
  const by = field(s, "not_assessed_by_reason");
@@ -192,8 +224,76 @@ function noteFor(head, x, d) {
192
224
  }
193
225
  return { note: out.join(" "), assessed: affected + notAffected + undetermined };
194
226
  }
227
+ // Summary fields summed over the batches, and bounds that hold per batch (the largest held for all).
228
+ const SUMMED = ["components", "affected", "not_affected", "undetermined", "not_assessed", "lookups", "elapsed_ms"];
229
+ const MAXED = ["time_budget_ms", "corpus_cache_max_age_ms"];
230
+ // The batches' answers as one (#2734): results concatenated with index counted across batches
231
+ // (batch b's row i becomes index b*MAX_COMPONENTS+i, its position in the list sent), summaries
232
+ // summed, not_assessed_by_reason summed per reason, partial true when any batch's was, answered_at
233
+ // the last batch's. A count some batch did not send is left out, never summed as 0.
234
+ export function mergeAnswers(answers) {
235
+ const out = { ...answers[0] };
236
+ const results = [];
237
+ answers.forEach((a, b) => {
238
+ const rows = field(a, "results");
239
+ if (!Array.isArray(rows))
240
+ return;
241
+ for (const r of rows) {
242
+ const i = field(r, "index");
243
+ results.push(isObj(r) && typeof i === "number" ? { ...r, index: i + b * MAX_COMPONENTS } : r);
244
+ }
245
+ });
246
+ const total = (vs) => vs.every((v) => typeof v === "number" && Number.isFinite(v)) ? vs.reduce((t, v) => t + v, 0) : undefined;
247
+ const comps = total(answers.map((a) => field(a, "components")));
248
+ if (comps === undefined)
249
+ delete out.components;
250
+ else
251
+ out.components = comps;
252
+ const ss = answers.map((a) => field(a, "summary"));
253
+ if (ss.every(isObj)) {
254
+ const sums = ss;
255
+ const m = { ...sums[0] };
256
+ for (const k of SUMMED) {
257
+ const t = total(sums.map((x) => x[k]));
258
+ if (t === undefined)
259
+ delete m[k];
260
+ else
261
+ m[k] = t;
262
+ }
263
+ for (const k of MAXED) {
264
+ const vs = sums.map((x) => x[k]).filter((v) => typeof v === "number");
265
+ if (vs.length)
266
+ m[k] = Math.max(...vs);
267
+ }
268
+ if (sums.some((x) => typeof x.partial === "boolean"))
269
+ m.partial = sums.some((x) => x.partial === true);
270
+ if (sums.some((x) => isObj(x.not_assessed_by_reason))) {
271
+ const by = {};
272
+ for (const x of sums) {
273
+ if (!isObj(x.not_assessed_by_reason))
274
+ continue;
275
+ for (const [r, n] of Object.entries(x.not_assessed_by_reason))
276
+ if (typeof n === "number")
277
+ by[r] = (by[r] ?? 0) + n;
278
+ }
279
+ m.not_assessed_by_reason = by;
280
+ }
281
+ out.summary = m;
282
+ }
283
+ else {
284
+ delete out.summary;
285
+ }
286
+ const last = answers[answers.length - 1];
287
+ if (field(last, "answered_at") !== undefined)
288
+ out.answered_at = field(last, "answered_at");
289
+ out.results = results;
290
+ return out;
291
+ }
292
+ const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
195
293
  export async function checkSbom(deps, a) {
196
294
  const tool = CHECK_SBOM;
295
+ const sleep = deps.sleep ?? realSleep;
296
+ const now = deps.now ?? Date.now;
197
297
  try {
198
298
  const x = extract(a);
199
299
  if (typeof x === "string")
@@ -201,22 +301,84 @@ export async function checkSbom(deps, a) {
201
301
  if (x.purls.length === 0) {
202
302
  return deps.badInput(tool, x.input === "purls" ? "purls holds no package URL" : `the document holds ${x.components_in_document ?? 0} components and none carries a purl, so there is nothing to check`);
203
303
  }
204
- if (x.purls.length > MAX_COMPONENTS) {
205
- return deps.badInput(tool, `${x.purls.length} distinct purls; at most ${MAX_COMPONENTS} are checked per call, and none is dropped silently: split the list into calls of ${MAX_COMPONENTS} or fewer`);
304
+ if (x.purls.length > MAX_PURLS) {
305
+ return deps.badInput(tool, `${x.purls.length} distinct purls; at most ${MAX_PURLS} are checked per call (in batches of ${MAX_COMPONENTS}, within the API's budget of ${API_COMPONENTS_PER_MINUTE} components a minute), and none is dropped silently: split the list into calls of ${MAX_PURLS} or fewer`);
206
306
  }
207
307
  const long = x.purls.findIndex((p) => p.length > MAX_PURL_LEN);
208
308
  if (long >= 0)
209
309
  return deps.badInput(tool, `purl ${long + 1} is longer than ${MAX_PURL_LEN} characters`);
210
- const r = await deps.api(BATCH_PATH, {
211
- method: "POST",
212
- headers: { "Content-Type": "application/json" },
213
- body: JSON.stringify({ components: x.purls.map((purl) => ({ purl })) }),
214
- });
215
- if (!r.ok)
216
- return deps.failed(tool, r);
217
- const { note, assessed } = noteFor(deps.okHead(tool, r.status), x, r.data);
218
- const s = field(r.data, "summary");
219
- return deps.succeeded(r.data, note, {
310
+ const batches = [];
311
+ for (let i = 0; i < x.purls.length; i += MAX_COMPONENTS)
312
+ batches.push(x.purls.slice(i, i + MAX_COMPONENTS));
313
+ const started = now();
314
+ const budgetS = TIME_BUDGET_MS / 1000;
315
+ const answers = [];
316
+ let status = 200;
317
+ let waits = 0;
318
+ let waitedMs = 0;
319
+ let stop = null;
320
+ // One batch at a time, in order; a 429 with Retry-After is waited out and the same batch sent
321
+ // again, while the wait fits in the call's budget.
322
+ while (answers.length < batches.length) {
323
+ if (answers.length > 0 && now() - started >= TIME_BUDGET_MS) {
324
+ stop = { reason: "time_budget", detail: `the call's ${budgetS} s budget ran out after ${answers.length} of ${batches.length} batches` };
325
+ break;
326
+ }
327
+ const r = await deps.api(BATCH_PATH, {
328
+ method: "POST",
329
+ headers: { "Content-Type": "application/json" },
330
+ body: JSON.stringify({ components: batches[answers.length].map((purl) => ({ purl })) }),
331
+ });
332
+ if (r.ok) {
333
+ answers.push(r.data);
334
+ status = r.status;
335
+ continue;
336
+ }
337
+ if (r.kind === "http" && r.status === 429) {
338
+ if (r.retryAfter === undefined) {
339
+ stop = { reason: "rate_limited", detail: "the API answered 429 without a Retry-After to wait for" };
340
+ }
341
+ else if (waits >= MAX_WAITS) {
342
+ stop = { reason: "rate_limited", detail: `the API answered 429 again after ${MAX_WAITS} waits` };
343
+ }
344
+ else {
345
+ const waitMs = Math.max(MIN_WAIT_MS, r.retryAfter * 1000);
346
+ if (now() - started + waitMs <= TIME_BUDGET_MS) {
347
+ await sleep(waitMs);
348
+ waits++;
349
+ waitedMs += waitMs;
350
+ continue;
351
+ }
352
+ stop = { reason: "time_budget", detail: `the API answered 429 asking for a wait of ${r.retryAfter} s (Retry-After), which would pass the call's ${budgetS} s budget` };
353
+ }
354
+ }
355
+ else {
356
+ stop = { reason: "request_failed", detail: deps.describeFailure(r).replace(/\.$/, "") };
357
+ }
358
+ // Nothing answered yet: nothing was measured, so the failure is the answer.
359
+ if (answers.length === 0)
360
+ return deps.failed(tool, r);
361
+ break;
362
+ }
363
+ const notSentPurls = x.purls.slice(answers.length * MAX_COMPONENTS);
364
+ const g = {
365
+ batches: batches.length,
366
+ batches_sent: answers.length,
367
+ sent: x.purls.length - notSentPurls.length,
368
+ not_sent_purls: notSentPurls,
369
+ not_sent_reason: notSentPurls.length ? (stop?.reason ?? null) : null,
370
+ not_sent_detail: notSentPurls.length ? (stop?.detail ?? null) : null,
371
+ waits,
372
+ waited_ms: waitedMs,
373
+ };
374
+ // One batch: data is the API's JSON as it came. More: the merged answer.
375
+ const data = batches.length === 1 ? answers[0] : mergeAnswers(answers);
376
+ if (notSentPurls.length)
377
+ data.not_sent_purls = notSentPurls;
378
+ const { note, assessed } = noteFor(deps.okHead(tool, status), x, data, g);
379
+ const s = field(data, "summary");
380
+ const summaryPartial = typeof field(s, "partial") === "boolean" ? field(s, "partial") : null;
381
+ return deps.succeeded(data, note, {
220
382
  state: assessed > 0 ? "measured" : "not_assessed",
221
383
  measured_at: null,
222
384
  method: METHOD,
@@ -226,9 +388,17 @@ export async function checkSbom(deps, a) {
226
388
  with_purl: x.with_purl,
227
389
  without_purl: x.without_purl,
228
390
  duplicates_removed: x.duplicates_removed,
229
- sent: x.purls.length,
391
+ distinct_purls: x.purls.length,
392
+ batch_size: MAX_COMPONENTS,
393
+ batches: g.batches,
394
+ batches_sent: g.batches_sent,
395
+ sent: g.sent,
396
+ not_sent: notSentPurls.length,
397
+ not_sent_reason: g.not_sent_reason,
398
+ rate_limit_waits: waits,
399
+ waited_ms: waitedMs,
230
400
  not_assessed: count(s, "not_assessed") ?? null,
231
- partial: typeof field(s, "partial") === "boolean" ? field(s, "partial") : null,
401
+ partial: notSentPurls.length > 0 ? true : summaryPartial,
232
402
  },
233
403
  freshness: null,
234
404
  notes: [DISTRO_NOTE, CLEAN_NOTE, MEASURED_AT_NOTE, FRESHNESS_NOTE],
@@ -294,6 +464,7 @@ export function checkSbomOutput(envelopeSchema) {
294
464
  .optional(),
295
465
  answered_at: opt(z.string()),
296
466
  results: z.array(Row).optional(),
467
+ not_sent_purls: opt(z.array(z.string())).describe("The distinct purls not sent (coverage.not_sent_reason says why): not checked, and not clean. Present only when some were not sent."),
297
468
  }),
298
469
  coverage: z.strictObject({
299
470
  input: z.enum(["purls", "cyclonedx", "spdx"]).describe("What was passed: a purl list, a CycloneDX JSON document or an SPDX JSON document."),
@@ -301,9 +472,23 @@ export function checkSbomOutput(envelopeSchema) {
301
472
  with_purl: z.number().int().describe("Of those, the ones carrying a purl."),
302
473
  without_purl: z.number().int().describe("The ones without a purl: not checked, and not clean."),
303
474
  duplicates_removed: z.number().int().describe("Purls that appeared more than once and were sent once."),
304
- sent: z.number().int().describe("Distinct purls sent and checked."),
475
+ distinct_purls: z.number().int().describe("Distinct purls to check: sent plus not_sent."),
476
+ batch_size: z.number().int().describe("The most purls one request carries (the API's cap per request)."),
477
+ batches: z.number().int().describe("Requests the distinct purls make, at batch_size each."),
478
+ batches_sent: z.number().int().describe("Of those, the ones the API answered."),
479
+ sent: z.number().int().describe("Distinct purls sent and answered."),
480
+ not_sent: z.number().int().describe("Distinct purls not sent, listed in data.not_sent_purls: not checked, and not clean."),
481
+ not_sent_reason: z
482
+ .enum(["time_budget", "rate_limited", "request_failed"])
483
+ .nullable()
484
+ .describe("Why not_sent is above 0: time_budget (the call's 50 s budget ran out, or waiting out the API's Retry-After would pass it), rate_limited (a 429 without Retry-After, or a 429 after 10 waits), request_failed (a batch after the first failed; the note quotes how). null when every purl was sent."),
485
+ rate_limit_waits: z.number().int().describe("How many times the API answered 429 and the tool waited its Retry-After before sending the batch again."),
486
+ waited_ms: z.number().int().describe("Milliseconds spent in those waits."),
305
487
  not_assessed: z.number().int().nullable().describe("Of those sent, the components with no verdict, as the answer counts them."),
306
- partial: z.boolean().nullable().describe("The answer's summary.partial: true when the time budget ran out first."),
488
+ partial: z
489
+ .boolean()
490
+ .nullable()
491
+ .describe("true when not every purl was sent (not_sent above 0) or any batch's summary.partial was true (its time budget ran out first); otherwise the answer's summary.partial."),
307
492
  }),
308
493
  freshness: null,
309
494
  });
@@ -317,7 +502,7 @@ export function registerCheckSbom(server, deps) {
317
502
  title: CHECK_SBOM_TITLE,
318
503
  description: CHECK_SBOM_DESCRIPTION,
319
504
  inputSchema: z.object({
320
- purls: z.array(z.string()).optional().describe("package URLs to check, e.g. pkg:npm/lodash@4.17.20 or pkg:deb/debian/openssl@3.0.11-1~deb12u1?distro=debian-12 (up to 200)"),
505
+ purls: z.array(z.string()).optional().describe("package URLs to check, e.g. pkg:npm/lodash@4.17.20 or pkg:deb/debian/openssl@3.0.11-1~deb12u1?distro=debian-12 (up to 2,000 distinct, sent in batches of 200)"),
321
506
  sbom: z
322
507
  .union([z.string(), z.record(z.string(), z.unknown())])
323
508
  .optional()
@@ -6,7 +6,7 @@ const MAX_VENDOR = 128;
6
6
  const MAX_CURSOR = 128;
7
7
  const KEV_RECENT_METHOD = "EchelonGraph's copy of the CISA KEV catalog: CISA's feed is polled every 5 minutes and each entry is written onto its CVE's record; rows are read from those records, newest kev_added_date first.";
8
8
  const KEV_RECENT_COVERAGE = "CISA KEV catalog";
9
- const KEV_RECENT_DESCRIPTION = "The CVEs CISA has added to its Known Exploited Vulnerabilities (KEV) catalog, newest first, from EchelonGraph's copy of that catalog, which polls CISA's feed every 5 minutes. Each row in kev gives cve_id, kev_added_date (CISA's dateAdded), kev_due_date, kev_vendor, kev_product, kev_vuln_name and kev_ransomware (known ransomware-campaign use), EchelonGraph's severity, cvss_v3_score, epss_score, epss_percentile and eg_kev_tier for the CVE, and our_first_seen_kev, when EchelonGraph's poller first recorded the CVE entering the catalog (null where no such record exists). Filter by since and until (YYYY-MM-DD, inclusive, on kev_added_date), ransomware, and vendor (an exact, case-insensitive match on kev_vendor); page with limit (1 to 200, default 50) and cursor, passing back the previous page's next_cursor with the same filters. Rows within one date are ordered by cve_id. total counts the CVEs matching the filters; kev_listed_total counts every CVE EchelonGraph holds as KEV-listed, and catalog.catalog_count is CISA's own count in the catalog last fetched, so a gap between the two is entries not yet in EchelonGraph's CVE table, which no page returns. CISA's requiredAction and shortDescription are not returned. Its structured result carries state (measured), measured_at (our last successful fetch of CISA's feed, null when the API does not give it), method, coverage (the CISA KEV catalog: total, returned, kev_listed_total, catalog_count, limit, has_more), freshness (last_successful_fetch_at, catalog_version, date_released) 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.";
9
+ const KEV_RECENT_DESCRIPTION = "The CVEs CISA has added to its Known Exploited Vulnerabilities (KEV) catalog, newest first, from EchelonGraph's copy of that catalog, which polls CISA's feed every 5 minutes. Each row in kev gives cve_id, kev_added_date (CISA's dateAdded), kev_due_date, kev_vendor, kev_product, kev_vuln_name and kev_ransomware (known ransomware-campaign use), EchelonGraph's severity, cvss_v3_score, epss_score, epss_percentile and eg_kev_tier for the CVE, and our_first_seen_kev, when EchelonGraph's poller first recorded the CVE entering the catalog (null where no such record exists). Filter by since and until (YYYY-MM-DD, inclusive, on kev_added_date; an RFC 3339 timestamp, such as the kev_added_date 2024-04-12T00:00:00Z that CVE records carry, is read as its UTC date, and anything else is refused), ransomware, and vendor (an exact, case-insensitive match on kev_vendor); page with limit (1 to 200, default 50) and cursor, passing back the previous page's next_cursor with the same filters. Rows within one date are ordered by cve_id. total counts the CVEs matching the filters; kev_listed_total counts every CVE EchelonGraph holds as KEV-listed, and catalog.catalog_count is CISA's own count in the catalog last fetched, so a gap between the two is entries not yet in EchelonGraph's CVE table, which no page returns. CISA's requiredAction and shortDescription are not returned. Its structured result carries state (measured), measured_at (our last successful fetch of CISA's feed, null when the API does not give it), method, coverage (the CISA KEV catalog: total, returned, kev_listed_total, catalog_count, limit, has_more), freshness (last_successful_fetch_at, catalog_version, date_released) 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.";
10
10
  const opt = (t) => t.nullable().optional();
11
11
  const KEVRow = z.looseObject({
12
12
  cve_id: opt(z.string()),
@@ -61,22 +61,35 @@ const Freshness = z.strictObject({
61
61
  // Inputs this side refuses before any request, with the reason.
62
62
  const DATE = /^\d{4}-\d{2}-\d{2}$/;
63
63
  const isDate = (s) => DATE.test(s) && new Date(`${s}T00:00:00Z`).toISOString().slice(0, 10) === s;
64
+ // An RFC 3339 date-time (#2736): get_cve gives kev_added_date as one ("2024-04-12T00:00:00Z"), and a
65
+ // model passes it on as given. It is read as its UTC date; the time of day is dropped.
66
+ const RFC3339 = /^(\d{4}-\d{2}-\d{2})T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d{1,9})?(Z|[+-]([01]\d|2[0-3]):[0-5]\d)$/;
67
+ // since or until as the YYYY-MM-DD the API takes, or null when it is neither form.
68
+ export function kevDate(raw) {
69
+ if (isDate(raw))
70
+ return raw;
71
+ const m = RFC3339.exec(raw.toUpperCase());
72
+ if (!m || !isDate(m[1]))
73
+ return null;
74
+ const t = Date.parse(raw.toUpperCase());
75
+ return Number.isFinite(t) ? new Date(t).toISOString().slice(0, 10) : null;
76
+ }
64
77
  // A header value must be visible ASCII: fetch refuses others, and that refusal would read as an outage.
65
78
  const PRINTABLE = /^[\x20-\x7e]+$/;
66
79
  function headersFor(a) {
67
80
  const h = {};
68
- const since = a.since?.trim();
69
- const until = a.until?.trim();
70
- if (since) {
71
- if (!isDate(since))
72
- return { why: "since must be a date, YYYY-MM-DD" };
81
+ const sinceRaw = a.since?.trim();
82
+ const untilRaw = a.until?.trim();
83
+ const since = sinceRaw ? kevDate(sinceRaw) : undefined;
84
+ const until = untilRaw ? kevDate(untilRaw) : undefined;
85
+ if (since === null)
86
+ return { why: "since must be a date, YYYY-MM-DD, or an RFC 3339 timestamp such as 2024-04-12T00:00:00Z" };
87
+ if (until === null)
88
+ return { why: "until must be a date, YYYY-MM-DD, or an RFC 3339 timestamp such as 2024-04-12T00:00:00Z" };
89
+ if (since)
73
90
  h["X-EG-Since"] = since;
74
- }
75
- if (until) {
76
- if (!isDate(until))
77
- return { why: "until must be a date, YYYY-MM-DD" };
91
+ if (until)
78
92
  h["X-EG-Until"] = until;
79
- }
80
93
  if (since && until && since > until)
81
94
  return { why: "since must not be after until" };
82
95
  if (a.ransomware !== undefined)
@@ -177,8 +190,8 @@ export function registerKevRecent(server, d) {
177
190
  title: "Recent CISA KEV additions",
178
191
  description: KEV_RECENT_DESCRIPTION,
179
192
  inputSchema: z.object({
180
- since: z.string().optional().describe("earliest kev_added_date to include, YYYY-MM-DD"),
181
- until: z.string().optional().describe("latest kev_added_date to include, YYYY-MM-DD"),
193
+ since: z.string().optional().describe("earliest kev_added_date to include, YYYY-MM-DD (an RFC 3339 timestamp is read as its UTC date)"),
194
+ until: z.string().optional().describe("latest kev_added_date to include, YYYY-MM-DD (an RFC 3339 timestamp is read as its UTC date)"),
182
195
  ransomware: z.boolean().optional().describe("true: only CVEs with known ransomware-campaign use; false: only those without"),
183
196
  vendor: z.string().optional().describe("CISA vendorProject, an exact case-insensitive match, e.g. 'Microsoft'"),
184
197
  limit: z.number().int().min(1).max(KEV_RECENT_MAX_LIMIT).optional().describe("page size (default 50, max 200)"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echelongraph-mcp",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
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
@@ -17,7 +17,7 @@
17
17
  ]
18
18
  }
19
19
  ],
20
- "version": "2.5.0",
20
+ "version": "2.6.0",
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.5.0",
31
+ "version": "2.6.0",
32
32
  "transport": {
33
33
  "type": "stdio"
34
34
  },