echelongraph-mcp 2.1.0 → 2.3.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
@@ -21,7 +21,7 @@ API call a tool needs to answer.
21
21
  | `search_cves` | Search/filter CVEs (severity, min CVSS, text, sort) with EchelonGraph scores. |
22
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
- | `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. |
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; and MCP servers found in EchelonGraph's own Certificate Transparency feed, by RFC 9728 verdict, protocol era and transport. Every number is labelled by what it counts, and a field the tool cannot label is left out and named. |
25
25
 
26
26
  Every figure is as fresh as the schedule that refreshes it. The CVE feed is polled from its
27
27
  sources on a schedule; each radar refreshes on its own.
@@ -164,6 +164,74 @@ its name and its count, and a field added to one is named too. The `poller` bloc
164
164
  checked the same way: a key this version does not know is left out and named, while the
165
165
  instance fields it knows are left out as described below.
166
166
 
167
+ #### `mcp_servers`
168
+
169
+ The AI-exposure radar's MCP-server verdicts, from `GET /api/v1/public/ai-exposure/stats?service=mcp`.
170
+ Every hostname was named like an MCP server in EchelonGraph's own Certificate Transparency
171
+ feed, matched by hostname pattern; no Shodan data is used. Each is checked by EchelonGraph's
172
+ identified MCP probe: a `server/discover` request, and `initialize` only if that is refused. It
173
+ never sends `tools/call`. The answer is counts and timestamps only, with no hostname, version or
174
+ server name, and the tool repeats no string it carries but its timestamps. Each hostname is
175
+ counted once, by its latest verdict on record, and EchelonGraph's own control servers are left
176
+ out. The unit is a hostname, not an ip:port service.
177
+
178
+ | Field | What it counts |
179
+ |---|---|
180
+ | `mcp_servers.total` | Hostnames the radar has checked for an MCP server, each once, by its latest verdict on record. Not every one is an MCP server: see `mcp_servers.not_assessed_by_reason`. It is `mcp_servers.protected` + `mcp_servers.pending_readjudication` + `mcp_servers.not_assessed`. |
181
+ | `mcp_servers.protected` | Hostnames whose `/mcp` endpoint asked for credentials (a 401 or 403) and whose OAuth protected-resource metadata validated under RFC 9728: a 200 JSON document whose resource is identical to the server's identifier and that names at least one authorization server. |
182
+ | `mcp_servers.prm_via.header`, `mcp_servers.prm_via.wellknown_path`, `mcp_servers.prm_via.wellknown_root` | The protected ones, by where that document was found: the same-origin URL the credential challenge named; `/.well-known/oauth-protected-resource` followed by the endpoint's path; or `/.well-known/oauth-protected-resource` itself. They add up to `mcp_servers.protected`. |
183
+ | `mcp_servers.pending_readjudication` | Verdicts decided by a rule EchelonGraph has since replaced and not yet re-checked under the current rules. They are in neither `mcp_servers.protected` nor `mcp_servers.not_assessed`. |
184
+ | `mcp_servers.not_assessed` | The rest, whose protection the radar could not assess. Not assessed does not mean unprotected. `mcp_servers.not_assessed_by_reason` puts each of them in exactly one bucket (below). |
185
+ | `mcp_servers.own_controls_excluded` | EchelonGraph's own control servers, left out of every other `mcp_servers` number. |
186
+ | `mcp_servers.window.from`, `mcp_servers.window.to` | Timestamps, not counts: when the oldest and the newest of the verdicts counted were last checked. The counts are each hostname's latest verdict, not one sweep at one time. With no verdict counted there is no window. |
187
+ | `mcp_servers.last_run_at` | A timestamp, not a count: when the AI-exposure radar, which checks other AI services as well as MCP servers, last completed a check (a cycle whose reads succeeded and whose scan opt-out register answered). It is not the time of every verdict counted. |
188
+ | `mcp_servers.enabled` | Whether the API reports the radar running: true when a check completed within 45 minutes of its answer. |
189
+ | `mcp_servers.counted_at` | A timestamp, not a count: when the API read these counts. |
190
+
191
+ `mcp_servers.not_assessed_by_reason` divides `mcp_servers.not_assessed`:
192
+
193
+ | Bucket | What it holds |
194
+ |---|---|
195
+ | `mcp_servers.not_assessed_by_reason.identified_no_challenge` | Servers that identified themselves as MCP servers (a DiscoverResult, an InitializeResult, or the endpoint event of the deprecated HTTP+SSE transport) and did not ask for credentials at the handshake. That is normal in MCP: authorization is optional in the spec, and a server can enforce it at `tools/call` instead, which EchelonGraph never sends. So this bucket is not a finding of exposure. |
196
+ | `mcp_servers.not_assessed_by_reason.resource_mismatch` | An endpoint that asked for credentials, whose metadata document's resource is absent or not identical to the server's identifier. |
197
+ | `mcp_servers.not_assessed_by_reason.cross_origin_pointer` | An endpoint that asked for credentials, whose challenge named a metadata URL on another origin, which EchelonGraph records and does not request. |
198
+ | `mcp_servers.not_assessed_by_reason.bare_challenge_no_prm` | An endpoint that asked for credentials, whose challenge named no metadata URL, and neither well-known URI answered 2xx. |
199
+ | `mcp_servers.not_assessed_by_reason.pointer_unreachable` | An endpoint that asked for credentials, whose same-origin metadata URL did not answer 2xx in full. |
200
+ | `mcp_servers.not_assessed_by_reason.pointer_invalid` | An endpoint that asked for credentials, whose challenge's metadata pointer is not a URL EchelonGraph will request. |
201
+ | `mcp_servers.not_assessed_by_reason.no_authorization_servers` | An endpoint that asked for credentials, whose metadata document names no authorization server. |
202
+ | `mcp_servers.not_assessed_by_reason.wellknown_unreachable` | An endpoint that asked for credentials, where a well-known metadata request got no complete HTTP answer. |
203
+ | `mcp_servers.not_assessed_by_reason.metadata_invalid` | An endpoint that asked for credentials, whose metadata answer is not a 200 with a JSON object within the size cap. |
204
+ | `mcp_servers.not_assessed_by_reason.challenge_unadjudicated` | An endpoint that asked for credentials before EchelonGraph read RFC 9728 metadata, not re-checked since. |
205
+ | `mcp_servers.not_assessed_by_reason.no_http_answer` | Hostnames that gave no HTTP answer: DNS, TCP or TLS failed, or the request timed out. Not a count of MCP servers. |
206
+ | `mcp_servers.not_assessed_by_reason.not_identified_as_mcp` | Hostnames that answered HTTP with nothing that identified an MCP server: a login or error page, a body that is not JSON-RPC, a 4xx or 5xx. Not a count of MCP servers. |
207
+
208
+ The eight credential-challenge buckets hold endpoints that asked for credentials: only their
209
+ metadata did not validate, so none of them is shown to lack protection.
210
+
211
+ `mcp_servers.era` and `mcp_servers.transport` each divide every counted hostname, so each adds
212
+ up to `mcp_servers.total`:
213
+
214
+ | Bucket | What it holds |
215
+ |---|---|
216
+ | `mcp_servers.era.legacy` | An initialize-era server: an InitializeResult, or the endpoint event of the deprecated HTTP+SSE transport. |
217
+ | `mcp_servers.era.dual` | A server that answered `server/discover` and itself named an initialize-era version too. A lower bound: a server built on the reference SDK names only modern versions there, and is counted modern. |
218
+ | `mcp_servers.era.modern` | A server that answered `server/discover` and named no initialize-era version. Not proven modern-only: EchelonGraph does not send it `initialize` to tell. |
219
+ | `mcp_servers.era.unknown` | Nothing identified an era; every credential challenge is here. |
220
+ | `mcp_servers.era.not_measured` | A verdict recorded before EchelonGraph's probe began recording the era, not re-checked since. |
221
+ | `mcp_servers.transport.streamable_http` | Identified by a POST to `/mcp`. |
222
+ | `mcp_servers.transport.legacy_sse` | Identified by the endpoint event of `GET /sse`, the deprecated HTTP+SSE transport. |
223
+ | `mcp_servers.transport.unknown` | Nothing identified a transport. |
224
+ | `mcp_servers.transport.not_measured` | Recorded before the era probe, not re-checked since. |
225
+
226
+ The tool refuses the answer, as a failure of that radar and never as zeros, when it does not say
227
+ `service` `mcp` (an API older than `?service=mcp` ignores the parameter and answers its counts
228
+ over every AI service it checks), when `mcp_servers.total`, `mcp_servers.protected`,
229
+ `mcp_servers.pending_readjudication` or `mcp_servers.not_assessed` is missing or not a whole
230
+ number, or when they contradict each other. A partition whose buckets are not exactly those
231
+ above, each a whole number adding up to the count it divides, is left out whole and named. A
232
+ field this version does not know is left out, and named only when its name is shaped like a
233
+ field name.
234
+
167
235
  ## What a result means
168
236
 
169
237
  Every tool answers in one of two shapes, so a model reading the result cannot mistake an
@@ -171,7 +239,7 @@ outage for an all-clear:
171
239
 
172
240
  - **Success** — the first text block is the API's JSON verbatim; the second is a one-line
173
241
  note saying the call succeeded, which base URL answered, and what it found; the third is
174
- the structured result without `data`, as JSON (below). When the
242
+ the structured result as JSON, less what the first two already say (below). When the
175
243
  feed genuinely holds nothing for the query the note says so in words ("we looked and
176
244
  found nothing … not a lookup failure"), because a measured zero is a measurement. The
177
245
  exception to verbatim is `exposure_radar`: each radar is cut to the fields listed above, and
@@ -191,12 +259,13 @@ outage for an all-clear:
191
259
  the host could not be reached, it answered non-2xx, it took longer than the timeout, or
192
260
  it answered 2xx with a body that is not a JSON object. The text names the tool, the
193
261
  cause (status code or error kind), the path, and the base URL, and says it is not a
194
- finding; the second text block is the structured result, as JSON (below). A failure is never
195
- rendered as a success with null fields.
262
+ finding; the second text block is the structured result as JSON, less the message's
263
+ sentences (below). A failure is never rendered as a success with null fields.
196
264
 
197
- Both shapes also carry a structured result, below, and repeat it in their last text block, so a
198
- client that passes only `content` to the model still sees how the answer was measured. The
199
- first text block (the API's JSON, or the failure) and the note after it are where 1.x put them.
265
+ Both shapes also carry a structured result, below, and repeat it in their last text block, less
266
+ what an earlier block already says, so a client that passes only `content` to the model still
267
+ sees how the answer was measured. The first text block (the API's JSON, or the failure) and the
268
+ note after it are where 1.x put them.
200
269
 
201
270
  ## Structured results
202
271
 
@@ -216,15 +285,21 @@ declares its shape as an `outputSchema` in `tools/list`, with a title and the an
216
285
  | `data` | On a success only: the same JSON as the first text block. |
217
286
  | `error` | On a failure only: `kind`, `path`, `status` and `message`. |
218
287
 
219
- The last text block of every result is this structured result serialized as JSON, without
220
- `data`: the same `state`, `measured_at`, `method`, `coverage`, `freshness` and `notes` (and on
221
- a failure `error`), key for key, since a client may pass only `content` to the model. With
222
- the first text block as `data`, the text carries the whole structured result.
288
+ The last text block of every result is this structured result serialized as JSON, less what
289
+ an earlier text block already says verbatim, since a client may pass only `content` to the
290
+ model. It carries the same `state`, `measured_at`, `coverage` and `freshness` (and on a failure
291
+ `error`), key for key. It leaves out `data`, which is a success's first text block, and the
292
+ note's sentences, which are the text block just before it (on a failure, the message) and
293
+ with which `notes` ends: its `notes` holds only the sentences the envelope adds about itself,
294
+ and is left out when there are none. It leaves out `method` too where that note quotes it verbatim, as
295
+ `cve_exposure`'s does ("Method: …"). So the text blocks together carry the whole structured
296
+ result, and cannot disagree with it. Before 2.2.0 this block repeated the whole note, so every
297
+ note was sent twice.
223
298
 
224
299
  | `state` | What it means |
225
300
  |---|---|
226
301
  | `measured` | A measurement of what was asked. An exposure count is `measured` only when the answer says when what it counts was observed (`measured_at`) and how it was produced (`method`). No exposure answer says when today, so neither exposure tool answers `measured`. |
227
- | `not_assessed` | The answer holds no dated measurement of what was asked, so no number in it is a finding: the radar does not look for this CVE, the answer does not say whether it does, or the answer does not say when what it counts was observed. |
302
+ | `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. |
228
303
  | `failed` | The lookup did not complete. Not a finding. |
