echelongraph-mcp 1.0.4 → 2.0.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
 
@@ -181,9 +181,87 @@ outage for an all-clear:
181
181
  cause (status code or error kind), the path, and the base URL, and says it is not a
182
182
  finding. A failure is never rendered as a success with null fields.
183
183
 
184
+ Both shapes also carry a structured result, below. The text blocks are the same as in 1.x, so
185
+ a client that reads only `content` sees what it always saw.
186
+
187
+ ## Structured results
188
+
189
+ Since 2.0.0 every result, success or failure, carries `structuredContent`, and every tool
190
+ declares its shape as an `outputSchema` in `tools/list`, with a title and the annotations
191
+ `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true` and
192
+ `openWorldHint: true` (hints, which a client treats as untrusted).
193
+
194
+ | Field | What it holds |
195
+ |---|---|
196
+ | `state` | `measured`, `not_assessed`, `failed` or `invalid_input` (below). |
197
+ | `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. |
198
+ | `method` | How the numbers were produced. `null` on a failure. |
199
+ | `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`. |
200
+ | `freshness` | The producing radar's last completed check (`last_run_at`), where the API serves one; `null` where it serves none. |
201
+ | `notes` | The caveats, one sentence each: what the envelope itself needs saying, then the note from the text block. |
202
+ | `data` | On a success only: the same JSON as the first text block. |
203
+ | `error` | On a failure only: `kind`, `path`, `status` and `message`. |
204
+
205
+ | `state` | What it means |
206
+ |---|---|
207
+ | `measured` | A measurement of what was asked. An exposure count is `measured` only when the answer dates it (`measured_at`) and says how it was produced (`method`). |
208
+ | `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. |
209
+ | `failed` | The lookup did not complete. Not a finding. |
210
+ | `invalid_input` | The input was refused, by this server or by the API, so nothing was looked up. Not a finding. |
211
+
212
+ Every field comes from what the API sends; where the API does not say, the field is `null` and
213
+ a note says so. Per tool:
214
+
215
+ - `cve_summary` is `measured`, and its `measured_at` is `summary.last_updated`, the newest
216
+ modification time among the active CVE records it counts.
217
+ - `search_cves` is `measured`, with `measured_at` `null`: each record carries its own times. Its
218
+ `coverage` repeats the list's `total`, `total_counted`, `total_is_lower_bound`,
219
+ `search_relaxed`, `limit` and `offset`, and adds `returned`, the rows in the page. When
220
+ `total_counted` is false the total is not a count, and the note does not call it one.
221
+ - `get_cve` is `measured`, and its `measured_at` is the record's `updated_at`, when EchelonGraph
222
+ last wrote it.
223
+ - `cve_exposure` is `measured` only for services on record with a real `last_seen`, which is its
224
+ `measured_at`. Every other answer is `not_assessed`, a tracked zero included, since a zero has
225
+ no observation to date it. `exposure_state` is `exposed`, `measured_zero`, `not_assessed` or
226
+ `tracking_unknown`, the cases of "How `cve_exposure` counts", and `coverage.in_scope` is the
227
+ API's `tracked`.
228
+ - `exposure_radar` is `not_assessed` with `measured_at` `null`: every radar answered, but no
229
+ stats answer says when the services or records it counts were observed, so no count is
230
+ presented as a dated measurement. Its numbers keep the labels above. `freshness` holds each
231
+ radar's `last_run_at` where the API serves one, and for `shadow_ai` also `running`.
232
+
233
+ The CVE feed tools' `freshness` is `null`: the feed's answers carry no time at which its
234
+ pollers last completed a poll for the feed as a whole.
235
+
236
+ A success whose fields do not fit the tool's `outputSchema` (a field of a type the schema does
237
+ not allow) is returned as a failure with `error.kind` `unexpected_shape`, never relayed. A
238
+ field the API adds later is still relayed by the four tools that relay the API's JSON, and
239
+ `exposure_radar` leaves it out and names it, as above.
240
+
241
+ ## Protocol versions
242
+
243
+ The server answers both eras of the Model Context Protocol on stdio:
244
+
245
+ - **2026-07-28**: a client that opens with `server/discover` receives a DiscoverResult listing
246
+ `2026-07-28`, the tools capability and the server instructions, and then sends each request
247
+ with the per-request `_meta` envelope.
248
+ - **2025 and earlier**: a client that opens with `initialize`, as every 1.x SDK client does,
249
+ negotiates `2025-11-25`, `2025-06-18`, `2025-03-26` or `2024-11-05`; a version the server does
250
+ not know is answered with `2025-11-25`.
251
+
252
+ The first message of a connection picks its era. The DiscoverResult lists only `2026-07-28`,
253
+ as the SDK builds it: the 2025-era versions are reached through `initialize`. Both handshakes
254
+ carry the package's name and version (`serverInfo`), and every API request carries them in its
255
+ User-Agent, `echelongraph-mcp/<version>`.
256
+
257
+ Both handshakes also carry the server instructions: the data is public; what `state`,
258
+ `measured_at` and `freshness` mean; that exposure numbers are aggregate counts of ip:port
259
+ services, not an internet-wide census; and that Shodan data is Shodan's.
260
+
184
261
  ## Install
185
262
 
186
- Add it to your MCP client's config. It runs via `npx` — no global install needed.
263
+ Requires Node.js 20 or later. Add it to your MCP client's config. It runs via `npx` — no global
264
+ install needed.
187
265
 
188
266
  ### Claude Desktop
189
267
 
@@ -230,13 +308,18 @@ services does EchelonGraph's radar have on record for it?"*
230
308
  ```bash
231
309
  npm install
232
310
  npm run build # tsc → dist/
233
- npm test # build, then behavioural tests against a stub API (no network needed)
311
+ npm test # build, then the behavioural suite over both protocol eras, against a stub API (no network needed)
234
312
  npm run smoke # spawn the server + call cve_exposure against the production API
235
313
  ```
