/**
* pfsense-mcp - MCP server for pfSense
*
* Bidirectional AI control of your firewall with NEVERHANG reliability
* and A.L.A.N. persistent learning.
*
* @author Claude (claude@arktechnwa.com) + Meldrey
* @license MIT
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import type Database from "better-sqlite3";
import { initDatabase, closeDatabase, getRrdCache, setRrdCache } from "./db.js";
import { NeverhangManager, NeverhangError } from "./neverhang.js";
import { PfSenseClient } from "./pfsense-client.js";
import { TOOLS } from "./tools/index.js";
const SERVER_NAME = "pfsense-mcp";
const SERVER_VERSION = "0.1.1";
// Guardian relay configuration
const GUARDIAN_RELAY_URL = process.env.GUARDIAN_RELAY_URL || "https://pfsense-mcp.arktechnwa.com";
const GUARDIAN_ADMIN_KEY = process.env.GUARDIAN_ADMIN_KEY || "";
// Global state
let db: Database.Database;
let client: PfSenseClient;
let neverhang: NeverhangManager;
/**
* Execute a tool with NEVERHANG protection
*/
async function executeTool(
name: string,
args: Record<string, unknown>
): Promise<unknown> {
// Check circuit breaker
const canExecute = neverhang.canExecute();
if (!canExecute.allowed) {
throw new NeverhangError("circuit_open", canExecute.reason || "Circuit open", 0);
}
const { timeout_ms } = neverhang.getTimeout(name);
const start = Date.now();
try {
const result = await executeToolInner(name, args, timeout_ms);
const duration = Date.now() - start;
neverhang.recordSuccess(name, duration);
return result;
} catch (error) {
const duration = Date.now() - start;
const errorType = error instanceof NeverhangError ? error.type : "api_error";
neverhang.recordFailure(name, duration, errorType);
throw error;
}
}
/**
* Inner tool execution (route to handlers)
*/
async function executeToolInner(
name: string,
args: Record<string, unknown>,
timeoutMs: number
): Promise<unknown> {
switch (name) {
// ========================================================================
// HEALTH
// ========================================================================
case "pf_health": {
const stats = neverhang.getStats();
const alanStats = neverhang.getDatabaseStats();
// Try a quick ping
let pingResult: { ok: boolean; latency_ms: number } = { ok: false, latency_ms: 0 };
try {
pingResult = await neverhang.health.ping();
} catch {
// Ping failed, already recorded
}
// Get system status for dashboard
let systemData: Record<string, unknown> = {};
try {
const sysResponse = await client.getSystemStatus();
const sysRaw = sysResponse.data as unknown as Record<string, unknown>;
if (sysRaw) {
systemData = {
uptime: sysRaw.uptime ?? null,
platform: sysRaw.platform ?? null,
cpu: {
model: sysRaw.cpu_model ?? null,
count: sysRaw.cpu_count ?? null,
usage_percent: sysRaw.cpu_usage ?? null,
temperature_c: sysRaw.temp_c ?? null,
},
memory: { usage_percent: sysRaw.mem_usage ?? null },
disk: { usage_percent: sysRaw.disk_usage ?? null },
};
}
} catch {
// System status failed, continue without it
}
// Get interface data
let interfaceData: Record<string, unknown> = {};
try {
const ifResponse = await client.getInterfaceStatuses();
const ifList = (ifResponse.data || []) as unknown as Array<Record<string, unknown>>;
for (const iface of ifList) {
const name = (iface.name || iface.descr || 'unknown') as string;
interfaceData[name.toLowerCase()] = {
status: iface.status,
ipaddr: iface.ipaddr,
inbytes: iface.inbytes,
outbytes: iface.outbytes,
inpkts: iface.inpkts,
outpkts: iface.outpkts,
};
}
} catch {
// Interface status failed, continue without it
}
const healthData = {
pfsense: {
reachable: pingResult.ok,
latency_ms: pingResult.latency_ms,
host: client.getBaseUrl(),
},
neverhang: {
status: stats.status,
circuit: stats.circuit,
circuit_opens_in_seconds: stats.circuit_opens_in
? Math.ceil(stats.circuit_opens_in / 1000)
: null,
latency_p95_ms: stats.latency_p95_ms,
recent_failures: stats.recent_failures,
uptime_percent: neverhang.getUptimePercent(),
},
alan: alanStats
? {
queries_24h: alanStats.queries_24h,
success_rate_24h: Math.round(alanStats.success_rate_24h * 100) + "%",
avg_latency_by_complexity: alanStats.avg_latency_by_complexity,
health_trend: alanStats.health_trend,
}
: null,
system: systemData,
interfaces: interfaceData,
};
// Push metrics to Guardian relay (fire and forget)
if (GUARDIAN_ADMIN_KEY && GUARDIAN_RELAY_URL) {
const deviceToken = process.env.PFSENSE_DEVICE_TOKEN || "default";
fetch(`${GUARDIAN_RELAY_URL}/api/admin/metrics`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Admin-Key": GUARDIAN_ADMIN_KEY,
},
body: JSON.stringify({
device_token: deviceToken,
metrics: healthData,
}),
}).catch(() => {
// Silently fail - don't break health check for relay issues
});
}
return healthData;
}
// ========================================================================
// SYSTEM
// ========================================================================
case "pf_system_info": {
const response = await client.getSystemInfo();
return response.data;
}
case "pf_system_status": {
const response = await client.getSystemStatus();
const data = response.data as unknown as Record<string, unknown>;
if (!data) return { error: "No data returned" };
// v2 API returns flat structure, not nested
return {
uptime: data.uptime ?? null,
platform: data.platform ?? null,
cpu: {
model: data.cpu_model ?? null,
count: data.cpu_count ?? null,
usage_percent: data.cpu_usage ?? null,
load_avg: data.cpu_load_avg ?? null,
temperature_c: data.temp_c ?? null,
},
memory: {
usage_percent: data.mem_usage ?? null,
},
swap: {
usage_percent: data.swap_usage ?? null,
},
disk: {
usage_percent: data.disk_usage ?? null,
},
mbuf_usage: data.mbuf_usage ?? null,
};
}
// ========================================================================
// INTERFACES
// ========================================================================
case "pf_interface_list": {
const response = await client.getInterfaces();
return response.data;
}
case "pf_interface_status": {
const iface = args.interface as string;
if (!iface) throw new Error("interface parameter required");
const response = await client.getInterfaceStatuses();
const statuses = (response.data || []) as unknown as Array<Record<string, unknown>>;
// Find by interface name (wan, lan) or device name (mvneta0, igb0)
const match = statuses.find((s) =>
s.name === iface || s.if === iface || s.descr === iface
);
if (!match) {
throw new Error(`Interface '${iface}' not found. Available: ${statuses.map((s) => s.name || s.descr).join(', ')}`);
}
return match;
}
// ========================================================================
// FIREWALL
// ========================================================================
case "pf_firewall_rules": {
const response = await client.getFirewallRules();
let rules = response.data || [];
// Filter by interface if specified
const iface = args.interface as string | undefined;
if (iface) {
rules = rules.filter((r) => r.interface === iface);
}
return rules.map((r) => ({
id: r.id,
type: r.type,
interface: r.interface,
protocol: r.protocol,
source: r.source,
destination: r.destination,
description: r.descr,
disabled: r.disabled || false,
}));
}
case "pf_firewall_states": {
const response = await client.getFirewallStates();
const limit = (args.limit as number) || 100;
const states = (response.data || []).slice(0, limit);
return states.map((s) => ({
interface: s.interface,
protocol: s.protocol,
source: s.src,
destination: s.dst,
state: s.state,
age: s.age,
packets: s.pkts,
bytes: s.bytes,
}));
}
// ========================================================================
// DHCP
// ========================================================================
case "pf_dhcp_leases": {
const response = await client.getDhcpLeases();
let leases = response.data || [];
// Filter by status
const status = args.status as string | undefined;
if (status && status !== "all") {
leases = leases.filter((l) => l.status === status);
}
return leases.map((l) => ({
ip: l.ip,
mac: l.mac,
hostname: l.hostname || "(unknown)",
status: l.status,
type: l.type,
start: l.start,
end: l.end,
}));
}
// ========================================================================
// GATEWAYS
// ========================================================================
case "pf_gateway_status": {
const response = await client.getGatewayStatus();
return (response.data || []).map((g) => ({
name: g.name,
gateway: g.gateway,
monitor: g.monitor,
status: g.status,
latency_ms: g.delay,
stddev_ms: g.stddev,
loss_percent: g.loss,
}));
}
// ========================================================================
// SERVICES
// ========================================================================
case "pf_services_list": {
const response = await client.getServices();
return (response.data || []).map((s) => ({
name: s.name,
description: s.description,
enabled: s.enabled,
status: s.status,
}));
}
case "pf_service_start": {
const service = args.service as string;
if (!service) throw new Error("service parameter required");
await client.startService(service);
return { success: true, action: "started", service };
}
case "pf_service_stop": {
const service = args.service as string;
if (!service) throw new Error("service parameter required");
await client.stopService(service);
return { success: true, action: "stopped", service };
}
case "pf_service_restart": {
const service = args.service as string;
if (!service) throw new Error("service parameter required");
await client.restartService(service);
return { success: true, action: "restarted", service };
}
// ========================================================================
// DIAGNOSTICS
// ========================================================================
case "pf_diag_ping": {
const host = args.host as string;
if (!host) throw new Error("host parameter required");
const count = Math.min((args.count as number) || 3, 10);
// Use command_prompt to run ping (v2 API doesn't have dedicated ping endpoint)
const response = await client.runCommand(`ping -c ${count} ${host}`);
return {
command: `ping -c ${count} ${host}`,
output: response.data?.output || "",
};
}
case "pf_diag_arp": {
const response = await client.arpTable();
return (response.data || []).map((e) => ({
ip: e.ip,
mac: e.mac,
interface: e.interface,
hostname: e.hostname,
type: e.type,
}));
}
// ========================================================================
// RRD HISTORICAL DATA
// ========================================================================
case "pf_rrd": {
const metric = args.metric as string;
const period = (args.period as string) || "1h";
if (!metric) throw new Error("metric parameter required");
// Check A.L.A.N. cache first
const cached = getRrdCache(db, metric, period);
if (cached) {
console.error(`[RRD] Cache hit: ${metric}/${period}`);
return JSON.parse(cached.data);
}
// Map metric to RRD file and column selection
const metricMap: Record<string, { file: string; cols: string[] }> = {
cpu: { file: "system-processor.rrd", cols: ["user", "system", "interrupt"] },
memory: { file: "system-memory.rrd", cols: ["active", "inactive", "free", "cache", "wired"] },
states: { file: "system-states.rrd", cols: ["pfstates"] },
traffic_lan: { file: "lan-traffic.rrd", cols: ["inpass", "outpass"] },
traffic_wan: { file: "wan-traffic.rrd", cols: ["inpass", "outpass"] },
gateway_quality: { file: "WAN_DHCP-quality.rrd", cols: ["loss", "delay"] },
};
const config = metricMap[metric];
if (!config) {
throw new Error(`Unknown metric: ${metric}. Available: ${Object.keys(metricMap).join(", ")}`);
}
// Map period to rrdtool time spec
const periodMap: Record<string, string> = {
"1h": "end-1h",
"4h": "end-4h",
"1d": "end-1d",
"1w": "end-1w",
"1m": "end-1m",
};
const timeSpec = periodMap[period];
if (!timeSpec) throw new Error(`Unknown period: ${period}`);
// Fetch from pfSense via rrdtool
console.error(`[RRD] Fetching ${metric}/${period} from pfSense...`);
const cmdResponse = await client.runCommand(
`rrdtool fetch /var/db/rrd/${config.file} AVERAGE --start ${timeSpec}`
);
const output = cmdResponse.data?.output || "";
if (!output) throw new Error("No data returned from rrdtool");
// Parse rrdtool output
const lines = output.trim().split("\n");
const headerLine = lines.find((l) => l.includes(":") === false && l.trim().length > 0);
const dataLines = lines.filter((l) => l.includes(":") && !l.includes("nan nan nan"));
// Parse header (column names)
const headers = headerLine?.trim().split(/\s+/) || config.cols;
// Parse data points
const dataPoints: Array<{ timestamp: number; values: Record<string, number> }> = [];
for (const line of dataLines) {
const [tsStr, ...valStrs] = line.trim().split(/\s+/);
const timestamp = parseInt(tsStr.replace(":", ""), 10);
const values: Record<string, number> = {};
headers.forEach((col, i) => {
const val = parseFloat(valStrs[i]);
if (!isNaN(val)) values[col] = val;
});
if (Object.keys(values).length > 0) {
dataPoints.push({ timestamp, values });
}
}
// Build result
const result = {
metric,
period,
file: config.file,
columns: headers,
data_points: dataPoints.length,
data: dataPoints,
fetched_at: Date.now(),
};
// Store in A.L.A.N. cache
setRrdCache(db, metric, period, JSON.stringify(result));
console.error(`[RRD] Cached ${metric}/${period}: ${dataPoints.length} points`);
return result;
}
// ========================================================================
// GUARDIAN RELAY
// ========================================================================
case "pf_guardian_devices": {
if (!GUARDIAN_ADMIN_KEY) {
throw new Error("GUARDIAN_ADMIN_KEY not configured");
}
const email = args.email as string | undefined;
const url = new URL("/api/admin/devices", GUARDIAN_RELAY_URL);
if (email) url.searchParams.set("email", email);
const response = await fetch(url.toString(), {
headers: { "X-Admin-Key": GUARDIAN_ADMIN_KEY },
});
if (!response.ok) {
throw new Error(`Guardian API error: ${response.status}`);
}
return await response.json();
}
case "pf_guardian_events": {
if (!GUARDIAN_ADMIN_KEY) {
throw new Error("GUARDIAN_ADMIN_KEY not configured");
}
const email = args.email as string | undefined;
const limit = Math.min((args.limit as number) || 20, 100);
const url = new URL("/api/admin/events", GUARDIAN_RELAY_URL);
if (email) url.searchParams.set("email", email);
url.searchParams.set("limit", limit.toString());
const response = await fetch(url.toString(), {
headers: { "X-Admin-Key": GUARDIAN_ADMIN_KEY },
});
if (!response.ok) {
throw new Error(`Guardian API error: ${response.status}`);
}
return await response.json();
}
case "pf_guardian_health": {
if (!GUARDIAN_ADMIN_KEY) {
throw new Error("GUARDIAN_ADMIN_KEY not configured");
}
const url = new URL("/api/admin/health", GUARDIAN_RELAY_URL);
const response = await fetch(url.toString(), {
headers: { "X-Admin-Key": GUARDIAN_ADMIN_KEY },
});
if (!response.ok) {
throw new Error(`Guardian API error: ${response.status}`);
}
return await response.json();
}
default:
throw new Error(`Unknown tool: ${name}`);
}
}
/**
* Format uptime in human-readable format
*/
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
return parts.join(" ") || "< 1m";
}
/**
* Main entry point
*/
async function main() {
// Initialize A.L.A.N. database
db = initDatabase();
// Initialize pfSense client
try {
client = new PfSenseClient();
console.error(`[pfsense-mcp] Configured for ${client.getBaseUrl()}`);
} catch (error) {
console.error("[pfsense-mcp] Warning: PFSENSE_HOST not set, tools will fail until configured");
// Create a dummy client that will fail on use
client = new PfSenseClient({ host: "unconfigured" });
}
// Initialize NEVERHANG with health ping
neverhang = new NeverhangManager(
{},
async () => {
await client.ping();
},
db
);
neverhang.start();
// Create MCP server
const server = new Server(
{
name: SERVER_NAME,
version: SERVER_VERSION,
},
{
capabilities: {
tools: {},
},
}
);
// Register tool list handler
server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools: TOOLS };
});
// Register tool call handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
const result = await executeTool(name, (args as Record<string, unknown>) || {});
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
} catch (error) {
const message =
error instanceof NeverhangError
? `${error.type}: ${error.message}\n${error.suggestion}`
: error instanceof Error
? error.message
: "Unknown error";
return {
content: [
{
type: "text",
text: `Error: ${message}`,
},
],
isError: true,
};
}
});
// Graceful shutdown
const shutdown = () => {
console.error("[pfsense-mcp] Shutting down...");
neverhang.stop();
closeDatabase(db);
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
// Connect via stdio
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`[pfsense-mcp] v${SERVER_VERSION} running with NEVERHANG + A.L.A.N.`);
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});