echelongraph-mcp 2.0.0 → 2.2.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
@@ -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 as JSON, less what the first two already say (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,10 +191,13 @@ 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, less the message's
195
+ sentences (below). A failure is never rendered as a success with null fields.
183
196
 
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.
197
+ Both shapes also carry a structured result, below, and repeat it in their last text block, less
198
+ what an earlier block already says, so a client that passes only `content` to the model still
199
+ sees how the answer was measured. The first text block (the API's JSON, or the failure) and the
200
+ note after it are where 1.x put them.
186
201
 
187
202
  ## Structured results
188
203
 
@@ -202,10 +217,21 @@ declares its shape as an `outputSchema` in `tools/list`, with a title and the an
202
217
  | `data` | On a success only: the same JSON as the first text block. |
203
218
  | `error` | On a failure only: `kind`, `path`, `status` and `message`. |
204
219
 
220
+ The last text block of every result is this structured result serialized as JSON, less what
221
+ an earlier text block already says verbatim, since a client may pass only `content` to the
222
+ model. It carries the same `state`, `measured_at`, `coverage` and `freshness` (and on a failure
223
+ `error`), key for key. It leaves out `data`, which is a success's first text block, and the
224
+ note's sentences, which are the text block just before it (on a failure, the message) and
225
+ with which `notes` ends: its `notes` holds only the sentences the envelope adds about itself,
226
+ and is left out when there are none. It leaves out `method` too where that note quotes it verbatim, as
227
+ `cve_exposure`'s does ("Method: …"). So the text blocks together carry the whole structured
228
+ result, and cannot disagree with it. Before 2.2.0 this block repeated the whole note, so every
229
+ note was sent twice.
230
+
205
231
  | `state` | What it means |
206
232
  |---|---|
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. |
233
+ | `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`. |
234
+ | `not_assessed` | The answer holds no dated measurement of what was asked, so no count in it is presented as one: 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. It can still relay a count, as what the source holds on record, undated: `cve_exposure`'s `exposed_hosts` when its `exposure_state` is `exposed`, and `exposure_radar`'s labelled totals. The notes, and `exposure_state` where the result carries it, say what each count is. |
209
235
  | `failed` | The lookup did not complete. Not a finding. |
210
236
  | `invalid_input` | The input was refused, by this server or by the API, so nothing was looked up. Not a finding. |
211
237
 
@@ -220,11 +246,14 @@ a note says so. Per tool:
220
246
  `total_counted` is false the total is not a count, and the note does not call it one.
221
247
  - `get_cve` is `measured`, and its `measured_at` is the record's `updated_at`, when EchelonGraph
222
248
  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`.
249
+ - `cve_exposure` is `not_assessed` with `measured_at` `null`, every answer: the per-CVE answer
250
+ carries no time at which the services it counts were observed. Its `last_seen` is when
251
+ EchelonGraph last wrote or refreshed one of their rows, not when any of them was observed,
252
+ so it is never `measured_at`, and a note says so. A tracked zero is `not_assessed` too, since
253
+ a zero has no observation to date it. The count is still relayed, labelled: `exposure_state`
254
+ is `exposed`, `measured_zero`, `not_assessed` or `tracking_unknown`, the cases of "How
255
+ `cve_exposure` counts", and `coverage.in_scope` is the API's `tracked`. It becomes `measured`
256
+ only when the API serves a time at which the counted services were observed.
228
257
  - `exposure_radar` is `not_assessed` with `measured_at` `null`: every radar answered, but no
229
258
  stats answer says when the services or records it counts were observed, so no count is
230
259
  presented as a dated measurement. Its numbers keep the labels above. `freshness` holds each
@@ -255,8 +284,9 @@ carry the package's name and version (`serverInfo`), and every API request carri
255
284
  User-Agent, `echelongraph-mcp/<version>`.
256
285
 
257
286
  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.
287
+ `measured_at` and `freshness` mean, and that the last text block repeats them; that exposure
288
+ numbers are aggregate counts of ip:port services, not an internet-wide census; and that Shodan
289
+ data is Shodan's.
260
290
 
261
291
  ## Install
262
292
 
package/dist/index.js CHANGED
@@ -13,9 +13,11 @@
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, less what
17
+ // content[0] and the note already say.
17
18
  // - 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.
19
+ // timeout, or a 2xx whose body is not a JSON object — named by tool, cause and base URL,
20
+ // in content[0]; content[1] is the envelope below, as JSON, less the message's sentences.
19
21
  // Never a success with null fields: a model handed one of those tells its user "no
20
22
  // exposure found", and an outage becomes an all-clear.
21
23
  //
@@ -23,8 +25,18 @@
23
25
  // structuredContent: one envelope that says how the answer was measured — state (measured,
24
26
  // not_assessed, failed, invalid_input), measured_at, method, coverage, freshness and notes —
25
27
  // 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
+ // Each tool advertises the envelope as its outputSchema.
29
+ //
30
+ // The envelope in the text (#2440, #2467). Many clients pass only `content` to the model, so the
31
+ // last text block of every result is structuredContent serialized as JSON, less what an earlier
32
+ // text block already carries verbatim: `data` (content[0] on a success), the note's sentences
33
+ // (the block just before, which structuredContent's notes end with), and method where that note
34
+ // quotes it. The rest — state, measured_at, coverage, freshness, the envelope's own notes and,
35
+ // on a failure, error — is copied key for key, so the text blocks together are the whole of
36
+ // structuredContent and cannot disagree with it. The MCP spec (2025-06-18, Tools, Structured
37
+ // Content) asks a tool that returns structured content to return it serialized in a text block
38
+ // too; the JSON block stays first and the note second, as in 1.x, so a client that parses
39
+ // content[0] is unaffected.
28
40
  //
29
41
  // Protocol eras (#2311). serveStdio answers both: a 2026-07-28 client's server/discover and
30
42
  // per-request `_meta` envelope, and a 2025-era client's `initialize` handshake. The first
@@ -198,13 +210,40 @@ const NO_RECORD = "The API returned no record for this request; the quoted messa
198
210
  // It is tagged invalid_input, the same state a request this server refuses locally carries.
199
211
  const INVALID_INPUT = "(state: invalid_input)";
200
212
  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
- });
213
+ // The envelope as a text block (#2440), so a client that passes only `content` to the model
214
+ // still sees how the answer was measured: structuredContent serialized as JSON, less what an
215
+ // earlier text block already carries verbatim (#2467). 2.1.0 sent the whole envelope without
216
+ // `data`, so every note went out twice, once as prose and once as the notes array, and
217
+ // cve_exposure's method a third time.
218
+ // data left out: content[0] carries it verbatim.
219
+ // notes only the envelope's own sentences (`own`), which no text block says; the rest of
220
+ // structuredContent's notes are the sentences of `said`, the text block just before
221
+ // this one (the note, or a failure's message). Left out when there are none.
222
+ // method left out when `said` quotes it verbatim (cve_exposure's note, "Method: …").
223
+ // Every other key is copied as is, in structuredContent's order, so the text blocks together are
224
+ // the whole of structuredContent and cannot disagree with it.
225
+ const envelopeText = (structured, said, own) => {
226
+ const envelope = {};
227
+ for (const [k, v] of Object.entries(structured)) {
228
+ if (k === "data")
229
+ continue;
230
+ if (k === "notes") {
231
+ if (own.length)
232
+ envelope.notes = own;
233
+ }
234
+ else if (!(k === "method" && typeof v === "string" && said.includes(v)))
235
+ envelope[k] = v;
236
+ }
237
+ return text(JSON.stringify(envelope, null, 2));
238
+ };
239
+ // A failure result: the #1874 text, and an envelope saying nothing was measured, in
240
+ // structuredContent and again as the last text block. The error quotes only what
241
+ // describeFailure already quotes (a redacted cause, the API's own message). Every note is a
242
+ // sentence of the message, so the text envelope carries none.
243
+ const failure = (state, message, error, coverage = null) => {
244
+ const structuredContent = { state, measured_at: null, method: null, coverage, freshness: null, notes: sentences(message), error };
245
+ return { content: [text(message), envelopeText(structuredContent, message, [])], structuredContent, isError: true };
246
+ };
208
247
  const errorOf = (f) => ({ kind: f.kind, path: f.path, status: f.status ?? null, message: f.detail });
209
248
  const failed = (tool, f) => {
210
249
  if (f.kind === "http" && f.status === 400) {
@@ -248,12 +287,13 @@ const checked = (tool, schema, r) => {
248
287
  const p = schema.safeParse(r.structuredContent);
249
288
  return p.success ? r : unexpectedShape(tool, p.error);
250
289
  };
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) => ({
254
- content: [text(JSON.stringify(data, null, 2)), text(note)],
255
- structuredContent: { ...env, notes: [...(env.notes ?? []), ...sentences(note)], data },
256
- });
290
+ // A success: content[0] is the data, content[1] the note, content[2] the envelope less both;
291
+ // the envelope carries both, the note as sentences after any the envelope adds about itself.
292
+ const succeeded = (data, note, env) => {
293
+ const own = env.notes ?? [];
294
+ const structuredContent = { ...env, notes: [...own, ...sentences(note)], data };
295
+ return { content: [text(JSON.stringify(data, null, 2)), text(note), envelopeText(structuredContent, note, own)], structuredContent };
296
+ };
257
297
  const okHead = (tool, status) => `${tool} OK: EchelonGraph answered${status === undefined ? "" : ` HTTP ${status}`} from ${SHOWN_BASE}.`;
258
298
  // Tolerant readers for the note: a missing or renamed field degrades the sentence, never
259
299
  // the result — content[0] still carries whatever the API sent.
@@ -428,10 +468,16 @@ async function getCVE(cve_id) {
428
468
  // The API says which with `tracked`; the note reads it and, when the field is absent (an older
429
469
  // API, or a CVE the radar cannot decide on), makes no claim either way about the zero.
430
470
  //
431
- // last_seen is not a re-sighting of the vulnerable banner: between searches the radar refreshes
432
- // it when Shodan InternetDB still lists the service's PORT, without re-reading the version, so
433
- // a patched service can stay counted while its port stays open. Every text says "last seen
434
- // listening on its port", never "re-seen" or "most recently seen".
471
+ // last_seen is not an observation time (#2439). It is when EchelonGraph last wrote or refreshed
472
+ // a service's row: core-backend kevexposure store.go Upsert sets it to now() when a search
473
+ // matches the banner, and Touch sets it to now() when the InternetDB re-check (poller.go
474
+ // reconcileStale) finds the service's PORT still listed, without re-reading the version. So a
475
+ // patched service can stay counted while its port stays open, and last_seen is our write time,
476
+ // not the time Shodan saw the service: on 2026-09-27 production's 50 newest rows carried
477
+ // last_seen within 0.78 s of one another, a batch write. Shodan's own banner timestamp is not
478
+ // stored.
479
+ // Every text calls last_seen a write or refresh time, never a sighting ("last seen listening",
480
+ // "re-seen", "most recently seen"), and it is never measured_at (see observedAt).
435
481
  const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
436
482
  // Quoted when the API does not send its own `method` string.
437
483
  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";
@@ -442,7 +488,7 @@ const SHODAN_ATTRIBUTION = "Exposure counts are derived from Shodan data.";
442
488
  // and copyright in the applicable Shodan materials" (#2306). The attribution above does the
443
489
  // first; this sentence, placed beside it wherever Shodan data is named, does the second.
444
490
  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.`;
491
+ 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 note says NOT ASSESSED (exposure_state not_assessed), and its 0 is not a measurement.`;
446
492
  function exposureNote(head, id, d) {
447
493
  const cve = strAt(d, "cve_id") ?? id;
448
494
  const hosts = numAt(d, "exposed_hosts");
@@ -468,22 +514,24 @@ function exposureNote(head, id, d) {
468
514
  const why = hosts === 0
469
515
  ? "outside the radar's tracked set; 0 is not a measurement, and it says nothing about whether any host is exposed to it."
470
516
  : `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.`;
471
- return `${head} (state: not_assessed) NOT ASSESSED: ${cve} is ${why} ${kev} ${how}`;
517
+ return `${head} (exposure_state: not_assessed) NOT ASSESSED: ${cve} is ${why} ${kev} ${how}`;
472
518
  }
519
+ // The tag in parentheses is exposure_state, named as such (#2440): it says what the count is,
520
+ // and is not the envelope's state, which the last text block gives.
473
521
  if (hosts === 0 && tracked === true) {
474
522
  // The backend answers tracked:true only when it holds rows (exposed_hosts > 0), so this
475
523
  // branch does not fire today; it is worded for the day it does.
476
- 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}`;
524
+ 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}`;
477
525
  }
478
526
  if (hosts === 0) {
479
- 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}`;
527
+ 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}`;
480
528
  }
481
529
  const countries = numAt(d, "countries");
482
530
  const last = strAt(d, "last_seen");
483
531
  const seen = realInstant(last)
484
- ? ` 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.`
532
+ ? ` 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.`
485
533
  : "";
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}`;
534
+ 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}`;
487
535
  }
488
536
  function cveExposureState(d) {
489
537
  const hosts = numAt(d, "exposed_hosts");
@@ -496,20 +544,36 @@ function cveExposureState(d) {
496
544
  return tracked === true ? "measured_zero" : "tracking_unknown";
497
545
  return "exposed";
498
546
  }
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).
547
+ // The seam for an observation time (#2439). A cve_exposure count is measured only when the
548
+ // answer says when the services it counts were observed, and this is the one place that reads
549
+ // such a time. The per-CVE answer serves none today: its fields are cve_id, exposed_hosts,
550
+ // countries, tracked, kev_listed, kev_catalog_listed, kev_seen_in_observations, ransomware,
551
+ // top_countries, top_products, method, last_seen and generated_at (production, 2026-09-27), and
552
+ // last_seen is EchelonGraph's write or refresh time (see the comment above CVE_ID), while
553
+ // generated_at is when the API built the answer. So this returns null and every cve_exposure
554
+ // answer is not_assessed. When the API serves an observation time (#2438), read that field here
555
+ // and nowhere else: measured and measured_at follow from this function alone, never from
556
+ // last_seen.
557
+ function observedAt(_d) {
558
+ return null;
559
+ }
560
+ // The envelope of a cve_exposure answer. Only a count of services on record that the answer
561
+ // dates by when they were observed (observedAt) is measured; every other answer is
562
+ // not_assessed, and its notes say why. Today that is every answer: the count is relayed,
563
+ // labelled by exposure_state and the note, as what the radar holds on record. A tracked zero
564
+ // is not_assessed too: it has no observation to date it, and the per-CVE answer does not carry
565
+ // the time of the radar's last completed check (exposure_radar relays that as
566
+ // kev_exposure.last_run_at).
504
567
  function cveExposureEnvelope(cve, d) {
505
568
  const exposureState = cveExposureState(d);
569
+ const observed = observedAt(d);
570
+ const measured = exposureState === "exposed" && realInstant(observed ?? undefined);
506
571
  const last = strAt(d, "last_seen");
507
- const measured = exposureState === "exposed" && realInstant(last);
508
572
  const tracked = boolAt(d, "tracked");
509
573
  const why = {
510
574
  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.`,
