echelongraph-mcp 2.7.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,6 +326,31 @@ 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
355
  export function registerVendorAdvisoryTools(server, kit) {
331
356
  const FOR_CVE_OUTPUT = kit.envelopeSchema({
@@ -617,20 +642,21 @@ export function registerVendorAdvisoryTools(server, kit) {
617
642
  server.registerTool("vendor_advisories_for_cve", {
618
643
  title: "Vendor advisories for one CVE",
619
644
  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") }),
645
+ inputSchema: z.object({ cve_id: z.string().describe("a CVE ID, e.g. CVE-2024-21412"), full: FULL_ARG }),
621
646
  outputSchema: FOR_CVE_OUTPUT,
622
647
  annotations: kit.annotations,
623
- }, async ({ cve_id }) => kit.checked("vendor_advisories_for_cve", FOR_CVE_OUTPUT, await forCVE(cve_id)));
648
+ }, async ({ cve_id, full }) => kit.checked("vendor_advisories_for_cve", FOR_CVE_OUTPUT, await answering(full, FOR_CVE_DATA, () => forCVE(cve_id))));
624
649
  server.registerTool("get_vendor_advisory", {
625
650
  title: "Vendor advisory detail",
626
651
  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
652
  inputSchema: z.object({
628
653
  vendor: z.string().describe("the vendor slug, e.g. microsoft, redhat, github"),
629
654
  advisory_id: z.string().describe("the vendor's advisory ID, e.g. RHSA-2024:1234 or GHSA-xxxx-xxxx-xxxx"),
655
+ full: FULL_ARG,
630
656
  }),
631
657
  outputSchema: DETAIL_OUTPUT,
632
658
  annotations: kit.annotations,
633
- }, async ({ vendor, advisory_id }) => kit.checked("get_vendor_advisory", DETAIL_OUTPUT, await detail(vendor, advisory_id)));
659
+ }, async ({ vendor, advisory_id, full }) => kit.checked("get_vendor_advisory", DETAIL_OUTPUT, await answering(full, NO_DATA_CUT, () => detail(vendor, advisory_id))));
634
660
  server.registerTool("search_vendor_advisories", {
635
661
  title: "Search vendor advisories",
636
662
  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 +667,9 @@ export function registerVendorAdvisoryTools(server, kit) {
641
667
  has_cve: z.boolean().optional().describe("true: only advisories naming a CVE; false: only those naming none"),
642
668
  limit: z.number().int().min(1).max(50).optional().describe("page size (default 20, max 50)"),
643
669
  offset: z.number().int().min(0).max(10000).optional().describe("rows to skip (default 0)"),
670
+ full: FULL_ARG,
644
671
  }),
645
672
  outputSchema: SEARCH_OUTPUT,
646
673
  annotations: kit.annotations,
647
- }, async (a) => kit.checked("search_vendor_advisories", SEARCH_OUTPUT, await search(a)));
674
+ }, async (a) => kit.checked("search_vendor_advisories", SEARCH_OUTPUT, await answering(a.full, SEARCH_DATA, () => search(a))));
648
675
  }
