echelongraph-mcp 2.6.2 → 2.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/README.md +47 -7
- package/dist/index.js +119 -29
- package/dist/resources.js +2 -0
- package/dist/textBudget.js +247 -0
- package/dist/tools/check_affected.js +55 -2
- package/dist/tools/check_sbom.js +65 -4
- package/dist/tools/cve_intel.js +2 -1
- package/dist/tools/epss_history.js +2 -1
- package/dist/tools/get_cwe.js +11 -2
- package/dist/tools/kev_recent.js +25 -2
- package/dist/tools/vendor_advisories.js +204 -17
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/tools/kev_recent.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
+
import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
2
3
|
const TOOL = "kev_recent";
|
|
3
4
|
const PATH = "/api/v1/public/kev/recent";
|
|
4
5
|
export const KEV_RECENT_MAX_LIMIT = 200;
|
|
@@ -6,7 +7,10 @@ const MAX_VENDOR = 128;
|
|
|
6
7
|
const MAX_CURSOR = 128;
|
|
7
8
|
const KEV_RECENT_METHOD = "EchelonGraph's copy of the CISA KEV catalog: CISA's feed is polled every 5 minutes and each entry is written onto its CVE's record; rows are read from those records, newest kev_added_date first.";
|
|
8
9
|
const KEV_RECENT_COVERAGE = "CISA KEV catalog";
|
|
9
|
-
const KEV_RECENT_DESCRIPTION = "The CVEs CISA has added to its Known Exploited Vulnerabilities (KEV) catalog, newest first, from EchelonGraph's copy of that catalog, which polls CISA's feed every 5 minutes. Each row in kev gives cve_id, kev_added_date (CISA's dateAdded), kev_due_date, kev_vendor, kev_product, kev_vuln_name and kev_ransomware (known ransomware-campaign use), EchelonGraph's severity, cvss_v3_score, epss_score, epss_percentile and eg_kev_tier for the CVE, and our_first_seen_kev, when EchelonGraph's poller first recorded the CVE entering the catalog (null where no such record exists). Filter by since and until (YYYY-MM-DD, inclusive, on kev_added_date; an RFC 3339 timestamp, such as the kev_added_date 2024-04-12T00:00:00Z that CVE records carry, is read as the date written in it, with the time and offset dropped, and anything else is refused), ransomware, and vendor (an exact, case-insensitive match on kev_vendor); page with limit (1 to 200, default 50) and cursor, passing back the previous page's next_cursor with the same filters. Rows within one date are ordered by cve_id. total counts the CVEs matching the filters; kev_listed_total counts every CVE EchelonGraph holds as KEV-listed, and catalog.catalog_count is CISA's own count in the catalog last fetched, so a gap between the two is entries not yet in EchelonGraph's CVE table, which no page returns. CISA's requiredAction and shortDescription are not returned. Its structured result carries state (measured), measured_at (our last successful fetch of CISA's feed, null when the API does not give it), method, coverage (the CISA KEV catalog: total, returned, kev_listed_total, catalog_count, limit, has_more), freshness (last_successful_fetch_at, catalog_version, date_released) 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."
|
|
10
|
+
const KEV_RECENT_DESCRIPTION = "The CVEs CISA has added to its Known Exploited Vulnerabilities (KEV) catalog, newest first, from EchelonGraph's copy of that catalog, which polls CISA's feed every 5 minutes. Each row in kev gives cve_id, kev_added_date (CISA's dateAdded), kev_due_date, kev_vendor, kev_product, kev_vuln_name and kev_ransomware (known ransomware-campaign use), EchelonGraph's severity, cvss_v3_score, epss_score, epss_percentile and eg_kev_tier for the CVE, and our_first_seen_kev, when EchelonGraph's poller first recorded the CVE entering the catalog (null where no such record exists). Filter by since and until (YYYY-MM-DD, inclusive, on kev_added_date; an RFC 3339 timestamp, such as the kev_added_date 2024-04-12T00:00:00Z that CVE records carry, is read as the date written in it, with the time and offset dropped, and anything else is refused), ransomware, and vendor (an exact, case-insensitive match on kev_vendor); page with limit (1 to 200, default 50) and cursor, passing back the previous page's next_cursor with the same filters. Rows within one date are ordered by cve_id. total counts the CVEs matching the filters; kev_listed_total counts every CVE EchelonGraph holds as KEV-listed, and catalog.catalog_count is CISA's own count in the catalog last fetched, so a gap between the two is entries not yet in EchelonGraph's CVE table, which no page returns. CISA's requiredAction and shortDescription are not returned. Its structured result carries state (measured), measured_at (our last successful fetch of CISA's feed, null when the API does not give it), method, coverage (the CISA KEV catalog: total, returned, kev_listed_total, catalog_count, limit, has_more), freshness (last_successful_fetch_at, catalog_version, date_released) 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." +
|
|
11
|
+
" " +
|
|
12
|
+
TEXT_BUDGET_DESCRIPTION +
|
|
13
|
+
" Cut, each row keeps fewer fields, cve_id, kev_added_date, kev_vendor and kev_ransomware at least, or rows are left out, and next_cursor still continues after the last row of the page, not of the text.";
|
|
10
14
|
const opt = (t) => t.nullable().optional();
|
|
11
15
|
const KEVRow = z.looseObject({
|
|
12
16
|
cve_id: opt(z.string()),
|
|
@@ -121,6 +125,25 @@ const str = (o, ...k) => {
|
|
|
121
125
|
return typeof v === "string" && v ? v : null;
|
|
122
126
|
};
|
|
123
127
|
const realInstant = (s) => s !== null && Date.parse(s) > 0;
|
|
128
|
+
// #2783: a production row is about 520 characters as pretty JSON, so a page of 200 was 105,925
|
|
129
|
+
// characters of text. Past DATA_TEXT_BUDGET each row in the first text block keeps CISA's fields
|
|
130
|
+
// and EchelonGraph's severity, cvss_v3_score and epss_score; then, if the page is still too long,
|
|
131
|
+
// only cve_id, kev_added_date, kev_vendor, kev_product and kev_ransomware, names cut to 40
|
|
132
|
+
// characters; then without kev_product, which fits a page of 200.
|
|
133
|
+
// next_cursor continues after the page's last row, so the note says how to read any row the text
|
|
134
|
+
// leaves out without skipping it.
|
|
135
|
+
function kevText(a) {
|
|
136
|
+
return {
|
|
137
|
+
rows: "kev",
|
|
138
|
+
levels: [
|
|
139
|
+
{ keep: ["cve_id", "kev_added_date", "kev_due_date", "kev_vendor", "kev_product", "kev_vuln_name", "kev_ransomware", "severity", "cvss_v3_score", "epss_score"], clip: 200 },
|
|
140
|
+
{ keep: ["cve_id", "kev_added_date", "kev_vendor", "kev_product", "kev_ransomware"], clip: 40 },
|
|
141
|
+
{ keep: ["cve_id", "kev_added_date", "kev_vendor", "kev_ransomware"], clip: 40 },
|
|
142
|
+
],
|
|
143
|
+
whole: "get_cve returns any one of these CVEs' records whole.",
|
|
144
|
+
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
|
+
}
|
|
124
147
|
async function kevRecent(d, a) {
|
|
125
148
|
try {
|
|
126
149
|
const h = headersFor(a);
|
|
@@ -178,7 +201,7 @@ async function kevRecent(d, a) {
|
|
|
178
201
|
? "measured_at is EchelonGraph's last successful fetch of CISA's feed, the time this copy of the catalog was last confirmed."
|
|
179
202
|
: "measured_at is null: the answer does not give EchelonGraph's last successful fetch of CISA's feed.",
|
|
180
203
|
],
|
|
181
|
-
});
|
|
204
|
+
}, kevText(a));
|
|
182
205
|
}
|
|
183
206
|
catch (e) {
|
|
184
207
|
return d.crashed(TOOL, e);
|
|
@@ -23,9 +23,31 @@
|
|
|
23
23
|
// the routes are keyed on, refused locally unless they have an identifier's shape, so no free
|
|
24
24
|
// text reaches a URL through them.
|
|
25
25
|
//
|
|
26
|
+
// How a search matched (#2728). A term of one or two characters cannot use the API's trigram
|
|
27
|
+
// index, so the API matches it as a WHOLE WORD (a run of letters and digits) and says so with
|
|
28
|
+
// search_match "word"; a longer term is a substring, search_match "substring". And a search
|
|
29
|
+
// counts at most 1,000 matches: past that the API answers total 1000 with total_capped true. The
|
|
30
|
+
// tools relay both in coverage, the description states the whole-word rule, and a capped total is
|
|
31
|
+
// written "1,000+" in the note, never as an exact count.
|
|
32
|
+
//
|
|
33
|
+
// What is held of each vendor (#2729). What EchelonGraph holds of a vendor is not the vendor's
|
|
34
|
+
// whole history: Palo Alto's RSS feed carries only its ~25 newest updates, and MSRC's poller moved
|
|
35
|
+
// past documents it never read, so vendor_advisories_for_cve answered 0 for CVE-2024-3400 and
|
|
36
|
+
// CVE-2024-21412, which both vendors did publish advisories for. core-backend now reads those
|
|
37
|
+
// histories back (vendoradv backfill.go) and serves each vendor's window at
|
|
38
|
+
// /api/v1/public/vendor-advisories/coverage (coverage.go): advisories held, the earliest and latest
|
|
39
|
+
// vendor_published_at among them, and history_backfill. All three tools read it beside their own
|
|
40
|
+
// request and relay it in coverage, and the note says when a CVE's ID year begins before a
|
|
41
|
+
// vendor's earliest held advisory, or a vendor's history is still being read, so an empty answer
|
|
42
|
+
// is never read as "the vendor published none". The window is context, not the answer: when it
|
|
43
|
+
// cannot be read (an API older than the route, a failure), the answer is relayed as before, with
|
|
44
|
+
// the windows null and a note saying they are unknown, never failed. The coverage request carries
|
|
45
|
+
// nothing typed: a fixed path, no query, no search header.
|
|
46
|
+
//
|
|
26
47
|
// Logic only: the envelope, the failure contract and the API call are index.ts's, handed in as
|
|
27
48
|
// a VendorAdvisoryKit so this file cannot drift from them.
|
|
28
49
|
import * as z from "zod";
|
|
50
|
+
import { TEXT_BUDGET_DESCRIPTION } from "../textBudget.js";
|
|
29
51
|
// core-backend vendoradv.SearchHeader.
|
|
30
52
|
export const VENDOR_ADVISORY_SEARCH_HEADER = "X-EG-Advisory-Search";
|
|
31
53
|
// handler.go List caps the search at 100 bytes; a longer term would be cut, possibly inside a
|
|
@@ -33,6 +55,11 @@ export const VENDOR_ADVISORY_SEARCH_HEADER = "X-EG-Advisory-Search";
|
|
|
33
55
|
const SEARCH_MAX_BYTES = 100;
|
|
34
56
|
// store.go ListAdvisoriesByCVE: LIMIT 20, newest first.
|
|
35
57
|
const BY_CVE_CAP = 20;
|
|
58
|
+
// #2729: every enabled vendor's window (handler.go CoverageHandler).
|
|
59
|
+
export const VENDOR_COVERAGE_PATH = "/api/v1/public/vendor-advisories/coverage";
|
|
60
|
+
// history_backfill values that mean the vendor's older advisories are not all held yet
|
|
61
|
+
// (backfill.go backfillStatus).
|
|
62
|
+
const BACKFILLING = new Set(["in_progress", "not_started"]);
|
|
36
63
|
const CVE_ID = /^CVE-\d{4}-\d{4,}$/i;
|
|
37
64
|
// vendors.slug's CHECK constraint (migration 063).
|
|
38
65
|
const VENDOR_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
@@ -65,6 +92,67 @@ function withdrawnNote(rows) {
|
|
|
65
92
|
return "";
|
|
66
93
|
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.`;
|
|
67
94
|
}
|
|
95
|
+
const instantOrNull = (o, k) => {
|
|
96
|
+
const v = field(o, k);
|
|
97
|
+
return realInstant(v) ? v : null;
|
|
98
|
+
};
|
|
99
|
+
// The /coverage answer's rows; undefined when it carries no vendors list. A row without a vendor
|
|
100
|
+
// slug cannot be named, so it is left out.
|
|
101
|
+
function windowsOf(d) {
|
|
102
|
+
const rows = field(d, "vendors");
|
|
103
|
+
if (!Array.isArray(rows))
|
|
104
|
+
return undefined;
|
|
105
|
+
return rows.flatMap((r) => {
|
|
106
|
+
const vendor = str(r, "vendor");
|
|
107
|
+
if (!vendor)
|
|
108
|
+
return [];
|
|
109
|
+
return [
|
|
110
|
+
{
|
|
111
|
+
vendor,
|
|
112
|
+
vendor_display_name: str(r, "vendor_display_name") ?? null,
|
|
113
|
+
advisories: num(r, "advisories") ?? null,
|
|
114
|
+
earliest_vendor_published_at: instantOrNull(r, "earliest_vendor_published_at"),
|
|
115
|
+
latest_vendor_published_at: instantOrNull(r, "latest_vendor_published_at"),
|
|
116
|
+
history_backfill: str(r, "history_backfill") ?? null,
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const day = (instant) => instant.slice(0, 10);
|
|
122
|
+
const backfilling = (w) => w.history_backfill !== null && BACKFILLING.has(w.history_backfill);
|
|
123
|
+
// What a note says of one window: what is held, and the history read when it is not done.
|
|
124
|
+
const windowItem = (w, heldOnly) => {
|
|
125
|
+
const held = w.earliest_vendor_published_at === null
|
|
126
|
+
? "none held"
|
|
127
|
+
: `held ${heldOnly ? "only " : ""}from ${day(w.earliest_vendor_published_at)}${w.latest_vendor_published_at ? ` to ${day(w.latest_vendor_published_at)}` : ""}`;
|
|
128
|
+
return `${w.vendor} (${held}${backfilling(w) ? `; history_backfill ${w.history_backfill}` : ""})`;
|
|
129
|
+
};
|
|
130
|
+
// The sentence for windows that could not be read: no "none here" is a finding about a vendor.
|
|
131
|
+
const windowsUnknown = (why) => ` The vendors' coverage windows could not be read (${why}), so this answer cannot say from what date each vendor's advisories are held: no advisory here from a vendor is not a finding that it published none.`;
|
|
132
|
+
// vendor_advisories_for_cve: the vendors with no advisory in the answer that may have published
|
|
133
|
+
// one EchelonGraph does not hold: none held at all, the earliest held dated after 1 January of the
|
|
134
|
+
// year in the CVE ID, or a history still being read.
|
|
135
|
+
function notFullyHeld(windows, rows, year) {
|
|
136
|
+
const answered = new Set(rows.map((r) => str(r, "vendor")).filter((v) => v !== undefined));
|
|
137
|
+
const yearStart = Date.UTC(year, 0, 1);
|
|
138
|
+
return windows.filter((w) => !answered.has(w.vendor) && (w.earliest_vendor_published_at === null || Date.parse(w.earliest_vendor_published_at) > yearStart || backfilling(w)));
|
|
139
|
+
}
|
|
140
|
+
function notFullyHeldNote(cve, year, missing) {
|
|
141
|
+
if (!missing.length)
|
|
142
|
+
return "";
|
|
143
|
+
const k = missing.length;
|
|
144
|
+
const yearStart = Date.UTC(year, 0, 1);
|
|
145
|
+
const items = missing.map((w) => windowItem(w, w.earliest_vendor_published_at !== null && Date.parse(w.earliest_vendor_published_at) > yearStart));
|
|
146
|
+
return ` ${cve} is a ${year} CVE ID, and EchelonGraph may not hold every advisory ${plural(k, "1 vendor", `${k} vendors`)} published for it (vendors_not_fully_held): ${items.join("; ")}. An advisory a vendor published before the earliest one EchelonGraph holds is not held, and while history_backfill is in_progress or not_started the vendor's older advisories are still being read, so no advisory here from ${plural(k, "that vendor", "those vendors")} is not a finding that ${plural(k, "it", "they")} published none.`;
|
|
147
|
+
}
|
|
148
|
+
// search_vendor_advisories and get_vendor_advisory: the vendors whose history is still being read.
|
|
149
|
+
function backfillingNote(windows) {
|
|
150
|
+
const b = windows.filter(backfilling);
|
|
151
|
+
if (!b.length)
|
|
152
|
+
return "";
|
|
153
|
+
const k = b.length;
|
|
154
|
+
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.`;
|
|
155
|
+
}
|
|
68
156
|
// ── Output schemas ──
|
|
69
157
|
const opt = (t) => t.nullable().optional();
|
|
70
158
|
const DATE_FIELDS = {
|
|
@@ -89,6 +177,48 @@ const AdvisoryRow = z.looseObject({
|
|
|
89
177
|
// (marketing-site/lib/mcpToolClaims.test.ts) can fold the descriptions to their text.
|
|
90
178
|
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).";
|
|
91
179
|
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.";
|
|
180
|
+
// #2729: what a vendor's window is.
|
|
181
|
+
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)";
|
|
182
|
+
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) and ${HISTORY_BACKFILL_DESCRIPTION}`;
|
|
183
|
+
const NOT_HELD_DESCRIPTION = "An advisory a vendor published before its earliest_vendor_published_at is not held, so no advisory from a vendor is not a finding that it published none.";
|
|
184
|
+
// #2728: how a search matches, and its capped total.
|
|
185
|
+
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.";
|
|
186
|
+
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.";
|
|
187
|
+
const VendorWindowSchema = z.strictObject({
|
|
188
|
+
vendor: z.string().describe("The vendor slug."),
|
|
189
|
+
vendor_display_name: z.string().nullable(),
|
|
190
|
+
advisories: z.number().nullable().describe("How many advisories EchelonGraph holds from this vendor, withdrawn ones not counted."),
|
|
191
|
+
earliest_vendor_published_at: z
|
|
192
|
+
.string()
|
|
193
|
+
.nullable()
|
|
194
|
+
.describe("The earliest vendor_published_at among them; null when none is held. An advisory this vendor published before it is not held."),
|
|
195
|
+
latest_vendor_published_at: z.string().nullable().describe("The latest vendor_published_at among them; null when none is held."),
|
|
196
|
+
// The four values the API sends today, and any later one as a string: a new state must not turn
|
|
197
|
+
// the answer into a failure.
|
|
198
|
+
history_backfill: z
|
|
199
|
+
.union([z.enum(["complete", "in_progress", "not_started", "not_supported"]), z.string()])
|
|
200
|
+
.nullable()
|
|
201
|
+
.describe(`${HISTORY_BACKFILL_DESCRIPTION}.`),
|
|
202
|
+
});
|
|
203
|
+
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}`);
|
|
204
|
+
// #2783: a production row is up to about 2,000 characters as pretty JSON (its summary, its
|
|
205
|
+
// affected_products and, for a library like log4j, dozens of cve_ids), so search_vendor_advisories
|
|
206
|
+
// "log4j" at limit 50 was 103,147 characters of text. Past DATA_TEXT_BUDGET each row in the first
|
|
207
|
+
// text block keeps what identifies and rates the advisory, every string cut to 200 characters and
|
|
208
|
+
// every list to 20 entries; then fewer fields, strings to 100 and lists to 10.
|
|
209
|
+
const ROW_CUT_LEVELS = [
|
|
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
|
+
{ keep: ["vendor", "vendor_advisory_id", "title", "severity", "cve_ids", "vendor_published_at", "withdrawn"], clip: 100, cap: 10 },
|
|
212
|
+
];
|
|
213
|
+
const WHOLE = "get_vendor_advisory returns any one of these advisories whole, by its vendor and vendor_advisory_id.";
|
|
214
|
+
const FOR_CVE_TEXT = { rows: "advisories", levels: ROW_CUT_LEVELS, whole: WHOLE };
|
|
215
|
+
const searchText = (a) => ({
|
|
216
|
+
rows: "advisories",
|
|
217
|
+
levels: ROW_CUT_LEVELS,
|
|
218
|
+
whole: WHOLE,
|
|
219
|
+
page: (shown, _rows, d) => `To read the rows left out in the text, call search_vendor_advisories again with the same arguments and offset ${(num(d, "offset") ?? a.offset ?? 0) + shown}; a page of ${shown} rows or fewer like these fits the text without leaving rows out.`,
|
|
220
|
+
});
|
|
221
|
+
const ROW_CUT_DESCRIPTION = " Cut, each row keeps vendor, vendor_advisory_id, title, severity and cve_ids at least, its strings and lists shortened";
|
|
92
222
|
export function registerVendorAdvisoryTools(server, kit) {
|
|
93
223
|
const FOR_CVE_OUTPUT = kit.envelopeSchema({
|
|
94
224
|
data: z.looseObject({ cve_id: opt(z.string()), advisories: opt(z.array(AdvisoryRow)), total: opt(z.number()) }),
|
|
@@ -96,6 +226,12 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
96
226
|
returned: z.number().nullable().describe("The advisories in this answer."),
|
|
97
227
|
cap: z.number().describe("The most the API returns for one CVE, newest first."),
|
|
98
228
|
at_cap: z.boolean().nullable().describe("true: the answer is full, so the vendor feeds may hold more advisories for this CVE than it lists."),
|
|
229
|
+
cve_year: z.number().describe("The year in the CVE ID."),
|
|
230
|
+
vendor_windows: WINDOWS,
|
|
231
|
+
vendors_not_fully_held: z
|
|
232
|
+
.array(z.string())
|
|
233
|
+
.nullable()
|
|
234
|
+
.describe("The vendors with no advisory in this answer that may have published one EchelonGraph does not hold: none held, the earliest held dated after 1 January of cve_year, or history_backfill in_progress or not_started. No advisory from them is not a finding that they published none. null when the windows could not be read."),
|
|
99
235
|
}),
|
|
100
236
|
freshness: null,
|
|
101
237
|
});
|
|
@@ -109,26 +245,41 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
109
245
|
withdrawn_at: opt(z.string()),
|
|
110
246
|
withdrawn_reason: opt(z.string()),
|
|
111
247
|
}),
|
|
112
|
-
coverage:
|
|
248
|
+
coverage: z.strictObject({
|
|
249
|
+
vendor_window: VendorWindowSchema.nullable().describe("This advisory's vendor's window in what EchelonGraph holds; null when the windows could not be read or carry none for this vendor."),
|
|
250
|
+
}),
|
|
113
251
|
freshness: null,
|
|
114
252
|
});
|
|
115
253
|
const SEARCH_OUTPUT = kit.envelopeSchema({
|
|
116
254
|
data: z.looseObject({
|
|
117
255
|
advisories: opt(z.array(AdvisoryRow)),
|
|
118
|
-
total: opt(z.number()),
|
|
256
|
+
total: opt(z.number()).describe("The matches; when total_capped is true, a lower bound: 1,000 or more."),
|
|
119
257
|
limit: opt(z.number()),
|
|
120
258
|
offset: opt(z.number()),
|
|
121
259
|
search_applied: opt(z.boolean()).describe("Whether a free-text search filtered this answer."),
|
|
260
|
+
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."),
|
|
261
|
+
total_capped: opt(z.boolean()).describe("true: the search stopped counting at total, so total is a lower bound (1,000+), not the count."),
|
|
122
262
|
}),
|
|
123
263
|
coverage: z.strictObject({
|
|
124
|
-
total: z.number().nullable(),
|
|
264
|
+
total: z.number().nullable().describe("The API's total; when total_capped is true, a lower bound, not the count."),
|
|
265
|
+
total_capped: z.boolean().nullable().describe("true: total is the API's cap on a search's count, so 1,000 or more match."),
|
|
125
266
|
returned: z.number().nullable(),
|
|
126
267
|
limit: z.number().nullable(),
|
|
127
268
|
offset: z.number().nullable(),
|
|
128
269
|
search_applied: z.boolean().nullable(),
|
|
270
|
+
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."),
|
|
271
|
+
vendor_windows: WINDOWS,
|
|
129
272
|
}),
|
|
130
273
|
freshness: null,
|
|
131
274
|
});
|
|
275
|
+
// The vendors' windows, read beside a tool's own request. Never a failure of the tool.
|
|
276
|
+
async function readWindows() {
|
|
277
|
+
const r = await kit.api(VENDOR_COVERAGE_PATH);
|
|
278
|
+
if (!r.ok)
|
|
279
|
+
return { ok: false, why: kit.describeFailure(r).replace(/\.$/, "") };
|
|
280
|
+
const windows = windowsOf(r.data);
|
|
281
|
+
return windows ? { ok: true, windows } : { ok: false, why: `the answer of GET ${VENDOR_COVERAGE_PATH} carries no vendors list` };
|
|
282
|
+
}
|
|
132
283
|
// ── vendor_advisories_for_cve ──
|
|
133
284
|
async function forCVE(cveArg) {
|
|
134
285
|
const tool = "vendor_advisories_for_cve";
|
|
@@ -138,29 +289,41 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
138
289
|
if (!CVE_ID.test(raw))
|
|
139
290
|
return kit.badInput(tool, "cve_id must be a CVE ID such as CVE-2024-21412");
|
|
140
291
|
const cve = raw.toUpperCase();
|
|
292
|
+
const year = Number(cve.slice(4, 8));
|
|
141
293
|
try {
|
|
294
|
+
const windowsRead = readWindows();
|
|
142
295
|
const r = await kit.api(`/api/v1/public/vendor-advisories/by-cve/${encodeURIComponent(cve)}`);
|
|
143
296
|
if (!r.ok)
|
|
144
297
|
return kit.failed(tool, r);
|
|
298
|
+
const w = await windowsRead;
|
|
145
299
|
const rows = rowsOf(r.data);
|
|
146
300
|
const returned = rows?.length ?? null;
|
|
147
301
|
const atCap = returned === null ? null : returned >= BY_CVE_CAP;
|
|
302
|
+
const missing = w.ok ? notFullyHeld(w.windows, rows ?? [], year) : null;
|
|
148
303
|
const env = {
|
|
149
304
|
state: "measured",
|
|
150
305
|
measured_at: null,
|
|
151
306
|
method: METHOD,
|
|
152
|
-
coverage: {
|
|
307
|
+
coverage: {
|
|
308
|
+
returned,
|
|
309
|
+
cap: BY_CVE_CAP,
|
|
310
|
+
at_cap: atCap,
|
|
311
|
+
cve_year: year,
|
|
312
|
+
vendor_windows: w.ok ? w.windows : null,
|
|
313
|
+
vendors_not_fully_held: missing ? missing.map((m) => m.vendor) : null,
|
|
314
|
+
},
|
|
153
315
|
freshness: null,
|
|
154
316
|
notes: [LIST_MEASURED_AT, NO_FRESHNESS, DATES],
|
|
155
317
|
};
|
|
156
318
|
const head = kit.okHead(tool, r.status);
|
|
319
|
+
const held = w.ok ? notFullyHeldNote(cve, year, missing ?? []) : windowsUnknown(w.why);
|
|
157
320
|
if (returned === 0) {
|
|
158
|
-
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}`, env);
|
|
321
|
+
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);
|
|
159
322
|
}
|
|
160
323
|
if (rows === undefined)
|
|
161
|
-
return kit.succeeded(r.data, head,
|
|
324
|
+
return kit.succeeded(r.data, `${head}${held}`, env, FOR_CVE_TEXT);
|
|
162
325
|
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.` : "";
|
|
163
|
-
return kit.succeeded(r.data, `${head} ${rows.length} vendor ${plural(rows.length, "advisory names", "advisories name")} ${cve}.${cap}${withdrawnNote(rows)} ${ONLY_POLLED}`, env);
|
|
326
|
+
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);
|
|
164
327
|
}
|
|
165
328
|
catch (e) {
|
|
166
329
|
return kit.crashed(tool, e);
|
|
@@ -180,17 +343,21 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
180
343
|
if (!ADVISORY_ID.test(id))
|
|
181
344
|
return kit.badInput(tool, "advisory_id must be the vendor's advisory ID (vendor_advisory_id), at most 200 characters, with no control characters");
|
|
182
345
|
try {
|
|
346
|
+
const windowsRead = readWindows();
|
|
183
347
|
const r = await kit.api(`/api/v1/public/vendor-advisories/${encodeURIComponent(vendor)}/${encodeURIComponent(id)}`);
|
|
184
348
|
if (!r.ok)
|
|
185
349
|
return kit.failed(tool, r);
|
|
350
|
+
const w = await windowsRead;
|
|
186
351
|
const firstSeen = field(r.data, "our_first_seen_at");
|
|
187
352
|
const cveIDs = field(r.data, "cve_ids");
|
|
188
353
|
const known = field(r.data, "known_cve_ids");
|
|
354
|
+
const slug = str(r.data, "vendor") ?? vendor;
|
|
355
|
+
const window = w.ok ? (w.windows.find((x) => x.vendor === slug) ?? null) : null;
|
|
189
356
|
const env = {
|
|
190
357
|
state: "measured",
|
|
191
358
|
measured_at: realInstant(firstSeen) ? firstSeen : null,
|
|
192
359
|
method: METHOD,
|
|
193
|
-
coverage:
|
|
360
|
+
coverage: { vendor_window: window },
|
|
194
361
|
freshness: null,
|
|
195
362
|
notes: [
|
|
196
363
|
realInstant(firstSeen)
|
|
@@ -200,14 +367,15 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
200
367
|
DATES,
|
|
201
368
|
],
|
|
202
369
|
};
|
|
203
|
-
const shown = `${
|
|
370
|
+
const shown = `${slug}/${str(r.data, "vendor_advisory_id") ?? id}`;
|
|
204
371
|
const withdrawn = field(r.data, "withdrawn") === true
|
|
205
372
|
? ` WITHDRAWN: the vendor withdrew (rescinded) ${shown} (withdrawn true${realInstant(field(r.data, "withdrawn_at")) ? `, withdrawn_at ${field(r.data, "withdrawn_at")}` : ""}). Report it as withdrawn, not as a current advisory.`
|
|
206
373
|
: "";
|
|
207
374
|
const cves = Array.isArray(cveIDs) && Array.isArray(known) && cveIDs.length > known.length
|
|
208
375
|
? ` 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.`
|
|
209
376
|
: "";
|
|
210
|
-
|
|
377
|
+
const held = !w.ok ? windowsUnknown(w.why) : window ? backfillingNote([window]) : "";
|
|
378
|
+
return kit.succeeded(r.data, `${kit.okHead(tool, r.status)} Returned the advisory ${shown}.${withdrawn}${cves}${held}`, env);
|
|
211
379
|
}
|
|
212
380
|
catch (e) {
|
|
213
381
|
return kit.crashed(tool, e);
|
|
@@ -234,6 +402,7 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
234
402
|
// The term in the header only, never the URL (#1983).
|
|
235
403
|
const headers = query ? { [VENDOR_ADVISORY_SEARCH_HEADER]: encodeURIComponent(query) } : {};
|
|
236
404
|
const path = `/api/v1/public/vendor-advisories?${q.toString()}`;
|
|
405
|
+
const windowsRead = readWindows();
|
|
237
406
|
const r = await kit.api(path, { headers });
|
|
238
407
|
if (!r.ok)
|
|
239
408
|
return kit.failed(tool, r);
|
|
@@ -247,18 +416,27 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
247
416
|
detail: `the answer does not say the search was applied (search_applied is ${applied === undefined ? "absent" : JSON.stringify(applied)}: an API that does not read the ${VENDOR_ADVISORY_SEARCH_HEADER} header answers the unsearched list), so it is not relayed as the search's matches`,
|
|
248
417
|
});
|
|
249
418
|
}
|
|
419
|
+
const w = await windowsRead;
|
|
420
|
+
// With a vendor filter, only that vendor's window.
|
|
421
|
+
const windows = w.ok ? w.windows.filter((x) => !vendor || x.vendor === vendor) : null;
|
|
250
422
|
const rows = rowsOf(r.data);
|
|
251
423
|
const total = num(r.data, "total");
|
|
424
|
+
const capped = field(r.data, "total_capped");
|
|
425
|
+
const isCapped = capped === true && total !== undefined && total > 0;
|
|
426
|
+
const match = field(r.data, "search_match");
|
|
252
427
|
const env = {
|
|
253
428
|
state: "measured",
|
|
254
429
|
measured_at: null,
|
|
255
430
|
method: METHOD,
|
|
256
431
|
coverage: {
|
|
257
432
|
total: total ?? null,
|
|
433
|
+
total_capped: typeof capped === "boolean" ? capped : null,
|
|
258
434
|
returned: rows?.length ?? null,
|
|
259
435
|
limit: num(r.data, "limit") ?? null,
|
|
260
436
|
offset: num(r.data, "offset") ?? null,
|
|
261
437
|
search_applied: typeof applied === "boolean" ? applied : null,
|
|
438
|
+
search_match: match === "word" || match === "substring" ? match : null,
|
|
439
|
+
vendor_windows: windows,
|
|
262
440
|
},
|
|
263
441
|
freshness: null,
|
|
264
442
|
notes: [
|
|
@@ -270,13 +448,22 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
270
448
|
};
|
|
271
449
|
const head = kit.okHead(tool, r.status);
|
|
272
450
|
const what = query ? "The search matched" : "The list holds";
|
|
451
|
+
// How the term was matched: a short term is whole words only. The term itself is never written.
|
|
452
|
+
const wordOnly = query && match === "word"
|
|
453
|
+
? " The query is 1 or 2 characters, so it matched whole words only (search_match word): an advisory matches when the query equals, ignoring case, a whole word (a run of letters and digits) of its title, description, vendor_advisory_id, an affected product, a CVE ID or its vendor's name, never part of a longer word."
|
|
454
|
+
: "";
|
|
455
|
+
const held = !w.ok ? windowsUnknown(w.why) : backfillingNote(windows ?? []);
|
|
273
456
|
if (total === 0) {
|
|
274
|
-
return kit.succeeded(r.data, `${head} ${what} 0 vendor advisories: a measured empty result, EchelonGraph was queried successfully and nothing matched these filters (we looked and found nothing). This is not a lookup failure. ${ONLY_POLLED}`, env);
|
|
457
|
+
return kit.succeeded(r.data, `${head} ${what} 0 vendor advisories: a measured empty result, EchelonGraph was queried successfully and nothing matched these filters (we looked and found nothing). This is not a lookup failure. ${ONLY_POLLED}${wordOnly}${held}`, env, searchText(a));
|
|
275
458
|
}
|
|
276
459
|
if (total === undefined)
|
|
277
|
-
return kit.succeeded(r.data, head,
|
|
460
|
+
return kit.succeeded(r.data, `${head}${wordOnly}${held}`, env, searchText(a));
|
|
278
461
|
const page = rows === undefined ? "" : `; ${rows.length} returned in this page (offset ${num(r.data, "offset") ?? a.offset ?? 0})`;
|
|
279
|
-
|
|
462
|
+
// A capped total is a lower bound: written "1,000+", never as the count.
|
|
463
|
+
const counted = isCapped
|
|
464
|
+
? `${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)`
|
|
465
|
+
: `${total} vendor ${plural(total, "advisory", "advisories")}`;
|
|
466
|
+
return kit.succeeded(r.data, `${head} ${what} ${counted}${page}.${wordOnly}${held}`, env, searchText(a));
|
|
280
467
|
}
|
|
281
468
|
catch (e) {
|
|
282
469
|
return kit.crashed(tool, e);
|
|
@@ -284,14 +471,14 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
284
471
|
}
|
|
285
472
|
server.registerTool("vendor_advisories_for_cve", {
|
|
286
473
|
title: "Vendor advisories for one CVE",
|
|
287
|
-
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 none of those names the CVE. ${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). measured_at is null. ${ENVELOPE_DESCRIPTION}
|
|
474
|
+
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 earliest held advisory is dated after 1 January of cve_year, 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}.`,
|
|
288
475
|
inputSchema: z.object({ cve_id: z.string().describe("a CVE ID, e.g. CVE-2024-21412") }),
|
|
289
476
|
outputSchema: FOR_CVE_OUTPUT,
|
|
290
477
|
annotations: kit.annotations,
|
|
291
478
|
}, async ({ cve_id }) => kit.checked("vendor_advisories_for_cve", FOR_CVE_OUTPUT, await forCVE(cve_id)));
|
|
292
479
|
server.registerTool("get_vendor_advisory", {
|
|
293
480
|
title: "Vendor advisory detail",
|
|
294
|
-
description: `One vendor advisory in full, by vendor and the vendor's advisory ID (the vendor and vendor_advisory_id fields of a row from search_vendor_advisories or vendor_advisories_for_cve): 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} measured_at is our_first_seen_at. ${ENVELOPE_DESCRIPTION}`,
|
|
481
|
+
description: `One vendor advisory in full, by vendor and the vendor's advisory ID (the vendor and vendor_advisory_id fields of a row from search_vendor_advisories or vendor_advisories_for_cve): 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}`,
|
|
295
482
|
inputSchema: z.object({
|
|
296
483
|
vendor: z.string().describe("the vendor slug, e.g. microsoft, redhat, github"),
|
|
297
484
|
advisory_id: z.string().describe("the vendor's advisory ID, e.g. RHSA-2024:1234 or GHSA-xxxx-xxxx-xxxx"),
|
|
@@ -301,9 +488,9 @@ export function registerVendorAdvisoryTools(server, kit) {
|
|
|
301
488
|
}, async ({ vendor, advisory_id }) => kit.checked("get_vendor_advisory", DETAIL_OUTPUT, await detail(vendor, advisory_id)));
|
|
302
489
|
server.registerTool("search_vendor_advisories", {
|
|
303
490
|
title: "Search vendor advisories",
|
|
304
|
-
description: `Search or list vendor-published advisories, newest first.
|
|
491
|
+
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.`,
|
|
305
492
|
inputSchema: z.object({
|
|
306
|
-
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'"),
|
|
493
|
+
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"),
|
|
307
494
|
vendor: z.string().optional().describe("a vendor slug, e.g. microsoft, redhat, github"),
|
|
308
495
|
severity: z.enum(["Critical", "High", "Medium", "Low"]).optional().describe("one severity band"),
|
|
309
496
|
has_cve: z.boolean().optional().describe("true: only advisories naming a CVE; false: only those naming none"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "echelongraph-mcp",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.3",
|
|
4
4
|
"mcpName": "io.echelongraph/echelongraph-mcp",
|
|
5
5
|
"description": "MCP server for CVE and vulnerability lookups: CVE records with EchelonGraph multi-source scores, CISA KEV, EPSS history, CWE weaknesses, public exploits, version and SBOM checks against the vulnerability advisory corpus, vendor security advisories, and a per-CVE internet-exposure footprint derived from Shodan data. Shodan data is owned by Shodan, which holds its copyright (© Shodan). Free and keyless, for Claude, Cursor and any MCP client.",
|
|
6
6
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
]
|
|
18
18
|
}
|
|
19
19
|
],
|
|
20
|
-
"version": "2.6.
|
|
20
|
+
"version": "2.6.3",
|
|
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.3",
|
|
32
32
|
"transport": {
|
|
33
33
|
"type": "stdio"
|
|
34
34
|
},
|