- Resilience Score (1-10): weighted 4-factor model (transit diversity 30%, peering breadth 25%, IXP presence 20%, path redundancy 25%), hard cap at 5.0 on single transit provider. Confidence: HIGH (cross-validated data). - Route Leak Detection: heuristic Tier-1 sandwich/downstream pattern check. Confidence: MEDIUM — pattern-based, not real-time, false positives flagged. - Data Provenance System: every API response field includes source, validation method and confidence level. UI shows green/orange provenance badges. - MCP Server: exposes PeerCortex as Claude Desktop/Code tools (lookup_asn, compare_networks, get_health_report, search_network, get_resilience_score).
266 lines
9.4 KiB
JavaScript
266 lines
9.4 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* PeerCortex MCP Server
|
|
* Exposes PeerCortex API as MCP tools for Claude Desktop / Claude Code.
|
|
* Communicates via stdio. Every response includes _provenance metadata.
|
|
*
|
|
* Usage in claude_desktop_config.json:
|
|
* { "mcpServers": { "peercortex": { "command": "node", "args": ["/opt/peercortex-app/mcp-server.js"] } } }
|
|
*/
|
|
|
|
const PEERCORTEX_BASE = process.env.PEERCORTEX_BASE || "http://localhost:3101";
|
|
|
|
// ── Minimal MCP stdio server (no SDK dependency issues) ──────────────────────
|
|
const readline = require("readline");
|
|
const http = require("http");
|
|
const https = require("https");
|
|
|
|
function callApi(path) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = PEERCORTEX_BASE + path;
|
|
const mod = url.startsWith("https") ? https : http;
|
|
const req = mod.get(url, { headers: { "User-Agent": "PeerCortex-MCP/1.0" } }, (res) => {
|
|
let data = "";
|
|
res.on("data", (c) => (data += c));
|
|
res.on("end", () => {
|
|
try { resolve(JSON.parse(data)); }
|
|
catch (e) { reject(new Error("JSON parse error: " + e.message)); }
|
|
});
|
|
});
|
|
req.on("error", reject);
|
|
req.setTimeout(30000, () => { req.destroy(new Error("timeout")); });
|
|
});
|
|
}
|
|
|
|
// Summarize a full lookup into a compact MCP-friendly object
|
|
function summarizeLookup(d) {
|
|
if (!d || !d.network) return null;
|
|
const n = d.network || {};
|
|
const p = d.prefixes || {};
|
|
const r = d.rpki || {};
|
|
const ix = d.ix_presence || {};
|
|
const nb = d.neighbours || {};
|
|
const rs = d.resilience_score;
|
|
const rl = d.route_leak;
|
|
const dq = d.data_quality || {};
|
|
const prov= d._provenance || {};
|
|
|
|
return {
|
|
asn: n.asn,
|
|
name: n.name,
|
|
org: n.org_name,
|
|
website: n.website,
|
|
rir: n.rir,
|
|
country: n.country,
|
|
type: n.type,
|
|
policy: n.policy,
|
|
prefixes: {
|
|
total: p.total,
|
|
ipv4: p.ipv4,
|
|
ipv6: p.ipv6,
|
|
},
|
|
rpki: {
|
|
coverage_pct: r.coverage_percent,
|
|
valid: r.valid,
|
|
invalid: r.invalid,
|
|
not_found: r.not_found,
|
|
},
|
|
ix_presence: {
|
|
unique_ixps: ix.unique_ixps,
|
|
total_connections: ix.total_connections,
|
|
},
|
|
neighbours: {
|
|
upstream_count: nb.upstream_count,
|
|
downstream_count: nb.downstream_count,
|
|
peer_count: nb.peer_count,
|
|
upstreams: (nb.upstreams || []).slice(0, 10).map(u => `AS${u.asn} ${u.name}`),
|
|
},
|
|
resilience_score: rs ? {
|
|
score: rs.score,
|
|
single_transit_cap: rs.single_transit_cap_applied,
|
|
breakdown: rs.breakdown,
|
|
_provenance: rs._provenance,
|
|
} : null,
|
|
route_leak: rl ? {
|
|
detected: rl.detected,
|
|
patterns: rl.patterns,
|
|
_provenance: rl._provenance,
|
|
} : null,
|
|
data_quality: {
|
|
score: dq.score,
|
|
confidence: dq.confidence,
|
|
},
|
|
_provenance: prov,
|
|
};
|
|
}
|
|
|
|
// ── MCP Tool Definitions ──────────────────────────────────────────────────────
|
|
const TOOLS = [
|
|
{
|
|
name: "lookup_asn",
|
|
description: "Look up comprehensive ASN data including prefixes, RPKI, IX presence, neighbours, resilience score, and route leak detection. All data points include provenance metadata (source, validation method, confidence level).",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
asn: { type: "string", description: "ASN number, e.g. '24940' or 'AS24940'" },
|
|
},
|
|
required: ["asn"],
|
|
},
|
|
},
|
|
{
|
|
name: "compare_networks",
|
|
description: "Compare two ASNs side by side — prefixes, RPKI coverage, IX presence, resilience scores, peering policy.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
asn1: { type: "string", description: "First ASN" },
|
|
asn2: { type: "string", description: "Second ASN" },
|
|
},
|
|
required: ["asn1", "asn2"],
|
|
},
|
|
},
|
|
{
|
|
name: "get_health_report",
|
|
description: "Run the PeerCortex 13-point health report for an ASN: RPKI, ASPA, ROA coverage, IRR, MANRS, route visibility, and more.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
asn: { type: "string", description: "ASN number" },
|
|
},
|
|
required: ["asn"],
|
|
},
|
|
},
|
|
{
|
|
name: "search_network",
|
|
description: "Search for a network by name or organization. Returns matching ASNs with basic info.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
query: { type: "string", description: "Network name or organization to search for" },
|
|
},
|
|
required: ["query"],
|
|
},
|
|
},
|
|
{
|
|
name: "get_resilience_score",
|
|
description: "Get the weighted resilience score (1-10) for an ASN with full breakdown: transit diversity, peering breadth, IXP presence, path redundancy.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
asn: { type: "string", description: "ASN number" },
|
|
},
|
|
required: ["asn"],
|
|
},
|
|
},
|
|
];
|
|
|
|
// ── MCP Request Handlers ──────────────────────────────────────────────────────
|
|
async function handleToolCall(name, args) {
|
|
try {
|
|
if (name === "lookup_asn") {
|
|
const asnNum = String(args.asn).replace(/[^0-9]/g, "");
|
|
const d = await callApi(`/api/lookup?asn=${asnNum}`);
|
|
const summary = summarizeLookup(d);
|
|
return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
|
|
}
|
|
|
|
if (name === "compare_networks") {
|
|
const a1 = String(args.asn1).replace(/[^0-9]/g, "");
|
|
const a2 = String(args.asn2).replace(/[^0-9]/g, "");
|
|
const [d1, d2] = await Promise.all([
|
|
callApi(`/api/lookup?asn=${a1}`),
|
|
callApi(`/api/lookup?asn=${a2}`),
|
|
]);
|
|
const comparison = {
|
|
asn1: summarizeLookup(d1),
|
|
asn2: summarizeLookup(d2),
|
|
comparison: {
|
|
prefixes: { [a1]: d1?.prefixes?.total, [a2]: d2?.prefixes?.total },
|
|
rpki_coverage: { [a1]: d1?.rpki?.coverage_percent + "%", [a2]: d2?.rpki?.coverage_percent + "%" },
|
|
unique_ixps: { [a1]: d1?.ix_presence?.unique_ixps, [a2]: d2?.ix_presence?.unique_ixps },
|
|
upstream_count: { [a1]: d1?.neighbours?.upstream_count, [a2]: d2?.neighbours?.upstream_count },
|
|
resilience_score: { [a1]: d1?.resilience_score?.score, [a2]: d2?.resilience_score?.score },
|
|
route_leak: { [a1]: d1?.route_leak?.detected, [a2]: d2?.route_leak?.detected },
|
|
},
|
|
};
|
|
return { content: [{ type: "text", text: JSON.stringify(comparison, null, 2) }] };
|
|
}
|
|
|
|
if (name === "get_health_report") {
|
|
const asnNum = String(args.asn).replace(/[^0-9]/g, "");
|
|
const d = await callApi(`/api/validate?asn=${asnNum}`);
|
|
return { content: [{ type: "text", text: JSON.stringify(d, null, 2) }] };
|
|
}
|
|
|
|
if (name === "search_network") {
|
|
const q = encodeURIComponent(args.query);
|
|
const d = await callApi(`/api/search?q=${q}`);
|
|
return { content: [{ type: "text", text: JSON.stringify(d, null, 2) }] };
|
|
}
|
|
|
|
if (name === "get_resilience_score") {
|
|
const asnNum = String(args.asn).replace(/[^0-9]/g, "");
|
|
const d = await callApi(`/api/lookup?asn=${asnNum}`);
|
|
const rs = d?.resilience_score;
|
|
const n = d?.network || {};
|
|
if (!rs) {
|
|
return { content: [{ type: "text", text: JSON.stringify({ error: "No resilience data available for AS" + asnNum }) }] };
|
|
}
|
|
return { content: [{ type: "text", text: JSON.stringify({
|
|
asn: asnNum,
|
|
name: n.name,
|
|
resilience_score: rs,
|
|
_note: "Score 1-10. Hard cap at 5.0 if single transit provider detected.",
|
|
}, null, 2) }] };
|
|
}
|
|
|
|
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
} catch (err) {
|
|
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
}
|
|
}
|
|
|
|
// ── MCP stdio protocol ────────────────────────────────────────────────────────
|
|
const rl = readline.createInterface({ input: process.stdin });
|
|
let buffer = "";
|
|
|
|
rl.on("line", async (line) => {
|
|
buffer += line;
|
|
let msg;
|
|
try { msg = JSON.parse(buffer); buffer = ""; }
|
|
catch (e) { return; } // not complete JSON yet
|
|
|
|
const { jsonrpc, id, method, params } = msg;
|
|
|
|
let response;
|
|
|
|
if (method === "initialize") {
|
|
response = {
|
|
jsonrpc: "2.0", id,
|
|
result: {
|
|
protocolVersion: "2024-11-05",
|
|
capabilities: { tools: {} },
|
|
serverInfo: { name: "peercortex", version: "0.6.9" },
|
|
},
|
|
};
|
|
} else if (method === "tools/list") {
|
|
response = { jsonrpc: "2.0", id, result: { tools: TOOLS } };
|
|
} else if (method === "tools/call") {
|
|
const result = await handleToolCall(params.name, params.arguments || {});
|
|
response = { jsonrpc: "2.0", id, result };
|
|
} else if (method === "notifications/initialized") {
|
|
return; // no response needed
|
|
} else {
|
|
response = {
|
|
jsonrpc: "2.0", id,
|
|
error: { code: -32601, message: `Method not found: ${method}` },
|
|
};
|
|
}
|
|
|
|
process.stdout.write(JSON.stringify(response) + "\n");
|
|
});
|
|
|
|
process.on("uncaughtException", (err) => {
|
|
process.stderr.write("PeerCortex MCP error: " + err.message + "\n");
|
|
});
|