229
304
  | `invalid_input` | The input was refused, by this server or by the API, so nothing was looked up. Not a finding. |
230
305
 
@@ -248,9 +323,11 @@ a note says so. Per tool:
248
323
  `cve_exposure` counts", and `coverage.in_scope` is the API's `tracked`. It becomes `measured`
249
324
  only when the API serves a time at which the counted services were observed.
250
325
  - `exposure_radar` is `not_assessed` with `measured_at` `null`: every radar answered, but no
251
- stats answer says when the services or records it counts were observed, so no count is
252
- presented as a dated measurement. Its numbers keep the labels above. `freshness` holds each
253
- radar's `last_run_at` where the API serves one, and for `shadow_ai` also `running`.
326
+ answer gives one time at which what it counts was observed, so no count is presented as a
327
+ dated measurement. `mcp_servers` dates its verdicts only by a window, `mcp_servers.window.from`
328
+ to `mcp_servers.window.to`, over checks made at different times, which is not one observation
329
+ time. Its numbers keep the labels above. `freshness` holds each radar's `last_run_at` where the
330
+ API serves one, for `shadow_ai` also `running`, and for `mcp_servers` also `enabled`.
254
331
 
255
332
  The CVE feed tools' `freshness` is `null`: the feed's answers carry no time at which its
256
333
  pollers last completed a poll for the feed as a whole.
package/dist/index.js CHANGED
@@ -12,11 +12,12 @@
12
12
  // the call succeeded, where, and what it found — in words, including when it found
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
- // what it left out (readKEVExposure, readExposedDatabases, readLeakedCredentials and
16
- // readShadowAI; #2307, #2313). content[2] is the envelope below, as JSON.
15
+ // what it left out (readKEVExposure, readExposedDatabases, readLeakedCredentials,
16
+ // readShadowAI and readMCPServers; #2307, #2313, #2315). content[2] is the envelope below,
17
+ // as JSON, less what content[0] and the note already say.
17
18
  // - failure (isError: true): the lookup did not complete — unreachable host, non-2xx,
18
19
  // timeout, or a 2xx whose body is not a JSON object — named by tool, cause and base URL,
19
- // in content[0]; content[1] is the envelope below, as JSON.
20
+ // in content[0]; content[1] is the envelope below, as JSON, less the message's sentences.
20
21
  // Never a success with null fields: a model handed one of those tells its user "no
21
22
  // exposure found", and an outage becomes an all-clear.
22
23
  //
@@ -26,13 +27,16 @@
26
27
  // with, on a success, `data` equal to what content[0] carries, and on a failure an `error`.
27
28
  // Each tool advertises the envelope as its outputSchema.
28
29
  //
29
- // The envelope in the text (#2440). Many clients pass only `content` to the model, so the last
30
- // text block of every result is structuredContent serialized as JSON, without `data` (which
31
- // content[0] already carries verbatim): the same state, measured_at, method, coverage,
32
- // freshness, notes and, on a failure, error, key for key. content[0] + that block is the whole
33
- // of structuredContent. The MCP spec (2025-06-18, Tools, Structured Content) asks a tool that
34
- // returns structured content to return it serialized in a text block too; the JSON block stays
35
- // first and the note second, as in 1.x, so a client that parses content[0] is unaffected.
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.
36
40
  //
37
41
  // Protocol eras (#2311). serveStdio answers both: a 2026-07-28 client's server/discover and
38
42
  // per-request `_meta` envelope, and a 2025-era client's `initialize` handshake. The first
@@ -195,6 +199,8 @@ function describeFailure(f) {
195
199
  return `EchelonGraph answered HTTP ${f.status} ${where} but the body was not JSON (${f.detail}).`;
196
200
  case "not_object":
197
201
  return `EchelonGraph answered HTTP ${f.status} ${where} but the body was not a JSON object (${f.detail}).`;
202
+ case "unexpected_shape":
203
+ return `EchelonGraph answered HTTP ${f.status} ${where}, but ${f.detail}.`;
198
204
  }
199
205
  }
200
206
  // What a model must not conclude from a failure.
@@ -206,20 +212,39 @@ const NO_RECORD = "The API returned no record for this request; the quoted messa
206
212
  // It is tagged invalid_input, the same state a request this server refuses locally carries.
207
213
  const INVALID_INPUT = "(state: invalid_input)";
208
214
  const REJECTED_INPUT = "The API rejected the request as invalid input, so nothing was looked up: this is not a finding. Do not report it as zero, none found, or unexposed; correct the input and retry.";
