echelongraph-mcp 2.6.1 → 2.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,93 @@ with a provenance attestation by the release workflow of
6
6
  [github.com/echelongraph/echelongraph-mcp](https://github.com/echelongraph/echelongraph-mcp),
7
7
  from the commit tagged `v<version>`.
8
8
 
9
+ ## 2.6.3 — 2026-10-04
10
+
11
+ - `exposure_radar` relays the observation window each count was measured over, where the API now
12
+ serves one: `exposed_databases.window` (when our verifier last confirmed each host),
13
+ `leaked_credentials.window` (when each key was last seen) and `shadow_ai.confirmed_exposed.window`
14
+ (when our verifier's deciding probe ran). A window is relayed only when no counted row is undated;
15
+ otherwise the note says how many are, and a malformed window is left out and named. KEV-exposure
16
+ carries no observation time yet. `exposure_radar` stays `not_assessed` with `measured_at` null:
17
+ a window's end is not one observation time (#2438).
18
+ - `vendor_advisories_for_cve`, `search_vendor_advisories` and `get_vendor_advisory` read each
19
+ vendor's window from `GET /api/v1/public/vendor-advisories/coverage` beside their own request,
20
+ and relay it in `coverage` (`vendor_windows`, or `vendor_window` for the detail): the advisories
21
+ held, `earliest_vendor_published_at`, `latest_vendor_published_at` and `history_backfill`.
22
+ `vendor_advisories_for_cve` adds `cve_year` and `vendors_not_fully_held`, the vendors with no
23
+ advisory in the answer of which EchelonGraph holds none, whose earliest held advisory is dated
24
+ after 1 January of the year in the CVE ID, or whose history is still being read, and its note
25
+ names each with its window: an empty answer for CVE-2024-3400 can no longer be read as Palo Alto
26
+ having published none. Its description no longer says an empty answer means none of the polled
27
+ feeds names the CVE: it means none of the advisories EchelonGraph holds does. The search
28
+ and the detail name the vendors whose history is still being read. When the windows cannot be
29
+ read, the answer is still relayed, measured, with the windows null and a note saying so (#2729).
30
+ - `search_vendor_advisories`: a query of 1 or 2 characters matches whole words only
31
+ (`search_match` `word`), as the API now answers it, and the description and the note say so; a
32
+ search's total is counted to at most 1,000, and a capped total (`total_capped` true) is written
33
+ "1,000+" in the note, never as a count. `coverage` gains `total_capped` and `search_match`
34
+ (#2728).
35
+ - `check_sbom`'s 50-second budget bounds the whole call, not only when a batch may start: each
36
+ batch's request may take only what is left of it, and a batch still unanswered when the budget
37
+ runs out is cut off and its purls answered as not sent (`not_sent_reason` `time_budget`). Before,
38
+ a batch started at 49.9 s could run for the 15-second request timeout, past the 60-second limits
39
+ of the SDK's client and the hosted endpoint, which then answered nothing at all. A Retry-After
40
+ wait that ends at the budget's end, before any batch was answered, answers that 429 instead of
41
+ sending a request with no time left (#2756).
42
+ - Repository only, not in the package: the production synthetic probes `check_sbom` with 201
43
+ purls, so every run sends two batches, and fails the probe (`expectation_unmet`) unless both
44
+ were answered; core-backend's tests fail when `MAX_COMPONENTS`, `MAX_PURL_LEN` or
45
+ `API_COMPONENTS_PER_MINUTE` disagree with the API's own values, and every core-backend deploy runs
46
+ them before it builds (`scripts/check-outbound-identity.sh`'s table), refusing on drift (#2757).
47
+ - A success's first text block is cut to fit 30,000 characters when the API's JSON is longer, and
48
+ `structuredContent.data` still carries the answer whole. Through 2.6.2 that block was the JSON
49
+ whatever its size: on production's answers of 2026-10-04 a default `search_cves` for "openssl"
50
+ was 133,300 characters of text, `get_cve` CVE-2021-44228 80,897, `check_affected` openssl 3.0.0
51
+ 156,058 (the Linux kernel at 5.10.0, 364,408), `check_sbom` on a 50-component SBOM 112,820,
52
+ `kev_recent` at limit 200 105,925 and `search_vendor_advisories` at limit 50 103,147, against
53
+ the 60,000-character ceiling; the same calls now measure 13,513, 22,960, 25,951 (27,717),
54
+ 28,733, 31,588 and 19,917. The JSON is first laid out one row a line, which loses nothing; past
55
+ that, a list tool cuts each row to the fields it names (the description cut to 200 characters,
56
+ every long string ending in "…"), then to fewer, and leaves rows out only when its leanest cut
57
+ is still too long (`check_sbom` leaves out `not_affected` rows first); `get_cve` keeps the first
58
+ entries of `cpe_match` and `references`. The note then ends with a sentence starting
59
+ `TEXT CUT` that names what the first text block leaves out, which tool returns a row whole, and,
60
+ when rows are left out, the `offset` or page size at which they fit. An answer that fits is sent
61
+ as before. Every tool description, the server instructions and the methodology resource say so
62
+ (#2783).
63
+ - `search_cves` takes `offset` (0 to 10,000), so the next page a cut text names can be read (#2783).
64
+ - A list beside the rows is cut before the rows are: `check_affected`'s `excluded` and
65
+ `undetermined` samples keep each entry's `cve_id` and `reason`, and `cve_ids` (each match's
66
+ `cve_id`, in order) its first 10, so every match stays in the text (`flash_player` 10.0.0, 200
67
+ matches and 50 excluded entries, had 75 matches left out of it, two of them KEV-listed), and `check_sbom`'s `not_sent_purls` keeps its first 10, with the note
68
+ saying from which position of the input the purls not sent run. A 2,000-purl `check_sbom` call
69
+ that the API's budget stops after 1,200 now keeps the affected rows before the clean ones in the
70
+ text. A cut `check_affected` match keeps `match_reason` (a registry match's advisory interval,
71
+ which names the fixed version) where it fits, and the note names `cve_intel` for fixed versions
72
+ (#2783).
73
+
74
+ ## 2.6.2 — 2026-10-04
75
+
76
+ - `kev_recent` reads an RFC 3339 `since` or `until` as the date written in it, dropping the time
77
+ and offset. It used to convert the timestamp to UTC first, so `2024-04-12T00:00:00+02:00` was
78
+ answered for 2024-04-11, and `2024-04-12T23:30:00-05:00` for 2024-04-13, while still reporting
79
+ a measured result. `kev_added_date` is a calendar date, so the written date is the one meant.
80
+ `Z` timestamps, such as those `get_cve` returns, are unaffected (#2755).
81
+ - Hosted endpoint only: a `tools/call` of `check_sbom`, or a `prompts/get` of `sbom_review`, may
82
+ now send a request body of up to 6 MiB, so a real CycloneDX or SPDX document (up to the tool's
83
+ 5,000,000-character cap) is accepted over `https://mcp.echelongraph.io/mcp`; before, any body
84
+ over 64 KiB was refused, and only a purl list fit. Every other request keeps the 64 KiB cap.
85
+ Each server instance serves at most two such large bodies at once; a third is answered HTTP 503
86
+ with `Retry-After` and JSON-RPC error `-32030`. The per-client limit is unchanged (120 requests
87
+ a minute). New settings `MCP_MAX_SBOM_BODY_BYTES` and `MCP_LARGE_BODY_SLOTS`. The `check_sbom`
88
+ description and the README say that over the hosted endpoint the document reaches
89
+ EchelonGraph's server, which reads the purls from it and neither logs nor keeps it (#2747).
90
+ - `cve_summary` says what the answer's `poller` block is: the in-memory counters of the NVD
91
+ poller of the one API instance that answered, since it last started, zeroed on every restart,
92
+ never the feed's size, intake, reliability or freshness. The note says so whenever the block is
93
+ present, quoting `cves_ingested`, `poll_count` and `poll_errors`; the description and the
94
+ outputSchema describe the block and each counter. The JSON is still relayed as sent (#2647).
95
+
9
96
  ## 2.6.1 — 2026-10-04
10
97
 
11
98
  - Hosted endpoint only: when the production synthetic calls `https://mcp.echelongraph.io/mcp`, the
package/README.md CHANGED
@@ -155,14 +155,14 @@ The example questions are ones a client can answer with that tool alone.
155
155
  | Tool | Title | What it answers | Example question |
156
156
  |---|---|---|---|
157
157
  | `cve_summary` | CVE feed summary | Counts of active CVEs by severity band, the count with no severity band from any source (`summary.none`, sent again as `summary.unscored`: CVEs not yet scored, not a rating of None), the same CVEs counted by NVD's severity label, as provenance (`summary.nvd_critical` to `summary.nvd_none`), the rejected (withdrawn) records outside the total (`summary.rejected`), and when the feed was last updated. | *How many critical CVEs does the feed hold, and when was it last updated?* |
158
- | `search_cves` | Search CVEs | Search/filter CVEs (severity, min CVSS, text, sort) with EchelonGraph scores and `score_assessed`; the note names each row not yet scored. | *Find critical CVEs that mention Tomcat with a CVSS of 9 or more.* |
158
+ | `search_cves` | Search CVEs | Search/filter CVEs (severity, min CVSS, text, sort) with EchelonGraph scores and `score_assessed`; page with `limit` and `offset`; the note names each row not yet scored. | *Find critical CVEs that mention Tomcat with a CVSS of 9 or more.* |
159
159
  | `get_cve` | CVE detail | Full record for one CVE: CVSS v3 and (when scored) v4, the EchelonGraph score and its confidence, whether EchelonGraph has scored it (`score_assessed`), EPSS, CISA-KEV status and known ransomware use, the GitHub GHSA id, references, and its published, modified and `updated_at` times. | *What are CVE-2024-3094's CVSS, EPSS and CISA-KEV status?* |
160
160
  | `cve_exposure` | Internet exposure for one CVE | Internet-exposure footprint for a CVE: exposed service count (distinct ip:port, the `exposed_hosts` field) + country/product breakdown, from the KEV-exposure radar. | *How many exposed services does EchelonGraph's radar have on record for CVE-2023-44487?* |
161
161
  | `exposure_radar` | Exposure radar totals | Aggregate totals across the exposure radars: services running CISA-KEV CVEs; unauthenticated data stores and observability UIs, found through Shodan (LeakIX when Shodan query credits run low) and then confirmed by EchelonGraph's own identified check, which is not a pure read (on Redis it names its client; on ClickHouse its query lands in the server's query log); leaked credentials; shadow AI; and MCP servers found in EchelonGraph's own Certificate Transparency feed, by RFC 9728 verdict, protocol era and transport. Every number is labelled by what it counts, and a field the tool cannot label is left out and named. | *How many unauthenticated data stores has EchelonGraph's radar confirmed?* |
162
162
  | `kev_recent` | Recent CISA KEV additions | The CVEs CISA has added to its Known Exploited Vulnerabilities catalog, newest first (`kev_added_date`), from EchelonGraph's copy of the catalog, polled from CISA every 5 minutes: due date, vendor, product, known ransomware use, EchelonGraph's severity, CVSS, EPSS and `eg_kev_tier`, and `our_first_seen_kev`. Filter by date range, ransomware and vendor; page with `limit` and `next_cursor`. Dated by `last_successful_fetch_at`, our last successful fetch of CISA's feed; the filters travel as request headers, never in the URL. | *Which CVEs has CISA added to KEV since 1 September, and which have known ransomware use?* |
163
163
  | `epss_history` | EPSS change history for one CVE | How one CVE's EPSS score has changed, as EchelonGraph recorded it: one point per recorded change (`series_kind` `change_only`), never a daily series, with the value now and `series_starts_at`, when recording began; before it a missing point means not recorded, not unchanged. | *How has CVE-2023-44487's EPSS score changed, as EchelonGraph recorded it?* |
164
164
  | `check_affected` | Am I affected? (product or package at a version) | Whether a product (its NVD CPE product token) or a registry package (`ecosystem` and `package`) at a given version is affected by known CVEs, from the matcher behind echelongraph.io/am-i-affected: `assessed` first (false: not evaluated, with `not_assessed_reason`, and a count of 0 then is not "not affected"), the matching CVEs with `kev_listed`, `ransomware`, `epss_score`, `effective_score` and `score_assessed`, and advisories it cannot decide counted as `undetermined_count`, never as safe. What you look up travels in request headers, never in the URL. | *Is openssl 3.0.0 affected by known CVEs? Is lodash 4.17.15 on npm?* |
165
- | `check_sbom` | Check an SBOM against the advisory corpus | A dependency list checked against EchelonGraph's advisory corpus (OSV.dev records), one verdict per component: `affected`, `not_affected`, `undetermined` or `not_assessed`, each with its `not_assessed_reason`. Pass up to 2,000 distinct purls, or a CycloneDX JSON or SPDX JSON document: the purls are read from it on your machine and only they are sent, in POST bodies of at most 200 each, one after another; the document is not. When the API's per-component rate limit answers 429, the tool waits the `Retry-After` it names, within 50 seconds per call; past that it answers what it has, with the purls not sent counted in `coverage.not_sent`, the reason in `coverage.not_sent_reason`, and the list in `data.not_sent_purls`, never dropped silently. A deb, apk or rpm purl without a `distro` qualifier naming its release is not assessed (`distro_release_unknown`): EchelonGraph does not guess a release. Only `not_affected` is clean. No ranking, no score. | *Check this CycloneDX SBOM against the advisory corpus.* |
165
+ | `check_sbom` | Check an SBOM against the advisory corpus | A dependency list checked against EchelonGraph's advisory corpus (OSV.dev records), one verdict per component: `affected`, `not_affected`, `undetermined` or `not_assessed`, each with its `not_assessed_reason`. Pass up to 2,000 distinct purls, or a CycloneDX JSON or SPDX JSON document: the purls are read from it by the MCP server (on your machine when it runs from npm) and only they are sent to the API, in POST bodies of at most 200 each, one after another; the document is not. When the API's per-component rate limit answers 429, the tool waits the `Retry-After` it names, within 50 seconds per call; past that it answers what it has, with the purls not sent counted in `coverage.not_sent`, the reason in `coverage.not_sent_reason`, and the list in `data.not_sent_purls`, never dropped silently. A deb, apk or rpm purl without a `distro` qualifier naming its release is not assessed (`distro_release_unknown`): EchelonGraph does not guess a release. Only `not_affected` is clean. No ranking, no score. | *Check this CycloneDX SBOM against the advisory corpus.* |
166
166
  | `cve_intel` | CVE weakness, exploits and packages | Weakness, public exploit code, affected packages and fixed versions for one CVE, from EchelonGraph's per-CVE enrichment: `cwes`, `exploits` (at most 10, verified first) with `exploits_total`, `exploits_capped`, `exploits_by_kind` and `exploits_by_status`, `affected_packages`, `fixed_versions` and `timeline`. `verified_status` is the label stored with each reference, not a guarantee that the exploit works. An empty `exploits` list is not evidence that no public exploit exists; a section the API could not read is named in `coverage.sections_failed`, never relayed as an empty list. | *Is there public exploit code for CVE-2021-44228, and which versions fix it?* |
167
167
  | `get_cwe` | CWE and its CVEs | One CWE (weakness class) and the CVEs classified under it: `name` and `description` from the MITRE CWE catalog EchelonGraph embeds, `total`, and one page of 50 `cves`, ordered as `order` states (CISA-KEV-listed first, then EchelonGraph score). A `total` of 0 says that no CVE in EchelonGraph's feed is classified under that CWE, not that none exists. | *Which CVEs are classified under CWE-79, CISA-KEV-listed first?* |
168
168
  | `vendor_advisories_for_cve` | Vendor advisories for one CVE | The vendor-published advisories (Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks, GitHub GHSA and the other feeds EchelonGraph polls) that name one CVE, newest first, at most 20. | *Which vendor advisories name CVE-2024-3400?* |
@@ -312,6 +312,7 @@ A `newest_kev` row's `exposure_state` says whether its count is a measurement:
312
312
  | `exposed_databases.engines` | Distinct engine types among them (for example `redis` or `grafana`). |
313
313
  | `exposed_databases.top_engines`, `exposed_databases.top_countries` | Up to 15 engines and 10 countries, ranked by those services. |
314
314
  | `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. |
315
+ | `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. |
315
316
  | `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. |
316
317
 
317
318
  #### `leaked_credentials`
@@ -322,6 +323,7 @@ A `newest_kev` row's `exposure_state` says whether its count is a measurement:
322
323
  | `leaked_credentials.distinct_secrets` | Each secret once. |
323
324
  | `leaked_credentials.distinct_repos` | Public GitHub repositories with at least one. |
324
325
  | `leaked_credentials.top_providers`, `leaked_credentials.top_types` | Up to 15 providers and secret types, ranked by (repository, secret) pairs. |
326
+ | `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. |
325
327
  | `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. |
326
328
 
327
329
  None of them is validated. Each is a credential-shaped string in a public commit that passed
@@ -339,6 +341,7 @@ labels each one. Only `shadow_ai.confirmed_exposed` counts exposed services.
339
341
  | `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`. |
340
342
  | `confirmed_exposed.by_category` | The same services, by category. |
341
343
  | `confirmed_exposed.last_24h` | Confirmed-exposed services first recorded in the last 24 hours. |
344
+ | `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. |
342
345
  | `observed.total` | Every Certificate Transparency or Shodan observation on record, whatever its verification state: observed, not exposed. |
343
346
  | `observed.by_category` | The same observations, by category. |
344
347
  | `observed.last_24h` | Observations first recorded in the last 24 hours. |
@@ -485,7 +488,8 @@ never "not affected", and each figure cites its `measured_at`. A prompt makes no
485
488
  Every tool answers in one of two shapes, so a model reading the result cannot mistake an
486
489
  outage for an all-clear:
487
490
 
488
- - **Success** — the first text block is the API's JSON verbatim; the second is a one-line
491
+ - **Success** — the first text block is the API's JSON verbatim (cut to fit 30,000 characters
492
+ when it is longer: see "Long answers" below); the second is a one-line
489
493
  note saying the call succeeded, which base URL answered, and what it found; the third is
490
494
  the structured result as JSON, less what the first two already say (below). When the
491
495
  feed genuinely holds nothing for the query the note says so in words ("we looked and
@@ -515,6 +519,37 @@ what an earlier block already says, so a client that passes only `content` to th
515
519
  sees how the answer was measured. The first text block (the API's JSON, or the failure) and the
516
520
  note after it are where 1.x put them.
517
521
 
522
+
523
+ ### Long answers
524
+
525
+ A model pays for every character of text it reads, and some clients cap what they pass on:
526
+ Claude Code warns past 10,000 tokens of tool output and, past 25,000, saves the result to a
527
+ file and hands the model its path instead. Through 2.6.2 the first text block was the API's JSON
528
+ whatever its size: on production's answers of 2026-10-04 a default `search_cves` page for
529
+ "openssl" was 133,300 characters of text, `get_cve` for CVE-2021-44228 80,897, `check_affected`
530
+ for the Linux kernel at 5.10.0 364,408, `kev_recent` at its largest page 105,925 and
531
+ `search_vendor_advisories` at its largest page 103,147.
532
+
533
+ So when the API's JSON is longer than 30,000 characters, the first text block holds it cut to
534
+ fit, and `structuredContent`'s `data` still holds it whole. First the same JSON is laid out with
535
+ each row on a line of its own, which drops no character of the answer but the indentation. If
536
+ that is still too long, a tool that returns a list of rows cuts each row to the fields it names,
537
+ most kept first (`search_cves` keeps `cve_id`, `severity`, `cvss_v3_score`, `echelongraph_score`,
538
+ `score_assessed`, `epss_score`, `kev_listed` and the first 200 characters of the description,
539
+ among others), with every long string ending in "…". A list beside the rows is cut before any
540
+ row field the first level keeps, and before any row leaves the text: `check_affected`'s
541
+ `excluded` and `undetermined` samples keep each entry's `cve_id` and `reason`, and its `cve_ids`
542
+ (each match's `cve_id`, in order) its first 10, so that every match stays in the text, and `check_sbom`'s `not_sent_purls` keeps its first 10, the note saying from
543
+ which position of the input the purls not sent run, so that a second call can send them without
544
+ reading the list. When even the leanest cut is too long, rows are left out of the text
545
+ (`check_sbom` leaves out the `not_affected` rows first, then the `not_assessed` ones), and the
546
+ note says how to read them: the `offset` to call next, or a smaller `limit`. Any other answer
547
+ keeps the first entries of each of its lists (`get_cve`'s `cpe_match` and `references`). The note
548
+ then ends with one sentence starting `TEXT CUT` that says what the first text block leaves out
549
+ and which tool returns a row whole (`get_cve`, `get_vendor_advisory`, `check_affected`, and
550
+ `cve_intel` a CVE's fixed versions). An answer that fits is sent as before, pretty-printed and
551
+ whole. The same answer is always cut the same way.
552
+
518
553
  ## Structured results
519
554
 
520
555
  Since 2.0.0 every result, success or failure, carries `structuredContent`, and every tool
@@ -530,7 +565,7 @@ declares its shape as an `outputSchema` in `tools/list`, with a title and the an
530
565
  | `coverage` | What the answer covers, where the tool can say: `in_scope` for `cve_exposure`, the list's own account of its count for `search_cves`, the radars that answered for `exposure_radar`, and `assessed`, `not_assessed_reason`, the lookup path and the match counts for `check_affected`. |
531
566
  | `freshness` | The producing radar's last completed check (`last_run_at`), where the API serves one; `null` where it serves none. |
532
567
  | `notes` | The caveats, one sentence each: what the envelope itself needs saying, then the note from the text block. |
533
- | `data` | On a success only: the same JSON as the first text block. |
568
+ | `data` | On a success only: the API's JSON, whole: the first text block carries the same JSON, or, past 30,000 characters, a cut of it that the note names (`TEXT CUT`). |
534
569
  | `error` | On a failure only: `kind`, `path`, `status` and `message`. |
535
570
 
536
571
  The last text block of every result is this structured result serialized as JSON, less what
@@ -574,7 +609,8 @@ a note says so. Per tool:
574
609
  answer gives one time at which what it counts was observed, so no count is presented as a
575
610
  dated measurement. `mcp_servers` dates its verdicts only by a window, `mcp_servers.window.from`
576
611
  to `mcp_servers.window.to`, over checks made at different times, which is not one observation
577
- time. Its numbers keep the labels above. `freshness` holds each radar's `last_run_at` where the
612
+ time; so, since #2438, do `exposed_databases.window`, `leaked_credentials.window` and
613
+ `shadow_ai.confirmed_exposed.window`. `kev_exposure` gives no observation time at all. Its numbers keep the labels above. `freshness` holds each radar's `last_run_at` where the
578
614
  API serves one, for `shadow_ai` also `running`, and for `mcp_servers` also `enabled`.
579
615
  - `check_affected` is `measured` only when the answer says `assessed` true, and `not_assessed`
580
616
  when it says false or does not say. Its `measured_at` and `freshness` are `null`: a match answer
@@ -626,11 +662,11 @@ Where each tool's input travels:
626
662
  | Input | Where it is sent |
627
663
  |---|---|
628
664
  | A CVE ID (`get_cve`, `cve_exposure`, `epss_history`, `cve_intel`, `vendor_advisories_for_cve`), a CWE ID and page (`get_cwe`), a vendor and advisory ID (`get_vendor_advisory`) | The URL path. |
629
- | `search_cves`: `search` | The `X-EG-Search` request header, never the URL. Its severity, minimum CVSS, sort and limit go in the query string. |
665
+ | `search_cves`: `search` | The `X-EG-Search` request header, never the URL. Its severity, minimum CVSS, sort, limit and offset go in the query string. |
630
666
  | `search_vendor_advisories`: `query` | The `X-EG-Advisory-Search` request header, never the URL. Its vendor, severity, CVE filter, limit and offset go in the query string. |
631
667
  | `check_affected`: `product`, `version`, `ecosystem`, `package` | The `X-EG-Product`, `X-EG-Version`, `X-EG-Ecosystem` and `X-EG-Package` request headers, never the URL. |
632
668
  | `kev_recent`: `since`, `until`, `ransomware`, `vendor`, `limit`, `cursor` | The `X-EG-Since`, `X-EG-Until`, `X-EG-Ransomware`, `X-EG-Vendor`, `X-EG-Limit` and `X-EG-Cursor` request headers, never the URL. |
633
- | `check_sbom`: `purls`, or an `sbom` document | The purls, in POST bodies of at most 200 each. A CycloneDX or SPDX document is read on your machine and only the purls in it are sent; the document is not. |
669
+ | `check_sbom`: `purls`, or an `sbom` document | The purls, in POST bodies of at most 200 each. A CycloneDX or SPDX document is read on your machine and only the purls in it are sent; the document is not. (Over the hosted endpoint the document goes to EchelonGraph's server instead: see below.) |
634
670
  | `cve_summary`, `exposure_radar` | No input. |
635
671
 
636
672
  The API marks its answers to header-carried lookups `no-store`, so no shared cache keeps them,
@@ -640,7 +676,11 @@ and request logs and trace spans that record URLs do not hold what was looked up
640
676
  server first, which then calls the same API as above. Per request it logs the HTTP method and
641
677
  path, the status, the duration, the body size, the JSON-RPC method and (for `tools/call`) the
642
678
  tool name, the protocol version, whether an `Origin` header was sent, and the family of your
643
- client's User-Agent. It never logs tool arguments or a query string. When a client passes the
679
+ client's User-Agent. It never logs tool arguments or a query string. A `check_sbom` document (or
680
+ an `sbom_review` prompt's) is a tool argument like any other here: it is in the request body, so
681
+ the whole document reaches EchelonGraph's server, which reads the purls from it in memory, sends
682
+ only those to the API, and neither logs nor keeps the document. To keep the document on your
683
+ machine, run the npm package, or pass `check_sbom` the purls. When a client passes the
644
684
  per-client limit it logs that client's address key. It passes your client's public address to
645
685
  the API, in a header the API trusts only with a token it checks, so the API's rate limit counts
646
686
  you and not the hosted service.
@@ -675,7 +715,10 @@ limits: <https://echelongraph.io/pulse/api>.
675
715
  The hosted endpoint adds its own limit before the API's: 120 MCP requests a minute per client
676
716
  address, counted by each server instance, answered over it with HTTP 429, `Retry-After` and a
677
717
  JSON-RPC error (code `-32029`) that names the limit and the seconds to wait. A request body over
678
- 64 KiB is refused.
718
+ 64 KiB is refused (HTTP 413, code `-32600`), except a `tools/call` of `check_sbom` or a
719
+ `prompts/get` of `sbom_review`, which may be up to 6 MiB: enough for a CycloneDX or SPDX document
720
+ at the tool's 5,000,000-character cap. Each server instance reads at most two such large bodies at
721
+ once; a third is answered HTTP 503 with `Retry-After` and a JSON-RPC error (code `-32030`).
679
722
 
680
723
  ## Configuration
681
724
 
@@ -695,7 +738,9 @@ variables above and:
695
738
  |---|---|---|
696
739
  | `PORT` | `8080` | The port it listens on. |
697
740
  | `MCP_RATE_LIMIT_PER_MIN` | `120` | MCP requests a minute per client address, per instance. |
698
- | `MCP_MAX_BODY_BYTES` | `65536` | The largest request body it reads. |
741
+ | `MCP_MAX_BODY_BYTES` | `65536` | The largest request body it reads, except for an SBOM (below). |
742
+ | `MCP_MAX_SBOM_BODY_BYTES` | `6291456` | The largest body of a `tools/call` of `check_sbom` or a `prompts/get` of `sbom_review`. |
743
+ | `MCP_LARGE_BODY_SLOTS` | `2` | How many bodies over `MCP_MAX_BODY_BYTES` it reads and serves at once; past that, 503 with `Retry-After`. |
699
744
  | `ECHELONGRAPH_FORWARD_TOKEN`, `ECHELONGRAPH_FORWARD_HOST`, `MCP_REQUIRE_FORWARD_TOKEN` | unset | EchelonGraph's hosted deployment only: the token with which it names each client to the API. Leave them unset when you run it yourself; the API then counts every request as coming from your server. |
700
745
 
701
746
  ## Develop
@@ -707,8 +752,12 @@ npm test # build, then the behavioural suite over both protocol eras,
707
752
  npm run smoke # spawn the server + call cve_exposure against the production API
708
753
  ```
709
754
 
710
- `npm run smoke` calls the production API with this package's User-Agent, so its requests are
711
- counted as external MCP adoption. `npm test` never leaves the machine.
755
+ `npm run smoke` calls the production API with this package's User-Agent. Run from our own
756
+ workspace, whose egress address the API recognises as ours, its requests are counted as our own
757
+ traffic and appear in no adoption count. Run from any other machine, they are counted as external
758
+ MCP adoption unless you set `ECHELONGRAPH_MCP_UA` (for example
759
+ `ECHELONGRAPH_MCP_UA=my-smoke/1.0 npm run smoke`), which files them under that token's family
760
+ instead. `npm test` never leaves the machine.
712
761
 
713
762
  `npm test` runs the suite once over a 2026-07-28 `server/discover` and once over a 2025-06-18
714
763
  `initialize`, plus the era tests. To run it against an installed package rather than `dist/`,
package/dist/http.js CHANGED
@@ -24,7 +24,7 @@
24
24
  import http from "node:http";
25
25
  import { createMcpHandler } from "@modelcontextprotocol/server";
26
26
  import { forwardToken, markHttpEntrypoint, SYNTHETIC_UA_FAMILY, withClient } from "./runtime.js";
27
- import { BODY_TOO_LARGE_CODE, DEFAULT_RATE_LIMIT_PER_MIN, FixedWindowLimiter, ORIGIN_REFUSED_CODE, RATE_LIMITED_CODE, bodyFacts, originVerdict, resolveClient, revisionLabel, rpcError, uaFamily, } from "./httpPolicy.js";
27
+ import { BODY_TOO_LARGE_CODE, BUSY_CODE, DEFAULT_LARGE_BODY_SLOTS, DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_SBOM_BODY_BYTES, DEFAULT_RATE_LIMIT_PER_MIN, LARGE_BODY_TARGETS_TEXT, Slots, FixedWindowLimiter, ORIGIN_REFUSED_CODE, RATE_LIMITED_CODE, bodyFacts, originVerdict, resolveClient, revisionLabel, rpcError, isLargeBodyTarget, largeBodyAdmissible, uaFamily, } from "./httpPolicy.js";
28
28
  // Must run before index.ts is evaluated: it is how index.ts knows not to start stdio.
29
29
  markHttpEntrypoint();
30
30
  const { createServer, NAME, VERSION, SHOWN_BASE, TIMEOUT_MS } = await import("./index.js");
@@ -37,9 +37,12 @@ const PORT = (() => {
37
37
  return Number.isInteger(n) && n >= 0 && n < 65536 && process.env.PORT !== "" && process.env.PORT !== undefined ? n : 8080;
38
38
  })();
39
39
  const RATE_LIMIT_PER_MIN = positiveInt(process.env.MCP_RATE_LIMIT_PER_MIN, DEFAULT_RATE_LIMIT_PER_MIN);
40
- // Tool arguments are a CVE ID or a short search; 64 KiB is far above any real request and far
41
- // below the SDK's 4 MiB default.
42
- const MAX_BODY_BYTES = positiveInt(process.env.MCP_MAX_BODY_BYTES, 64 * 1024);
40
+ // The body caps and the large-body slots (#2747): see httpPolicy.ts "Request-body caps".
41
+ const MAX_BODY_BYTES = positiveInt(process.env.MCP_MAX_BODY_BYTES, DEFAULT_MAX_BODY_BYTES);
42
+ const MAX_SBOM_BODY_BYTES = Math.max(MAX_BODY_BYTES, positiveInt(process.env.MCP_MAX_SBOM_BODY_BYTES, DEFAULT_MAX_SBOM_BODY_BYTES));
43
+ const LARGE_BODY_SLOTS = positiveInt(process.env.MCP_LARGE_BODY_SLOTS, DEFAULT_LARGE_BODY_SLOTS);
44
+ // A large body's wait for a slot is not queued: the client is told to come back.
45
+ const BUSY_RETRY_AFTER_SEC = 5;
43
46
  const MCP_PATH = "/mcp";
44
47
  const HEALTH_PATH = "/health";
45
48
  // One JSON object per line on stdout: Cloud Logging reads `severity` and `message`.
@@ -57,11 +60,13 @@ const handler = createMcpHandler(createServer, {
57
60
  // responseMode stays the SDK default, "auto": a modern request is answered with one JSON body
58
61
  // unless a handler emits something before its result (none of these tools does). The legacy
59
62
  // leg answers over SSE, as a 2025-era streamable HTTP server does.
60
- maxRequestBodySize: MAX_BODY_BYTES,
63
+ // The largest body readBody can hand on; which request may be that large is decided here first.
64
+ maxRequestBodySize: MAX_SBOM_BODY_BYTES,
61
65
  // Names only: an SDK error message can quote what the client sent.
62
66
  onerror: (e) => log("WARNING", "mcp_handler_error", { error_name: e.name }),
63
67
  });
64
68
  const limiter = new FixedWindowLimiter(RATE_LIMIT_PER_MIN);
69
+ const largeBodies = new Slots(LARGE_BODY_SLOTS);
65
70
  // CORS for a browser client on an https Origin (the Origin policy has already admitted it).
66
71
  // A wildcard is safe here because nothing is credentialed: no cookies, no auth.
67
72
  const CORS_HEADERS = {
@@ -79,39 +84,69 @@ function send(res, status, body, headers = {}) {
79
84
  res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", ...headers });
80
85
  res.end(body);
81
86
  }
82
- // Reads the body up to MAX_BODY_BYTES. Over the cap: "too_large", and the rest is discarded
83
- // unread (the response closes the connection).
84
87
  function readBody(req) {
88
+ const mayBeLarge = largeBodyAdmissible(header(req, "mcp-method"), header(req, "mcp-name"));
89
+ const cap = mayBeLarge ? MAX_SBOM_BODY_BYTES : MAX_BODY_BYTES;
85
90
  const declared = Number(req.headers["content-length"]);
86
- if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
91
+ if (Number.isFinite(declared) && declared > cap) {
87
92
  req.resume();
88
93
  return Promise.resolve("too_large");
89
94
  }
95
+ let slot = false;
96
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
97
+ if (!largeBodies.tryAcquire()) {
98
+ req.resume();
99
+ return Promise.resolve("busy");
100
+ }
101
+ slot = true;
102
+ }
90
103
  return new Promise((resolve, reject) => {
91
104
  const chunks = [];
92
105
  let size = 0;
93
106
  let done = false;
107
+ const stop = (r) => {
108
+ done = true;
109
+ chunks.length = 0;
110
+ if (slot)
111
+ largeBodies.release();
112
+ slot = false;
113
+ resolve(r);
114
+ };
94
115
  req.on("data", (chunk) => {
95
116
  if (done)
96
117
  return;
97
118
  size += chunk.length;
98
- if (size > MAX_BODY_BYTES) {
99
- done = true;
100
- resolve("too_large");
101
- return;
119
+ if (size > cap)
120
+ return stop("too_large");
121
+ if (size > MAX_BODY_BYTES && !slot) {
122
+ if (!largeBodies.tryAcquire())
123
+ return stop("busy");
124
+ slot = true;
102
125
  }
103
126
  chunks.push(chunk);
104
127
  });
105
128
  req.on("end", () => {
106
129
  if (!done)
107
- resolve(Buffer.concat(chunks));
130
+ resolve({ body: Buffer.concat(chunks), slot });
108
131
  });
109
132
  req.on("error", (e) => {
110
- if (!done)
111
- reject(e);
133
+ if (done)
134
+ return;
135
+ done = true;
136
+ if (slot)
137
+ largeBodies.release();
138
+ reject(e);
112
139
  });
113
140
  });
114
141
  }
142
+ function header(req, name) {
143
+ const v = req.headers[name];
144
+ return Array.isArray(v) ? v.join(", ") : v;
145
+ }
146
+ function tooLarge(res, cors) {
147
+ send(res, 413, rpcError(BODY_TOO_LARGE_CODE, `Request body too large: at most ${MAX_BODY_BYTES} bytes, or ${MAX_SBOM_BODY_BYTES} bytes for ${LARGE_BODY_TARGETS_TEXT}. A larger SBOM can be passed to check_sbom as its purls.`, { max_bytes: MAX_BODY_BYTES, max_bytes_sbom: MAX_SBOM_BODY_BYTES }), { ...cors, Connection: "close" });
148
+ return { status: 413, refused: "body_size" };
149
+ }
115
150
  async function serveMcp(req, res, pathname) {
116
151
  const method = req.method ?? "GET";
117
152
  const origin = req.headers.origin;
@@ -135,14 +170,39 @@ async function serveMcp(req, res, pathname) {
135
170
  return { status: 429, throttled: true };
136
171
  }
137
172
  let body;
173
+ let facts;
174
+ let slot = false;
138
175
  if (method === "POST") {
139
176
  const read = await readBody(req);
140
- if (read === "too_large") {
141
- send(res, 413, rpcError(BODY_TOO_LARGE_CODE, `Request body too large: at most ${MAX_BODY_BYTES} bytes.`), { ...cors, Connection: "close" });
142
- return { status: 413, refused: "body_size" };
177
+ if (read === "too_large")
178
+ return tooLarge(res, cors);
179
+ if (read === "busy") {
180
+ send(res, 503, rpcError(BUSY_CODE, `Server busy: this instance is already reading its ${LARGE_BODY_SLOTS} large request bodies at once. Retry after ${BUSY_RETRY_AFTER_SEC} s, or pass check_sbom the SBOM's purls.`, { retry_after_seconds: BUSY_RETRY_AFTER_SEC }), { ...cors, "Retry-After": String(BUSY_RETRY_AFTER_SEC), Connection: "close" });
181
+ log("WARNING", "mcp_large_body_busy", { slots: LARGE_BODY_SLOTS });
182
+ return { status: 503, refused: "large_body_busy" };
183
+ }
184
+ body = read.body;
185
+ slot = read.slot;
186
+ facts = bodyFacts(body);
187
+ // Past the general cap, the body itself must be what the headers (if any) said: one of
188
+ // LARGE_BODY_TARGETS. A 2025-era client sends no Mcp-Method, so this is where its large body
189
+ // is held to that.
190
+ if (body.length > MAX_BODY_BYTES && !isLargeBodyTarget(facts)) {
191
+ if (slot)
192
+ largeBodies.release();
193
+ return { ...tooLarge(res, cors), body, facts };
143
194
  }
144
- body = read;
145
195
  }
196
+ try {
197
+ return { ...(await relay(req, res, pathname, method, cors, client, body)), facts };
198
+ }
199
+ finally {
200
+ if (slot)
201
+ largeBodies.release();
202
+ }
203
+ }
204
+ // Hands one admitted request to the SDK's handler and streams its answer back.
205
+ async function relay(req, res, pathname, method, cors, client, body) {
146
206
  const headers = new Headers();
147
207
  for (const [k, v] of Object.entries(req.headers)) {
148
208
  if (v === undefined || HOP_BY_HOP.has(k))
@@ -187,7 +247,7 @@ const server = http.createServer((req, res) => {
187
247
  }
188
248
  const method = req.method ?? "GET";
189
249
  const finish = (o) => {
190
- const facts = o.body ? bodyFacts(o.body) : { rpc_method: "(none)" };
250
+ const facts = o.facts ?? (o.body ? bodyFacts(o.body) : { rpc_method: "(none)" });
191
251
  log("INFO", "mcp_request", {
192
252
  method,
193
253
  path: pathname === MCP_PATH || pathname === HEALTH_PATH ? pathname : "(other)",
@@ -235,6 +295,8 @@ server.listen(PORT, () => {
235
295
  forward_token_configured: forwardToken() !== "",
236
296
  rate_limit_per_min: RATE_LIMIT_PER_MIN,
237
297
  max_body_bytes: MAX_BODY_BYTES,
298
+ max_sbom_body_bytes: MAX_SBOM_BODY_BYTES,
299
+ large_body_slots: LARGE_BODY_SLOTS,
238
300
  });
239
301
  });
240
302
  const shutdown = () => {
@@ -10,6 +10,7 @@ import { isIP } from "node:net";
10
10
  export const ORIGIN_REFUSED_CODE = -32000; // the SDK's own code for an Origin refusal (originValidationResponse)
11
11
  export const RATE_LIMITED_CODE = -32029; // implementation-defined server error range (-32000..-32099); "429"
12
12
  export const BODY_TOO_LARGE_CODE = -32600; // Invalid Request
13
+ export const BUSY_CODE = -32030; // implementation-defined server error range; "503", no large-body slot free (#2747)
13
14
  export function rpcError(code, message, data) {
14
15
  return JSON.stringify({ jsonrpc: "2.0", id: null, error: { code, message, ...(data ? { data } : {}) } });
15
16
  }
@@ -198,6 +199,80 @@ export class FixedWindowLimiter {
198
199
  }
199
200
  }
200
201
  }
202
+ // ── Request-body caps (#2747) ──
203
+ //
204
+ // Two caps, because one tool's input is a document and every other tool's is a CVE ID or a short
205
+ // search.
206
+ //
207
+ // - 64 KiB for every request: far above any real request but an SBOM's, and the cap the
208
+ // endpoint shipped with (#2316).
209
+ // - 6 MiB for one tools/call of check_sbom, or one prompts/get of sbom_review, which takes the
210
+ // same document to hand to check_sbom (LARGE_BODY_TARGETS). The sbom argument is up to MAX_SBOM_CHARS
211
+ // (5,000,000) characters, and over this endpoint that document IS the JSON-RPC body: JSON
212
+ // text passed as a string is escaped once more (every '"' and newline doubles), which for a
213
+ // pretty-printed CycloneDX document is about 10% (a 4,998,809-character one is a 5,470,320-byte
214
+ // body; test/http.test.mjs holds the cap above it). Below Cloud Run's 32 MiB HTTP/1 request
215
+ // limit. OWASP Juice Shop 11.1.2's 0.74 MB SBOM, which the 64 KiB cap refused, fits eight
216
+ // times over; a 2,000-component one in the same shape is a ~2 MB body.
217
+ //
218
+ // Which request may be large is decided before it is read where the client says so (a
219
+ // 2026-07-28 client sends Mcp-Method and Mcp-Name; another value is held to 64 KiB without
220
+ // reading on), and from the body itself otherwise: a body past 64 KiB that is not one of
221
+ // LARGE_BODY_TARGETS is refused 413 once read.
222
+ //
223
+ // Memory, not the cap, is what bounds this. An instance has 256 MiB and serves up to 250
224
+ // requests at once (deploy-all.sh SVC_MEMORY / SVC_CONCURRENCY); a check_sbom call holds the raw
225
+ // body, its JSON parse here, the SDK's own parse, the tool's parse of the document and the answer.
226
+ // Measured 2026-10-04 on dist/http.js, Node 22, peak RSS (VmHWM) over an idle ~80 MB: one
227
+ // 2,000-purl ~2 MB body +62 MB; one 5.47 MB body +76 MB; two 5.47 MB bodies at once 209 MB in
228
+ // all, also with V8's old space held to 64 MB: ~59 MB of 256 MiB left for everything else, so
229
+ // not a third. So a body past 64 KiB must hold one of DEFAULT_LARGE_BODY_SLOTS (2) slots per
230
+ // instance for as long as it is read and served; with none free it is answered 503 with
231
+ // Retry-After, never queued. The per-client limit still counts each large request as one
232
+ // request, before its body is read.
233
+ export const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
234
+ export const DEFAULT_MAX_SBOM_BODY_BYTES = 6 * 1024 * 1024;
235
+ export const DEFAULT_LARGE_BODY_SLOTS = 2;
236
+ /** The requests whose body may pass DEFAULT_MAX_BODY_BYTES: JSON-RPC method → the one tool or prompt name. */
237
+ export const LARGE_BODY_TARGETS = { "tools/call": "check_sbom", "prompts/get": "sbom_review" };
238
+ /** LARGE_BODY_TARGETS in words, for the refusals. */
239
+ export const LARGE_BODY_TARGETS_TEXT = Object.entries(LARGE_BODY_TARGETS)
240
+ .map(([m, n]) => `a ${m} of ${n}`)
241
+ .join(" or ");
242
+ /** Whether a request's own Mcp-Method / Mcp-Name headers leave it free to be one of LARGE_BODY_TARGETS. Absent headers do (a 2025-era client sends neither). */
243
+ export function largeBodyAdmissible(mcpMethod, mcpName) {
244
+ const name = mcpName?.trim();
245
+ if (mcpMethod === undefined)
246
+ return name === undefined || Object.values(LARGE_BODY_TARGETS).includes(name);
247
+ const want = Object.hasOwn(LARGE_BODY_TARGETS, mcpMethod.trim()) ? LARGE_BODY_TARGETS[mcpMethod.trim()] : undefined;
248
+ return want !== undefined && (name === undefined || name === want);
249
+ }
250
+ /** Whether a read body is one of LARGE_BODY_TARGETS, by its bodyFacts. */
251
+ export function isLargeBodyTarget(facts) {
252
+ const name = facts.rpc_method === "tools/call" ? facts.tool : facts.rpc_method === "prompts/get" ? facts.prompt : undefined;
253
+ return name !== undefined && Object.hasOwn(LARGE_BODY_TARGETS, facts.rpc_method) && LARGE_BODY_TARGETS[facts.rpc_method] === name;
254
+ }
255
+ /** A counting semaphore that never waits: tryAcquire answers at once. */
256
+ export class Slots {
257
+ max;
258
+ used = 0;
259
+ constructor(max) {
260
+ this.max = max;
261
+ }
262
+ tryAcquire() {
263
+ if (this.used >= this.max)
264
+ return false;
265
+ this.used++;
266
+ return true;
267
+ }
268
+ release() {
269
+ if (this.used > 0)
270
+ this.used--;
271
+ }
272
+ get inUse() {
273
+ return this.used;
274
+ }
275
+ }
201
276
  // ── Bounded access-log fields ──
202
277
  //
203
278
  // The access line carries no body, no query string, no argument and no client address: only
@@ -206,7 +281,7 @@ const RPC_METHOD = /^[a-z][a-z/_]{0,39}$/i;
206
281
  const TOOL_NAME = /^[a-z][a-z0-9_]{0,39}$/;
207
282
  const REVISION = /^\d{4}-\d{2}-\d{2}$/;
208
283
  const UA_FAMILY = /^[A-Za-z0-9._-]{1,40}$/;
209
- /** The JSON-RPC method and, for tools/call, the tool name, shape-checked; never anything else from the body. */
284
+ /** The JSON-RPC method and, for tools/call, the tool name (for prompts/get, the prompt name), shape-checked; never anything else from the body. */
210
285
  export function bodyFacts(body) {
211
286
  if (!body || body.length === 0)
212
287
  return { rpc_method: "(none)" };
@@ -226,10 +301,11 @@ export function bodyFacts(body) {
226
301
  return { rpc_method: "(response)" };
227
302
  if (!RPC_METHOD.test(m))
228
303
  return { rpc_method: "(other)" };
229
- if (m !== "tools/call")
304
+ if (m !== "tools/call" && m !== "prompts/get")
230
305
  return { rpc_method: m };
231
306
  const name = parsed.params?.name;
232
- return { rpc_method: m, tool: typeof name === "string" && TOOL_NAME.test(name) ? name : "(other)" };
307
+ const shaped = typeof name === "string" && TOOL_NAME.test(name) ? name : "(other)";
308
+ return m === "tools/call" ? { rpc_method: m, tool: shaped } : { rpc_method: m, prompt: shaped };
233
309
  }
234
310
  export function revisionLabel(v) {
235
311
  if (v === undefined)