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