209
- // The envelope as a text block (#2440): structuredContent without `data`, serialized as JSON,
210
- // so a client that passes only `content` to the model still sees how the answer was measured.
211
- // `data` is left out because content[0] carries it verbatim; every other key is copied as is,
212
- // so the text and the structured result cannot disagree.
213
- const envelopeText = (structured) => {
214
- const { data: _data, ...envelope } = structured;
215
+ // The envelope as a text block (#2440), so a client that passes only `content` to the model
216
+ // still sees how the answer was measured: structuredContent serialized as JSON, less what an
217
+ // earlier text block already carries verbatim (#2467). 2.1.0 sent the whole envelope without
218
+ // `data`, so every note went out twice, once as prose and once as the notes array, and
219
+ // cve_exposure's method a third time.
220
+ // data left out: content[0] carries it verbatim.
221
+ // notes only the envelope's own sentences (`own`), which no text block says; the rest of
222
+ // structuredContent's notes are the sentences of `said`, the text block just before
223
+ // this one (the note, or a failure's message). Left out when there are none.
224
+ // method left out when `said` quotes it verbatim (cve_exposure's note, "Method: …").
225
+ // Every other key is copied as is, in structuredContent's order, so the text blocks together are
226
+ // the whole of structuredContent and cannot disagree with it.
227
+ const envelopeText = (structured, said, own) => {
228
+ const envelope = {};
229
+ for (const [k, v] of Object.entries(structured)) {
230
+ if (k === "data")
231
+ continue;
232
+ if (k === "notes") {
233
+ if (own.length)
234
+ envelope.notes = own;
235
+ }
236
+ else if (!(k === "method" && typeof v === "string" && said.includes(v)))
237
+ envelope[k] = v;
238
+ }
215
239
  return text(JSON.stringify(envelope, null, 2));
216
240
  };
217
241
  // A failure result: the #1874 text, and an envelope saying nothing was measured, in
218
242
  // structuredContent and again as the last text block. The error quotes only what
219
- // describeFailure already quotes (a redacted cause, the API's own message).
243
+ // describeFailure already quotes (a redacted cause, the API's own message). Every note is a
244
+ // sentence of the message, so the text envelope carries none.
220
245
  const failure = (state, message, error, coverage = null) => {
221
246
  const structuredContent = { state, measured_at: null, method: null, coverage, freshness: null, notes: sentences(message), error };
222
- return { content: [text(message), envelopeText(structuredContent)], structuredContent, isError: true };
247
+ return { content: [text(message), envelopeText(structuredContent, message, [])], structuredContent, isError: true };
223
248
  };
224
249
  const errorOf = (f) => ({ kind: f.kind, path: f.path, status: f.status ?? null, message: f.detail });
225
250
  const failed = (tool, f) => {
@@ -264,11 +289,12 @@ const checked = (tool, schema, r) => {
264
289
  const p = schema.safeParse(r.structuredContent);
265
290
  return p.success ? r : unexpectedShape(tool, p.error);
266
291
  };
267
- // A success: content[0] is the data, content[1] the note, content[2] the envelope without data;
292
+ // A success: content[0] is the data, content[1] the note, content[2] the envelope less both;
268
293
  // the envelope carries both, the note as sentences after any the envelope adds about itself.
269
294
  const succeeded = (data, note, env) => {
270
- const structuredContent = { ...env, notes: [...(env.notes ?? []), ...sentences(note)], data };
271
- return { content: [text(JSON.stringify(data, null, 2)), text(note), envelopeText(structuredContent)], structuredContent };
295
+ const own = env.notes ?? [];
296
+ const structuredContent = { ...env, notes: [...own, ...sentences(note)], data };
297
+ return { content: [text(JSON.stringify(data, null, 2)), text(note), envelopeText(structuredContent, note, own)], structuredContent };
272
298
  };
273
299
  const okHead = (tool, status) => `${tool} OK: EchelonGraph answered${status === undefined ? "" : ` HTTP ${status}`} from ${SHOWN_BASE}.`;
274
300
  // Tolerant readers for the note: a missing or renamed field degrades the sentence, never
@@ -464,7 +490,7 @@ const SHODAN_ATTRIBUTION = "Exposure counts are derived from Shodan data.";
464
490
  // and copyright in the applicable Shodan materials" (#2306). The attribution above does the
465
491
  // first; this sentence, placed beside it wherever Shodan data is named, does the second.
466
492
  const SHODAN_OWNERSHIP = "Shodan data is owned by Shodan, which holds its copyright (© Shodan).";
467
- const CVE_EXPOSURE_DESCRIPTION = `Internet-exposure footprint for one CVE from EchelonGraph's KEV-exposure radar: how many internet-facing services (distinct ip:port, returned as exposed_hosts; a machine answering on two ports counts twice) the radar has on record running a version its CVE matcher maps to this CVE, with a country/product breakdown and a ransomware flag. Aggregate and host-redacted; free and keyless. Method: exposure counts are derived from Shodan data. ${SHODAN_OWNERSHIP} Every 12 h, when Shodan query credits allow, the radar runs one Shodan query per tracked product, reads up to 100 ip:port services per query, and keeps a service when its banner version matches a CISA-KEV or high-EPSS CVE; a service whose row has not been written or refreshed for 21 days is dropped. last_seen is when EchelonGraph last wrote or refreshed a service's row, not when the service was observed and not when its vulnerable version was last confirmed: it is set to the time of the write when a search matches the banner, and again when a re-check finds the port still listed by Shodan InternetDB, without re-reading the banner, so a patched service can stay counted while its port stays open. A count is therefore a banner-version inference over a sample, not an exploit test and not an internet-wide census. The radar only looks for its tracked set of CVEs: for a CVE outside that set the result says NOT ASSESSED, and its 0 is not a measurement.`;
493
+ 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.`;
468
494
  function exposureNote(head, id, d) {
469
495
  const cve = strAt(d, "cve_id") ?? id;
470
496
  const hosts = numAt(d, "exposed_hosts");
@@ -600,13 +626,16 @@ async function cveExposure(cve_id) {
600
626
  }
601
627
  // Every quadrant that finds its services through Shodan, attributed. The exposed-database
602
628
  // radar searches Shodan dorks and falls back to LeakIX when the Shodan query budget is low
603
- // (core-backend/internal/exposeddb/poller.go).
604
- const RADAR_SOURCES = "KEV-exposure, exposed-database and shadow-AI discovery use Shodan data (shadow AI also uses Certificate Transparency logs; exposed databases fall back to LeakIX when Shodan query credits run low).";
629
+ // (core-backend/internal/exposeddb/poller.go). MCP-server discovery is named too, as the one
630
+ // that uses none (#2315; see the block comment above readMCPServers), so the attribution is
631
+ // never read as covering it.
632
+ const RADAR_SOURCES = "KEV-exposure, exposed-database and shadow-AI discovery use Shodan data (shadow AI also uses Certificate Transparency logs; exposed databases fall back to LeakIX when Shodan query credits run low). MCP-server discovery uses no Shodan data: its hostnames come from EchelonGraph's own Certificate Transparency feed, matched by hostname pattern.";
605
633
  const RADARS = [
606
634
  ["kev_exposure", "/api/v1/public/kev-exposure/stats"],
607
635
  ["exposed_databases", "/api/v1/public/exposed-databases/stats"],
608
636
  ["leaked_credentials", "/api/v1/public/leaked-credentials/stats"],
609
637
  ["shadow_ai", "/api/v1/public/shadow-ai-radar/stats"],
638
+ ["mcp_servers", "/api/v1/public/ai-exposure/stats?service=mcp"],
610
639
  ];
611
640
  // The shadow-AI stats answer, regrouped by what each number counts (#2307).
612
641
  //
@@ -1171,33 +1200,265 @@ function readShadowAI(sa) {
1171
1200
  sentences.push(`Latest shadow-AI observation on record: ${lastObservation}.`);
1172
1201
  return { data, sentences };
1173
1202
  }
1174
- const EXPOSURE_RADAR_DESCRIPTION = `Aggregate totals from EchelonGraph's internet-exposure radars, each refreshed on its own schedule: internet-facing services running actively-exploited (CISA-KEV) CVEs, plus the ransomware-linked subset, derived from Shodan data; unauthenticated data stores and observability UIs, found through Shodan (LeakIX when Shodan query credits run low) and then confirmed by EchelonGraph's own identified check (not a pure read: on Redis it names its client, and on ClickHouse its query is recorded in the server's query log); leaked credentials sampled from public GitHub push events; and shadow AI services found through Certificate Transparency logs and Shodan and then checked by EchelonGraph's identified probes. Every number in the result is labelled here and in the result's note by what it counts; a field this version cannot label is left out and named in the note. ` +
1203
+ // ── mcp_servers: GET /api/v1/public/ai-exposure/stats?service=mcp (#2315) ──
1204
+ //
1205
+ // core-backend/internal/aiexposure (handler.go MCPPublicCounts, store.go): the AI-exposure
1206
+ // radar's MCP rows, one per hostname, each counted once by its latest verdict on record, with
1207
+ // EchelonGraph's own control servers left out and counted in own_controls_excluded. Counts,
1208
+ // timestamps and one constant (service "mcp"); no hostname, version or server name.
1209
+ // total protected + pending_readjudication + not_assessed
1210
+ // protected the /mcp endpoint answered 401/403 and its RFC 9728 protected-resource
1211
+ // metadata validated (prm.go); prm_via divides exactly these rows by
1212
+ // where that document was found
1213
+ // pending_readjudication a verdict of a rule since replaced, not yet re-checked (store.go
1214
+ // readjudicationPredicate); in neither of the other two
1215
+ // not_assessed the rest; not_assessed_by_reason divides exactly these rows, first
1216
+ // match wins: the eight prm.go reason codes (a challenge whose metadata
1217
+ // did not validate), identified_no_challenge, challenge_unadjudicated
1218
+ // (a challenge recorded before #2312), no_http_answer, not_identified_as_mcp
1219
+ // era, transport divide every counted row; a row written before #2314 recorded an era
1220
+ // is not_measured (era.go says what each value rests on)
1221
+ // window from/to: the oldest and newest last-check time over the counted rows
1222
+ // enabled, last_run_at exactly as the unparameterised answer computes them: the AI-exposure
1223
+ // radar's fleet-wide liveness and last completed check, over every AI
1224
+ // service it checks, not MCP servers alone. enabled is true when a check
1225
+ // completed within liveWindow (45 minutes) of the answer.
1226
+ // counted_at when the API read the counts
1227
+ //
1228
+ // Discovery uses no Shodan data, so this reader carries no Shodan attribution. poller.go
1229
+ // discover enqueues only hostnames that store.go CandidateHosts matches by pattern (catalog.go
1230
+ // HostPatternIndex) in ct_domains and shadow_ct_observations, and that query drops every domain
1231
+ // holding a ':'; a Shodan row's domain is its ip:port (shadowctlog poller.go shodanDomain), so
1232
+ // none reaches the queue.
1233
+ //
1234
+ // identified_no_challenge is where #2307's mistake would be made again: a server that
1235
+ // identified itself as an MCP server and asked for no credentials at the handshake. MCP
1236
+ // authorization is optional (MCP 2026-07-28, Authorization › Protocol Requirements), a server
1237
+ // can enforce it at tools/call, and the probe never sends tools/call (mcp.go), so nothing here
1238
+ // says whether such a server gives anything away. Every text says what the bucket is, and never
1239
+ // calls it open, exposed or unauthenticated.
1240
+ //
1241
+ // What is refused, and what is left out. An API older than ?service=mcp ignores the parameter
1242
+ // and answers its counts over EVERY AI service, `protected` among them (aiexposure PublicCounts);
1243
+ // relayed as MCP-server counts, that is #2307 again. And the API refuses to serve counts that
1244
+ // contradict its method (a 500, never a 200 that breaks a sum), so a 200 that breaks one is not
1245
+ // an answer of that contract. So the radar is refused, as a per-radar failure (mcpRefusal),
1246
+ // when the answer does not say service "mcp", when a headline count is missing or not a whole
1247
+ // number, or when total is not the sum of the other three. Below the headline, the file's rule
1248
+ // for a known field in an unexpected shape holds: a partition is relayed only with exactly its
1249
+ // buckets, each a whole number, adding up to the count it divides, or it is left out whole and
1250
+ // named, since a partition missing a bucket, or carrying one this version cannot label, is an
1251
+ // undercount that reads as complete. A field this version does not know is left out, and named
1252
+ // only when its name is shaped like a field name: the answer is counts only, a key could itself
1253
+ // be a hostname, and the tool repeats no string the answer carries but its timestamps.
1254
+ const MCP_HEADLINE = ["total", "protected", "pending_readjudication", "not_assessed"];
1255
+ const MCP_CHALLENGE_REASONS = [
1256
+ "resource_mismatch",
1257
+ "cross_origin_pointer",
1258
+ "bare_challenge_no_prm",
1259
+ "pointer_unreachable",
1260
+ "pointer_invalid",
1261
+ "no_authorization_servers",
1262
+ "wellknown_unreachable",
1263
+ "metadata_invalid",
1264
+ ];
1265
+ const MCP_PARTITIONS = {
1266
+ not_assessed_by_reason: {
1267
+ of: "not_assessed",
1268
+ buckets: ["identified_no_challenge", ...MCP_CHALLENGE_REASONS, "challenge_unadjudicated", "no_http_answer", "not_identified_as_mcp"],
1269
+ },
1270
+ prm_via: { of: "protected", buckets: ["header", "wellknown_path", "wellknown_root"] },
1271
+ era: { of: "total", buckets: ["legacy", "dual", "modern", "unknown", "not_measured"] },
1272
+ transport: { of: "total", buckets: ["streamable_http", "legacy_sse", "unknown", "not_measured"] },
1273
+ };
1274
+ const MCP_PARTITION_NAMES = Object.keys(MCP_PARTITIONS);
1275
+ const MCP_INSTANTS = ["last_run_at", "counted_at"];
1276
+ const MCP_KNOWN = new Set(["service", ...MCP_HEADLINE, ...MCP_PARTITION_NAMES, "own_controls_excluded", "window", "enabled", ...MCP_INSTANTS]);
1277
+ // What each challenge reason code says (prm.go prmReasonCodes), for the note.
1278
+ const MCP_CHALLENGE_MEANS = {
1279
+ resource_mismatch: "the document's resource is absent or not identical to the server's identifier",
1280
+ cross_origin_pointer: "the challenge pointed to metadata on another origin, which EchelonGraph does not request",
1281
+ bare_challenge_no_prm: "no metadata URL was named and neither well-known URI answered 2xx",
1282
+ pointer_unreachable: "the same-origin metadata URL did not answer 2xx in full",
1283
+ pointer_invalid: "the metadata pointer is not a URL EchelonGraph will request",
1284
+ no_authorization_servers: "the document names no authorization server",
1285
+ wellknown_unreachable: "a well-known metadata request got no complete HTTP answer",
1286
+ metadata_invalid: "the answer is not a 200 JSON object within the size cap",
1287
+ };
1288
+ // A count as the MCP answer states one: a whole number, zero or more.
1289
+ const isWhole = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
1290
+ // The shape of a field name. An unknown key of this shape is named when left out; any other is
1291
+ // counted, not repeated, since it could be a hostname.
1292
+ const FIELD_NAME = /^[a-z][a-z0-9_]{0,63}$/;
1293
+ // core-backend aiexposure handler.go liveWindow: how recently a check must have completed for
1294
+ // the API to answer enabled true. A liveness window, not a re-probe interval.
1295
+ // MCP_CHALLENGE_REASONS as the tool description names them, spelled out as ONE constant string: the
1296
+ // description is published, and the site's tool-claims check (marketing-site lib/mcpToolClaims.test.ts)
1297
+ // reads every description statically, folding only references to constant strings, so a .map() here
1298
+ // made the whole description unreadable to it. tools.test.mjs pins this to MCP_CHALLENGE_REASONS.
1299
+ const MCP_CHALLENGE_REASON_FIELDS = "mcp_servers.not_assessed_by_reason.resource_mismatch, mcp_servers.not_assessed_by_reason.cross_origin_pointer, mcp_servers.not_assessed_by_reason.bare_challenge_no_prm, mcp_servers.not_assessed_by_reason.pointer_unreachable, mcp_servers.not_assessed_by_reason.pointer_invalid, mcp_servers.not_assessed_by_reason.no_authorization_servers, mcp_servers.not_assessed_by_reason.wellknown_unreachable and mcp_servers.not_assessed_by_reason.metadata_invalid";
1300
+ const MCP_ENABLED_WITHIN = "45 minutes";
1301
+ // Why an ?service=mcp answer is not relayed at all, or undefined when it can be read. The
1302
+ // sentence quotes no value the answer carries.
1303
+ function mcpRefusal(body) {
1304
+ if (!isPlainObject(body))
1305
+ return "the answer is not a JSON object carrying the MCP-server counts";
1306
+ if (body.service === undefined) {
1307
+ return "the answer carries no service field, so it is not the MCP-server counts: an API older than service=mcp ignores that parameter and answers the AI-exposure radar's counts over every AI service it checks, which this tool does not relay as MCP-server counts";
1308
+ }
1309
+ if (body.service !== "mcp")
1310
+ return "the answer names a service other than mcp, so it is not the MCP-server counts";
1311
+ const bad = MCP_HEADLINE.filter((k) => !isWhole(body[k]));
1312
+ if (bad.length)
1313
+ return `its ${listed(bad)} ${be(bad.length)} missing or not a whole number of zero or more, so its counts are not relayed`;
1314
+ const [total, protectedN, pending, notAssessed] = MCP_HEADLINE.map((k) => body[k]);
1315
+ if (total !== protectedN + pending + notAssessed) {
1316
+ return "its counts contradict each other (total is not protected + pending_readjudication + not_assessed, an answer the API's own integrity check does not serve), so they are not relayed";
1317
+ }
1318
+ return undefined;
1319
+ }
1320
+ function readMCPServers(src) {
1321
+ const data = {};
1322
+ const unusable = [];
1323
+ const unsummed = [];
1324
+ for (const k of MCP_HEADLINE)
1325
+ data[k] = src[k];
1326
+ for (const k of MCP_PARTITION_NAMES) {
1327
+ const { of, buckets } = MCP_PARTITIONS[k];
1328
+ const v = src[k];
1329
+ const exact = isPlainObject(v) && Object.keys(v).length === buckets.length && buckets.every((b) => Object.prototype.hasOwnProperty.call(v, b) && isWhole(v[b]));
1330
+ if (!exact) {
1331
+ unusable.push(k);
1332
+ continue;
1333
+ }
1334
+ if (buckets.reduce((sum, b) => sum + v[b], 0) !== src[of]) {
1335
+ unsummed.push(k);
1336
+ continue;
1337
+ }
1338
+ data[k] = Object.fromEntries(buckets.map((b) => [b, v[b]]));
1339
+ }
1340
+ if (isWhole(src.own_controls_excluded))
1341
+ data.own_controls_excluded = src.own_controls_excluded;
1342
+ else
1343
+ unusable.push("own_controls_excluded");
1344
+ // window: from and to, both real instants, from not after to. With no row counted the API has
1345
+ // no window to give (null, or from and to null), and none is relayed.
1346
+ const w = src.window;
1347
+ const from = strAt(w, "from");
1348
+ const to = strAt(w, "to");
1349
+ const noWindow = w === null || w === undefined || (isPlainObject(w) && w.from === null && w.to === null && Object.keys(w).length === 2);
1350
+ if (isPlainObject(w) && Object.keys(w).length === 2 && realInstant(from) && realInstant(to) && Date.parse(from) <= Date.parse(to))
1351
+ data.window = { from, to };
1352
+ else if (!(noWindow && src.total === 0))
1353
+ unusable.push("window");
1354
+ if (typeof src.enabled === "boolean")
1355
+ data.enabled = src.enabled;
1356
+ else
1357
+ unusable.push("enabled");
1358
+ // A timestamp that is not a real instant is not relayed as one (readRadar's rule).
1359
+ for (const k of MCP_INSTANTS) {
1360
+ const s = strAt(src, k);
1361
+ if (realInstant(s))
1362
+ data[k] = s;
1363
+ else if (src[k] !== undefined && src[k] !== null && typeof src[k] !== "string")
1364
+ unusable.push(k);
1365
+ }
1366
+ const extra = Object.keys(src).filter((k) => !MCP_KNOWN.has(k));
1367
+ const named = extra.filter((k) => FIELD_NAME.test(k));
1368
+ const unnamed = extra.length - named.length;
1369
+ const sentences = [];
1370
+ const n = (k) => data[k];
1371
+ const b = (p, bucket) => `mcp_servers.${p}.${bucket} (${data[p][bucket]})`;
1372
+ sentences.push(`MCP servers: mcp_servers.total (${n("total")}) counts hostnames the AI-exposure radar has checked for an MCP server, each once, by its latest verdict on record; each was named like an MCP server in EchelonGraph's own Certificate Transparency feed, and not every one is an MCP server.`);
1373
+ if (n("total") === 0)
1374
+ sentences.push("mcp_servers.total is 0: the radar holds no verdict on record for a hostname named like an MCP server.");
1375
+ sentences.push(`mcp_servers.protected (${n("protected")}) counts hostnames whose /mcp endpoint asked for credentials (a 401 or 403) and whose OAuth protected-resource metadata validated under RFC 9728: a 200 JSON document whose resource is identical to the server's identifier and that names at least one authorization server.`);
1376
+ if (data.prm_via) {
1377
+ sentences.push(`mcp_servers.prm_via divides them by where that document was found: ${b("prm_via", "header")}, the same-origin URL the challenge named; ${b("prm_via", "wellknown_path")}, /.well-known/oauth-protected-resource followed by the endpoint's path; and ${b("prm_via", "wellknown_root")}, that URI at the root.`);
1378
+ }
1379
+ sentences.push(`mcp_servers.pending_readjudication (${n("pending_readjudication")}) counts verdicts decided by a rule EchelonGraph has since replaced and not yet re-checked under the current rules; they are in neither mcp_servers.protected nor mcp_servers.not_assessed.`, `mcp_servers.not_assessed (${n("not_assessed")}) counts the rest, whose protection the radar could not assess; not assessed does not mean unprotected${data.not_assessed_by_reason ? ", and mcp_servers.not_assessed_by_reason puts each in exactly one bucket" : ""}.`);
1380
+ if (data.not_assessed_by_reason) {
1381
+ sentences.push(`${b("not_assessed_by_reason", "identified_no_challenge")} counts servers that identified themselves as MCP servers (a DiscoverResult, an InitializeResult, or the endpoint event of the deprecated HTTP+SSE transport) and did not ask for credentials at the handshake.`, "That is normal in MCP: authorization is optional in the spec, and a server can enforce it at tools/call instead, which EchelonGraph never sends, so this bucket is not a finding of exposure.", `Endpoints that asked for credentials (a 401 or 403) but whose RFC 9728 metadata did not validate are counted by why: ${MCP_CHALLENGE_REASONS.map((r, i) => `${i === MCP_CHALLENGE_REASONS.length - 1 ? "and " : ""}${b("not_assessed_by_reason", r)}, ${MCP_CHALLENGE_MEANS[r]}`).join("; ")}.`, "Each of those endpoints asked for credentials, so none of them is shown to lack protection: only its metadata did not validate.", `${b("not_assessed_by_reason", "challenge_unadjudicated")} counts endpoints that asked for credentials before EchelonGraph read RFC 9728 metadata, not re-checked since.`, `${b("not_assessed_by_reason", "no_http_answer")} counts hostnames that gave no HTTP answer (DNS, TCP or TLS failed, or the request timed out), and ${b("not_assessed_by_reason", "not_identified_as_mcp")} hostnames that answered HTTP with nothing that identified an MCP server (a login or error page, a body that is not JSON-RPC, a 4xx or 5xx): neither is a count of MCP servers.`);
1382
+ }
1383
+ if (data.era) {
1384
+ sentences.push(`mcp_servers.era divides every counted hostname by the protocol era its answer identified: ${b("era", "legacy")}, an initialize-era server (an InitializeResult, or the legacy SSE endpoint event); ${b("era", "dual")}, answered server/discover and named an initialize-era version too, a lower bound, since a server built on the reference SDK names only modern versions there and is counted modern; ${b("era", "modern")}, answered server/discover and named no initialize-era version, not proven modern-only, since EchelonGraph does not send initialize to tell; ${b("era", "unknown")}, nothing identified an era, every credential challenge included; and ${b("era", "not_measured")}, a verdict recorded before EchelonGraph's probe began recording the era and not re-checked since.`);
1385
+ }
1386
+ if (data.transport) {
1387
+ sentences.push(`mcp_servers.transport divides them by the transport that identified the server: ${b("transport", "streamable_http")}, a POST to /mcp; ${b("transport", "legacy_sse")}, the endpoint event of GET /sse (the deprecated HTTP+SSE transport); ${b("transport", "unknown")}, nothing identified one; and ${b("transport", "not_measured")}, recorded before the era probe and not re-checked since.`);
1388
+ }
1389
+ if (data.own_controls_excluded !== undefined) {
1390
+ sentences.push(`mcp_servers.own_controls_excluded (${n("own_controls_excluded")}) counts EchelonGraph's own control servers, which are left out of every other mcp_servers number.`);
1391
+ }
1392
+ if (data.window) {
1393
+ sentences.push(`mcp_servers.window says when the verdicts counted were last checked: the oldest at ${from} (mcp_servers.window.from) and the newest at ${to} (mcp_servers.window.to), so the counts are each hostname's latest verdict, not one sweep at one time.`);
1394
+ }
1395
+ const at = data.last_run_at;
1396
+ if (typeof at === "string") {
1397
+ sentences.push(`mcp_servers last completed check: ${at} (mcp_servers.last_run_at, a timestamp, not a count).`, "mcp_servers.last_run_at is when the AI-exposure radar, which checks other AI services as well as MCP servers, last finished a cycle whose reads succeeded and whose scan opt-out register answered.", "mcp_servers.last_run_at is not the time of every verdict the mcp_servers numbers count (mcp_servers.window says when those were checked); nor is it mcp_servers.counted_at, when the API read those numbers.");
1398
+ }
1399
+ if (data.enabled === true) {
1400
+ sentences.push(`mcp_servers.enabled is true: the API reports the AI-exposure radar running, a check having completed within ${MCP_ENABLED_WITHIN} of its answer.`);
1401
+ }
1402
+ else if (data.enabled === false) {
1403
+ sentences.push(`mcp_servers.enabled is false: the API does not report the AI-exposure radar running (no check completed within ${MCP_ENABLED_WITHIN} of its answer), so these numbers may be stale.`);
1404
+ }
1405
+ if (typeof data.counted_at === "string") {
1406
+ sentences.push(`mcp_servers.counted_at (${data.counted_at}) is when the API read these counts: a timestamp, not a count, and not when any verdict was checked.`);
1407
+ }
1408
+ sentences.push(...leftOut("mcp_servers", named, unusable));
1409
+ if (unsummed.length)
1410
+ sentences.push(`Left out of mcp_servers because their buckets do not add up to the count they divide: ${unsummed.join(", ")}.`);
1411
+ if (unnamed) {
1412
+ sentences.push(`Also left out of mcp_servers: ${unnamed} field${unnamed === 1 ? "" : "s"} whose name is not shaped like a field name, not repeated here, since a name could itself identify a server.`);
1413
+ }
1414
+ return { data, sentences };
1415
+ }
1416
+ const EXPOSURE_RADAR_DESCRIPTION = `Aggregate totals from EchelonGraph's internet-exposure radars, each refreshed on its own schedule: internet-facing services running actively-exploited (CISA-KEV) CVEs, plus the ransomware-linked subset, derived from Shodan data; unauthenticated data stores and observability UIs, found through Shodan (LeakIX when Shodan query credits run low) and then confirmed by EchelonGraph's own identified check (not a pure read: on Redis it names its client, and on ClickHouse its query is recorded in the server's query log); leaked credentials sampled from public GitHub push events; and shadow AI services found through Certificate Transparency logs and Shodan and then checked by EchelonGraph's identified probes. It also gives MCP-server counts: hostnames named like an MCP server in EchelonGraph's own Certificate Transparency feed (no Shodan data), each counted once by its latest verdict from EchelonGraph's identified MCP probe, which never sends tools/call. Every number in the result is labelled here and in the result's note by what it counts; a field this version cannot label is left out and named in the note. ` +
1175
1417
  `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. ` +
1176
1418
  `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. ` +
1177
1419
  `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. ` +
1178
- `shadow_ai: the result is regrouped by what each number counts. shadow_ai.confirmed_exposed counts services EchelonGraph's probes found answering without an authentication gate (liveness active, or rechecking during a re-check): confirmed_exposed.total is the sum of confirmed_exposed.by_category, and confirmed_exposed.last_24h counts those first recorded in the last 24 h. Of the shadow_ai numbers, only confirmed_exposed counts exposed services. shadow_ai.observed counts every Certificate Transparency or Shodan observation on record, whatever its verification state: its numbers are observed, not exposed. They are observed.total; observed.by_category (the same observations by category); observed.last_24h (those first recorded in the last 24 h); observed.trend_30d (observations per UTC day over the last 30 days); and observed.top_products, observed.top_countries and observed.top_issuers (up to ten products, countries and issuers ranked by observations, where an issuer is the certificate's CA for a Certificate Transparency observation and the hosting operator Shodan reports for a Shodan one). shadow_ai.authentication counts observations by probe outcome: authentication.observed where a probe observed an authentication gate (a 401/403, a login page or an auth marker), and authentication.not_determined where the service answered but no probe could tell. Neither authentication count is part of confirmed_exposed, and observed.total minus confirmed_exposed.total is not a count of secured services. The shadow-AI poller block carries only running and last_run_at: last_run_at is when the radar's leader last completed a Certificate Transparency (crt.sh) cycle, and its running is true only when that was within ${SHADOW_AI_SILENT_AFTER} of the answer. ${SHODAN_OWNERSHIP}`;
1420
+ `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. ` +
1421
+ `mcp_servers: counts and timestamps only, no hostname. mcp_servers.total counts hostnames the AI-exposure radar has checked for an MCP server, each once by its latest verdict on record, and not every one is an MCP server; EchelonGraph's own control servers are left out, and mcp_servers.own_controls_excluded counts them. mcp_servers.total is mcp_servers.protected plus mcp_servers.pending_readjudication plus mcp_servers.not_assessed. mcp_servers.protected counts hostnames whose /mcp endpoint asked for credentials and whose OAuth protected-resource metadata validated under RFC 9728; mcp_servers.prm_via divides them by where that document was found: mcp_servers.prm_via.header, mcp_servers.prm_via.wellknown_path and mcp_servers.prm_via.wellknown_root. mcp_servers.pending_readjudication counts verdicts of a rule since replaced, not yet re-checked. mcp_servers.not_assessed counts the rest, whose protection the radar could not assess (not assessed does not mean unprotected), and mcp_servers.not_assessed_by_reason puts each in one bucket. mcp_servers.not_assessed_by_reason.identified_no_challenge holds servers that identified themselves as MCP servers and did not ask for credentials at the handshake. That is normal in MCP: authorization is optional in the spec, and a server can enforce it at tools/call instead, which EchelonGraph never sends, so this bucket is not a finding of exposure. ${MCP_CHALLENGE_REASON_FIELDS} hold endpoints that asked for credentials but whose RFC 9728 metadata did not validate, by why, so none of them is shown to lack protection; mcp_servers.not_assessed_by_reason.challenge_unadjudicated holds endpoints that asked for credentials before that check existed, not yet re-checked. mcp_servers.not_assessed_by_reason.no_http_answer holds hostnames that gave no HTTP answer, and mcp_servers.not_assessed_by_reason.not_identified_as_mcp hostnames whose HTTP answer identified no MCP server. mcp_servers.era divides every counted hostname by protocol era: mcp_servers.era.legacy; mcp_servers.era.dual, a lower bound; mcp_servers.era.modern, not proven modern-only; mcp_servers.era.unknown; and mcp_servers.era.not_measured, recorded before the era probe and not re-checked since. mcp_servers.transport divides them by transport: mcp_servers.transport.streamable_http, mcp_servers.transport.legacy_sse, mcp_servers.transport.unknown and mcp_servers.transport.not_measured. mcp_servers.window.from and mcp_servers.window.to are when the oldest and the newest of the verdicts counted were last checked. mcp_servers.last_run_at is a timestamp, not a count: when the AI-exposure radar, which checks other AI services too, last completed a check; mcp_servers.enabled is whether one completed within ${MCP_ENABLED_WITHIN} of the answer, and mcp_servers.counted_at is when the API read the counts. A partition whose buckets do not add up to the count it divides is left out and named; an answer that does not say it is the MCP-server counts, or whose mcp_servers.total, mcp_servers.protected, mcp_servers.pending_readjudication and mcp_servers.not_assessed are missing or contradict each other, is a failure. ${SHODAN_OWNERSHIP}`;
1179
1422
  // exposure_radar's envelope. Every radar answered, but no stats answer says when the services
1180
1423
  // or records it counts were observed: each count covers everything that radar still holds on
1181
1424
  // record, and the answer carries only generated_at (when the API computed the totals) and
1182
1425
  // last_run_at (the radar's last completed check), neither of which is that time (#2335). So
1183
1426
  // the result is not_assessed with measured_at null, and says why; its numbers keep their labels,
1184
1427
  // and freshness carries each radar's last_run_at where the API serves one.
1185
- const RADAR_METHOD = "Aggregate counts over what each radar holds on record: kev_exposure, Shodan banners whose version maps to a CISA-KEV-listed CVE; exposed_databases, services found through Shodan (LeakIX when Shodan query credits run low) and confirmed by EchelonGraph's own identified check; leaked_credentials, credential-shaped strings in public GitHub push events; shadow_ai, services found through Certificate Transparency logs and Shodan and checked by EchelonGraph's identified probes.";
1428
+ //
1429
+ // mcp_servers (#2315) does not change that, by the envelope's own rule (the `state` block above
1430
+ // the State type): an exposure count is measured only with measured_at, "when the underlying
1431
+ // observation was made, as the API states it", and otherwise it is not_assessed and relayed as
1432
+ // what the source holds on record. The MCP answer states no such time. Its counts are each
1433
+ // hostname's LATEST verdict, and window.from and window.to are the oldest and newest last-check
1434
+ // times over the counted rows, a span of checks made at different times, not one observation.
1435
+ // Taking window.to as measured_at would date every count by its newest verdict, the reading
1436
+ // #2335 and #2439 refuse for last_run_at and last_seen; window.from would date each one by its
1437
+ // oldest. And the envelope is one for five radars, four of which carry no time of observation at
1438
+ // all, so a measured state would date their numbers too (#2313 done-means 5). So the state stays
1439
+ // not_assessed with measured_at null, the window is relayed in data and labelled by the note,
1440
+ // and the envelope's own note names it.
1441
+ const RADAR_METHOD = "Aggregate counts over what each radar holds on record: kev_exposure, Shodan banners whose version maps to a CISA-KEV-listed CVE; exposed_databases, services found through Shodan (LeakIX when Shodan query credits run low) and confirmed by EchelonGraph's own identified check; leaked_credentials, credential-shaped strings in public GitHub push events; shadow_ai, services found through Certificate Transparency logs and Shodan and checked by EchelonGraph's identified probes; mcp_servers, the latest verdict on record per hostname named like an MCP server in EchelonGraph's own Certificate Transparency feed, from EchelonGraph's identified MCP probe (server/discover, and initialize only if that is refused; never tools/call), where protected means the endpoint's RFC 9728 protected-resource metadata validated.";
1186
1442
  const RADAR_NAMES = RADARS.map(([name]) => name);
1187
- const RADAR_STATE_NOTE = "state is not_assessed: every radar answered, but no stats answer says when the services or records it counts were observed, so no count here is presented as a dated measurement and measured_at is null. Each count is what that radar holds on record, labelled below by what it counts.";
1188
- const RADAR_FRESHNESS_NOTE = "freshness gives each radar's last completed check where the API serves one (freshness.kev_exposure.last_run_at, freshness.exposed_databases.last_run_at, freshness.leaked_credentials.last_run_at, and freshness.shadow_ai.last_run_at with freshness.shadow_ai.running), and null where it does not.";
1443
+ const RADAR_STATE_NOTE = "state is not_assessed: every radar answered, but none gives one time at which what it counts was observed: kev_exposure, exposed_databases, leaked_credentials and shadow_ai give none, and mcp_servers dates its verdicts at most by a window (mcp_servers.window), from the oldest check among them to the newest, so no count here is presented as a dated measurement and measured_at is null. Each count is what that radar holds on record, and the note labels each one by what it counts.";
1444
+ const RADAR_FRESHNESS_NOTE = "freshness gives each radar's last completed check where the API serves one (freshness.kev_exposure.last_run_at, freshness.exposed_databases.last_run_at, freshness.leaked_credentials.last_run_at, freshness.shadow_ai.last_run_at with freshness.shadow_ai.running, and freshness.mcp_servers.last_run_at with freshness.mcp_servers.enabled), and null where it does not.";
1189
1445
  async function exposureRadar() {
1190
1446
  const tool = "exposure_radar";
1191
1447
  try {
1192
1448
  const results = await Promise.all(RADARS.map(([, path]) => api(path)));
1193
1449
  const data = {};
1194
1450
  const failures = [];
1195
- RADARS.forEach(([name], i) => {
1451
+ RADARS.forEach(([name, path], i) => {
1196
1452
  const r = results[i];
1197
- if (r.ok)
1453
+ if (!r.ok)
1454
+ return void failures.push([name, r]);
1455
+ // mcp_servers is read only when the answer is the MCP-server counts at all (mcpRefusal): an
1456
+ // answer that is not is a failure of that radar, named like any other, never zeros.
1457
+ const refused = name === "mcp_servers" ? mcpRefusal(r.data) : undefined;
1458
+ if (refused === undefined)
1198
1459
  data[name] = r.data;
1199
1460
  else
1200
- failures.push([name, r]);
1461
+ failures.push([name, { ok: false, kind: "unexpected_shape", path, status: r.status, detail: refused }]);
1201
1462
  });
1202
1463
  if (failures.length) {
1203
1464
  // All or nothing: a radar picture with one quadrant missing reads as the whole one,
@@ -1217,6 +1478,7 @@ async function exposureRadar() {
1217
1478
  exposed_databases: readExposedDatabases(data.exposed_databases),
1218
1479
  leaked_credentials: readLeakedCredentials(data.leaked_credentials),
1219
1480
  shadow_ai: readShadowAI(data.shadow_ai),
1481
+ mcp_servers: readMCPServers(data.mcp_servers),
1220
1482
  };
1221
1483
  const relayed = {};
1222
1484
  const said = [];
@@ -1227,6 +1489,7 @@ async function exposureRadar() {
1227
1489
  // Only what the readers relayed: a last_run_at that is not a real instant never got there.
1228
1490
  const stamp = (radar, ...keys) => instantOrNull(strAt(relayed[radar], ...keys));
1229
1491
  const running = boolAt(relayed.shadow_ai, "poller", "running");
1492
+ const enabled = boolAt(relayed.mcp_servers, "enabled");
1230
1493
  return succeeded(relayed, `${okHead(tool)} All ${RADARS.length} radars answered: ${RADARS.map(([name]) => name).join(", ")}. Every number relayed is labelled below by what it counts, and a field this version cannot label is left out and named. ${said.join(" ")} ${RADAR_SOURCES} ${SHODAN_OWNERSHIP} Each radar refreshes on its own schedule.`, {
1231
1494
  state: "not_assessed",
1232
1495
  measured_at: null,
@@ -1237,6 +1500,7 @@ async function exposureRadar() {
1237
1500
  exposed_databases: { last_run_at: stamp("exposed_databases", "last_run_at") },
1238
1501
  leaked_credentials: { last_run_at: stamp("leaked_credentials", "last_run_at") },
1239
1502
  shadow_ai: { last_run_at: stamp("shadow_ai", "poller", "last_run_at"), running: running ?? null },
1503
+ mcp_servers: { last_run_at: stamp("mcp_servers", "last_run_at"), enabled: enabled ?? null },
1240
1504
  },
1241
1505
  notes: [RADAR_STATE_NOTE, RADAR_FRESHNESS_NOTE],
1242
1506
  });
@@ -1262,11 +1526,19 @@ const ErrorSchema = z.strictObject({
1262
1526
  message: z.string().describe("The cause: the API's own message, or what went wrong."),
1263
1527
  });
1264
1528
  const NOTES = z.array(z.string()).describe("Caveats, one sentence each.");
1529
+ // The success states (#2465). 2.1.0 described not_assessed as no dated measurement whose numbers
1530
+ // were all denied the status of findings, on the same schema as a cve_exposure answer relaying
1531
+ // services on record for a CISA-KEV CVE, which is not_assessed because its count is undated
1532
+ // (#2439). A client that respects the schema was told that count is not a finding. The count is
1533
+ // what the radar holds on record: not a dated measurement, and not nothing. So the description
1534
+ // says both, and leaves what each count is to the notes and exposure_state, which say it per
1535
+ // answer; it makes no blanket claim about the numbers, in either direction.
1536
+ 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.";
1265
1537
  function envelopeSchema(o) {
1266
1538
  const coverage = o.coverage ? o.coverage.nullable() : z.null();
1267
1539
  return z.discriminatedUnion("state", [
1268
1540
  z.strictObject({
1269
- state: z.enum(["measured", "not_assessed"]).describe("measured: a measurement of what was asked; an exposure count is measured only with measured_at and method. not_assessed: no dated measurement of what was asked, and no number in it is a finding."),
1541
+ state: z.enum(["measured", "not_assessed"]).describe(SUCCESS_STATE),
1270
1542
  measured_at: Instant.nullable().describe("When the underlying observation was made, as the API states it; null when the answer does not say or holds no observation."),
1271
1543
  method: z.string().describe("How the numbers were produced."),
1272
1544
  coverage: coverage.describe("What the answer covers; null where the answer says nothing about it."),
@@ -1373,7 +1645,7 @@ const CVE_EXPOSURE_OUTPUT = envelopeSchema({
1373
1645
  exposure_state: z
1374
1646
  .enum(["exposed", "measured_zero", "not_assessed", "tracking_unknown"])
1375
1647
  .nullable()
1376
- .describe("What the answer's count is: exposed, a measured zero, not assessed, or tracking unknown; null when it carries no count."),
1648
+ .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."),
1377
1649
  },
1378
1650
  });
1379
1651
  // exposure_radar's data, from the readers' own specs, so the schema cannot drift from them.
@@ -1413,12 +1685,25 @@ const ShadowAIData = z.strictObject({
1413
1685
  poller: z.strictObject({ running: z.boolean(), last_run_at: Instant }).optional(),
1414
1686
  });
1415
1687
  const LastRun = z.strictObject({ last_run_at: Instant.nullable() });
1688
+ // mcp_servers as readMCPServers relays it, from the same constants (#2315): the headline counts
1689
+ // always (an answer without them is refused), and each partition with exactly its buckets or not
1690
+ // at all.
1691
+ const Whole = z.number().int().min(0);
1692
+ const MCPServersData = z.strictObject({
1693
+ ...Object.fromEntries(MCP_HEADLINE.map((k) => [k, Whole])),
1694
+ ...Object.fromEntries(MCP_PARTITION_NAMES.map((k) => [k, z.strictObject(Object.fromEntries(MCP_PARTITIONS[k].buckets.map((b) => [b, Whole]))).optional()])),
1695
+ own_controls_excluded: Whole.optional(),
1696
+ window: z.strictObject({ from: Instant, to: Instant }).optional(),
1697
+ enabled: z.boolean().optional(),
1698
+ ...Object.fromEntries(MCP_INSTANTS.map((k) => [k, Instant.optional()])),
1699
+ });
1416
1700
  const EXPOSURE_RADAR_OUTPUT = envelopeSchema({
1417
1701
  data: z.strictObject({
1418
1702
  kev_exposure: zRadar(KEV_SPEC, { newest_kev: z.array(NewestKEVRow) }),
1419
1703
  exposed_databases: zRadar(EXPOSED_DB_SPEC),
1420
1704
  leaked_credentials: zRadar(LEAKED_CREDS_SPEC),
1421
1705
  shadow_ai: ShadowAIData,
1706
+ mcp_servers: MCPServersData,
1422
1707
  }),
1423
1708
  coverage: z.strictObject({
1424
1709
  radars: z.array(z.string()).describe("The radars this tool reads."),
@@ -1430,12 +1715,13 @@ const EXPOSURE_RADAR_OUTPUT = envelopeSchema({
1430
1715
  exposed_databases: LastRun,
1431
1716
  leaked_credentials: LastRun,
1432
1717
  shadow_ai: z.strictObject({ last_run_at: Instant.nullable(), running: z.boolean().nullable() }),
1718
+ mcp_servers: z.strictObject({ last_run_at: Instant.nullable(), enabled: z.boolean().nullable() }),
1433
1719
  }),
1434
1720
  error: ErrorSchema.extend({
1435
1721
  radars: z
1436
1722
  .array(z.strictObject({
1437
1723
  radar: z.string(),
1438
- kind: z.enum(["network", "timeout", "http", "not_json", "not_object"]),
1724
+ kind: z.enum(["network", "timeout", "http", "not_json", "not_object", "unexpected_shape"]),
1439
1725
  path: z.string(),
1440
1726
  status: z.number().int().nullable(),
1441
1727
  message: z.string(),
@@ -1450,21 +1736,26 @@ const EXPOSURE_RADAR_OUTPUT = envelopeSchema({
1450
1736
  const INSTRUCTIONS = [
1451
1737
  "EchelonGraph's public CVE and internet-exposure data, read-only and keyless.",
1452
1738
  "Everything these tools return is public: EchelonGraph's CVE Pulse feed, and aggregate, host-redacted totals from its exposure radars.",
1453
- "Every result says how it was measured: state, measured_at, method, coverage, freshness and notes, in its structuredContent and again in its last text block, which is that structuredContent as JSON without data (the first text block is the data).",
1739
+ "Every result says how it was measured: state, measured_at, method, coverage, freshness and notes, in its structuredContent and again in its text.",
1740
+ "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.",
1454
1741
  "state is measured, not_assessed, failed or invalid_input.",
1455
- "not_assessed means the answer holds no dated measurement of what was asked (for example a CVE outside the KEV-exposure radar's tracked set), so a zero in it is not a finding of no exposure.",
1742
+ "not_assessed means the answer holds no dated measurement of what was asked, so no count in it is presented as one.",
1743
+ "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.",
1744
+ "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).",
1456
1745
  "failed and invalid_input mean nothing was measured: never report them as zero, none found or unexposed.",
1457
1746
  "measured_at is when the underlying observation was made, and null when the answer does not say or holds none.",
1458
1747
  "freshness gives the producing radar's last_run_at: when it last completed a check whose reads succeeded.",
1459
1748
  "That is not the time of every record the radar's numbers count, which cover everything still on record, and it is null when the API does not say.",
1460
1749
  "Every figure is as fresh as the schedule that refreshes it.",
1461
1750
  "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.",
1751
+ "exposure_radar's mcp_servers counts hostnames instead, each by its latest verdict on record.",
1462
1752
  `${SHODAN_ATTRIBUTION} ${SHODAN_OWNERSHIP}`,
1753
+ "exposure_radar's mcp_servers counts use no Shodan data: their hostnames come from EchelonGraph's own Certificate Transparency feed.",
1463
1754
  ].join(" ");
1464
1755
  const ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
1465
1756
  const CVE_ID_ARG = z.string().describe("a CVE ID, e.g. CVE-2023-44487");
1466
1757
  // What each description says about its structured result, naming only fields its schema holds.
1467
- const FEED_ENVELOPE = "Its structured result carries state (measured), measured_at, method, coverage, freshness (null: the feed serves no poll-completion time) and notes, with data equal to the API's JSON; the result's last text block repeats it without data.";
1758
+ 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.";
1468
1759
  // One server, built per connection by serveStdio for whichever era the client opens with.
1469
1760
  // tools/list answers in registration order, which is the order below.
1470
1761
  function createServer() {
@@ -1497,14 +1788,14 @@ function createServer() {
1497
1788
  }, async ({ cve_id }) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(cve_id)));
1498
1789
  server.registerTool("cve_exposure", {
1499
1790
  title: "Internet exposure for one CVE",
1500
- description: `${CVE_EXPOSURE_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: the per-CVE answer says when EchelonGraph last wrote a row (last_seen), not when any counted service was observed, so no count is presented as a dated measurement; the count is still relayed, labelled. exposure_state says what the count is: exposed, measured_zero, not_assessed or tracking_unknown; coverage.in_scope is the API's tracked verdict; freshness is null, since the per-CVE answer carries no last completed check; data is the API's JSON. The result's last text block repeats the structured result without data.`,
1791
+ 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: …").`,
1501
1792
  inputSchema: z.object({ cve_id: CVE_ID_ARG }),
1502
1793
  outputSchema: CVE_EXPOSURE_OUTPUT,
1503
1794
  annotations: ANNOTATIONS,
1504
1795
  }, async ({ cve_id }) => checked("cve_exposure", CVE_EXPOSURE_OUTPUT, await cveExposure(cve_id)));
1505
1796
  server.registerTool("exposure_radar", {
1506
1797
  title: "Exposure radar totals",
1507
- description: `${EXPOSURE_RADAR_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: every radar answered, but no stats answer says when the services or records it counts were observed, so no count is presented as a dated measurement. freshness gives each radar's last_run_at (and shadow_ai's running) where the API serves one; coverage names the radars that answered; data is the relayed result above. The result's last text block repeats the structured result without data.`,
1798
+ description: `${EXPOSURE_RADAR_DESCRIPTION} Its structured result's state is not_assessed with measured_at null: every radar answered, but none gives one time at which what it counts was observed (mcp_servers dates its verdicts at most by a window, mcp_servers.window), so no count is presented as a dated measurement; each count is still relayed, labelled, as what that radar holds on record. freshness gives each radar's last_run_at (and running for shadow_ai, enabled for mcp_servers) where the API serves one; coverage names the radars that answered; data is the relayed result above. The result's last text block repeats the structured result without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.`,
1508
1799
  outputSchema: EXPOSURE_RADAR_OUTPUT,
1509
1800
  annotations: ANNOTATIONS,
1510
1801
  }, 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.1.0",
3
+ "version": "2.3.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.1.0",
11
+ "version": "2.3.0",
12
12
  "packages": [
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "echelongraph-mcp",
16
- "version": "2.1.0",
16
+ "version": "2.3.0",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  },