echelongraph-mcp 2.7.2 → 3.0.2

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.
@@ -1,6 +1,6 @@
1
1
  import * as z from "zod";
2
- import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
3
- import { sbomText } from "./check_sbom.js";
2
+ import { answering, FULL_ARG, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
3
+ import { sbomData, sbomText } from "./check_sbom.js";
4
4
  import { FORMAT_NAMES, FORMATS, NOT_CHECKED_REASONS, detect, readFile } from "./manifests/index.js";
5
5
  import { CLEAN_NOTE, DISTRO_NOTE, FRESHNESS_NOTE, MAX_PURLS, MEASURED_AT_NOTE, METHOD, batchDataShape, dedupe, field, plural, purlsRefusal, sendBatches, sendingCoverage, sendingCoverageShape, sendingSentences, verdictSentences, } from "./match_batch.js";
6
6
  export const SCAN_MANIFEST = "scan_manifest";
@@ -22,12 +22,20 @@ export const SCAN_MANIFEST_DESCRIPTION = "Check a project's lockfile or pinned m
22
22
  " Cut, each row keeps index, purl, verdict, not_assessed_reason and cve_ids at least; not_checked keeps its first 20.";
23
23
  // The text's cut: check_sbom's rows and levels, and data.not_checked beside them, cut to its
24
24
  // first TEXT_NOT_CHECKED once the first level does not fit (structuredContent.data keeps it whole).
25
+ // #3132's second review: check_sbom's sentence on not_sent_purls says a second call can send them
26
+ // without reading the list, from their positions in the input; here those positions index purls
27
+ // read from the files, which a client cannot rebuild, so the sentence names full: true instead.
25
28
  export function manifestText(sent) {
26
29
  const sbom = sbomText(sent);
27
30
  return {
28
31
  ...sbom,
29
32
  sides: [
30
- ...(sbom.sides ?? []),
33
+ ...(sbom.sides ?? []).map((side) => side.list === "not_sent_purls"
34
+ ? {
35
+ ...side,
36
+ said: (_inText, total) => `The ${total} purls not sent are the purls this tool read from position ${sent + 1} on, counted in the order the note gives for them; structuredContent.data.not_sent_purls lists every one with full: true.`,
37
+ }
38
+ : side),
31
39
  {
32
40
  list: "not_checked",
33
41
  cap: TEXT_NOT_CHECKED,
@@ -37,6 +45,9 @@ export function manifestText(sent) {
37
45
  page: (shown) => `To read every row in the text, pass files that hold ${shown} or fewer distinct purls per call.`,
38
46
  };
39
47
  }
48
+ // #3013: the structured result's cut, check_sbom's (sbomData: the lists beside the rows, then every
49
+ // row thinned, then rows leaving, clean ones first) with not_checked beside not_sent_purls.
50
+ const MANIFEST_DATA = sbomData(["not_sent_purls", "not_checked"], (k) => `To read every row in data, pass files that hold ${k} or fewer distinct purls per call.`);
40
51
  export function readFiles(a) {
41
52
  if (!Array.isArray(a.files))
42
53
  return "files must be a list of {filename, content}";
@@ -79,8 +90,13 @@ export function readFiles(a) {
79
90
  out.skipped = r.skipped;
80
91
  out.notes = r.notes;
81
92
  out.replaced = r.replaced ?? [];
82
- all.push(...r.pinned.map((x) => x.purl));
83
- notChecked.push(...r.not_checked.map((x) => ({ file: f.filename, ...x })));
93
+ // One by one, never push(...list): a 5,000,000-character requirements.txt of unpinned lines
94
+ // is some 170,000 entries, and, spread into push() as that many arguments, they overflowed
95
+ // V8's stack ("Maximum call stack size exceeded", #3013's review): the call crashed.
96
+ for (const p of r.pinned)
97
+ all.push(p.purl);
98
+ for (const n of r.not_checked)
99
+ notChecked.push({ file: f.filename, ...n });
84
100
  }
85
101
  if (files.every((f) => !f.read))
86
102
  return `no file could be read: ${files.map((f) => `${f.filename}: ${f.refused}`).join("; ")}`;
@@ -112,9 +128,13 @@ function noteFor(head, x, d, g) {
112
128
  const nc = x.not_checked.length;
113
129
  if (nc) {
114
130
  const parts = NOT_CHECKED_REASONS.filter((r) => by[r] > 0).map((r) => `${r} ${by[r]}`);
115
- out.push(`${plural(nc, "entry was", "entries were")} NOT checked and ${nc === 1 ? "is" : "are"} not clean (${parts.join(", ")}): data.not_checked lists each with its file, line and reason, and none was sent to the API.`);
131
+ // #3013's review: the note is written before the structured result is cut, and a DATA CUT can
132
+ // cut data.not_checked (it goes before any row), so this says the list is whole with full:
133
+ // true, as the sentence on data.not_sent_purls does (match_batch.ts sendingSentences); the
134
+ // counts above are coverage.not_checked_by_reason's, which no cut touches.
135
+ out.push(`${plural(nc, "entry was", "entries were")} NOT checked and ${nc === 1 ? "is" : "are"} not clean (${parts.join(", ")}): data.not_checked lists each with its file, line and reason, whole with full: true (past the structured result's budget, that list can be cut by default), and none was sent to the API.`);
116
136
  }
117
- out.push(...sendingSentences(g, x.purls.length, READ_ORDER, "check_sbom with purls set to"));
137
+ out.push(...sendingSentences(g, x.purls.length, READ_ORDER, "check_sbom with purls set to", false));
118
138
  if (d === null)
119
139
  return { note: out.join(" "), assessed: 0 };
120
140
  const v = verdictSentences(d, g);
@@ -231,11 +251,15 @@ export function scanManifestOutput(envelopeSchema) {
231
251
  freshness: null,
232
252
  });
233
253
  }
254
+ // One output schema per process, not per request (3.0.2; index.ts envelopeSchema says why).
255
+ const OUTPUTS = new WeakMap();
234
256
  // A literal server.registerTool(name, { title, description, … }) call with constant strings:
235
257
  // marketing-site lib/mcpToolClaims.test.ts reads it here (it follows index.ts's call to this
236
258
  // function) and holds the /pulse/mcp row to this title and description.
237
259
  export function registerScanManifest(server, deps) {
238
- const output = scanManifestOutput(deps.envelopeSchema);
260
+ const envelope = deps.envelopeSchema;
261
+ const output = OUTPUTS.get(envelope) ?? scanManifestOutput(envelope);
262
+ OUTPUTS.set(envelope, output);
239
263
  server.registerTool(SCAN_MANIFEST, {
240
264
  title: SCAN_MANIFEST_TITLE,
241
265
  description: SCAN_MANIFEST_DESCRIPTION,
@@ -247,11 +271,12 @@ export function registerScanManifest(server, deps) {
247
271
  format: formatEnum.optional().describe("the format to read the file as, when its name does not say"),
248
272
  }))
249
273
  .describe("1 to 20 files, up to 5,000,000 characters of content in all; read by this MCP server into purls, and only the purls are sent to the API; over the hosted endpoint (mcp.echelongraph.io) the files are the request body"),
274
+ full: FULL_ARG,
250
275
  }),
251
276
  outputSchema: output,
252
277
  annotations: deps.annotations,
253
- }, async (a, ctx) => {
278
+ }, async (a, ctx) => answering(a.full, MANIFEST_DATA, async () => {
254
279
  const run = scanManifest({ ...deps, signal: ctx.mcpReq.signal }, a);
255
280
  return deps.checked(SCAN_MANIFEST, output, await (deps.hold ? deps.hold(run) : run));
256
- });
281
+ }));
257
282
  }
