echelongraph-mcp 1.0.4 → 2.1.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/README.md CHANGED
@@ -19,7 +19,7 @@ API call a tool needs to answer.
19
19
  |---|---|
20
20
  | `cve_summary` | Counts of active CVEs by severity, and when the feed was last updated. |
21
21
  | `search_cves` | Search/filter CVEs (severity, min CVSS, text, sort) with EchelonGraph scores. |
22
- | `get_cve` | Full detail for one CVE (CVSS v3/v4, EG score, EPSS, KEV+ransomware, GHSA, CWE, references). |
22
+ | `get_cve` | Full record for one CVE: CVSS v3 and (when scored) v4, the EchelonGraph score and its confidence, EPSS, CISA-KEV status and known ransomware use, the GitHub GHSA id, references, and its published, modified and `updated_at` times. |
23
23
  | `cve_exposure` | Internet-exposure footprint for a CVE: exposed service count (distinct ip:port, the `exposed_hosts` field) + country/product breakdown, from the KEV-exposure radar. |
24
24
  | `exposure_radar` | Aggregate totals across the exposure radars: services running CISA-KEV CVEs; unauthenticated data stores and observability UIs, found through Shodan (LeakIX when Shodan query credits run low) and then confirmed by EchelonGraph's own identified check, which is not a pure read (on Redis it names its client; on ClickHouse its query lands in the server's query log); leaked credentials; shadow AI. Every number is labelled by what it counts, and a field the tool cannot label is left out and named. |
25
25
 
@@ -30,29 +30,38 @@ sources on a schedule; each radar refreshes on its own.
30
30
 
31
31
  Every 12 hours, when Shodan query credits allow, the KEV-exposure radar runs one Shodan query
32
32
  per tracked product, reads up to 100 ip:port services per query, and keeps a service when its
33
- banner version matches a CISA-KEV or high-EPSS (>= 0.5) CVE. A service not seen on its port
34
- for 21 days is dropped. A count is therefore a banner-version inference over a sample: not an
35
- exploit test, and not an internet-wide census.
36
-
37
- `last_seen` is when a service was last seen listening on its port, not when its vulnerable
38
- version was last confirmed. Between searches, a re-check that finds the port still listed by
39
- Shodan InternetDB refreshes `last_seen` without re-reading the banner, so a patched service can
40
- stay counted while its port stays open.
33
+ banner version matches a CISA-KEV or high-EPSS (>= 0.5) CVE. A service whose row has not been
34
+ written or refreshed for 21 days is dropped. A count is therefore a banner-version inference
35
+ over a sample: not an exploit test, and not an internet-wide census.
36
+
37
+ `last_seen` is when EchelonGraph last wrote or refreshed a service's row, not when the service
38
+ was observed, and not when its vulnerable version was last confirmed. It is set to the time of
39
+ the write when a search matches the banner, and again when a re-check finds the port still
40
+ listed by Shodan InternetDB, without re-reading the banner, so a patched service can stay
41
+ counted while its port stays open. Shodan's own time for the banner is not stored. So
42
+ `last_seen` dates the write, never the sighting, and it is never `measured_at` (see
43
+ "Structured results").
41
44
 
42
45
  The unit is a service, not a machine. Shodan returns one banner per port and the radar keys
43
46
  each observation on ip:port, so a machine answering on two ports counts twice. The API's field
