From f6168f1329d424f496ddab54240b120780a69e46 Mon Sep 17 00:00:00 2001 From: Rene Fichtmueller Date: Sat, 4 Apr 2026 23:46:36 +0200 Subject: [PATCH] feat: resilience score, route leak detection, data provenance, MCP server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- CHANGELOG.md | 18 ++++ mcp-server.js | 265 ++++++++++++++++++++++++++++++++++++++++++++++ public/index.html | 30 +++++- server.js | 173 +++++++++++++++++++++++++++++- 4 files changed, 482 insertions(+), 4 deletions(-) create mode 100644 mcp-server.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 5617cca..3f31fc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,3 +139,21 @@ All notable changes to PeerCortex are documented here. ### Infrastructure - Daily automated audit introduced: 103 ASNs validated every 24h + +## [0.6.9] — 2026-04-04 + +### Added +- **Resilience Score (1-10)**: Weighted score combining Transit Diversity (30%), + Peering Breadth (25%), IXP Presence (20%), Path Redundancy (25%). + Hard cap at 5.0 when single transit provider detected. + Confidence: HIGH — all inputs cross-validated daily vs RIPE Stat + PeeringDB. +- **Route Leak Detection**: Heuristic pattern detection for suspicious routing + relationships (Tier-1 as downstream, sandwich patterns). Confidence: MEDIUM — + pattern-based, not real-time. False positives possible. +- **Data Provenance System**: Every data point in the API response now includes + a _provenance field: source, validation method (cross-validated / heuristic / + computed / single-source), and confidence level (high / medium / experimental). + Visible in UI as colour-coded badges: green = validated, orange = indicative. +- **MCP Server** (mcp-server.js): Exposes PeerCortex as MCP tools for Claude + Desktop / Claude Code. Tools: lookup_asn, compare_networks, get_health_report, + search_network, get_resilience_score. All responses include provenance metadata. diff --git a/mcp-server.js b/mcp-server.js new file mode 100644 index 0000000..4ae819e --- /dev/null +++ b/mcp-server.js @@ -0,0 +1,265 @@ +#!/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"); +}); diff --git a/public/index.html b/public/index.html index 4425dd2..ec8e722 100644 --- a/public/index.html +++ b/public/index.html @@ -153,7 +153,23 @@ a:hover{color:var(--purple)} .scroll-wrap::-webkit-scrollbar-thumb{background:var(--border);border-radius:0} /* ─── RPKI ─────────────────────────────────────────────── */ -.rpki-valid{color:var(--green)}.rpki-invalid{color:var(--red)}.rpki-unknown{color:var(--dim)} +.rpki-valid{color:var(--green)} +.prov-badge{display:inline-flex;align-items:center;gap:5px;vertical-align:middle;cursor:help} +.prov-dot{display:inline-block;width:8px;height:8px;border-radius:50%} +.prov-high{background:var(--green)} +.prov-medium{background:var(--orange)} +.prov-experimental{background:transparent;border:1.5px solid var(--dim);width:7px;height:7px} +.prov-heuristic{background:var(--orange);opacity:0.6} +.prov-label{font-size:10px;color:var(--dim);font-family:monospace;letter-spacing:.3px} +.res-bar-wrap{display:flex;align-items:center;gap:8px;margin:4px 0} +.res-bar-bg{flex:1;height:6px;background:#1e293b;border-radius:3px;overflow:hidden} +.res-bar-fill{height:100%;border-radius:3px;transition:width .4s ease} +.res-score-big{font-size:48px;font-weight:800;letter-spacing:-1px;line-height:1} +.res-score-label{font-size:11px;color:var(--dim);margin-top:2px} +.leak-detected{color:var(--orange);font-weight:700} +.leak-clean{color:var(--green)} +.leak-pattern{background:#1a1f2e;border-radius:6px;padding:8px 12px;margin:6px 0;font-size:12px} +.leak-pattern-type{font-size:10px;color:var(--dim);font-family:monospace;margin-bottom:3px}.rpki-invalid{color:var(--red)}.rpki-unknown{color:var(--dim)} /* ─── Status ───────────────────────────────────────────── */ .status-yes{color:var(--green);font-weight:600} @@ -837,7 +853,15 @@ body.dark .card{border-top-color:#e8e4dc} -