575
+ ? `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.`
576
+ : `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.`,
513
577
  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
578
  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
579
  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.`,
@@ -517,13 +581,18 @@ function cveExposureEnvelope(cve, d) {
517
581
  };
518
582
  return {
519
583
  state: measured ? "measured" : "not_assessed",
520
- measured_at: measured ? last : null,
584
+ measured_at: measured ? observed : null,
521
585
  method: (strAt(d, "method") ?? EXPOSURE_METHOD).replace(/\s+$/, ""),
522
586
  coverage: { in_scope: tracked ?? null },
523
587
  freshness: null,
524
588
  exposure_state: exposureState,
525
589
  notes: [
526
590
  why[exposureState ?? "none"],
591
+ ...(realInstant(last)
592
+ ? [
593
+ `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.`,
594
+ ]
595
+ : []),
527
596
  tracked === undefined
528
597
  ? "coverage.in_scope is null: the answer does not say whether the CVE is in the radar's tracked set."
529
598
  : `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"}.`,
@@ -579,8 +648,8 @@ const RADARS = [
579
648
  // under the API's names, every one of those reads as an exposure count. So the tool relays them
580
649
  // grouped as confirmed_exposed, observed and authentication, with the note labelling each, and
581
650
  // relays nothing it cannot label: a stats field this version does not know, a field other than
582
- // a ranked row's label and count, and the poller block's instance fields are left out, the
583
- // first named in the note.
651
+ // a ranked row's label and count, and the poller block's instance fields are left out, and a
652
+ // field of any of them this version does not know is named in the note.
584
653
  //
585
654
  // The `poller` block's running and last_run_at describe the radar's fleet, not the instance
586
655
  // that answered (core-backend shadowctlog Poller.Status): last_run_at is when the fleet's
@@ -612,6 +681,22 @@ const SHADOW_AI_STATS = new Set([
612
681
  "top_issuers",
613
682
  "trend_30d",
614
683
  ]);
684
+ // The poller block's fields this version knows (core-backend shadowctlog poller.go Status):
685
+ // running and last_run_at, relayed when both are real; the rest describe the instance that
686
+ // answered and are left out by design (see above). Any other key is left out and named, as a
687
+ // stats field is (#2313 item 7, #2440): an injected poller.exposed_now must not vanish unnamed.
688
+ const SHADOW_AI_POLLER = new Set([
689
+ "running",
690
+ "last_run_at",
691
+ "interval_seconds",
692
+ "shodan_enabled",
693
+ "last_new_inserts",
694
+ "last_error",
695
+ "skipped_as_follower",
696
+ "source_health",
697
+ "consecutive_fails",
698
+ "shodan_last_new",
699
+ ]);
615
700
  // The observed rankings and the daily series: API field, and the row field that names each row.
616
701
  const SHADOW_AI_ROWS = [
617
702
  ["trend_30d", "date"],
@@ -1063,13 +1148,19 @@ function readShadowAI(sa) {
1063
1148
  sentences.push(`Shadow AI authentication, over every observation on record: ${authParts.join("; ")}.`);
1064
1149
  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).");
1065
1150
  }
1066
- // What the answer carried that the tool cannot label: named, and left out.
1151
+ // What the answer carried that the tool cannot label: named, and left out. The poller block's
1152
+ // keys are checked like the stats keys: its known instance fields are left out by design, and
1153
+ // any other key is named.
1154
+ const poller = field(sa, "poller");
1067
1155
  const unknown = [
1068
1156
  ...Object.keys(isPlainObject(sa) ? sa : {}).filter((k) => k !== "stats" && k !== "poller"),
1069
1157
  ...Object.keys(isPlainObject(stats) ? stats : {})
1070
1158
  .filter((k) => !SHADOW_AI_STATS.has(k))
1071
1159
  .map((k) => `stats.${k}`),
1072
1160
  ...rowExtras,
1161
+ ...Object.keys(isPlainObject(poller) ? poller : {})
1162
+ .filter((k) => !SHADOW_AI_POLLER.has(k))
1163
+ .map((k) => `poller.${k}`),
1073
1164
  ];
1074
1165
  const unusable = Object.entries(isPlainObject(stats) ? stats : {})
1075
1166
  .filter(([k, v]) => SHADOW_AI_STATS.has(k) && !used.has(k) && v !== null)
@@ -1094,7 +1185,7 @@ function readShadowAI(sa) {
1094
1185
  data.poller = { running, last_run_at: lastRun };
1095
1186
  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.`);
1096
1187
  }
1097
- else if (field(sa, "poller") === undefined) {
1188
+ else if (poller === undefined) {
1098
1189
  sentences.push("Shadow-AI radar freshness is unknown: the answer carries no poller block.");
1099
1190
  }
1100
1191
  else {
@@ -1117,7 +1208,7 @@ const EXPOSURE_RADAR_DESCRIPTION = `Aggregate totals from EchelonGraph's interne
1117
1208
  // and freshness carries each radar's last_run_at where the API serves one.
1118
1209
  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
1210
  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.";
1211
+ 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, and the note labels each one by what it counts.";
1121
1212
  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
1213
  async function exposureRadar() {
1123
1214
  const tool = "exposure_radar";
@@ -1195,11 +1286,19 @@ const ErrorSchema = z.strictObject({
1195
1286
  message: z.string().describe("The cause: the API's own message, or what went wrong."),
1196
1287
  });
1197
1288
  const NOTES = z.array(z.string()).describe("Caveats, one sentence each.");
1289
+ // The success states (#2465). 2.1.0 described not_assessed as no dated measurement whose numbers
1290
+ // were all denied the status of findings, on the same schema as a cve_exposure answer relaying
1291
+ // services on record for a CISA-KEV CVE, which is not_assessed because its count is undated
1292
+ // (#2439). A client that respects the schema was told that count is not a finding. The count is
1293
+ // what the radar holds on record: not a dated measurement, and not nothing. So the description
1294
+ // says both, and leaves what each count is to the notes and exposure_state, which say it per
1295
+ // answer; it makes no blanket claim about the numbers, in either direction.
1296
+ const SUCCESS_STATE = "measured: a measurement of what was asked; an exposure count is measured only with measured_at and method. not_assessed: the answer holds no dated measurement of what was asked, so no count in it is presented as one; it can still relay a count, as what the source holds on record, undated, and its notes (and exposure_state, where the result carries it) say what each count is.";
1198
1297
  function envelopeSchema(o) {
1199
1298
  const coverage = o.coverage ? o.coverage.nullable() : z.null();
1200
1299
  return z.discriminatedUnion("state", [
1201
1300
  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."),
1301
+ state: z.enum(["measured", "not_assessed"]).describe(SUCCESS_STATE),
1203
1302
  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
1303
  method: z.string().describe("How the numbers were produced."),
1205
1304
  coverage: coverage.describe("What the answer covers; null where the answer says nothing about it."),
@@ -1306,7 +1405,7 @@ const CVE_EXPOSURE_OUTPUT = envelopeSchema({
1306
1405
  exposure_state: z
1307
1406
  .enum(["exposed", "measured_zero", "not_assessed", "tracking_unknown"])
1308
1407
  .nullable()
1309
- .describe("What the answer's count is: exposed, a measured zero, not assessed, or tracking unknown; null when it carries no count."),
1408
+ .describe("What the answer's count is, as the note's (exposure_state: …) tag says; not state, which says whether the answer is a dated measurement. exposed: data.exposed_hosts counts the services the radar holds on record for the CVE, above 0. measured_zero: the API marks the CVE as tracked with 0 services, a zero in the radar's sample. not_assessed: the CVE is outside the radar's tracked set, so its count, if any, is not a current measurement. tracking_unknown: the API does not say whether the radar tracks the CVE, so its 0 is not evidence either way. null: the answer carries no count."),
1310
1409
  },
1311
1410
  });
1312
1411
  // exposure_radar's data, from the readers' own specs, so the schema cannot drift from them.
@@ -1383,9 +1482,12 @@ const EXPOSURE_RADAR_OUTPUT = envelopeSchema({
1383
1482
  const INSTRUCTIONS = [
1384
1483
  "EchelonGraph's public CVE and internet-exposure data, read-only and keyless.",
1385
1484
  "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.",
1485
+ "Every result says how it was measured: state, measured_at, method, coverage, freshness and notes, in its structuredContent and again in its text.",
1486
+ "Its last text block is that structuredContent as JSON, less what an earlier text block already gives verbatim: data, which is a success's first text block; the sentences of the text block just before it (the note, or a failure's message), with which structuredContent's notes end; and method, where that block quotes it.",
1387
1487
  "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.",
1488
+ "not_assessed means the answer holds no dated measurement of what was asked, so no count in it is presented as one.",
1489
+ "It can still relay a count, as what the source holds on record, undated, and its notes say what each count is: cve_exposure's exposed_hosts when its exposure_state is exposed, and exposure_radar's labelled totals, are such counts.",
1490
+ "A zero is not a finding of no exposure where the answer holds no measurement for that CVE, for example a CVE outside the KEV-exposure radar's tracked set (exposure_state not_assessed).",
1389
1491
  "failed and invalid_input mean nothing was measured: never report them as zero, none found or unexposed.",
1390
1492
  "measured_at is when the underlying observation was made, and null when the answer does not say or holds none.",
1391
1493
  "freshness gives the producing radar's last_run_at: when it last completed a check whose reads succeeded.",
@@ -1397,7 +1499,7 @@ const INSTRUCTIONS = [
1397
1499
  const ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
1398
1500
  const CVE_ID_ARG = z.string().describe("a CVE ID, e.g. CVE-2023-44487");
1399
1501
  // 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.";
1502
+ 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 (the first text block) and without the note's sentences (the text block before it), with which notes ends.";
1401
1503
  // One server, built per connection by serveStdio for whichever era the client opens with.
1402
1504
  // tools/list answers in registration order, which is the order below.
1403
1505
  function createServer() {
@@ -1430,14 +1532,14 @@ function createServer() {
1430
1532
  }, async ({ cve_id }) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(cve_id)));
1431
1533
  server.registerTool("cve_exposure", {
1432
1534
  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.`,
1535
+ description: `${CVE_EXPOSURE_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: the per-CVE answer says when EchelonGraph last wrote a row (last_seen), not when any counted service was observed, so no count is presented as a dated measurement; the count is still relayed, labelled, as what the radar holds on record. exposure_state says what the count is: exposed, measured_zero, not_assessed or tracking_unknown; coverage.in_scope is the API's tracked verdict; freshness is null, since the per-CVE answer carries no last completed check; data is the API's JSON. The result's last text block repeats the structured result without data (the first text block), without the note's sentences (the text block before it), with which notes ends, and without method where the note quotes it verbatim ("Method: …").`,
1434
1536
  inputSchema: z.object({ cve_id: CVE_ID_ARG }),
1435
1537
  outputSchema: CVE_EXPOSURE_OUTPUT,
1436
1538
  annotations: ANNOTATIONS,
1437
1539
  }, async ({ cve_id }) => checked("cve_exposure", CVE_EXPOSURE_OUTPUT, await cveExposure(cve_id)));
1438
1540
  server.registerTool("exposure_radar", {
1439
1541
  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.`,
1542
+ 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; each count is still relayed, labelled, as what that radar holds on record. 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 (the first text block) and without the note's sentences (the text block before it), with which notes ends.`,
1441
1543
  outputSchema: EXPOSURE_RADAR_OUTPUT,
1442
1544
  annotations: ANNOTATIONS,
1443
1545
  }, async () => checked("exposure_radar", EXPOSURE_RADAR_OUTPUT, await exposureRadar()));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echelongraph-mcp",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "mcpName": "io.echelongraph/echelongraph-mcp",
5
5
  "description": "EchelonGraph CVE and internet-exposure data as an MCP server: CVEs with EchelonGraph multi-source scores, CISA-KEV and EPSS, plus a per-CVE internet-exposure footprint derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan). Free and keyless, for Claude, Cursor and any MCP client.",
6
6
  "type": "module",
package/server.json CHANGED
@@ -8,12 +8,12 @@
8
8
  "url": "https://github.com/echelongraph/echelongraph-mcp",
9
9
  "source": "github"
10
10
  },
11
- "version": "2.0.0",
11
+ "version": "2.2.0",
12
12
  "packages": [
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "echelongraph-mcp",
16
- "version": "2.0.0",
16
+ "version": "2.2.0",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  },