44
47
  names are kept (`exposed_hosts` here; `distinct_hosts`, `ransomware_hosts` and each ranked
45
48
  row's count in `exposure_radar`), but every one of them counts ip:port services.
46
49
 
47
- The radar only looks for its **tracked** set of CVEs, so a zero means different things:
50
+ The radar only looks for its **tracked** set of CVEs, so a zero means different things. The
51
+ note tags each case with its `exposure_state`, as "(exposure_state: …)"; that tag says what the
52
+ count is, and is not the result's `state`:
48
53
 
49
54
  | API answer | What the tool says |
50
55
  |---|---|
51
- | `tracked: false` | **NOT ASSESSED**: outside the radar's tracked set; 0 is not a measurement. |
52
- | `tracked: true`, `exposed_hosts: 0` | A measured zero in the radar's sample: none of the up to 100 services it read per tracked-product query matched. Not an internet-wide zero. |
53
- | no `tracked` field | The API did not say whether the CVE is in the tracked set: an older API, or the radar cannot decide (for example a CISA-KEV or high-EPSS CVE in a tracked product with 0 services on record). 0 exposed services on record, with no claim either way. |
56
+ | `tracked: true`, `exposed_hosts` above 0 | `exposure_state` `exposed`: the count of services the radar has on record for the CVE. |
57
+ | `tracked: false` | `exposure_state` `not_assessed`, **NOT ASSESSED**: outside the radar's tracked set; 0 is not a measurement. |
58
+ | `tracked: true`, `exposed_hosts: 0` | `exposure_state` `measured_zero`, a zero in the radar's sample: none of the up to 100 services it read per tracked-product query matched. Not an internet-wide zero, and undated: the answer does not say when the radar looked. The backend does not send this answer today. |
59
+ | no `tracked` field | The API did not say whether the CVE is in the tracked set: an older API, or the radar cannot decide (for example a CISA-KEV or high-EPSS CVE in a tracked product with 0 services on record). 0 exposed services on record, with no claim either way (`exposure_state` `tracking_unknown`). |
54
60
  | HTTP 400, or an id that is not `CVE-YYYY-NNNN…` | An error result tagged `invalid_input`; nothing was looked up. |
55
61
 
62
+ Whatever the `exposure_state`, the result's `state` is `not_assessed` with `measured_at` `null`:
63
+ the answer does not say when any service it counts was observed (see "Structured results").
64
+
56
65
  ### How `exposure_radar` counts
57
66
 
58
67
  `exposure_radar` relays each radar's answer cut to the fields it can label, and its note labels
@@ -151,7 +160,9 @@ yet verified, whose hostname no longer resolves, or whose service no longer answ
151
160
 
152
161
  The tool relays no number it cannot label. A stats field this version does not know is left
153
162
  out, and the note names it; so is a count in an unexpected shape. A ranked row carries only
154
- its name and its count, and a field added to one is named too.
163
+ its name and its count, and a field added to one is named too. The `poller` block's keys are
164
+ checked the same way: a key this version does not know is left out and named, while the
165
+ instance fields it knows are left out as described below.
155
166
 
156
167
  ## What a result means
157
168
 
@@ -159,7 +170,8 @@ Every tool answers in one of two shapes, so a model reading the result cannot mi
159
170
  outage for an all-clear:
160
171
 
161
172
  - **Success** — the first text block is the API's JSON verbatim; the second is a one-line
162
- note saying the call succeeded, which base URL answered, and what it found. When the
173
+ note saying the call succeeded, which base URL answered, and what it found; the third is
174
+ the structured result without `data`, as JSON (below). When the
163
175
  feed genuinely holds nothing for the query the note says so in words ("we looked and
164
176
  found nothing … not a lookup failure"), because a measured zero is a measurement. The
165
177
  exception to verbatim is `exposure_radar`: each radar is cut to the fields listed above, and
@@ -179,11 +191,100 @@ outage for an all-clear:
179
191
  the host could not be reached, it answered non-2xx, it took longer than the timeout, or
180
192
  it answered 2xx with a body that is not a JSON object. The text names the tool, the
181
193
  cause (status code or error kind), the path, and the base URL, and says it is not a
182
- finding. A failure is never rendered as a success with null fields.
194
+ finding; the second text block is the structured result, as JSON (below). A failure is never
195
+ rendered as a success with null fields.
196
+
197
+ Both shapes also carry a structured result, below, and repeat it in their last text block, so a
198
+ client that passes only `content` to the model still sees how the answer was measured. The
199
+ first text block (the API's JSON, or the failure) and the note after it are where 1.x put them.
200
+
201
+ ## Structured results
202
+
203
+ Since 2.0.0 every result, success or failure, carries `structuredContent`, and every tool
204
+ declares its shape as an `outputSchema` in `tools/list`, with a title and the annotations
205
+ `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true` and
206
+ `openWorldHint: true` (hints, which a client treats as untrusted).
207
+
208
+ | Field | What it holds |
209
+ |---|---|
210
+ | `state` | `measured`, `not_assessed`, `failed` or `invalid_input` (below). |
211
+ | `measured_at` | When the underlying observation was made, as the API states it. `null` when the answer does not say or holds no observation; never the Go zero time. |
212
+ | `method` | How the numbers were produced. `null` on a failure. |
213
+ | `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`, and the radars that answered for `exposure_radar`. |
214
+ | `freshness` | The producing radar's last completed check (`last_run_at`), where the API serves one; `null` where it serves none. |
215
+ | `notes` | The caveats, one sentence each: what the envelope itself needs saying, then the note from the text block. |
216
+ | `data` | On a success only: the same JSON as the first text block. |
217
+ | `error` | On a failure only: `kind`, `path`, `status` and `message`. |
218
+
219
+ The last text block of every result is this structured result serialized as JSON, without
220
+ `data`: the same `state`, `measured_at`, `method`, `coverage`, `freshness` and `notes` (and on
221
+ a failure `error`), key for key, since a client may pass only `content` to the model. With
222
+ the first text block as `data`, the text carries the whole structured result.
223
+
224
+ | `state` | What it means |
225
+ |---|---|
226
+ | `measured` | A measurement of what was asked. An exposure count is `measured` only when the answer says when what it counts was observed (`measured_at`) and how it was produced (`method`). No exposure answer says when today, so neither exposure tool answers `measured`. |
227
+ | `not_assessed` | The answer holds no dated measurement of what was asked, so no number in it is a finding: the radar does not look for this CVE, the answer does not say whether it does, or the answer does not say when what it counts was observed. |
228
+ | `failed` | The lookup did not complete. Not a finding. |
229
+ | `invalid_input` | The input was refused, by this server or by the API, so nothing was looked up. Not a finding. |
230
+
231
+ Every field comes from what the API sends; where the API does not say, the field is `null` and
232
+ a note says so. Per tool:
233
+
234
+ - `cve_summary` is `measured`, and its `measured_at` is `summary.last_updated`, the newest
235
+ modification time among the active CVE records it counts.
236
+ - `search_cves` is `measured`, with `measured_at` `null`: each record carries its own times. Its
237
+ `coverage` repeats the list's `total`, `total_counted`, `total_is_lower_bound`,
238
+ `search_relaxed`, `limit` and `offset`, and adds `returned`, the rows in the page. When
239
+ `total_counted` is false the total is not a count, and the note does not call it one.
240
+ - `get_cve` is `measured`, and its `measured_at` is the record's `updated_at`, when EchelonGraph
241
+ last wrote it.
242
+ - `cve_exposure` is `not_assessed` with `measured_at` `null`, every answer: the per-CVE answer
243
+ carries no time at which the services it counts were observed. Its `last_seen` is when
244
+ EchelonGraph last wrote or refreshed one of their rows, not when any of them was observed,
245
+ so it is never `measured_at`, and a note says so. A tracked zero is `not_assessed` too, since
246
+ a zero has no observation to date it. The count is still relayed, labelled: `exposure_state`
247
+ is `exposed`, `measured_zero`, `not_assessed` or `tracking_unknown`, the cases of "How
248
+ `cve_exposure` counts", and `coverage.in_scope` is the API's `tracked`. It becomes `measured`
249
+ only when the API serves a time at which the counted services were observed.
250
+ - `exposure_radar` is `not_assessed` with `measured_at` `null`: every radar answered, but no
251
+ stats answer says when the services or records it counts were observed, so no count is
252
+ presented as a dated measurement. Its numbers keep the labels above. `freshness` holds each
253
+ radar's `last_run_at` where the API serves one, and for `shadow_ai` also `running`.
254
+
255
+ The CVE feed tools' `freshness` is `null`: the feed's answers carry no time at which its
256
+ pollers last completed a poll for the feed as a whole.
257
+
258
+ A success whose fields do not fit the tool's `outputSchema` (a field of a type the schema does
259
+ not allow) is returned as a failure with `error.kind` `unexpected_shape`, never relayed. A
260
+ field the API adds later is still relayed by the four tools that relay the API's JSON, and
261
+ `exposure_radar` leaves it out and names it, as above.
262
+
263
+ ## Protocol versions
264
+
265
+ The server answers both eras of the Model Context Protocol on stdio:
266
+
267
+ - **2026-07-28**: a client that opens with `server/discover` receives a DiscoverResult listing
268
+ `2026-07-28`, the tools capability and the server instructions, and then sends each request
269
+ with the per-request `_meta` envelope.
270
+ - **2025 and earlier**: a client that opens with `initialize`, as every 1.x SDK client does,
271
+ negotiates `2025-11-25`, `2025-06-18`, `2025-03-26` or `2024-11-05`; a version the server does
272
+ not know is answered with `2025-11-25`.
273
+
274
+ The first message of a connection picks its era. The DiscoverResult lists only `2026-07-28`,
275
+ as the SDK builds it: the 2025-era versions are reached through `initialize`. Both handshakes
276
+ carry the package's name and version (`serverInfo`), and every API request carries them in its
277
+ User-Agent, `echelongraph-mcp/<version>`.
278
+
279
+ Both handshakes also carry the server instructions: the data is public; what `state`,
280
+ `measured_at` and `freshness` mean, and that the last text block repeats them; that exposure
281
+ numbers are aggregate counts of ip:port services, not an internet-wide census; and that Shodan
282
+ data is Shodan's.
183
283
 
184
284
  ## Install
185
285
 
186
- Add it to your MCP client's config. It runs via `npx` — no global install needed.
286
+ Requires Node.js 20 or later. Add it to your MCP client's config. It runs via `npx` — no global
287
+ install needed.
187
288
 
188
289
  ### Claude Desktop
189
290
 
@@ -230,13 +331,18 @@ services does EchelonGraph's radar have on record for it?"*
230
331
  ```bash
231
332
  npm install
232
333
  npm run build # tsc → dist/
233
- npm test # build, then behavioural tests against a stub API (no network needed)
334
+ npm test # build, then the behavioural suite over both protocol eras, against a stub API (no network needed)
234
335
  npm run smoke # spawn the server + call cve_exposure against the production API
235
336
  ```
236
337
 
237
338
  `npm run smoke` calls the production API with this package's User-Agent, so its requests are
238
339
  counted as external MCP adoption. `npm test` never leaves the machine.
239
340
 
341
+ `npm test` runs the suite once over a 2026-07-28 `server/discover` and once over a 2025-06-18
342
+ `initialize`, plus the era tests. To run it against an installed package rather than `dist/`,
343
+ set `ECHELONGRAPH_MCP_BIN` to that package's `echelongraph-mcp` bin: the tests then run the bin
344
+ directly, as `npx` does, and read that package's own files.
345
+
240
346
  ## License
241
347
 
242
348
  The code is MIT-licensed.
package/dist/index.js CHANGED
@@ -13,30 +13,54 @@
13
13
  // nothing ("we looked and found nothing" is a measurement). One exception: exposure_radar
14
14
  // relays each radar's answer cut to the fields it can label by what they count, and names
15
15
  // what it left out (readKEVExposure, readExposedDatabases, readLeakedCredentials and
16
- // readShadowAI; #2307, #2313).
16
+ // readShadowAI; #2307, #2313). content[2] is the envelope below, as JSON.
17
17
  // - failure (isError: true): the lookup did not complete — unreachable host, non-2xx,
18
- // timeout, or a 2xx whose body is not a JSON object — named by tool, cause and base URL.
18
+ // timeout, or a 2xx whose body is not a JSON object — named by tool, cause and base URL,
19
+ // in content[0]; content[1] is the envelope below, as JSON.
19
20
  // Never a success with null fields: a model handed one of those tells its user "no
20
21
  // exposure found", and an outage becomes an all-clear.
22
+ //
23
+ // Structured results (#2311, #2313). Every result, success or failure, also carries
24
+ // structuredContent: one envelope that says how the answer was measured — state (measured,
25
+ // not_assessed, failed, invalid_input), measured_at, method, coverage, freshness and notes —
26
+ // with, on a success, `data` equal to what content[0] carries, and on a failure an `error`.
27
+ // Each tool advertises the envelope as its outputSchema.
28
+ //
29
+ // The envelope in the text (#2440). Many clients pass only `content` to the model, so the last
30
+ // text block of every result is structuredContent serialized as JSON, without `data` (which
31
+ // content[0] already carries verbatim): the same state, measured_at, method, coverage,
32
+ // freshness, notes and, on a failure, error, key for key. content[0] + that block is the whole
33
+ // of structuredContent. The MCP spec (2025-06-18, Tools, Structured Content) asks a tool that
34
+ // returns structured content to return it serialized in a text block too; the JSON block stays
35
+ // first and the note second, as in 1.x, so a client that parses content[0] is unaffected.
36
+ //
37
+ // Protocol eras (#2311). serveStdio answers both: a 2026-07-28 client's server/discover and
38
+ // per-request `_meta` envelope, and a 2025-era client's `initialize` handshake. The first
39
+ // message of a connection picks the era; the same factory builds the server for either.
21
40
  import { readFileSync } from "node:fs";
22
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
23
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
24
- import { z } from "zod";
25
- // The release actually running, read from the package.json that ships beside dist/. The MCP
26
- // handshake and the User-Agent both carry it, so the API's access log can count adoption per
27
- // version without a code change per release.
28
- const VERSION = (() => {
41
+ import { McpServer } from "@modelcontextprotocol/server";
42
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
43
+ import * as z from "zod";
44
+ // The package actually running, read from the package.json that ships beside dist/. The MCP
45
+ // handshake (serverInfo) and the User-Agent both carry its name and version, so the API's
46
+ // access log can count adoption per version without a code change per release.
47
+ const PKG = (() => {
29
48
  try {
30
49
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
31
- const v = pkg?.version;
32
- return typeof v === "string" && v ? v : "unknown";
50
+ return pkg !== null && typeof pkg === "object" && !Array.isArray(pkg) ? pkg : {};
33
51
  }
34
52
  catch {
35
- return "unknown";
53
+ return {};
36
54
  }
37
55
  })();
56
+ const pkgField = (k, fallback) => {
57
+ const v = PKG[k];
58
+ return typeof v === "string" && v ? v : fallback;
59
+ };
60
+ const NAME = pkgField("name", "echelongraph-mcp");
61
+ const VERSION = pkgField("version", "unknown");
38
62
  const BASE = (process.env.ECHELONGRAPH_API_BASE || "https://app.echelongraph.io").replace(/\/+$/, "");
39
- const UA = `echelongraph-mcp/${VERSION} (+https://echelongraph.io/pulse/mcp)`;
63
+ const UA = `${NAME}/${VERSION} (+https://echelongraph.io/pulse/mcp)`;
40
64
  const DEFAULT_TIMEOUT_MS = 15_000;
41
65
  const TIMEOUT_MS = (() => {
42
66
  const n = Number(process.env.ECHELONGRAPH_API_TIMEOUT_MS);
@@ -148,6 +172,15 @@ async function api(path) {
148
172
  }
149
173
  }
150
174
  const text = (t) => ({ type: "text", text: t });
175
+ // A note, one sentence per entry. Sentences end in ".", "!" or "?" before whitespace, so a URL,
176
+ // a version or a field path (observed.total, crt.sh) does not split one.
177
+ const sentences = (t) => t
178
+ .replace(/\s+/g, " ")
179
+ .trim()
180
+ .split(/(?<=[.!?])\s+/)
181
+ .filter(Boolean);
182
+ // A timestamp worth relaying as one: see realInstant below.
183
+ const instantOrNull = (s) => (realInstant(s) ? s : null);
151
184
  // One sentence per failure kind: where we looked, for what, and what came back.
152
185
  function describeFailure(f) {
153
186
  const where = `from ${SHOWN_BASE} for GET ${f.path}`;
@@ -173,27 +206,70 @@ const NO_RECORD = "The API returned no record for this request; the quoted messa
173
206
  // It is tagged invalid_input, the same state a request this server refuses locally carries.
174
207
  const INVALID_INPUT = "(state: invalid_input)";
175
208
  const REJECTED_INPUT = "The API rejected the request as invalid input, so nothing was looked up: this is not a finding. Do not report it as zero, none found, or unexposed; correct the input and retry.";
209
+ // The envelope as a text block (#2440): structuredContent without `data`, serialized as JSON,
210
+ // so a client that passes only `content` to the model still sees how the answer was measured.
211
+ // `data` is left out because content[0] carries it verbatim; every other key is copied as is,
212
+ // so the text and the structured result cannot disagree.
213
+ const envelopeText = (structured) => {
214
+ const { data: _data, ...envelope } = structured;
215
+ return text(JSON.stringify(envelope, null, 2));
216
+ };
217
+ // A failure result: the #1874 text, and an envelope saying nothing was measured, in
218
+ // structuredContent and again as the last text block. The error quotes only what
219
+ // describeFailure already quotes (a redacted cause, the API's own message).
220
+ const failure = (state, message, error, coverage = null) => {
221
+ const structuredContent = { state, measured_at: null, method: null, coverage, freshness: null, notes: sentences(message), error };
222
+ return { content: [text(message), envelopeText(structuredContent)], structuredContent, isError: true };
223
+ };
224
+ const errorOf = (f) => ({ kind: f.kind, path: f.path, status: f.status ?? null, message: f.detail });
176
225
  const failed = (tool, f) => {
177
226
  if (f.kind === "http" && f.status === 400) {
178
- return { content: [text(`${tool} FAILED ${INVALID_INPUT}: ${describeFailure(f)} ${REJECTED_INPUT}`)], isError: true };
227
+ return failure("invalid_input", `${tool} FAILED ${INVALID_INPUT}: ${describeFailure(f)} ${REJECTED_INPUT}`, errorOf(f));
179
228
  }
180
- return {
181
- content: [text(`${tool} FAILED: ${describeFailure(f)} ${f.kind === "http" && f.status === 404 ? NO_RECORD : NOT_A_FINDING}`)],
182
- isError: true,
183
- };
229
+ return failure("failed", `${tool} FAILED: ${describeFailure(f)} ${f.kind === "http" && f.status === 404 ? NO_RECORD : NOT_A_FINDING}`, errorOf(f));
184
230
  };
185
- const badInput = (tool, why) => ({
186
- content: [text(`${tool} FAILED ${INVALID_INPUT}: ${why}. Nothing was looked up, so this is not a finding.`)],
187
- isError: true,
231
+ const badInput = (tool, why) => failure("invalid_input", `${tool} FAILED ${INVALID_INPUT}: ${why}. Nothing was looked up, so this is not a finding.`, {
232
+ kind: "invalid_input",
233
+ path: null,
234
+ status: null,
235
+ message: why,
188
236
  });
189
237
  // A bug in this server is still not a finding — it must never surface as an empty success.
190
- const crashed = (tool, e) => ({
191
- content: [text(`${tool} FAILED inside the MCP server while querying ${SHOWN_BASE}: ${describeError(e)}. ${NOT_A_FINDING}`)],
192
- isError: true,
193
- });
194
- const succeeded = (data, note) => ({
195
- content: [text(JSON.stringify(data, null, 2)), text(note)],
238
+ const crashed = (tool, e) => failure("failed", `${tool} FAILED inside the MCP server while querying ${SHOWN_BASE}: ${describeError(e)}. ${NOT_A_FINDING}`, {
239
+ kind: "internal",
240
+ path: null,
241
+ status: null,
242
+ message: describeError(e),
196
243
  });
244
+ // A 2xx whose fields do not match the tool's outputSchema (a field of a type the schema does
245
+ // not allow) cannot be relayed as structuredContent: a client validates it against the schema
246
+ // and rejects it. So it is a failure, worded as one, rather than the SDK's bare validation error.
247
+ function unexpectedShape(tool, error) {
248
+ const issues = error.issues
249
+ .slice(0, 3)
250
+ .map((i) => `${i.path.map(String).join(".") || "(root)"}: ${i.message}`)
251
+ .join("; ");
252
+ const detail = `the answer did not match this tool's output schema (${issues})`;
253
+ return failure("failed", `${tool} FAILED: EchelonGraph at ${SHOWN_BASE} answered, but ${detail}, so it is not relayed. ${NOT_A_FINDING}`, {
254
+ kind: "unexpected_shape",
255
+ path: null,
256
+ status: null,
257
+ message: detail,
258
+ });
259
+ }
260
+ // Every success is checked against the tool's own outputSchema before it is returned.
261
+ const checked = (tool, schema, r) => {
262
+ if (r.isError)
263
+ return r;
264
+ const p = schema.safeParse(r.structuredContent);
265
+ return p.success ? r : unexpectedShape(tool, p.error);
266
+ };
267
+ // A success: content[0] is the data, content[1] the note, content[2] the envelope without data;
268
+ // the envelope carries both, the note as sentences after any the envelope adds about itself.
269
+ const succeeded = (data, note, env) => {
270
+ const structuredContent = { ...env, notes: [...(env.notes ?? []), ...sentences(note)], data };
271
+ return { content: [text(JSON.stringify(data, null, 2)), text(note), envelopeText(structuredContent)], structuredContent };
272
+ };
197
273
  const okHead = (tool, status) => `${tool} OK: EchelonGraph answered${status === undefined ? "" : ` HTTP ${status}`} from ${SHOWN_BASE}.`;
198
274
  // Tolerant readers for the note: a missing or renamed field degrades the sentence, never
199
275
  // the result — content[0] still carries whatever the API sent.
@@ -218,36 +294,54 @@ const boolAt = (o, ...keys) => {
218
294
  // (0001-01-01T00:00:00Z) where it had no instant to give; that is not a time, so it is never
219
295
  // presented as one.
220
296
  const realInstant = (s) => s !== undefined && Date.parse(s) > 0;
221
- const server = new McpServer({ name: "echelongraph-cve", version: VERSION });
222
- server.tool("cve_summary", "Summary of EchelonGraph's CVE Pulse feed: total active CVEs and counts by severity (critical/high/medium/low/none), plus when the feed was last updated. The feed is polled from its sources on a schedule, so this is the state as of that update.", {}, async () => {
297
+ // ── The CVE feed tools: cve_summary, search_cves, get_cve ──
298
+ //
299
+ // Their numbers are CVE records and scores, not exposure counts, so a success is measured.
300
+ // measured_at is the time the answer itself gives for its records, where it gives one:
301
+ // summary.last_updated (MAX(modified) over the active records, core-backend cve/store.go
302
+ // Summary) for cve_summary, the record's updated_at (when EchelonGraph last wrote it) for
303
+ // get_cve, and none for a search page, whose rows each carry their own. None of them serves a
304
+ // fleet-wide time at which the feed's pollers last completed, so freshness is null: the
305
+ // summary's poller.last_poll_at is the answering instance's own NVD poller (cve/poller.go
306
+ // Stats, in memory), and is relayed in data but not presented as the feed's freshness.
307
+ const CVE_FEED_METHOD = "EchelonGraph's CVE Pulse feed: CVE records compiled from NVD, MITRE-CNA pre-NVD records, CISA-KEV, EPSS and GitHub GHSA, each polled from its source on a schedule.";
308
+ const NO_FEED_FRESHNESS = "freshness is null: the CVE feed's answer carries no time at which its pollers last completed a poll for the feed as a whole.";
309
+ async function cveSummary() {
223
310
  const tool = "cve_summary";
224
311
  try {
225
312
  const r = await api("/api/v1/public/cves/summary");
226
313
  if (!r.ok)
227
314
  return failed(tool, r);
228
315
  const head = okHead(tool, r.status);
316
+ const updated = strAt(r.data, "summary", "last_updated");
317
+ const env = {
318
+ state: "measured",
319
+ measured_at: instantOrNull(updated),
320
+ method: CVE_FEED_METHOD,
321
+ coverage: null,
322
+ freshness: null,
323
+ notes: [
324
+ realInstant(updated)
325
+ ? "measured_at is summary.last_updated: the newest modification time among the active CVE records these counts include."
326
+ : "measured_at is null: the answer carries no real summary.last_updated.",
327
+ NO_FEED_FRESHNESS,
328
+ ],
329
+ };
229
330
  const total = numAt(r.data, "summary", "total");
230
331
  if (total === undefined)
231
- return succeeded(r.data, head);
332
+ return succeeded(r.data, head, env);
232
333
  if (total === 0) {
233
- return succeeded(r.data, `${head} The feed reports 0 active CVEs — a measured empty result (we looked and found nothing), not a lookup failure.`);
334
+ return succeeded(r.data, `${head} The feed reports 0 active CVEs — a measured empty result (we looked and found nothing), not a lookup failure.`, env);
234
335
  }
235
336
  // A stamp that is not a real instant stays in the JSON and is not repeated as prose.
236
- const updated = strAt(r.data, "summary", "last_updated");
237
337
  const stamp = realInstant(updated) ? ` (last updated ${updated})` : "";
238
- return succeeded(r.data, `${head} The feed holds ${total} active CVEs${stamp}.`);
338
+ return succeeded(r.data, `${head} The feed holds ${total} active CVEs${stamp}.`, env);
239
339
  }
240
340
  catch (e) {
241
341
  return crashed(tool, e);
242
342
  }
243
- });
244
- server.tool("search_cves", "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 with the EchelonGraph multi-source score, severity, CVSS, EPSS, and KEV status.", {
245
- search: z.string().optional().describe("free-text search (product, vendor, or keyword, e.g. 'tomcat')"),
246
- severity: z.enum(["CRITICAL", "HIGH", "MEDIUM", "LOW"]).optional().describe("filter to one severity"),
247
- min_cvss: z.number().min(0).max(10).optional().describe("minimum CVSS score"),
248
- sort: z.enum(["published", "modified", "nvd", "echelongraph", "epss"]).optional().describe("sort order (default: published)"),
249
- limit: z.number().int().min(1).max(50).optional().describe("page size (default 20, max 50)"),
250
- }, async (a) => {
343
+ }
344
+ async function searchCVEs(a) {
251
345
  const tool = "search_cves";
252
346
  try {
253
347
  const q = new URLSearchParams();
@@ -265,19 +359,53 @@ server.tool("search_cves", "Search/list CVEs from EchelonGraph's CVE feed (NVD +
265
359
  return failed(tool, r);
266
360
  const head = okHead(tool, r.status);
267
361
  const shown = lenAt(r.data, "cves");
362
+ const counted = boolAt(r.data, "total_counted");
363
+ const floor = boolAt(r.data, "total_is_lower_bound");
364
+ const relaxed = boolAt(r.data, "search_relaxed");
365
+ const offset = numAt(r.data, "offset");
366
+ const env = {
367
+ state: "measured",
368
+ measured_at: null,
369
+ method: CVE_FEED_METHOD,
370
+ coverage: {
371
+ total: numAt(r.data, "total") ?? null,
372
+ total_counted: counted ?? null,
373
+ total_is_lower_bound: floor ?? null,
374
+ search_relaxed: relaxed ?? null,
375
+ returned: shown ?? null,
376
+ limit: numAt(r.data, "limit") ?? null,
377
+ offset: offset ?? null,
378
+ },
379
+ freshness: null,
380
+ notes: [
381
+ "measured_at is null: a page of search results has no single observation time; each CVE record in data.cves carries its own times where the API sends them.",
382
+ NO_FEED_FRESHNESS,
383
+ ],
384
+ };
385
+ const relaxedNote = relaxed === true
386
+ ? " The API relaxed the search phrase to all of its words (search_relaxed is true), so these rows are a superset of the rows that match the phrase itself."
387
+ : "";
388
+ // A total the API did not count is not a count: the page's own rows are all that is known.
389
+ if (counted === false) {
390
+ const empty = shown === 0 && (offset ?? 0) === 0;
391
+ return succeeded(r.data, empty
392
+ ? `${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.${relaxedNote}`
393
+ : `${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`}.${relaxedNote}`, env);
394
+ }
268
395
  const total = numAt(r.data, "total") ?? shown;
269
396
  if (total === undefined)
270
- return succeeded(r.data, head);
271
- if (total === 0) {
272
- 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.`);
397
+ return succeeded(r.data, `${head}${relaxedNote}`, env);
398
+ if (total === 0 && floor !== true) {
399
+ 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.${relaxedNote}`, env);
273
400
  }
274
- return succeeded(r.data, `${head} The query matched ${total} CVEs${shown === undefined ? "" : `; ${shown} returned in this page`}.`);
401
+ const matched = floor === true ? `at least ${total} CVEs (total_is_lower_bound is true: the count stopped at that floor)` : `${total} CVEs`;
402
+ return succeeded(r.data, `${head} The query matched ${matched}${shown === undefined ? "" : `; ${shown} returned in this page`}.${relaxedNote}`, env);
275
403
  }
276
404
  catch (e) {
277
405
  return crashed(tool, e);
278
406
  }
279
- });
280
- server.tool("get_cve", "Full detail for one CVE: description, NVD CVSS v3/v4, the EchelonGraph multi-source score + confidence, EPSS, CISA-KEV status (including ransomware-campaign use), GitHub GHSA, CWE, and references. Pass a CVE ID like CVE-2023-44487.", { cve_id: z.string().describe("a CVE ID, e.g. CVE-2023-44487") }, async ({ cve_id }) => {
407
+ }
408
+ async function getCVE(cve_id) {
281
409
  const tool = "get_cve";
282
410
  const id = cve_id.trim();
283
411
  if (!id)
@@ -286,12 +414,25 @@ server.tool("get_cve", "Full detail for one CVE: description, NVD CVSS v3/v4, th
286
414
  const r = await api(`/api/v1/public/cves/${encodeURIComponent(id)}`);
287
415
  if (!r.ok)
288
416
  return failed(tool, r);
289
- return succeeded(r.data, `${okHead(tool, r.status)} Returned the record for ${strAt(r.data, "cve_id") ?? id}.`);
417
+ const written = strAt(r.data, "updated_at");
418
+ return succeeded(r.data, `${okHead(tool, r.status)} Returned the record for ${strAt(r.data, "cve_id") ?? id}.`, {
419
+ state: "measured",
420
+ measured_at: instantOrNull(written),
421
+ method: CVE_FEED_METHOD,
422
+ coverage: null,
423
+ freshness: null,
424
+ notes: [
425
+ realInstant(written)
426
+ ? "measured_at is the record's updated_at: when EchelonGraph last wrote this record."
427
+ : "measured_at is null: the record carries no real updated_at.",
428
+ NO_FEED_FRESHNESS,
429
+ ],
430
+ });
290
431
  }
291
432
  catch (e) {
292
433
  return crashed(tool, e);
293
434
  }
294
- });
435
+ }
295
436
  // cve_exposure. What the API's footprint means, and what it does not (#2306, #2307).
296
437
  //
297
438
  // The counts come from the KEV-exposure radar, which does not look at every CVE: every 12 h,
@@ -303,10 +444,16 @@ server.tool("get_cve", "Full detail for one CVE: description, NVD CVSS v3/v4, th
303
444
  // The API says which with `tracked`; the note reads it and, when the field is absent (an older
304
445
  // API, or a CVE the radar cannot decide on), makes no claim either way about the zero.
305
446
  //
306
- // last_seen is not a re-sighting of the vulnerable banner: between searches the radar refreshes
307
- // it when Shodan InternetDB still lists the service's PORT, without re-reading the version, so
308
- // a patched service can stay counted while its port stays open. Every text says "last seen
309
- // listening on its port", never "re-seen" or "most recently seen".
447
+ // last_seen is not an observation time (#2439). It is when EchelonGraph last wrote or refreshed
448
+ // a service's row: core-backend kevexposure store.go Upsert sets it to now() when a search
449
+ // matches the banner, and Touch sets it to now() when the InternetDB re-check (poller.go
450
+ // reconcileStale) finds the service's PORT still listed, without re-reading the version. So a
451
+ // patched service can stay counted while its port stays open, and last_seen is our write time,
452
+ // not the time Shodan saw the service: on 2026-09-27 production's 50 newest rows carried
453
+ // last_seen within 0.78 s of one another, a batch write. Shodan's own banner timestamp is not
454
+ // stored.
455
+ // Every text calls last_seen a write or refresh time, never a sighting ("last seen listening",
456
+ // "re-seen", "most recently seen"), and it is never measured_at (see observedAt).
310
457
  const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
311
458
  // Quoted when the API does not send its own `method` string.
312
459
  const EXPOSURE_METHOD = "Shodan banner match on the radar's tracked products; up to 100 ip:port services per product query; searched every 12 h when Shodan query credits allow";
@@ -317,6 +464,7 @@ const SHODAN_ATTRIBUTION = "Exposure counts are derived from Shodan data.";
317
464
  // and copyright in the applicable Shodan materials" (#2306). The attribution above does the
318
465
  // first; this sentence, placed beside it wherever Shodan data is named, does the second.
319
466
  const SHODAN_OWNERSHIP = "Shodan data is owned by Shodan, which holds its copyright (© Shodan).";
467
+ const CVE_EXPOSURE_DESCRIPTION = `Internet-exposure footprint for one CVE from EchelonGraph's KEV-exposure radar: how many internet-facing services (distinct ip:port, returned as exposed_hosts; a machine answering on two ports counts twice) the radar has on record running a version its CVE matcher maps to this CVE, with a country/product breakdown and a ransomware flag. Aggregate and host-redacted; free and keyless. Method: exposure counts are derived from Shodan data. ${SHODAN_OWNERSHIP} Every 12 h, when Shodan query credits allow, the radar runs one Shodan query per tracked product, reads up to 100 ip:port services per query, and keeps a service when its banner version matches a CISA-KEV or high-EPSS CVE; a service whose row has not been written or refreshed for 21 days is dropped. last_seen is when EchelonGraph last wrote or refreshed a service's row, not when the service was observed and not when its vulnerable version was last confirmed: it is set to the time of the write when a search matches the banner, and again when a re-check finds the port still listed by Shodan InternetDB, without re-reading the banner, so a patched service can stay counted while its port stays open. A count is therefore a banner-version inference over a sample, not an exploit test and not an internet-wide census. The radar only looks for its tracked set of CVEs: for a CVE outside that set the result says NOT ASSESSED, and its 0 is not a measurement.`;
320
468
  function exposureNote(head, id, d) {
321
469
  const cve = strAt(d, "cve_id") ?? id;
322
470
  const hosts = numAt(d, "exposed_hosts");
@@ -342,24 +490,93 @@ function exposureNote(head, id, d) {
342
490
  const why = hosts === 0
343
491
  ? "outside the radar's tracked set; 0 is not a measurement, and it says nothing about whether any host is exposed to it."
344
492
  : `outside the radar's tracked set, so the radar is not looking for it; the ${hosts} service(s) (distinct ip:port) on record are left from earlier scans and are not a current measurement.`;
345
- return `${head} (state: not_assessed) NOT ASSESSED: ${cve} is ${why} ${kev} ${how}`;
493
+ return `${head} (exposure_state: not_assessed) NOT ASSESSED: ${cve} is ${why} ${kev} ${how}`;
346
494
  }
495
+ // The tag in parentheses is exposure_state, named as such (#2440): it says what the count is,
496
+ // and is not the envelope's state, which the last text block gives.
347
497
  if (hosts === 0 && tracked === true) {
348
498
  // The backend answers tracked:true only when it holds rows (exposed_hosts > 0), so this
349
499
  // branch does not fire today; it is worded for the day it does.
350
- return `${head} (state: measured_zero) The radar has 0 internet-facing services (distinct ip:port) on record whose banner version maps to ${cve}: a measured zero in the radar's sample. ${cve} is in the tracked set, and the radar looked and found nothing. This is not a lookup failure. It is a zero in a sample, not an internet-wide zero: the radar reads at most the first 100 Shodan results per tracked-product query, so a vulnerable service outside those results is not counted. ${kev} ${how}`;
500
+ return `${head} (exposure_state: measured_zero) The radar has 0 internet-facing services (distinct ip:port) on record whose banner version maps to ${cve}: a measured zero in the radar's sample. ${cve} is in the tracked set, and the radar looked and found nothing. This is not a lookup failure. It is a zero in a sample, not an internet-wide zero: the radar reads at most the first 100 Shodan results per tracked-product query, so a vulnerable service outside those results is not counted. ${kev} ${how}`;
351
501
  }
352
502
  if (hosts === 0) {
353
- return `${head} (state: tracking_unknown) EchelonGraph reports 0 exposed services (distinct ip:port) on record for ${cve}. The API does not say whether ${cve} is in the radar's tracked set: it is an API older than that field, or the radar cannot decide for this CVE (for example a CISA-KEV or high-EPSS CVE in a tracked product that it has no service on record for). The radar only looks for a tracked set of CISA-KEV and high-EPSS CVEs in specific products, so this 0 is not evidence either way: it neither shows that nothing is exposed nor that ${cve} went unassessed. This is not a lookup failure. ${kev} ${how}`;
503
+ return `${head} (exposure_state: tracking_unknown) EchelonGraph reports 0 exposed services (distinct ip:port) on record for ${cve}. The API does not say whether ${cve} is in the radar's tracked set: it is an API older than that field, or the radar cannot decide for this CVE (for example a CISA-KEV or high-EPSS CVE in a tracked product that it has no service on record for). The radar only looks for a tracked set of CISA-KEV and high-EPSS CVEs in specific products, so this 0 is not evidence either way: it neither shows that nothing is exposed nor that ${cve} went unassessed. This is not a lookup failure. ${kev} ${how}`;
354
504
  }
355
505
  const countries = numAt(d, "countries");
356
506
  const last = strAt(d, "last_seen");
357
507
  const seen = realInstant(last)
358
- ? ` Their latest last_seen is ${last}: when one of them was last seen listening on its port, not when its vulnerable version was last confirmed.`
508
+ ? ` Their latest last_seen is ${last}: when EchelonGraph last wrote or refreshed one of their rows, not when any of them was observed and not when a vulnerable version was last confirmed.`
359
509
  : "";
360
- return `${head} (state: exposed) The radar has ${hosts} internet-facing services (distinct ip:port, the exposed_hosts field) on record whose banner version maps to ${cve}${countries === undefined ? "" : ` across ${countries} countries`}.${seen} ${kev} ${how}`;
510
+ return `${head} (exposure_state: exposed) The radar has ${hosts} internet-facing services (distinct ip:port, the exposed_hosts field) on record whose banner version maps to ${cve}${countries === undefined ? "" : ` across ${countries} countries`}.${seen} ${kev} ${how}`;
511
+ }
512
+ function cveExposureState(d) {
513
+ const hosts = numAt(d, "exposed_hosts");
514
+ const tracked = boolAt(d, "tracked");
515
+ if (hosts === undefined)
516
+ return null;
517
+ if (tracked === false)
518
+ return "not_assessed";
519
+ if (hosts === 0)
520
+ return tracked === true ? "measured_zero" : "tracking_unknown";
521
+ return "exposed";
522
+ }
523
+ // The seam for an observation time (#2439). A cve_exposure count is measured only when the
524
+ // answer says when the services it counts were observed, and this is the one place that reads
525
+ // such a time. The per-CVE answer serves none today: its fields are cve_id, exposed_hosts,
526
+ // countries, tracked, kev_listed, kev_catalog_listed, kev_seen_in_observations, ransomware,
527
+ // top_countries, top_products, method, last_seen and generated_at (production, 2026-09-27), and
528
+ // last_seen is EchelonGraph's write or refresh time (see the comment above CVE_ID), while
529
+ // generated_at is when the API built the answer. So this returns null and every cve_exposure
530
+ // answer is not_assessed. When the API serves an observation time (#2438), read that field here
531
+ // and nowhere else: measured and measured_at follow from this function alone, never from
532
+ // last_seen.
533
+ function observedAt(_d) {
534
+ return null;
535
+ }
536
+ // The envelope of a cve_exposure answer. Only a count of services on record that the answer
537
+ // dates by when they were observed (observedAt) is measured; every other answer is
538
+ // not_assessed, and its notes say why. Today that is every answer: the count is relayed,
539
+ // labelled by exposure_state and the note, as what the radar holds on record. A tracked zero
540
+ // is not_assessed too: it has no observation to date it, and the per-CVE answer does not carry
541
+ // the time of the radar's last completed check (exposure_radar relays that as
542
+ // kev_exposure.last_run_at).
543
+ function cveExposureEnvelope(cve, d) {
544
+ const exposureState = cveExposureState(d);
545
+ const observed = observedAt(d);
546
+ const measured = exposureState === "exposed" && realInstant(observed ?? undefined);
547
+ const last = strAt(d, "last_seen");
548
+ const tracked = boolAt(d, "tracked");
549
+ const why = {
550
+ exposed: measured
551
+ ? `state is measured: exposed_hosts counts the services the radar has on record for ${cve}, and measured_at is the time the API gives for when they were observed.`
552
+ : `state is not_assessed: exposed_hosts counts the services the radar has on record for ${cve}, but the answer does not say when any of them was observed, so that count is relayed as what the radar holds on record, not as a dated measurement, and measured_at is null.`,
553
+ measured_zero: `state is not_assessed: the API marks ${cve} as tracked with 0 services on record, but a zero has no observation to date it and the answer does not say when the radar last looked, so it is not presented as a dated measurement.`,
554
+ not_assessed: `state is not_assessed: ${cve} is outside the radar's tracked set, so no number in data is a measurement of its exposure.`,
555
+ tracking_unknown: `state is not_assessed: the API does not say whether ${cve} is in the radar's tracked set, so its 0 is not a measurement.`,
556
+ none: `state is not_assessed: the answer carries no exposed_hosts count for ${cve}.`,
557
+ };
558
+ return {
559
+ state: measured ? "measured" : "not_assessed",
560
+ measured_at: measured ? observed : null,
561
+ method: (strAt(d, "method") ?? EXPOSURE_METHOD).replace(/\s+$/, ""),
562
+ coverage: { in_scope: tracked ?? null },
563
+ freshness: null,
564
+ exposure_state: exposureState,
565
+ notes: [
566
+ why[exposureState ?? "none"],
567
+ ...(realInstant(last)
568
+ ? [
569
+ `last_seen (${last}) is not measured_at: it is when EchelonGraph last wrote or refreshed one of these rows (when a Shodan search matched the banner, or a re-check found the port still listed), not when any service was observed, and Shodan's own banner time is not stored.`,
570
+ ]
571
+ : []),
572
+ tracked === undefined
573
+ ? "coverage.in_scope is null: the answer does not say whether the CVE is in the radar's tracked set."
574
+ : `coverage.in_scope is the API's tracked verdict: ${tracked ? "the CVE is in the radar's tracked set" : "the CVE is outside the radar's tracked set"}.`,
575
+ "freshness is null: the per-CVE answer does not carry the time of the radar's last completed check.",
576
+ ],
577
+ };
361
578
  }
362
- server.tool("cve_exposure", `Internet-exposure footprint for one CVE from EchelonGraph's KEV-exposure radar: how many internet-facing services (distinct ip:port, returned as exposed_hosts; a machine answering on two ports counts twice) the radar has on record running a version its CVE matcher maps to this CVE, with a country/product breakdown and a ransomware flag. Aggregate and host-redacted; free and keyless. Method: exposure counts are derived from Shodan data. ${SHODAN_OWNERSHIP} Every 12 h, when Shodan query credits allow, the radar runs one Shodan query per tracked product, reads up to 100 ip:port services per query, and keeps a service when its banner version matches a CISA-KEV or high-EPSS CVE; a service not seen on its port for 21 days is dropped. last_seen is when a service was last seen listening on its port, not when its vulnerable version was last confirmed: between searches a re-check that finds the port still listed refreshes it without re-reading the banner, so a patched service can stay counted while its port stays open. A count is therefore a banner-version inference over a sample, not an exploit test and not an internet-wide census. The radar only looks for its tracked set of CVEs: for a CVE outside that set the result says NOT ASSESSED, and its 0 is not a measurement.`, { cve_id: z.string().describe("a CVE ID, e.g. CVE-2023-44487") }, async ({ cve_id }) => {
579
+ async function cveExposure(cve_id) {
363
580
  const tool = "cve_exposure";
364
581
  const id = cve_id.trim();
365
582
  if (!id)
@@ -375,12 +592,12 @@ server.tool("cve_exposure", `Internet-exposure footprint for one CVE from Echelo
375
592
  const r = await api(`/api/v1/public/kev-exposure/cve/${encodeURIComponent(cve)}`);
376
593
  if (!r.ok)
377
594
  return failed(tool, r);
378
- return succeeded(r.data, exposureNote(okHead(tool, r.status), cve, r.data));
595
+ return succeeded(r.data, exposureNote(okHead(tool, r.status), cve, r.data), cveExposureEnvelope(strAt(r.data, "cve_id") ?? cve, r.data));
379
596
  }
380
597
  catch (e) {
381
598
  return crashed(tool, e);
382
599
  }
383
- });
600
+ }
384
601
  // Every quadrant that finds its services through Shodan, attributed. The exposed-database
385
602
  // radar searches Shodan dorks and falls back to LeakIX when the Shodan query budget is low
386
603
  // (core-backend/internal/exposeddb/poller.go).
@@ -407,8 +624,8 @@ const RADARS = [
407
624
  // under the API's names, every one of those reads as an exposure count. So the tool relays them
408
625
  // grouped as confirmed_exposed, observed and authentication, with the note labelling each, and
409
626
  // relays nothing it cannot label: a stats field this version does not know, a field other than
410
- // a ranked row's label and count, and the poller block's instance fields are left out, the
411
- // first named in the note.
627
+ // a ranked row's label and count, and the poller block's instance fields are left out, and a
628
+ // field of any of them this version does not know is named in the note.
412
629
  //
413
630
  // The `poller` block's running and last_run_at describe the radar's fleet, not the instance
414
631
  // that answered (core-backend shadowctlog Poller.Status): last_run_at is when the fleet's
@@ -440,6 +657,22 @@ const SHADOW_AI_STATS = new Set([
440
657
  "top_issuers",
441
658
  "trend_30d",
442
659
  ]);
660
+ // The poller block's fields this version knows (core-backend shadowctlog poller.go Status):
661
+ // running and last_run_at, relayed when both are real; the rest describe the instance that
662
+ // answered and are left out by design (see above). Any other key is left out and named, as a
663
+ // stats field is (#2313 item 7, #2440): an injected poller.exposed_now must not vanish unnamed.
664
+ const SHADOW_AI_POLLER = new Set([
665
+ "running",
666
+ "last_run_at",
667
+ "interval_seconds",
668
+ "shodan_enabled",
669
+ "last_new_inserts",
670
+ "last_error",
671
+ "skipped_as_follower",
672
+ "source_health",
673
+ "consecutive_fails",
674
+ "shodan_last_new",
675
+ ]);
443
676
  // The observed rankings and the daily series: API field, and the row field that names each row.
444
677
  const SHADOW_AI_ROWS = [
445
678
  ["trend_30d", "date"],
@@ -891,13 +1124,19 @@ function readShadowAI(sa) {
891
1124
  sentences.push(`Shadow AI authentication, over every observation on record: ${authParts.join("; ")}.`);
892
1125
  sentences.push("Neither authentication count is part of confirmed_exposed, and observed.total minus confirmed_exposed.total is not a count of secured services: it also holds observations not yet verified (liveness unverified), whose hostname no longer resolves (unreachable), or whose service no longer answers openly (resolved).");
893
1126
  }
894
- // What the answer carried that the tool cannot label: named, and left out.
1127
+ // What the answer carried that the tool cannot label: named, and left out. The poller block's
1128
+ // keys are checked like the stats keys: its known instance fields are left out by design, and
1129
+ // any other key is named.
1130
+ const poller = field(sa, "poller");
895
1131
  const unknown = [
896
1132
  ...Object.keys(isPlainObject(sa) ? sa : {}).filter((k) => k !== "stats" && k !== "poller"),
897
1133
  ...Object.keys(isPlainObject(stats) ? stats : {})
898
1134
  .filter((k) => !SHADOW_AI_STATS.has(k))
899
1135
  .map((k) => `stats.${k}`),
900
1136
  ...rowExtras,
1137
+ ...Object.keys(isPlainObject(poller) ? poller : {})
1138
+ .filter((k) => !SHADOW_AI_POLLER.has(k))
1139
+ .map((k) => `poller.${k}`),
901
1140
  ];
902
1141
  const unusable = Object.entries(isPlainObject(stats) ? stats : {})
903
1142
  .filter(([k, v]) => SHADOW_AI_STATS.has(k) && !used.has(k) && v !== null)
@@ -922,7 +1161,7 @@ function readShadowAI(sa) {
922
1161
  data.poller = { running, last_run_at: lastRun };
923
1162
  sentences.push(`Shadow-AI radar freshness: no Certificate Transparency (crt.sh) discovery cycle has completed since ${lastRun}, when the radar's leader last completed one (poller.running is false: that was more than ${SHADOW_AI_SILENT_AFTER} before the API's answer), so the shadow-AI figures may be stale.`);
924
1163
  }
925
- else if (field(sa, "poller") === undefined) {
1164
+ else if (poller === undefined) {
926
1165
  sentences.push("Shadow-AI radar freshness is unknown: the answer carries no poller block.");
927
1166
  }
928
1167
  else {
@@ -932,11 +1171,22 @@ function readShadowAI(sa) {
932
1171
  sentences.push(`Latest shadow-AI observation on record: ${lastObservation}.`);
933
1172
  return { data, sentences };
934
1173
  }
935
- server.tool("exposure_radar", `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. 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. ` +
1174
+ 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. 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. ` +
936
1175
  `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. ` +
937
1176
  `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. ` +
938
1177
  `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. ` +
939
- `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. ${SHODAN_OWNERSHIP}`, {}, async () => {
1178
+ `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. ${SHODAN_OWNERSHIP}`;
1179
+ // exposure_radar's envelope. Every radar answered, but no stats answer says when the services
1180
+ // or records it counts were observed: each count covers everything that radar still holds on
1181
+ // record, and the answer carries only generated_at (when the API computed the totals) and
1182
+ // last_run_at (the radar's last completed check), neither of which is that time (#2335). So
1183
+ // the result is not_assessed with measured_at null, and says why; its numbers keep their labels,
1184
+ // and freshness carries each radar's last_run_at where the API serves one.
1185
+ 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.";
1186
+ const RADAR_NAMES = RADARS.map(([name]) => name);
1187
+ const RADAR_STATE_NOTE = "state is not_assessed: every radar answered, but no stats answer says when the services or records it counts were observed, so no count here is presented as a dated measurement and measured_at is null. Each count is what that radar holds on record, labelled below by what it counts.";
1188
+ 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, and freshness.shadow_ai.last_run_at with freshness.shadow_ai.running), and null where it does not.";
1189
+ async function exposureRadar() {
940
1190
  const tool = "exposure_radar";
941
1191
  try {
942
1192
  const results = await Promise.all(RADARS.map(([, path]) => api(path)));
@@ -957,10 +1207,9 @@ server.tool("exposure_radar", `Aggregate totals from EchelonGraph's internet-exp
957
1207
  const withheld = answered.length
958
1208
  ? ` The ${answered.length} radar(s) that did answer (${answered.join(", ")}) are withheld: a partial radar picture would be read as the whole one.`
959
1209
  : "";
960
- return {
961
- content: [text(`${tool} FAILED: ${failures.length} of ${RADARS.length} radars could not be read from ${SHOWN_BASE}.\n${lines.join("\n")}\n${NOT_A_FINDING}${withheld}`)],
962
- isError: true,
963
- };
1210
+ const message = `${tool} FAILED: ${failures.length} of ${RADARS.length} radars could not be read from ${SHOWN_BASE}.\n${lines.join("\n")}\n${NOT_A_FINDING}${withheld}`;
1211
+ const radars = failures.map(([radar, f]) => ({ radar, kind: f.kind, path: f.path, status: f.status ?? null, message: f.detail }));
1212
+ return failure("failed", message, { kind: "radars", path: null, status: null, message: `${failures.length} of ${RADARS.length} radars could not be read`, radars }, { radars: RADAR_NAMES, answered, failed: failures.map(([name]) => name) });
964
1213
  }
965
1214
  // Each radar's answer is relayed cut to the fields its reader can label, in RADARS order.
966
1215
  const read = {
@@ -970,18 +1219,298 @@ server.tool("exposure_radar", `Aggregate totals from EchelonGraph's internet-exp
970
1219
  shadow_ai: readShadowAI(data.shadow_ai),
971
1220
  };
972
1221
  const relayed = {};
973
- const sentences = [];
1222
+ const said = [];
974
1223
  for (const [name] of RADARS) {
975
1224
  relayed[name] = read[name].data;
976
- sentences.push(...read[name].sentences);
1225
+ said.push(...read[name].sentences);
977
1226
  }
978
- return succeeded(relayed, `${okHead(tool)} All ${RADARS.length} radars answered: ${RADARS.map(([name]) => name).join(", ")}. Every number relayed is labelled below by what it counts, and a field this version cannot label is left out and named. ${sentences.join(" ")} ${RADAR_SOURCES} ${SHODAN_OWNERSHIP} Each radar refreshes on its own schedule.`);
1227
+ // Only what the readers relayed: a last_run_at that is not a real instant never got there.
1228
+ const stamp = (radar, ...keys) => instantOrNull(strAt(relayed[radar], ...keys));
1229
+ const running = boolAt(relayed.shadow_ai, "poller", "running");
1230
+ return succeeded(relayed, `${okHead(tool)} All ${RADARS.length} radars answered: ${RADARS.map(([name]) => name).join(", ")}. Every number relayed is labelled below by what it counts, and a field this version cannot label is left out and named. ${said.join(" ")} ${RADAR_SOURCES} ${SHODAN_OWNERSHIP} Each radar refreshes on its own schedule.`, {
1231
+ state: "not_assessed",
1232
+ measured_at: null,
1233
+ method: RADAR_METHOD,
1234
+ coverage: { radars: RADAR_NAMES, answered: RADAR_NAMES, failed: [] },
1235
+ freshness: {
1236
+ kev_exposure: { last_run_at: stamp("kev_exposure", "last_run_at") },
1237
+ exposed_databases: { last_run_at: stamp("exposed_databases", "last_run_at") },
1238
+ leaked_credentials: { last_run_at: stamp("leaked_credentials", "last_run_at") },
1239
+ shadow_ai: { last_run_at: stamp("shadow_ai", "poller", "last_run_at"), running: running ?? null },
1240
+ },
1241
+ notes: [RADAR_STATE_NOTE, RADAR_FRESHNESS_NOTE],
1242
+ });
979
1243
  }
980
1244
  catch (e) {
981
1245
  return crashed(tool, e);
982
1246
  }
1247
+ }
1248
+ // ── outputSchema: the envelope, per tool (#2311, #2313) ──
1249
+ //
1250
+ // A discriminated union on state: a success (measured, not_assessed) carries data and a method;
1251
+ // a failure (failed, invalid_input) carries error, and measured_at, method and freshness null.
1252
+ // The CVE-feed and cve_exposure tools relay the API's JSON verbatim as data, so their data
1253
+ // schemas name the fields the tool descriptions mention and admit the rest (a field the API
1254
+ // adds later must not turn an answer into a failure). exposure_radar's data is the tool's own
1255
+ // cut, so its schema is closed: exactly the fields the readers relay, and nothing else.
1256
+ const Instant = z.string().describe("An RFC 3339 instant.");
1257
+ const ERROR_KINDS = ["network", "timeout", "http", "not_json", "not_object", "invalid_input", "internal", "unexpected_shape", "radars"];
1258
+ const ErrorSchema = z.strictObject({
1259
+ kind: z.enum(ERROR_KINDS).describe("What failed: the request (network, timeout), the answer (http, not_json, not_object, unexpected_shape), the input, or this server."),
1260
+ path: z.string().nullable().describe("The API path requested, when a request was made."),
1261
+ status: z.number().int().nullable().describe("The HTTP status, when the API answered one."),
1262
+ message: z.string().describe("The cause: the API's own message, or what went wrong."),
1263
+ });
1264
+ const NOTES = z.array(z.string()).describe("Caveats, one sentence each.");
1265
+ function envelopeSchema(o) {
1266
+ const coverage = o.coverage ? o.coverage.nullable() : z.null();
1267
+ return z.discriminatedUnion("state", [
1268
+ z.strictObject({
1269
+ state: z.enum(["measured", "not_assessed"]).describe("measured: a measurement of what was asked; an exposure count is measured only with measured_at and method. not_assessed: no dated measurement of what was asked, and no number in it is a finding."),
1270
+ measured_at: Instant.nullable().describe("When the underlying observation was made, as the API states it; null when the answer does not say or holds no observation."),
1271
+ method: z.string().describe("How the numbers were produced."),
1272
+ coverage: coverage.describe("What the answer covers; null where the answer says nothing about it."),
1273
+ freshness: (o.freshness ? o.freshness.nullable() : z.null()).describe("The producing radar's last completed check (last_run_at), where the API serves one."),
1274
+ notes: NOTES,
1275
+ ...(o.extra ?? {}),
1276
+ data: o.data,
1277
+ }),
1278
+ z.strictObject({
1279
+ state: z.enum(["failed", "invalid_input"]).describe("failed: the lookup did not complete. invalid_input: the input was refused, so nothing was looked up. Neither is a finding."),
1280
+ measured_at: z.null(),
1281
+ method: z.null(),
1282
+ coverage,
1283
+ freshness: z.null(),
1284
+ notes: NOTES,
1285
+ error: o.error ?? ErrorSchema,
1286
+ }),
1287
+ ]);
1288
+ }
1289
+ // A field of the API's JSON: optional, and null where the Go type can marshal null.
1290
+ const opt = (t) => t.nullable().optional();
1291
+ const CVERecord = z.looseObject({
1292
+ cve_id: opt(z.string()),
1293
+ description: opt(z.string()),
1294
+ severity: opt(z.string()),
1295
+ cvss_v3_score: opt(z.number()),
1296
+ cvss_v4_score: opt(z.number()),
1297
+ cvss_v4_severity: opt(z.string()),
1298
+ echelongraph_score: opt(z.number()),
1299
+ echelongraph_severity: opt(z.string()),
1300
+ score_confidence: opt(z.string()),
1301
+ epss_score: opt(z.number()),
1302
+ epss_percentile: opt(z.number()),
1303
+ kev_listed: opt(z.boolean()),
1304
+ kev_ransomware: opt(z.boolean()),
1305
+ kev_added_date: opt(z.string()),
1306
+ ghsa_id: opt(z.string()),
1307
+ references: z.unknown().optional(),
1308
+ published: opt(z.string()),
1309
+ modified: opt(z.string()),
1310
+ updated_at: opt(z.string()),
1311
+ });
1312
+ const CVE_SUMMARY_OUTPUT = envelopeSchema({
1313
+ data: z.looseObject({
1314
+ summary: z
1315
+ .looseObject({
1316
+ total: opt(z.number()),
1317
+ critical: opt(z.number()),
1318
+ high: opt(z.number()),
1319
+ medium: opt(z.number()),
1320
+ low: opt(z.number()),
1321
+ none: opt(z.number()),
1322
+ last_updated: opt(z.string()),
1323
+ })
1324
+ .optional(),
1325
+ }),
1326
+ coverage: null,
1327
+ freshness: null,
1328
+ });
1329
+ const SEARCH_CVES_OUTPUT = envelopeSchema({
1330
+ data: z.looseObject({
1331
+ cves: z.array(CVERecord).optional(),
1332
+ total: opt(z.number()),
1333
+ total_counted: opt(z.boolean()),
1334
+ total_is_lower_bound: opt(z.boolean()),
1335
+ search_relaxed: opt(z.boolean()),
1336
+ limit: opt(z.number()),
1337
+ offset: opt(z.number()),
1338
+ }),
1339
+ coverage: z.strictObject({
1340
+ total: z.number().nullable(),
1341
+ total_counted: z.boolean().nullable(),
1342
+ total_is_lower_bound: z.boolean().nullable(),
1343
+ search_relaxed: z.boolean().nullable(),
1344
+ returned: z.number().nullable(),
1345
+ limit: z.number().nullable(),
1346
+ offset: z.number().nullable(),
1347
+ }),
1348
+ freshness: null,
1349
+ });
1350
+ const GET_CVE_OUTPUT = envelopeSchema({ data: CVERecord, coverage: null, freshness: null });
1351
+ const CountryRows = z.array(z.looseObject({ country: opt(z.string()), hosts: opt(z.number()) }));
1352
+ const CVE_EXPOSURE_OUTPUT = envelopeSchema({
1353
+ data: z.looseObject({
1354
+ cve_id: opt(z.string()),
1355
+ exposed_hosts: opt(z.number()),
1356
+ countries: opt(z.number()),
1357
+ tracked: opt(z.boolean()),
1358
+ kev_listed: opt(z.boolean()),
1359
+ kev_catalog_listed: opt(z.boolean()),
1360
+ kev_seen_in_observations: opt(z.boolean()),
1361
+ ransomware: opt(z.boolean()),
1362
+ top_countries: opt(CountryRows),
1363
+ top_products: opt(z.array(z.looseObject({ product: opt(z.string()), hosts: opt(z.number()) }))),
1364
+ last_seen: opt(z.string()),
1365
+ method: opt(z.string()),
1366
+ generated_at: opt(z.string()),
1367
+ }),
1368
+ coverage: z.strictObject({
1369
+ in_scope: z.boolean().nullable().describe("The API's tracked verdict: whether the CVE is in the radar's tracked set; null when the answer does not say."),
1370
+ }),
1371
+ freshness: null,
1372
+ extra: {
1373
+ exposure_state: z
1374
+ .enum(["exposed", "measured_zero", "not_assessed", "tracking_unknown"])
1375
+ .nullable()
1376
+ .describe("What the answer's count is: exposed, a measured zero, not assessed, or tracking unknown; null when it carries no count."),
1377
+ },
1378
+ });
1379
+ // exposure_radar's data, from the readers' own specs, so the schema cannot drift from them.
1380
+ const zKind = (k) => (k === "number" ? z.number() : k === "string" ? z.string() : z.boolean());
1381
+ const zRow = (spec) => z.strictObject({
1382
+ ...Object.fromEntries(Object.entries(spec.required).map(([k, kind]) => [k, zKind(kind)])),
1383
+ ...Object.fromEntries(Object.entries(spec.optional ?? {}).map(([k, kind]) => [k, zKind(kind).optional()])),
1384
+ });
1385
+ const zRadar = (spec, rows = {}) => z.strictObject({
1386
+ ...Object.fromEntries(spec.counts.map((k) => [k, z.number().optional()])),
1387
+ ...Object.fromEntries(Object.entries(spec.rows).map(([k, rs]) => [k, (rows[k] ?? z.array(zRow(rs))).optional()])),
1388
+ ...Object.fromEntries(spec.instants.map((k) => [k, Instant.optional()])),
1389
+ });
1390
+ // A newest_kev row as newestKEVRow relays it: `tracked` is read, not relayed, and a count only
1391
+ // when the row's exposure_state is not not_assessed.
1392
+ const { tracked: _read, ...newestKEVOptional } = KEV_SPEC.rows.newest_kev.optional ?? {};
1393
+ const NewestKEVRow = z.strictObject({
1394
+ cve_id: z.string(),
1395
+ ...Object.fromEntries(Object.entries(newestKEVOptional).map(([k, kind]) => [k, zKind(kind).optional()])),
1396
+ exposure_state: z.enum(["exposed", "measured_zero", "not_assessed"]),
1397
+ exposed_hosts: z.number().optional(),
983
1398
  });
984
- const transport = new StdioServerTransport();
985
- await server.connect(transport);
1399
+ const CountMap = z.record(z.string(), z.number());
1400
+ const countRowsOf = (label) => z.array(z.strictObject({ [label]: z.string(), count: z.number() }));
1401
+ const ShadowAIData = z.strictObject({
1402
+ confirmed_exposed: z.strictObject({ total: z.number().optional(), by_category: CountMap.optional(), last_24h: z.number().optional() }).optional(),
1403
+ observed: z
1404
+ .strictObject({
1405
+ total: z.number().optional(),
1406
+ by_category: CountMap.optional(),
1407
+ last_24h: z.number().optional(),
1408
+ ...Object.fromEntries(SHADOW_AI_ROWS.map(([key, label]) => [key, countRowsOf(label).optional()])),
1409
+ last_observation: Instant.optional(),
1410
+ })
1411
+ .optional(),
1412
+ authentication: z.strictObject({ observed: z.number().optional(), not_determined: z.number().optional() }).optional(),
1413
+ poller: z.strictObject({ running: z.boolean(), last_run_at: Instant }).optional(),
1414
+ });
1415
+ const LastRun = z.strictObject({ last_run_at: Instant.nullable() });
1416
+ const EXPOSURE_RADAR_OUTPUT = envelopeSchema({
1417
+ data: z.strictObject({
1418
+ kev_exposure: zRadar(KEV_SPEC, { newest_kev: z.array(NewestKEVRow) }),
1419
+ exposed_databases: zRadar(EXPOSED_DB_SPEC),
1420
+ leaked_credentials: zRadar(LEAKED_CREDS_SPEC),
1421
+ shadow_ai: ShadowAIData,
1422
+ }),
1423
+ coverage: z.strictObject({
1424
+ radars: z.array(z.string()).describe("The radars this tool reads."),
1425
+ answered: z.array(z.string()).describe("The radars that answered. On a failure their answers are withheld."),
1426
+ failed: z.array(z.string()).describe("The radars that could not be read."),
1427
+ }),
1428
+ freshness: z.strictObject({
1429
+ kev_exposure: LastRun,
1430
+ exposed_databases: LastRun,
1431
+ leaked_credentials: LastRun,
1432
+ shadow_ai: z.strictObject({ last_run_at: Instant.nullable(), running: z.boolean().nullable() }),
1433
+ }),
1434
+ error: ErrorSchema.extend({
1435
+ radars: z
1436
+ .array(z.strictObject({
1437
+ radar: z.string(),
1438
+ kind: z.enum(["network", "timeout", "http", "not_json", "not_object"]),
1439
+ path: z.string(),
1440
+ status: z.number().int().nullable(),
1441
+ message: z.string(),
1442
+ }))
1443
+ .optional()
1444
+ .describe("Each radar that could not be read, and why."),
1445
+ }),
1446
+ });
1447
+ // ── Server instructions (#2311) ──
1448
+ // No sentence here names a re-probe interval; any that does must say it applies to hosts we do
1449
+ // not own (the package's tests hold every shipped text to that).
1450
+ const INSTRUCTIONS = [
1451
+ "EchelonGraph's public CVE and internet-exposure data, read-only and keyless.",
1452
+ "Everything these tools return is public: EchelonGraph's CVE Pulse feed, and aggregate, host-redacted totals from its exposure radars.",
1453
+ "Every result says how it was measured: state, measured_at, method, coverage, freshness and notes, in its structuredContent and again in its last text block, which is that structuredContent as JSON without data (the first text block is the data).",
1454
+ "state is measured, not_assessed, failed or invalid_input.",
1455
+ "not_assessed means the answer holds no dated measurement of what was asked (for example a CVE outside the KEV-exposure radar's tracked set), so a zero in it is not a finding of no exposure.",
1456
+ "failed and invalid_input mean nothing was measured: never report them as zero, none found or unexposed.",
1457
+ "measured_at is when the underlying observation was made, and null when the answer does not say or holds none.",
1458
+ "freshness gives the producing radar's last_run_at: when it last completed a check whose reads succeeded.",
1459
+ "That is not the time of every record the radar's numbers count, which cover everything still on record, and it is null when the API does not say.",
1460
+ "Every figure is as fresh as the schedule that refreshes it.",
1461
+ "Exposure numbers are aggregate: counts of distinct ip:port services on EchelonGraph's record (a machine answering on two ports counts twice), not machines, and not an internet-wide census.",
1462
+ `${SHODAN_ATTRIBUTION} ${SHODAN_OWNERSHIP}`,
1463
+ ].join(" ");
1464
+ const ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
1465
+ const CVE_ID_ARG = z.string().describe("a CVE ID, e.g. CVE-2023-44487");
1466
+ // What each description says about its structured result, naming only fields its schema holds.
1467
+ const FEED_ENVELOPE = "Its structured result carries state (measured), measured_at, method, coverage, freshness (null: the feed serves no poll-completion time) and notes, with data equal to the API's JSON; the result's last text block repeats it without data.";
1468
+ // One server, built per connection by serveStdio for whichever era the client opens with.
1469
+ // tools/list answers in registration order, which is the order below.
1470
+ function createServer() {
1471
+ const server = new McpServer({ name: NAME, version: VERSION }, { instructions: INSTRUCTIONS, capabilities: { tools: { listChanged: false } } });
1472
+ server.registerTool("cve_summary", {
1473
+ title: "CVE feed summary",
1474
+ description: `Summary of EchelonGraph's CVE Pulse feed: summary.total active CVEs and their counts by severity (summary.critical, summary.high, summary.medium, summary.low, summary.none), plus summary.last_updated, the newest modification time among those records. The feed is polled from its sources on a schedule, so this is the state as of that update. ${FEED_ENVELOPE}`,
1475
+ outputSchema: CVE_SUMMARY_OUTPUT,
1476
+ annotations: ANNOTATIONS,
1477
+ }, async () => checked("cve_summary", CVE_SUMMARY_OUTPUT, await cveSummary()));
1478
+ server.registerTool("search_cves", {
1479
+ title: "Search CVEs",
1480
+ description: `Search/list CVEs from EchelonGraph's CVE feed (NVD + MITRE-CNA pre-NVD + CISA-KEV + EPSS + GitHub GHSA, each polled on a schedule). Filter by severity, minimum CVSS, free text, and sort. Returns cves, each with cve_id, severity, cvss_v3_score, echelongraph_score, epss_score and kev_listed where the record has them, and the list's total, total_counted (false: the matches were not counted, so total is not a count), total_is_lower_bound (true: at least total), search_relaxed (true: a phrase was relaxed to all of its words), limit and offset. ${FEED_ENVELOPE} coverage repeats total, total_counted, total_is_lower_bound, search_relaxed, limit and offset, and gives returned, the rows in this page.`,
1481
+ inputSchema: z.object({
1482
+ search: z.string().optional().describe("free-text search (product, vendor, or keyword, e.g. 'tomcat')"),
1483
+ severity: z.enum(["CRITICAL", "HIGH", "MEDIUM", "LOW"]).optional().describe("filter to one severity"),
1484
+ min_cvss: z.number().min(0).max(10).optional().describe("minimum CVSS score"),
1485
+ sort: z.enum(["published", "modified", "nvd", "echelongraph", "epss"]).optional().describe("sort order (default: published)"),
1486
+ limit: z.number().int().min(1).max(50).optional().describe("page size (default 20, max 50)"),
1487
+ }),
1488
+ outputSchema: SEARCH_CVES_OUTPUT,
1489
+ annotations: ANNOTATIONS,
1490
+ }, async (a) => checked("search_cves", SEARCH_CVES_OUTPUT, await searchCVEs(a)));
1491
+ server.registerTool("get_cve", {
1492
+ title: "CVE detail",
1493
+ description: `Full record for one CVE: description, severity, cvss_v3_score, cvss_v4_score and cvss_v4_severity (when the record has a CVSS v4 score), echelongraph_score and echelongraph_severity with score_confidence (EchelonGraph's multi-source score), epss_score and epss_percentile, CISA-KEV status (kev_listed, kev_added_date, and kev_ransomware for known ransomware-campaign use), ghsa_id (GitHub GHSA), references, published, modified and updated_at; each field only where the record has it. Pass a CVE ID like CVE-2023-44487. ${FEED_ENVELOPE}`,
1494
+ inputSchema: z.object({ cve_id: CVE_ID_ARG }),
1495
+ outputSchema: GET_CVE_OUTPUT,
1496
+ annotations: ANNOTATIONS,
1497
+ }, async ({ cve_id }) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(cve_id)));
1498
+ server.registerTool("cve_exposure", {
1499
+ title: "Internet exposure for one CVE",
1500
+ 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. 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.`,
1501
+ inputSchema: z.object({ cve_id: CVE_ID_ARG }),
1502
+ outputSchema: CVE_EXPOSURE_OUTPUT,
1503
+ annotations: ANNOTATIONS,
1504
+ }, async ({ cve_id }) => checked("cve_exposure", CVE_EXPOSURE_OUTPUT, await cveExposure(cve_id)));
1505
+ server.registerTool("exposure_radar", {
1506
+ title: "Exposure radar totals",
1507
+ description: `${EXPOSURE_RADAR_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: every radar answered, but no stats answer says when the services or records it counts were observed, so no count is presented as a dated measurement. freshness gives each radar's last_run_at (and shadow_ai's running) 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.`,
1508
+ outputSchema: EXPOSURE_RADAR_OUTPUT,
1509
+ annotations: ANNOTATIONS,
1510
+ }, async () => checked("exposure_radar", EXPOSURE_RADAR_OUTPUT, await exposureRadar()));
1511
+ return server;
1512
+ }
1513
+ // Both eras on stdio: a 2026-07-28 client's server/discover, and a 2025-era client's initialize.
1514
+ serveStdio(createServer, { legacy: "serve" });
986
1515
  // MCP uses stdout for the protocol — diagnostics go to stderr.
987
- console.error(`EchelonGraph CVE MCP server running (API: ${SHOWN_BASE}, timeout ${TIMEOUT_MS} ms)`);
1516
+ console.error(`EchelonGraph CVE MCP server ${NAME}@${VERSION} running (API: ${SHOWN_BASE}, timeout ${TIMEOUT_MS} ms)`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echelongraph-mcp",
3
- "version": "1.0.4",
3
+ "version": "2.1.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",
@@ -16,7 +16,7 @@
16
16
  "build": "tsc",
17
17
  "start": "node dist/index.js",
18
18
  "smoke": "node test/smoke.mjs",
19
- "test": "npm run build && node --test test/tools.test.mjs"
19
+ "test": "npm run build && node --test test/*.test.mjs"
20
20
  },
21
21
  "keywords": [
22
22
  "mcp",
@@ -42,11 +42,11 @@
42
42
  "url": "https://github.com/echelongraph/echelongraph-mcp/issues"
43
43
  },
44
44
  "engines": {
45
- "node": ">=18"
45
+ "node": ">=20"
46
46
  },
47
47
  "dependencies": {
48
- "@modelcontextprotocol/sdk": "^1.10.0",
49
- "zod": "^3.23.8"
48
+ "@modelcontextprotocol/server": "2.1.0",
49
+ "zod": "4.6.5"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^20.14.0",
package/server.json CHANGED
@@ -8,12 +8,12 @@
8
8
  "url": "https://github.com/echelongraph/echelongraph-mcp",
9
9
  "source": "github"
10
10
  },
11
- "version": "1.0.4",
11
+ "version": "2.1.0",
12
12
  "packages": [
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "echelongraph-mcp",
16
- "version": "1.0.4",
16
+ "version": "2.1.0",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  },