echelongraph-mcp 2.6.3 → 2.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +191 -2
- package/README.md +129 -35
- package/dist/http.js +111 -33
- package/dist/httpPolicy.js +160 -4
- package/dist/index.js +126 -42
- package/dist/prompts.js +60 -10
- package/dist/resources.js +61 -12
- package/dist/runtime.js +26 -4
- package/dist/textBudget.js +11 -4
- package/dist/tools/check_affected.js +24 -15
- package/dist/tools/check_sbom.js +43 -8
- package/dist/tools/get_cwe.js +2 -2
- package/dist/tools/kev_recent.js +3 -2
- package/dist/tools/vendor_advisories.js +3 -1
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/resources.js
CHANGED
|
@@ -5,16 +5,19 @@
|
|
|
5
5
|
// docs). No API request.
|
|
6
6
|
// echelongraph://sources the feeds and what the API itself reports about their polling,
|
|
7
7
|
// read from the API at request time and never hard-coded: the CVE
|
|
8
|
-
// summary's poller block (/api/v1/public/cves/summary
|
|
8
|
+
// summary's poller block (/api/v1/public/cves/summary; its interval,
|
|
9
|
+
// last poll and two counters, the answering instance's) and the KEV
|
|
9
10
|
// answer's catalog block and method (/api/v1/public/kev/recent).
|
|
10
11
|
// What no endpoint reports is named as not reported.
|
|
11
12
|
// cve://{cve_id} get_cve's structured result for one CVE, as JSON: the envelope
|
|
12
|
-
// (state, measured_at, method, notes) with data, the API's record
|
|
13
|
+
// (state, measured_at, method, notes) with data, the API's record,
|
|
14
|
+
// cut past 30,000 characters as get_cve's first text block is.
|
|
13
15
|
//
|
|
14
16
|
// A resource read either answers or is a JSON-RPC error; it never answers a failure as data that
|
|
15
17
|
// reads like a record. sources is the exception by design: each feed it reads carries its own
|
|
16
18
|
// state, and a feed that could not be read says so (state failed), never an empty block.
|
|
17
19
|
import { ProtocolError, ProtocolErrorCode, ResourceNotFoundError, ResourceTemplate } from "@modelcontextprotocol/server";
|
|
20
|
+
import { DATA_TEXT_BUDGET, dataText } from "./textBudget.js";
|
|
18
21
|
export const METHODOLOGY_URI = "echelongraph://methodology";
|
|
19
22
|
export const SOURCES_URI = "echelongraph://sources";
|
|
20
23
|
export const CVE_TEMPLATE = "cve://{cve_id}";
|
|
@@ -22,7 +25,7 @@ export const METHODOLOGY = `# How EchelonGraph MCP results are measured
|
|
|
22
25
|
|
|
23
26
|
Every tool result carries an evidence envelope in its structuredContent, and repeats it in its last text block (less what an earlier text block already carries verbatim), so a client that passes text blocks alone still sees it.
|
|
24
27
|
|
|
25
|
-
A success's first text block is the API's JSON, cut to fit 30,000 characters when it is longer: the note then says what the cut leaves out and how to read it (TEXT CUT), and structuredContent's data holds the answer whole.
|
|
28
|
+
A success's first text block is the API's JSON, cut to fit 30,000 characters when it is longer: the note then says what the cut leaves out and how to read it (TEXT CUT), and structuredContent's data holds the answer whole. The resource ${CVE_TEMPLATE} cuts its data the same way, and its notes say so (DATA CUT).
|
|
26
29
|
|
|
27
30
|
## Envelope fields
|
|
28
31
|
|
|
@@ -34,7 +37,7 @@ A success's first text block is the API's JSON, cut to fit 30,000 characters whe
|
|
|
34
37
|
| coverage | What the answer covers, where the tool can say: for example in_scope for cve_exposure, the list's account of its own count for search_cves, assessed and not_assessed_reason first for check_affected. |
|
|
35
38
|
| freshness | The producing radar's or fetcher's last completed check, where the API serves one; null where it serves none. |
|
|
36
39
|
| notes | The caveats, one sentence each. |
|
|
37
|
-
| data | On a success: the API's JSON (exposure_radar and cve_intel relay a labelled selection). |
|
|
40
|
+
| data | On a success: the API's JSON (exposure_radar and cve_intel relay a labelled selection, and cve_summary leaves out a poller field of another JSON type, named in the note). |
|
|
38
41
|
| error | On a failure: kind, path, status and message. |
|
|
39
42
|
|
|
40
43
|
## state
|
|
@@ -70,9 +73,17 @@ const KEV_PATH = "/api/v1/public/kev/recent";
|
|
|
70
73
|
const obj = (v) => (v !== null && typeof v === "object" && !Array.isArray(v) ? v : undefined);
|
|
71
74
|
const pick = (o, keys) => Object.fromEntries(keys.filter((k) => o !== undefined && o[k] !== undefined).map((k) => [k, o[k]]));
|
|
72
75
|
const failure = (r) => ({ state: "failed", error: { kind: r.kind, path: r.path, status: r.status ?? null, message: r.detail } });
|
|
73
|
-
//
|
|
74
|
-
|
|
76
|
+
// A JSON value's type in words, for a value that is not the JSON object read here.
|
|
77
|
+
const jsonKind = (v) => Array.isArray(v) ? "an array" : typeof v === "number" ? "a number" : typeof v === "boolean" ? "a boolean" : typeof v === "string" ? "a string" : typeof v;
|
|
78
|
+
// What /api/v1/public/cves/summary reports about the NVD poller: its interval and last poll, and
|
|
79
|
+
// its poll and failed-poll counts, of the server instance that answered (core-backend
|
|
80
|
+
// cve/poller.go Stats, in memory).
|
|
75
81
|
const POLLER_FIELDS = ["interval", "last_poll_at", "poll_count", "poll_errors"];
|
|
82
|
+
// #2770: poll_count and poll_errors are that instance's counters since it last started, zeroed on
|
|
83
|
+
// every restart (cve_summary's summaryPollerNote, #2647, says the same of the whole block). 2.6.3
|
|
84
|
+
// relayed them here with a note on interval and last_poll_at alone, so poll_errors 0 over
|
|
85
|
+
// poll_count 7 read as the feed's track record. Whenever reported carries either, notes says so.
|
|
86
|
+
const SOURCES_POLLER_COUNTERS_NOTE = "poll_count and poll_errors, where reported carries them, are that one instance's counters since it last started, zeroed on every restart: they count that instance's polls and failed polls, never the feed's reliability.";
|
|
76
87
|
const CATALOG_FIELDS = ["source", "feed_url", "last_successful_fetch_at", "catalog_version", "date_released", "catalog_count"];
|
|
77
88
|
export async function readSources(kit) {
|
|
78
89
|
const [summary, kev] = await Promise.all([kit.api(SUMMARY_PATH), kit.api(KEV_PATH, { headers: { "X-EG-Limit": "1" } })]);
|
|
@@ -80,16 +91,29 @@ export async function readSources(kit) {
|
|
|
80
91
|
if (!summary.ok)
|
|
81
92
|
Object.assign(nvd, failure(summary));
|
|
82
93
|
else {
|
|
83
|
-
const
|
|
84
|
-
|
|
94
|
+
const sent = obj(summary.data)?.poller;
|
|
95
|
+
const poller = obj(sent);
|
|
96
|
+
if (poller === undefined && sent !== undefined && sent !== null) {
|
|
97
|
+
// #2770's review: a poller of another JSON type (cve_summary leaves it out and names it,
|
|
98
|
+
// #2771) is one the answer carries, so it is never described as missing.
|
|
99
|
+
Object.assign(nvd, {
|
|
100
|
+
state: "not_reported",
|
|
101
|
+
reported: null,
|
|
102
|
+
notes: [`The answer carries poller as ${jsonKind(sent)}, where a JSON object was expected, so it reports neither a poll interval nor a last poll time.`],
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
else if (poller === undefined) {
|
|
85
106
|
Object.assign(nvd, { state: "not_reported", reported: null, notes: ["The answer carries no poller block, so it reports neither a poll interval nor a last poll time."] });
|
|
86
107
|
}
|
|
87
108
|
else {
|
|
109
|
+
const reported = pick(poller, POLLER_FIELDS);
|
|
110
|
+
const counted = reported.poll_count !== undefined || reported.poll_errors !== undefined;
|
|
88
111
|
Object.assign(nvd, {
|
|
89
112
|
state: "reported",
|
|
90
|
-
reported
|
|
113
|
+
reported,
|
|
91
114
|
notes: [
|
|
92
115
|
"poller is the NVD poller of the server instance that answered, held in its memory: interval is its configured interval (each cycle adds a start-up jitter), and last_poll_at is that instance's last poll, not a fleet-wide time.",
|
|
116
|
+
...(counted ? [SOURCES_POLLER_COUNTERS_NOTE] : []),
|
|
93
117
|
],
|
|
94
118
|
});
|
|
95
119
|
}
|
|
@@ -121,6 +145,31 @@ export async function readSources(kit) {
|
|
|
121
145
|
};
|
|
122
146
|
}
|
|
123
147
|
const json = (uri, v) => ({ contents: [{ uri, mimeType: "application/json", text: JSON.stringify(v, null, 2) }] });
|
|
148
|
+
// cve://{cve_id}'s text (#2801). Through 2.6.3 it was get_cve's structuredContent pretty-printed
|
|
149
|
+
// whole: 86,559 characters for CVE-2021-44228 on the hosted endpoint (2026-10-04), past #2467's
|
|
150
|
+
// ceiling of 60,000 (test/text-bound.mjs), its notes ending with get_cve's TEXT CUT sentences, which
|
|
151
|
+
// describe a first text block the resource does not have ("Every list in it keeps at most its first
|
|
152
|
+
// 64 entries: cpe_match (396)…" beside all 396 of them). A resource is one text, with no
|
|
153
|
+
// structuredContent beside it, so its data is now cut as get_cve's first text block is (dataText:
|
|
154
|
+
// the same answer, cut the same way, laid out as that block lays it out) after the envelope,
|
|
155
|
+
// pretty-printed as before; its notes leave out get_cve's sentences about its first text block and,
|
|
156
|
+
// when data is cut, end with the resource's own, starting DATA CUT, which names get_cve's
|
|
157
|
+
// structuredContent.data as the record whole. A record that fits is the structured result
|
|
158
|
+
// pretty-printed, character for character as before.
|
|
159
|
+
const CVE_RESOURCE_CUT = {
|
|
160
|
+
opens: (chars) => `DATA CUT: the API's answer is ${chars} characters of JSON, more than the ${DATA_TEXT_BUDGET} this resource's data holds, so data here is cut, and get_cve's structuredContent.data carries the answer whole.`,
|
|
161
|
+
};
|
|
162
|
+
export function cveResourceText(structured, sentences) {
|
|
163
|
+
const { data, ...envelope } = structured;
|
|
164
|
+
if (data === null || typeof data !== "object" || Array.isArray(data))
|
|
165
|
+
return JSON.stringify(structured, null, 2);
|
|
166
|
+
const firstBlock = new Set(sentences(dataText(data).said));
|
|
167
|
+
const own = dataText(data, undefined, CVE_RESOURCE_CUT);
|
|
168
|
+
const notes = Array.isArray(envelope.notes) ? [...envelope.notes.filter((n) => !firstBlock.has(n)), ...sentences(own.said)] : envelope.notes;
|
|
169
|
+
const head = JSON.stringify({ ...envelope, ...(notes === undefined ? {} : { notes }) }, null, 2);
|
|
170
|
+
const body = own.text.replace(/\n/g, "\n ");
|
|
171
|
+
return head === "{}" ? `{\n "data": ${body}\n}` : `${head.slice(0, -2)},\n "data": ${body}\n}`;
|
|
172
|
+
}
|
|
124
173
|
export function registerResources(server, kit) {
|
|
125
174
|
server.registerResource("methodology", METHODOLOGY_URI, {
|
|
126
175
|
title: "How results are measured",
|
|
@@ -129,12 +178,12 @@ export function registerResources(server, kit) {
|
|
|
129
178
|
}, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: METHODOLOGY }] }));
|
|
130
179
|
server.registerResource("sources", SOURCES_URI, {
|
|
131
180
|
title: "Feed sources and polling",
|
|
132
|
-
description: "The feeds and what the API reports about their polling, read from the API when the resource is read: the NVD poller's interval
|
|
181
|
+
description: "The feeds and what the API reports about their polling, read from the API when the resource is read: the NVD poller's interval, last poll, poll_count and poll_errors, all of the one instance that answered (the two counts since that instance last started, zeroed on every restart, never the feed's reliability), and the CISA KEV catalog's last successful fetch and method. What no endpoint reports is named as not reported.",
|
|
133
182
|
mimeType: "application/json",
|
|
134
183
|
}, async (uri) => json(uri.href, await readSources(kit)));
|
|
135
184
|
server.registerResource("cve", new ResourceTemplate(CVE_TEMPLATE, { list: undefined }), {
|
|
136
185
|
title: "CVE record",
|
|
137
|
-
description: "One CVE's record as get_cve returns it, as JSON: the evidence envelope (state, measured_at, method, notes) with data, the API's record. cve_id is a CVE ID such as CVE-2024-3400.",
|
|
186
|
+
description: "One CVE's record as get_cve returns it, as JSON: the evidence envelope (state, measured_at, method, notes) with data, the API's record. Past 30,000 characters of JSON, data is cut as get_cve's first text block is, and a note starting DATA CUT says what the cut leaves out; get_cve's structured result holds the record whole. cve_id is a CVE ID such as CVE-2024-3400.",
|
|
138
187
|
mimeType: "application/json",
|
|
139
188
|
}, async (uri, vars) => {
|
|
140
189
|
const raw = Array.isArray(vars.cve_id) ? vars.cve_id[0] : vars.cve_id;
|
|
@@ -150,6 +199,6 @@ export function registerResources(server, kit) {
|
|
|
150
199
|
throw new ResourceNotFoundError(uri.href);
|
|
151
200
|
throw new ProtocolError(ProtocolErrorCode.InternalError, msg, { uri: uri.href, state: r.structuredContent.state ?? null, error: err ?? null });
|
|
152
201
|
}
|
|
153
|
-
return
|
|
202
|
+
return { contents: [{ uri: uri.href, mimeType: "application/json", text: cveResourceText(r.structuredContent, kit.sentences) }] };
|
|
154
203
|
});
|
|
155
204
|
}
|
package/dist/runtime.js
CHANGED
|
@@ -18,12 +18,34 @@ export function markHttpEntrypoint() {
|
|
|
18
18
|
export function isHttpEntrypoint() {
|
|
19
19
|
return httpEntrypoint;
|
|
20
20
|
}
|
|
21
|
-
// The client a tool call is being answered for. http.ts runs each request inside
|
|
22
|
-
// withClient(), and the SDK dispatches the tool handler inside that same async context, so
|
|
23
|
-
// api() can read it without the tool module passing it through every function.
|
|
24
21
|
const clientStore = new AsyncLocalStorage();
|
|
25
22
|
export function withClient(clientIp, fn, opts = {}) {
|
|
26
|
-
return clientStore.run({ clientIp, synthetic: opts.synthetic === true }, fn);
|
|
23
|
+
return clientStore.run({ clientIp, synthetic: opts.synthetic === true, signal: opts.signal, work: opts.work }, fn);
|
|
24
|
+
}
|
|
25
|
+
// #2775: a hosted client can go before its answer is ready (its own timeout, a closed tab, a
|
|
26
|
+
// retry). Nobody will read that answer, so the request's API calls are cancelled (index.ts api()
|
|
27
|
+
// reads this signal) and check_sbom sends no further batch: they would spend the client's API
|
|
28
|
+
// budget and hold the instance's memory for nothing. On stdio there is none: the SDK's own
|
|
29
|
+
// cancellation (notifications/cancelled) reaches check_sbom through its handler's signal.
|
|
30
|
+
export function requestSignal() {
|
|
31
|
+
return httpEntrypoint ? clientStore.getStore()?.signal : undefined;
|
|
32
|
+
}
|
|
33
|
+
/** Registers `p` as the hosted request's work, so its admission and large-body slot are held until p settles; on stdio, p untouched. */
|
|
34
|
+
export function holdRequest(p) {
|
|
35
|
+
const work = httpEntrypoint ? clientStore.getStore()?.work : undefined;
|
|
36
|
+
if (!work)
|
|
37
|
+
return p;
|
|
38
|
+
work.add(p);
|
|
39
|
+
const drop = () => {
|
|
40
|
+
work.delete(p);
|
|
41
|
+
};
|
|
42
|
+
p.then(drop, drop);
|
|
43
|
+
return p;
|
|
44
|
+
}
|
|
45
|
+
/** Resolves once everything held for a request (holdRequest) has settled, including what is held while it waits. */
|
|
46
|
+
export async function requestSettled(work) {
|
|
47
|
+
while (work.size > 0)
|
|
48
|
+
await Promise.allSettled([...work]);
|
|
27
49
|
}
|
|
28
50
|
// #2737: the production MCP synthetic probes this endpoint too, and its calls must reach the API
|
|
29
51
|
// under its own family, never as hosted MCP adoption. The synthetic names itself to this service
|
package/dist/textBudget.js
CHANGED
|
@@ -34,9 +34,13 @@
|
|
|
34
34
|
// 4. else every list keeps its first n entries, n halving, and then strings are cut too.
|
|
35
35
|
// Whenever the text is not the JSON whole, the note ends with one sentence, starting TEXT CUT,
|
|
36
36
|
// that says what the first text block leaves out, that structuredContent.data carries all of it,
|
|
37
|
-
// and how to read the rest in the text: the tool that returns one row
|
|
38
|
-
// offset at which a page fits. The cut is deterministic: the same answer is cut the same way.
|
|
37
|
+
// and how to read the rest in the text: the tool that returns one row, and what of it, and the page
|
|
38
|
+
// size or offset at which a page fits. The cut is deterministic: the same answer is cut the same way.
|
|
39
39
|
export const DATA_TEXT_BUDGET = 30_000;
|
|
40
|
+
// What a note may say of reading one CVE's record through get_cve (#2802): get_cve's own first text
|
|
41
|
+
// block is cut too past the budget (each list to its first entries, every other field kept), so
|
|
42
|
+
// only its structuredContent.data is the record whole. A row tool's `whole` sentence starts with it.
|
|
43
|
+
export const GET_CVE_WHOLE = "get_cve returns any one of these CVEs' records, whole in its structuredContent.data, and in its first text block with every field (past 30,000 characters, each list cut to its first entries)";
|
|
40
44
|
// What every tool description says about it, one constant string (marketing-site
|
|
41
45
|
// lib/mcpToolClaims.test.ts folds only constant strings); the suite holds its number to
|
|
42
46
|
// DATA_TEXT_BUDGET's. It uses none of the words that check reviews as the page's own prose (how,
|
|
@@ -158,8 +162,11 @@ function cutSides(data, sides) {
|
|
|
158
162
|
}
|
|
159
163
|
return { fields, said };
|
|
160
164
|
}
|
|
165
|
+
export const FIRST_TEXT_BLOCK = {
|
|
166
|
+
opens: (chars) => `TEXT CUT: the API's answer is ${chars} characters of JSON, more than the ${DATA_TEXT_BUDGET} the first text block holds, so that block is cut, and structuredContent.data carries the answer whole.`,
|
|
167
|
+
};
|
|
161
168
|
/** The first text block for `data` within DATA_TEXT_BUDGET, and the TEXT CUT sentence when it is cut. */
|
|
162
|
-
export function dataText(data, cut) {
|
|
169
|
+
export function dataText(data, cut, place = FIRST_TEXT_BLOCK) {
|
|
163
170
|
const pretty = JSON.stringify(data, null, 2);
|
|
164
171
|
if (pretty.length <= DATA_TEXT_BUDGET)
|
|
165
172
|
return { text: pretty, said: "" };
|
|
@@ -167,7 +174,7 @@ export function dataText(data, cut) {
|
|
|
167
174
|
const all = layout(data);
|
|
168
175
|
if (all.length <= DATA_TEXT_BUDGET)
|
|
169
176
|
return { text: all, said: "" };
|
|
170
|
-
const head =
|
|
177
|
+
const head = place.opens(pretty.length);
|
|
171
178
|
const say = (...xs) => [head, ...xs].filter((x) => Boolean(x)).join(" ");
|
|
172
179
|
let base = data;
|
|
173
180
|
let rowsSaid = "";
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
// The JSON is relayed verbatim as data (#1874): every match keeps kev_listed, ransomware,
|
|
28
28
|
// epss_score, effective_score, effective_severity and score_assessed as the API sent them.
|
|
29
29
|
import * as z from "zod";
|
|
30
|
-
import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
30
|
+
import { GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
31
31
|
const TOOL = "check_affected";
|
|
32
32
|
export const MATCH_PATH = "/api/v1/public/cves/match";
|
|
33
33
|
// handler.go, the four request headers the endpoint reads before the query string.
|
|
@@ -129,7 +129,7 @@ export const CHECK_AFFECTED_TITLE = "Am I affected? (product or package at a ver
|
|
|
129
129
|
export const CHECK_AFFECTED_DESCRIPTION = "Whether a product or package at a given version is affected by known CVEs, from the same matcher as echelongraph.io/am-i-affected. Two lookup paths. The CPE path takes product (the NVD CPE product token, such as openssl or nginx) and version, and returns the CVEs whose NVD CPE match criteria name that product with a version range that includes the version; it matches the token across vendors, so each match names the vendor NVD asserts (cpe_vendor) and whether that vendor was verified (vendor_unknown). The registry path takes ecosystem (npm, PyPI, Maven and other OSV ecosystem names), package and version, and decides each OSV advisory record EchelonGraph holds for that package as affected, not affected or undetermined. Read assessed before count: assessed false means the lookup did not evaluate this component, not_assessed_reason says why (product_not_in_cpe_corpus, package_not_cpe_nameable, candidate_load_pending, candidate_window_truncated, package_not_in_advisory_corpus, no_decidable_advisory), the structured result's state is not_assessed, and a count of 0 there must never be reported as not affected. An advisory whose version range cannot be decided at this version is reported as undetermined (undetermined_count, and up to 50 of them in undetermined), never as safe. The answer also carries match_layer (which path answered), capped (the match list stopped at its cap), candidates_capped (not every candidate CVE was loaded), excluded_count (CPE candidates suppressed by the vendor or platform gate), not_affected_count (advisories decided in your favour) and degraded (the lookup ran out of time). Each match carries cve_id, kev_listed, ransomware, epss_score, effective_score, effective_severity and score_assessed (false: EchelonGraph has not scored the CVE yet, so its echelongraph_score is withheld). Product, version, ecosystem and package travel in request headers, never in the URL. Its structured result carries state (measured only when the answer says assessed true), measured_at (null), method (which matcher answered), coverage (assessed and not_assessed_reason first, then the lookup path and the counts above), freshness (null) and notes, with data equal to the API's JSON." +
|
|
130
130
|
" " +
|
|
131
131
|
TEXT_BUDGET_DESCRIPTION +
|
|
132
|
-
" Cut,
|
|
132
|
+
" Cut, the excluded and undetermined samples keep their first 10 entries, each its cve_id and reason, cve_ids keeps its first 10 (each match carries its cve_id), and each match keeps fewer fields, cve_id, kev_listed, ransomware, epss_score, effective_score and score_assessed at least, so that every match stays in the text.";
|
|
133
133
|
export const CHECK_AFFECTED_INPUT = z.object({
|
|
134
134
|
product: z.string().optional().describe("CPE path: the NVD CPE product token, such as openssl, nginx or linux_kernel. Leave out for the registry path."),
|
|
135
135
|
ecosystem: z.string().optional().describe("Registry path: the package's ecosystem, such as npm, PyPI or Maven. Give with package, not with product."),
|
|
@@ -174,15 +174,23 @@ function matchesNote(matches) {
|
|
|
174
174
|
// text block keeps its scores, flags, vendor attribution and match_reason (for a registry match,
|
|
175
175
|
// the advisory interval its version falls inside, which names the fixed version) with the
|
|
176
176
|
// description cut to 200 characters; then the same without the description; then only the fields
|
|
177
|
-
// the description promises;
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
177
|
+
// the description promises; then, for a list at the cap, without cpe_vendor and vendor_unknown;
|
|
178
|
+
// and last without effective_severity, the band of effective_score. kev_listed, ransomware,
|
|
179
|
+
// epss_score, effective_score and score_assessed stay in every level (#2799): the am_i_affected
|
|
180
|
+
// prompt asks for each match's ransomware and epss_score, and through 2.6.3 the last level left
|
|
181
|
+
// them out of every match of a list at the cap (hosted linux_kernel 5.10.0, 200 matches, 1 with
|
|
182
|
+
// known ransomware use, named nowhere in the text). 200 matches with effective_severity beside
|
|
183
|
+
// them are 33,240 characters, past the budget alone, so the band goes first. The lists beside the
|
|
184
|
+
// matches, the excluded sample (up to 50 CPE candidates the vendor or platform gate suppressed,
|
|
185
|
+
// each with a sentence of detail, 13,861 characters for flash_player 10.0.0) and the undetermined
|
|
186
|
+
// sample (up to 50), keep their first 10 entries, each its cve_id and reason, once the first level
|
|
187
|
+
// does not fit with them whole, before any match field that level keeps goes (50 of them beside
|
|
188
|
+
// 200 matches left the last level 120 characters past the budget for flash_player 10.0.0, and
|
|
189
|
+
// 1,071 for a registry answer at the cap with 50 undetermined); and the registry path's cve_ids,
|
|
190
|
+
// which is each match's cve_id in the order of matches (core-backend cve/handler.go), keeps its
|
|
191
|
+
// first 10, so that every match stays in the text, 200 at the cap: this tool has no page to read
|
|
192
|
+
// more from. excluded_count and undetermined_count count every entry. structuredContent.data keeps
|
|
193
|
+
// every list whole.
|
|
186
194
|
const CHECK_AFFECTED_TEXT = {
|
|
187
195
|
rows: "matches",
|
|
188
196
|
levels: [
|
|
@@ -208,14 +216,15 @@ const CHECK_AFFECTED_TEXT = {
|
|
|
208
216
|
clip: 100,
|
|
209
217
|
},
|
|
210
218
|
{ keep: ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "effective_severity", "score_assessed", "cpe_vendor", "vendor_unknown"], clip: 100 },
|
|
211
|
-
{ keep: ["cve_id", "kev_listed", "effective_score", "effective_severity", "score_assessed"], clip: 100 },
|
|
219
|
+
{ keep: ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "effective_severity", "score_assessed"], clip: 100 },
|
|
220
|
+
{ keep: ["cve_id", "kev_listed", "ransomware", "epss_score", "effective_score", "score_assessed"], clip: 100 },
|
|
212
221
|
],
|
|
213
222
|
sides: [
|
|
214
223
|
{ list: "cve_ids", cap: 10, said: () => "cve_ids lists each match's cve_id, in the order of matches." },
|
|
215
|
-
{ list: "excluded", keep: ["cve_id", "reason"], clip: 100 },
|
|
216
|
-
{ list: "undetermined", keep: ["cve_id", "reason"], clip: 100 },
|
|
224
|
+
{ list: "excluded", keep: ["cve_id", "reason"], clip: 100, cap: 10 },
|
|
225
|
+
{ list: "undetermined", keep: ["cve_id", "reason"], clip: 100, cap: 10 },
|
|
217
226
|
],
|
|
218
|
-
whole:
|
|
227
|
+
whole: `${GET_CVE_WHOLE}, and cve_intel a CVE's affected packages and fixed versions.`,
|
|
219
228
|
};
|
|
220
229
|
async function checkAffected(kit, a) {
|
|
221
230
|
const product = (a.product ?? "").trim();
|
package/dist/tools/check_sbom.js
CHANGED
|
@@ -42,6 +42,12 @@ const NOTE_AFFECTED_MAX = 25;
|
|
|
42
42
|
const NOTE_CVES_MAX = 5;
|
|
43
43
|
// CycloneDX nests components; this bounds the walk.
|
|
44
44
|
const MAX_DEPTH = 32;
|
|
45
|
+
// The order in which extract() reads and counts the distinct purls (#2799 review): purls as listed;
|
|
46
|
+
// a CycloneDX document's components depth first, each one's nested components right after it
|
|
47
|
+
// (fromCycloneDX); an SPDX document's packages as listed; each purl trimmed and kept at its first
|
|
48
|
+
// place (dedupe). The note says it beside the position from which the purls not sent run, so a
|
|
49
|
+
// second call can rebuild them from its own input whether or not the text cuts not_sent_purls.
|
|
50
|
+
const READ_ORDER = "counted in the order this tool read them (the order of purls, or of the document's components or packages, each component's nested components right after it and before its next sibling, each purl at its first place)";
|
|
45
51
|
const METHOD = "EchelonGraph's registry advisory matcher (POST /api/v1/public/cves/match/batch): each component's purl is mapped to its OSV ecosystem, package name and version, and matched against the affected version ranges EchelonGraph holds from OSV.dev advisory records, the same matcher GET /api/v1/public/cves/match uses when given an ecosystem. No CPE matching, no score, no ranking.";
|
|
46
52
|
// Always in the envelope's notes, whatever the answer: the refusals a model must not read as clean.
|
|
47
53
|
const DISTRO_NOTE = "A deb, apk or rpm purl without a distro qualifier naming its release (for example ?distro=debian-12 or ?distro=alpine-3.20) is not assessed (distro_release_unknown): the advisory corpus is keyed per distro release, and EchelonGraph does not guess one.";
|
|
@@ -68,7 +74,8 @@ export const CHECK_SBOM_DESCRIPTION = "Check a dependency list against EchelonGr
|
|
|
68
74
|
// fit with that list whole, the text keeps its first 10 and the note says which they are: the
|
|
69
75
|
// input's distinct purls from position `sent` + 1 on, in input order, which is how check_sbom read
|
|
70
76
|
// them (x.purls.slice(answers.length * MAX_COMPONENTS)), so a second call can send them without
|
|
71
|
-
// reading the list.
|
|
77
|
+
// reading the list. noteFor gives that order (READ_ORDER) beside the position, so this sentence
|
|
78
|
+
// points to it.
|
|
72
79
|
export function sbomText(sent) {
|
|
73
80
|
const matches = ["cve_id", "severity", "effective_severity", "effective_score", "kev_listed", "ransomware", "epss_score", "score_assessed"];
|
|
74
81
|
const row = ["index", "purl", "ecosystem", "package", "version", "verdict", "assessed", "not_assessed_reason", "count", "not_affected_count", "undetermined_count", "cve_ids"];
|
|
@@ -83,11 +90,13 @@ export function sbomText(sent) {
|
|
|
83
90
|
{
|
|
84
91
|
list: "not_sent_purls",
|
|
85
92
|
cap: 10,
|
|
86
|
-
said: (_inText, total) => `The ${total} purls not sent are the input's distinct purls from position ${sent + 1} on, in the order
|
|
93
|
+
said: (_inText, total) => `The ${total} purls not sent are the input's distinct purls from position ${sent + 1} on, counted in the order the note gives for them, so a second call can send them without reading the list.`,
|
|
87
94
|
},
|
|
88
95
|
],
|
|
89
96
|
leaveOutFirst: { field: "verdict", values: ["not_affected", "not_assessed"], why: "data.summary counts every verdict" },
|
|
90
|
-
|
|
97
|
+
// check_affected cuts its text too past the budget (#2802): only its structuredContent.data is
|
|
98
|
+
// a component's matches whole.
|
|
99
|
+
whole: "check_affected, given a row's ecosystem, package and version, returns that component's matches, whole in its structuredContent.data, and cve_intel a CVE's affected packages and fixed versions.",
|
|
91
100
|
page: (shown) => `To read every row in the text, check ${shown} or fewer purls per call.`,
|
|
92
101
|
};
|
|
93
102
|
}
|
|
@@ -236,9 +245,17 @@ function noteFor(head, x, d, g) {
|
|
|
236
245
|
if (g.waits > 0) {
|
|
237
246
|
out.push(`The API's component budget (${API_COMPONENTS_PER_MINUTE} components a minute) ran out ${g.waits === 1 ? "once" : `${g.waits} times`}: the tool waited ${Math.round(g.waited_ms / 1000)} s in all, as its Retry-After asked, and sent the batch again.`);
|
|
238
247
|
}
|
|
248
|
+
// #2799: the position, not only the list. Past DATA_TEXT_BUDGET the first text block keeps the
|
|
249
|
+
// first 10 of data.not_sent_purls (sbomText), so a model that reads the text alone cannot send
|
|
250
|
+
// "that list"; the purls not sent are always the input's distinct purls from position sent + 1 on
|
|
251
|
+
// (x.purls.slice(answers.length * MAX_COMPONENTS)), which it can rebuild from its own input, given
|
|
252
|
+
// the order they are counted in (READ_ORDER), which this sentence says whenever it gives the
|
|
253
|
+
// position, not only when the text cuts the list.
|
|
239
254
|
const notSent = g.not_sent_purls.length;
|
|
240
255
|
if (notSent > 0) {
|
|
241
|
-
|
|
256
|
+
const one = notSent === 1;
|
|
257
|
+
const where = one ? `it is the input's distinct purl at position ${g.sent + 1}` : `they are the input's distinct purls from position ${g.sent + 1} on`;
|
|
258
|
+
out.push(`${plural(notSent, "purl was", "purls were")} NOT sent (not_sent_reason ${g.not_sent_reason}: ${g.not_sent_detail}), so ${one ? "it is" : "they are"} not checked and not clean; ${where}, ${READ_ORDER}, and data.not_sent_purls lists ${one ? "it" : "them"}: call check_sbom again with purls set to ${one ? "it" : "them"}${g.not_sent_reason === "request_failed" ? "" : " after a minute"}.`);
|
|
242
259
|
}
|
|
243
260
|
out.push(`Of the ${components} components checked: ${affected} affected, ${notAffected} not affected, ${undetermined} undetermined, ${notAssessed} not assessed.`);
|
|
244
261
|
const by = field(s, "not_assessed_by_reason");
|
|
@@ -335,7 +352,17 @@ export function mergeAnswers(answers) {
|
|
|
335
352
|
out.results = results;
|
|
336
353
|
return out;
|
|
337
354
|
}
|
|
338
|
-
const realSleep = (ms) => new Promise((r) =>
|
|
355
|
+
const realSleep = (ms, signal) => new Promise((r) => {
|
|
356
|
+
if (signal?.aborted)
|
|
357
|
+
return r();
|
|
358
|
+
const done = () => {
|
|
359
|
+
clearTimeout(timer);
|
|
360
|
+
signal?.removeEventListener("abort", done);
|
|
361
|
+
r();
|
|
362
|
+
};
|
|
363
|
+
const timer = setTimeout(done, ms);
|
|
364
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
365
|
+
});
|
|
339
366
|
export async function checkSbom(deps, a) {
|
|
340
367
|
const tool = CHECK_SBOM;
|
|
341
368
|
const sleep = deps.sleep ?? realSleep;
|
|
@@ -384,12 +411,17 @@ export async function checkSbom(deps, a) {
|
|
|
384
411
|
headers: { "Content-Type": "application/json" },
|
|
385
412
|
body: JSON.stringify({ components: batches[answers.length].map((purl) => ({ purl })) }),
|
|
386
413
|
timeoutMs: Math.max(0, left),
|
|
414
|
+
signal: deps.signal,
|
|
387
415
|
});
|
|
388
416
|
if (r.ok) {
|
|
389
417
|
answers.push(r.data);
|
|
390
418
|
status = r.status;
|
|
391
419
|
continue;
|
|
392
420
|
}
|
|
421
|
+
// Cancelled (#2775): this batch was cut off, or never sent after a wait the signal ended.
|
|
422
|
+
// Nothing more is sent, and the answer goes nowhere.
|
|
423
|
+
if (deps.signal?.aborted)
|
|
424
|
+
return deps.failed(tool, r);
|
|
393
425
|
if (r.kind === "http" && r.status === 429) {
|
|
394
426
|
if (r.retryAfter === undefined) {
|
|
395
427
|
stop = { reason: "rate_limited", detail: "the API answered 429 without a Retry-After to wait for" };
|
|
@@ -400,7 +432,7 @@ export async function checkSbom(deps, a) {
|
|
|
400
432
|
else {
|
|
401
433
|
const waitMs = Math.max(MIN_WAIT_MS, r.retryAfter * 1000);
|
|
402
434
|
if (now() - started + waitMs <= TIME_BUDGET_MS) {
|
|
403
|
-
await sleep(waitMs);
|
|
435
|
+
await sleep(waitMs, deps.signal);
|
|
404
436
|
waits++;
|
|
405
437
|
waitedMs += waitMs;
|
|
406
438
|
waitedFor = r;
|
|
@@ -567,9 +599,12 @@ export function registerCheckSbom(server, deps) {
|
|
|
567
599
|
sbom: z
|
|
568
600
|
.union([z.string(), z.record(z.string(), z.unknown())])
|
|
569
601
|
.optional()
|
|
570
|
-
.describe("a CycloneDX JSON or SPDX JSON document, as JSON text or as an object; its purls are read
|
|
602
|
+
.describe("a CycloneDX JSON or SPDX JSON document, as JSON text or as an object; its purls are read by this MCP server and only they are sent to the API; over the hosted endpoint (mcp.echelongraph.io) the document is the request body"),
|
|
571
603
|
}),
|
|
572
604
|
outputSchema: output,
|
|
573
605
|
annotations: deps.annotations,
|
|
574
|
-
}, async (a) =>
|
|
606
|
+
}, async (a, ctx) => {
|
|
607
|
+
const run = checkSbom({ ...deps, signal: ctx.mcpReq.signal }, a);
|
|
608
|
+
return deps.checked(CHECK_SBOM, output, await (deps.hold ? deps.hold(run) : run));
|
|
609
|
+
});
|
|
575
610
|
}
|
package/dist/tools/get_cwe.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// the note says it can lag the feed.
|
|
14
14
|
import * as z from "zod";
|
|
15
15
|
import { ANNOTATIONS, api, badInput, checked, crashed, envelopeSchema, failed, field, lenAt, numAt, okHead, opt, rowsScoreNote, strAt, succeeded, } from "../index.js";
|
|
16
|
-
import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
16
|
+
import { GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
17
17
|
const TOOL = "get_cwe";
|
|
18
18
|
const CWE_ARG = /^(?:CWE-)?0*(\d{1,6})$/i;
|
|
19
19
|
const MAX_PAGE = 200;
|
|
@@ -25,7 +25,7 @@ export const GET_CWE_DESCRIPTION = `One CWE (weakness class) and the CVEs classi
|
|
|
25
25
|
const GET_CWE_TEXT = {
|
|
26
26
|
rows: "cves",
|
|
27
27
|
levels: [{ keep: ["cve_id", "severity", "cvss_v3_score", "echelongraph_score", "echelongraph_severity", "score_assessed", "score_unassessed_reason", "kev_listed", "published", "description"], clip: 120 }],
|
|
28
|
-
whole:
|
|
28
|
+
whole: `${GET_CVE_WHOLE}.`,
|
|
29
29
|
};
|
|
30
30
|
function schemas() {
|
|
31
31
|
const row = z.looseObject({
|
package/dist/tools/kev_recent.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
-
import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
2
|
+
import { GET_CVE_WHOLE, TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
3
3
|
const TOOL = "kev_recent";
|
|
4
4
|
const PATH = "/api/v1/public/kev/recent";
|
|
5
5
|
export const KEV_RECENT_MAX_LIMIT = 200;
|
|
@@ -140,7 +140,8 @@ function kevText(a) {
|
|
|
140
140
|
{ keep: ["cve_id", "kev_added_date", "kev_vendor", "kev_product", "kev_ransomware"], clip: 40 },
|
|
141
141
|
{ keep: ["cve_id", "kev_added_date", "kev_vendor", "kev_ransomware"], clip: 40 },
|
|
142
142
|
],
|
|
143
|
-
|
|
143
|
+
// A record carries the CISA fields a row leaves out (kev_due_date, kev_product, kev_vuln_name).
|
|
144
|
+
whole: `${GET_CVE_WHOLE}.`,
|
|
144
145
|
page: (shown) => `next_cursor continues after the last row of this page, not after the last row in this text: to read the rows left out in the text, call kev_recent again with the same filters${a.cursor?.trim() ? " and cursor" : ""} and a limit of ${shown}, whose rows are the ones in this text, then with that page's next_cursor and a limit of ${shown} or fewer.`,
|
|
145
146
|
};
|
|
146
147
|
}
|
|
@@ -210,7 +210,9 @@ const ROW_CUT_LEVELS = [
|
|
|
210
210
|
{ 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 },
|
|
211
211
|
{ keep: ["vendor", "vendor_advisory_id", "title", "severity", "cve_ids", "vendor_published_at", "withdrawn"], clip: 100, cap: 10 },
|
|
212
212
|
];
|
|
213
|
-
|
|
213
|
+
// get_vendor_advisory's own first text block is cut too past the budget (#2802): only its
|
|
214
|
+
// structuredContent.data is one advisory whole.
|
|
215
|
+
const WHOLE = "get_vendor_advisory returns any one of these advisories by its vendor and vendor_advisory_id, whole in its structuredContent.data, and in its first text block with every field (past 30,000 characters, each list cut to its first entries).";
|
|
214
216
|
const FOR_CVE_TEXT = { rows: "advisories", levels: ROW_CUT_LEVELS, whole: WHOLE };
|
|
215
217
|
const searchText = (a) => ({
|
|
216
218
|
rows: "advisories",
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "echelongraph-mcp",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.5",
|
|
4
4
|
"mcpName": "io.echelongraph/echelongraph-mcp",
|
|
5
|
-
"description": "MCP server for CVE
|
|
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",
|
|
7
7
|
"bin": {
|
|
8
8
|
"echelongraph-mcp": "dist/index.js"
|
package/server.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
]
|
|
18
18
|
}
|
|
19
19
|
],
|
|
20
|
-
"version": "2.6.
|
|
20
|
+
"version": "2.6.5",
|
|
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.
|
|
31
|
+
"version": "2.6.5",
|
|
32
32
|
"transport": {
|
|
33
33
|
"type": "stdio"
|
|
34
34
|
},
|