@scarlet-mesh/mcp-cve
Version:
CVE MCP Server providing security vulnerability analysis and Red Hat CVE data
436 lines (429 loc) • 18 kB
JavaScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// Create MCP Server instance
const server = new McpServer({
name: "cve",
version: "1.0.0",
capabilities: {
resources: {},
},
});
async function fetchCveData(cveId) {
const match = cveId.match(/^CVE-(\d{4})-(\d+)$/i);
if (!match) {
console.error("Invalid CVE ID format");
return null;
}
const [, year, number] = match;
const url = `https://security.access.redhat.com/data/csaf/v2/vex/${year}/cve-${year}-${number}.json`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
}
catch (err) {
console.error(`Failed to fetch CVE data:`, err);
return null;
}
}
server.tool("summarize-cve", "Fetch and summarize a CVE from Red Hat's CSAF feed", {
cveId: z.string().describe("The CVE ID to summarize (e.g. CVE-2024-53907)"),
}, async ({ cveId }) => {
const data = await fetchCveData(cveId);
if (!data) {
return {
content: [{ type: "text", text: `Could not retrieve data for ${cveId}` }],
};
}
const title = data?.document?.title || "No title available";
const notes = data?.document?.notes?.map((n) => n.text).join("\n\n") || "No additional notes found.";
const trackingId = data?.document?.tracking?.id || cveId;
const severity = data?.vulnerabilities?.[0]?.scores?.[0]?.cvss_v3?.baseSeverity || "Unknown";
const productBranches = data?.product_tree?.branches || [];
const affectedProducts = productBranches
.map((branch) => {
const subBranches = branch.branches || [];
return [branch.name, ...subBranches.map((sb) => sb.name)].filter(Boolean);
})
.flat()
.filter(Boolean);
const affectedList = affectedProducts.length
? affectedProducts.map((p) => `- ${p}`).join("\n")
: "No affected products listed.";
const references = data?.vulnerabilities?.[0]?.references?.map((ref) => `- [${ref.url}](${ref.url})`) || [];
const referencesText = references.length ? references.join("\n") : "No references available.";
return {
content: [
{
type: "text",
text: `### ${title}\n\n**Tracking ID:** ${trackingId}\n**Severity:** ${severity}\n\n---\n\n### Notes\n${notes}\n\n---\n\n### Affected Products\n${affectedList}\n\n---\n\n### 🔗 References\n${referencesText}`,
},
],
};
});
server.tool("get-cve-summary-html", "Returns a rich HTML card-style summary for a CVE with severity, products, links, and remediation info", {
cveId: z.string().describe("The CVE ID to summarize (e.g. CVE-2024-53907)"),
}, async ({ cveId }) => {
const data = await fetchCveData(cveId);
if (!data) {
return {
content: [
{
type: "text",
text: `Could not retrieve data for ${cveId}`,
},
],
isError: true,
};
}
const title = data?.document?.title || "No title available";
const severity = data?.vulnerabilities?.[0]?.scores?.[0]?.cvss_v3?.baseSeverity || "Unknown";
const remediationNotes = data?.document?.notes
?.filter((n) => n.category === "remediation")
.map((n) => n.text)
.join("<br/>") || "No remediation steps available.";
const productBranches = data?.product_tree?.branches || [];
const affectedProducts = productBranches
.map((branch) => {
const subBranches = branch.branches || [];
return [branch.name, ...subBranches.map((sb) => sb.name)].filter(Boolean);
})
.flat()
.filter(Boolean);
const references = data?.vulnerabilities?.[0]?.references || [];
const html = `
<div style="background-color: #f5f3f4; border: 1px solid #ccc; border-radius: 8px; padding: 16px; max-width: 600px; font-family: sans-serif; box-shadow: 0 4px 12px rgba(0,0,0,0.1)">
<h2 style="margin-top: 0;">${title}</h2>
<span style="display: inline-block; padding: 4px 10px; background-color: ${severity === "Critical"
? "#a30000"
: severity === "Important"
? "#b85c00"
: severity === "Moderated"
? "#f5c12e"
: severity === "Low"
? "#316dc1"
: "#90a4ae"}; color: white; border-radius: 4px; font-size: 14px;">${severity}</span>
<h4 style="margin-top: 20px;">Affected Products:</h4>
<ul>
${affectedProducts.length > 0 ? affectedProducts.map((p) => `<li>${p}</li>`).join("") : "<li>None listed.</li>"}
</ul>
<h4 style="margin-top: 20px;">Remediation:</h4>
<p>${remediationNotes}</p>
<h4 style="margin-top: 20px;">References:</h4>
<ul>
${references.length > 0
? references.map((ref) => `<li><a href="${ref.url}" target="_blank">${ref.url}</a></li>`).join("")
: "<li>None listed.</li>"}
</ul>
</div>
`;
return {
content: [
{
type: "resource",
resource: {
text: html,
mimeType: "text/html",
uri: "data:text/html;base64," + Buffer.from(html).toString("base64"),
},
},
],
isError: false,
_meta: {},
};
});
// Add new tool: search-security-advisories
server.tool('search-security-advisories', 'Get Red Hat Security Advisories (RHSA) with various filters. You can specify 10, 20, 50, or 100 results. Supports product, severity, date range filters and more', {
query: z
.string()
.optional()
.describe("Search query term (default: '*:*' for all)"),
product: z.string().optional().describe('Filter by product name'),
version: z.string().optional().describe('Filter by product version'),
architecture: z.string().optional().describe('Filter by architecture'),
variant: z.string().optional().describe('Filter by product variant'),
severity: z
.string()
.optional()
.describe('Filter by severity level. Case-insensitive. Valid values: Low, Moderate, Important, Critical'),
startDate: z
.string()
.optional()
.describe('Start date filter (YYYY-MM-DD format)'),
endDate: z
.string()
.optional()
.describe('End date filter (YYYY-MM-DD format)'),
rows: z
.union([z.number(), z.string(), z.coerce.number()])
.optional()
.describe('Number of RHSA results to return. Common values: 10, 20, 50, 100 (default: 10)'),
page: z
.union([z.number(), z.string(), z.coerce.number()])
.optional()
.describe('Page number (default: 1)'),
sortBy: z
.enum([
'portal_publication_date desc',
'portal_update_date desc',
'portal_severity desc',
])
.optional()
.describe('Sort order (default: portal_publication_date desc)'),
}, async ({ query, product, version, architecture, variant, severity, startDate, endDate, rows, page, sortBy, }) => {
// Normalize severity input to proper case
let normalizedSeverity;
if (severity) {
const severityLower = severity.toLowerCase();
switch (severityLower) {
case 'low':
normalizedSeverity = 'Low';
break;
case 'moderate':
normalizedSeverity = 'Moderate';
break;
case 'important':
normalizedSeverity = 'Important';
break;
case 'critical':
normalizedSeverity = 'Critical';
break;
default:
return {
content: [
{
type: 'text',
text: `Invalid severity value: '${severity}'. Valid values are: Low, Moderate, Important, Critical (case-insensitive)`,
},
],
};
}
}
const baseUrl = 'https://access.redhat.com/hydra/rest/search/kcs';
const params = new URLSearchParams();
// Convert string inputs to numbers for calculations with better validation
let numRows = 10;
let numPage = 1;
if (rows !== undefined) {
if (Array.isArray(rows)) {
numRows = parseInt(String(rows[0]), 10) || 10;
}
else if (typeof rows === 'string') {
numRows = parseInt(rows, 10) || 10;
}
else if (typeof rows === 'number') {
numRows = rows;
}
// Validate and cap the number of rows to reasonable limits
if (numRows < 1)
numRows = 10;
if (numRows > 200)
numRows = 200; // Cap at 200 for performance
}
if (page !== undefined) {
if (Array.isArray(page)) {
numPage = parseInt(String(page[0]), 10) || 1;
}
else if (typeof page === 'string') {
numPage = parseInt(page, 10) || 1;
}
else if (typeof page === 'number') {
numPage = page;
}
// Validate page number
if (numPage < 1)
numPage = 1;
}
// Basic query parameters
params.append('q', query || '*:*');
params.append('start', String((numPage - 1) * numRows));
params.append('rows', String(numRows));
params.append('p', String(numPage));
params.append('sort', sortBy || 'portal_publication_date desc');
// Highlighting parameters
params.append('hl', 'true');
params.append('hl.fl', 'lab_description');
params.append('hl.simple.pre', '%3Cmark%3E');
params.append('hl.simple.post', '%3C%2Fmark%3E');
// Faceting parameters
params.append('facet', 'true');
params.append('facet.mincount', '1');
params.append('facet.field', 'portal_severity');
params.append('facet.field', 'portal_advisory_type');
// Field list to return - specific fields for Red Hat Security Advisories
params.append('fl', 'id,portal_severity,portal_product_names,portal_CVE,portal_publication_date,portal_synopsis,view_uri,allTitle,portal_update_date,portal_advisory_type');
// Build filter query (fq) parameters - Updated to specifically target Red Hat Security Advisories
const fqParts = [
'portal_advisory_type:("Security Advisory")',
'documentKind:("Errata")',
'id:(RHSA-*)', // Only return RHSA advisories
];
if (product) {
fqParts.push(`portal_product_names:("${product}")`);
}
if (normalizedSeverity) {
fqParts.push(`portal_severity:("${normalizedSeverity}")`);
}
if (version) {
fqParts.push(`portal_product_version:("${version}")`);
}
if (architecture) {
fqParts.push(`portal_architecture:("${architecture}")`);
}
if (variant) {
fqParts.push(`portal_product_variant:("${variant}")`);
}
// If no dates are specified, default to last 2 years to get latest advisories
if (!startDate && !endDate) {
const currentDate = new Date();
const twoYearsAgo = new Date();
twoYearsAgo.setFullYear(currentDate.getFullYear() - 2);
const defaultStartDate = twoYearsAgo.toISOString().split('T')[0]; // YYYY-MM-DD format
const defaultEndDate = currentDate.toISOString().split('T')[0]; // YYYY-MM-DD format
fqParts.push(`portal_publication_date:[${defaultStartDate}T00:00:00Z TO ${defaultEndDate}T23:59:59Z]`);
}
else if (startDate || endDate) {
const startDateStr = startDate ? `${startDate}T00:00:00Z` : '*';
const endDateStr = endDate ? `${endDate}T23:59:59Z` : '*';
fqParts.push(`portal_publication_date:[${startDateStr} TO ${endDateStr}]`);
}
params.append('fq', fqParts.join(' AND '));
const url = `${baseUrl}?${params.toString()}`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
const advisories = data?.response?.docs || [];
const totalResults = data?.response?.numFound || 0;
const facets = data?.facet_counts?.facet_fields || {};
if (!Array.isArray(advisories) || advisories.length === 0) {
return {
content: [
{
type: 'text',
text: 'No Red Hat Security Advisories found matching your criteria.',
},
],
};
}
// Format the results
const summary = advisories
.map((advisory, idx) => {
const rawAdvisoryId = advisory.id || 'No ID';
const advisoryId = rawAdvisoryId.replace(':', '-');
const title = advisory.allTitle || advisory.portal_synopsis || 'No title';
const severity = advisory.portal_severity || 'Unknown';
const pubDate = advisory.portal_publication_date
? new Date(advisory.portal_publication_date).toLocaleDateString()
: 'Unknown date';
const updateDate = advisory.portal_update_date
? new Date(advisory.portal_update_date).toLocaleDateString()
: 'Unknown date';
const link = advisory.view_uri || '#';
const synopsis = advisory.portal_synopsis || 'No synopsis available';
const cves = advisory.portal_CVE
? `${advisory.portal_CVE.length} related CVEs`
: 'No CVEs listed';
const products = advisory.portal_product_names
? advisory.portal_product_names.slice(0, 3).join(', ') +
(advisory.portal_product_names.length > 3 ? '...' : '')
: 'No products listed';
return `**${idx + 1}. [${advisoryId}](${link})**
**${cves}**
**${severity}: ${title}**
**${severity}**
**Products: ${products}**
**${pubDate}**`;
})
.join('\n\n');
// Format facet information
const severityFacets = facets.portal_severity || [];
const severityCounts = [];
for (let i = 0; i < severityFacets.length; i += 2) {
severityCounts.push(`${severityFacets[i]}: ${severityFacets[i + 1]}`);
}
const facetInfo = severityCounts.length > 0
? `\n\n**Severity Distribution:**\n${severityCounts.join(' | ')}`
: '';
return {
content: [
{
type: 'text',
text: `# Red Hat Security Advisories (RHSA)\n\n**Total Results:** ${totalResults}\n**Showing:** ${advisories.length} results${facetInfo}\n\n---\n\n${summary}`,
},
],
};
}
catch (err) {
return {
content: [
{ type: 'text', text: `Failed to fetch Red Hat Security Advisories: ${err}` },
],
};
}
});
// Add new tool: get-latest-cves
// server.tool(
// "get-latest-cves",
// "Fetch and summarize the latest 10 Red Hat CVEs from the Red Hat KCS API.",
// {},
// async () => {
// const url =
// "https://access.redhat.com/hydra/rest/search/kcs?facet=true&facet.field=cve_threatSeverity&facet.mincount=1&facet.range=%7B%21ex%3Date%7Dcve_publicDate&facet.range.end=NOW&facet.range.gap=%2B1YEAR&facet.range.start=NOW%2FYEAR-15YEARS&fl=id%2Ccve_threatSeverity%2Ccve_publicDate%2Cview_uri%2CallTitle%2Ccve_details%2Cdetails_source%2Ccve_state_package&fq=documentKind%3A%28%22Cve%22%29+AND+language%3A%28%22en%22%29+AND+details_source%3A%28%22Red+Hat%22%29+AND+cve_state_package%3A%5B%22%7B%5C%5C%22%22+TO+*%5D&hl=true&hl.fl=lab_description&hl.simple.post=%253C%252Fmark%253E&hl.simple.pre=%253Cmark%253E&q=red+hat&rows=10&sort=cve_publicDate+desc%2CallTitle+desc&start=0&p=1&facet.field=platform_state_string";
// try {
// const response = await fetch(url);
// if (!response.ok) {
// throw new Error(`HTTP ${response.status}: ${response.statusText}`);
// }
// const data = await response.json();
// const cves = data?.response?.docs || [];
// if (!Array.isArray(cves) || cves.length === 0) {
// return {
// content: [
// { type: "text", text: "No recent CVEs found from Red Hat KCS API." },
// ],
// };
// }
// const summary = cves
// .map((cve: any, idx: number) => {
// const title = cve.allTitle || cve.id || "No title";
// const severity = cve.cve_threatSeverity || "Unknown";
// const date = cve.cve_publicDate ? new Date(cve.cve_publicDate).toLocaleDateString() : "Unknown date";
// const link = cve.view_uri || "#";
// const details = cve.cve_details || "";
// return `**${idx + 1}. [${title}](${link})**\n- Severity: ${severity}\n- Public Date: ${date}\n- Details: ${details}`;
// })
// .join("\n\n");
// return {
// content: [
// {
// type: "text",
// text: `# Latest 10 Red Hat CVEs\n\n${summary}`,
// },
// ],
// };
// } catch (err) {
// return {
// content: [
// { type: "text", text: `Failed to fetch latest CVEs: ${err}` },
// ],
// };
// }
// }
// );
// Start the server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("CVE MCP Server running on stdio");
}
main().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
});