echelongraph-mcp 2.7.2 → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ // fix_status (#2841, #2518 item 3): the API's fix-status rule for a CVE (core-backend
2
+ // internal/cve/fixstatus.go, the rule CVE Pulse pages render), as get_cve's remediation summary
3
+ // (its first field) and cve_remediation's answer carry it. Relayed, never derived here.
4
+ //
5
+ // A module of its own, importing only zod: index.ts and tools/cve_remediation.ts import each
6
+ // other, so a const shared between them would be read before it is initialised whichever loads
7
+ // first.
8
+ import * as z from "zod";
9
+ export const FIX_STATES = ["available", "fix_referenced", "fix_merged", "none_confirmed", "unknown"];
10
+ export const FIX_STATUS_FIELD = "The API's fix-status rule for the CVE, the one CVE Pulse pages show: state is available (a fix is on record), fix_referenced (a reference tagged Patch, often a source commit, and no release confirmed), fix_merged (an upstream fix merged, not yet in a tagged release), none_confirmed (no fix evidence on record, which is not a finding that no fix exists) or unknown (an input could not be read). basis is the step of the rule that decided it; described_by is the CVE description's sentence naming the fix, solution_by the CVE record's solution sentence naming it (statement) and the CNA that wrote it (cna), stated_in_record that patch_evidence counts a fix the CVE record states with no sentence to quote, listed_in_patch_table that a fixed version or patch row is on record; unread_inputs names any input the API could not read. vendor_released (basis vendor_fix_released) is the vendor's own statement that a fix is released where the CVE record names no fixed version: its sentence, verbatim (statement, or null), never a version, so the fixed release is the vendor advisory's to name. solution_by's section is workarounds when the fix sentence is the record's workaround, not its solution.";
11
+ const opt = (t) => t.nullable().optional();
12
+ export function fixStatusSchema() {
13
+ return z
14
+ .looseObject({
15
+ // A state a later API adds is admitted, never a failed call.
16
+ state: opt(z.union([z.enum(FIX_STATES), z.string()])),
17
+ basis: opt(z.string()),
18
+ described_by: opt(z.string()),
19
+ // #3076 (merged from 24dad5eb): the CVE record's own fix sentence, and its CNA.
20
+ solution_by: opt(z.looseObject({ statement: opt(z.string()), cna: opt(z.string()), section: opt(z.string()) })),
21
+ stated_in_record: opt(z.boolean()),
22
+ listed_in_patch_table: opt(z.boolean()),
23
+ // #3076, third pass: the vendor's released-fix statement with no version (cause G).
24
+ vendor_released: opt(z.looseObject({ statement: opt(z.string()) })),
25
+ unread_inputs: opt(z.array(z.string())),
26
+ })
27
+ .nullable()
28
+ .optional()
29
+ .describe(FIX_STATUS_FIELD);
30
+ }
@@ -295,7 +295,7 @@ export class Slots {
295
295
  // - V8's heap. In that sandbox (a cgroup v1 on a 16 GB host) Node 22 sized V8's heap from the
296
296
  // host's memory, not the cgroup's (heap_size_limit 8,195 MiB in 256 MiB), so V8 left the
297
297
  // garbage of the requests served (~1 MB each) uncollected until the kernel killed the
298
- // instance. So the image caps V8's old space at 128 MiB (Dockerfile.http), and V8 collects
298
+ // instance. So the image caps V8's old space at 104 MiB (Dockerfile.http; 128 until 3.0.2), and V8 collects
299
299
  // first. On Cloud Run this is not verified: an instance that shows Node only its 256 MiB gets
300
300
  // a default heap near the cap already, and then the cap changes little. Its start-up line says
301
301
  // which (heap_limit_mb; memory_limit_mb, null where Node is told no limit).
@@ -482,7 +482,22 @@ export function uaFamily(ua) {
482
482
  // method name), so the message is never logged. It is matched against the SDK's own fixed
483
483
  // prefixes and only the matching label is logged; anything unrecognised is "other". A
484
484
  // ProtocolError also carries its numeric JSON-RPC code, which is content-free.
485
+ //
486
+ // #2923 (2.7.1 audit, 2026-10-10): 78 of 78 production lines were "other": the list held the legacy
487
+ // transport's messages, not those of the bundled SDK's createMcpHandler (@modelcontextprotocol/server
488
+ // 2.1.0 dist/index.mjs), which reports "Rejected inbound request (<cell>): …" (:1346, :1384, :1458),
489
+ // "Rejected 2025-era request on a modern-only endpoint (<cell>): …" (:1438), an
490
+ // UnsupportedProtocolVersionError, "Unsupported protocol version: <requested>", code -32022 (:1338;
491
+ // src-D-y6h4N7.mjs:3693), and a MissingRequiredClientCapabilityError, "Missing required client
492
+ // capabilities: …" (:1357; src-D-y6h4N7.mjs:3727). The legacy transport's own wording is
493
+ // "Bad Request: Unsupported protocol version: …" (index.mjs:896). All of these lead the list, each
494
+ // with its fixed prefix only (the cell and the rest can name the client's method or version).
485
495
  const HANDLER_ERROR_CLASSES = [
496
+ ["Rejected 2025-era request on a modern-only endpoint (", "rejected_legacy_on_modern_only"],
497
+ ["Rejected inbound request (", "rejected_inbound_request"],
498
+ ["Unsupported protocol version", "unsupported_protocol_version"],
499
+ ["Bad Request: Unsupported protocol version", "unsupported_protocol_version"],
500
+ ["Missing required client capabilities", "missing_client_capability"],
486
501
  ["Not Acceptable", "not_acceptable"],
487
502
  ["Unsupported Media Type", "unsupported_media_type"],
488
503
  ["Method not allowed", "method_not_allowed"],
package/dist/index.js CHANGED
@@ -16,8 +16,9 @@
16
16
  // what it left out (readKEVExposure, readExposedDatabases, readLeakedCredentials,
17
17
  // readShadowAI and readMCPServers; #2307, #2313, #2315). content[2] is the envelope below,
18
18
  // as JSON, less what content[0] and the note already say. Past 30,000 characters of JSON,
19
- // content[0] is that JSON cut to fit and the note ends by saying how (TEXT CUT); the
20
- // envelope's data is always the answer whole (textBudget.ts, #2783).
19
+ // content[0] is that JSON cut to fit and the note ends by saying how (TEXT CUT, #2783); past
20
+ // 30,000 characters of structured result, the envelope's data is cut too, unless the call
21
+ // says full: true, and the note says how (DATA CUT, #3013; both in textBudget.ts).
21
22
  // - failure (isError: true): the lookup did not complete — unreachable host, non-2xx,
22
23
  // timeout, or a 2xx whose body is not a JSON object — named by tool, cause and base URL,
23
24
  // in content[0]; content[1] is the envelope below, as JSON, less the message's sentences.
@@ -27,18 +28,23 @@
27
28
  // Structured results (#2311, #2313). Every result, success or failure, also carries
28
29
  // structuredContent: one envelope that says how the answer was measured — state (measured,
29
30
  // not_assessed, failed, invalid_input), measured_at, method, coverage, freshness and notes —
30
- // with, on a success, `data`, the answer whole, and on a failure an `error`. content[0] carries
31
- // `data` as JSON up to 30,000 characters, and past them a cut of it that the note names (TEXT
32
- // CUT, #2783), so past them content[0] is not equal to `data` (#2802). Each tool advertises the
33
- // envelope as its outputSchema.
31
+ // with, on a success, `data`, the answer, and on a failure an `error`. content[0] carries the
32
+ // answer as JSON up to 30,000 characters, and past them a cut of it that the note names (TEXT
33
+ // CUT, #2783), so past them content[0] is not equal to `data` (#2802). `data` is the answer whole
34
+ // unless the whole structured result would pass 30,000 characters of JSON: then, unless the call
35
+ // says full: true, its lists are cut in the order the tool names (DATA CUT, #3013), data_truncated
36
+ // is true and truncated gives each list cut (data_truncated is false otherwise). Each tool
37
+ // advertises the envelope as its outputSchema, which `data` validates against cut or whole.
34
38
  //
35
39
  // The envelope in the text (#2440, #2467). Many clients pass only `content` to the model, so the
36
40
  // last text block of every result is structuredContent serialized as JSON, less what an earlier
37
41
  // text block already carries: `data` (content[0] on a success, whole or cut as above), the note's
38
42
  // sentences (the block just before, which structuredContent's notes end with), and method where
39
43
  // that note quotes it. The rest — state, measured_at, coverage, freshness, the envelope's own notes
40
- // and, on a failure, error — is copied key for key, so the text blocks together are the whole of
41
- // structuredContent, but for what a TEXT CUT leaves out of `data`, and cannot disagree with it. The
44
+ // and, on a failure, error; on a success data_truncated and truncated — is copied key for key, so
45
+ // the text blocks together are the whole of structuredContent, but for what a TEXT CUT leaves out
46
+ // of the answer, and cannot disagree with it. Beside a DATA CUT (#3013) content[0] is still cut from
47
+ // the answer whole, not from the cut data, so it can hold entries data leaves out. The
42
48
  // MCP spec (2025-06-18, Tools, Structured Content) asks a tool that returns structured content to
43
49
  // return it serialized in a text block too; the JSON block stays first and the note second, as in
44
50
  // 1.x, so a client that parses content[0] still parses the answer's JSON, which past 30,000
@@ -59,13 +65,14 @@ import { registerCheckSbom } from "./tools/check_sbom.js";
59
65
  import { registerScanManifest } from "./tools/scan_manifest.js";
60
66
  import { registerCveIntel } from "./tools/cve_intel.js";
61
67
  import { registerCveRemediation } from "./tools/cve_remediation.js";
68
+ import { fixStatusSchema } from "./fixStatus.js";
62
69
  import { registerGetCwe } from "./tools/get_cwe.js";
63
70
  import { holdRequest, isHttpEntrypoint, requestSignal, upstreamHeaders, upstreamUserAgent } from "./runtime.js";
64
71
  import { registerVendorAdvisoryTools } from "./tools/vendor_advisories.js";
65
72
  import { registerPrompts } from "./prompts.js";
66
73
  import { registerResources } from "./resources.js";
67
- import { dataText, GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "./textBudget.js";
68
- import { SHODAN_ATTRIBUTION, SHODAN_OWNERSHIP } from "./requiredText.js";
74
+ import { answering, answeringNow, cutData, dataText, FIRST_TEXT_BLOCK_BESIDE_A_CUT, FULL_ARG, GET_CVE_WHOLE, NO_DATA_CUT, STRUCTURED_BUDGET, TEXT_BUDGET_DESCRIPTION, wholePage, } from "./textBudget.js";
75
+ import { PATCH_AVAILABLE_WITHDRAWN, SHODAN_ATTRIBUTION, SHODAN_OWNERSHIP } from "./requiredText.js";
69
76
  // The package actually running, read from the package.json that ships beside dist/. The MCP
70
77
  // handshake (serverInfo) and the User-Agent both carry its name and version, so the API's
71
78
  // access log can count adoption per version without a code change per release.
@@ -367,17 +374,41 @@ function unexpectedShape(tool, error) {
367
374
  export const checked = (tool, schema, r) => {
368
375
  if (r.isError)
369
376
  return r;
370
- const p = schema.safeParse(r.structuredContent);
377
+ // #3121: the published schema's success branch is open; this server's own answers are held to
378
+ // the closed one.
379
+ const p = (STRICT.get(schema) ?? schema).safeParse(r.structuredContent);
371
380
  return p.success ? r : unexpectedShape(tool, p.error);
372
381
  };
373
382
  // A success: content[0] is the data (cut to DATA_TEXT_BUDGET when it is larger, #2783), content[1]
374
383
  // the note, content[2] the envelope less both; the envelope carries both, the note as sentences
375
- // after any the envelope adds about itself. structuredContent.data is the data whole.
384
+ // after any the envelope adds about itself. structuredContent.data is the data whole, or (#3013),
385
+ // when the structured result would pass STRUCTURED_BUDGET and the call (answering()) does not say
386
+ // full: true, cut as the call's DataCut says, with data_truncated, truncated and the note's DATA CUT
387
+ // sentences saying how. content[0] is cut from the data whole either way.
376
388
  export const succeeded = (data, note, env, cut) => {
377
389
  const own = env.notes ?? [];
390
+ const call = answeringNow();
378
391
  const shown = dataText(data, cut);
379
- const said = shown.said ? `${note} ${shown.said}` : note;
380
- const structuredContent = { ...env, notes: [...own, ...sentences(said)], data };
392
+ const structuredOf = (d, said, truncated) => ({
393
+ ...env,
394
+ notes: [...own, ...sentences(said)],
395
+ data_truncated: truncated !== undefined,
396
+ ...(truncated === undefined ? {} : { truncated }),
397
+ data: d,
398
+ });
399
+ let said = shown.said ? `${note} ${shown.said}` : note;
400
+ let structuredContent = structuredOf(data, said);
401
+ const wholeChars = JSON.stringify(structuredContent).length;
402
+ if (call?.full !== true && wholeChars > STRUCTURED_BUDGET) {
403
+ // The same text, with the sentences a first text block beside a cut data says.
404
+ const beside = dataText(data, cut, FIRST_TEXT_BLOCK_BESIDE_A_CUT).said;
405
+ const head = beside ? `${note} ${beside}` : note;
406
+ const c = cutData(data, call?.cut ?? NO_DATA_CUT, wholeChars, (d, truncated, dataSaid) => structuredOf(d, `${head} ${dataSaid}`, truncated));
407
+ if (c) {
408
+ said = `${head} ${c.said}`;
409
+ structuredContent = c.structured;
410
+ }
411
+ }
381
412
  return { content: [text(shown.text), text(said), envelopeText(structuredContent, said, own)], structuredContent };
382
413
  };
383
414
  export const okHead = (tool, status) => `${tool} OK: EchelonGraph answered${status === undefined ? "" : ` HTTP ${status}`} from ${SHOWN_BASE}.`;
@@ -663,6 +694,35 @@ const SEARCH_CVES_TEXT = {
663
694
  whole: `${GET_CVE_WHOLE}.`,
664
695
  page: (shown, _rows, d) => `To read the rows left out in the text, call search_cves again with the same arguments and offset ${(numAt(d, "offset") ?? 0) + shown}; a page of ${shown} rows or fewer like these fits the text without leaving rows out.`,
665
696
  };
697
+ // #3013: the structured result's cut. A row of production's answer is about 4,200 characters of
698
+ // compact JSON (score_factors, cpe_match and references, about 2,000 of them, are get_cve's), so the
699
+ // default page of 20 was 85,383 characters of structured result. Before any row leaves, every row
700
+ // is thinned: first to the first text cut level's fields, its description whole; then to the fields
701
+ // the description names, without the description. Only then do rows leave data from the end, and
702
+ // the note gives the offset to read them from, and the page size whose rows fit data whole. Through
703
+ // #3013's review that size was the number of thinned rows data kept, so a page of it came back
704
+ // thinned again (data_truncated true) while the note said it fit data whole: it is now counted on
705
+ // the rows whole (PageAt.wholeFit), the same call's envelope and note beside them.
706
+ const SEARCH_CVES_DATA = {
707
+ steps: [
708
+ {
709
+ lists: ["cves"],
710
+ thin: [
711
+ [
712
+ "cve_id", "description", "severity", "cvss_v3_score", "echelongraph_score", "echelongraph_severity", "echelongraph_risk", "score_assessed", "score_unassessed_reason", "epss_score",
713
+ "kev_listed", "kev_ransomware", "published", "vuln_status", "patch_available", "patch_evidence", "exploit_poc_available",
714
+ ],
715
+ ["cve_id", "severity", "cvss_v3_score", "echelongraph_score", "score_assessed", "score_unassessed_reason", "epss_score", "kev_listed", "kev_ransomware", "published", "patch_available", "patch_evidence"],
716
+ ],
717
+ },
718
+ ],
719
+ page: (t, d, at) => {
720
+ const k = t.cves?.returned;
721
+ if (!k || !t.cves || k === t.cves.total)
722
+ return "";
723
+ return `To read the rows past the first ${k} in data, call search_cves again with the same arguments and offset ${(numAt(d, "offset") ?? 0) + k}; ${wholePage(at.wholeFit("cves"))}`;
724
+ },
725
+ };
666
726
  async function searchCVEs(a) {
667
727
  const tool = "search_cves";
668
728
  try {
@@ -751,10 +811,79 @@ function kevTextNote(rec) {
751
811
  return "";
752
812
  const has = ["kev_required_action", "kev_short_description", "kev_notes_urls"].some((k) => field(rec, k) !== undefined && field(rec, k) !== null);
753
813
  return has
754
- ? " kev_required_action, kev_short_description and kev_notes_urls are CISA's own text from its KEV catalog, relayed verbatim: CISA's required action is directed at US federal civilian agencies under Binding Operational Directive 22-01 and is not EchelonGraph's advice."
814
+ ? " kev_required_action, kev_short_description and kev_notes_urls are CISA's own text from its KEV catalog, relayed verbatim: CISA's required action is directed at US federal civilian agencies under Binding Operational Directive 26-04 (which replaced BOD 22-01 on 10 June 2026) and is not EchelonGraph's advice."
755
815
  : "";
756
816
  }
817
+ // #2880: what the record's CISA BOD 26-04 timeline is, where the scorer wrote it
818
+ // (core-backend/internal/cve/scoring/bod2604.go, algorithm v3.4+): given for both answers to "is
819
+ // the system publicly exposed?", because that is the reader's own system's, each input with its
820
+ // source. A record scored before v3.4, or a malformed block, gets no sentence: the JSON is relayed
821
+ // whole either way. What the SSVC decision is (EchelonGraph's run of CISA's table, never a
822
+ // decision CISA published, #3053) is in the outputSchema's echelongraph_ssvc_decision, not the
823
+ // note: get_cve's text is measured to the character (test/text-bound.mjs).
824
+ const BOD_TIMELINE = {
825
+ FSU: "Fix on system upgrade",
826
+ "60D": "60 days",
827
+ "14D": "14 days",
828
+ "3D": "3 days",
829
+ "3DF": "3 days & forensic triage",
830
+ };
831
+ const BOD_SOURCE = {
832
+ "cisa-adp": "CISA's Vulnrichment value",
833
+ cisa_kev: "CISA's KEV catalog",
834
+ cvss_v4_au: "the CVSS v4.0 Automatable metric",
835
+ cvss_v4_av_physical: "CVSS v4.0 attack vector Physical",
836
+ cvss_v4_vc_vi_high: "CVSS v4.0 confidentiality and integrity impact both High",
837
+ cvss_v4_vc_vi_not_high: "CVSS v4.0 confidentiality and integrity impact neither High",
838
+ cisa_bod_26_04_default: "CISA's BOD 26-04 default for a CVE not in KEV that nobody has assessed",
839
+ not_supplied: "not yet published by CISA, so every timeline it allows is listed",
840
+ };
841
+ function bodTimelines(v) {
842
+ if (!Array.isArray(v) || v.length === 0 || !v.every((k) => typeof k === "string" && BOD_TIMELINE[k]))
843
+ return null;
844
+ const w = v.map((k) => BOD_TIMELINE[k]);
845
+ return w.length === 1 ? w[0] : `${w.slice(0, -1).join(", ")} or ${w[w.length - 1]}`;
846
+ }
847
+ function bodNote(cve, rec) {
848
+ const b = field(rec, "score_factors", "bod_26_04");
849
+ const exposed = bodTimelines(field(b, "if_publicly_exposed"));
850
+ const internal = bodTimelines(field(b, "if_not_publicly_exposed"));
851
+ const kev = strAt(b, "kev", "value");
852
+ const input = (k, name) => {
853
+ const v = strAt(b, k, "value");
854
+ const src = strAt(b, k, "source");
855
+ return v && src ? `${name} ${v} (${BOD_SOURCE[src] ?? src})` : null;
856
+ };
857
+ const auto = input("automatable", "automatable");
858
+ const ti = input("technical_impact", "technical impact");
859
+ if (!(exposed && internal && (kev === "yes" || kev === "no") && auto && ti))
860
+ return "";
861
+ return ` score_factors.bod_26_04 is CISA BOD 26-04's remediation timeline for ${cve}: ${exposed} if the affected system is publicly exposed, ${internal} if it is not; whether a system is publicly exposed is its owner's to say. Its inputs: in CISA KEV ${kev}, ${auto}, ${ti}. BOD 26-04 binds US Federal Civilian Executive Branch agencies; for anyone else it is CISA's published timeline, not EchelonGraph's advice.`;
862
+ }
757
863
  // #2841: one sentence on the record's remediation summary, where the API sends one.
864
+ // #3070, #3076: the CNA's own date and fix sentence, named as the CNA's. Nothing when the record
865
+ // carries neither; a value of another JSON type is not quoted (the outputSchema refuses it).
866
+ function cnaRecordNote(cve, rec) {
867
+ const cna = field(rec, "cna");
868
+ const nameOf = (k) => (cna && typeof cna === "object" && typeof field(cna, k) === "string" ? String(field(cna, k)).trim() : "");
869
+ const who = nameOf("name") || nameOf("short_name");
870
+ // An appositive name is closed by a comma: "its CNA, Palo Alto Networks, Inc., states".
871
+ const by = who ? `its CNA, ${who},` : "its CNA";
872
+ const date = strAt(rec, "date_public");
873
+ const fix = strAt(rec, "cna_solution");
874
+ // #3178: a sentence of the record's workarounds is said to be its workaround.
875
+ const workaround = strAt(rec, "cna_solution_section") === "workaround";
876
+ const parts = [];
877
+ if (date)
878
+ parts.push(` date_public is when ${cve} was made public as ${by} states it in the CVE record (datePublic), not the record's publication date.`);
879
+ if (fix && workaround) {
880
+ parts.push(` cna_solution is a fix statement from the CVE record's workarounds (cna_solution_section workaround), not its solutions, written by ${by} and relayed verbatim: it names the fixed release, and is not EchelonGraph's advice.`);
881
+ }
882
+ else if (fix) {
883
+ parts.push(` cna_solution is the CVE record's own fix statement, written by ${by} and relayed verbatim: it names the fixed release, and is not EchelonGraph's advice.`);
884
+ }
885
+ return parts.join("");
886
+ }
758
887
  function remediationSummaryNote(cve, rec) {
759
888
  const s = field(rec, "remediation");
760
889
  if (s === null || typeof s !== "object")
@@ -764,8 +893,21 @@ function remediationSummaryNote(cve, rec) {
764
893
  const failed = field(s, "failed_sections");
765
894
  const notRead = Array.isArray(failed) && failed.length ? ` The summary could not read ${failed.join(", ")} (failed_sections), so a missing count there is not a finding of none.` : "";
766
895
  const byKind = counted.length ? ` Vendor advisories list ${counted.map(([k, n]) => `${k} (${n})`).join(", ")}, by the vendor's own category.` : "";
767
- return ` remediation summarises how ${cve} is fixed, as its sources state it; cve_remediation returns each vendor's text, CISA's action and every fixed range.${byKind}${notRead}`;
896
+ // #2841: fix_status is the API's, relayed; a state is named only as the API sent it.
897
+ const fs = field(s, "fix_status");
898
+ const state = strAt(fs, "state");
899
+ const basis = strAt(fs, "basis");
900
+ const fix = state
901
+ ? ` remediation.fix_status is ${state}${basis ? ` (basis ${basis})` : ""}, the API's fix-status rule${state === "none_confirmed" ? ", which is not a finding that no fix exists" : ""}.`
902
+ : "";
903
+ return ` remediation summarises how ${cve} is fixed, as its sources state it; cve_remediation returns each vendor's text, CISA's action and every fixed range.${fix}${byKind}${notRead}`;
768
904
  }
905
+ // #3013: the structured result's cut. CVE-2021-44228's record is 64,170 characters of structured
906
+ // result, 41,091 of them cpe_match (396 entries) and 17,103 references (103): those two are cut
907
+ // first, each to at most the same number of its first entries, then cpe_configurations, then any
908
+ // other list at the top level of the record. The remediation summary (#2841), an object, is never
909
+ // cut, so its fix_status is whole at every level.
910
+ const GET_CVE_DATA = { steps: [{ lists: ["cpe_match", "references"] }, { lists: ["cpe_configurations"] }] };
769
911
  async function getCVE(cve_id) {
770
912
  const tool = "get_cve";
771
913
  const id = cve_id.trim();
@@ -777,7 +919,7 @@ async function getCVE(cve_id) {
777
919
  return failed(tool, r);
778
920
  const written = strAt(r.data, "updated_at");
779
921
  const cve = strAt(r.data, "cve_id") ?? id;
780
- return succeeded(r.data, `${okHead(tool, r.status)} Returned the record for ${cve}.${cveScoreNote(cve, r.data)}${kevTextNote(r.data)}${remediationSummaryNote(cve, r.data)}`, {
922
+ return succeeded(r.data, `${okHead(tool, r.status)} Returned the record for ${cve}.${cveScoreNote(cve, r.data)}${bodNote(cve, r.data)}${kevTextNote(r.data)}${cnaRecordNote(cve, r.data)}${remediationSummaryNote(cve, r.data)}`, {
781
923
  state: "measured",
782
924
  measured_at: instantOrNull(written),
783
925
  method: CVE_FEED_METHOD,
@@ -1960,6 +2102,35 @@ const RADAR_METHOD = "Aggregate counts over what each radar holds on record: kev
1960
2102
  export const RADAR_NAMES = RADARS.map(([name]) => name);
1961
2103
  const RADAR_STATE_NOTE = "state is not_assessed: every radar answered, but none gives one time at which what it counts was observed: kev_exposure, exposed_databases, leaked_credentials, shadow_ai's confirmed_exposed and mcp_servers date what they count at most by a window (kev_exposure.window, exposed_databases.window, leaked_credentials.window, shadow_ai.confirmed_exposed.window, mcp_servers.window), from the oldest observation among the rows counted to the newest, so no count here is presented as a dated measurement and measured_at is null. Each count is what that radar holds on record, and the note labels each one by what it counts.";
1962
2104
  const RADAR_FRESHNESS_NOTE = "freshness gives each radar's last completed check where the API serves one (freshness.kev_exposure.last_run_at, freshness.exposed_databases.last_run_at, freshness.leaked_credentials.last_run_at, freshness.shadow_ai.last_run_at with freshness.shadow_ai.running, and freshness.mcp_servers.last_run_at with freshness.mcp_servers.enabled), and null where it does not.";
2105
+ // #3013: the structured result's cut. Production's answers of 2026-10-05 (fixtures/prod-answers) are
2106
+ // 33,278 characters of structured result, past the budget by notes that label every number (about
2107
+ // 20,000 characters) beside 12,733 of data. The notes say what each list counts, from the answer
2108
+ // whole, so the lists are cut, never a note: first the two series (shadow_ai.observed.trend_30d,
2109
+ // kev_exposure.trend), then every ranking to at most the same number of its first rows, then
2110
+ // kev_exposure.newest_kev, whose CVEs and exposure states the note names one by one. #3013's
2111
+ // review: the series arrive oldest first (a day, a week a row), and a cut that kept their first
2112
+ // entries showed the oldest, lowest weeks as the trend and left out the latest; each now keeps its
2113
+ // newest entries (keepNewest), and the note says so.
2114
+ const EXPOSURE_RADAR_DATA = {
2115
+ steps: [
2116
+ { lists: ["shadow_ai.observed.trend_30d", "kev_exposure.trend"], keepNewest: true },
2117
+ {
2118
+ lists: [
2119
+ "kev_exposure.top_products",
2120
+ "kev_exposure.top_countries",
2121
+ "kev_exposure.top_cves",
2122
+ "exposed_databases.top_engines",
2123
+ "exposed_databases.top_countries",
2124
+ "leaked_credentials.top_providers",
2125
+ "leaked_credentials.top_types",
2126
+ "shadow_ai.observed.top_products",
2127
+ "shadow_ai.observed.top_countries",
2128
+ "shadow_ai.observed.top_issuers",
2129
+ ],
2130
+ },
2131
+ { lists: ["kev_exposure.newest_kev"] },
2132
+ ],
2133
+ };
1963
2134
  async function exposureRadar() {
1964
2135
  const tool = "exposure_radar";
1965
2136
  try {
@@ -2059,10 +2230,47 @@ export const NOTES = z.array(z.string()).describe("Caveats, one sentence each.")
2059
2230
  // says both, and leaves what each count is to the notes and exposure_state, which say it per
2060
2231
  // answer; it makes no blanket claim about the numbers, in either direction.
2061
2232
  const SUCCESS_STATE = "measured: a measurement of what was asked; an exposure count is measured only with measured_at and method. not_assessed: the answer holds no dated measurement of what was asked, so no count in it is presented as one; it can still relay a count, as what the source holds on record, undated, and its notes (and exposure_state, where the result carries it) say what each count is.";
2233
+ // #3013: what a success says of its data's cut (textBudget.ts cutData), one constant string each.
2234
+ const DATA_TRUNCATED_FIELD = "false: data is the answer whole. true: the structured result would pass 30,000 characters of JSON with data whole, so data is cut (DATA CUT): the entries of some of its lists keep only some of their fields, or the lists only some of their entries, truncated gives each, and no string is shortened. The same call with full: true returns data whole.";
2235
+ const TRUNCATED_FIELD = "Present when data_truncated is true: each list of data that was cut, by its path in data (a dot between the names of an object and a list inside it), with total, its entries in the answer, returned, the entries data holds, and thinned, true when each of them keeps only some of its fields.";
2236
+ const TRUNCATED = z.record(z.string(), z.strictObject({ total: z.number().int().min(0), returned: z.number().int().min(0), thinned: z.literal(true).optional() }));
2237
+ // #3121: the success branch is published open to fields it does not name, and this server holds
2238
+ // its own answers to the closed one (STRICT, read by checked). Through 3.0.0 both branches were
2239
+ // closed (additionalProperties false), and a client validates against the outputSchema it listed,
2240
+ // which the hosted endpoint never tells it to list again (stateless, listChanged false): a Claude
2241
+ // Code session that listed the tools under 2.7.2 failed its first check_sbom call once 3.0.0 went
2242
+ // live, "Structured content does not match the tool's output schema: data must NOT have additional
2243
+ // properties" (ajv's root is "data"; 3.0.0 added data_truncated and truncated to every success),
2244
+ // until it reconnected. So a field added beside them in a minor release would break every such
2245
+ // session too, against the README's semver promise. Opening the success branch keeps such a field
2246
+ // additive for a client holding an older list; the failure branch stays closed (a failure is never
2247
+ // read as data), and data keeps each tool's own schema. The suite validates answers against the
2248
+ // closed branch (test/strict-output.mjs), and checked() does here, so a field this server adds
2249
+ // without naming it in the schema still fails, as it did when the branch was closed.
2250
+ //
2251
+ // 3.0.2: every output schema is built once per process, and its strict copy with it. The hosted
2252
+ // endpoint builds a server for each request (http.ts: createMcpHandler's stateless leg calls
2253
+ // createServer), and through 3.0.1 the tool modules that take envelopeSchema from this file
2254
+ // (check_affected, check_sbom, epss_history, kev_recent, scan_manifest and the three vendor-advisory
2255
+ // tools) built their output schemas in their register functions, so once per request in flight.
2256
+ // 3.0.1's strict copy doubled those: 1,371 KB of heap per server, against 3.0.0's 1,168
2257
+ // (64 servers held at once, node --expose-gc). test/http-memory.test.mjs's "served as they come"
2258
+ // case holds 64 requests in flight beside two 5,000,000-character SBOMs; it failed the 3.0.1
2259
+ // release run at Dockerfile.http's 128 MiB, and passed here only at 128, where 3.0.0 passes at
2260
+ // 112. At 112 V8 aborted 3.5 s in, with 59 servers live and both documents still at their first
2261
+ // batches, so before either answer was cut (a heap snapshot near the limit). Each such module now
2262
+ // keeps its schemas per envelopeSchema, as cve_intel, cve_remediation and get_cwe already did
2263
+ // (schemasOnce): 611 KB a server, and the case passes at --max-old-space-size=80.
2264
+ const STRICT = new WeakMap();
2062
2265
  export function envelopeSchema(o) {
2266
+ const published = envelopeOf(o, z.looseObject);
2267
+ STRICT.set(published, envelopeOf(o, z.strictObject));
2268
+ return published;
2269
+ }
2270
+ function envelopeOf(o, success) {
2063
2271
  const coverage = o.coverage ? o.coverage.nullable() : z.null();
2064
2272
  return z.discriminatedUnion("state", [
2065
- z.strictObject({
2273
+ success({
2066
2274
  state: z.enum(["measured", "not_assessed"]).describe(SUCCESS_STATE),
2067
2275
  measured_at: Instant.nullable().describe("When the underlying observation was made, as the API states it; null when the answer does not say or holds no observation."),
2068
2276
  method: z.string().describe("How the numbers were produced."),
@@ -2070,6 +2278,8 @@ export function envelopeSchema(o) {
2070
2278
  freshness: (o.freshness ? o.freshness.nullable() : z.null()).describe("The producing radar's last completed check (last_run_at), where the API serves one."),
2071
2279
  notes: NOTES,
2072
2280
  ...(o.extra ?? {}),
2281
+ data_truncated: z.boolean().describe(DATA_TRUNCATED_FIELD),
2282
+ truncated: TRUNCATED.optional().describe(TRUNCATED_FIELD),
2073
2283
  data: o.data,
2074
2284
  }),
2075
2285
  z.strictObject({
@@ -2100,13 +2310,41 @@ const CPE_CONFIGURATIONS_DESCRIPTION = "NVD's configurations array as NVD sent i
2100
2310
  // the GitHub API, a published release holding the fix; its HTML heuristics, the CISA KEV inference
2101
2311
  // among them, are not evidence) and cna_patch_reference (the CNA's own CVE record tags a reference
2102
2312
  // "patch"; NVD's capitalised "Patch" stays nvd_patch_reference).
2313
+ // #3076 / #2518 (rule 3): vendor_advisory_fix (a vendor's own structured "vendor fix" for the CVE:
2314
+ // Red Hat CSAF, Microsoft CVRF), cna_solution and description_fix (a sentence of the CVE record's
2315
+ // solutions, or of its description, that states a released fix in core-backend's fix-statement
2316
+ // templates, internal/cve/fix_statement.go). The last two are the record's own words: get_cve
2317
+ // relays the solution's sentence as cna_solution, and the description is in the record.
2103
2318
  // One constant string each (the site's tool-claims check folds only constant strings).
2104
- const PATCH_EVIDENCE_LABELS = ["vendor_patch", "fixed_version", "release_reference", "nvd_patch_reference", "cna_patch_reference"];
2105
- const PATCH_EVIDENCE_SOURCES = "vendor_patch (a vendor patch on record, from Ubuntu or Red Hat), fixed_version (a fixed version recorded for an affected package, or the fixed bound of an affected version range), release_reference (a reference that EchelonGraph confirmed through the GitHub API is a published release containing the fix, or a pull request or commit that a published release contains), nvd_patch_reference (an NVD reference tagged Patch) and cna_patch_reference (a reference the CVE's CNA tagged patch in its CVE record)";
2319
+ const PATCH_EVIDENCE_LABELS = ["vendor_patch", "fixed_version", "release_reference", "vendor_advisory_fix", "nvd_patch_reference", "cna_patch_reference", "cna_solution", "description_fix", "vendor_fix_released"];
2320
+ const PATCH_EVIDENCE_SOURCES = "vendor_patch (a vendor patch on record, from Ubuntu or Red Hat), fixed_version (a fixed version recorded for an affected package, or the fixed bound of an affected version range), release_reference (a reference that EchelonGraph confirmed through the GitHub API is a published release containing the fix, or a pull request or commit that a published release contains), vendor_advisory_fix (a vendor advisory that lists a fix for the CVE under the vendor's own structured category: Red Hat's CSAF vendor_fix, Microsoft's Vendor Fix), nvd_patch_reference (an NVD reference tagged Patch), cna_patch_reference (a reference the CVE's CNA tagged patch in its CVE record), cna_solution (a sentence of the CVE record's solutions or workarounds, written by its CNA, that names a fixed version; relayed as cna_solution), description_fix (a sentence of the CVE description that names a fixed version) and vendor_fix_released (the vendor states, in the CVE record, that a fix is released and the record names no fixed version; check the vendor's advisory)";
2321
+ // #3070, #3076: what the CVE record's CNA wrote, as GET /cves/:id serves it (core-backend
2322
+ // cna_record.go). One constant string each.
2323
+ const CVE_CNA_ABSENT = "Absent where EchelonGraph has not read the CVE record's CNA container yet, where the record has none, for a REJECTED CVE, and from an API older than the field.";
2324
+ const DATE_PUBLIC_FIELD = "When the vulnerability was made public, as the CVE record's CNA states it (the record's datePublic: \"if known, the date/time the vulnerability was disclosed publicly\"). It is the CNA's date, not the record's own publication date, which is published. " +
2325
+ CVE_CNA_ABSENT;
2326
+ const DATE_PUBLIC_SOURCE_FIELD = "Whose date date_public is: cna, the CVE record's CNA. Absent whenever date_public is.";
2327
+ const CNA_FIELD = "The CNA that wrote the CVE record: short_name as the record gives it, and name, the CVE Program's name for that CNA (the short name where its list does not hold it). " + CVE_CNA_ABSENT;
2328
+ const CNA_SOLUTION_FIELD = "The sentence of the CVE record's solutions or workarounds, written by its CNA, that names a released fix, verbatim: what makes cna_solution patch evidence. It is the CNA's text, not EchelonGraph's advice. cna_solution_section says which of the two sections it is. Absent where the record's solutions name no fixed version, as well as where the field itself is: " +
2329
+ CVE_CNA_ABSENT;
2330
+ // #3178: which section cna_solution is. MCP relays a field its outputSchema does not describe without saying what it
2331
+ // is, so the section is described here, beside the sentence.
2332
+ const CNA_SOLUTION_SECTION_FIELD = "Which section of the CVE record cna_solution was read from: solution (the record's solutions) or workaround (its workarounds, read only where its solutions name no fix: a temporary measure the CNA lists beside its fix). A workaround's sentence is the record's workaround, never its solution. Present whenever cna_solution is; absent from an API older than the field.";
2106
2333
  // #2842: what the descriptions say; the sources, and that patch_available once true is never set
2107
2334
  // back, are the outputSchema's (PATCH_AVAILABLE_FIELD, PATCH_EVIDENCE_FIELD).
2108
- const PATCH_AVAILABLE_DESCRIPTION = "patch_available is true when EchelonGraph holds evidence of a fix for the CVE, and patch_evidence names its sources, strongest first. patch_available false means no fix evidence is on record, which is not a finding that no fix exists.";
2109
- const PATCH_AVAILABLE_FIELD = "true: EchelonGraph holds evidence of a fix for the CVE, its sources named in patch_evidence. false: no fix evidence is on record, which is not a finding that no fix exists. Once true it is never set back to false. An answer with no patch_evidence (an API older than that field) does not name the evidence, and a false there is not a finding that no fix exists either.";
2335
+ const PATCH_AVAILABLE_DESCRIPTION = "patch_available is true when EchelonGraph holds, or once held, fix evidence for the CVE; patch_evidence is what it holds now, strongest first. patch_available false means no fix evidence is on record, which is not a finding that no fix exists.";
2336
+ // #3138: true says evidence was held at some point, not that it is held now: the sources held now
2337
+ // are patch_evidence's, and true beside an empty one is a withdrawal (requiredText.ts
2338
+ // PATCH_AVAILABLE_WITHDRAWN, pinned there). #3138's review: round 1 changed the field text only,
2339
+ // and this sentence, in get_cve's and search_cves' descriptions, still said true means
2340
+ // EchelonGraph holds evidence; it now says "holds, or once held", in 9 more characters than it
2341
+ // had, as get_cve's description had 9 left under #2924's headroom of 100 below the 2,048 cut
2342
+ // (get_cve 1,948 characters, search_cves 1,807; test/prompts-resources.test.mjs holds the
2343
+ // headroom, test/client-cut.mjs (b) the cut, and marketing-site lib/mcpToolClaims.test.ts the
2344
+ // word "for" in get_cve's description, which its /pulse/mcp row uses).
2345
+ const PATCH_AVAILABLE_FIELD = "true: EchelonGraph holds, or once held, evidence of a fix for the CVE; patch_evidence names the sources it holds now. " +
2346
+ PATCH_AVAILABLE_WITHDRAWN +
2347
+ " false: no fix evidence is on record, which is not a finding that no fix exists. An answer with no patch_evidence (an API older than that field) does not name the evidence, and a false there is not a finding that no fix exists either.";
2110
2348
  const PATCH_EVIDENCE_FIELD = "The sources of the fix evidence EchelonGraph holds for the CVE, strongest first: " +
2111
2349
  PATCH_EVIDENCE_SOURCES +
2112
2350
  ". Empty where none was found when EchelonGraph last computed it. Absent (an API older than the field): the answer does not name the evidence.";
@@ -2114,14 +2352,14 @@ const PATCH_EVIDENCE_FIELD = "The sources of the fix evidence EchelonGraph holds
2114
2352
  // while kev_listed. Relayed as CISA's, verbatim; the required action is CISA's directive to US
2115
2353
  // federal civilian agencies, not EchelonGraph's advice. Constant strings, like the others here.
2116
2354
  const KEV_TEXT_ABSENT = "Absent where the CVE is not CISA-KEV-listed, where CISA's entry has none, or where EchelonGraph has not stored it yet (an API older than the field serves none).";
2117
- const KEV_REQUIRED_ACTION_FIELD = "CISA's requiredAction for the CVE, verbatim from its Known Exploited Vulnerabilities catalog: the action CISA requires of US federal civilian agencies under Binding Operational Directive 22-01, by kev_due_date. It is CISA's text, not EchelonGraph's advice. " +
2355
+ const KEV_REQUIRED_ACTION_FIELD = "CISA's requiredAction for the CVE, verbatim from its Known Exploited Vulnerabilities catalog: the action CISA requires of US federal civilian agencies under Binding Operational Directive 26-04 (which replaced BOD 22-01 on 10 June 2026), by kev_due_date. It is CISA's text, not EchelonGraph's advice. " +
2118
2356
  KEV_TEXT_ABSENT;
2119
2357
  const KEV_SHORT_DESCRIPTION_FIELD = "CISA's shortDescription of the vulnerability, verbatim from its Known Exploited Vulnerabilities catalog. " + KEV_TEXT_ABSENT;
2120
2358
  const KEV_NOTES_URLS_FIELD = "The https links in CISA's notes for the CVE, in CISA's order, de-duplicated, at most 10: the references CISA's catalog entry gives, such as the vendor's advisory or instructions and NVD's page. " +
2121
2359
  KEV_TEXT_ABSENT;
2122
2360
  // #2841: what get_cve's remediation is. One constant string (the site's tool-claims check folds
2123
2361
  // only constant strings).
2124
- const REMEDIATION_SUMMARY_FIELD = "A capped summary (at most 1,500 characters of JSON) of how the CVE is fixed, as its sources state it: vendor_remediation_kinds (per kind, the vendor advisories listing one for this CVE, by the vendor's own category), vendor_advisories, vendors_not_parsed, kev_required_action (CISA's, verbatim), fixed_branches_count, fixed_versions (per package, fixed versions on record), fix_references (URLs tagged Patch or Mitigation), failed_sections, truncated, and see, the name of the tool that returns the whole, with each text. A kind missing from vendor_remediation_kinds is not a finding that no such remediation exists. Absent for a REJECTED CVE and from an API older than the field.";
2362
+ const REMEDIATION_SUMMARY_FIELD = "A capped summary (at most 1,500 characters of JSON) of how the CVE is fixed, as its sources state it: fix_status first (the API's fix-status rule: state, basis, described_by, solution_by, stated_in_record, listed_in_patch_table and unread_inputs), vendor_remediation_kinds (per kind, the vendor advisories listing one for this CVE, by the vendor's own category), vendor_advisories, vendors_not_parsed, kev_required_action (CISA's, verbatim), fixed_branches_count, fixed_versions (per package, fixed versions on record), fix_references (URLs tagged Patch or Mitigation by NVD, or patch or mitigation by the CNA), failed_sections, truncated, and see, the name of the tool that returns the whole, with each text. A kind missing from vendor_remediation_kinds is not a finding that no such remediation exists. Absent for a REJECTED CVE and from an API older than the field.";
2125
2363
  const CVERecord = z.looseObject({
2126
2364
  cve_id: opt(z.string()),
2127
2365
  description: opt(z.string()),
@@ -2136,6 +2374,9 @@ const CVERecord = z.looseObject({
2136
2374
  score_confidence: opt(z.string()).describe("How confident EchelonGraph is in its score: NONE when score_assessed is false."),
2137
2375
  score_assessed: opt(z.boolean()).describe("Whether EchelonGraph has scored the CVE. true: echelongraph_score, echelongraph_severity and echelongraph_risk are its score. false: the CVE is NOT YET SCORED, not scored 0, and any of those three it carries is a placeholder, not a rating; the API may leave them out. Absent (an API older than the field): the answer does not say whether the CVE was scored."),
2138
2376
  score_unassessed_reason: opt(z.string()).describe("Why score_assessed is false: no_signal, no source has yet published severity data EchelonGraph can score; or rejected, the record was withdrawn by its numbering authority and is never scored. Absent when the CVE is scored."),
2377
+ // #2880: the SSVC decision, and CISA BOD 26-04's timeline (bodNote words it in the note).
2378
+ echelongraph_ssvc_decision: opt(z.string()).describe("The SSVC decision (Act, Attend, Track* or Track) EchelonGraph computes with CISA's decision table at medium mission impact. CISA publishes the table and, for some CVEs, its inputs, not a decision for a CVE. score_factors.ssvc gives the decision at low, medium and high mission impact, with each input and its source."),
2379
+ score_factors: z.unknown().optional().describe("EchelonGraph's evidence for its score. ssvc: the SSVC decision at every mission impact level, each input with its source. bod_26_04 (scorer v3.4 and later): CISA BOD 26-04's remediation timeline, if_publicly_exposed and if_not_publicly_exposed, each a list of CERT/CC's keys (3DF, 3D, 14D, 60D, FSU) named in timelines, with kev, automatable and technical_impact, each with its source; source cisa_bod_26_04_default is CISA's own default for a CVE not in KEV that nobody has assessed."),
2139
2380
  epss_score: opt(z.number()),
2140
2381
  epss_percentile: opt(z.number()),
2141
2382
  kev_listed: opt(z.boolean()),
@@ -2149,12 +2390,20 @@ const CVERecord = z.looseObject({
2149
2390
  // #2861: fix evidence. A label the API adds later is admitted, never a failed call.
2150
2391
  patch_available: opt(z.boolean()).describe(PATCH_AVAILABLE_FIELD),
2151
2392
  patch_evidence: opt(z.array(z.union([z.enum(PATCH_EVIDENCE_LABELS), z.string()]))).describe(PATCH_EVIDENCE_FIELD),
2393
+ // #3070, #3076: what the CVE record's CNA wrote (see cnaRecordNote).
2394
+ date_public: opt(z.string()).describe(DATE_PUBLIC_FIELD),
2395
+ date_public_source: opt(z.string()).describe(DATE_PUBLIC_SOURCE_FIELD),
2396
+ cna: opt(z.looseObject({ short_name: opt(z.string()), name: opt(z.string()) })).describe(CNA_FIELD),
2397
+ cna_solution: opt(z.string()).describe(CNA_SOLUTION_FIELD),
2398
+ // #3178: a section the API adds later is relayed, never a failed call (as patch_evidence's labels).
2399
+ cna_solution_section: opt(z.union([z.enum(["solution", "workaround"]), z.string()])).describe(CNA_SOLUTION_SECTION_FIELD),
2152
2400
  references: z.unknown().optional(),
2153
2401
  // #2720: NVD's configurations, losslessly, beside the flat list (see cpeConfigurationsNote).
2154
2402
  cpe_match: z.unknown().optional().describe(CPE_MATCH_DESCRIPTION),
2155
2403
  cpe_configurations: opt(z.array(z.unknown())).describe(CPE_CONFIGURATIONS_DESCRIPTION),
2156
- // #2841: a capped summary of how the CVE is fixed; cve_remediation returns the whole.
2157
- remediation: opt(z.looseObject({})).describe(REMEDIATION_SUMMARY_FIELD),
2404
+ // #2841: a capped summary of how the CVE is fixed; cve_remediation returns the whole. Its first
2405
+ // field, fix_status, is the API's fix-status rule, relayed (src/fixStatus.ts).
2406
+ remediation: opt(z.looseObject({ fix_status: fixStatusSchema() })).describe(REMEDIATION_SUMMARY_FIELD),
2158
2407
  published: opt(z.string()),
2159
2408
  modified: opt(z.string()),
2160
2409
  updated_at: opt(z.string()),
@@ -2409,9 +2658,10 @@ export function createServer() {
2409
2658
  server.registerTool("cve_summary", {
2410
2659
  title: "CVE feed summary",
2411
2660
  description: `Summary of EchelonGraph's CVE Pulse feed: summary.total active CVEs, their counts by severity band (summary.critical, summary.high, summary.medium, summary.low), the count with no band (summary.none), and summary.last_updated, the newest modification time among those records. ${SUMMARY_NONE_DESCRIPTION} ${SUMMARY_NVD_DESCRIPTION} ${SUMMARY_REJECTED_DESCRIPTION} ${SUMMARY_POLLER_HEAD} The feed is polled from its sources on a schedule; this is the state as of that update. ${CVE_SUMMARY_ENVELOPE} ${TEXT_BUDGET_DESCRIPTION}`,
2661
+ inputSchema: z.object({ full: FULL_ARG }),
2412
2662
  outputSchema: CVE_SUMMARY_OUTPUT,
2413
2663
  annotations: ANNOTATIONS,
2414
- }, async () => checked("cve_summary", CVE_SUMMARY_OUTPUT, await cveSummary()));
2664
+ }, async (a) => checked("cve_summary", CVE_SUMMARY_OUTPUT, await answering(a.full, NO_DATA_CUT, () => cveSummary())));
2415
2665
  server.registerTool("search_cves", {
2416
2666
  title: "Search CVEs",
2417
2667
  description: `Search/list CVEs from EchelonGraph's CVE feed (NVD + MITRE-CNA pre-NVD + CISA-KEV + EPSS + GitHub GHSA, each polled on a schedule). Filter by severity, minimum CVSS, free text, and sort; page with limit and offset. Returns cves, each with cve_id, severity, cvss_v3_score, echelongraph_score and score_assessed (whether EchelonGraph has scored it), epss_score and kev_listed where the record has them, and the list's total, total_counted (false: total is not a count), total_is_lower_bound (true: at least total), search_relaxed, limit and offset. ${SCORE_ASSESSED_DESCRIPTION} ${PATCH_AVAILABLE_DESCRIPTION} ${FEED_ENVELOPE} ${TEXT_BUDGET_DESCRIPTION} Cut, each row keeps at least the fields named above and the first 200 characters of its description (100 on a page too long for that), or rows are left out and the note gives the offset to call next.`,
@@ -2423,30 +2673,32 @@ export function createServer() {
2423
2673
  limit: z.number().int().min(1).max(50).optional().describe("page size (default 20, max 50)"),
2424
2674
  // #2783: the next page, which the note names when the text leaves rows out.
2425
2675
  offset: z.number().int().min(0).max(10000).optional().describe("rows to skip (default 0)"),
2676
+ full: FULL_ARG,
2426
2677
  }),
2427
2678
  outputSchema: SEARCH_CVES_OUTPUT,
2428
2679
  annotations: ANNOTATIONS,
2429
- }, async (a) => checked("search_cves", SEARCH_CVES_OUTPUT, await searchCVEs(a)));
2680
+ }, async (a) => checked("search_cves", SEARCH_CVES_OUTPUT, await answering(a.full, SEARCH_CVES_DATA, () => searchCVEs(a))));
2430
2681
  server.registerTool("get_cve", {
2431
2682
  title: "CVE detail",
2432
2683
  description: `One CVE's record: description, severity, cvss_v3_score, cvss_v4_score and cvss_v4_severity, echelongraph_score, echelongraph_severity and score_confidence, epss_score and epss_percentile, CISA-KEV status (kev_listed, kev_added_date, kev_due_date, kev_ransomware) and, if KEV-listed, CISA's own text relayed verbatim (kev_required_action, not EchelonGraph's advice; kev_short_description; kev_notes_urls), ghsa_id, references, cpe_match (one flat list) and cpe_configurations (NVD's configurations as NVD sent them, with each AND/OR operator; absent where none is stored, which is not a finding that no product is affected), remediation (a capped summary of its fix), published, modified and updated_at; each field only where the record has it. Pass a CVE ID like CVE-2023-44487. ${SCORE_ASSESSED_DESCRIPTION} ${PATCH_AVAILABLE_DESCRIPTION} ${FEED_ENVELOPE} ${TEXT_BUDGET_DESCRIPTION} Cut, each list in the record keeps its first entries, and the note names each list cut with its full length.`,
2433
- inputSchema: z.object({ cve_id: CVE_ID_ARG }),
2684
+ inputSchema: z.object({ cve_id: CVE_ID_ARG, full: FULL_ARG }),
2434
2685
  outputSchema: GET_CVE_OUTPUT,
2435
2686
  annotations: ANNOTATIONS,
2436
- }, async ({ cve_id }) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(cve_id)));
2687
+ }, async ({ cve_id, full }) => checked("get_cve", GET_CVE_OUTPUT, await answering(full, GET_CVE_DATA, () => getCVE(cve_id))));
2437
2688
  server.registerTool("cve_exposure", {
2438
2689
  title: "Internet exposure for one CVE",
2439
2690
  description: `${CVE_EXPOSURE_DESCRIPTION} Its structured result's state is not_assessed with measured_at null, since the answer dates no observation; the count is still relayed, labelled, as what the radar holds on record. exposure_state says what the count is: exposed, measured_zero, not_assessed or tracking_unknown; coverage.in_scope is the API's tracked verdict; freshness is null; data is the API's JSON. ${TEXT_BUDGET_DESCRIPTION}`,
2440
- inputSchema: z.object({ cve_id: CVE_ID_ARG }),
2691
+ inputSchema: z.object({ cve_id: CVE_ID_ARG, full: FULL_ARG }),
2441
2692
  outputSchema: CVE_EXPOSURE_OUTPUT,
2442
2693
  annotations: ANNOTATIONS,
2443
- }, async ({ cve_id }) => checked("cve_exposure", CVE_EXPOSURE_OUTPUT, await cveExposure(cve_id)));
2694
+ }, async ({ cve_id, full }) => checked("cve_exposure", CVE_EXPOSURE_OUTPUT, await answering(full, NO_DATA_CUT, () => cveExposure(cve_id))));
2444
2695
  server.registerTool("exposure_radar", {
2445
2696
  title: "Exposure radar totals",
2446
2697
  description: `${EXPOSURE_RADAR_DESCRIPTION} Its structured result's state is not_assessed with measured_at null, since no radar dates what it counts; each count is still relayed as what that radar holds on record. freshness gives each radar's last_run_at where the API serves one; coverage names the radars that answered. ${TEXT_BUDGET_DESCRIPTION}`,
2698
+ inputSchema: z.object({ full: FULL_ARG }),
2447
2699
  outputSchema: EXPOSURE_RADAR_OUTPUT,
2448
2700
  annotations: ANNOTATIONS,
2449
- }, async () => checked("exposure_radar", EXPOSURE_RADAR_OUTPUT, await exposureRadar()));
2701
+ }, async (a) => checked("exposure_radar", EXPOSURE_RADAR_OUTPUT, await answering(a.full, EXPOSURE_RADAR_DATA, () => exposureRadar())));
2450
2702
  registerKevRecent(server, { api, succeeded, failed, badInput, crashed, checked, envelopeSchema, okHead, annotations: ANNOTATIONS });
2451
2703
  registerEPSSHistory(server, TOOL_KIT);
2452
2704
  // #2716: its logic, schemas and description live in tools/check_affected.ts.
@@ -2467,7 +2719,8 @@ export function createServer() {
2467
2719
  // #2722: prompts and resources, in prompts.ts and resources.ts. Both lists are fixed per
2468
2720
  // connection, so listChanged is false above.
2469
2721
  registerPrompts(server, { cveId: CVE_ID });
2470
- registerResources(server, { api, cveId: CVE_ID, shownBase: SHOWN_BASE, sentences, getCve: async (id) => checked("get_cve", GET_CVE_OUTPUT, await getCVE(id)) });
2722
+ // #3013: cve:// cuts get_cve's data itself (DATA CUT, resources.ts), so it reads it whole.
2723
+ registerResources(server, { api, cveId: CVE_ID, shownBase: SHOWN_BASE, sentences, getCve: async (id) => checked("get_cve", GET_CVE_OUTPUT, await answering(true, GET_CVE_DATA, () => getCVE(id))) });
2471
2724
  return server;
2472
2725
  }
2473
2726
  // The package identity and API settings, for the hosted HTTP entrypoint's health answer and