PeerCortex/mcp-server.js
Rene Fichtmueller f6168f1329 feat: resilience score, route leak detection, data provenance, MCP server
- 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).
2026-04-04 23:46:36 +02:00

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");
});