@@ -59,7 +59,7 @@
59
59
  // Logic only: the envelope, the failure contract and the API call are index.ts's, handed in as
60
60
  // a VendorAdvisoryKit so this file cannot drift from them.
61
61
  import * as z from "zod";
62
- import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
62
+ import { answering, FULL_ARG, NO_DATA_CUT, TEXT_BUDGET_DESCRIPTION, wholePage } from "../textBudget.js";
63
63
  // core-backend vendoradv.SearchHeader.
64
64
  export const VENDOR_ADVISORY_SEARCH_HEADER = "X-EG-Advisory-Search";
65
65
  // handler.go List caps the search at 100 bytes; a longer term would be cut, possibly inside a
@@ -317,8 +317,8 @@ const ROW_CUT_LEVELS = [
317
317
  { keep: ["vendor", "vendor_advisory_id", "title", "severity", "cve_ids", "vendor_published_at", "withdrawn", "rejected_cve_ids", "remediation_kinds", "remediation_state"], clip: 100, cap: 10 },
318
318
  ];
319
319
  // get_vendor_advisory's own first text block is cut too past the budget (#2802): only its
320
- // structuredContent.data is one advisory whole.
321
- const WHOLE = "get_vendor_advisory returns any one of these advisories by its vendor and vendor_advisory_id, whole in its structuredContent.data, and in its first text block with every field (past 30,000 characters, each list cut to its first entries).";
320
+ // structuredContent.data is one advisory whole, with full: true (#3013).
321
+ const WHOLE = "get_vendor_advisory returns any one of these advisories by its vendor and vendor_advisory_id, whole in its structuredContent.data with full: true, and in its first text block with every field (past 30,000 characters, each list cut to its first entries).";
322
322
  const FOR_CVE_TEXT = { rows: "advisories", levels: ROW_CUT_LEVELS, whole: WHOLE };
