echelongraph-mcp 2.6.2 → 2.6.5

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/dist/prompts.js CHANGED
@@ -2,6 +2,16 @@ import * as z from "zod";
2
2
  import { MAX_COMPONENTS, MAX_PURLS, MAX_SBOM_CHARS } from "./tools/check_sbom.js";
3
3
  export const KEV_BRIEF_DEFAULT_DAYS = 7;
4
4
  export const KEV_BRIEF_MAX_DAYS = 365;
5
+ // The page the brief reads kev_recent in (#2799). The brief puts kev_vuln_name and kev_due_date on
6
+ // every line, and a model whose client passes only `content` reads them from the first text block,
7
+ // which past 30,000 characters keeps fewer fields a row (src/tools/kev_recent.ts kevText). Through
8
+ // 2.6.3 the brief asked for limit 200: on 2026-10-04 the hosted endpoint's text for 365 days at 200
9
+ // (306 matching) kept cve_id, kev_added_date, kev_vendor, kev_product and kev_ransomware a row, and
10
+ // neither field. A page of 50, kev_recent's default, is sent whole (27,514 characters of production's
11
+ // 50 newest), and its first cut level, which keeps both, fits 85 production-shaped rows in the
12
+ // budget, so at 50 rows three quarters longer than production's still keep them.
13
+ // test/prompt-fields.test.mjs runs the brief's call at this limit.
14
+ export const KEV_BRIEF_LIMIT = 50;
5
15
  // The envelope rules every prompt carries, word for word.
