echelongraph-mcp 2.6.2 → 2.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,71 @@ 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.3 — 2026-10-04
10
+
11
+ - `exposure_radar` relays the observation window each count was measured over, where the API now
12
+ serves one: `exposed_databases.window` (when our verifier last confirmed each host),
13
+ `leaked_credentials.window` (when each key was last seen) and `shadow_ai.confirmed_exposed.window`
14
+ (when our verifier's deciding probe ran). A window is relayed only when no counted row is undated;
15
+ otherwise the note says how many are, and a malformed window is left out and named. KEV-exposure
16
+ carries no observation time yet. `exposure_radar` stays `not_assessed` with `measured_at` null:
17
+ a window's end is not one observation time (#2438).
18
+ - `vendor_advisories_for_cve`, `search_vendor_advisories` and `get_vendor_advisory` read each
19
+ vendor's window from `GET /api/v1/public/vendor-advisories/coverage` beside their own request,
20
+ and relay it in `coverage` (`vendor_windows`, or `vendor_window` for the detail): the advisories
21
+ held, `earliest_vendor_published_at`, `latest_vendor_published_at` and `history_backfill`.
22
+ `vendor_advisories_for_cve` adds `cve_year` and `vendors_not_fully_held`, the vendors with no
23
+ advisory in the answer of which EchelonGraph holds none, whose earliest held advisory is dated
24
+ after 1 January of the year in the CVE ID, or whose history is still being read, and its note
25
+ names each with its window: an empty answer for CVE-2024-3400 can no longer be read as Palo Alto
26
+ having published none. Its description no longer says an empty answer means none of the polled
27
+ feeds names the CVE: it means none of the advisories EchelonGraph holds does. The search
28
+ and the detail name the vendors whose history is still being read. When the windows cannot be
29
+ read, the answer is still relayed, measured, with the windows null and a note saying so (#2729).
30
+ - `search_vendor_advisories`: a query of 1 or 2 characters matches whole words only
31
+ (`search_match` `word`), as the API now answers it, and the description and the note say so; a
32
+ search's total is counted to at most 1,000, and a capped total (`total_capped` true) is written
33
+ "1,000+" in the note, never as a count. `coverage` gains `total_capped` and `search_match`
34
+ (#2728).
35
+ - `check_sbom`'s 50-second budget bounds the whole call, not only when a batch may start: each
36
+ batch's request may take only what is left of it, and a batch still unanswered when the budget
37
+ runs out is cut off and its purls answered as not sent (`not_sent_reason` `time_budget`). Before,
38
+ a batch started at 49.9 s could run for the 15-second request timeout, past the 60-second limits
39
+ of the SDK's client and the hosted endpoint, which then answered nothing at all. A Retry-After
40
+ wait that ends at the budget's end, before any batch was answered, answers that 429 instead of
41
+ sending a request with no time left (#2756).
42
+ - Repository only, not in the package: the production synthetic probes `check_sbom` with 201
43
+ purls, so every run sends two batches, and fails the probe (`expectation_unmet`) unless both
44
+ were answered; core-backend's tests fail when `MAX_COMPONENTS`, `MAX_PURL_LEN` or
45
+ `API_COMPONENTS_PER_MINUTE` disagree with the API's own values, and every core-backend deploy runs
46
+ them before it builds (`scripts/check-outbound-identity.sh`'s table), refusing on drift (#2757).
47
+ - A success's first text block is cut to fit 30,000 characters when the API's JSON is longer, and
48
+ `structuredContent.data` still carries the answer whole. Through 2.6.2 that block was the JSON
49
+ whatever its size: on production's answers of 2026-10-04 a default `search_cves` for "openssl"
50
+ was 133,300 characters of text, `get_cve` CVE-2021-44228 80,897, `check_affected` openssl 3.0.0
51
+ 156,058 (the Linux kernel at 5.10.0, 364,408), `check_sbom` on a 50-component SBOM 112,820,
52
+ `kev_recent` at limit 200 105,925 and `search_vendor_advisories` at limit 50 103,147, against
53
+ the 60,000-character ceiling; the same calls now measure 13,513, 22,960, 25,951 (27,717),
54
+ 28,733, 31,588 and 19,917. The JSON is first laid out one row a line, which loses nothing; past
55
+ that, a list tool cuts each row to the fields it names (the description cut to 200 characters,
56
+ every long string ending in "…"), then to fewer, and leaves rows out only when its leanest cut
57
+ is still too long (`check_sbom` leaves out `not_affected` rows first); `get_cve` keeps the first
58
+ entries of `cpe_match` and `references`. The note then ends with a sentence starting
59
+ `TEXT CUT` that names what the first text block leaves out, which tool returns a row whole, and,
60
+ when rows are left out, the `offset` or page size at which they fit. An answer that fits is sent
61
+ as before. Every tool description, the server instructions and the methodology resource say so
62
+ (#2783).
63
+ - `search_cves` takes `offset` (0 to 10,000), so the next page a cut text names can be read (#2783).
64
+ - A list beside the rows is cut before the rows are: `check_affected`'s `excluded` and
65
+ `undetermined` samples keep each entry's `cve_id` and `reason`, and `cve_ids` (each match's
66
+ `cve_id`, in order) its first 10, so every match stays in the text (`flash_player` 10.0.0, 200
67
+ matches and 50 excluded entries, had 75 matches left out of it, two of them KEV-listed), and `check_sbom`'s `not_sent_purls` keeps its first 10, with the note
68
+ saying from which position of the input the purls not sent run. A 2,000-purl `check_sbom` call
69
+ that the API's budget stops after 1,200 now keeps the affected rows before the clean ones in the
70
+ text. A cut `check_affected` match keeps `match_reason` (a registry match's advisory interval,
71
+ which names the fixed version) where it fits, and the note names `cve_intel` for fixed versions
72
+ (#2783).
73
+
9
74
  ## 2.6.2 — 2026-10-04
10
75
 
11
76
  - `kev_recent` reads an RFC 3339 `since` or `until` as the date written in it, dropping the time
package/README.md CHANGED
@@ -155,7 +155,7 @@ The example questions are ones a client can answer with that tool alone.
155
155
  | Tool | Title | What it answers | Example question |
156
156
  |---|---|---|---|
157
157
  | `cve_summary` | CVE feed 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. | *How many critical CVEs does the feed hold, and when was it last updated?* |
158
- | `search_cves` | Search CVEs | Search/filter CVEs (severity, min CVSS, text, sort) with EchelonGraph scores and `score_assessed`; the note names each row not yet scored. | *Find critical CVEs that mention Tomcat with a CVSS of 9 or more.* |
158
+ | `search_cves` | Search CVEs | Search/filter CVEs (severity, min CVSS, text, sort) with EchelonGraph scores and `score_assessed`; page with `limit` and `offset`; the note names each row not yet scored. | *Find critical CVEs that mention Tomcat with a CVSS of 9 or more.* |
159
159
  | `get_cve` | CVE detail | 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. | *What are CVE-2024-3094's CVSS, EPSS and CISA-KEV status?* |
160
160
  | `cve_exposure` | Internet exposure for one CVE | Internet-exposure footprint for a CVE: exposed service count (distinct ip:port, the `exposed_hosts` field) + country/product breakdown, from the KEV-exposure radar. | *How many exposed services does EchelonGraph's radar have on record for CVE-2023-44487?* |
161
161
  | `exposure_radar` | Exposure radar totals | 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. | *How many unauthenticated data stores has EchelonGraph's radar confirmed?* |
@@ -312,6 +312,7 @@ A `newest_kev` row's `exposure_state` says whether its count is a measurement:
312
312
  | `exposed_databases.engines` | Distinct engine types among them (for example `redis` or `grafana`). |
313
313
  | `exposed_databases.top_engines`, `exposed_databases.top_countries` | Up to 15 engines and 10 countries, ranked by those services. |
314
314
  | `exposed_databases.pii_likely`, `exposed_databases.pci_likely` | Services whose schema names pass a high-confidence gate for personal data, or for payment-card data. The names are index, database, table or field names in the Shodan or LeakIX banner or in the check's response, never record values; the gate passes one unambiguous term (such as `ssn` or `cardholder`) or two distinct indicators. It is a precision-first schema gate, not a census: a service whose names do not pass it is not counted, whatever it holds, so the other services are not shown to hold no such data. |
315
+ | `exposed_databases.window.from`, `exposed_databases.window.to` | Timestamps, not counts: when EchelonGraph's check last confirmed the oldest and the newest of the services counted answering without authentication, a span of checks made at different times. Relayed only when every counted service has such a time; otherwise the note says how many do not. |
315
316
  | `exposed_databases.last_run_at` | A timestamp, not a count: when the radar last completed a check, a cycle in which its Shodan search, or the LeakIX fallback, answered at least one query (others may have failed), its list of services due for a re-check was read, and the scan opt-out register could be consulted. A cycle that searched nothing, or whose reads failed, does not move it. |
316
317
 
317
318
  #### `leaked_credentials`
@@ -322,6 +323,7 @@ A `newest_kev` row's `exposure_state` says whether its count is a measurement:
322
323
  | `leaked_credentials.distinct_secrets` | Each secret once. |
323
324
  | `leaked_credentials.distinct_repos` | Public GitHub repositories with at least one. |
324
325
  | `leaked_credentials.top_providers`, `leaked_credentials.top_types` | Up to 15 providers and secret types, ranked by (repository, secret) pairs. |
326
+ | `leaked_credentials.window.from`, `leaked_credentials.window.to` | Timestamps, not counts: when EchelonGraph's detector last found the oldest and the newest of the (repository, secret) pairs counted in a public commit. |
325
327
  | `leaked_credentials.last_run_at` | A timestamp, not a count: when the radar last completed a check, a cycle that read the public GitHub event stream (fetches of some of the commits it lists may have failed). A cycle whose read of that stream failed does not move it. |
326
328
 
327
329
  None of them is validated. Each is a credential-shaped string in a public commit that passed
@@ -339,6 +341,7 @@ labels each one. Only `shadow_ai.confirmed_exposed` counts exposed services.
339
341
  | `confirmed_exposed.total` | Confirmed exposed: services EchelonGraph's probes found answering without an authentication gate (liveness `active`, or `rechecking` during a re-check). It is the sum of `confirmed_exposed.by_category`. |
340
342
  | `confirmed_exposed.by_category` | The same services, by category. |
341
343
  | `confirmed_exposed.last_24h` | Confirmed-exposed services first recorded in the last 24 hours. |
344
+ | `confirmed_exposed.window.from`, `confirmed_exposed.window.to` | Timestamps, not counts: when EchelonGraph's verifier ran the probe that last decided the oldest and the newest of the confirmed-exposed services, a span of probes made at different times. Relayed only when every one has such a time. |
342
345
  | `observed.total` | Every Certificate Transparency or Shodan observation on record, whatever its verification state: observed, not exposed. |
343
346
  | `observed.by_category` | The same observations, by category. |
344
347
  | `observed.last_24h` | Observations first recorded in the last 24 hours. |
@@ -485,7 +488,8 @@ never "not affected", and each figure cites its `measured_at`. A prompt makes no
485
488
  Every tool answers in one of two shapes, so a model reading the result cannot mistake an
486
489
  outage for an all-clear:
487
490
 
488
- - **Success** — the first text block is the API's JSON verbatim; the second is a one-line
491
+ - **Success** — the first text block is the API's JSON verbatim (cut to fit 30,000 characters
492
+ when it is longer: see "Long answers" below); the second is a one-line
489
493
  note saying the call succeeded, which base URL answered, and what it found; the third is
490
494
  the structured result as JSON, less what the first two already say (below). When the
491
495
  feed genuinely holds nothing for the query the note says so in words ("we looked and
@@ -515,6 +519,37 @@ what an earlier block already says, so a client that passes only `content` to th
515
519
  sees how the answer was measured. The first text block (the API's JSON, or the failure) and the
516
520
  note after it are where 1.x put them.
517
521
 
522
+
523
+ ### Long answers
524
+
525
+ A model pays for every character of text it reads, and some clients cap what they pass on:
526
+ Claude Code warns past 10,000 tokens of tool output and, past 25,000, saves the result to a
527
+ file and hands the model its path instead. Through 2.6.2 the first text block was the API's JSON
528
+ whatever its size: on production's answers of 2026-10-04 a default `search_cves` page for
529
+ "openssl" was 133,300 characters of text, `get_cve` for CVE-2021-44228 80,897, `check_affected`
530
+ for the Linux kernel at 5.10.0 364,408, `kev_recent` at its largest page 105,925 and
531
+ `search_vendor_advisories` at its largest page 103,147.
532
+
533
+ So when the API's JSON is longer than 30,000 characters, the first text block holds it cut to
534
+ fit, and `structuredContent`'s `data` still holds it whole. First the same JSON is laid out with
535
+ each row on a line of its own, which drops no character of the answer but the indentation. If
536
+ that is still too long, a tool that returns a list of rows cuts each row to the fields it names,
537
+ most kept first (`search_cves` keeps `cve_id`, `severity`, `cvss_v3_score`, `echelongraph_score`,
538
+ `score_assessed`, `epss_score`, `kev_listed` and the first 200 characters of the description,
539
+ among others), with every long string ending in "…". A list beside the rows is cut before any
540
+ row field the first level keeps, and before any row leaves the text: `check_affected`'s
541
+ `excluded` and `undetermined` samples keep each entry's `cve_id` and `reason`, and its `cve_ids`
542
+ (each match's `cve_id`, in order) its first 10, so that every match stays in the text, and `check_sbom`'s `not_sent_purls` keeps its first 10, the note saying from
543
+ which position of the input the purls not sent run, so that a second call can send them without
544
+ reading the list. When even the leanest cut is too long, rows are left out of the text
545
+ (`check_sbom` leaves out the `not_affected` rows first, then the `not_assessed` ones), and the
546
+ note says how to read them: the `offset` to call next, or a smaller `limit`. Any other answer
547
+ keeps the first entries of each of its lists (`get_cve`'s `cpe_match` and `references`). The note
548
+ then ends with one sentence starting `TEXT CUT` that says what the first text block leaves out
549
+ and which tool returns a row whole (`get_cve`, `get_vendor_advisory`, `check_affected`, and
550
+ `cve_intel` a CVE's fixed versions). An answer that fits is sent as before, pretty-printed and
551
+ whole. The same answer is always cut the same way.
552
+
518
553
  ## Structured results
519
554
 
520
555
  Since 2.0.0 every result, success or failure, carries `structuredContent`, and every tool
@@ -530,7 +565,7 @@ declares its shape as an `outputSchema` in `tools/list`, with a title and the an
530
565
  | `coverage` | What the answer covers, where the tool can say: `in_scope` for `cve_exposure`, the list's own account of its count for `search_cves`, the radars that answered for `exposure_radar`, and `assessed`, `not_assessed_reason`, the lookup path and the match counts for `check_affected`. |
531
566
  | `freshness` | The producing radar's last completed check (`last_run_at`), where the API serves one; `null` where it serves none. |
532
567
  | `notes` | The caveats, one sentence each: what the envelope itself needs saying, then the note from the text block. |
533
- | `data` | On a success only: the same JSON as the first text block. |
568
+ | `data` | On a success only: the API's JSON, whole: the first text block carries the same JSON, or, past 30,000 characters, a cut of it that the note names (`TEXT CUT`). |
534
569
  | `error` | On a failure only: `kind`, `path`, `status` and `message`. |
535
570
 
536
571
  The last text block of every result is this structured result serialized as JSON, less what
@@ -574,7 +609,8 @@ a note says so. Per tool:
574
609
  answer gives one time at which what it counts was observed, so no count is presented as a
575
610
  dated measurement. `mcp_servers` dates its verdicts only by a window, `mcp_servers.window.from`
576
611
  to `mcp_servers.window.to`, over checks made at different times, which is not one observation
577
- time. Its numbers keep the labels above. `freshness` holds each radar's `last_run_at` where the
612
+ time; so, since #2438, do `exposed_databases.window`, `leaked_credentials.window` and
613
+ `shadow_ai.confirmed_exposed.window`. `kev_exposure` gives no observation time at all. Its numbers keep the labels above. `freshness` holds each radar's `last_run_at` where the
578
614
  API serves one, for `shadow_ai` also `running`, and for `mcp_servers` also `enabled`.
579
615
  - `check_affected` is `measured` only when the answer says `assessed` true, and `not_assessed`
580
616
  when it says false or does not say. Its `measured_at` and `freshness` are `null`: a match answer
@@ -626,7 +662,7 @@ Where each tool's input travels:
626
662
  | Input | Where it is sent |
627
663
  |---|---|
628
664
  | 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`: `search` | The `X-EG-Search` request header, never the URL. Its severity, minimum CVSS, sort and limit go in the query string. |
665
+ | `search_cves`: `search` | The `X-EG-Search` request header, never the URL. Its severity, minimum CVSS, sort, limit and offset go in the query string. |
630
666
  | `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
667
  | `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
668
  | `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. |
@@ -716,8 +752,12 @@ npm test # build, then the behavioural suite over both protocol eras,
716
752
  npm run smoke # spawn the server + call cve_exposure against the production API
717
753
  ```
718
754
 
719
- `npm run smoke` calls the production API with this package's User-Agent, so its requests are
720
- counted as external MCP adoption. `npm test` never leaves the machine.
755
+ `npm run smoke` calls the production API with this package's User-Agent. Run from our own
756
+ workspace, whose egress address the API recognises as ours, its requests are counted as our own
757
+ traffic and appear in no adoption count. Run from any other machine, they are counted as external
758
+ MCP adoption unless you set `ECHELONGRAPH_MCP_UA` (for example
759
+ `ECHELONGRAPH_MCP_UA=my-smoke/1.0 npm run smoke`), which files them under that token's family
760
+ instead. `npm test` never leaves the machine.
721
761
 
722
762
  `npm test` runs the suite once over a 2026-07-28 `server/discover` and once over a 2025-06-18
723
763
  `initialize`, plus the era tests. To run it against an installed package rather than `dist/`,
package/dist/index.js CHANGED
@@ -15,7 +15,9 @@
15
15
  // relays each radar's answer cut to the fields it can label by what they count, and names
16
16
  // what it left out (readKEVExposure, readExposedDatabases, readLeakedCredentials,
17
17
  // readShadowAI and readMCPServers; #2307, #2313, #2315). content[2] is the envelope below,
18
- // as JSON, less what content[0] and the note already say.
18
+ // as JSON, less what content[0] and the note already say. Past 30,000 characters of JSON,
19
+ // content[0] is that JSON cut to fit and the note ends by saying how (TEXT CUT); the
20
+ // envelope's data is always the answer whole (textBudget.ts, #2783).
19
21
  // - failure (isError: true): the lookup did not complete — unreachable host, non-2xx,
20
22
  // timeout, or a 2xx whose body is not a JSON object — named by tool, cause and base URL,
21
23
  // in content[0]; content[1] is the envelope below, as JSON, less the message's sentences.
@@ -56,6 +58,7 @@ import { isHttpEntrypoint, upstreamHeaders, upstreamUserAgent } from "./runtime.
56
58
  import { registerVendorAdvisoryTools } from "./tools/vendor_advisories.js";
57
59
  import { registerPrompts } from "./prompts.js";
58
60
  import { registerResources } from "./resources.js";
61
+ import { dataText, TEXT_BUDGET_DESCRIPTION } from "./textBudget.js";
59
62
  // The package actually running, read from the package.json that ships beside dist/. The MCP
60
63
  // handshake (serverInfo) and the User-Agent both carry its name and version, so the API's
61
64
  // access log can count adoption per version without a code change per release.
@@ -169,16 +172,20 @@ function retryAfterSeconds(v) {
169
172
  // can fail comes back as a typed Failure so the tool renders it as an error result. A 2xx whose
170
173
  // body is not a JSON object (an SPA shell, an edge challenge page, a literal null) is a failure
171
174
  // too — it is not data. init.headers carries what a caller typed (#1983: X-EG-* headers, never
172
- // the URL); the User-Agent and Accept are this server's and are not overridden.
175
+ // the URL); the User-Agent and Accept are this server's and are not overridden. init.timeoutMs
176
+ // can only shorten the request's timeout below TIMEOUT_MS, never lengthen it: check_sbom passes
177
+ // what is left of its call's budget, so a slow batch cannot carry the call past it (#2756).
173
178
  export async function api(path, init) {
179
+ const limitMs = init?.timeoutMs === undefined ? TIMEOUT_MS : Math.max(0, Math.min(TIMEOUT_MS, Math.floor(init.timeoutMs)));
174
180
  const ctrl = new AbortController();
175
181
  let timedOut = false;
176
182
  const timer = setTimeout(() => {
177
183
  timedOut = true;
178
184
  ctrl.abort();
179
- }, TIMEOUT_MS);
185
+ }, limitMs);
180
186
  const m = init?.method && init.method.toUpperCase() !== "GET" ? { method: init.method.toUpperCase() } : {};
181
- const timeout = () => ({ ok: false, kind: "timeout", path, ...m, detail: `no response within ${TIMEOUT_MS} ms` });
187
+ const own = limitMs === TIMEOUT_MS ? {} : { timeoutMs: limitMs };
188
+ const timeout = () => ({ ok: false, kind: "timeout", path, ...m, ...own, detail: `no response within ${limitMs} ms` });
182
189
  try {
183
190
  let res;
184
191
  try {
@@ -241,7 +248,7 @@ export function describeFailure(f) {
241
248
  case "network":
242
249
  return `EchelonGraph at ${SHOWN_BASE} could not be reached for ${verb} ${f.path}: ${f.detail}.`;
243
250
  case "timeout":
244
- return `EchelonGraph at ${SHOWN_BASE} did not answer ${verb} ${f.path} within ${TIMEOUT_MS} ms.`;
251
+ return `EchelonGraph at ${SHOWN_BASE} did not answer ${verb} ${f.path} within ${f.timeoutMs ?? TIMEOUT_MS} ms.`;
245
252
  case "http":
246
253
  return `EchelonGraph answered HTTP ${f.status} ${where} — the API said: ${f.detail}.`;
247
254
  case "not_json":
@@ -338,12 +345,15 @@ export const checked = (tool, schema, r) => {
338
345
  const p = schema.safeParse(r.structuredContent);
339
346
  return p.success ? r : unexpectedShape(tool, p.error);
340
347
  };
341
- // A success: content[0] is the data, content[1] the note, content[2] the envelope less both;
342
- // the envelope carries both, the note as sentences after any the envelope adds about itself.
343
- export const succeeded = (data, note, env) => {
348
+ // A success: content[0] is the data (cut to DATA_TEXT_BUDGET when it is larger, #2783), content[1]
349
+ // the note, content[2] the envelope less both; the envelope carries both, the note as sentences
350
+ // after any the envelope adds about itself. structuredContent.data is the data whole.
351
+ export const succeeded = (data, note, env, cut) => {
344
352
  const own = env.notes ?? [];
345
- const structuredContent = { ...env, notes: [...own, ...sentences(note)], data };
346
- return { content: [text(JSON.stringify(data, null, 2)), text(note), envelopeText(structuredContent, note, own)], structuredContent };
353
+ const shown = dataText(data, cut);
354
+ const said = shown.said ? `${note} ${shown.said}` : note;
355
+ const structuredContent = { ...env, notes: [...own, ...sentences(said)], data };
356
+ return { content: [text(shown.text), text(said), envelopeText(structuredContent, said, own)], structuredContent };
347
357
  };
348
358
  export const okHead = (tool, status) => `${tool} OK: EchelonGraph answered${status === undefined ? "" : ` HTTP ${status}`} from ${SHOWN_BASE}.`;
349
359
  // Tolerant readers for the note: a missing or renamed field degrades the sentence, never
@@ -557,6 +567,24 @@ async function cveSummary() {
557
567
  return crashed(tool, e);
558
568
  }
559
569
  }
570
+ // #2783: a page of production rows is about 6,600 characters a row as pretty JSON (38 fields, the
571
+ // description, cpe_match, references and score_factors among them), so the default page of 20 was
572
+ // 133,300 characters of text. Past DATA_TEXT_BUDGET each row in the first text block keeps the
573
+ // fields the description promises and a few more a triage reads, the description cut to 200
574
+ // characters; then, for a page of 50, the fields the description promises with the description cut
575
+ // to 100. structuredContent.data keeps every field of every row; get_cve returns one record whole.
576
+ const SEARCH_CVES_TEXT = {
577
+ rows: "cves",
578
+ levels: [
579
+ {
580
+ 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"],
581
+ clip: 200,
582
+ },
583
+ { keep: ["cve_id", "description", "severity", "cvss_v3_score", "echelongraph_score", "score_assessed", "score_unassessed_reason", "epss_score", "kev_listed", "published"], clip: 100 },
584
+ ],
585
+ whole: "get_cve returns any one of these CVEs' records whole.",
586
+ page: (shown, _rows, d) => `To read the rows left out in the text, call search_cves again with the same arguments and offset ${(numAt(d, "offset") ?? 0) + shown}; a page of ${shown} rows or fewer like these fits the text without leaving rows out.`,
587
+ };
560
588
  async function searchCVEs(a) {
561
589
  const tool = "search_cves";
562
590
  try {
@@ -571,6 +599,8 @@ async function searchCVEs(a) {
571
599
  if (a.sort)
572
600
  q.set("sort", a.sort);
573
601
  q.set("limit", String(a.limit ?? 20));
602
+ if (a.offset)
603
+ q.set("offset", String(a.offset));
574
604
  const r = await api(`/api/v1/public/cves?${q.toString()}`, a.search ? { headers: { "X-EG-Search": encodeURIComponent(a.search) } } : undefined);
575
605
  if (!r.ok)
576
606
  return failed(tool, r);
@@ -610,16 +640,16 @@ async function searchCVEs(a) {
610
640
  const empty = shown === 0 && (offset ?? 0) === 0;
611
641
  return succeeded(r.data, empty
612
642
  ? `${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}`
613
- : `${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);
643
+ : `${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, SEARCH_CVES_TEXT);
614
644
  }
615
645
  const total = numAt(r.data, "total") ?? shown;
616
646
  if (total === undefined)
617
- return succeeded(r.data, `${head}${tail}`, env);
647
+ return succeeded(r.data, `${head}${tail}`, env, SEARCH_CVES_TEXT);
618
648
  if (total === 0 && floor !== true) {
619
- 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);
649
+ 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, SEARCH_CVES_TEXT);
620
650
  }
621
651
  const matched = floor === true ? `at least ${total} CVEs (total_is_lower_bound is true: the count stopped at that floor)` : `${total} CVEs`;
622
- return succeeded(r.data, `${head} The query matched ${matched}${shown === undefined ? "" : `; ${shown} returned in this page`}.${tail}`, env);
652
+ return succeeded(r.data, `${head} The query matched ${matched}${shown === undefined ? "" : `; ${shown} returned in this page`}.${tail}`, env, SEARCH_CVES_TEXT);
623
653
  }
624
654
  catch (e) {
625
655
  return crashed(tool, e);
@@ -882,6 +912,7 @@ const SHADOW_AI_STATS = new Set([
882
912
  "total",
883
913
  "by_category",
884
914
  "visible_by_category",
915
+ "confirmed_window",
885
916
  "last_observation",
886
917
  "last_24h_count",
887
918
  "last_24h_visible_count",
@@ -972,6 +1003,34 @@ function leftOut(radar, unknown, unusable) {
972
1003
  out.push(`Left out of ${radar} because they were not in the expected shape: ${unusable.join(", ")}.`);
973
1004
  return out;
974
1005
  }
1006
+ function readWindow(v) {
1007
+ if (v === undefined || v === null)
1008
+ return { bad: false };
1009
+ if (!isPlainObject(v))
1010
+ return { bad: true };
1011
+ const from = strAt(v, "from");
1012
+ const to = strAt(v, "to");
1013
+ const undated = v.undated;
1014
+ if (!isCount(undated) || undated < 0 || !Number.isInteger(undated))
1015
+ return { bad: true };
1016
+ if (undated > 0)
1017
+ return { undated, bad: false };
1018
+ if (v.from === null && v.to === null)
1019
+ return { bad: false }; // nothing counted, so nothing dated
1020
+ if (!realInstant(from) || !realInstant(to) || Date.parse(from) > Date.parse(to))
1021
+ return { bad: true };
1022
+ return { window: { from, to }, bad: false };
1023
+ }
1024
+ // The sentence for a radar's window, or for the rows it leaves undated.
1025
+ function windowSentences(prefix, w, what) {
1026
+ if (w.window) {
1027
+ return [`${prefix}.window.from (${w.window.from}) and ${prefix}.window.to (${w.window.to}) are timestamps, not counts: ${what}. They span observations made at different times, not one observation time.`];
1028
+ }
1029
+ if (w.undated !== undefined) {
1030
+ return [`${prefix} carries no window: ${w.undated} of the rows it counts have no observation time on record, so a window over the rest would not describe them.`];
1031
+ }
1032
+ return [];
1033
+ }
975
1034
  function readRadar(body, spec) {
976
1035
  const src = isPlainObject(body) ? body : {};
977
1036
  const data = {};
@@ -1001,9 +1060,18 @@ function readRadar(body, spec) {
1001
1060
  else if (present(k) && typeof src[k] !== "string")
1002
1061
  unusable.push(k);
1003
1062
  }
1004
- const known = new Set([...spec.counts, ...Object.keys(spec.rows), ...spec.instants]);
1063
+ let undated;
1064
+ if (spec.window) {
1065
+ const w = readWindow(src[spec.window]);
1066
+ if (w.window)
1067
+ data.window = w.window;
1068
+ else if (w.bad)
1069
+ unusable.push(spec.window);
1070
+ undated = w.undated;
1071
+ }
1072
+ const known = new Set([...spec.counts, ...Object.keys(spec.rows), ...spec.instants, ...(spec.window ? [spec.window] : [])]);
1005
1073
  unknown.unshift(...Object.keys(src).filter((k) => !known.has(k)));
1006
- return { data, unknown, unusable };
1074
+ return { data, unknown, unusable, undated };
1007
1075
  }
1008
1076
  // ── last_run_at: when a radar last COMPLETED a check (#2335) ──
1009
1077
  //
@@ -1201,9 +1269,10 @@ const EXPOSED_DB_SPEC = {
1201
1269
  top_countries: { required: { country: "string", hosts: "number" } },
1202
1270
  },
1203
1271
  instants: ["generated_at", "last_run_at"],
1272
+ window: "observed_window",
1204
1273
  };
1205
1274
  function readExposedDatabases(body) {
1206
- const { data, unknown, unusable } = readRadar(body, EXPOSED_DB_SPEC);
1275
+ const { data, unknown, unusable, undated } = readRadar(body, EXPOSED_DB_SPEC);
1207
1276
  const sentences = [];
1208
1277
  const n = (k) => numIn(data, k);
1209
1278
  const totals = [
@@ -1226,6 +1295,7 @@ function readExposedDatabases(body) {
1226
1295
  if (gated.length) {
1227
1296
  sentences.push(`${gated.join("; ")}. The names are index, database, table or field names in the Shodan or LeakIX banner or in the check's response, never record values, and the gate passes one unambiguous term (such as ssn or cardholder) or two distinct indicators. It is precision-first: a service whose names do not pass it is not counted, whatever it holds, so the other services are not shown to hold no personal or card data.`);
1228
1297
  }
1298
+ sentences.push(...windowSentences("exposed_databases", { window: data.window, undated, bad: false }, "when EchelonGraph's check last confirmed the oldest and the newest of the services counted answering without authentication"));
1229
1299
  sentences.push(...lastCheck("exposed_databases", data));
1230
1300
  sentences.push(...leftOut("exposed_databases", unknown, unusable));
1231
1301
  return { data, sentences };
@@ -1249,9 +1319,10 @@ const LEAKED_CREDS_SPEC = {
1249
1319
  top_types: { required: { secret_type: "string", count: "number" } },
1250
1320
  },
1251
1321
  instants: ["generated_at", "last_run_at"],
1322
+ window: "observed_window",
1252
1323
  };
1253
1324
  function readLeakedCredentials(body) {
1254
- const { data, unknown, unusable } = readRadar(body, LEAKED_CREDS_SPEC);
1325
+ const { data, unknown, unusable, undated } = readRadar(body, LEAKED_CREDS_SPEC);
1255
1326
  const sentences = [];
1256
1327
  const n = (k) => numIn(data, k);
1257
1328
  const totals = [
@@ -1271,6 +1342,7 @@ function readLeakedCredentials(body) {
1271
1342
  if (totals.length || rankings.length) {
1272
1343
  sentences.push("None of these is validated: each is a credential-shaped string in a public commit that passed EchelonGraph's filters, at most structurally checked (a checksum or format decode) and never tested against its provider, so none of them is a count of working credentials.");
1273
1344
  }
1345
+ sentences.push(...windowSentences("leaked_credentials", { window: data.window, undated, bad: false }, "when EchelonGraph's detector last found the oldest and the newest of the (repository, secret) pairs counted in a public commit"));
1274
1346
  sentences.push(...lastCheck("leaked_credentials", data));
1275
1347
  sentences.push(...leftOut("leaked_credentials", unknown, unusable));
1276
1348
  return { data, sentences };
@@ -1295,6 +1367,13 @@ function readShadowAI(sa) {
1295
1367
  const last24Visible = take("last_24h_visible_count", numAt(stats, "last_24h_visible_count"));
1296
1368
  if (last24Visible !== undefined)
1297
1369
  confirmed.last_24h = last24Visible;
1370
+ // When the confirmed rows were observed (#2438): the verifier's deciding probe times, over the
1371
+ // rows visible_by_category counts. Relayed only beside the count it dates.
1372
+ const confirmedWindow = readWindow(field(stats, "confirmed_window"));
1373
+ if (!confirmedWindow.bad && field(stats, "confirmed_window") !== undefined)
1374
+ used.add("confirmed_window");
1375
+ if (confirmedWindow.window && confirmed.total !== undefined)
1376
+ confirmed.window = confirmedWindow.window;
1298
1377
  // Observed: every row of shadow_ct_observations, whatever its liveness.
1299
1378
  const observed = {};
1300
1379
  const total = take("total", numAt(stats, "total"));
@@ -1330,6 +1409,9 @@ function readShadowAI(sa) {
1330
1409
  sentences.push(confirmed.total === undefined
1331
1410
  ? `Shadow AI: the answer carries no usable stats.visible_by_category, so it does not say how many services are confirmed exposed${last24Visible === undefined ? "" : `; confirmed_exposed.last_24h (${last24Visible}) counts confirmed-exposed services first recorded in the last 24 h`}.`
1332
1411
  : `Shadow AI confirmed exposed: ${confirmed.total} (confirmed_exposed.total, the sum of confirmed_exposed.by_category: services EchelonGraph's probes found answering without an authentication gate, with liveness active or, during a re-check, rechecking)${last24Visible === undefined ? "" : `, ${last24Visible} of them first recorded in the last 24 h (confirmed_exposed.last_24h)`}. Of the shadow_ai numbers, only confirmed_exposed counts exposed services.`);
1412
+ if (confirmed.total !== undefined) {
1413
+ sentences.push(...windowSentences("shadow_ai.confirmed_exposed", { window: confirmedWindow.window, undated: confirmedWindow.undated, bad: false }, "when EchelonGraph's verifier ran the probe that last decided the oldest and the newest of the services confirmed_exposed counts"));
1414
+ }
1333
1415
  if (total !== undefined) {
1334
1416
  sentences.push(`Shadow AI observed: ${total} (observed.total) counts every Certificate Transparency or Shodan observation on record, whatever its verification state: that many observed, not that many exposed.`);
1335
1417
  }
@@ -1621,9 +1703,9 @@ function readMCPServers(src) {
1621
1703
  }
1622
1704
  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. ` +
1623
1705
  `kev_exposure: kev_exposure.distinct_hosts counts distinct ip:port services, not machines (a machine answering on two ports counts twice), with at least one CISA-KEV-listed CVE on record, and kev_exposure.ransomware_hosts those with a ransomware-linked one; kev_exposure.kev_cves_exposed and kev_exposure.ransomware_cves count distinct CVEs with at least one such service; kev_exposure.correlations counts service×CVE pairs, not services, so a service with three KEV CVEs counts three times. kev_exposure.top_products, kev_exposure.top_countries and kev_exposure.top_cves rank up to 12 products, 10 countries and 12 CVEs by those services; kev_exposure.top_cves[].cvss_v3_score and kev_exposure.top_cves[].epss_score are the highest CVSS v3 base score and EPSS probability recorded on that CVE's observations. kev_exposure.trend counts service×CVE pairs still on record by the week in which each was first recorded, over 12 weeks, so earlier weeks read low. kev_exposure.newest_kev lists the 15 CVEs that EchelonGraph's CVE records most recently mark as CISA-KEV-listed, each with an exposure_state: exposed, where kev_exposure.newest_kev[].exposed_hosts counts distinct ip:port services on record with it; or not_assessed, where the API's answer holds no measurement for that CVE (its 0 is not one) and no count is relayed, and cve_exposure says per CVE whether the radar tracks it. kev_exposure.newest_kev[].cvss_v3_score and kev_exposure.newest_kev[].epss_score are the CVE record's CVSS v3 base score and EPSS probability. ` +
1624
- `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. ` +
1625
- `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. 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. ` +
1626
- `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. 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. ` +
1706
+ `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. ` +
1707
+ `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. ` +
1708
+ `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. ` +
1627
1709
  `mcp_servers: counts and timestamps only, no hostname. mcp_servers.total counts hostnames the AI-exposure radar has checked for an MCP server, each once by its latest verdict on record, and not every one is an MCP server; EchelonGraph's own control servers are left out, and mcp_servers.own_controls_excluded counts them. mcp_servers.total is mcp_servers.protected plus mcp_servers.pending_readjudication plus mcp_servers.not_assessed. mcp_servers.protected counts hostnames whose /mcp endpoint asked for credentials and whose OAuth protected-resource metadata validated under RFC 9728; mcp_servers.prm_via divides them by where that document was found: mcp_servers.prm_via.header, mcp_servers.prm_via.wellknown_path and mcp_servers.prm_via.wellknown_root. mcp_servers.pending_readjudication counts verdicts of a rule since replaced, not yet re-checked. mcp_servers.not_assessed counts the rest, whose protection the radar could not assess (not assessed does not mean unprotected), and mcp_servers.not_assessed_by_reason puts each in one bucket. mcp_servers.not_assessed_by_reason.identified_no_challenge holds servers that identified themselves as MCP servers and did not ask for credentials at the handshake. That is normal in MCP: authorization is optional in the spec, and a server can enforce it at tools/call instead, which EchelonGraph never sends, so this bucket is not a finding of exposure. ${MCP_CHALLENGE_REASON_FIELDS} hold endpoints that asked for credentials but whose RFC 9728 metadata did not validate, by why, so none of them is shown to lack protection; mcp_servers.not_assessed_by_reason.challenge_unadjudicated holds endpoints that asked for credentials before that check existed, not yet re-checked. mcp_servers.not_assessed_by_reason.no_http_answer holds hostnames that gave no HTTP answer, and mcp_servers.not_assessed_by_reason.not_identified_as_mcp hostnames whose HTTP answer identified no MCP server. mcp_servers.era divides every counted hostname by protocol era: mcp_servers.era.legacy; mcp_servers.era.dual, a lower bound; mcp_servers.era.modern, not proven modern-only; mcp_servers.era.unknown; and mcp_servers.era.not_measured, recorded before the era probe and not re-checked since. mcp_servers.transport divides them by transport: mcp_servers.transport.streamable_http, mcp_servers.transport.legacy_sse, mcp_servers.transport.unknown and mcp_servers.transport.not_measured. mcp_servers.window.from and mcp_servers.window.to are when the oldest and the newest of the verdicts counted were last checked. mcp_servers.last_run_at is a timestamp, not a count: when the AI-exposure radar, which checks other AI services too, last completed a check; mcp_servers.enabled is whether one completed within ${MCP_ENABLED_WITHIN} of the answer, and mcp_servers.counted_at is when the API read the counts. A partition whose buckets do not add up to the count it divides is left out and named; an answer that does not say it is the MCP-server counts, or whose mcp_servers.total, mcp_servers.protected, mcp_servers.pending_readjudication and mcp_servers.not_assessed are missing or contradict each other, is a failure. ${SHODAN_OWNERSHIP}`;
1628
1710
  // exposure_radar's envelope. Every radar answered, but no stats answer says when the services
1629
1711
  // or records it counts were observed: each count covers everything that radar still holds on
@@ -1646,7 +1728,7 @@ const EXPOSURE_RADAR_DESCRIPTION = `Aggregate totals from EchelonGraph's interne
1646
1728
  // and the envelope's own note names it.
1647
1729
  const RADAR_METHOD = "Aggregate counts over what each radar holds on record: kev_exposure, Shodan banners whose version maps to a CISA-KEV-listed CVE; exposed_databases, services found through Shodan (LeakIX when Shodan query credits run low) and confirmed by EchelonGraph's own identified check; leaked_credentials, credential-shaped strings in public GitHub push events; shadow_ai, services found through Certificate Transparency logs and Shodan and checked by EchelonGraph's identified probes; mcp_servers, the latest verdict on record per hostname named like an MCP server in EchelonGraph's own Certificate Transparency feed, from EchelonGraph's identified MCP probe (server/discover, and initialize only if that is refused; never tools/call), where protected means the endpoint's RFC 9728 protected-resource metadata validated.";
1648
1730
  const RADAR_NAMES = RADARS.map(([name]) => name);
1649
- const RADAR_STATE_NOTE = "state is not_assessed: every radar answered, but none gives one time at which what it counts was observed: kev_exposure, exposed_databases, leaked_credentials and shadow_ai give none, and mcp_servers dates its verdicts at most by a window (mcp_servers.window), from the oldest check among them to the newest, so no count here is presented as a dated measurement and measured_at is null. Each count is what that radar holds on record, and the note labels each one by what it counts.";
1731
+ const RADAR_STATE_NOTE = "state is not_assessed: every radar answered, but none gives one time at which what it counts was observed: kev_exposure gives none, and exposed_databases, leaked_credentials, shadow_ai's confirmed_exposed and mcp_servers date what they count at most by a window (exposed_databases.window, leaked_credentials.window, shadow_ai.confirmed_exposed.window, mcp_servers.window), from the oldest observation among the rows counted to the newest, so no count here is presented as a dated measurement and measured_at is null. Each count is what that radar holds on record, and the note labels each one by what it counts.";
1650
1732
  const RADAR_FRESHNESS_NOTE = "freshness gives each radar's last completed check where the API serves one (freshness.kev_exposure.last_run_at, freshness.exposed_databases.last_run_at, freshness.leaked_credentials.last_run_at, freshness.shadow_ai.last_run_at with freshness.shadow_ai.running, and freshness.mcp_servers.last_run_at with freshness.mcp_servers.enabled), and null where it does not.";
1651
1733
  async function exposureRadar() {
1652
1734
  const tool = "exposure_radar";
@@ -1902,10 +1984,13 @@ const zRow = (spec) => z.strictObject({
1902
1984
  ...Object.fromEntries(Object.entries(spec.required).map(([k, kind]) => [k, zKind(kind)])),
1903
1985
  ...Object.fromEntries(Object.entries(spec.optional ?? {}).map(([k, kind]) => [k, zKind(kind).optional()])),
1904
1986
  });
1987
+ // A relayed observation window (#2438): two instants, never a count.
1988
+ const Window = z.strictObject({ from: Instant, to: Instant });
1905
1989
  const zRadar = (spec, rows = {}) => z.strictObject({
1906
1990
  ...Object.fromEntries(spec.counts.map((k) => [k, z.number().optional()])),
1907
1991
  ...Object.fromEntries(Object.entries(spec.rows).map(([k, rs]) => [k, (rows[k] ?? z.array(zRow(rs))).optional()])),
1908
1992
  ...Object.fromEntries(spec.instants.map((k) => [k, Instant.optional()])),
1993
+ ...(spec.window ? { window: Window.optional() } : {}),
1909
1994
  });
1910
1995
  // A newest_kev row as newestKEVRow relays it: `tracked` is read, not relayed, and a count only
1911
1996
  // when the row's exposure_state is not not_assessed.
@@ -1919,7 +2004,9 @@ const NewestKEVRow = z.strictObject({
1919
2004
  const CountMap = z.record(z.string(), z.number());
1920
2005
  const countRowsOf = (label) => z.array(z.strictObject({ [label]: z.string(), count: z.number() }));
1921
2006
  const ShadowAIData = z.strictObject({
1922
- confirmed_exposed: z.strictObject({ total: z.number().optional(), by_category: CountMap.optional(), last_24h: z.number().optional() }).optional(),
2007
+ confirmed_exposed: z
2008
+ .strictObject({ total: z.number().optional(), by_category: CountMap.optional(), last_24h: z.number().optional(), window: Window.optional() })
2009
+ .optional(),
1923
2010
  observed: z
1924
2011
  .strictObject({
1925
2012
  total: z.number().optional(),
@@ -1986,6 +2073,7 @@ const INSTRUCTIONS = [
1986
2073
  "Everything these tools return is public: EchelonGraph's CVE Pulse feed, and aggregate, host-redacted totals from its exposure radars.",
1987
2074
  "Every result says how it was measured: state, measured_at, method, coverage, freshness and notes, in its structuredContent and again in its text.",
1988
2075
  "Its last text block is that structuredContent as JSON, less what an earlier text block already gives verbatim: data, which is a success's first text block; the sentences of the text block just before it (the note, or a failure's message), with which structuredContent's notes end; and method, where that block quotes it.",
2076
+ "Past 30,000 characters of JSON, that first text block holds data cut to fit, and the note says what the cut leaves out and where to read it (TEXT CUT); structuredContent's data always holds it whole.",
1989
2077
  "state is measured, not_assessed, failed or invalid_input.",
1990
2078
  "not_assessed means the answer holds no dated measurement of what was asked, so no count in it is presented as one.",
1991
2079
  "It can still relay a count, as what the source holds on record, undated, and its notes say what each count is: cve_exposure's exposed_hosts when its exposure_state is exposed, and exposure_radar's labelled totals, are such counts.",
@@ -2026,40 +2114,42 @@ export function createServer() {
2026
2114
  const server = new McpServer({ name: NAME, version: VERSION }, { instructions: INSTRUCTIONS, capabilities: { tools: { listChanged: false }, prompts: { listChanged: false }, resources: { listChanged: false } } });
2027
2115
  server.registerTool("cve_summary", {
2028
2116
  title: "CVE feed summary",
2029
- 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} ${SUMMARY_POLLER_DESCRIPTION} The feed is polled from its sources on a schedule, so this is the state as of that update. ${FEED_ENVELOPE}`,
2117
+ 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} ${SUMMARY_POLLER_DESCRIPTION} The feed is polled from its sources on a schedule, so this is the state as of that update. ${FEED_ENVELOPE} ${TEXT_BUDGET_DESCRIPTION}`,
2030
2118
  outputSchema: CVE_SUMMARY_OUTPUT,
2031
2119
  annotations: ANNOTATIONS,
2032
2120
  }, async () => checked("cve_summary", CVE_SUMMARY_OUTPUT, await cveSummary()));
2033
2121
  server.registerTool("search_cves", {
2034
2122
  title: "Search CVEs",
2035
- 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.`,
2123
+ 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.`,
2036
2124
  inputSchema: z.object({
2037
2125
  search: z.string().optional().describe("free-text search (product, vendor, or keyword, e.g. 'tomcat')"),
2038
2126
  severity: z.enum(["CRITICAL", "HIGH", "MEDIUM", "LOW"]).optional().describe("filter to one severity"),
2039
2127
  min_cvss: z.number().min(0).max(10).optional().describe("minimum CVSS score"),
2040
2128
  sort: z.enum(["published", "modified", "nvd", "echelongraph", "epss"]).optional().describe("sort order (default: published)"),
2041
2129
  limit: z.number().int().min(1).max(50).optional().describe("page size (default 20, max 50)"),
2130
+ // #2783: the next page, which the note names when the text leaves rows out.
2131
+ offset: z.number().int().min(0).max(10000).optional().describe("rows to skip (default 0)"),
2042
2132
  }),
2043
2133
  outputSchema: SEARCH_CVES_OUTPUT,
2044
2134
  annotations: ANNOTATIONS,
2045
2135
  }, async (a) => checked("search_cves", SEARCH_CVES_OUTPUT, await searchCVEs(a)));
2046
2136
  server.registerTool("get_cve", {
2047
2137
  title: "CVE detail",
2048
- 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, 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}`,
2138
+ 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, 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.`,
2049
2139
  inputSchema: z.object({ cve_id: CVE_ID_ARG }),
2050
2140
  outputSchema: GET_CVE_OUTPUT,
2051
2141
  annotations: ANNOTATIONS,
2052
2142
  }, async ({ cve_id }) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(cve_id)));
2053
2143
  server.registerTool("cve_exposure", {
2054
2144
  title: "Internet exposure for one CVE",
2055
- description: `${CVE_EXPOSURE_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: the per-CVE answer says when EchelonGraph last wrote a row (last_seen), not when any counted service was observed, so no count is presented as a dated measurement; the count is still relayed, labelled, as what the radar holds on record. exposure_state says what the count is: exposed, measured_zero, not_assessed or tracking_unknown; coverage.in_scope is the API's tracked verdict; freshness is null, since the per-CVE answer carries no last completed check; data is the API's JSON. The result's last text block repeats the structured result without data (the first text block), without the note's sentences (the text block before it), with which notes ends, and without method where the note quotes it verbatim ("Method: …").`,
2145
+ description: `${CVE_EXPOSURE_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: the per-CVE answer says when EchelonGraph last wrote a row (last_seen), not when any counted service was observed, so no count is presented as a dated measurement; the count is still relayed, labelled, as what the radar holds on record. exposure_state says what the count is: exposed, measured_zero, not_assessed or tracking_unknown; coverage.in_scope is the API's tracked verdict; freshness is null, since the per-CVE answer carries no last completed check; data is the API's JSON. The result's last text block repeats the structured result without data (the first text block), without the note's sentences (the text block before it), with which notes ends, and without method where the note quotes it verbatim ("Method: …"). ${TEXT_BUDGET_DESCRIPTION}`,
2056
2146
  inputSchema: z.object({ cve_id: CVE_ID_ARG }),
2057
2147
  outputSchema: CVE_EXPOSURE_OUTPUT,
2058
2148
  annotations: ANNOTATIONS,
2059
2149
  }, async ({ cve_id }) => checked("cve_exposure", CVE_EXPOSURE_OUTPUT, await cveExposure(cve_id)));
2060
2150
  server.registerTool("exposure_radar", {
2061
2151
  title: "Exposure radar totals",
2062
- description: `${EXPOSURE_RADAR_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: every radar answered, but none gives one time at which what it counts was observed (mcp_servers dates its verdicts at most by a window, mcp_servers.window), so no count is presented as a dated measurement; each count is still relayed, labelled, as what that radar holds on record. freshness gives each radar's last_run_at (and running for shadow_ai, enabled for mcp_servers) where the API serves one; coverage names the radars that answered; data is the relayed result above. The result's last text block repeats the structured result without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.`,
2152
+ description: `${EXPOSURE_RADAR_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: every radar answered, but none gives one time at which what it counts was observed (mcp_servers dates its verdicts at most by a window, mcp_servers.window), so no count is presented as a dated measurement; each count is still relayed, labelled, as what that radar holds on record. freshness gives each radar's last_run_at (and running for shadow_ai, enabled for mcp_servers) where the API serves one; coverage names the radars that answered; data is the relayed result above. The result's last text block repeats the structured result without data (the first text block) and without the note's sentences (the text block before it), with which notes ends. ${TEXT_BUDGET_DESCRIPTION}`,
2063
2153
  outputSchema: EXPOSURE_RADAR_OUTPUT,
2064
2154
  annotations: ANNOTATIONS,
2065
2155
  }, async () => checked("exposure_radar", EXPOSURE_RADAR_OUTPUT, await exposureRadar()));
@@ -2072,7 +2162,7 @@ export function createServer() {
2072
2162
  registerCveIntel(server);
2073
2163
  registerGetCwe(server);
2074
2164
  // #2719: vendor_advisories_for_cve, get_vendor_advisory, search_vendor_advisories.
2075
- registerVendorAdvisoryTools(server, { api, failed, badInput, crashed, succeeded, checked, okHead, envelopeSchema, annotations: ANNOTATIONS });
2165
+ registerVendorAdvisoryTools(server, { api, failed, describeFailure, badInput, crashed, succeeded, checked, okHead, envelopeSchema, annotations: ANNOTATIONS });
2076
2166
  // #2722: prompts and resources, in prompts.ts and resources.ts. Both lists are fixed per
2077
2167
  // connection, so listChanged is false above.
2078
2168
  registerPrompts(server, { cveId: CVE_ID });