echelongraph-mcp 2.6.8 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,257 @@
1
+ import * as z from "zod";
2
+ import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
3
+ import { sbomText } from "./check_sbom.js";
4
+ import { FORMAT_NAMES, FORMATS, NOT_CHECKED_REASONS, detect, readFile } from "./manifests/index.js";
5
+ import { CLEAN_NOTE, DISTRO_NOTE, FRESHNESS_NOTE, MAX_PURLS, MEASURED_AT_NOTE, METHOD, batchDataShape, dedupe, field, plural, purlsRefusal, sendBatches, sendingCoverage, sendingCoverageShape, sendingSentences, verdictSentences, } from "./match_batch.js";
6
+ export const SCAN_MANIFEST = "scan_manifest";
7
+ // At most this many files per call, and this many characters of content in all: check_sbom's
8
+ // document cap, so the hosted endpoint's 6 MiB body cap holds a call at this size (httpPolicy.ts).
9
+ export const MAX_FILES = 20;
10
+ export const MAX_MANIFEST_CHARS = 5_000_000;
11
+ // How many files the note names one by one.
12
+ const NOTE_FILES_MAX = 20;
13
+ // How many entries of data.not_checked the text keeps once it must be cut.
14
+ const TEXT_NOT_CHECKED = 20;
15
+ // The order the distinct purls are counted in, for the position of those not sent (#2799).
16
+ const READ_ORDER = "counted in the order this tool read them (the files in the order given, each file's entries in the order the file lists them, each purl at its first place)";
17
+ const NOT_CHECKED_NOTE = "not_checked entries are not clean: each names a dependency this tool did not send to the API (its version is a range or unresolved, or it comes from a local path, a VCS checkout or a line this tool does not read), so no verdict was looked up for it.";
18
+ export const SCAN_MANIFEST_TITLE = "Check a lockfile against the advisory corpus";
19
+ export const SCAN_MANIFEST_DESCRIPTION = "Check a project's lockfile or pinned manifest against EchelonGraph's advisory corpus, one verdict per dependency. Pass files: 1 to 20 of filename and content (its text), up to 5,000,000 characters in all; the filename gives the format, or format sets it. Read: requirements.txt (== and === pins only), go.mod (require, with replace and exclude applied), package-lock.json and npm-shrinkwrap.json (v1 to v3), Cargo.lock, Gemfile.lock, composer.lock, poetry.lock and gradle.lockfile. package.json, pyproject.toml, Pipfile, Gemfile, Cargo.toml, composer.json and build.gradle hold ranges and are refused, each naming the lockfile to pass; go.sum is refused, as it lists versions the build does not select. The files are read into purls by this MCP server and only the purls are sent to the API, in POST bodies of at most 200 each, never in a URL; filenames and file contents are not sent on. Run from npm, this server is on your machine; over the hosted endpoint (mcp.echelongraph.io) it is EchelonGraph's, and the files are the request body, accepted up to 6 MiB. data.results holds one row per distinct purl, with verdict (affected, not_affected, undetermined or not_assessed), not_assessed_reason, cve_ids and matched_via; each match carries kev_listed, epss_score and fixed_in, or null with fixed_in_reason. not_affected is the only clean verdict. data.not_checked lists each entry not sent, with its file, line and reason: version_unpinned (a range: django>=4), version_unresolved, local_path, vcs_source or unsupported_line; not_checked entries are not clean. At most 2,000 distinct purls per call, within 50 seconds; data.not_sent_purls lists any not sent, which are not checked and not clean." +
20
+ " " +
21
+ TEXT_BUDGET_DESCRIPTION +
22
+ " Cut, each row keeps index, purl, verdict, not_assessed_reason and cve_ids at least; not_checked keeps its first 20.";
23
+ // The text's cut: check_sbom's rows and levels, and data.not_checked beside them, cut to its
24
+ // first TEXT_NOT_CHECKED once the first level does not fit (structuredContent.data keeps it whole).
25
+ export function manifestText(sent) {
26
+ const sbom = sbomText(sent);
27
+ return {
28
+ ...sbom,
29
+ sides: [
30
+ ...(sbom.sides ?? []),
31
+ {
32
+ list: "not_checked",
33
+ cap: TEXT_NOT_CHECKED,
34
+ said: (inText, total) => `coverage.not_checked_by_reason counts all ${total} entries not checked, ${inText} of them in the text.`,
35
+ },
36
+ ],
37
+ page: (shown) => `To read every row in the text, pass files that hold ${shown} or fewer distinct purls per call.`,
38
+ };
39
+ }
40
+ export function readFiles(a) {
41
+ if (!Array.isArray(a.files))
42
+ return "files must be a list of {filename, content}";
43
+ if (a.files.length === 0)
44
+ return "files holds no file";
45
+ if (a.files.length > MAX_FILES)
46
+ return `${a.files.length} files; at most ${MAX_FILES} are read per call`;
47
+ let total = 0;
48
+ for (const [n, f] of a.files.entries()) {
49
+ if (f === null || typeof f !== "object" || typeof f.filename !== "string" || typeof f.content !== "string")
50
+ return `files[${n}] must be {filename, content}, both strings`;
51
+ if (f.format !== undefined && f.format !== null && !FORMAT_NAMES.includes(f.format)) {
52
+ return `files[${n}].format ${JSON.stringify(f.format)} is not one of ${FORMAT_NAMES.join(", ")}`;
53
+ }
54
+ total += f.content.length;
55
+ }
56
+ if (total > MAX_MANIFEST_CHARS)
57
+ return `the files are ${total} characters in all; at most ${MAX_MANIFEST_CHARS} are read per call`;
58
+ const files = [];
59
+ const all = [];
60
+ const notChecked = [];
61
+ for (const f of a.files) {
62
+ const out = { filename: f.filename, format: null, ecosystem: null, read: false, refused: null, purls: 0, not_checked: 0, skipped: 0, notes: [], replaced: [] };
63
+ files.push(out);
64
+ const d = f.format ? { format: f.format } : detect(f.filename);
65
+ if ("refused" in d) {
66
+ out.refused = d.refused;
67
+ continue;
68
+ }
69
+ out.format = d.format;
70
+ out.ecosystem = FORMATS[d.format].ecosystem;
71
+ const r = readFile(d.format, f.content);
72
+ if ("unreadable" in r) {
73
+ out.refused = `read as ${d.format}, ${r.unreadable}`;
74
+ continue;
75
+ }
76
+ out.read = true;
77
+ out.purls = r.pinned.length;
78
+ out.not_checked = r.not_checked.length;
79
+ out.skipped = r.skipped;
80
+ out.notes = r.notes;
81
+ out.replaced = r.replaced ?? [];
82
+ all.push(...r.pinned.map((x) => x.purl));
83
+ notChecked.push(...r.not_checked.map((x) => ({ file: f.filename, ...x })));
84
+ }
85
+ if (files.every((f) => !f.read))
86
+ return `no file could be read: ${files.map((f) => `${f.filename}: ${f.refused}`).join("; ")}`;
87
+ const { purls, removed } = dedupe(all);
88
+ return { files, purls, read_purls: all.length, duplicates_removed: removed, not_checked: notChecked };
89
+ }
90
+ // ── The answer ──
91
+ const byReason = (rows) => {
92
+ const by = Object.fromEntries(NOT_CHECKED_REASONS.map((r) => [r, 0]));
93
+ for (const r of rows)
94
+ by[r.reason]++;
95
+ return by;
96
+ };
97
+ function noteFor(head, x, d, g) {
98
+ const out = [head];
99
+ const read = x.files.filter((f) => f.read);
100
+ const shown = read.slice(0, NOTE_FILES_MAX).map((f) => `${f.filename} as ${f.format}: ${plural(f.purls, "entry", "entries")} pinned to one version${f.not_checked ? `, ${f.not_checked} not checked` : ""}${f.skipped ? `, ${f.skipped} skipped (the project's own entry)` : ""}`);
101
+ out.push(`Read ${plural(read.length, "file", "files")}: ${shown.join("; ")}${read.length > NOTE_FILES_MAX ? `; and ${read.length - NOTE_FILES_MAX} more in data.manifest.files` : ""}.`);
102
+ for (const f of x.files.filter((f) => !f.read))
103
+ out.push(`NOT READ, so nothing in it is checked: ${f.filename}: ${f.refused}.`);
104
+ for (const f of read)
105
+ for (const n of f.notes)
106
+ out.push(`${f.filename}: ${n}`);
107
+ const replaced = read.flatMap((f) => f.replaced);
108
+ if (replaced.length)
109
+ out.push(`${plural(replaced.length, "go.mod replace was", "go.mod replaces were")} applied, each module checked at its replacement (data.manifest.files lists them).`);
110
+ out.push(`${plural(x.purls.length, "distinct purl", "distinct purls")}${x.duplicates_removed ? ` (${plural(x.duplicates_removed, "duplicate was", "duplicates were")} sent once)` : ""}; sent ${g.sent}.`);
111
+ const by = byReason(x.not_checked);
112
+ const nc = x.not_checked.length;
113
+ if (nc) {
114
+ const parts = NOT_CHECKED_REASONS.filter((r) => by[r] > 0).map((r) => `${r} ${by[r]}`);
115
+ out.push(`${plural(nc, "entry was", "entries were")} NOT checked and ${nc === 1 ? "is" : "are"} not clean (${parts.join(", ")}): data.not_checked lists each with its file, line and reason, and none was sent to the API.`);
116
+ }
117
+ out.push(...sendingSentences(g, x.purls.length, READ_ORDER, "check_sbom with purls set to"));
118
+ if (d === null)
119
+ return { note: out.join(" "), assessed: 0 };
120
+ const v = verdictSentences(d, g);
121
+ out.push(...v.sentences);
122
+ return { note: out.join(" "), assessed: v.assessed };
123
+ }
124
+ // What data.manifest.files and data.not_checked carry, beside the API's answer.
125
+ const manifestData = (x) => ({
126
+ manifest: {
127
+ files: x.files.map((f) => ({ ...f })),
128
+ },
129
+ not_checked: x.not_checked,
130
+ });
131
+ export async function scanManifest(deps, a) {
132
+ const tool = SCAN_MANIFEST;
133
+ try {
134
+ const x = readFiles(a);
135
+ if (typeof x === "string")
136
+ return deps.badInput(tool, x);
137
+ const refused = purlsRefusal(x.purls, `pass fewer files per call (${x.files.filter((f) => f.read).map((f) => `${f.filename}: ${f.purls}`).join(", ")}), or pass a lockfile's purls to check_sbom in parts of ${MAX_PURLS} or fewer`);
138
+ if (refused)
139
+ return deps.badInput(tool, refused);
140
+ const by = byReason(x.not_checked);
141
+ const fileCoverage = {
142
+ files: x.files.length,
143
+ files_read: x.files.filter((f) => f.read).length,
144
+ files_not_read: x.files.filter((f) => !f.read).length,
145
+ entries_pinned: x.read_purls,
146
+ duplicates_removed: x.duplicates_removed,
147
+ not_checked: x.not_checked.length,
148
+ not_checked_by_reason: by,
149
+ };
150
+ const notes = [NOT_CHECKED_NOTE, CLEAN_NOTE, DISTRO_NOTE, MEASURED_AT_NOTE, FRESHNESS_NOTE];
151
+ // Nothing to send: every entry is a range, a local path, a VCS checkout or unread. The answer
152
+ // says so, entry by entry; no request is made, and nothing is measured.
153
+ if (x.purls.length === 0) {
154
+ const g = { batches: 0, batches_sent: 0, sent: 0, not_sent_purls: [], not_sent_reason: null, not_sent_detail: null, waits: 0, waited_ms: 0 };
155
+ const head = `${tool} OK: nothing was sent to EchelonGraph, as no entry in the files names one registry version this tool can check, so nothing is measured and nothing is clean.`;
156
+ const { note } = noteFor(head, x, null, g);
157
+ return deps.succeeded({ ...manifestData(x), results: [] }, note, {
158
+ state: "not_assessed",
159
+ measured_at: null,
160
+ method: METHOD,
161
+ coverage: { ...fileCoverage, ...sendingCoverage(g, 0, undefined) },
162
+ freshness: null,
163
+ notes,
164
+ }, manifestText(0));
165
+ }
166
+ // match_batch.ts: one batch at a time, Retry-After waited out within the budget, stopped on
167
+ // cancellation (#2775), exactly as check_sbom sends its purls.
168
+ const run = await sendBatches(deps, tool, x.purls);
169
+ if (!run.ok)
170
+ return run.result;
171
+ const { data, status, sending: g } = run;
172
+ Object.assign(data, manifestData(x));
173
+ const { note, assessed } = noteFor(deps.okHead(tool, status), x, data, g);
174
+ return deps.succeeded(data, note, {
175
+ state: assessed > 0 ? "measured" : "not_assessed",
176
+ measured_at: null,
177
+ method: METHOD,
178
+ coverage: { ...fileCoverage, ...sendingCoverage(g, x.purls.length, field(data, "summary")) },
179
+ freshness: null,
180
+ notes,
181
+ }, manifestText(g.sent));
182
+ }
183
+ catch (e) {
184
+ return deps.crashed(tool, e);
185
+ }
186
+ }
187
+ // ── Schemas ──
188
+ const reasonEnum = z.enum(NOT_CHECKED_REASONS);
189
+ const formatEnum = z.enum(FORMAT_NAMES);
190
+ export function scanManifestOutput(envelopeSchema) {
191
+ return envelopeSchema({
192
+ data: z.looseObject({
193
+ ...batchDataShape,
194
+ manifest: z
195
+ .object({
196
+ files: z.array(z.object({
197
+ filename: z.string(),
198
+ format: formatEnum.nullable().describe("The format the file was read as: set by format, or read from the filename; null when it was not read."),
199
+ ecosystem: z.string().nullable().describe("The OSV ecosystem of the purls read from it."),
200
+ read: z.boolean().describe("false: the file was refused or could not be read, and nothing in it is checked (not clean)."),
201
+ refused: z.string().nullable().describe("Why the file was not read, and the file to pass instead where there is one; null when it was read."),
202
+ purls: z.number().int().describe("Entries pinned to one version, read as purls (duplicates included)."),
203
+ not_checked: z.number().int().describe("Entries not sent, listed in data.not_checked: not clean."),
204
+ skipped: z.number().int().describe("Entries that are not dependencies (a package-lock's root entry, the project itself)."),
205
+ notes: z.array(z.string()).describe("How this file was read where that limits what its verdicts cover (a go.mod below go 1.17)."),
206
+ replaced: z.array(z.object({ from: z.string(), to: z.string() })).describe("go.mod replace directives applied: the module required, and the module and version checked instead."),
207
+ })),
208
+ })
209
+ .describe("Each file passed, in order, and how it was read. Filenames and contents are not sent to the API."),
210
+ not_checked: z
211
+ .array(z.object({
212
+ file: z.string(),
213
+ name: z.string().nullable(),
214
+ spec: z.string().describe("What the file says for the entry: its line, or its lockfile key and version (at most 200 characters)."),
215
+ reason: reasonEnum.describe("version_unpinned: a range or wildcard, not one version; version_unresolved: no single registry version (none given, a branch, or an excluded go.mod version); local_path: a directory, file or workspace member; vcs_source: a VCS checkout; unsupported_line: a line or entry this tool does not read (another file named by -r or -c, a direct URL, another registry)."),
216
+ line: z.number().int().nullable().describe("1-based line; null in a JSON lockfile, whose entries spec names by key."),
217
+ detail: z.string(),
218
+ }))
219
+ .describe("Each entry not sent to the API, with its file, line and reason: not checked, and not clean."),
220
+ }),
221
+ coverage: z.strictObject({
222
+ files: z.number().int().describe("Files passed."),
223
+ files_read: z.number().int().describe("Of those, the ones read."),
224
+ files_not_read: z.number().int().describe("The ones refused or unreadable: nothing in them is checked, and none is clean."),
225
+ entries_pinned: z.number().int().describe("Entries pinned to one version across the files, duplicates included."),
226
+ duplicates_removed: z.number().int().describe("Purls read more than once (the same package and version in two places) and sent once."),
227
+ not_checked: z.number().int().describe("Entries not sent (data.not_checked): not clean."),
228
+ not_checked_by_reason: z.strictObject(Object.fromEntries(NOT_CHECKED_REASONS.map((r) => [r, z.number().int()]))).describe("not_checked, counted by reason."),
229
+ ...sendingCoverageShape,
230
+ }),
231
+ freshness: null,
232
+ });
233
+ }
234
+ // A literal server.registerTool(name, { title, description, … }) call with constant strings:
235
+ // marketing-site lib/mcpToolClaims.test.ts reads it here (it follows index.ts's call to this
236
+ // function) and holds the /pulse/mcp row to this title and description.
237
+ export function registerScanManifest(server, deps) {
238
+ const output = scanManifestOutput(deps.envelopeSchema);
239
+ server.registerTool(SCAN_MANIFEST, {
240
+ title: SCAN_MANIFEST_TITLE,
241
+ description: SCAN_MANIFEST_DESCRIPTION,
242
+ inputSchema: z.object({
243
+ files: z
244
+ .array(z.object({
245
+ filename: z.string().describe("the file's name, e.g. package-lock.json or services/api/go.mod; it picks the format unless format is set"),
246
+ content: z.string().describe("the file's text, as it is on disk"),
247
+ format: formatEnum.optional().describe("the format to read the file as, when its name does not say"),
248
+ }))
249
+ .describe("1 to 20 files, up to 5,000,000 characters of content in all; read by this MCP server into purls, and only the purls are sent to the API; over the hosted endpoint (mcp.echelongraph.io) the files are the request body"),
250
+ }),
251
+ outputSchema: output,
252
+ annotations: deps.annotations,
253
+ }, async (a, ctx) => {
254
+ const run = scanManifest({ ...deps, signal: ctx.mcpReq.signal }, a);
255
+ return deps.checked(SCAN_MANIFEST, output, await (deps.hold ? deps.hold(run) : run));
256
+ });
257
+ }
@@ -8,8 +8,10 @@
8
8
  // vendor_published_at is the date the vendor gives, our_first_seen_at is when EchelonGraph
9
9
  // first recorded the advisory. withdrawn is the vendor rescinding its own advisory; the list
10
10
  // and the search leave such advisories out, the per-CVE lookup and the detail keep them, and
11
- // the note names each one. Advisories of a vendor feed EchelonGraph has disabled are left out
12
- // of all three. None of the answers carries a time at which the pollers last completed a poll,
11
+ // the note names each one. rejected_cve_ids (#2865) is separate from it: the CVE IDs a row names
12
+ // whose CVE record was rejected by its numbering authority, while the vendor's own document may
13
+ // still read as live; the note names those rows and the by-cve answer's cve_rejected. Advisories
14
+ // of a vendor feed EchelonGraph has disabled are left out of all three. None of the answers carries a time at which the pollers last completed a poll,
13
15
  // so freshness is null.
14
16
  //
15
17
  // The search term never travels in a URL (#1983). search_vendor_advisories sends it in the
@@ -102,6 +104,31 @@ function withdrawnNote(rows) {
102
104
  return "";
103
105
  return ` WITHDRAWN: ${w.length} of these ${rows.length} ${plural(rows.length, "advisory", "advisories")} ${plural(w.length, "was", "were")} withdrawn (rescinded) by ${plural(w.length, "its", "their")} vendor (withdrawn true): ${w.join(", ")}. Report ${plural(w.length, "it", "them")} as withdrawn, not as a current advisory.`;
104
106
  }
107
+ // #2865: a vendor advisory can name a CVE ID its numbering authority rejected while the vendor's own
108
+ // text still reads as a live advisory (CVE-2026-7936's MSRC entry). core-backend serves
109
+ // rejected_cve_ids, the CVE IDs of the row whose CVE record was rejected, beside the vendor's own
110
+ // withdrawn, which it leaves as the vendor set it. A row the vendor withdrew is named by
111
+ // withdrawnNote already, so this names the live ones.
112
+ const rejectedIDs = (r) => {
113
+ const v = field(r, "rejected_cve_ids");
114
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string" && x !== "") : [];
115
+ };
116
+ function rejectedCVENote(rows) {
117
+ const hit = rows.flatMap((r, i) => field(r, "withdrawn") !== true && rejectedIDs(r).length
118
+ ? [`${str(r, "vendor") ?? "?"}/${str(r, "vendor_advisory_id") ?? `advisories[${i}]`} (${rejectedIDs(r).join(", ")})`]
119
+ : []);
120
+ if (!hit.length)
121
+ return "";
122
+ const k = hit.length;
123
+ return ` CVE REJECTED: ${k} of these ${rows.length} ${plural(rows.length, "advisory", "advisories")}, not withdrawn by ${plural(k, "its", "their")} vendor, ${plural(k, "names", "name")} a CVE ID whose CVE record was rejected (withdrawn) by its numbering authority (rejected_cve_ids): ${hit.join("; ")}. Report each such CVE ID as a rejected record, not as an active vulnerability, even where the vendor's text reads as a current advisory.`;
124
+ }
125
+ // The rows' rejected_cve_ids could not be checked (null on every row that names a CVE).
126
+ function rejectedUnknownNote(rows) {
127
+ const unknown = rows.filter((r) => Array.isArray(field(r, "cve_ids")) && field(r, "cve_ids").length > 0 && field(r, "rejected_cve_ids") === null);
128
+ if (!unknown.length)
129
+ return "";
130
+ return ` For ${unknown.length} of these advisories rejected_cve_ids is null: whether a CVE ID they name was rejected could not be checked, which is not a finding that none was.`;
131
+ }
105
132
  const instantOrNull = (o, k) => {
106
133
  const v = field(o, k);
107
134
  return realInstant(v) ? v : null;
@@ -182,13 +209,41 @@ function backfillingNote(windows) {
182
209
  const k = b.length;
183
210
  return ` The older advisories of ${plural(k, "1 vendor", `${k} vendors`)} are still being read (history_backfill in_progress or not_started): ${b.map((w) => windowItem(w, false)).join("; ")}. EchelonGraph does not yet hold all of ${plural(k, "its", "their")} older advisories, so none found from ${plural(k, "it", "them")} is not a finding that ${plural(k, "it", "they")} published none.`;
184
211
  }
212
+ // #2840: what the rows say of their remediation, where the API sends remediation_state.
213
+ function remediationNote(cve, rows) {
214
+ const states = rows.map((r) => field(r, "remediation_state")).filter((x) => typeof x === "string");
215
+ if (!states.length)
216
+ return "";
217
+ const count = (st) => states.filter((x) => x === st).length;
218
+ const kinds = new Map();
219
+ for (const r of rows) {
220
+ const ks = field(r, "remediation_kinds");
221
+ if (Array.isArray(ks))
222
+ for (const k of ks)
223
+ if (typeof k === "string")
224
+ kinds.set(k, (kinds.get(k) ?? 0) + 1);
225
+ }
226
+ const listedKinds = [...kinds].map(([k, n]) => `${k} (${n})`).join(", ");
227
+ const parts = [
228
+ listedKinds ? ` By the vendors' own categories, the advisories list for ${cve}: ${listedKinds}; each is the vendor's text, untested by EchelonGraph, and cve_remediation or get_vendor_advisory returns it.` : "",
229
+ count("none_in_source") ? ` ${count("none_in_source")} ${plural(count("none_in_source"), "lists", "list")} no remediation for ${cve} (none_in_source), which is not a finding that no fix exists.` : "",
230
+ count("not_parsed") ? ` EchelonGraph does not read the remediation of ${count("not_parsed")} (remediation_state not_parsed), which is not a finding that ${plural(count("not_parsed"), "it lists", "they list")} none.` : "",
231
+ ];
232
+ return parts.join("");
233
+ }
185
234
  // ── Output schemas ──
186
235
  const opt = (t) => t.nullable().optional();
187
236
  const DATE_FIELDS = {
188
237
  vendor_published_at: opt(z.string()).describe("The date the vendor gives for the advisory."),
189
238
  our_first_seen_at: opt(z.string()).describe("When EchelonGraph first recorded the advisory; not the vendor's date."),
190
239
  withdrawn: opt(z.boolean()).describe("true: the vendor withdrew (rescinded) this advisory, so it is a withdrawn advisory, not a current one."),
240
+ rejected_cve_ids: opt(z.array(z.string())).describe("The CVE IDs this advisory names whose CVE record was rejected (withdrawn) by its numbering authority: each is a rejected record, not an active vulnerability, whatever the advisory's text says. The vendor's withdrawn is separate and unchanged by it. [] when none is; null when that could not be checked."),
191
241
  };
242
+ // #2840: what remediation_kinds and remediation_state are, on a by-cve row and on the detail.
243
+ const REMEDIATION_STATE_FIELD = "parsed; none_in_source (EchelonGraph read the vendor's remediation, and it lists none for this CVE, which is not a finding that no fix exists); not_parsed (EchelonGraph does not read this vendor's remediation, or has not yet read this advisory: not a finding of none); withdrawn (the vendor rescinded the advisory).";
244
+ const REMEDIATION_KINDS_FIELD = "The kinds of remediation the advisory lists (for the CVE asked about, on a by-cve row), each the vendor's own category, never read from the text: vendor_fix, workaround, mitigation, no_fix_planned, none_available or other. [] with remediation_state not_parsed is not a finding that it lists none.";
245
+ const REMEDIATION_KINDS = ["vendor_fix", "workaround", "mitigation", "no_fix_planned", "none_available", "other"];
246
+ const REMEDIATION_STATES = ["parsed", "none_in_source", "not_parsed", "withdrawn"];
192
247
  const AdvisoryRow = z.looseObject({
193
248
  advisory_id: opt(z.string()),
194
249
  vendor: opt(z.string()),
@@ -201,18 +256,34 @@ const AdvisoryRow = z.looseObject({
201
256
  summary: opt(z.string()),
202
257
  affected_products: opt(z.array(z.string())),
203
258
  ...DATE_FIELDS,
259
+ remediation_kinds: opt(z.array(z.union([z.enum(REMEDIATION_KINDS), z.string()]))).describe(REMEDIATION_KINDS_FIELD),
260
+ remediation_state: opt(z.union([z.enum(REMEDIATION_STATES), z.string()])).describe(REMEDIATION_STATE_FIELD),
204
261
  });
205
262
  // Shared by the three descriptions. Top-level consts, so the site's tool-claims check
206
263
  // (marketing-site/lib/mcpToolClaims.test.ts) can fold the descriptions to their text.
207
- const DATES_DESCRIPTION = "Each advisory carries vendor_published_at (the vendor's date), our_first_seen_at (when EchelonGraph first recorded it) and withdrawn (true: the vendor rescinded it; the note names each such advisory).";
208
- const ENVELOPE_DESCRIPTION = "Its structured result carries state (measured), measured_at, method, coverage, freshness (null: the answer carries no poll-completion time) and notes, with data equal to the API's JSON; the result's last text block repeats it without data (the first text block) and without the note's sentences (the text block before it), with which notes ends.";
264
+ const DATES_DESCRIPTION = "Each advisory carries vendor_published_at (the vendor's date), our_first_seen_at (when EchelonGraph first recorded it) and withdrawn (true: the vendor rescinded it).";
265
+ // #2865: the CVE-state flag beside the vendor's own withdrawn. #2842: the head the descriptions
266
+ // carry; rejected_cve_ids' and cve_rejected's outputSchema descriptions say the rest (null: not checked).
267
+ const REJECTED_CVE_DESCRIPTION = "Each advisory also carries rejected_cve_ids: the CVE IDs it names whose CVE record was rejected (withdrawn) by its numbering authority, which are not active vulnerabilities.";
268
+ // #2842: the generic rule for the result's last text block is in the README and the methodology
269
+ // resource, not in each description.
270
+ const ENVELOPE_DESCRIPTION = "Its freshness is null: the answer carries no poll-completion time.";
209
271
  // #2729: what a vendor's window is.
210
272
  const HISTORY_BACKFILL_DESCRIPTION = "history_backfill: complete (the vendor's published history has been read back as far as its source goes), in_progress or not_started (it is still being read, so the vendor's older advisories are not all held yet), or not_supported (EchelonGraph has no history read for that vendor, so what it holds is what the vendor's feed has carried)";
211
273
  // #2803: where a vendor's window begins.
212
274
  const HELD_SINCE_DESCRIPTION = "held_since (the start of the window EchelonGraph has read that vendor's advisories from, before which an advisory may not be held; null when none is held or the API does not say; for a vendor with no history read it is when its feed was first read from, and can be years after earliest_vendor_published_at, since an older advisory is held when the vendor revised it later, not the ones published beside it)";
213
275
  const WINDOW_DESCRIPTION = `vendor, advisories (how many EchelonGraph holds from that vendor), earliest_vendor_published_at and latest_vendor_published_at (the earliest and latest vendor_published_at among them, null when none is held), ${HELD_SINCE_DESCRIPTION} and ${HISTORY_BACKFILL_DESCRIPTION}`;
276
+ // #2842: what the descriptions say of a window, within the client cut (requiredText.ts);
277
+ // WINDOW_DESCRIPTION in full is the outputSchema's description of a window.
278
+ const WINDOW_HEAD = "vendor, advisories, earliest_vendor_published_at, latest_vendor_published_at, held_since (where that window begins) and history_backfill (in_progress or not_started: older advisories not all held yet)";
214
279
  const NOT_HELD_DESCRIPTION = "An advisory a vendor published before its held_since may not be held, even when its earliest_vendor_published_at is older, so no advisory from a vendor is not a finding that it published none.";
280
+ // #2842: the same rule within the client cut; NOT_HELD_DESCRIPTION whole is the windows' outputSchema description.
281
+ const NOT_HELD_HEAD = "An advisory published before its vendor's held_since may not be held, so no advisory from a vendor is not a finding that it published none.";
215
282
  // #2728: how a search matches, and its capped total.
283
+ // #2842: what search_vendor_advisories' description says; SEARCH_MATCH_DESCRIPTION and
284
+ // TOTAL_CAPPED_DESCRIPTION in full are the outputSchema's descriptions of search_match and total_capped.
285
+ const SEARCH_MATCH_HEAD = "A query of 3 or more characters is matched case-insensitively as a substring of each advisory's title, description, vendor name, IDs and affected_products (search_match substring). A query of 1 or 2 characters matches whole words only (search_match word).";
286
+ const TOTAL_CAPPED_HEAD = "A search counts at most 1,000 matches: past that, total is 1000 and total_capped is true, which means 1,000 or more (the note writes 1,000+), never exactly 1,000.";
216
287
  const SEARCH_MATCH_DESCRIPTION = "A query of 3 or more characters is matched case-insensitively as a substring of each advisory's title, description, vendor name, vendor_advisory_id, affected_products and cve_ids (search_match substring). A query of 1 or 2 characters matches whole words only (search_match word): it must equal, ignoring case, a whole word (a run of letters and digits) of one of those, so xz finds xz-utils but not xzibit, and a 1- or 2-character query with any other character, such as c#, matches nothing.";
217
288
  const TOTAL_CAPPED_DESCRIPTION = "A search counts at most 1,000 matches: past that, total is 1000 and total_capped is true, which means 1,000 or more (the note writes 1,000+), never exactly 1,000; paging past it still works. Without a query, total is the exact count and total_capped is false.";
218
289
  const VendorWindowSchema = z.strictObject({
@@ -234,7 +305,7 @@ const VendorWindowSchema = z.strictObject({
234
305
  .union([z.enum(["complete", "in_progress", "not_started", "not_supported"]), z.string()])
235
306
  .nullable()
236
307
  .describe(`${HISTORY_BACKFILL_DESCRIPTION}.`),
237
- });
308
+ }).describe(`A vendor's window in what EchelonGraph holds: ${WINDOW_DESCRIPTION}.`);
238
309
  const WINDOWS = z.array(VendorWindowSchema).nullable().describe(`Each vendor's window in what EchelonGraph holds; null when the windows could not be read. ${NOT_HELD_DESCRIPTION}`);
239
310
  // #2783: a production row is up to about 2,000 characters as pretty JSON (its summary, its
240
311
  // affected_products and, for a library like log4j, dozens of cve_ids), so search_vendor_advisories
@@ -242,8 +313,8 @@ const WINDOWS = z.array(VendorWindowSchema).nullable().describe(`Each vendor's w
242
313
  // text block keeps what identifies and rates the advisory, every string cut to 200 characters and
243
314
  // every list to 20 entries; then fewer fields, strings to 100 and lists to 10.
244
315
  const ROW_CUT_LEVELS = [
245
- { keep: ["vendor", "vendor_display_name", "vendor_advisory_id", "title", "severity", "cvss_v3_score", "cve_ids", "summary", "affected_products", "vendor_published_at", "withdrawn"], clip: 200, cap: 20 },
246
- { keep: ["vendor", "vendor_advisory_id", "title", "severity", "cve_ids", "vendor_published_at", "withdrawn"], clip: 100, cap: 10 },
316
+ { keep: ["vendor", "vendor_display_name", "vendor_advisory_id", "title", "severity", "cvss_v3_score", "cve_ids", "summary", "affected_products", "vendor_published_at", "withdrawn", "rejected_cve_ids", "remediation_kinds", "remediation_state"], clip: 200, cap: 20 },
317
+ { keep: ["vendor", "vendor_advisory_id", "title", "severity", "cve_ids", "vendor_published_at", "withdrawn", "rejected_cve_ids", "remediation_kinds", "remediation_state"], clip: 100, cap: 10 },
247
318
  ];
248
319
  // get_vendor_advisory's own first text block is cut too past the budget (#2802): only its
249
320
  // structuredContent.data is one advisory whole.
@@ -258,7 +329,12 @@ const searchText = (a) => ({
258
329
  const ROW_CUT_DESCRIPTION = " Cut, each row keeps vendor, vendor_advisory_id, title, severity and cve_ids at least, its strings and lists shortened";
259
330
  export function registerVendorAdvisoryTools(server, kit) {
260
331
  const FOR_CVE_OUTPUT = kit.envelopeSchema({
261
- data: z.looseObject({ cve_id: opt(z.string()), advisories: opt(z.array(AdvisoryRow)), total: opt(z.number()) }),
332
+ data: z.looseObject({
333
+ cve_id: opt(z.string()),
334
+ advisories: opt(z.array(AdvisoryRow)),
335
+ total: opt(z.number()),
336
+ cve_rejected: opt(z.boolean()).describe("true: the CVE record of cve_id was rejected (withdrawn) by its numbering authority, so these advisories name a rejected record, not an active vulnerability; null when that could not be checked."),
337
+ }),
262
338
  coverage: z.strictObject({
263
339
  returned: z.number().nullable().describe("The advisories in this answer."),
264
340
  cap: z.number().describe("The most the API returns for one CVE, newest first."),
@@ -276,7 +352,21 @@ export function registerVendorAdvisoryTools(server, kit) {
276
352
  data: AdvisoryRow.extend({
277
353
  description: opt(z.string()),
278
354
  known_cve_ids: opt(z.array(z.string())).describe("The CVE IDs of cve_ids that have a record in EchelonGraph's CVE feed."),
279
- remediation: opt(z.string()),
355
+ remediation: opt(z.string()).describe("The vendor's remediation as one text (the legacy field), beside remediations."),
356
+ remediations: opt(z.array(z.looseObject({
357
+ kind: opt(z.union([z.enum(REMEDIATION_KINDS), z.string()])),
358
+ source_category: opt(z.string()).describe("The vendor's own name for the category."),
359
+ source_subcategory: opt(z.string()),
360
+ text: opt(z.string()).describe("The vendor's text, verbatim but for HTML markup removed, untested by EchelonGraph; at most 2,000 characters (text_truncated, text_chars)."),
361
+ text_truncated: opt(z.boolean()),
362
+ text_chars: opt(z.number()),
363
+ url: opt(z.string()),
364
+ fixed_build: opt(z.string()),
365
+ cve_ids: opt(z.array(z.string())).describe("The CVEs the item covers; [] for the whole advisory."),
366
+ product_ids: opt(z.array(z.string())),
367
+ product_count: opt(z.number()),
368
+ }))).describe("Each remediation the vendor lists, with its kind, the vendor's own category."),
369
+ remediation_items_not_stored: opt(z.number()).describe("Items past the 64 stored per advisory."),
280
370
  references: z.unknown().optional(),
281
371
  vendor_modified_at: opt(z.string()),
282
372
  withdrawn_at: opt(z.string()),
@@ -294,8 +384,8 @@ export function registerVendorAdvisoryTools(server, kit) {
294
384
  limit: opt(z.number()),
295
385
  offset: opt(z.number()),
296
386
  search_applied: opt(z.boolean()).describe("Whether a free-text search filtered this answer."),
297
- search_match: opt(z.string()).describe("How the query was matched: word (a query of 1 or 2 characters, whole words only) or substring; null without a query."),
298
- total_capped: opt(z.boolean()).describe("true: the search stopped counting at total, so total is a lower bound (1,000+), not the count."),
387
+ search_match: opt(z.string()).describe(`How the query was matched: word (a query of 1 or 2 characters, whole words only) or substring; null without a query. ${SEARCH_MATCH_DESCRIPTION}`),
388
+ total_capped: opt(z.boolean()).describe(`true: the search stopped counting at total, so total is a lower bound (1,000+), not the count. ${TOTAL_CAPPED_DESCRIPTION}`),
299
389
  }),
300
390
  coverage: z.strictObject({
301
391
  total: z.number().nullable().describe("The API's total; when total_capped is true, a lower bound, not the count."),
@@ -305,7 +395,7 @@ export function registerVendorAdvisoryTools(server, kit) {
305
395
  offset: z.number().nullable(),
306
396
  search_applied: z.boolean().nullable(),
307
397
  search_match: z.enum(["word", "substring"]).nullable().describe("word: a query of 1 or 2 characters, matched as a whole word only; substring: a longer query; null without a query."),
308
- vendor_windows: WINDOWS,
398
+ vendor_windows: WINDOWS.describe(`Each vendor's window in what EchelonGraph holds, only that vendor's when vendor is given; null when the windows could not be read. ${NOT_HELD_DESCRIPTION}`),
309
399
  }),
310
400
  freshness: null,
311
401
  });
@@ -354,13 +444,23 @@ export function registerVendorAdvisoryTools(server, kit) {
354
444
  };
355
445
  const head = kit.okHead(tool, r.status);
356
446
  const held = w.ok ? notFullyHeldNote(cve, year, missing ?? []) : windowsUnknown(w.why);
447
+ // #2865: the queried CVE's own state, first, so no advisory below reads as a live CVE's.
448
+ const cveState = field(r.data, "cve_rejected");
449
+ const rejectedHead = cveState === true
450
+ ? ` CVE REJECTED: the CVE record of ${cve} was rejected (withdrawn) by its numbering authority (cve_rejected true). It is a rejected record, not an active vulnerability: report it that way, even where a vendor advisory below reads as a current one.`
451
+ : cveState === null
452
+ ? ` Whether the CVE record of ${cve} was rejected could not be checked (cve_rejected null), which is not a finding that it was not.`
453
+ : "";
357
454
  if (returned === 0) {
358
- return kit.succeeded(r.data, `${head} No vendor advisory on record names ${cve}: a measured empty result, EchelonGraph was queried successfully and found nothing (we looked and found nothing). This is not a lookup failure. ${ONLY_POLLED}${held}`, env, FOR_CVE_TEXT);
455
+ return kit.succeeded(r.data, `${head}${rejectedHead} No vendor advisory on record names ${cve}: a measured empty result, EchelonGraph was queried successfully and found nothing (we looked and found nothing). This is not a lookup failure. ${ONLY_POLLED}${held}`, env, FOR_CVE_TEXT);
359
456
  }
360
457
  if (rows === undefined)
361
- return kit.succeeded(r.data, `${head}${held}`, env, FOR_CVE_TEXT);
458
+ return kit.succeeded(r.data, `${head}${rejectedHead}${held}`, env, FOR_CVE_TEXT);
362
459
  const cap = atCap ? ` The API returns at most ${BY_CVE_CAP} advisories for one CVE, newest first, and this answer is full (at_cap true), so there may be more.` : "";
363
- return kit.succeeded(r.data, `${head} ${rows.length} vendor ${plural(rows.length, "advisory names", "advisories name")} ${cve}.${cap}${withdrawnNote(rows)} ${ONLY_POLLED}${held}`, env, FOR_CVE_TEXT);
460
+ // With cve_rejected true the head has said it of the one CVE every row names; the per-row
461
+ // note then only adds the other rejected IDs a multi-CVE advisory names.
462
+ const perRow = cveState === true ? rejectedCVENote(rows.map((x) => ({ ...x, rejected_cve_ids: rejectedIDs(x).filter((id) => id.toUpperCase() !== cve) }))) : rejectedCVENote(rows);
463
+ return kit.succeeded(r.data, `${head}${rejectedHead} ${rows.length} vendor ${plural(rows.length, "advisory names", "advisories name")} ${cve}.${cap}${withdrawnNote(rows)}${perRow}${rejectedUnknownNote(rows)}${remediationNote(cve, rows)} ${ONLY_POLLED}${held}`, env, FOR_CVE_TEXT);
364
464
  }
365
465
  catch (e) {
366
466
  return kit.crashed(tool, e);
@@ -412,7 +512,14 @@ export function registerVendorAdvisoryTools(server, kit) {
412
512
  ? ` Of the ${cveIDs.length} CVE IDs it lists (cve_ids), ${known.length} ${plural(known.length, "has", "have")} a record in EchelonGraph's CVE feed (known_cve_ids); the others have no record there yet, which does not mean they are not CVEs.`
413
513
  : "";
414
514
  const held = !w.ok ? windowsUnknown(w.why) : window ? backfillingNote([window]) : "";
415
- return kit.succeeded(r.data, `${kit.okHead(tool, r.status)} Returned the advisory ${shown}.${withdrawn}${cves}${held}`, env);
515
+ // #2865: the CVE IDs it names that were rejected, said whether or not the vendor withdrew it.
516
+ const rej = rejectedIDs(r.data);
517
+ const rejectedCVEs = rej.length
518
+ ? ` CVE REJECTED: ${rej.length} of the CVE IDs it lists ${plural(rej.length, "was", "were")} rejected (withdrawn) by ${plural(rej.length, "its", "their")} numbering authority (rejected_cve_ids): ${rej.join(", ")}. Report ${plural(rej.length, "it", "each")} as a rejected record, not as an active vulnerability${field(r.data, "withdrawn") === true ? "" : ", even though the vendor has not withdrawn this advisory and its text reads as a current one"}.`
519
+ : field(r.data, "rejected_cve_ids") === null && Array.isArray(cveIDs) && cveIDs.length > 0
520
+ ? " Whether a CVE ID it lists was rejected could not be checked (rejected_cve_ids null), which is not a finding that none was."
521
+ : "";
522
+ return kit.succeeded(r.data, `${kit.okHead(tool, r.status)} Returned the advisory ${shown}.${withdrawn}${rejectedCVEs}${cves}${held}`, env);
416
523
  }
417
524
  catch (e) {
418
525
  return kit.crashed(tool, e);
@@ -500,7 +607,8 @@ export function registerVendorAdvisoryTools(server, kit) {
500
607
  const counted = isCapped
501
608
  ? `${total.toLocaleString("en-US")}+ vendor advisories (total_capped true: the API stops counting a search's matches at ${total.toLocaleString("en-US")}, so total is a lower bound, not the count; paging past it still works)`
502
609
  : `${total} vendor ${plural(total, "advisory", "advisories")}`;
503
- return kit.succeeded(r.data, `${head} ${what} ${counted}${page}.${wordOnly}${held}`, env, searchText(a));
610
+ const rejected = rows === undefined ? "" : `${rejectedCVENote(rows)}${rejectedUnknownNote(rows)}`;
611
+ return kit.succeeded(r.data, `${head} ${what} ${counted}${page}.${rejected}${wordOnly}${held}`, env, searchText(a));
504
612
  }
505
613
  catch (e) {
506
614
  return kit.crashed(tool, e);
@@ -508,14 +616,14 @@ export function registerVendorAdvisoryTools(server, kit) {
508
616
  }
509
617
  server.registerTool("vendor_advisories_for_cve", {
510
618
  title: "Vendor advisories for one CVE",
511
- description: `The vendor-published advisories that name one CVE, newest first, at most 20: for each, vendor, vendor_display_name, vendor_advisory_id, title, severity and cvss_v3_score where the vendor gives them. Covers the vendor feeds EchelonGraph polls, for example Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks and GitHub GHSA; an empty answer means that none of the advisories EchelonGraph holds from those feeds names the CVE, not that no vendor published one (see vendor_windows and vendors_not_fully_held). ${DATES_DESCRIPTION} Pass a CVE ID like CVE-2024-21412. coverage gives returned, cap and at_cap (true: the answer is full, so there may be more); cve_year, the year in the CVE ID; vendor_windows, each vendor's window in what EchelonGraph holds: ${WINDOW_DESCRIPTION}; and vendors_not_fully_held, the vendors with no advisory in the answer of which EchelonGraph holds none, whose held_since is after 1 January of cve_year or not known, or whose history is still being read, which the note names. ${NOT_HELD_DESCRIPTION} vendor_windows and vendors_not_fully_held are null when the windows could not be read, and the note says so. measured_at is null. ${ENVELOPE_DESCRIPTION} ${TEXT_BUDGET_DESCRIPTION}${ROW_CUT_DESCRIPTION}.`,
619
+ description: `The vendor-published advisories that name one CVE, newest first, at most 20: for each, vendor, vendor_display_name, vendor_advisory_id, title, severity and cvss_v3_score where the vendor gives them. Covers the vendor feeds EchelonGraph polls, for example Microsoft MSRC, Red Hat, Cisco, Palo Alto Networks and GitHub GHSA; an empty answer means that none of the advisories EchelonGraph holds from those feeds names the CVE, not that no vendor published one. ${DATES_DESCRIPTION} ${REJECTED_CVE_DESCRIPTION} The answer's cve_rejected is true when the CVE record of the CVE asked for was rejected (withdrawn) by its numbering authority. Each row has remediation_kinds and remediation_state (not_parsed is not none). coverage gives returned, cap, at_cap (true: there may be more) and cve_year; vendor_windows, each vendor's window in what EchelonGraph holds: ${WINDOW_HEAD}; and vendors_not_fully_held, the vendors with no advisory in the answer of which EchelonGraph holds none, whose held_since is after 1 January of cve_year or not known, or whose history is still being read, which the note names. ${NOT_HELD_HEAD} ${TEXT_BUDGET_DESCRIPTION}${ROW_CUT_DESCRIPTION}.`,
512
620
  inputSchema: z.object({ cve_id: z.string().describe("a CVE ID, e.g. CVE-2024-21412") }),
513
621
  outputSchema: FOR_CVE_OUTPUT,
514
622
  annotations: kit.annotations,
515
623
  }, async ({ cve_id }) => kit.checked("vendor_advisories_for_cve", FOR_CVE_OUTPUT, await forCVE(cve_id)));
516
624
  server.registerTool("get_vendor_advisory", {
517
625
  title: "Vendor advisory detail",
518
- description: `One vendor advisory in full, by vendor and the vendor's advisory ID (the vendor and vendor_advisory_id of a vendor advisory row): title, description, severity, cvss_v3_score, cve_ids and known_cve_ids (those with a record in EchelonGraph's CVE feed), affected_products, remediation, references, vendor_modified_at, and withdrawn_at and withdrawn_reason when the vendor withdrew it. ${DATES_DESCRIPTION} coverage.vendor_window is that vendor's window in what EchelonGraph holds: ${WINDOW_DESCRIPTION}; null when the windows could not be read or carry none for that vendor. measured_at is our_first_seen_at. ${ENVELOPE_DESCRIPTION} ${TEXT_BUDGET_DESCRIPTION}`,
626
+ description: `One vendor advisory in full, by vendor and the vendor's advisory ID (the vendor and vendor_advisory_id of a vendor advisory row): title, description, severity, cvss_v3_score, cve_ids and known_cve_ids (those with a record in EchelonGraph's CVE feed), affected_products, remediation, references, vendor_modified_at, and withdrawn_at and withdrawn_reason when the vendor withdrew it. remediations lists each remediation the vendor lists, with kind (the vendor's own category, never read from the text: vendor_fix, workaround, mitigation, no_fix_planned, none_available or other), source_category, text, url and the cve_ids and product_ids it covers; each text is the vendor's, verbatim, untested by EchelonGraph. remediation_state is parsed, none_in_source, not_parsed or withdrawn: none_in_source and not_parsed are not a finding that no fix exists. ${DATES_DESCRIPTION} ${REJECTED_CVE_DESCRIPTION} coverage.vendor_window is that vendor's window in what EchelonGraph holds: ${WINDOW_HEAD}; null when the windows could not be read or carry none for that vendor. measured_at is our_first_seen_at. ${ENVELOPE_DESCRIPTION} ${TEXT_BUDGET_DESCRIPTION}`,
519
627
  inputSchema: z.object({
520
628
  vendor: z.string().describe("the vendor slug, e.g. microsoft, redhat, github"),
521
629
  advisory_id: z.string().describe("the vendor's advisory ID, e.g. RHSA-2024:1234 or GHSA-xxxx-xxxx-xxxx"),
@@ -525,7 +633,7 @@ export function registerVendorAdvisoryTools(server, kit) {
525
633
  }, async ({ vendor, advisory_id }) => kit.checked("get_vendor_advisory", DETAIL_OUTPUT, await detail(vendor, advisory_id)));
526
634
  server.registerTool("search_vendor_advisories", {
527
635
  title: "Search vendor advisories",
528
- description: `Search or list vendor-published advisories, newest first. ${SEARCH_MATCH_DESCRIPTION} Filter by vendor (slug), by severity band, and by whether the advisory names any CVE ID. Advisories their vendor withdrew are left out. Returns advisories, each with vendor, vendor_advisory_id, title, severity, cvss_v3_score, cve_ids, summary and affected_products where present, and the answer's total, total_capped, limit, offset, search_applied and search_match. ${TOTAL_CAPPED_DESCRIPTION} ${DATES_DESCRIPTION} The query is sent in a request header, never in the URL. coverage repeats total, total_capped, limit, offset, search_applied and search_match, and gives returned, the rows in this page, and vendor_windows, each vendor's window in what EchelonGraph holds (only that vendor's when vendor is given): ${WINDOW_DESCRIPTION}. ${NOT_HELD_DESCRIPTION} measured_at is null. ${ENVELOPE_DESCRIPTION} ${TEXT_BUDGET_DESCRIPTION}${ROW_CUT_DESCRIPTION}, or rows are left out and the note gives the offset to call next.`,
636
+ description: `Search or list vendor-published advisories, newest first. ${SEARCH_MATCH_HEAD} Filter by vendor (slug), severity band and whether the advisory names any CVE ID. Advisories their vendor withdrew are left out. Returns advisories, each with vendor, vendor_advisory_id, title, severity, cvss_v3_score, cve_ids, summary and affected_products where present, and total, total_capped, limit, offset, search_applied and search_match. ${TOTAL_CAPPED_HEAD} ${REJECTED_CVE_DESCRIPTION} The query is sent in a request header, never in the URL. coverage repeats those beside returned, and gives vendor_windows, each vendor's window in what EchelonGraph holds: ${WINDOW_HEAD}. ${NOT_HELD_HEAD} ${TEXT_BUDGET_DESCRIPTION}${ROW_CUT_DESCRIPTION}, or rows are left out and the note gives the offset to call next.`,
529
637
  inputSchema: z.object({
530
638
  query: z.string().optional().describe("free text, at most 100 bytes: a product, an advisory ID, a CVE ID or a keyword, e.g. 'exchange server'; 1 or 2 characters match whole words only"),
531
639
  vendor: z.string().optional().describe("a vendor slug, e.g. microsoft, redhat, github"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "echelongraph-mcp",
3
- "version": "2.6.8",
3
+ "version": "2.7.0",
4
4
  "mcpName": "io.echelongraph/echelongraph-mcp",
5
5
  "description": "MCP server for CVE lookups: EchelonGraph scores, CISA KEV, EPSS, CWE, exploits, version and SBOM checks, vendor advisories, and per-CVE exposure derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan). Keyless.",
6
6
  "type": "module",
@@ -39,6 +39,8 @@
39
39
  "cwe",
40
40
  "cvss",
41
41
  "sbom",
42
+ "lockfile",
43
+ "sca",
42
44
  "security-advisories",
43
45
  "threat-intelligence",
44
46
  "exposure",
package/server.json CHANGED
@@ -17,7 +17,7 @@
17
17
  ]
18
18
  }
19
19
  ],
20
- "version": "2.6.8",
20
+ "version": "2.7.0",
21
21
  "remotes": [
22
22
  {
23
23
  "type": "streamable-http",
@@ -28,7 +28,7 @@
28
28
  {
29
29
  "registryType": "npm",
30
30
  "identifier": "echelongraph-mcp",
31
- "version": "2.6.8",
31
+ "version": "2.7.0",
32
32
  "transport": {
33
33
  "type": "stdio"
34
34
  },