323
323
  const searchText = (a) => ({
324
324
  rows: "advisories",
@@ -326,9 +326,37 @@ const searchText = (a) => ({
326
326
  whole: WHOLE,
327
327
  page: (shown, _rows, d) => `To read the rows left out in the text, call search_vendor_advisories again with the same arguments and offset ${(num(d, "offset") ?? a.offset ?? 0) + shown}; a page of ${shown} rows or fewer like these fits the text without leaving rows out.`,
328
328
  });
329
+ // #3013: the structured result's cut. A production row is up to about 1,700 characters of compact
330
+ // JSON, 782 of them its affected_products, so search_vendor_advisories "log4j" at its default page
331
+ // was 38,733 characters of structured result. Before any row leaves, every row is thinned: first
332
+ // without affected_products, then without summary too (get_vendor_advisory returns one whole);
333
+ // only then do rows leave data from the end, and the note gives the offset to read them from. The
334
+ // by-CVE answer (at most 20 advisories, 12,652 characters for CVE-2021-44228) is cut the same way.
335
+ const ADVISORY_ROW_THIN = [
336
+ [
337
+ "vendor", "vendor_display_name", "vendor_advisory_id", "advisory_id", "title", "severity", "cvss_v3_score", "cve_ids", "summary", "vendor_published_at", "our_first_seen_at", "withdrawn",
338
+ "rejected_cve_ids", "remediation_kinds", "remediation_state",
339
+ ],
340
+ ["vendor", "vendor_advisory_id", "title", "severity", "cvss_v3_score", "cve_ids", "vendor_published_at", "withdrawn", "rejected_cve_ids", "remediation_kinds", "remediation_state"],
341
+ ];
342
+ const FOR_CVE_DATA = { steps: [{ lists: ["advisories"], thin: ADVISORY_ROW_THIN }] };
343
+ // The page size the note says fits data whole is counted on the rows whole (#3013's review: it was
344
+ // the number of thinned rows kept, and a page of that many came back thinned again).
345
+ const SEARCH_DATA = {
346
+ steps: [{ lists: ["advisories"], thin: ADVISORY_ROW_THIN }],
347
+ page: (t, d, at) => {
348
+ const k = t.advisories?.returned;
349
+ if (!k || !t.advisories || k === t.advisories.total)
350
+ return "";
351
+ return `To read the rows past the first ${k} in data, call search_vendor_advisories again with the same arguments and offset ${(num(d, "offset") ?? 0) + k}; ${wholePage(at.wholeFit("advisories"))}`;
352
+ },
353
+ };
329
354
  const ROW_CUT_DESCRIPTION = " Cut, each row keeps vendor, vendor_advisory_id, title, severity and cve_ids at least, its strings and lists shortened";
330
- export function registerVendorAdvisoryTools(server, kit) {
331
- const FOR_CVE_OUTPUT = kit.envelopeSchema({
355
+ // The three tools' output schemas, one each per process, not per request (3.0.2; index.ts
356
+ // envelopeSchema says why).
357
+ const OUTPUTS = new WeakMap();
358
+ function outputsOf(envelopeSchema) {
359
+ const FOR_CVE_OUTPUT = envelopeSchema({
332
360
  data: z.looseObject({
333
361
  cve_id: opt(z.string()),
334
362
  advisories: opt(z.array(AdvisoryRow)),
@@ -348,7 +376,7 @@ export function registerVendorAdvisoryTools(server, kit) {
348
376
  }),
349
377
  freshness: null,
350
378
  });
351
- const DETAIL_OUTPUT = kit.envelopeSchema({
379
+ const DETAIL_OUTPUT = envelopeSchema({
352
380
  data: AdvisoryRow.extend({
353
381
  description: opt(z.string()),
354
382
  known_cve_ids: opt(z.array(z.string())).describe("The CVE IDs of cve_ids that have a record in EchelonGraph's CVE feed."),
@@ -377,7 +405,7 @@ export function registerVendorAdvisoryTools(server, kit) {
377
405
  }),
378
406
  freshness: null,
379
407
  });
380
- const SEARCH_OUTPUT = kit.envelopeSchema({
408
+ const SEARCH_OUTPUT = envelopeSchema({
381
409
  data: z.looseObject({
382
410
  advisories: opt(z.array(AdvisoryRow)),
383
411
  total: opt(z.number()).describe("The matches; when total_capped is true, a lower bound: 1,000 or more."),
@@ -399,6 +427,12 @@ export function registerVendorAdvisoryTools(server, kit) {
399
427
  }),
400
428
  freshness: null,
401
429
  });
430
+ return { FOR_CVE_OUTPUT, DETAIL_OUTPUT, SEARCH_OUTPUT };
431
+ }
432
+ export function registerVendorAdvisoryTools(server, kit) {
433
+ const outputs = OUTPUTS.get(kit.envelopeSchema) ?? outputsOf(kit.envelopeSchema);
434
+ OUTPUTS.set(kit.envelopeSchema, outputs);
435
+ const { FOR_CVE_OUTPUT, DETAIL_OUTPUT, SEARCH_OUTPUT } = outputs;
402
436
  // The vendors' windows, read beside a tool's own request. Never a failure of the tool.
403
437
  async function readWindows() {
404
438
  const r = await kit.api(VENDOR_COVERAGE_PATH);
@@ -617,20 +651,21 @@ export function registerVendorAdvisoryTools(server, kit) {
617
651
  server.registerTool("vendor_advisories_for_cve", {
618
652
  title: "Vendor advisories for one CVE",
619
653
  description: `The vendor-published advisories that name one CVE, newest first, at most 20: for each, vendor, vendor_display_name, vendor_advisory_id, title, severity and cvss_v3_score where the vendor gives them. Covers the vendor feeds EchelonGraph polls, for example Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks and GitHub GHSA; an empty answer means none of the advisories EchelonGraph holds names the CVE, not that no vendor published one. ${DATES_DESCRIPTION} ${REJECTED_CVE_DESCRIPTION} The answer's cve_rejected is true when the CVE record of the CVE asked for was rejected. Each row has remediation_kinds and remediation_state (not_parsed is not none). coverage gives returned, cap, at_cap (true: there may be more) and cve_year; vendor_windows, each vendor's held window: ${WINDOW_HEAD}; and vendors_not_fully_held, the vendors absent from the answer of which EchelonGraph holds none, whose held_since is after 1 January of cve_year or not known, or whose history is still being read, which the note names. ${NOT_HELD_HEAD} ${TEXT_BUDGET_DESCRIPTION}${ROW_CUT_DESCRIPTION}.`,
620
- inputSchema: z.object({ cve_id: z.string().describe("a CVE ID, e.g. CVE-2024-21412") }),
654
+ inputSchema: z.object({ cve_id: z.string().describe("a CVE ID, e.g. CVE-2024-21412"), full: FULL_ARG }),
621
655
  outputSchema: FOR_CVE_OUTPUT,
622
656
  annotations: kit.annotations,
623
- }, async ({ cve_id }) => kit.checked("vendor_advisories_for_cve", FOR_CVE_OUTPUT, await forCVE(cve_id)));
657
+ }, async ({ cve_id, full }) => kit.checked("vendor_advisories_for_cve", FOR_CVE_OUTPUT, await answering(full, FOR_CVE_DATA, () => forCVE(cve_id))));
624
658
  server.registerTool("get_vendor_advisory", {
625
659
  title: "Vendor advisory detail",
626
660
  description: `One vendor advisory in full, by vendor and the vendor's advisory ID (the vendor and vendor_advisory_id of a vendor advisory row): title, description, severity, cvss_v3_score, cve_ids and known_cve_ids (those with a record in EchelonGraph's CVE feed), affected_products, remediation, references, vendor_modified_at, and withdrawn_at and withdrawn_reason when the vendor withdrew it. remediations lists each remediation the vendor lists, with kind (the vendor's own category, never read from the text: vendor_fix, workaround, mitigation, no_fix_planned, none_available or other), source_category, text, url and the cve_ids and product_ids it covers; each text is the vendor's, verbatim, untested by EchelonGraph. remediation_state is parsed, none_in_source, not_parsed or withdrawn: none_in_source and not_parsed are not a finding that no fix exists. ${DATES_DESCRIPTION} ${REJECTED_CVE_DESCRIPTION} coverage.vendor_window is that vendor's window in what EchelonGraph holds: ${WINDOW_HEAD}; null when the windows could not be read or carry none for that vendor. measured_at is our_first_seen_at. ${ENVELOPE_DESCRIPTION} ${TEXT_BUDGET_DESCRIPTION}`,
627
661
  inputSchema: z.object({
628
662
  vendor: z.string().describe("the vendor slug, e.g. microsoft, redhat, github"),
629
663
  advisory_id: z.string().describe("the vendor's advisory ID, e.g. RHSA-2024:1234 or GHSA-xxxx-xxxx-xxxx"),
664
+ full: FULL_ARG,
630
665
  }),
631
666
  outputSchema: DETAIL_OUTPUT,
632
667
  annotations: kit.annotations,
633
- }, async ({ vendor, advisory_id }) => kit.checked("get_vendor_advisory", DETAIL_OUTPUT, await detail(vendor, advisory_id)));
668
+ }, async ({ vendor, advisory_id, full }) => kit.checked("get_vendor_advisory", DETAIL_OUTPUT, await answering(full, NO_DATA_CUT, () => detail(vendor, advisory_id))));
634
669
  server.registerTool("search_vendor_advisories", {
635
670
  title: "Search vendor advisories",
636
671
  description: `Search or list vendor-published advisories, newest first. ${SEARCH_MATCH_HEAD} Filter by vendor (slug), severity band and whether the advisory names any CVE ID. Advisories their vendor withdrew are left out. Returns advisories, each with vendor, vendor_advisory_id, title, severity, cvss_v3_score, cve_ids, summary and affected_products where present, and total, total_capped, limit, offset, search_applied and search_match. ${TOTAL_CAPPED_HEAD} ${REJECTED_CVE_DESCRIPTION} The query is sent in a request header, never in the URL. coverage repeats those beside returned, and gives vendor_windows, each vendor's window in what EchelonGraph holds: ${WINDOW_HEAD}. ${NOT_HELD_HEAD} ${TEXT_BUDGET_DESCRIPTION}${ROW_CUT_DESCRIPTION}, or rows are left out and the note gives the offset to call next.`,
@@ -641,8 +676,9 @@ export function registerVendorAdvisoryTools(server, kit) {
641
676
  has_cve: z.boolean().optional().describe("true: only advisories naming a CVE; false: only those naming none"),
642
677
  limit: z.number().int().min(1).max(50).optional().describe("page size (default 20, max 50)"),
643
678
  offset: z.number().int().min(0).max(10000).optional().describe("rows to skip (default 0)"),
679
+ full: FULL_ARG,
644
680
  }),
645
681
  outputSchema: SEARCH_OUTPUT,
646
682
  annotations: kit.annotations,
647
- }, async (a) => kit.checked("search_vendor_advisories", SEARCH_OUTPUT, await search(a)));
683
+ }, async (a) => kit.checked("search_vendor_advisories", SEARCH_OUTPUT, await answering(a.full, SEARCH_DATA, () => search(a))));
648
684
  }
@@ -0,0 +1,181 @@
1
+ # How `exposure_radar` counts
2
+
3
+ Part of the [echelongraph-mcp README](../README.md): every number `exposure_radar` relays, radar by
4
+ radar, and what it counts. It is kept out of the README because npm keeps only the first 65,536
5
+ characters of a package's README, and the package ships it in `docs/`. Exposure counts are derived
6
+ from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan).
7
+
8
+ `exposure_radar` relays each radar's answer cut to the fields it can label, and its note labels
9
+ every number by what it counts. A field this version does not know is left out and the note
10
+ names it, whether it is a total or a field inside a ranked row; so is a known field in an
11
+ unexpected shape, and a list with one malformed row is left out whole, since a partial list
12
+ reads as a complete one. `kev_exposure`, `exposed_databases` and `leaked_credentials` also
13
+ carry `generated_at`, when the API computed their totals, and, when the API can tell,
14
+ `last_run_at`: when that radar last completed a check (each table below says what a completed
15
+ check is for that radar).
16
+
17
+ `last_run_at` is a timestamp, not a count. It is recorded for the radar as a whole, not for
18
+ the server instance that answered, in UTC to the second. It moves only at the end of a cycle
19
+ whose reads succeeded, so a cycle that read nothing leaves it where it was. It is not the time
20
+ of every record a radar's numbers count: those cover everything still on record, not only what
21
+ the last check found. Nor is it `generated_at`, which is only when the API recomputed the
22
+ totals. The API omits `last_run_at` when it has no completed check on record or cannot read
23
+ it; the result then carries none, and the note says nothing about it. When it is there, the
24
+ note gives it as, for example, "kev_exposure last completed check: 2026-09-27T03:12:44Z".
25
+
26
+ ## `kev_exposure`
27
+
28
+ Every count is over the services the KEV-exposure radar keeps: a Shodan banner whose version
29
+ maps to a CISA-KEV-listed CVE (see [How `cve_exposure` counts](tools.md#how-cve_exposure-counts)).
30
+
31
+ | Field | What it counts |
32
+ |---|---|
33
+ | `kev_exposure.distinct_hosts` | Distinct ip:port services, not machines, with at least one CISA-KEV-listed CVE on record. A machine answering on two ports counts twice. |
34
+ | `kev_exposure.ransomware_hosts` | The services among them with at least one ransomware-linked KEV CVE (one whose KEV entry notes known use in ransomware campaigns). |
35
+ | `kev_exposure.kev_cves_exposed` | Distinct CISA-KEV-listed CVEs with at least one service on record. |
36
+ | `kev_exposure.ransomware_cves` | The ransomware-linked CVEs among them. |
37
+ | `kev_exposure.correlations` | Service×CVE pairs, not services: a service with three KEV CVEs counts three times. |
38
+ | `kev_exposure.top_products`, `kev_exposure.top_countries`, `kev_exposure.top_cves` | Up to 12 products, 10 countries and 12 CVEs, ranked by distinct ip:port services. |
39
+ | `kev_exposure.top_cves[].cvss_v3_score`, `kev_exposure.top_cves[].epss_score` | Scores, not counts: the highest CVSS v3 base score and EPSS probability (0 to 1) recorded on that CVE's observations. |
40
+ | `kev_exposure.trend` | Service×CVE pairs, not services, by the week (starting Monday) in which each pair was first recorded, over the last 12 weeks. Only pairs still on record are counted, so earlier weeks read low. |
41
+ | `kev_exposure.newest_kev` | The 15 CVEs that EchelonGraph's CVE records most recently mark as CISA-KEV-listed (by `added_date`), each with an `exposure_state` (below). |
42
+ | `kev_exposure.newest_kev[].exposed_hosts` | Only on a row whose `exposure_state` is `exposed` (or `measured_zero`): distinct ip:port services on record with that CVE. |
43
+ | `kev_exposure.newest_kev[].cvss_v3_score`, `kev_exposure.newest_kev[].epss_score` | Scores, not counts: the CVE record's CVSS v3 base score and EPSS probability (0 to 1), absent when the record has none. |
44
+ | `kev_exposure.last_run_at` | A timestamp, not a count: when the radar last completed a check, a cycle in which its Shodan search answered at least one query (others may have failed) and its list of services due for a re-check was read. A cycle that skipped the search for want of Shodan query credits, or whose reads failed, does not move it. |
45
+ | `kev_exposure.window.from`, `kev_exposure.window.to` | Timestamps, not counts: when Shodan collected the banners of the oldest and the newest of the service×CVE pairs counted, Shodan's own times, not when EchelonGraph stored or re-checked a pair; a span of observations made at different times. Relayed only when every counted pair has such a time; otherwise the note says how many do not. |
46
+
47
+ A `newest_kev` row's `exposure_state` says whether its count is a measurement:
48
+
49
+ | `exposure_state` | What it means |
50
+ |---|---|
51
+ | `exposed` | The radar has services on record with this CVE, and `exposed_hosts` counts them. |
52
+ | `not_assessed` | **NOT ASSESSED**: the answer holds no measurement for this CVE, so no count is relayed. The API answers 0 for every CVE the radar holds no service for, whether or not the radar looks for that CVE at all, and it never marks a CVE as tracked with 0 services, so that 0 is not a measurement. `cve_exposure` says per CVE whether it is in the radar's tracked set. |
53
+ | `measured_zero` | Only if the API ever marks a row `tracked: true` with 0 services: a zero in the radar's sample, not an internet-wide zero. If it marks a row `tracked: false`, that row is `not_assessed` whatever its count. |
54
+
55
+ ## `exposed_databases`
56
+
57
+ | Field | What it counts |
58
+ |---|---|
59
+ | `exposed_databases.distinct_hosts` | Distinct ip:port services, not machines, that EchelonGraph's check confirmed answering without authentication: data stores and observability UIs alike. |
60
+ | `exposed_databases.engines` | Distinct engine types among them (for example `redis` or `grafana`). |
61
+ | `exposed_databases.top_engines`, `exposed_databases.top_countries` | Up to 15 engines and 10 countries, ranked by those services. |
62
+ | `exposed_databases.pii_likely`, `exposed_databases.pci_likely` | Services whose schema names pass a high-confidence gate for personal data, or for payment-card data. The names are index, database, table or field names in the Shodan or LeakIX banner or in the check's response, never record values; the gate passes one unambiguous term (such as `ssn` or `cardholder`) or two distinct indicators. It is a precision-first schema gate, not a census: a service whose names do not pass it is not counted, whatever it holds, so the other services are not shown to hold no such data. |
63
+ | `exposed_databases.window.from`, `exposed_databases.window.to` | Timestamps, not counts: when EchelonGraph's check last confirmed the oldest and the newest of the services counted answering without authentication, a span of checks made at different times. Relayed only when every counted service has such a time; otherwise the note says how many do not. |
64
+ | `exposed_databases.last_run_at` | A timestamp, not a count: when the radar last completed a check, a cycle in which its Shodan search, or the LeakIX fallback, answered at least one query (others may have failed), its list of services due for a re-check was read, and the scan opt-out register could be consulted. A cycle that searched nothing, or whose reads failed, does not move it. |
65
+
66
+ ## `leaked_credentials`
67
+
68
+ | Field | What it counts |
69
+ |---|---|
70
+ | `leaked_credentials.total` | (repository, secret) pairs, not distinct secrets: a secret committed to three repositories counts three times. |
71
+ | `leaked_credentials.distinct_secrets` | Each secret once. |
72
+ | `leaked_credentials.distinct_repos` | Public GitHub repositories with at least one. |
73
+ | `leaked_credentials.top_providers`, `leaked_credentials.top_types` | Up to 15 providers and secret types, ranked by (repository, secret) pairs. |
74
+ | `leaked_credentials.window.from`, `leaked_credentials.window.to` | Timestamps, not counts: when EchelonGraph's detector last found the oldest and the newest of the (repository, secret) pairs counted in a public commit. |
75
+ | `leaked_credentials.last_run_at` | A timestamp, not a count: when the radar last completed a check, a cycle that read the public GitHub event stream (fetches of some of the commits it lists may have failed). A cycle whose read of that stream failed does not move it. |
76
+
77
+ None of them is validated. Each is a credential-shaped string in a public commit that passed
78
+ EchelonGraph's filters, at most structurally checked (a checksum or format decode), and never
79
+ tested against its provider, so none of these is a count of working credentials.
80
+
81
+ ## `shadow_ai`
82
+
83
+ The shadow-AI radar's answer mixes numbers that count exposed services with numbers that count
84
+ every observation. So `exposure_radar` regroups it by what each number counts, and its note
85
+ labels each one. Only `shadow_ai.confirmed_exposed` counts exposed services.
86
+
87
+ | Field | What it counts |
88
+ |---|---|
89
+ | `confirmed_exposed.total` | Confirmed exposed: services EchelonGraph's probes found answering without an authentication gate (liveness `active`, or `rechecking` during a re-check). It is the sum of `confirmed_exposed.by_category`. |
90
+ | `confirmed_exposed.by_category` | The same services, by category. |
91
+ | `confirmed_exposed.last_24h` | Confirmed-exposed services first recorded in the last 24 hours. |
92
+ | `confirmed_exposed.window.from`, `confirmed_exposed.window.to` | Timestamps, not counts: when EchelonGraph's verifier ran the probe that last decided the oldest and the newest of the confirmed-exposed services, a span of probes made at different times. Relayed only when every one has such a time. |
93
+ | `observed.total` | Every Certificate Transparency or Shodan observation on record, whatever its verification state: observed, not exposed. |
94
+ | `observed.by_category` | The same observations, by category. |
95
+ | `observed.last_24h` | Observations first recorded in the last 24 hours. |
96
+ | `observed.trend_30d` | Observations per UTC day over the last 30 days. |
97
+ | `observed.top_products`, `observed.top_countries`, `observed.top_issuers` | Up to ten products, countries and issuers, ranked by observations, not by exposed services. An issuer is the certificate's CA for a Certificate Transparency observation, and the hosting operator Shodan reports for a Shodan one. |
98
+ | `observed.last_observation` | When the latest observation was recorded. |
99
+ | `authentication.observed` | Observations where a probe observed an authentication gate: a 401/403, a login page or an auth marker. |
100
+ | `authentication.not_determined` | Observations whose service answered, but where no probe could tell whether it enforces authentication. |
101
+
102
+ Neither authentication count is part of `confirmed_exposed`, and `observed.total` minus
103
+ `confirmed_exposed.total` is not a count of secured services: it also holds observations not
104
+ yet verified, whose hostname no longer resolves, or whose service no longer answers openly.
105
+
106
+ The tool relays no number it cannot label. A stats field this version does not know is left
107
+ out, and the note names it; so is a count in an unexpected shape. A ranked row carries only
108
+ its name and its count, and a field added to one is named too. The `poller` block's keys are
109
+ checked the same way: a key this version does not know is left out and named, while the
110
+ instance fields it knows are left out as the README's [What a result means](../README.md#what-a-result-means)
111
+ describes.
112
+
113
+ ## `mcp_servers`
114
+
115
+ The AI-exposure radar's MCP-server verdicts, from `GET /api/v1/public/ai-exposure/stats?service=mcp`.
116
+ Every hostname was named like an MCP server in EchelonGraph's own Certificate Transparency
117
+ feed, matched by hostname pattern; no Shodan data is used. Each is checked by EchelonGraph's
118
+ identified MCP probe: a `server/discover` request, and `initialize` only if that is refused. It
119
+ never sends `tools/call`. The answer is counts and timestamps only, with no hostname, version or
120
+ server name, and the tool repeats no string it carries but its timestamps. Each hostname is
121
+ counted once, by its latest verdict on record. EchelonGraph's own control servers are left
122
+ out, and so is every hostname whose owner opted out of scanning, since it is not checked again
123
+ while the opt-out stands and its last verdict cannot be re-checked. The unit is a hostname, not an ip:port service.
124
+
125
+ | Field | What it counts |
126
+ |---|---|
127
+ | `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`. |
128
+ | `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. |
129
+ | `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`. |
130
+ | `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`. |
131
+ | `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). |
132
+ | `mcp_servers.own_controls_excluded` | EchelonGraph's own control servers, left out of every other `mcp_servers` number. |
133
+ | `mcp_servers.withheld_opted_out` | Hostnames whose owner opted out of scanning, left out of every other `mcp_servers` number. Such a hostname is not checked again while the opt-out stands, so its last verdict cannot be re-checked; the API's unparameterised answer withholds the same hostnames under the same name. An answer that does not carry this field (an API that predates it) counted them, and the note says so. |
134
+ | `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. |
135
+ | `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. |
136
+ | `mcp_servers.enabled` | Whether the API reports the radar running: true when a check completed within 45 minutes of its answer. |
137
+ | `mcp_servers.counted_at` | A timestamp, not a count: when the API read these counts. |
138
+
139
+ `mcp_servers.not_assessed_by_reason` divides `mcp_servers.not_assessed`:
140
+
141
+ | Bucket | What it holds |
142
+ |---|---|
143
+ | `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. |
144
+ | `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. |
145
+ | `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. |
146
+ | `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. |
147
+ | `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. |
148
+ | `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. |
149
+ | `mcp_servers.not_assessed_by_reason.no_authorization_servers` | An endpoint that asked for credentials, whose metadata document names no authorization server. |
150
+ | `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. |
151
+ | `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. |
152
+ | `mcp_servers.not_assessed_by_reason.challenge_unadjudicated` | An endpoint that asked for credentials before EchelonGraph read RFC 9728 metadata, not re-checked since. |
153
+ | `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. |
154
+ | `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. |
155
+
156
+ The eight credential-challenge buckets hold endpoints that asked for credentials: only their
157
+ metadata did not validate, so none of them is shown to lack protection.
158
+
159
+ `mcp_servers.era` and `mcp_servers.transport` each divide every counted hostname, so each adds
160
+ up to `mcp_servers.total`:
161
+
162
+ | Bucket | What it holds |
163
+ |---|---|
164
+ | `mcp_servers.era.legacy` | An initialize-era server: an InitializeResult, or the endpoint event of the deprecated HTTP+SSE transport. |
165
+ | `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. |
166
+ | `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. |
167
+ | `mcp_servers.era.unknown` | Nothing identified an era; every credential challenge is here. |
168
+ | `mcp_servers.era.not_measured` | A verdict recorded before EchelonGraph's probe began recording the era, not re-checked since. |
169
+ | `mcp_servers.transport.streamable_http` | Identified by a POST to `/mcp`. |
170
+ | `mcp_servers.transport.legacy_sse` | Identified by the endpoint event of `GET /sse`, the deprecated HTTP+SSE transport. |
171
+ | `mcp_servers.transport.unknown` | Nothing identified a transport. |
172
+ | `mcp_servers.transport.not_measured` | Recorded before the era probe, not re-checked since. |
173
+
174
+ The tool refuses the answer, as a failure of that radar and never as zeros, when it does not say
175
+ `service` `mcp` (an API older than `?service=mcp` ignores the parameter and answers its counts
176
+ over every AI service it checks), when `mcp_servers.total`, `mcp_servers.protected`,
177
+ `mcp_servers.pending_readjudication` or `mcp_servers.not_assessed` is missing or not a whole
178
+ number, or when they contradict each other. A partition whose buckets are not exactly those
179
+ above, each a whole number adding up to the count it divides, is left out whole and named. A
180
+ field this version does not know is left out, and named only when its name is shaped like a
181
+ field name.
@@ -0,0 +1,50 @@
1
+ # echelongraph-mcp: rate limits
2
+
3
+ Part of the README's [Rate limits](../README.md#rate-limits), in full: every limit the API and the
4
+ hosted endpoint apply, path by path, and what a tool answers past each. It is kept out of the README
5
+ because npm keeps only the first 65,536 characters of a package's README, and the package ships it
6
+ in `docs/`.
7
+
8
+ No API key, so the API limits each caller, one IPv4 address or one IPv6 /64 network as its
9
+ platform records it. Its WAF counts all your requests together in a 60-second window and
10
+ refuses one once the count passes the ceiling for its path:
11
+
12
+ | Path | Ceiling per minute | Tools |
13
+ |---|---|---|
14
+ | `/api/v1/public/cves` and every path under it | 12,000 | `cve_summary`, `search_cves`, `get_cve`, `epss_history`, `cve_intel`, `cve_remediation`, `check_affected`, `check_sbom`, `scan_manifest` |
15
+ | `/api/v1/public/cwes`, `/api/v1/public/vendor-advisories`, `/api/v1/public/kev/recent`, `/api/v1/public/shadow-ai-radar` | 6,000 | `get_cwe`, the three vendor-advisory tools, `kev_recent`, and one of `exposure_radar`'s five calls |
16
+ | any other `/api/v1/public` path | 600 | `cve_exposure`, and the rest of `exposure_radar`'s calls |
17
+
18
+ Because the count is shared, heavy use of one path also uses up the lower ceilings. On top of
19
+ that, 500 requests per second per caller is a burst cap on each serving instance, and
20
+ `check_sbom`'s and `scan_manifest`'s batch route is charged per component: 1,200 components a minute per caller.
21
+ Over a limit the API answers 429 with `Retry-After`, and the tool returns a failure
22
+ (`state` `failed`, `error.status` 429), never empty data. `check_sbom` and `scan_manifest` are the exception: each
23
+ sends a long list as batches of 200, waits out a 429's `Retry-After` and sends the batch again,
24
+ within 50 seconds per call (MCP clients time a call out at about 60 s). From a fresh budget a
25
+ list of up to 1,200 distinct purls finishes in one call; a longer one (the cap is 2,000) is
26
+ checked in two calls a minute apart. When a wait would pass the 50 seconds, it
27
+ answers the batches it has as `measured` with `coverage.partial` true and the rest counted in
28
+ `coverage.not_sent` (`not_sent_reason` `time_budget`); only a 429 on the first batch is a failure. The API's own documentation of these
29
+ limits: <https://echelongraph.io/pulse/api>.
30
+
31
+ The hosted endpoint adds its own limit before the API's: 120 MCP requests a minute per client
32
+ address, counted by each server instance, answered over it with HTTP 429, `Retry-After` and a
33
+ JSON-RPC error (code `-32029`) that names the limit and the seconds to wait. A request body over
34
+ 64 KiB is refused (HTTP 413, code `-32600`), except a `tools/call` of `check_sbom` or
35
+ `scan_manifest`, or a `prompts/get` of `sbom_review`, which may be up to 6 MiB: enough for a
36
+ CycloneDX or SPDX document, or a call's lockfiles, at the tools' 5,000,000-character cap. Each server instance reads at most two such large bodies at
37
+ once; a third is answered HTTP 503 with `Retry-After` and a JSON-RPC error (code `-32030`). Each
38
+ instance serves at most 64 requests at once; the next waits up to 10 seconds for one to finish,
39
+ and is then answered the same way. A `subscriptions/listen` stream, which stays open until its
40
+ client leaves, is not one of the 64.
41
+
42
+ A call whose client goes away (its own timeout, a closed tab) is stopped only once the server
43
+ learns of it. Its API calls are then cancelled, `check_sbom` and `scan_manifest` send no further batch, and its
44
+ large-body slot is freed. Over HTTP/1.1, Cloud Run does not pass a client's disconnect on to the
45
+ server at all (<https://docs.cloud.google.com/run/docs/troubleshooting>). The hosted endpoint
46
+ speaks HTTP/2 to Cloud Run, where the same page says a disconnect is passed on. Measured on
47
+ 2026-10-05, Cloud Run reset the stream of a client that stopped reading its answer 15 s in, and the
48
+ call was logged as a client that closed. Whether a client that aborts mid-call is passed on at once
49
+ has not been observed yet. Until it is, an abandoned `check_sbom` or `scan_manifest` call may run for up to its
50
+ 50-second budget and hold its slot that long.
@@ -0,0 +1,43 @@
1
+ # echelongraph-mcp: the structured result, tool by tool
2
+
3
+ Part of the README's [Structured results](../README.md#structured-results), which says what each
4
+ envelope field and `state` means: here, tool by tool, what each one's `state`, `measured_at`,
5
+ `coverage` and `freshness` hold. It is kept out of the README because npm keeps only the first
6
+ 65,536 characters of a package's README, and the package ships it in `docs/`. Exposure counts are
7
+ derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan).
8
+
9
+ Every field comes from what the API sends; where the API does not say, the field is `null` and
10
+ a note says so. Per tool:
11
+
12
+ - `cve_summary` is `measured`, and its `measured_at` is `summary.last_updated`, the newest
13
+ modification time among the active CVE records it counts.
14
+ - `search_cves` is `measured`, with `measured_at` `null`: each record carries its own times. Its
15
+ `coverage` repeats the list's `total`, `total_counted`, `total_is_lower_bound`,
16
+ `search_relaxed`, `limit` and `offset`, and adds `returned`, the rows in the page. When
17
+ `total_counted` is false the total is not a count, and the note does not call it one.
18
+ - `get_cve` is `measured`, and its `measured_at` is the record's `updated_at`, when EchelonGraph
19
+ last wrote it.
20
+ - `cve_exposure` is `not_assessed` with `measured_at` `null`, every answer: the per-CVE answer
21
+ carries no time at which the services it counts were observed. Its `last_seen` is when
22
+ EchelonGraph last wrote or refreshed one of their rows, not when any of them was observed,
23
+ so it is never `measured_at`, and a note says so. A tracked zero is `not_assessed` too, since
24
+ a zero has no observation to date it. The count is still relayed, labelled: `exposure_state`
25
+ is `exposed`, `measured_zero`, `not_assessed` or `tracking_unknown`, the cases of [How
26
+ `cve_exposure` counts](tools.md#how-cve_exposure-counts), and `coverage.in_scope` is the API's `tracked`. It becomes `measured`
27
+ only when the API serves a time at which the counted services were observed.
28
+ - `exposure_radar` is `not_assessed` with `measured_at` `null`: every radar answered, but no
29
+ answer gives one time at which what it counts was observed, so no count is presented as a
30
+ dated measurement. `mcp_servers` dates its verdicts only by a window, `mcp_servers.window.from`
31
+ to `mcp_servers.window.to`, over checks made at different times, which is not one observation
32
+ time; so, since #2438, do `exposed_databases.window`, `leaked_credentials.window` and
33
+ `shadow_ai.confirmed_exposed.window`, and `kev_exposure.window`, from Shodan's banner
34
+ timestamps, where the API serves it. Its numbers keep the labels [exposure-radar.md](exposure-radar.md) gives them. `freshness` holds each radar's `last_run_at` where the
35
+ API serves one, for `shadow_ai` also `running`, and for `mcp_servers` also `enabled`.
36
+ - `check_affected` is `measured` only when the answer says `assessed` true, and `not_assessed`
37
+ when it says false or does not say. Its `measured_at` and `freshness` are `null`: a match answer
38
+ carries no observation time. Its `coverage` gives `assessed` and `not_assessed_reason` first,
39
+ then `lookup` (`cpe` or `registry`), `match_layer`, `count`, `capped`, `candidates_capped`,
40
+ `excluded_count`, `undetermined_count`, `not_affected_count` and `degraded`.
41
+
42
+ The CVE feed tools' `freshness` is `null`: the feed's answers carry no time at which its
43
+ pollers last completed a poll for the feed as a whole.