@@ -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.
package/docs/tools.md ADDED
@@ -0,0 +1,163 @@
1
+ # echelongraph-mcp: how each tool reads its answer
2
+
3
+ Part of the [README](../README.md), whose Tools table says what each tool answers. This page says,
4
+ field by field, how `cve_summary`, `get_cve`, `search_cves`, `cve_exposure` and `check_affected`
5
+ read the API's answer; `exposure_radar` has [a page of its own](exposure-radar.md). It is kept out
6
+ of the README because npm keeps only the first 65,536 characters of a package's README, and the
7
+ package ships it in `docs/`. Exposure counts are derived from Shodan data. Shodan data is owned by
8
+ Shodan, which holds its copyright (© Shodan).
9
+
10
+ ## How `cve_summary` reads its counts
11
+
12
+ `summary.critical`, `summary.high`, `summary.medium` and `summary.low` count the active CVEs by
13
+ severity band, and `summary.total` counts them all. `summary.none` counts the active CVEs with no
14
+ severity band from any source: CVEs not yet scored, not CVEs rated None. The answer sends the same
15
+ count again as `summary.unscored`.
16
+
17
+ `summary.nvd_critical`, `summary.nvd_high`, `summary.nvd_medium`, `summary.nvd_low` and
18
+ `summary.nvd_none` count the same active CVEs a second time, by NVD's CVSS severity label (v3.x,
19
+ else v4.0). Before NVD's record arrives, or where it gives none, a pre-NVD label from the CVE.org
20
+ record or a GitHub advisory can stand in. They are provenance, never EchelonGraph's severity band. `summary.nvd_none` counts the active CVEs with no
21
+ Critical, High, Medium or Low label there, and many of those carry an NVD CVSS v2 score instead, so
22
+ it is neither a count of CVEs rated None nor the count of CVEs with no severity, which is
23
+ `summary.none`.
24
+
25
+ `summary.rejected` counts the CVE records rejected (withdrawn) by their numbering authority.
26
+ `summary.total` and the other counts leave them out, and they are withdrawn records, never
27
+ vulnerabilities.
28
+
29
+ `poller` holds the in-memory counters of the NVD poller of the one API instance that answered,
30
+ counted since that instance last started and zeroed on every restart: they describe that instance,
31
+ never the feed's size, intake, reliability or freshness, and the note says so whenever the answer
32
+ carries `poller`. A `poller` field the answer sends in a JSON type other than the one the
33
+ outputSchema describes is left out and named in the note, and a `poller` that is neither a JSON
34
+ object nor null is left out whole; `summary` is relayed either way, so a change to that diagnostic
35
+ block never withholds the counts.
36
+
37
+ The tool relays the API's JSON as it was sent, less a `poller` field left out as above. When
38
+ `summary.none`, `summary.nvd_none` or `summary.rejected` is above zero, the note says what it
39
+ counts, with the number from the answer.
40
+ The note says the five NVD counts add up to `summary.total` only when the answer's do, and says
41
+ nothing about a field the answer does not carry.
42
+
43
+ ## How `get_cve` and `search_cves` read the EchelonGraph score
44
+
45
+ `echelongraph_score` (0 to 10), its band `echelongraph_severity` and the risk priority
46
+ `echelongraph_risk` (0 to 100) are EchelonGraph's score only when the record's
47
+ `score_assessed` is `true`. A CVE EchelonGraph has not scored carries `score_assessed: false`,
48
+ and it is **not yet scored**, not scored 0. The API leaves those three fields out on it, or (an
49
+ API before that change) sends `0`, `NONE` and `0` as placeholders, which are not a rating and do
50
+ not mean the CVE is harmless. Its `score_confidence` is `NONE`, and `score_unassessed_reason`
51
+ says why: no source has yet published severity data EchelonGraph can score, or the record was
52
+ rejected (withdrawn) by its numbering authority, and a rejected record is never scored.
53
+
54
+ Both tools relay the API's JSON as it was sent, and the note says what the score is, for
55
+ `get_cve` per CVE and for `search_cves` per row, naming each CVE it is about:
56
+
57
+ | API answer | What the note says |
58
+ |---|---|
59
+ | `score_assessed: true` | Nothing more: the score is a score. |
60
+ | `score_assessed: false` | **NOT YET SCORED**, tagged "(score_assessed: false)": report the CVE as not yet scored, never as a zero or low score. Each of `echelongraph_score`, `echelongraph_severity` and `echelongraph_risk` it carries is named as a placeholder. A rejected record is **NOT SCORED** instead. |
61
+ | no `score_assessed` field | The answer does not say whether the CVE was scored (an API older than the field), and a zero `echelongraph_score` in it is not a rating. |
62
+
63
+ ## What `patch_available` says
64
+
65
+ `patch_available` is `true` when EchelonGraph holds evidence of a fix for the CVE, and
66
+ `patch_evidence` lists the sources of that evidence, strongest first: `vendor_patch` (a vendor
67
+ patch on record, from Ubuntu or Red Hat), `fixed_version` (a fixed version recorded for an
68
+ affected package, or the fixed bound of an affected version range), `release_reference` (a
69
+ reference that EchelonGraph confirmed through the GitHub API is a published release containing
70
+ the fix, or a pull request or commit that a published release contains), `vendor_advisory_fix` (a
71
+ vendor advisory that lists a fix for the CVE under the vendor's own structured category: Red Hat's
72
+ CSAF `vendor_fix`, Microsoft's Vendor Fix), `nvd_patch_reference` (an NVD reference tagged Patch),
73
+ `cna_patch_reference` (a reference the CVE's CNA tagged `patch` in its CVE record), `cna_solution`
74
+ (a sentence of the CVE record's solutions, written by its CNA, that names a fixed version) and
75
+ `description_fix` (a sentence of the CVE description that names a fixed version). A reference page
76
+ that only mentions a patch, and a CVE's presence in CISA's KEV catalog, are not evidence.
77
+ `get_cve` relays the CNA's own sentence as `cna_solution`, its date for when the vulnerability was
78
+ made public as `date_public` (with `date_public_source` `cna`), and the CNA as `cna`, each only
79
+ where the CVE record has it. `false` means no fix evidence is on record, which is **not** a
80
+ finding that no fix exists. Once `true`, `patch_available` is not set back to `false`, so it can be
81
+ `true` beside an empty `patch_evidence`. An answer with no `patch_evidence` (an API older than that
82
+ field) does not name the evidence, and a `false` there is not a finding that no fix exists either.
83
+ Both tools relay the two fields as the API sends them.
84
+
85
+ ## How `cve_exposure` counts
86
+
87
+ Every 12 hours, when Shodan query credits allow, the KEV-exposure radar runs one Shodan query
88
+ per tracked product, reads up to 100 ip:port services per query, and keeps a service when its
89
+ banner version matches a CISA-KEV or high-EPSS (>= 0.5) CVE. A service whose row has not been
90
+ written or refreshed for 21 days is dropped. A count is therefore a banner-version inference
91
+ over a sample: not an exploit test, and not an internet-wide census.
92
+
93
+ `last_seen` is when EchelonGraph last wrote or refreshed a service's row, not when the service
94
+ was observed, and not when its vulnerable version was last confirmed. It is set to the time of
95
+ the write when a search matches the banner, and again when a re-check finds the port still
96
+ listed by Shodan InternetDB, without re-reading the banner, so a patched service can stay
97
+ counted while its port stays open. Shodan's own time for the banner is not stored. So
98
+ `last_seen` dates the write, never the sighting, and it is never `measured_at` (see
99
+ [Structured results](../README.md#structured-results)).
100
+
101
+ The unit is a service, not a machine. Shodan returns one banner per port and the radar keys
102
+ each observation on ip:port, so a machine answering on two ports counts twice. The API's field
103
+ names are kept (`exposed_hosts` here; `distinct_hosts`, `ransomware_hosts` and each ranked
104
+ row's count in `exposure_radar`), but every one of them counts ip:port services.
105
+
106
+ The radar only looks for its **tracked** set of CVEs, so a zero means different things. The
107
+ note tags each case with its `exposure_state`, as "(exposure_state: …)"; that tag says what the
108
+ count is, and is not the result's `state`:
109
+
110
+ | API answer | What the tool says |
111
+ |---|---|
112
+ | `tracked: true`, `exposed_hosts` above 0 | `exposure_state` `exposed`: the count of services the radar has on record for the CVE. |
113
+ | `tracked: false` | `exposure_state` `not_assessed`, **NOT ASSESSED**: outside the radar's tracked set; 0 is not a measurement. |
114
+ | `tracked: true`, `exposed_hosts: 0` | `exposure_state` `measured_zero`, a zero in the radar's sample: none of the up to 100 services it read per tracked-product query matched. Not an internet-wide zero, and undated: the answer does not say when the radar looked. The backend does not send this answer today. |
115
+ | no `tracked` field | The API did not say whether the CVE is in the tracked set: an older API, or the radar cannot decide (for example a CISA-KEV or high-EPSS CVE in a tracked product with 0 services on record). 0 exposed services on record, with no claim either way (`exposure_state` `tracking_unknown`). |
116
+ | HTTP 400, or an id that is not `CVE-YYYY-NNNN…` | An error result tagged `invalid_input`; nothing was looked up. |
117
+
118
+ Whatever the `exposure_state`, the result's `state` is `not_assessed` with `measured_at` `null`:
119
+ the answer does not say when any service it counts was observed (see [Structured results](../README.md#structured-results)).
120
+
121
+ ## How `check_affected` decides
122
+
123
+ `check_affected` asks the matcher behind
124
+ [echelongraph.io/am-i-affected](https://echelongraph.io/am-i-affected) whether a version is
125
+ affected, by one of two lookup paths:
126
+
127
+ - **CPE path** — `product` and `version`. `product` is the NVD CPE product token (`openssl`,
128
+ `nginx`), which can differ from a package name. It returns the CVEs whose NVD CPE match
129
+ criteria name that product with a version range that includes the version. It matches the
130
+ token across vendors, so each match carries `cpe_vendor` and `vendor_unknown`: a match with
131
+ `vendor_unknown` true is real, but its vendor is not verified to be yours.
132
+ - **Registry path** — `ecosystem` (`npm`, `PyPI`, `Maven`, …), `package` and `version`. Each OSV
133
+ advisory record EchelonGraph holds for the package is decided against the version as
134
+ affected (a match), not affected (`not_affected_count`) or undetermined.
135
+
136
+ Read `assessed` before `count`. `assessed` false means the lookup did not evaluate the
137
+ component, and `not_assessed_reason` says why: `product_not_in_cpe_corpus`,
138
+ `package_not_cpe_nameable`, `candidate_load_pending` (with `degraded` true),
139
+ `candidate_window_truncated`, `package_not_in_advisory_corpus` or `no_decidable_advisory`. The
140
+ result's `state` is then `not_assessed`, and its `count` of 0 never means "not affected". An
141
+ advisory whose version range cannot be decided at this version is counted in
142
+ `undetermined_count` (up to 50 listed in `undetermined`) and is never reported as safe. `capped`
143
+ says the match list stopped at its cap, and `candidates_capped` that not every candidate CVE was
144
+ loaded. A failed advisory lookup (`advisory_lookup_failed`) comes back as a failure. Each match
145
+ keeps `kev_listed`, `ransomware`, `epss_score`, `effective_score`, `effective_severity` and
146
+ `score_assessed` as the API sent them.
147
+
148
+ A registry match also carries `interval`, the advisory interval that holds the version
149
+ (`introduced`, and `fixed`, exclusive, or `last_affected`, inclusive), and `fixed_in`, the lowest
150
+ version above the one asked about that no affected interval of the same record holds: the
151
+ holding interval's fixed bound, moved past any overlapping interval that still holds that bound,
152
+ and `fixed_in_detail` then names the intervals it moved past (#3074). `fixed_in` is always above
153
+ the version asked about. It is null where no such version is recorded (the walk reaches an
154
+ interval that ends at `last_affected`, or has no end, with none beyond it) or no interval holds
155
+ the version, and `fixed_in_reason` then says which: a null is not a finding that no fix exists. A CPE
156
+ match carries no `fixed_in`, since the end of a CPE version range is not a fix. `check_sbom` and
157
+ `scan_manifest` relay the same fields (`interval`, `fixed_in`, `fixed_in_reason` and
158
+ `fixed_in_detail`) on each match.
159
+
160
+ `product`, `version`, `ecosystem` and `package` travel in the `X-EG-Product`, `X-EG-Version`,
161
+ `X-EG-Ecosystem` and `X-EG-Package` request headers, never in the URL, so request logs and
162
+ trace spans that record URLs do not hold what was looked up. The endpoint reads a CPE vendor
163
+ from the URL only, so this tool takes none.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echelongraph-mcp",
3
- "version": "2.7.2",
3
+ "version": "3.0.0",
4
4
  "mcpName": "io.echelongraph/echelongraph-mcp",
5
5
  "description": "MCP server for CVE lookups: EchelonGraph scores, CISA KEV, EPSS, CWE, exploits, version and SBOM checks, vendor advisories, and per-CVE exposure derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan). Keyless.",
6
6
  "type": "module",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "dist",
12
+ "docs",
12
13
  "README.md",
13
14
  "CHANGELOG.md",
14
15
  "server.json"
package/server.json CHANGED
@@ -17,7 +17,7 @@
17
17
  ]
18
18
  }
19
19
  ],
20
- "version": "2.7.2",
20
+ "version": "3.0.0",
21
21
  "remotes": [
22
22
  {
23
23
  "type": "streamable-http",
@@ -28,7 +28,7 @@
28
28
  {
29
29
  "registryType": "npm",
30
30
  "identifier": "echelongraph-mcp",
31
- "version": "2.7.2",
31
+ "version": "3.0.0",
32
32
  "transport": {
33
33
  "type": "stdio"
34
34
  },