6
16
  export const ENVELOPE_RULES = [
7
17
  "Before you use any figure, read that result's evidence envelope: its structuredContent (where your client shows text blocks alone, the last text block) carries state, measured_at, method, coverage, freshness and notes.",
@@ -14,22 +24,36 @@ export const ENVELOPE_RULES = [
14
24
  "Do not add facts the results do not carry: where a result says nothing about a point, write that it does not say.",
15
25
  ].join(" ");
16
26
  const text = (t) => ({ messages: [{ role: "user", content: { type: "text", text: t } }] });
27
+ // triage_cve reads kev_due_date from get_cve's record (#2799 review). Through 2.6.3 its step 6 read
28
+ // it from kev_recent with since and until set to the CVE's kev_added_date, with no limit and no
29
+ // paging, so on a day CISA added more than a page the CVE's row could be on no page it read: on the
30
+ // hosted endpoint (2.6.3, 2026-10-04) that call for 2021-11-03 answered total 287, 50 rows from
31
+ // CVE-2010-5326 to CVE-2019-0797 and has_more true, without CVE-2021-40444, added that day; get_cve
32
+ // for CVE-2021-40444 carried kev_due_date 2021-11-17T00:00:00Z in its first text block. The record
33
+ // carries kev_due_date wherever CISA gives one (core-backend cve/store.go, omitempty), and get_cve's
34
+ // first text block keeps every field of it.
35
+ //
36
+ // Its Patch line (#2817): cve_intel's fixed_version is one value per (CVE, ecosystem, package), the
37
+ // advisory's last range's (core-backend cve/advisory.go UpsertAffectedPackages), and triage_cve has
38
+ // no installed version to hold it against: for CVE-2021-44228 production's cve_intel gives
39
+ // log4j-core 2.12.2 (2026-10-04, 2.6.3), below an affected 2.14.1. So the line gives it as
40
+ // what cve_intel records for the package, never as the fix for every affected branch, and sends
41
+ // the reader to get_cve's references tagged Vendor Advisory or Patch for a branch's fix.
17
42
  export function triageCveText(cve) {
18
43
  return [
19
44
  `Triage ${cve} using the EchelonGraph MCP tools. Call them in this order, each with cve_id ${cve}:`,
20
- "1. get_cve: the record. Read score_assessed before echelongraph_score (false: not yet scored, which is not a score of 0); read severity, cvss_v3_score, epss_score and epss_percentile, and the CISA-KEV fields kev_listed, kev_added_date and kev_ransomware.",
45
+ "1. get_cve: the record. Read score_assessed before echelongraph_score (false: not yet scored, which is not a score of 0); read severity, cvss_v3_score, epss_score and epss_percentile, and the CISA-KEV fields kev_listed, kev_added_date, kev_due_date and kev_ransomware.",
21
46
  "2. cve_intel: weaknesses (cwes), public exploit code (exploits and exploits_total; verified_status is the label stored with each reference, not a guarantee that it works, and an empty list is not evidence that no exploit exists), affected_packages and fixed_versions.",
22
47
  "3. vendor_advisories_for_cve: the vendor advisories that name it. Call get_vendor_advisory for one whose remediation you need in full.",
23
48
  "4. epss_history: how its EPSS score changed, one point per recorded change (series_kind change_only): before series_starts_at a missing point means not recorded, not unchanged.",
24
49
  "5. cve_exposure: internet exposure on record. exposure_state says what the count is; exposed_hosts counts distinct ip:port services, not machines; the result is not_assessed with measured_at null, so present any count as undated and on record, and exposure_state not_assessed as not assessed.",
25
- `6. If get_cve says kev_listed is true: kev_recent with since and until both set to the date part (YYYY-MM-DD) of its kev_added_date (for 2024-04-12T00:00:00Z, 2024-04-12), to read kev_due_date from the row for ${cve}.`,
26
50
  ENVELOPE_RULES,
27
51
  "Then answer with this template, one line each, naming the tool and the measured_at behind every line:",
28
52
  "- Exploited? CISA-KEV listing and its date, known ransomware use, and public exploit code on record.",
29
53
  "- Likelihood: EPSS score and percentile, and how it has moved.",
30
54
  "- Reachable? exposure_state and the count on record, or not assessed.",
31
- '- Patch: fixed versions and vendor advisories; where the results hold none, write "none on record", which is not a finding that no fix exists.',
32
- "- Deadline: kev_due_date when the CVE is KEV-listed (CISA's due date for US federal civilian agencies); otherwise write that the results give no CISA deadline.",
55
+ `- Patch: fixed versions and vendor advisories; where the results hold none, write "none on record", which is not a finding that no fix exists. cve_intel keeps one fixed_version per ecosystem and package, its advisory's last range's, which need not be the fix for every affected version of the package: give each as the fixed version cve_intel records for that package, never as the fix for every affected branch or the version every install should move to, and for the fix on a given branch point to get_cve's references tagged Vendor Advisory or Patch, where it has them, and to the vendor advisories.`,
56
+ "- Deadline: get_cve's kev_due_date when the CVE is KEV-listed (CISA's due date for US federal civilian agencies); otherwise write that the results give no CISA deadline.",
33
57
  "- Decision: patch now, schedule, or monitor, with a one-line reason drawn from the lines above.",
34
58
  ].join("\n");
35
59
  }
@@ -37,7 +61,7 @@ export function kevWeeklyBriefText(days) {
37
61
  return [
38
62
  `Write a brief of the CVEs CISA added to its Known Exploited Vulnerabilities (KEV) catalog in the last ${days} days, using the EchelonGraph MCP tools.`,
39
63
  `1. Compute since: today's date in UTC minus ${days} days, as YYYY-MM-DD. State the since date you used, and today's date.`,
40
- "2. Call kev_recent with since set to that date and limit 200. While its coverage.has_more is true, call kev_recent again with the same since and cursor set to the previous answer's next_cursor.",
64
+ `2. Call kev_recent with since set to that date and limit ${KEV_BRIEF_LIMIT}. While its coverage.has_more is true, call kev_recent again with the same since and limit and cursor set to the previous answer's next_cursor.`,
41
65
  ENVELOPE_RULES,
42
66
  "Then write the brief:",
43
67
  "- Header: the date range, the number of CVEs added (total), and measured_at, EchelonGraph's last successful fetch of CISA's feed, with catalog_version.",
@@ -61,15 +85,41 @@ export function amIAffectedText(a) {
61
85
  "Where capped, candidates_capped or degraded is true, say the list may be incomplete and why.",
62
86
  ].join("\n");
63
87
  }
88
+ // sbom_review (#2799). The purls a call did not send: data.not_sent_purls lists them, and where the
89
+ // first text block cuts that list to its first entries (check_sbom's TEXT CUT says so), the model
90
+ // rebuilds them from the document, from the position check_sbom's note gives, in the order it
91
+ // gives (check_sbom.ts READ_ORDER).
92
+ //
93
+ // Fixed versions (#2799 review, #2817). cve_intel's fixed_version is one value per (CVE, ecosystem,
94
+ // package), the advisory's last range's (core-backend cve/advisory.go UpsertAffectedPackages), so
95
+ // it need not be the fix for the installed version's branch: for CVE-2021-44228, production's
96
+ // cve_intel gives log4j-core 2.12.2 (2026-10-04, 2.6.3), older than an installed 2.14.1, and
97
+ // check_sbom's matches for 2.14.1 say it "is listed verbatim in the advisory's affected versions",
98
+ // which names no fix. So a fixed version is given only where it is strictly greater than the
99
+ // installed version, and otherwise the line says the results give none for the component's branch
100
+ // and points to the advisory. The advisory interval in a match's match_reason, "[A, B)" (core-backend
101
+ // cve/pkgmatch.go evaluateRegistryRow), is preferred where the text keeps it (its first cut level;
102
+ // a Juice Shop SBOM of 50 components is cut past it); B is the interval's fixed bound, or its
103
+ // last_affected one, which the text does not tell apart, so B is given as the interval's end.
104
+ // A cut text keeps index, purl, verdict, not_assessed_reason and cve_ids on a row, not its
105
+ // ecosystem, package or version, so the model reads those from the purl, as core-backend's
106
+ // matcher does (cve/matchbatch.go purlNameVersion, shared/pkg/purlosv): step 2 lists every type
107
+ // purlosv maps (its unversioned table, and deb, apk, alpine and rpm by the distro qualifier, of
108
+ // the three kinds osvDistro reads, Alpine cut to major.minor and Debian to its major), the types
109
+ // purlNameVersion names with their namespace, and says any other is unknown, with no fixed version.
110
+ // test/prompt-fields.test.mjs fromPurl holds the same tables.
64
111
  export function sbomReviewText(sbom) {
65
112
  return [
66
113
  "Review the SBOM below for known vulnerabilities, using the EchelonGraph MCP tools.",
67
- `1. Call check_sbom with sbom set to the document below, as given. It reads the document's purls and checks up to ${MAX_PURLS} distinct purls per call, sending them in batches of ${MAX_COMPONENTS}; a document with more is refused, not truncated. If it is refused, say why from the result and do not guess at the components. If coverage.not_sent is above 0, the API's rate limit or the call's time budget stopped it early: say how many purls were not sent and coverage.not_sent_reason, list them as not checked (never as clean), and call check_sbom again with purls set to data.not_sent_purls once, after a minute, merging what it returns.`,
68
- "2. For each distinct cve_id on a component whose verdict is affected, call get_cve, to read kev_listed, epss_score, score_assessed, echelongraph_score and cvss_v3_score. If there are more than 30 distinct CVEs, do this for 30 of them and list the rest as not looked up, unranked.",
114
+ `1. Call check_sbom with sbom set to the document below, as given. It reads the document's purls and checks up to ${MAX_PURLS} distinct purls per call, sending them in batches of ${MAX_COMPONENTS}; a document with more is refused, not truncated. If it is refused, say why from the result and do not guess at the components. If coverage.not_sent is above 0, the API's rate limit or the call's time budget stopped it early: say how many purls were not sent and coverage.not_sent_reason, and that they are not checked (never clean). After a minute, call check_sbom once more with purls set to the purls not sent, and merge what it returns. data.not_sent_purls lists them; where the note says the first text block cuts not_sent_purls to its first entries, rebuild the list from the document instead, as the note says: the document's distinct purls from the position the note gives on, counted in the order it gives.`,
115
+ "2. Read each component's ecosystem, package and version from its purl, which every row of data.results in the text carries (the leanest cut of the text leaves out the row's own ecosystem, package and version), as check_sbom maps them. A purl is pkg:type/namespace/name@version?qualifiers, each part percent-decoded (%40 is @), its type and distro qualifier in any case. The version is what follows the last @, up to any ? or #. The ecosystem follows from the type: npm is npm, pypi PyPI, maven Maven, golang Go, cargo crates.io, gem RubyGems, nuget NuGet, composer Packagist, hex Hex, pub Pub, swift SwiftURL, hackage Hackage, cran CRAN, bitnami Bitnami, conan ConanCenter and githubact GitHub Actions. For deb, apk, alpine and rpm it is the release the distro qualifier names, which must be one of these three kinds: distro=debian-N is Debian:N, the major release alone (debian-12 and debian-12.5 are Debian:12); distro=ubuntu-V is Ubuntu:V as given (ubuntu-22.04 is Ubuntu:22.04); distro=alpine-X.Y is Alpine:vX.Y, cut to major.minor (alpine-3.20 and alpine-3.20.10 are Alpine:v3.20; alpine-3 names none). For any other type, and a deb, apk, alpine or rpm purl without such a qualifier, the ecosystem is unknown: check_sbom does not check the component (not_assessed, never clean), and the fix list gives no version for it. The package is namespace:name for maven (pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1 is the package org.apache.logging.log4j:log4j-core in Maven, at version 2.14.1), namespace/name for npm, golang, composer, swift and githubact, and the name alone for every other type (pkg:deb/debian/openssl@3.0.15-1~deb12u1?distro=debian-12 is openssl in Debian:12).",
116
+ "3. For each distinct cve_id on a component whose verdict is affected, call get_cve, to read kev_listed, epss_score, score_assessed, echelongraph_score and cvss_v3_score, and cve_intel, to read fixed_version from its affected_packages and fixed_versions rows for the component's ecosystem and package (package_name). If there are more than 30 distinct CVEs, do this for 30 of them and list the rest as not looked up, unranked.",
69
117
  ENVELOPE_RULES,
70
118
  "Read each component's verdict: not_affected is the one clean verdict; undetermined and not_assessed are not clean, and not_assessed_reason says why (for example distro_release_unknown: a deb, apk or rpm purl without a distro qualifier, which EchelonGraph does not guess).",
71
119
  "Then write a prioritised fix list of the affected components. State the ordering keys, in this order: 1. CISA-KEV listed (kev_listed true) first; 2. then EPSS score, highest first; 3. then echelongraph_score where score_assessed is true, else cvss_v3_score, highest first. A CVE with no value for a key sorts after those with one, and its line says which key was missing.",
72
- "For each line give the component (purl), its cve_ids, the key values used, and fixed versions where a result gives them. After the list, give data.summary's counts, and list the undetermined and not_assessed components with their reasons as not checked, never as clean.",
120
+ "For each line give the component (purl), its cve_ids, the key values used, and for each CVE a fixed version where the results support one, by this rule. Never give a fixed version that is not strictly greater than the component's installed version (the version in its purl), compared in the ecosystem's version order: release numbers part by part, as numbers, not as text (2.9.1 is below 2.12.2, and 2.12.2 is below 2.14.1); where you are not sure of the order (pre-releases, qualifiers and distro revisions have their own rules), say so and do not give it. Prefer the match's match_reason where the text keeps it: \"falls inside the advisory interval [A, B)\" says the advisory's range that holds the installed version ends at B, which match_reason does not say is a fixed version rather than the range's last affected version, so give B as the end of that interval, quoting it (∞: the range has no end on record). Otherwise give cve_intel's fixed_version for the component's ecosystem and package (or the recorded fix a match_reason names, the same value): cve_intel keeps one fixed_version per package, its advisory's last range's, which need not be the fix on the component's branch, so say so beside it. Where no version meets the rule (\"listed verbatim in the advisory's affected versions\" names none), write that the results give no fixed version for the component's branch, which is not a finding that no fix exists, and point to the CVE's advisory for it: get_cve's references tagged Vendor Advisory or Patch, where it has them.",
121
+ "After the list, give data.summary's counts, and list the undetermined and not_assessed components with their reasons as not checked, never as clean.",
122
+ "Where the note says that rows of data.results are left out of the first text block, list the components its rows carry, say how many affected components the text leaves out (data.summary.affected, less those listed), and give the undetermined and not_assessed components it leaves out as data.summary's counts, by not_assessed_reason, as not checked, never as clean.",
73
123
  "",
74
124
  "SBOM:",
75
125
  "```json",
@@ -80,7 +130,7 @@ export function sbomReviewText(sbom) {
80
130
  export function registerPrompts(server, kit) {
81
131
  server.registerPrompt("triage_cve", {
82
132
  title: "Triage a CVE",
83
- description: "Triage one CVE: get_cve, cve_intel, vendor_advisories_for_cve, epss_history and cve_exposure (and kev_recent for the due date when the CVE is KEV-listed), then a decision template: exploited, likelihood, reachable, patch, deadline. Each line cites its tool and measured_at, and not_assessed is never reported as clean.",
133
+ description: "Triage one CVE: get_cve (with CISA's due date when the CVE is KEV-listed), cve_intel, vendor_advisories_for_cve, epss_history and cve_exposure, then a decision template: exploited, likelihood, reachable, patch, deadline. Each line cites its tool and measured_at, and not_assessed is never reported as clean.",
84
134
  argsSchema: z.object({
85
135
  cve_id: z
86
136
  .string()
@@ -125,7 +175,7 @@ export function registerPrompts(server, kit) {
125
175
  }, (a) => text(amIAffectedText(a)));
126
176
  server.registerPrompt("sbom_review", {
127
177
  title: "SBOM review",
128
- description: `Review an SBOM (CycloneDX JSON or SPDX JSON text, up to ${MAX_SBOM_CHARS} characters): it is passed to check_sbom, which checks up to ${MAX_PURLS} distinct purls per call in batches of ${MAX_COMPONENTS}, then get_cve for each affected CVE, and a prioritised fix list ordered by CISA-KEV listing, then EPSS, then score, each key stated. undetermined and not_assessed components are listed as not checked, never as clean.`,
178
+ description: `Review an SBOM (CycloneDX JSON or SPDX JSON text, up to ${MAX_SBOM_CHARS} characters): it is passed to check_sbom, which checks up to ${MAX_PURLS} distinct purls per call in batches of ${MAX_COMPONENTS}, then get_cve and cve_intel for each affected CVE, and a prioritised fix list ordered by CISA-KEV listing, then EPSS, then score, each key stated. undetermined and not_assessed components are listed as not checked, never as clean.`,
129
179
  argsSchema: z.object({
130
180
  sbom: z
131
181
  .string()
package/dist/resources.js CHANGED
@@ -5,16 +5,19 @@
5
5
  // docs). No API request.
6
6
  // echelongraph://sources the feeds and what the API itself reports about their polling,
7
7
  // read from the API at request time and never hard-coded: the CVE
8
- // summary's poller block (/api/v1/public/cves/summary) and the KEV
8
+ // summary's poller block (/api/v1/public/cves/summary; its interval,
9
+ // last poll and two counters, the answering instance's) and the KEV
9
10
  // answer's catalog block and method (/api/v1/public/kev/recent).
10
11
  // What no endpoint reports is named as not reported.
11
12
  // cve://{cve_id} get_cve's structured result for one CVE, as JSON: the envelope
12
- // (state, measured_at, method, notes) with data, the API's record.
13
+ // (state, measured_at, method, notes) with data, the API's record,
14
+ // cut past 30,000 characters as get_cve's first text block is.
13
15
  //
14
16
  // A resource read either answers or is a JSON-RPC error; it never answers a failure as data that
15
17
  // reads like a record. sources is the exception by design: each feed it reads carries its own
16
18
  // state, and a feed that could not be read says so (state failed), never an empty block.
17
19
  import { ProtocolError, ProtocolErrorCode, ResourceNotFoundError, ResourceTemplate } from "@modelcontextprotocol/server";
20
+ import { DATA_TEXT_BUDGET, dataText } from "./textBudget.js";
18
21
  export const METHODOLOGY_URI = "echelongraph://methodology";
19
22
  export const SOURCES_URI = "echelongraph://sources";
20
23
  export const CVE_TEMPLATE = "cve://{cve_id}";
@@ -22,6 +25,8 @@ export const METHODOLOGY = `# How EchelonGraph MCP results are measured
22
25
 
23
26
  Every tool result carries an evidence envelope in its structuredContent, and repeats it in its last text block (less what an earlier text block already carries verbatim), so a client that passes text blocks alone still sees it.
24
27
 
28
+ A success's first text block is the API's JSON, cut to fit 30,000 characters when it is longer: the note then says what the cut leaves out and how to read it (TEXT CUT), and structuredContent's data holds the answer whole. The resource ${CVE_TEMPLATE} cuts its data the same way, and its notes say so (DATA CUT).
29
+
25
30
  ## Envelope fields
26
31
 
27
32
  | Field | What it holds |
@@ -32,7 +37,7 @@ Every tool result carries an evidence envelope in its structuredContent, and rep
32
37
  | coverage | What the answer covers, where the tool can say: for example in_scope for cve_exposure, the list's account of its own count for search_cves, assessed and not_assessed_reason first for check_affected. |
33
38
  | freshness | The producing radar's or fetcher's last completed check, where the API serves one; null where it serves none. |
34
39
  | notes | The caveats, one sentence each. |
35
- | data | On a success: the API's JSON (exposure_radar and cve_intel relay a labelled selection). |
40
+ | data | On a success: the API's JSON (exposure_radar and cve_intel relay a labelled selection, and cve_summary leaves out a poller field of another JSON type, named in the note). |
36
41
  | error | On a failure: kind, path, status and message. |
37
42
 
38
43
  ## state
@@ -68,9 +73,17 @@ const KEV_PATH = "/api/v1/public/kev/recent";
68
73
  const obj = (v) => (v !== null && typeof v === "object" && !Array.isArray(v) ? v : undefined);
69
74
  const pick = (o, keys) => Object.fromEntries(keys.filter((k) => o !== undefined && o[k] !== undefined).map((k) => [k, o[k]]));
70
75
  const failure = (r) => ({ state: "failed", error: { kind: r.kind, path: r.path, status: r.status ?? null, message: r.detail } });
71
- // What /api/v1/public/cves/summary reports about the NVD poller: its interval and last poll, of
72
- // the server instance that answered (core-backend cve/poller.go Stats, in memory).
76
+ // A JSON value's type in words, for a value that is not the JSON object read here.
77
+ const jsonKind = (v) => Array.isArray(v) ? "an array" : typeof v === "number" ? "a number" : typeof v === "boolean" ? "a boolean" : typeof v === "string" ? "a string" : typeof v;
78
+ // What /api/v1/public/cves/summary reports about the NVD poller: its interval and last poll, and
79
+ // its poll and failed-poll counts, of the server instance that answered (core-backend
80
+ // cve/poller.go Stats, in memory).
73
81
  const POLLER_FIELDS = ["interval", "last_poll_at", "poll_count", "poll_errors"];
82
+ // #2770: poll_count and poll_errors are that instance's counters since it last started, zeroed on
83
+ // every restart (cve_summary's summaryPollerNote, #2647, says the same of the whole block). 2.6.3
84
+ // relayed them here with a note on interval and last_poll_at alone, so poll_errors 0 over
85
+ // poll_count 7 read as the feed's track record. Whenever reported carries either, notes says so.
86
+ const SOURCES_POLLER_COUNTERS_NOTE = "poll_count and poll_errors, where reported carries them, are that one instance's counters since it last started, zeroed on every restart: they count that instance's polls and failed polls, never the feed's reliability.";
74
87
  const CATALOG_FIELDS = ["source", "feed_url", "last_successful_fetch_at", "catalog_version", "date_released", "catalog_count"];
75
88
  export async function readSources(kit) {
76
89
  const [summary, kev] = await Promise.all([kit.api(SUMMARY_PATH), kit.api(KEV_PATH, { headers: { "X-EG-Limit": "1" } })]);
@@ -78,16 +91,29 @@ export async function readSources(kit) {
78
91
  if (!summary.ok)
79
92
  Object.assign(nvd, failure(summary));
80
93
  else {
81
- const poller = obj(obj(summary.data)?.poller);
82
- if (poller === undefined) {
94
+ const sent = obj(summary.data)?.poller;
95
+ const poller = obj(sent);
96
+ if (poller === undefined && sent !== undefined && sent !== null) {
97
+ // #2770's review: a poller of another JSON type (cve_summary leaves it out and names it,
98
+ // #2771) is one the answer carries, so it is never described as missing.
99
+ Object.assign(nvd, {
100
+ state: "not_reported",
101
+ reported: null,
102
+ notes: [`The answer carries poller as ${jsonKind(sent)}, where a JSON object was expected, so it reports neither a poll interval nor a last poll time.`],
103
+ });
104
+ }
105
+ else if (poller === undefined) {
83
106
  Object.assign(nvd, { state: "not_reported", reported: null, notes: ["The answer carries no poller block, so it reports neither a poll interval nor a last poll time."] });
84
107
  }
85
108
  else {
109
+ const reported = pick(poller, POLLER_FIELDS);
110
+ const counted = reported.poll_count !== undefined || reported.poll_errors !== undefined;
86
111
  Object.assign(nvd, {
87
112
  state: "reported",
88
- reported: pick(poller, POLLER_FIELDS),
113
+ reported,
89
114
  notes: [
90
115
  "poller is the NVD poller of the server instance that answered, held in its memory: interval is its configured interval (each cycle adds a start-up jitter), and last_poll_at is that instance's last poll, not a fleet-wide time.",
116
+ ...(counted ? [SOURCES_POLLER_COUNTERS_NOTE] : []),
91
117
  ],
92
118
  });
93
119
  }
@@ -119,6 +145,31 @@ export async function readSources(kit) {
119
145
  };
120
146
  }
121
147
  const json = (uri, v) => ({ contents: [{ uri, mimeType: "application/json", text: JSON.stringify(v, null, 2) }] });
148
+ // cve://{cve_id}'s text (#2801). Through 2.6.3 it was get_cve's structuredContent pretty-printed
149
+ // whole: 86,559 characters for CVE-2021-44228 on the hosted endpoint (2026-10-04), past #2467's
150
+ // ceiling of 60,000 (test/text-bound.mjs), its notes ending with get_cve's TEXT CUT sentences, which
151
+ // describe a first text block the resource does not have ("Every list in it keeps at most its first
152
+ // 64 entries: cpe_match (396)…" beside all 396 of them). A resource is one text, with no
153
+ // structuredContent beside it, so its data is now cut as get_cve's first text block is (dataText:
154
+ // the same answer, cut the same way, laid out as that block lays it out) after the envelope,
155
+ // pretty-printed as before; its notes leave out get_cve's sentences about its first text block and,
156
+ // when data is cut, end with the resource's own, starting DATA CUT, which names get_cve's
157
+ // structuredContent.data as the record whole. A record that fits is the structured result
158
+ // pretty-printed, character for character as before.
159
+ const CVE_RESOURCE_CUT = {
160
+ opens: (chars) => `DATA CUT: the API's answer is ${chars} characters of JSON, more than the ${DATA_TEXT_BUDGET} this resource's data holds, so data here is cut, and get_cve's structuredContent.data carries the answer whole.`,
161
+ };
162
+ export function cveResourceText(structured, sentences) {
163
+ const { data, ...envelope } = structured;
164
+ if (data === null || typeof data !== "object" || Array.isArray(data))
165
+ return JSON.stringify(structured, null, 2);
166
+ const firstBlock = new Set(sentences(dataText(data).said));
167
+ const own = dataText(data, undefined, CVE_RESOURCE_CUT);
168
+ const notes = Array.isArray(envelope.notes) ? [...envelope.notes.filter((n) => !firstBlock.has(n)), ...sentences(own.said)] : envelope.notes;
169
+ const head = JSON.stringify({ ...envelope, ...(notes === undefined ? {} : { notes }) }, null, 2);
170
+ const body = own.text.replace(/\n/g, "\n ");
171
+ return head === "{}" ? `{\n "data": ${body}\n}` : `${head.slice(0, -2)},\n "data": ${body}\n}`;
172
+ }
122
173
  export function registerResources(server, kit) {
123
174
  server.registerResource("methodology", METHODOLOGY_URI, {
124
175
  title: "How results are measured",
@@ -127,12 +178,12 @@ export function registerResources(server, kit) {
127
178
  }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: METHODOLOGY }] }));
128
179
  server.registerResource("sources", SOURCES_URI, {
129
180
  title: "Feed sources and polling",
130
- description: "The feeds and what the API reports about their polling, read from the API when the resource is read: the NVD poller's interval and last poll (of the instance that answered) and the CISA KEV catalog's last successful fetch and method. What no endpoint reports is named as not reported.",
181
+ description: "The feeds and what the API reports about their polling, read from the API when the resource is read: the NVD poller's interval, last poll, poll_count and poll_errors, all of the one instance that answered (the two counts since that instance last started, zeroed on every restart, never the feed's reliability), and the CISA KEV catalog's last successful fetch and method. What no endpoint reports is named as not reported.",
131
182
  mimeType: "application/json",
132
183
  }, async (uri) => json(uri.href, await readSources(kit)));
133
184
  server.registerResource("cve", new ResourceTemplate(CVE_TEMPLATE, { list: undefined }), {
134
185
  title: "CVE record",
135
- description: "One CVE's record as get_cve returns it, as JSON: the evidence envelope (state, measured_at, method, notes) with data, the API's record. cve_id is a CVE ID such as CVE-2024-3400.",
186
+ description: "One CVE's record as get_cve returns it, as JSON: the evidence envelope (state, measured_at, method, notes) with data, the API's record. Past 30,000 characters of JSON, data is cut as get_cve's first text block is, and a note starting DATA CUT says what the cut leaves out; get_cve's structured result holds the record whole. cve_id is a CVE ID such as CVE-2024-3400.",
136
187
  mimeType: "application/json",
137
188
  }, async (uri, vars) => {
138
189
  const raw = Array.isArray(vars.cve_id) ? vars.cve_id[0] : vars.cve_id;
@@ -148,6 +199,6 @@ export function registerResources(server, kit) {
148
199
  throw new ResourceNotFoundError(uri.href);
149
200
  throw new ProtocolError(ProtocolErrorCode.InternalError, msg, { uri: uri.href, state: r.structuredContent.state ?? null, error: err ?? null });
150
201
  }
151
- return json(uri.href, r.structuredContent);
202
+ return { contents: [{ uri: uri.href, mimeType: "application/json", text: cveResourceText(r.structuredContent, kit.sentences) }] };
152
203
  });
153
204
  }
package/dist/runtime.js CHANGED
@@ -18,12 +18,34 @@ export function markHttpEntrypoint() {
18
18
  export function isHttpEntrypoint() {
19
19
  return httpEntrypoint;
20
20
  }
21
- // The client a tool call is being answered for. http.ts runs each request inside
22
- // withClient(), and the SDK dispatches the tool handler inside that same async context, so
23
- // api() can read it without the tool module passing it through every function.
24
21
  const clientStore = new AsyncLocalStorage();
25
22
  export function withClient(clientIp, fn, opts = {}) {
26
- return clientStore.run({ clientIp, synthetic: opts.synthetic === true }, fn);
23
+ return clientStore.run({ clientIp, synthetic: opts.synthetic === true, signal: opts.signal, work: opts.work }, fn);
24
+ }
25
+ // #2775: a hosted client can go before its answer is ready (its own timeout, a closed tab, a
26
+ // retry). Nobody will read that answer, so the request's API calls are cancelled (index.ts api()
27
+ // reads this signal) and check_sbom sends no further batch: they would spend the client's API
28
+ // budget and hold the instance's memory for nothing. On stdio there is none: the SDK's own
29
+ // cancellation (notifications/cancelled) reaches check_sbom through its handler's signal.
30
+ export function requestSignal() {
31
+ return httpEntrypoint ? clientStore.getStore()?.signal : undefined;
32
+ }
33
+ /** Registers `p` as the hosted request's work, so its admission and large-body slot are held until p settles; on stdio, p untouched. */
34
+ export function holdRequest(p) {
35
+ const work = httpEntrypoint ? clientStore.getStore()?.work : undefined;
36
+ if (!work)
37
+ return p;
38
+ work.add(p);
39
+ const drop = () => {
40
+ work.delete(p);
41
+ };
42
+ p.then(drop, drop);
43
+ return p;
44
+ }
45
+ /** Resolves once everything held for a request (holdRequest) has settled, including what is held while it waits. */
46
+ export async function requestSettled(work) {
47
+ while (work.size > 0)
48
+ await Promise.allSettled([...work]);
27
49
  }
28
50
  // #2737: the production MCP synthetic probes this endpoint too, and its calls must reach the API
29
51
  // under its own family, never as hosted MCP adoption. The synthetic names itself to this service
@@ -0,0 +1,254 @@
1
+ // The text budget (#2783): what a success's first text block may cost, and how an answer larger
2
+ // than that is cut. index.ts's `succeeded` builds every success's first text block with dataText;
3
+ // a tool that returns a list of rows names it with a TextCut. structuredContent.data is never cut.
4
+ // A module of its own, so a tool module can import its types and TEXT_BUDGET_DESCRIPTION without
5
+ // importing index.ts, which starts the stdio server when loaded.
6
+ //
7
+ // What a success costs in the channel a model reads is mostly its first text block, and through
8
+ // 2.6.2 that block was the API's JSON pretty-printed, whatever its size. Measured on production
9
+ // through https://mcp.echelongraph.io/mcp (2.6.2, 2026-10-04): search_cves {search: "openssl"} at
10
+ // its default limit was 133,300 characters of text, get_cve CVE-2021-44228 80,897, check_affected
11
+ // openssl 3.0.0 156,058 and linux_kernel 5.10.0 364,408, kev_recent at limit 200 105,925 and
12
+ // search_vendor_advisories "log4j" at limit 50 103,147, against #2467's ceiling of 60,000
13
+ // (mcp-server/test/text-bound.mjs). Claude Code warns past 10,000 tokens of tool output and past
14
+ // 25,000 saves the result to a file and hands the model its path instead; Anthropic's guidance to
15
+ // tool authors ("Writing effective tools for agents") is pagination, filtering and truncation with
16
+ // sensible defaults, steering the agent when a response is cut. The MCP spec (2025-06-18 and
17
+ // 2025-11-25, Tools, Structured Content) asks a tool that returns structuredContent to return it
18
+ // serialized in a text block too, as a SHOULD, for backwards compatibility, and SEP-1624 proposes
19
+ // content as the model-facing, token-efficient form and structuredContent as the machine-facing one.
20
+ //
21
+ // So structuredContent.data is always the API's JSON whole, and the first text block is that JSON
22
+ // when it fits DATA_TEXT_BUDGET, half the ceiling, and cut when it does not:
23
+ // 1. pretty-printed, as since 1.x, when that fits;
24
+ // 2. else the same JSON with each element of a list on a line of its own, which parses to the
25
+ // same value, when that fits;
26
+ // 3. else, for a tool that names its list of rows (TextCut), each row cut as the tool's first
27
+ // level says (the fields it keeps, every string in it to its first `clip` characters, ending
28
+ // in "…", every list in it to its first `cap` entries); then, for a tool that names the
29
+ // lists beside its rows (`sides`: check_affected's excluded sample, check_sbom's
30
+ // not_sent_purls), the same level with those lists cut as it says; then each next level with
31
+ // them cut; and when no level fits, rows are left out of the text, those the tool names
32
+ // first, then from the end. A list beside the rows is cut before any row field the first
33
+ // level keeps, and before any row leaves the text;
34
+ // 4. else every list keeps its first n entries, n halving, and then strings are cut too.
35
+ // Whenever the text is not the JSON whole, the note ends with one sentence, starting TEXT CUT,
36
+ // that says what the first text block leaves out, that structuredContent.data carries all of it,
37
+ // and how to read the rest in the text: the tool that returns one row, and what of it, and the page
38
+ // size or offset at which a page fits. The cut is deterministic: the same answer is cut the same way.
39
+ export const DATA_TEXT_BUDGET = 30_000;
40
+ // What a note may say of reading one CVE's record through get_cve (#2802): get_cve's own first text
41
+ // block is cut too past the budget (each list to its first entries, every other field kept), so
42
+ // only its structuredContent.data is the record whole. A row tool's `whole` sentence starts with it.
43
+ export const GET_CVE_WHOLE = "get_cve returns any one of these CVEs' records, 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)";
44
+ // What every tool description says about it, one constant string (marketing-site
45
+ // lib/mcpToolClaims.test.ts folds only constant strings); the suite holds its number to
46
+ // DATA_TEXT_BUDGET's. It uses none of the words that check reviews as the page's own prose (how,
47
+ // when, does, have, on and the rest of its PROSE list), so no reviewed entry goes stale.
48
+ export const TEXT_BUDGET_DESCRIPTION = "Past 30,000 characters of JSON, the first text block holds data cut to fit, and the note says what the cut leaves out and where to read it (TEXT CUT); data in the structured result always holds it whole.";
49
+ const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
50
+ const field = (o, k) => (isPlainObject(o) ? o[k] : undefined);
51
+ const listed = (xs) => (xs.length < 2 ? xs.join("") : `${xs.slice(0, -1).join(", ")} and ${xs[xs.length - 1]}`);
52
+ // The JSON with each element of a list of objects, at the top or one level down, on a line of its
53
+ // own: the same value as JSON.stringify(data), without the indentation of every nested key.
54
+ function lined(v) {
55
+ const entry = (x) => Array.isArray(x) && x.some((e) => e !== null && typeof e === "object") ? `[\n${x.map((e) => ` ${JSON.stringify(e)}`).join(",\n")}\n ]` : JSON.stringify(x);
56
+ return `{\n${Object.entries(v)
57
+ .map(([k, x]) => ` ${JSON.stringify(k)}: ${entry(x)}`)
58
+ .join(",\n")}\n}`;
59
+ }
60
+ // A string's first n characters and "…", never splitting a surrogate pair.
61
+ const clipString = (s, n) => {
62
+ const end = n > 0 && /[\uD800-\uDBFF]/.test(s[n - 1]) ? n - 1 : n;
63
+ return `${s.slice(0, end)}…`;
64
+ };
65
+ function cutValue(v, clip, cap, s) {
66
+ if (typeof v === "string") {
67
+ if (v.length <= clip)
68
+ return v;
69
+ s.clipped++;
70
+ return clipString(v, clip);
71
+ }
72
+ if (Array.isArray(v)) {
73
+ if (v.length > cap)
74
+ s.capped++;
75
+ return v.slice(0, cap).map((x) => cutValue(x, clip, cap, s));
76
+ }
77
+ if (isPlainObject(v))
78
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, cutValue(x, clip, cap, s)]));
79
+ return v;
80
+ }
81
+ function cutRow(row, keep, level, s, prefix = "") {
82
+ const wanted = new Map(keep.map((k) => (typeof k === "string" ? [k, null] : [k[0], k[1]])));
83
+ const cap = level.cap ?? Number.POSITIVE_INFINITY;
84
+ const out = {};
85
+ for (const [k, v] of Object.entries(row)) {
86
+ if (!wanted.has(k)) {
87
+ s.left.add(`${prefix}${k}`);
88
+ continue;
89
+ }
90
+ const sub = wanted.get(k);
91
+ if (sub && Array.isArray(v)) {
92
+ if (v.length > cap)
93
+ s.capped++;
94
+ out[k] = v.slice(0, cap).map((e) => (isPlainObject(e) ? cutRow(e, sub, level, s, `${prefix}${k}[].`) : cutValue(e, level.clip, cap, s)));
95
+ }
96
+ else {
97
+ out[k] = cutValue(v, level.clip, cap, s);
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+ // Step 4: every list to its first n entries (and, with clip, every string to its first clip
103
+ // characters), recording the longest list cut at each path.
104
+ function capAll(v, n, clip, caps, s, path) {
105
+ if (Array.isArray(v)) {
106
+ if (v.length > n)
107
+ caps.set(path, Math.max(caps.get(path) ?? 0, v.length));
108
+ return v.slice(0, n).map((x) => capAll(x, n, clip, caps, s, `${path}[]`));
109
+ }
110
+ if (isPlainObject(v))
111
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, capAll(x, n, clip, caps, s, path ? `${path}.${k}` : k)]));
112
+ return cutValue(v, clip, Number.POSITIVE_INFINITY, s);
113
+ }
114
+ const CAP_LEVELS = [
115
+ ...[128, 64, 32, 16, 8, 4, 2, 1].map((n) => ({ n, clip: Number.POSITIVE_INFINITY })),
116
+ { n: 1, clip: 200 },
117
+ { n: 0, clip: 50 },
118
+ ];
119
+ // The sentence for a level of a TextCut: what each row leaves out.
120
+ function levelSaid(rows, n, level, s) {
121
+ const parts = [];
122
+ if (s.left.size)
123
+ parts.push(`each of the ${n} rows of ${rows} in it leaves out ${listed([...s.left])}`);
124
+ if (s.clipped)
125
+ parts.push(`every string in a row longer than ${level.clip} characters keeps its first ${level.clip}, ending in "…"`);
126
+ if (s.capped && level.cap !== undefined)
127
+ parts.push(`every list in a row longer than ${level.cap} entries keeps its first ${level.cap}`);
128
+ if (!parts.length)
129
+ return "";
130
+ const said = parts.join("; ");
131
+ return `${said[0].toUpperCase()}${said.slice(1)}.`;
132
+ }
133
+ // The lists beside the rows, each cut as its TextSide says, and the sentences saying how: what its
134
+ // entries leave out, and how many of them the text keeps, then the tool's own sentence.
135
+ function cutSides(data, sides) {
136
+ const fields = {};
137
+ const said = [];
138
+ for (const side of sides) {
139
+ const v = data[side.list];
140
+ if (!Array.isArray(v) || v.length === 0)
141
+ continue;
142
+ const clip = side.clip ?? Number.POSITIVE_INFINITY;
143
+ const cap = side.cap ?? v.length;
144
+ const s = { left: new Set(), clipped: 0, capped: 0 };
145
+ const level = { keep: side.keep ?? [], clip };
146
+ const kept = v.slice(0, cap).map((e) => (side.keep && isPlainObject(e) ? cutRow(e, side.keep, level, s) : cutValue(e, clip, Number.POSITIVE_INFINITY, s)));
147
+ fields[side.list] = kept;
148
+ const parts = [];
149
+ if (kept.length < v.length)
150
+ parts.push(`in it, ${side.list} keeps its first ${kept.length} of ${v.length} entries`);
151
+ if (s.left.size)
152
+ parts.push(`each entry of ${side.list} in it leaves out ${listed([...s.left])}`);
153
+ if (s.clipped)
154
+ parts.push(`every string in ${side.list} longer than ${clip} characters keeps its first ${clip}, ending in "…"`);
155
+ if (!parts.length)
156
+ continue;
157
+ const one = parts.join("; ");
158
+ said.push(`${one[0].toUpperCase()}${one.slice(1)}.`);
159
+ const own = side.said?.(kept.length, v.length, data);
160
+ if (own)
161
+ said.push(own);
162
+ }
163
+ return { fields, said };
164
+ }
165
+ export const FIRST_TEXT_BLOCK = {
166
+ opens: (chars) => `TEXT CUT: the API's answer is ${chars} characters of JSON, more than the ${DATA_TEXT_BUDGET} the first text block holds, so that block is cut, and structuredContent.data carries the answer whole.`,
167
+ };
168
+ /** The first text block for `data` within DATA_TEXT_BUDGET, and the TEXT CUT sentence when it is cut. */
169
+ export function dataText(data, cut, place = FIRST_TEXT_BLOCK) {
170
+ const pretty = JSON.stringify(data, null, 2);
171
+ if (pretty.length <= DATA_TEXT_BUDGET)
172
+ return { text: pretty, said: "" };
173
+ const layout = (v) => (isPlainObject(v) ? lined(v) : JSON.stringify(v));
174
+ const all = layout(data);
175
+ if (all.length <= DATA_TEXT_BUDGET)
176
+ return { text: all, said: "" };
177
+ const head = place.opens(pretty.length);
178
+ const say = (...xs) => [head, ...xs].filter((x) => Boolean(x)).join(" ");
179
+ let base = data;
180
+ let rowsSaid = "";
181
+ let sidesSaid = [];
182
+ const rows = cut && isPlainObject(data) ? data[cut.rows] : undefined;
183
+ if (cut && isPlainObject(data) && Array.isArray(rows) && rows.length > 0 && cut.levels.length > 0) {
184
+ const n = rows.length;
185
+ // The lists beside the rows, cut once; tried with the first level after it fails with them
186
+ // whole, and with every later level.
187
+ const sides = cut.sides?.length ? cutSides(data, cut.sides) : { fields: {}, said: [] };
188
+ const sided = sides.said.length > 0;
189
+ const attempts = cut.levels.flatMap((level, i) => (i === 0 && sided ? [{ level, sided: false }, { level, sided: true }] : [{ level, sided }]));
190
+ let cutRows = [];
191
+ let beside = data;
192
+ for (const attempt of attempts) {
193
+ const { level } = attempt;
194
+ const s = { left: new Set(), clipped: 0, capped: 0 };
195
+ const cap = level.cap ?? Number.POSITIVE_INFINITY;
196
+ cutRows = rows.map((r) => (isPlainObject(r) ? cutRow(r, level.keep, level, s) : cutValue(r, level.clip, cap, s)));
197
+ rowsSaid = levelSaid(cut.rows, n, level, s);
198
+ beside = attempt.sided ? { ...data, ...sides.fields } : data;
199
+ sidesSaid = attempt.sided ? sides.said : [];
200
+ const shown = lined({ ...beside, [cut.rows]: cutRows });
201
+ if (shown.length <= DATA_TEXT_BUDGET)
202
+ return { text: shown, said: say(rowsSaid, ...sidesSaid, cut.whole) };
203
+ }
204
+ // No level fits: rows leave the text, those the tool names first (by value, in its order,
205
+ // each from the end), then the rest from the end, until it fits.
206
+ const lens = cutRows.map((r) => 4 + JSON.stringify(r).length);
207
+ const emptyLen = lined({ ...beside, [cut.rows]: [] }).length;
208
+ const textLen = (kept, sum) => (kept === 0 ? emptyLen : emptyLen + 2 + sum + 2 * kept);
209
+ const firstOut = cut.leaveOutFirst;
210
+ const order = [];
211
+ for (const value of firstOut?.values ?? []) {
212
+ for (let i = n - 1; i >= 0; i--)
213
+ if (field(rows[i], firstOut?.field ?? "") === value)
214
+ order.push(i);
215
+ }
216
+ const queued = new Set(order);
217
+ for (let i = n - 1; i >= 0; i--)
218
+ if (!queued.has(i))
219
+ order.push(i);
220
+ const out = new Set();
221
+ let sum = lens.reduce((t, x) => t + x, 0);
222
+ for (const i of order) {
223
+ if (textLen(n - out.size, sum) <= DATA_TEXT_BUDGET)
224
+ break;
225
+ out.add(i);
226
+ sum -= lens[i];
227
+ }
228
+ const kept = cutRows.filter((_, i) => !out.has(i));
229
+ const shown = lined({ ...beside, [cut.rows]: kept });
230
+ if (kept.length > 0 && shown.length <= DATA_TEXT_BUDGET) {
231
+ const outFirst = firstOut ? [...out].filter((i) => firstOut.values.includes(String(field(rows[i], firstOut.field)))).length : 0;
232
+ const which = outFirst > 0
233
+ ? `: the ${outFirst} whose ${firstOut?.field} is ${(firstOut?.values ?? []).join(" or ")} (${firstOut?.why})${out.size > outFirst ? `, and the last ${out.size - outFirst} of the rest` : ""}`
234
+ : `, the last ${out.size} in order`;
235
+ const left = `Only ${kept.length} of the ${n} rows of ${cut.rows} are in it; the other ${out.size} are in structuredContent.data only${which}.`;
236
+ return { text: shown, said: say(rowsSaid, ...sidesSaid, left, cut.whole, cut.page?.(kept.length, n, data)) };
237
+ }
238
+ base = { ...beside, [cut.rows]: cutRows };
239
+ }
240
+ // Step 4: lists, then strings.
241
+ let last = { text: all, caps: new Map(), s: { left: new Set(), clipped: 0, capped: 0 }, n: 0, clip: 0 };
242
+ for (const { n, clip } of CAP_LEVELS) {
243
+ const caps = new Map();
244
+ const s = { left: new Set(), clipped: 0, capped: 0 };
245
+ last = { text: layout(capAll(base, n, clip, caps, s, "")), caps, s, n, clip };
246
+ if (last.text.length <= DATA_TEXT_BUDGET)
247
+ break;
248
+ }
249
+ const named = [...last.caps].slice(0, 8).map(([p, len]) => `${p || "the answer"} (${len})`);
250
+ const more = last.caps.size > 8 ? `, and ${last.caps.size - 8} more` : "";
251
+ const lists = last.caps.size ? `Every list in it keeps at most its first ${last.n} ${last.n === 1 ? "entry" : "entries"}: ${listed(named)}${more}, each named with its full length.` : "";
252
+ const strings = last.s.clipped ? `Every string in it longer than ${last.clip} characters keeps its first ${last.clip}, ending in "…".` : "";
253
+ return { text: last.text, said: say(rowsSaid, ...sidesSaid, lists, strings, cut?.whole) };
254
+ }