236
314
 
237
315
  `npm run smoke` calls the production API with this package's User-Agent, so its requests are
238
316
  counted as external MCP adoption. `npm test` never leaves the machine.
239
317
 
318
+ `npm test` runs the suite once over a 2026-07-28 `server/discover` and once over a 2025-06-18
319
+ `initialize`, plus the era tests. To run it against an installed package rather than `dist/`,
320
+ set `ECHELONGRAPH_MCP_BIN` to that package's `echelongraph-mcp` bin: the tests then run the bin
321
+ directly, as `npx` does, and read that package's own files.
322
+
240
323
  ## License
241
324
 
242
325
  The code is MIT-licensed.
package/dist/index.js CHANGED
@@ -18,25 +18,41 @@
18
18
  // timeout, or a 2xx whose body is not a JSON object — named by tool, cause and base URL.
19
19
  // Never a success with null fields: a model handed one of those tells its user "no
20
20
  // exposure found", and an outage becomes an all-clear.
21
+ //
22
+ // Structured results (#2311, #2313). Every result, success or failure, also carries
23
+ // structuredContent: one envelope that says how the answer was measured — state (measured,
24
+ // not_assessed, failed, invalid_input), measured_at, method, coverage, freshness and notes —
25
+ // with, on a success, `data` equal to what content[0] carries, and on a failure an `error`.
26
+ // Each tool advertises the envelope as its outputSchema. The text blocks above are unchanged
27
+ // from 1.x, so a client that reads only `content` sees what it always saw.
28
+ //
29
+ // Protocol eras (#2311). serveStdio answers both: a 2026-07-28 client's server/discover and
30
+ // per-request `_meta` envelope, and a 2025-era client's `initialize` handshake. The first
31
+ // message of a connection picks the era; the same factory builds the server for either.
21
32
  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 = (() => {
33
+ import { McpServer } from "@modelcontextprotocol/server";
34
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
35
+ import * as z from "zod";
36
+ // The package actually running, read from the package.json that ships beside dist/. The MCP
37
+ // handshake (serverInfo) and the User-Agent both carry its name and version, so the API's
38
+ // access log can count adoption per version without a code change per release.
39
+ const PKG = (() => {
29
40
  try {
30
41
  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";
42
+ return pkg !== null && typeof pkg === "object" && !Array.isArray(pkg) ? pkg : {};
33
43
  }
34
44
  catch {
35
- return "unknown";
45
+ return {};
36
46
  }
37
47
  })();
48
+ const pkgField = (k, fallback) => {
49
+ const v = PKG[k];
50
+ return typeof v === "string" && v ? v : fallback;
51
+ };
52
+ const NAME = pkgField("name", "echelongraph-mcp");
53
+ const VERSION = pkgField("version", "unknown");
38
54
  const BASE = (process.env.ECHELONGRAPH_API_BASE || "https://app.echelongraph.io").replace(/\/+$/, "");
39
- const UA = `echelongraph-mcp/${VERSION} (+https://echelongraph.io/pulse/mcp)`;
55
+ const UA = `${NAME}/${VERSION} (+https://echelongraph.io/pulse/mcp)`;
40
56
  const DEFAULT_TIMEOUT_MS = 15_000;
41
57
  const TIMEOUT_MS = (() => {
42
58
  const n = Number(process.env.ECHELONGRAPH_API_TIMEOUT_MS);
@@ -148,6 +164,15 @@ async function api(path) {
148
164
  }
149
165
  }
150
166
  const text = (t) => ({ type: "text", text: t });
167
+ // A note, one sentence per entry. Sentences end in ".", "!" or "?" before whitespace, so a URL,
168
+ // a version or a field path (observed.total, crt.sh) does not split one.
169
+ const sentences = (t) => t
170
+ .replace(/\s+/g, " ")
171
+ .trim()
172
+ .split(/(?<=[.!?])\s+/)
173
+ .filter(Boolean);
174
+ // A timestamp worth relaying as one: see realInstant below.
175
+ const instantOrNull = (s) => (realInstant(s) ? s : null);
151
176
  // One sentence per failure kind: where we looked, for what, and what came back.
152
177
  function describeFailure(f) {
153
178
  const where = `from ${SHOWN_BASE} for GET ${f.path}`;
@@ -173,26 +198,61 @@ const NO_RECORD = "The API returned no record for this request; the quoted messa
173
198
  // It is tagged invalid_input, the same state a request this server refuses locally carries.
174
199
  const INVALID_INPUT = "(state: invalid_input)";
175
200
  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.";
201
+ // A failure result: the #1874 text, and an envelope saying nothing was measured. The error
202
+ // quotes only what describeFailure already quotes (a redacted cause, the API's own message).
203
+ const failure = (state, message, error, coverage = null) => ({
204
+ content: [text(message)],
205
+ structuredContent: { state, measured_at: null, method: null, coverage, freshness: null, notes: sentences(message), error },
206
+ isError: true,
207
+ });
208
+ const errorOf = (f) => ({ kind: f.kind, path: f.path, status: f.status ?? null, message: f.detail });
176
209
  const failed = (tool, f) => {
177
210
  if (f.kind === "http" && f.status === 400) {
178
- return { content: [text(`${tool} FAILED ${INVALID_INPUT}: ${describeFailure(f)} ${REJECTED_INPUT}`)], isError: true };
211
+ return failure("invalid_input", `${tool} FAILED ${INVALID_INPUT}: ${describeFailure(f)} ${REJECTED_INPUT}`, errorOf(f));
179
212
  }
180
- return {
181
- content: [text(`${tool} FAILED: ${describeFailure(f)} ${f.kind === "http" && f.status === 404 ? NO_RECORD : NOT_A_FINDING}`)],
182
- isError: true,
183
- };
213
+ return failure("failed", `${tool} FAILED: ${describeFailure(f)} ${f.kind === "http" && f.status === 404 ? NO_RECORD : NOT_A_FINDING}`, errorOf(f));
184
214
  };
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,
215
+ const badInput = (tool, why) => failure("invalid_input", `${tool} FAILED ${INVALID_INPUT}: ${why}. Nothing was looked up, so this is not a finding.`, {
216
+ kind: "invalid_input",
217
+ path: null,
218
+ status: null,
219
+ message: why,
188
220
  });
189
221
  // 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,
222
+ const crashed = (tool, e) => failure("failed", `${tool} FAILED inside the MCP server while querying ${SHOWN_BASE}: ${describeError(e)}. ${NOT_A_FINDING}`, {
223
+ kind: "internal",
224
+ path: null,
225
+ status: null,
226
+ message: describeError(e),
193
227
  });
194
- const succeeded = (data, note) => ({
228
+ // A 2xx whose fields do not match the tool's outputSchema (a field of a type the schema does
229
+ // not allow) cannot be relayed as structuredContent: a client validates it against the schema
230
+ // and rejects it. So it is a failure, worded as one, rather than the SDK's bare validation error.
231
+ function unexpectedShape(tool, error) {
232
+ const issues = error.issues
233
+ .slice(0, 3)
234
+ .map((i) => `${i.path.map(String).join(".") || "(root)"}: ${i.message}`)
235
+ .join("; ");
236
+ const detail = `the answer did not match this tool's output schema (${issues})`;
237
+ return failure("failed", `${tool} FAILED: EchelonGraph at ${SHOWN_BASE} answered, but ${detail}, so it is not relayed. ${NOT_A_FINDING}`, {
238
+ kind: "unexpected_shape",
239
+ path: null,
240
+ status: null,
241
+ message: detail,
242
+ });
243
+ }
244
+ // Every success is checked against the tool's own outputSchema before it is returned.
245
+ const checked = (tool, schema, r) => {
246
+ if (r.isError)
247
+ return r;
248
+ const p = schema.safeParse(r.structuredContent);
249
+ return p.success ? r : unexpectedShape(tool, p.error);
250
+ };
251
+ // A success: content[0] is the data, content[1] the note; the envelope carries both, the note
252
+ // as sentences after any the envelope adds about itself.
253
+ const succeeded = (data, note, env) => ({
195
254
  content: [text(JSON.stringify(data, null, 2)), text(note)],
255
+ structuredContent: { ...env, notes: [...(env.notes ?? []), ...sentences(note)], data },
196
256
  });
197
257
  const okHead = (tool, status) => `${tool} OK: EchelonGraph answered${status === undefined ? "" : ` HTTP ${status}`} from ${SHOWN_BASE}.`;
198
258
  // Tolerant readers for the note: a missing or renamed field degrades the sentence, never
@@ -218,36 +278,54 @@ const boolAt = (o, ...keys) => {
218
278
  // (0001-01-01T00:00:00Z) where it had no instant to give; that is not a time, so it is never
219
279
  // presented as one.
220
280
  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 () => {
281
+ // ── The CVE feed tools: cve_summary, search_cves, get_cve ──
282
+ //
283
+ // Their numbers are CVE records and scores, not exposure counts, so a success is measured.
284
+ // measured_at is the time the answer itself gives for its records, where it gives one:
285
+ // summary.last_updated (MAX(modified) over the active records, core-backend cve/store.go
286
+ // Summary) for cve_summary, the record's updated_at (when EchelonGraph last wrote it) for
287
+ // get_cve, and none for a search page, whose rows each carry their own. None of them serves a
288
+ // fleet-wide time at which the feed's pollers last completed, so freshness is null: the
289
+ // summary's poller.last_poll_at is the answering instance's own NVD poller (cve/poller.go
290
+ // Stats, in memory), and is relayed in data but not presented as the feed's freshness.
291
+ 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.";
292
+ 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.";
293
+ async function cveSummary() {
223
294
  const tool = "cve_summary";
224
295
  try {
225
296
  const r = await api("/api/v1/public/cves/summary");
226
297
  if (!r.ok)
227
298
  return failed(tool, r);
228
299
  const head = okHead(tool, r.status);
300
+ const updated = strAt(r.data, "summary", "last_updated");
301
+ const env = {
302
+ state: "measured",
303
+ measured_at: instantOrNull(updated),
304
+ method: CVE_FEED_METHOD,
305
+ coverage: null,
306
+ freshness: null,
307
+ notes: [
308
+ realInstant(updated)
309
+ ? "measured_at is summary.last_updated: the newest modification time among the active CVE records these counts include."
310
+ : "measured_at is null: the answer carries no real summary.last_updated.",
311
+ NO_FEED_FRESHNESS,
312
+ ],
313
+ };
229
314
  const total = numAt(r.data, "summary", "total");
230
315
  if (total === undefined)
231
- return succeeded(r.data, head);
316
+ return succeeded(r.data, head, env);
232
317
  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.`);
318
+ 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
319
  }
235
320
  // 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
321
  const stamp = realInstant(updated) ? ` (last updated ${updated})` : "";
238
- return succeeded(r.data, `${head} The feed holds ${total} active CVEs${stamp}.`);
322
+ return succeeded(r.data, `${head} The feed holds ${total} active CVEs${stamp}.`, env);
239
323
  }
240
324
  catch (e) {
241
325
  return crashed(tool, e);
242
326
  }
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) => {
327
+ }
328
+ async function searchCVEs(a) {
251
329
  const tool = "search_cves";
252
330
  try {
253
331
  const q = new URLSearchParams();
@@ -265,19 +343,53 @@ server.tool("search_cves", "Search/list CVEs from EchelonGraph's CVE feed (NVD +
265
343
  return failed(tool, r);
266
344
  const head = okHead(tool, r.status);
267
345
  const shown = lenAt(r.data, "cves");
346
+ const counted = boolAt(r.data, "total_counted");
347
+ const floor = boolAt(r.data, "total_is_lower_bound");
348
+ const relaxed = boolAt(r.data, "search_relaxed");
349
+ const offset = numAt(r.data, "offset");
350
+ const env = {
351
+ state: "measured",
352
+ measured_at: null,
353
+ method: CVE_FEED_METHOD,
354
+ coverage: {
355
+ total: numAt(r.data, "total") ?? null,
356
+ total_counted: counted ?? null,
357
+ total_is_lower_bound: floor ?? null,
358
+ search_relaxed: relaxed ?? null,
359
+ returned: shown ?? null,
360
+ limit: numAt(r.data, "limit") ?? null,
361
+ offset: offset ?? null,
362
+ },
363
+ freshness: null,
364
+ notes: [
365
+ "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.",
366
+ NO_FEED_FRESHNESS,
367
+ ],
368
+ };
369
+ const relaxedNote = relaxed === true
370
+ ? " 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."
371
+ : "";
372
+ // A total the API did not count is not a count: the page's own rows are all that is known.
373
+ if (counted === false) {
374
+ const empty = shown === 0 && (offset ?? 0) === 0;
375
+ return succeeded(r.data, empty
376
+ ? `${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}`
377
+ : `${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);
378
+ }
268
379
  const total = numAt(r.data, "total") ?? shown;
269
380
  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.`);
381
+ return succeeded(r.data, `${head}${relaxedNote}`, env);
382
+ if (total === 0 && floor !== true) {
383
+ 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
384
  }
274
- return succeeded(r.data, `${head} The query matched ${total} CVEs${shown === undefined ? "" : `; ${shown} returned in this page`}.`);
385
+ const matched = floor === true ? `at least ${total} CVEs (total_is_lower_bound is true: the count stopped at that floor)` : `${total} CVEs`;
386
+ return succeeded(r.data, `${head} The query matched ${matched}${shown === undefined ? "" : `; ${shown} returned in this page`}.${relaxedNote}`, env);
275
387
  }
276
388
  catch (e) {
277
389
  return crashed(tool, e);
278
390
  }
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 }) => {
391
+ }
392
+ async function getCVE(cve_id) {
281
393
  const tool = "get_cve";
282
394
  const id = cve_id.trim();
283
395
  if (!id)
@@ -286,12 +398,25 @@ server.tool("get_cve", "Full detail for one CVE: description, NVD CVSS v3/v4, th
286
398
  const r = await api(`/api/v1/public/cves/${encodeURIComponent(id)}`);
287
399
  if (!r.ok)
288
400
  return failed(tool, r);
289
- return succeeded(r.data, `${okHead(tool, r.status)} Returned the record for ${strAt(r.data, "cve_id") ?? id}.`);
401
+ const written = strAt(r.data, "updated_at");
402
+ return succeeded(r.data, `${okHead(tool, r.status)} Returned the record for ${strAt(r.data, "cve_id") ?? id}.`, {
403
+ state: "measured",
404
+ measured_at: instantOrNull(written),
405
+ method: CVE_FEED_METHOD,
406
+ coverage: null,
407
+ freshness: null,
408
+ notes: [
409
+ realInstant(written)
410
+ ? "measured_at is the record's updated_at: when EchelonGraph last wrote this record."
411
+ : "measured_at is null: the record carries no real updated_at.",
412
+ NO_FEED_FRESHNESS,
413
+ ],
414
+ });
290
415
  }
291
416
  catch (e) {
292
417
  return crashed(tool, e);
293
418
  }
294
- });
419
+ }
295
420
  // cve_exposure. What the API's footprint means, and what it does not (#2306, #2307).
296
421
  //
297
422
  // The counts come from the KEV-exposure radar, which does not look at every CVE: every 12 h,
@@ -317,6 +442,7 @@ const SHODAN_ATTRIBUTION = "Exposure counts are derived from Shodan data.";
317
442
  // and copyright in the applicable Shodan materials" (#2306). The attribution above does the
318
443
  // first; this sentence, placed beside it wherever Shodan data is named, does the second.
319
444
  const SHODAN_OWNERSHIP = "Shodan data is owned by Shodan, which holds its copyright (© Shodan).";
445
+ 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 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.`;
320
446
  function exposureNote(head, id, d) {
321
447
  const cve = strAt(d, "cve_id") ?? id;
322
448
  const hosts = numAt(d, "exposed_hosts");
@@ -359,7 +485,53 @@ function exposureNote(head, id, d) {
359
485
  : "";
360
486
  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}`;
361
487
  }
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 }) => {
488
+ function cveExposureState(d) {
489
+ const hosts = numAt(d, "exposed_hosts");
490
+ const tracked = boolAt(d, "tracked");
491
+ if (hosts === undefined)
492
+ return null;
493
+ if (tracked === false)
494
+ return "not_assessed";
495
+ if (hosts === 0)
496
+ return tracked === true ? "measured_zero" : "tracking_unknown";
497
+ return "exposed";
498
+ }
499
+ // The envelope of a cve_exposure answer. Only a count of services on record that the API dates
500
+ // (last_seen, when one of them was last seen listening on its port) is measured; every other
501
+ // answer is not_assessed, and its notes say why. A tracked zero is not_assessed too: it has no
502
+ // observation to date it, and the per-CVE answer does not carry the time of the radar's last
503
+ // completed check (exposure_radar relays that as kev_exposure.last_run_at).
504
+ function cveExposureEnvelope(cve, d) {
505
+ const exposureState = cveExposureState(d);
506
+ const last = strAt(d, "last_seen");
507
+ const measured = exposureState === "exposed" && realInstant(last);
508
+ const tracked = boolAt(d, "tracked");
509
+ const why = {
510
+ exposed: measured
511
+ ? `state is measured: exposed_hosts counts the services the radar has on record for ${cve}, and measured_at is their latest last_seen, when one of them was last seen listening on its port (not when its vulnerable version was last confirmed).`
512
+ : `state is not_assessed: the answer gives no real last_seen for the services it counts for ${cve}, so that count is not presented as a dated measurement.`,
513
+ 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.`,
514
+ 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.`,
515
+ 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.`,
516
+ none: `state is not_assessed: the answer carries no exposed_hosts count for ${cve}.`,
517
+ };
518
+ return {
519
+ state: measured ? "measured" : "not_assessed",
520
+ measured_at: measured ? last : null,
521
+ method: (strAt(d, "method") ?? EXPOSURE_METHOD).replace(/\s+$/, ""),
522
+ coverage: { in_scope: tracked ?? null },
523
+ freshness: null,
524
+ exposure_state: exposureState,
525
+ notes: [
526
+ why[exposureState ?? "none"],
527
+ tracked === undefined
528
+ ? "coverage.in_scope is null: the answer does not say whether the CVE is in the radar's tracked set."
529
+ : `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"}.`,
530
+ "freshness is null: the per-CVE answer does not carry the time of the radar's last completed check.",
531
+ ],
532
+ };
533
+ }
534
+ async function cveExposure(cve_id) {
363
535
  const tool = "cve_exposure";
364
536
  const id = cve_id.trim();
365
537
  if (!id)
@@ -375,12 +547,12 @@ server.tool("cve_exposure", `Internet-exposure footprint for one CVE from Echelo
375
547
  const r = await api(`/api/v1/public/kev-exposure/cve/${encodeURIComponent(cve)}`);
376
548
  if (!r.ok)
377
549
  return failed(tool, r);
378
- return succeeded(r.data, exposureNote(okHead(tool, r.status), cve, r.data));
550
+ return succeeded(r.data, exposureNote(okHead(tool, r.status), cve, r.data), cveExposureEnvelope(strAt(r.data, "cve_id") ?? cve, r.data));
379
551
  }
380
552
  catch (e) {
381
553
  return crashed(tool, e);
382
554
  }
383
- });
555
+ }
384
556
  // Every quadrant that finds its services through Shodan, attributed. The exposed-database
385
557
  // radar searches Shodan dorks and falls back to LeakIX when the Shodan query budget is low
386
558
  // (core-backend/internal/exposeddb/poller.go).
@@ -932,11 +1104,22 @@ function readShadowAI(sa) {
932
1104
  sentences.push(`Latest shadow-AI observation on record: ${lastObservation}.`);
933
1105
  return { data, sentences };
934
1106
  }
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. ` +
1107
+ 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
1108
  `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
1109
  `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
1110
  `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 () => {
1111
+ `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}`;
1112
+ // exposure_radar's envelope. Every radar answered, but no stats answer says when the services
1113
+ // or records it counts were observed: each count covers everything that radar still holds on
1114
+ // record, and the answer carries only generated_at (when the API computed the totals) and
1115
+ // last_run_at (the radar's last completed check), neither of which is that time (#2335). So
1116
+ // the result is not_assessed with measured_at null, and says why; its numbers keep their labels,
1117
+ // and freshness carries each radar's last_run_at where the API serves one.
1118
+ 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.";
1119
+ const RADAR_NAMES = RADARS.map(([name]) => name);
1120
+ 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.";
1121
+ 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.";
1122
+ async function exposureRadar() {
940
1123
  const tool = "exposure_radar";
941
1124
  try {
942
1125
  const results = await Promise.all(RADARS.map(([, path]) => api(path)));
@@ -957,10 +1140,9 @@ server.tool("exposure_radar", `Aggregate totals from EchelonGraph's internet-exp
957
1140
  const withheld = answered.length
958
1141
  ? ` The ${answered.length} radar(s) that did answer (${answered.join(", ")}) are withheld: a partial radar picture would be read as the whole one.`
959
1142
  : "";
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
- };
1143
+ 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}`;
1144
+ const radars = failures.map(([radar, f]) => ({ radar, kind: f.kind, path: f.path, status: f.status ?? null, message: f.detail }));
1145
+ 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
1146
  }
965
1147
  // Each radar's answer is relayed cut to the fields its reader can label, in RADARS order.
966
1148
  const read = {
@@ -970,18 +1152,298 @@ server.tool("exposure_radar", `Aggregate totals from EchelonGraph's internet-exp
970
1152
  shadow_ai: readShadowAI(data.shadow_ai),
971
1153
  };
972
1154
  const relayed = {};
973
- const sentences = [];
1155
+ const said = [];
974
1156
  for (const [name] of RADARS) {
975
1157
  relayed[name] = read[name].data;
976
- sentences.push(...read[name].sentences);
1158
+ said.push(...read[name].sentences);
977
1159
  }
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.`);
1160
+ // Only what the readers relayed: a last_run_at that is not a real instant never got there.
1161
+ const stamp = (radar, ...keys) => instantOrNull(strAt(relayed[radar], ...keys));
1162
+ const running = boolAt(relayed.shadow_ai, "poller", "running");
1163
+ 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.`, {
1164
+ state: "not_assessed",
1165
+ measured_at: null,
1166
+ method: RADAR_METHOD,
1167
+ coverage: { radars: RADAR_NAMES, answered: RADAR_NAMES, failed: [] },
1168
+ freshness: {
1169
+ kev_exposure: { last_run_at: stamp("kev_exposure", "last_run_at") },
1170
+ exposed_databases: { last_run_at: stamp("exposed_databases", "last_run_at") },
1171
+ leaked_credentials: { last_run_at: stamp("leaked_credentials", "last_run_at") },
1172
+ shadow_ai: { last_run_at: stamp("shadow_ai", "poller", "last_run_at"), running: running ?? null },
1173
+ },
1174
+ notes: [RADAR_STATE_NOTE, RADAR_FRESHNESS_NOTE],
1175
+ });
979
1176
  }
980
1177
  catch (e) {
981
1178
  return crashed(tool, e);
982
1179
  }
1180
+ }
1181
+ // ── outputSchema: the envelope, per tool (#2311, #2313) ──
1182
+ //
1183
+ // A discriminated union on state: a success (measured, not_assessed) carries data and a method;
1184
+ // a failure (failed, invalid_input) carries error, and measured_at, method and freshness null.
1185
+ // The CVE-feed and cve_exposure tools relay the API's JSON verbatim as data, so their data
1186
+ // schemas name the fields the tool descriptions mention and admit the rest (a field the API
1187
+ // adds later must not turn an answer into a failure). exposure_radar's data is the tool's own
1188
+ // cut, so its schema is closed: exactly the fields the readers relay, and nothing else.
1189
+ const Instant = z.string().describe("An RFC 3339 instant.");
1190
+ const ERROR_KINDS = ["network", "timeout", "http", "not_json", "not_object", "invalid_input", "internal", "unexpected_shape", "radars"];
1191
+ const ErrorSchema = z.strictObject({
1192
+ 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."),
1193
+ path: z.string().nullable().describe("The API path requested, when a request was made."),
1194
+ status: z.number().int().nullable().describe("The HTTP status, when the API answered one."),
1195
+ message: z.string().describe("The cause: the API's own message, or what went wrong."),
1196
+ });
1197
+ const NOTES = z.array(z.string()).describe("Caveats, one sentence each.");
1198
+ function envelopeSchema(o) {
1199
+ const coverage = o.coverage ? o.coverage.nullable() : z.null();
1200
+ return z.discriminatedUnion("state", [
1201
+ z.strictObject({
1202
+ 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."),
1203
+ 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."),
1204
+ method: z.string().describe("How the numbers were produced."),
1205
+ coverage: coverage.describe("What the answer covers; null where the answer says nothing about it."),
1206
+ freshness: (o.freshness ? o.freshness.nullable() : z.null()).describe("The producing radar's last completed check (last_run_at), where the API serves one."),
1207
+ notes: NOTES,
1208
+ ...(o.extra ?? {}),
1209
+ data: o.data,
1210
+ }),
1211
+ z.strictObject({
1212
+ 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."),
1213
+ measured_at: z.null(),
1214
+ method: z.null(),
1215
+ coverage,
1216
+ freshness: z.null(),
1217
+ notes: NOTES,
1218
+ error: o.error ?? ErrorSchema,
1219
+ }),
1220
+ ]);
1221
+ }
1222
+ // A field of the API's JSON: optional, and null where the Go type can marshal null.
1223
+ const opt = (t) => t.nullable().optional();
1224
+ const CVERecord = z.looseObject({
1225
+ cve_id: opt(z.string()),
1226
+ description: opt(z.string()),
1227
+ severity: opt(z.string()),
1228
+ cvss_v3_score: opt(z.number()),
1229
+ cvss_v4_score: opt(z.number()),
1230
+ cvss_v4_severity: opt(z.string()),
1231
+ echelongraph_score: opt(z.number()),
1232
+ echelongraph_severity: opt(z.string()),
1233
+ score_confidence: opt(z.string()),
1234
+ epss_score: opt(z.number()),
1235
+ epss_percentile: opt(z.number()),
1236
+ kev_listed: opt(z.boolean()),
1237
+ kev_ransomware: opt(z.boolean()),
1238
+ kev_added_date: opt(z.string()),
1239
+ ghsa_id: opt(z.string()),
1240
+ references: z.unknown().optional(),
1241
+ published: opt(z.string()),
1242
+ modified: opt(z.string()),
1243
+ updated_at: opt(z.string()),
1244
+ });
1245
+ const CVE_SUMMARY_OUTPUT = envelopeSchema({
1246
+ data: z.looseObject({
1247
+ summary: z
1248
+ .looseObject({
1249
+ total: opt(z.number()),
1250
+ critical: opt(z.number()),
1251
+ high: opt(z.number()),
1252
+ medium: opt(z.number()),
1253
+ low: opt(z.number()),
1254
+ none: opt(z.number()),
1255
+ last_updated: opt(z.string()),
1256
+ })
1257
+ .optional(),
1258
+ }),
1259
+ coverage: null,
1260
+ freshness: null,
1261
+ });
1262
+ const SEARCH_CVES_OUTPUT = envelopeSchema({
1263
+ data: z.looseObject({
1264
+ cves: z.array(CVERecord).optional(),
1265
+ total: opt(z.number()),
1266
+ total_counted: opt(z.boolean()),
1267
+ total_is_lower_bound: opt(z.boolean()),
1268
+ search_relaxed: opt(z.boolean()),
1269
+ limit: opt(z.number()),
1270
+ offset: opt(z.number()),
1271
+ }),
1272
+ coverage: z.strictObject({
1273
+ total: z.number().nullable(),
1274
+ total_counted: z.boolean().nullable(),
1275
+ total_is_lower_bound: z.boolean().nullable(),
1276
+ search_relaxed: z.boolean().nullable(),
1277
+ returned: z.number().nullable(),
1278
+ limit: z.number().nullable(),
1279
+ offset: z.number().nullable(),
1280
+ }),
1281
+ freshness: null,
1282
+ });
1283
+ const GET_CVE_OUTPUT = envelopeSchema({ data: CVERecord, coverage: null, freshness: null });
1284
+ const CountryRows = z.array(z.looseObject({ country: opt(z.string()), hosts: opt(z.number()) }));
1285
+ const CVE_EXPOSURE_OUTPUT = envelopeSchema({
1286
+ data: z.looseObject({
1287
+ cve_id: opt(z.string()),
1288
+ exposed_hosts: opt(z.number()),
1289
+ countries: opt(z.number()),
1290
+ tracked: opt(z.boolean()),
1291
+ kev_listed: opt(z.boolean()),
1292
+ kev_catalog_listed: opt(z.boolean()),
1293
+ kev_seen_in_observations: opt(z.boolean()),
1294
+ ransomware: opt(z.boolean()),
1295
+ top_countries: opt(CountryRows),
1296
+ top_products: opt(z.array(z.looseObject({ product: opt(z.string()), hosts: opt(z.number()) }))),
1297
+ last_seen: opt(z.string()),
1298
+ method: opt(z.string()),
1299
+ generated_at: opt(z.string()),
1300
+ }),
1301
+ coverage: z.strictObject({
1302
+ 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."),
1303
+ }),
1304
+ freshness: null,
1305
+ extra: {
1306
+ exposure_state: z
1307
+ .enum(["exposed", "measured_zero", "not_assessed", "tracking_unknown"])
1308
+ .nullable()
1309
+ .describe("What the answer's count is: exposed, a measured zero, not assessed, or tracking unknown; null when it carries no count."),
1310
+ },
983
1311
  });
984
- const transport = new StdioServerTransport();
985
- await server.connect(transport);
1312
+ // exposure_radar's data, from the readers' own specs, so the schema cannot drift from them.
1313
+ const zKind = (k) => (k === "number" ? z.number() : k === "string" ? z.string() : z.boolean());
1314
+ const zRow = (spec) => z.strictObject({
1315
+ ...Object.fromEntries(Object.entries(spec.required).map(([k, kind]) => [k, zKind(kind)])),
1316
+ ...Object.fromEntries(Object.entries(spec.optional ?? {}).map(([k, kind]) => [k, zKind(kind).optional()])),
1317
+ });
1318
+ const zRadar = (spec, rows = {}) => z.strictObject({
1319
+ ...Object.fromEntries(spec.counts.map((k) => [k, z.number().optional()])),
1320
+ ...Object.fromEntries(Object.entries(spec.rows).map(([k, rs]) => [k, (rows[k] ?? z.array(zRow(rs))).optional()])),
1321
+ ...Object.fromEntries(spec.instants.map((k) => [k, Instant.optional()])),
1322
+ });
1323
+ // A newest_kev row as newestKEVRow relays it: `tracked` is read, not relayed, and a count only
1324
+ // when the row's exposure_state is not not_assessed.
1325
+ const { tracked: _read, ...newestKEVOptional } = KEV_SPEC.rows.newest_kev.optional ?? {};
1326
+ const NewestKEVRow = z.strictObject({
1327
+ cve_id: z.string(),
1328
+ ...Object.fromEntries(Object.entries(newestKEVOptional).map(([k, kind]) => [k, zKind(kind).optional()])),
1329
+ exposure_state: z.enum(["exposed", "measured_zero", "not_assessed"]),
1330
+ exposed_hosts: z.number().optional(),
1331
+ });
1332
+ const CountMap = z.record(z.string(), z.number());
1333
+ const countRowsOf = (label) => z.array(z.strictObject({ [label]: z.string(), count: z.number() }));
1334
+ const ShadowAIData = z.strictObject({
1335
+ confirmed_exposed: z.strictObject({ total: z.number().optional(), by_category: CountMap.optional(), last_24h: z.number().optional() }).optional(),
1336
+ observed: z
1337
+ .strictObject({
1338
+ total: z.number().optional(),
1339
+ by_category: CountMap.optional(),
1340
+ last_24h: z.number().optional(),
1341
+ ...Object.fromEntries(SHADOW_AI_ROWS.map(([key, label]) => [key, countRowsOf(label).optional()])),
1342
+ last_observation: Instant.optional(),
1343
+ })
1344
+ .optional(),
1345
+ authentication: z.strictObject({ observed: z.number().optional(), not_determined: z.number().optional() }).optional(),
1346
+ poller: z.strictObject({ running: z.boolean(), last_run_at: Instant }).optional(),
1347
+ });
1348
+ const LastRun = z.strictObject({ last_run_at: Instant.nullable() });
1349
+ const EXPOSURE_RADAR_OUTPUT = envelopeSchema({
1350
+ data: z.strictObject({
1351
+ kev_exposure: zRadar(KEV_SPEC, { newest_kev: z.array(NewestKEVRow) }),
1352
+ exposed_databases: zRadar(EXPOSED_DB_SPEC),
1353
+ leaked_credentials: zRadar(LEAKED_CREDS_SPEC),
1354
+ shadow_ai: ShadowAIData,
1355
+ }),
1356
+ coverage: z.strictObject({
1357
+ radars: z.array(z.string()).describe("The radars this tool reads."),
1358
+ answered: z.array(z.string()).describe("The radars that answered. On a failure their answers are withheld."),
1359
+ failed: z.array(z.string()).describe("The radars that could not be read."),
1360
+ }),
1361
+ freshness: z.strictObject({
1362
+ kev_exposure: LastRun,
1363
+ exposed_databases: LastRun,
1364
+ leaked_credentials: LastRun,
1365
+ shadow_ai: z.strictObject({ last_run_at: Instant.nullable(), running: z.boolean().nullable() }),
1366
+ }),
1367
+ error: ErrorSchema.extend({
1368
+ radars: z
1369
+ .array(z.strictObject({
1370
+ radar: z.string(),
1371
+ kind: z.enum(["network", "timeout", "http", "not_json", "not_object"]),
1372
+ path: z.string(),
1373
+ status: z.number().int().nullable(),
1374
+ message: z.string(),
1375
+ }))
1376
+ .optional()
1377
+ .describe("Each radar that could not be read, and why."),
1378
+ }),
1379
+ });
1380
+ // ── Server instructions (#2311) ──
1381
+ // No sentence here names a re-probe interval; any that does must say it applies to hosts we do
1382
+ // not own (the package's tests hold every shipped text to that).
1383
+ const INSTRUCTIONS = [
1384
+ "EchelonGraph's public CVE and internet-exposure data, read-only and keyless.",
1385
+ "Everything these tools return is public: EchelonGraph's CVE Pulse feed, and aggregate, host-redacted totals from its exposure radars.",
1386
+ "Every result's structuredContent says how it was measured: state, measured_at, method, coverage, freshness and notes.",
1387
+ "state is measured, not_assessed, failed or invalid_input.",
1388
+ "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.",
1389
+ "failed and invalid_input mean nothing was measured: never report them as zero, none found or unexposed.",
1390
+ "measured_at is when the underlying observation was made, and null when the answer does not say or holds none.",
1391
+ "freshness gives the producing radar's last_run_at: when it last completed a check whose reads succeeded.",
1392
+ "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.",
1393
+ "Every figure is as fresh as the schedule that refreshes it.",
1394
+ "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.",
1395
+ `${SHODAN_ATTRIBUTION} ${SHODAN_OWNERSHIP}`,
1396
+ ].join(" ");
1397
+ const ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
1398
+ const CVE_ID_ARG = z.string().describe("a CVE ID, e.g. CVE-2023-44487");
1399
+ // What each description says about its structured result, naming only fields its schema holds.
1400
+ 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.";
1401
+ // One server, built per connection by serveStdio for whichever era the client opens with.
1402
+ // tools/list answers in registration order, which is the order below.
1403
+ function createServer() {
1404
+ const server = new McpServer({ name: NAME, version: VERSION }, { instructions: INSTRUCTIONS, capabilities: { tools: { listChanged: false } } });
1405
+ server.registerTool("cve_summary", {
1406
+ title: "CVE feed summary",
1407
+ 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}`,
1408
+ outputSchema: CVE_SUMMARY_OUTPUT,
1409
+ annotations: ANNOTATIONS,
1410
+ }, async () => checked("cve_summary", CVE_SUMMARY_OUTPUT, await cveSummary()));
1411
+ server.registerTool("search_cves", {
1412
+ title: "Search CVEs",
1413
+ 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.`,
1414
+ inputSchema: z.object({
1415
+ search: z.string().optional().describe("free-text search (product, vendor, or keyword, e.g. 'tomcat')"),
1416
+ severity: z.enum(["CRITICAL", "HIGH", "MEDIUM", "LOW"]).optional().describe("filter to one severity"),
1417
+ min_cvss: z.number().min(0).max(10).optional().describe("minimum CVSS score"),
1418
+ sort: z.enum(["published", "modified", "nvd", "echelongraph", "epss"]).optional().describe("sort order (default: published)"),
1419
+ limit: z.number().int().min(1).max(50).optional().describe("page size (default 20, max 50)"),
1420
+ }),
1421
+ outputSchema: SEARCH_CVES_OUTPUT,
1422
+ annotations: ANNOTATIONS,
1423
+ }, async (a) => checked("search_cves", SEARCH_CVES_OUTPUT, await searchCVEs(a)));
1424
+ server.registerTool("get_cve", {
1425
+ title: "CVE detail",
1426
+ 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}`,
1427
+ inputSchema: z.object({ cve_id: CVE_ID_ARG }),
1428
+ outputSchema: GET_CVE_OUTPUT,
1429
+ annotations: ANNOTATIONS,
1430
+ }, async ({ cve_id }) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(cve_id)));
1431
+ server.registerTool("cve_exposure", {
1432
+ title: "Internet exposure for one CVE",
1433
+ description: `${CVE_EXPOSURE_DESCRIPTION} Its structured result's state is measured only for a count of services on record that last_seen dates (measured_at); a tracked zero, a CVE outside the tracked set, or an answer that does not say, is not_assessed. exposure_state 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.`,
1434
+ inputSchema: z.object({ cve_id: CVE_ID_ARG }),
1435
+ outputSchema: CVE_EXPOSURE_OUTPUT,
1436
+ annotations: ANNOTATIONS,
1437
+ }, async ({ cve_id }) => checked("cve_exposure", CVE_EXPOSURE_OUTPUT, await cveExposure(cve_id)));
1438
+ server.registerTool("exposure_radar", {
1439
+ title: "Exposure radar totals",
1440
+ 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.`,
1441
+ outputSchema: EXPOSURE_RADAR_OUTPUT,
1442
+ annotations: ANNOTATIONS,
1443
+ }, async () => checked("exposure_radar", EXPOSURE_RADAR_OUTPUT, await exposureRadar()));
1444
+ return server;
1445
+ }
1446
+ // Both eras on stdio: a 2026-07-28 client's server/discover, and a 2025-era client's initialize.
1447
+ serveStdio(createServer, { legacy: "serve" });
986
1448
  // 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)`);
1449
+ 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.0.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.0.0",
12
12
  "packages": [
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "echelongraph-mcp",
16
- "version": "1.0.4",
16
+ "version": "2.0.0",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  },