#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
} from '@modelcontextprotocol/sdk/types.js';
// ============================================================================
// Structured Logger
// ============================================================================
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
context?: Record<string, unknown>;
}
const LOG_LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
export const ENV_LOG_LEVEL = 'LOG_LEVEL';
class Logger {
private level: LogLevel;
private readonly serviceName: string;
constructor(serviceName: string = 'firewalla-mcp') {
this.serviceName = serviceName;
this.level = this.parseLogLevel(process.env[ENV_LOG_LEVEL]);
}
private parseLogLevel(envValue?: string): LogLevel {
if (envValue && envValue.toLowerCase() in LOG_LEVELS) {
return envValue.toLowerCase() as LogLevel;
}
return 'info'; // default
}
private shouldLog(level: LogLevel): boolean {
return LOG_LEVELS[level] >= LOG_LEVELS[this.level];
}
private formatEntry(
level: LogLevel,
message: string,
context?: Record<string, unknown>
): LogEntry {
return {
timestamp: new Date().toISOString(),
level,
message,
...(context && Object.keys(context).length > 0 ? { context } : {}),
};
}
private output(entry: LogEntry): void {
// Use stderr for logging (stdout is reserved for MCP communication)
const json = JSON.stringify({ service: this.serviceName, ...entry });
process.stderr.write(json + '\n');
}
debug(message: string, context?: Record<string, unknown>): void {
if (this.shouldLog('debug')) {
this.output(this.formatEntry('debug', message, context));
}
}
info(message: string, context?: Record<string, unknown>): void {
if (this.shouldLog('info')) {
this.output(this.formatEntry('info', message, context));
}
}
warn(message: string, context?: Record<string, unknown>): void {
if (this.shouldLog('warn')) {
this.output(this.formatEntry('warn', message, context));
}
}
error(message: string, context?: Record<string, unknown>): void {
if (this.shouldLog('error')) {
this.output(this.formatEntry('error', message, context));
}
}
setLevel(level: LogLevel): void {
this.level = level;
}
getLevel(): LogLevel {
return this.level;
}
}
// Global logger instance
export const logger = new Logger();
// ============================================================================
// Types based on your Firewalla API responses
// ============================================================================
export interface FirewallaConfig {
baseUrl: string;
bearerToken: string;
firewallId: string;
}
export interface FlowRecord {
ts: number;
fd: 'in' | 'out';
count: number;
duration?: number;
intf: string;
dTags?: string[];
oIntf?: string;
protocol: 'tcp' | 'udp';
port: number;
devicePort: number;
ip: string;
deviceIP: string;
upload?: number;
download?: number;
device: string;
host?: string;
country: string;
deviceName?: string;
macVendor?: string;
tags: string[];
tagIds: string[];
networkName: string;
onWan: boolean;
blocked?: boolean;
blockType?: string;
blockPid?: string;
category?: string;
app?: string;
apid?: number;
flowTags?: string[];
intfInfo?: {
name: string;
type: string;
uuid: string;
};
portInfo?: {
description?: string;
name?: string;
port: number;
protocol: string;
};
devicePortInfo?: {
description?: string;
name?: string;
port: number;
protocol: string;
};
type?: string;
total?: number | null;
}
interface DeviceInterface {
uuid: string;
name: string;
}
interface DevicePolicy {
family?: boolean;
acl?: boolean;
doh?: { state: boolean };
safeSearch?: { state: boolean };
unbound?: { state: boolean };
device_service_scan?: boolean;
weak_password_scan?: { state: boolean };
deviceOffline?: boolean;
monitor?: boolean;
adblock?: boolean;
ntp_redirect?: { state: boolean };
devicePresence?: boolean;
qos?: boolean;
ipAllocation?: {
allocations: Record<
string,
{
type: 'static' | 'dynamic';
ipv4?: string;
}
>;
};
deviceTags: string[];
userTags: string[];
tags: string[];
ssidTags: string[];
}
interface NetworkGroup {
meta: {
type: string;
name: string;
uuid: string;
};
enabled: boolean;
intf?: string;
ipv4?: string;
uuid: string;
name: string;
gid: string;
devices: Array<{
ip: string;
mac: string;
name: string;
lastActive: number;
deviceType?: string;
}>;
dhcp?: boolean;
extra?: Record<string, any>;
listenPort?: number;
peers?: Array<{
allowedIPs: string[];
publicKey: string;
}>;
privateKey?: string;
vpn?: boolean;
}
interface CloudRule {
id: string;
type: string;
name: string;
source: string;
scope: string;
beta: boolean;
disabled: boolean;
last_updated: number;
rules: Array<{
type: string;
target: string[];
action: string;
remotePort: string;
protocol: string;
dnsmasq_only: boolean;
}>;
notes: string;
count: number;
dnsmasq_only: boolean;
}
interface DeviceRule {
cronTime?: string;
duration?: string;
expire?: string;
action: 'allow' | 'block' | 'deny';
notes?: string;
type: string;
remotePort?: string;
targetList?: string;
target: string;
dnsmasq_only: boolean;
direction: 'inbound' | 'outbound';
scope: string[];
timestamp?: number;
activatedTime?: number;
pid?: string;
autoDeleteWhenExpires?: number;
tag?: string[];
}
interface RuleUpdateRequest {
rules: DeviceRule[];
gids: string[];
}
interface TrendData {
upload: Record<string, number>;
download: Record<string, number>;
totalUpload: number;
totalDownload: number;
block: Record<
string,
{
percent: number;
total: number;
blocked: number;
}
>;
totalConn: number;
totalIpB: number;
totalDnsB: number;
}
interface FirewallaBox {
name: string;
model: string;
gid: string;
eid: string;
status: boolean;
activeTs: number;
syncTs: number;
lokiEnabled: boolean;
}
interface NetworkTargets {
devices: Device[];
deviceTags: DeviceTag[];
networkGroups: NetworkGroup[];
}
interface DeviceTag {
uid: string;
name: string;
createTs: number;
policy: Record<string, any>;
gid: string;
devices: Array<{
ip: string;
mac: string;
name: string;
lastActive: number;
deviceType: string;
}>;
}
export interface Device {
ip: string;
mac: string;
lastActive: number;
firstFound: number;
macVendor: string;
bname: string;
names: string[];
policy: DevicePolicy;
name: string;
deviceType: string;
intf: DeviceInterface;
reserved?: boolean;
totalUpload: number;
totalDownload: number;
gid: string;
online: boolean;
tags?: DeviceTag[];
firewallas?: Array<any>;
isFirewalla?: boolean;
publicKey?: string; // For WireGuard devices
allowedIPs?: string[]; // For WireGuard devices
uid?: string; // For WireGuard devices
}
interface FlowQueryParams {
start: number;
end: number;
filters?: any[];
focus?: boolean;
}
// Custom error classes for better error handling
export class FirewallaAPIError extends Error {
constructor(
message: string,
public readonly statusCode?: number,
public readonly endpoint?: string,
public readonly retryable: boolean = false
) {
super(message);
this.name = 'FirewallaAPIError';
}
}
export class FirewallaNetworkError extends FirewallaAPIError {
constructor(message: string, endpoint?: string) {
super(message, undefined, endpoint, true);
this.name = 'FirewallaNetworkError';
}
}
export class FirewallaAuthError extends FirewallaAPIError {
constructor(message: string, endpoint?: string) {
super(message, 401, endpoint, false);
this.name = 'FirewallaAuthError';
}
}
export class FirewallaRateLimitError extends FirewallaAPIError {
constructor(
message: string,
public readonly retryAfter?: number,
endpoint?: string
) {
super(message, 429, endpoint, true);
this.name = 'FirewallaRateLimitError';
}
}
// Retry configuration
interface RetryConfig {
maxRetries: number;
baseDelayMs: number;
maxDelayMs: number;
timeoutMs: number;
}
const DEFAULT_RETRY_CONFIG: RetryConfig = {
maxRetries: 3,
baseDelayMs: 1000,
maxDelayMs: 10000,
timeoutMs: 30000,
};
export class FirewallaAPI {
private config: FirewallaConfig;
private retryConfig: RetryConfig;
constructor(config: FirewallaConfig, retryConfig: Partial<RetryConfig> = {}) {
this.config = config;
this.retryConfig = { ...DEFAULT_RETRY_CONFIG, ...retryConfig };
}
/**
* Determines if an error is retryable
*/
private isRetryableError(error: unknown, statusCode?: number): boolean {
// Network errors are retryable
if (error instanceof TypeError && error.message.includes('fetch')) {
return true;
}
// Retry on server errors (5xx) and rate limits (429)
if (statusCode) {
return statusCode === 429 || (statusCode >= 500 && statusCode < 600);
}
return false;
}
/**
* Calculate delay for exponential backoff with jitter
*/
private calculateBackoffDelay(attempt: number): number {
const exponentialDelay = this.retryConfig.baseDelayMs * Math.pow(2, attempt);
const jitter = Math.random() * 0.3 * exponentialDelay; // Add up to 30% jitter
return Math.min(exponentialDelay + jitter, this.retryConfig.maxDelayMs);
}
/**
* Sleep for a specified duration
*/
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Create an AbortController with timeout
*/
private createTimeoutController(): { controller: AbortController; timeoutId: NodeJS.Timeout } {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.retryConfig.timeoutMs);
return { controller, timeoutId };
}
private async makeRequest(endpoint: string, options: RequestInit = {}): Promise<any> {
const url = `${this.config.baseUrl}${endpoint}`;
let lastError: Error | null = null;
for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
const { controller, timeoutId } = this.createTimeoutController();
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
'Content-Type': 'application/json; charset=utf-8',
Authorization: `Bearer ${this.config.bearerToken}`,
'X-Firewalla-ID': this.config.firewallId,
'Cache-Control': 'no-cache',
...options.headers,
},
});
clearTimeout(timeoutId);
if (response.ok) {
return response.json();
}
// Handle specific error codes
const statusCode = response.status;
const statusText = response.statusText;
if (statusCode === 401 || statusCode === 403) {
throw new FirewallaAuthError(
`Authentication failed: ${statusCode} ${statusText}`,
endpoint
);
}
if (statusCode === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '60', 10);
if (attempt < this.retryConfig.maxRetries) {
logger.warn('Rate limited, waiting before retry', {
endpoint,
retryAfterSeconds: retryAfter,
});
await this.sleep(retryAfter * 1000);
continue;
}
throw new FirewallaRateLimitError(
`Rate limited: ${statusCode} ${statusText}`,
retryAfter,
endpoint
);
}
// Check if error is retryable
if (this.isRetryableError(null, statusCode) && attempt < this.retryConfig.maxRetries) {
const delay = this.calculateBackoffDelay(attempt);
logger.warn('Request failed, retrying', {
endpoint,
statusCode,
delayMs: Math.round(delay),
attempt: attempt + 1,
maxRetries: this.retryConfig.maxRetries,
});
await this.sleep(delay);
continue;
}
throw new FirewallaAPIError(
`Firewalla API error: ${statusCode} ${statusText}`,
statusCode,
endpoint,
false
);
} catch (error) {
clearTimeout(timeoutId);
// Don't retry auth errors
if (error instanceof FirewallaAuthError) {
throw error;
}
// Handle abort/timeout
if (error instanceof Error && error.name === 'AbortError') {
lastError = new FirewallaNetworkError(
`Request to ${endpoint} timed out after ${this.retryConfig.timeoutMs}ms`,
endpoint
);
if (attempt < this.retryConfig.maxRetries) {
const delay = this.calculateBackoffDelay(attempt);
logger.warn('Request timed out, retrying', {
endpoint,
timeoutMs: this.retryConfig.timeoutMs,
delayMs: Math.round(delay),
attempt: attempt + 1,
maxRetries: this.retryConfig.maxRetries,
});
await this.sleep(delay);
continue;
}
throw lastError;
}
// Handle network errors (fetch failures)
if (error instanceof TypeError) {
lastError = new FirewallaNetworkError(
`Network error connecting to ${endpoint}: ${error.message}`,
endpoint
);
if (attempt < this.retryConfig.maxRetries) {
const delay = this.calculateBackoffDelay(attempt);
logger.warn('Network error, retrying', {
endpoint,
error: error.message,
delayMs: Math.round(delay),
attempt: attempt + 1,
maxRetries: this.retryConfig.maxRetries,
});
await this.sleep(delay);
continue;
}
throw lastError;
}
// Re-throw known API errors
if (error instanceof FirewallaAPIError) {
throw error;
}
// Unknown error
throw new FirewallaAPIError(
`Unexpected error calling ${endpoint}: ${error instanceof Error ? error.message : String(error)}`,
undefined,
endpoint,
false
);
}
}
// Should not reach here, but just in case
throw (
lastError ||
new FirewallaAPIError('Request failed after all retries', undefined, endpoint, false)
);
}
async queryFlows(params: FlowQueryParams): Promise<FlowRecord[]> {
return this.makeRequest('/v1/flows/query', {
method: 'POST',
body: JSON.stringify(params),
});
}
async listDevices(): Promise<Device[]> {
return this.makeRequest('/v1/device/list');
}
async getNetworkTargets(): Promise<NetworkTargets> {
return this.makeRequest('/v1/rule/targets');
}
async getCloudRules(): Promise<Record<string, CloudRule>> {
return this.makeRequest('/v1/rule/cloud/list');
}
async getTrendData(type: '24h' | '7d' | '30d' = '24h'): Promise<TrendData> {
return this.makeRequest(`/v1/dashboard/trend?type=${type}`);
}
async getFirewallaBoxes(): Promise<FirewallaBox[]> {
return this.makeRequest('/v1/box/list');
}
async getDevice(mac: string): Promise<Device> {
const devices = await this.listDevices();
const device = devices.find((d) => d.mac === mac);
if (!device) {
throw new Error(`Device with MAC ${mac} not found`);
}
return device;
}
async updateRules(rules: DeviceRule[], gids: string[]): Promise<any> {
const requestBody: RuleUpdateRequest = {
rules,
gids,
};
return this.makeRequest('/v1/rule/batchUpdate', {
method: 'POST',
body: JSON.stringify(requestBody),
});
}
}
// Environment variable names for configuration
export const ENV_FIREWALLA_URL = 'FIREWALLA_URL';
export const ENV_FIREWALLA_TOKEN = 'FIREWALLA_TOKEN';
export const ENV_FIREWALLA_ID = 'FIREWALLA_ID';
export class FirewallaMCPServer {
private server: Server;
private api: FirewallaAPI | null = null;
private configSource: 'none' | 'env' | 'tool' = 'none';
constructor() {
this.server = new Server(
{
name: 'firewalla-server',
version: '0.1.0',
},
{
capabilities: {
tools: {},
},
}
);
this.initializeFromEnv();
this.setupToolHandlers();
}
/**
* Initialize the API client from environment variables if available.
* Required env vars: FIREWALLA_URL, FIREWALLA_TOKEN, FIREWALLA_ID
*/
private initializeFromEnv(): void {
const baseUrl = process.env[ENV_FIREWALLA_URL];
const bearerToken = process.env[ENV_FIREWALLA_TOKEN];
const firewallId = process.env[ENV_FIREWALLA_ID];
if (baseUrl && bearerToken && firewallId) {
this.api = new FirewallaAPI({
baseUrl,
bearerToken,
firewallId,
});
this.configSource = 'env';
logger.info('Firewalla API configured from environment variables', { baseUrl });
} else {
const missing: string[] = [];
if (!baseUrl) missing.push(ENV_FIREWALLA_URL);
if (!bearerToken) missing.push(ENV_FIREWALLA_TOKEN);
if (!firewallId) missing.push(ENV_FIREWALLA_ID);
if (missing.length > 0 && missing.length < 3) {
// Some env vars set but not all - warn user
logger.warn('Partial Firewalla config detected', { missing });
}
// If no env vars set, user must use configure_firewalla tool
}
}
private setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'configure_firewalla',
description:
'Configure Firewalla API connection. Can also be configured via environment variables: FIREWALLA_URL, FIREWALLA_TOKEN, FIREWALLA_ID',
inputSchema: {
type: 'object',
properties: {
baseUrl: {
type: 'string',
description: 'Base URL for Firewalla API (e.g., https://my.firewalla.com)',
},
bearerToken: {
type: 'string',
description: 'Bearer token for authentication',
},
firewallId: {
type: 'string',
description: 'Firewalla device ID (X-Firewalla-ID header)',
},
},
required: ['baseUrl', 'bearerToken', 'firewallId'],
},
},
{
name: 'get_config_status',
description: 'Check if Firewalla API is configured and show configuration source',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'query_network_flows',
description: 'Query network flows with time range and optional filters',
inputSchema: {
type: 'object',
properties: {
startTime: {
type: 'number',
description: 'Start time as Unix timestamp',
},
endTime: {
type: 'number',
description: 'End time as Unix timestamp',
},
filters: {
type: 'array',
description: 'Optional filters for the query',
items: { type: 'object' },
},
focus: {
type: 'boolean',
description: 'Whether to focus the query',
default: false,
},
},
required: ['startTime', 'endTime'],
},
},
{
name: 'list_devices',
description: 'List all devices on the network',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'get_device_details',
description: 'Get detailed information about a specific device by MAC address',
inputSchema: {
type: 'object',
properties: {
mac: {
type: 'string',
description: 'MAC address of the device',
},
},
required: ['mac'],
},
},
{
name: 'analyze_network_traffic',
description: 'Analyze network traffic patterns for a time period',
inputSchema: {
type: 'object',
properties: {
startTime: {
type: 'number',
description: 'Start time as Unix timestamp',
},
endTime: {
type: 'number',
description: 'End time as Unix timestamp',
},
analysisType: {
type: 'string',
enum: ['summary', 'top_talkers', 'blocked_traffic', 'security_events'],
description: 'Type of analysis to perform',
},
},
required: ['startTime', 'endTime', 'analysisType'],
},
},
{
name: 'get_device_traffic',
description: 'Get traffic information for a specific device',
inputSchema: {
type: 'object',
properties: {
mac: {
type: 'string',
description: 'MAC address of the device',
},
startTime: {
type: 'number',
description: 'Start time as Unix timestamp',
},
endTime: {
type: 'number',
description: 'End time as Unix timestamp',
},
},
required: ['mac', 'startTime', 'endTime'],
},
},
{
name: 'get_network_overview',
description:
'Get comprehensive network overview including devices, groups, and policies',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'get_cloud_rules',
description: 'Get active cloud security rules and policies',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'get_traffic_trends',
description: 'Get network traffic trends over time',
inputSchema: {
type: 'object',
properties: {
period: {
type: 'string',
enum: ['24h', '7d', '30d'],
description: 'Time period for trend analysis',
default: '24h',
},
},
},
},
{
name: 'get_firewalla_status',
description: 'Get Firewalla device status and information',
inputSchema: {
type: 'object',
properties: {},
},
},
{
name: 'search_devices',
description: 'Search devices by name, IP, MAC, or device type',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search query (name, IP, MAC, or device type)',
},
deviceType: {
type: 'string',
description: 'Filter by specific device type',
},
online: {
type: 'boolean',
description: 'Filter by online status',
},
},
required: ['query'],
},
},
{
name: 'update_device_rules',
description:
'Update security rules for devices (allow/block/deny categories, apps, or specific targets)',
inputSchema: {
type: 'object',
properties: {
deviceMac: {
type: 'string',
description: 'MAC address of the device to apply rules to',
},
action: {
type: 'string',
enum: ['allow', 'block', 'deny'],
description: 'Action to take (allow, block, or deny)',
},
target: {
type: 'string',
description:
'Target to apply rule to (e.g., "av" for All Video Sites, specific domain, IP, etc.)',
},
type: {
type: 'string',
description: 'Type of rule (e.g., "category", "domain", "ip")',
default: 'category',
},
direction: {
type: 'string',
enum: ['inbound', 'outbound'],
description: 'Traffic direction',
default: 'outbound',
},
dnsmasqOnly: {
type: 'boolean',
description: 'Whether to use DNS-only blocking',
default: false,
},
notes: {
type: 'string',
description: 'Optional notes for the rule',
},
},
required: ['deviceMac', 'action', 'target'],
},
},
],
};
});
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
switch (name) {
case 'configure_firewalla':
return this.configureFirewalla(args as any);
case 'get_config_status':
return this.getConfigStatus();
case 'query_network_flows':
return this.queryNetworkFlows(args as any);
case 'list_devices':
return this.listDevices();
case 'get_device_details':
return this.getDeviceDetails(args as any);
case 'analyze_network_traffic':
return this.analyzeNetworkTraffic(args as any);
case 'get_device_traffic':
return this.getDeviceTraffic(args as any);
case 'get_network_overview':
return this.getNetworkOverview();
case 'get_cloud_rules':
return this.getCloudRules();
case 'get_traffic_trends':
return this.getTrafficTrends(args as any);
case 'get_firewalla_status':
return this.getFirewallaStatus();
case 'search_devices':
return this.searchDevices(args as any);
case 'update_device_rules':
return this.updateDeviceRules(args as any);
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
}
} catch (error) {
throw new McpError(
ErrorCode.InternalError,
`Error executing ${name}: ${error instanceof Error ? error.message : String(error)}`
);
}
});
}
private async configureFirewalla(args: {
baseUrl: string;
bearerToken: string;
firewallId: string;
}) {
const wasConfigured = this.api !== null;
const previousSource = this.configSource;
this.api = new FirewallaAPI({
baseUrl: args.baseUrl,
bearerToken: args.bearerToken,
firewallId: args.firewallId,
});
this.configSource = 'tool';
let message =
'Firewalla API configured successfully. You can now use other tools to interact with your Firewalla device.';
if (wasConfigured && previousSource === 'env') {
message =
'Firewalla API reconfigured (overriding environment variable configuration). You can now use other tools to interact with your Firewalla device.';
}
return {
content: [
{
type: 'text',
text: message,
},
],
};
}
private async getConfigStatus() {
const envVars = {
FIREWALLA_URL: process.env[ENV_FIREWALLA_URL] ? 'set' : 'not set',
FIREWALLA_TOKEN: process.env[ENV_FIREWALLA_TOKEN] ? 'set (hidden)' : 'not set',
FIREWALLA_ID: process.env[ENV_FIREWALLA_ID] ? 'set' : 'not set',
};
const status = {
configured: this.api !== null,
source: this.configSource,
environmentVariables: envVars,
};
let message: string;
if (this.api) {
message = `Firewalla API is configured (source: ${this.configSource === 'env' ? 'environment variables' : 'configure_firewalla tool'})`;
} else {
message =
'Firewalla API is not configured. Set environment variables or use configure_firewalla tool.';
}
return {
content: [
{
type: 'text',
text: message,
},
{
type: 'text',
text: JSON.stringify(status, null, 2),
},
],
};
}
private async queryNetworkFlows(args: {
startTime: number;
endTime: number;
filters?: any[];
focus?: boolean;
}) {
if (!this.api) {
throw new Error(
'Firewalla API not configured. Set environment variables (FIREWALLA_URL, FIREWALLA_TOKEN, FIREWALLA_ID) or use configure_firewalla tool.'
);
}
const flows = await this.api.queryFlows({
start: args.startTime,
end: args.endTime,
filters: args.filters || [],
focus: args.focus || false,
});
return {
content: [
{
type: 'text',
text: `Found ${flows.length} network flows between ${new Date(args.startTime * 1000).toISOString()} and ${new Date(args.endTime * 1000).toISOString()}.`,
},
{
type: 'text',
text: JSON.stringify(flows, null, 2),
},
],
};
}
private async listDevices() {
if (!this.api) {
throw new Error(
'Firewalla API not configured. Set environment variables (FIREWALLA_URL, FIREWALLA_TOKEN, FIREWALLA_ID) or use configure_firewalla tool.'
);
}
const devices = await this.api.listDevices();
const summary = devices.map((device) => ({
name: device.name,
ip: device.ip,
mac: device.mac,
deviceType: device.deviceType,
online: device.online,
totalUpload: device.totalUpload,
totalDownload: device.totalDownload,
lastActive: new Date(device.lastActive * 1000).toISOString(),
}));
return {
content: [
{
type: 'text',
text: `Found ${devices.length} devices on the network:`,
},
{
type: 'text',
text: JSON.stringify(summary, null, 2),
},
],
};
}
private async getDeviceDetails(args: { mac: string }) {
if (!this.api) {
throw new Error(
'Firewalla API not configured. Set environment variables (FIREWALLA_URL, FIREWALLA_TOKEN, FIREWALLA_ID) or use configure_firewalla tool.'
);
}
const device = await this.api.getDevice(args.mac);
return {
content: [
{
type: 'text',
text: `Device details for ${device.name} (${device.mac}):`,
},
{
type: 'text',
text: JSON.stringify(device, null, 2),
},
],
};
}
private async analyzeNetworkTraffic(args: {
startTime: number;
endTime: number;
analysisType: 'summary' | 'top_talkers' | 'blocked_traffic' | 'security_events';
}) {
if (!this.api) {
throw new Error(
'Firewalla API not configured. Set environment variables (FIREWALLA_URL, FIREWALLA_TOKEN, FIREWALLA_ID) or use configure_firewalla tool.'
);
}
const flows = await this.api.queryFlows({
start: args.startTime,
end: args.endTime,
filters: [],
focus: false,
});
let analysis: any = {};
switch (args.analysisType) {
case 'summary':
analysis = this.generateTrafficSummary(flows);
break;
case 'top_talkers':
analysis = this.getTopTalkers(flows);
break;
case 'blocked_traffic':
analysis = this.getBlockedTraffic(flows);
break;
case 'security_events':
analysis = this.getSecurityEvents(flows);
break;
}
return {
content: [
{
type: 'text',
text: `Network traffic analysis (${args.analysisType}) for period ${new Date(args.startTime * 1000).toISOString()} to ${new Date(args.endTime * 1000).toISOString()}:`,
},
{
type: 'text',
text: JSON.stringify(analysis, null, 2),
},
],
};
}
private async getDeviceTraffic(args: { mac: string; startTime: number; endTime: number }) {
if (!this.api) {
throw new Error(
'Firewalla API not configured. Set environment variables (FIREWALLA_URL, FIREWALLA_TOKEN, FIREWALLA_ID) or use configure_firewalla tool.'
);
}
try {
const [device, flows] = await Promise.all([
this.api.getDevice(args.mac),
this.api.queryFlows({
start: args.startTime,
end: args.endTime,
filters: [],
focus: false,
}),
]);
const deviceFlows = flows.filter((flow) => flow.device === args.mac);
const traffic = {
device: {
name: device.name,
ip: device.ip,
mac: device.mac,
deviceType: device.deviceType,
online: device.online,
},
period: {
start: new Date(args.startTime * 1000).toISOString(),
end: new Date(args.endTime * 1000).toISOString(),
},
totalFlows: deviceFlows.length,
totalUpload: deviceFlows.reduce((sum, flow) => sum + (flow.upload || 0), 0),
totalDownload: deviceFlows.reduce((sum, flow) => sum + (flow.download || 0), 0),
protocols: this.groupFlowsByProtocol(deviceFlows),
topDestinations: this.getTopDestinations(deviceFlows),
blockedConnections: deviceFlows.filter((flow) => flow.blocked).length,
securityEvents: deviceFlows.filter((flow) => flow.category === 'intel' || flow.blocked)
.length,
};
return {
content: [
{
type: 'text',
text: `Traffic analysis for device ${device.name} (${device.mac}):`,
},
{
type: 'text',
text: JSON.stringify(traffic, null, 2),
},
],
};
} catch (error) {
throw new Error(
`Failed to get device traffic: ${error instanceof Error ? error.message : String(error)}`
);
}
}
private async getNetworkOverview() {
if (!this.api) {
throw new Error(
'Firewalla API not configured. Set environment variables (FIREWALLA_URL, FIREWALLA_TOKEN, FIREWALLA_ID) or use configure_firewalla tool.'
);
}
try {
const targets = await this.api.getNetworkTargets();
const overview = {
summary: {
totalDevices: targets.devices.length,
onlineDevices: targets.devices.filter((d) => d.online).length,
totalNetworkGroups: targets.networkGroups.length,
totalDeviceTags: targets.deviceTags.length,
},
networkGroups: targets.networkGroups.map((group) => ({
name: group.name,
type: group.meta.type,
enabled: group.enabled,
deviceCount: group.devices.length,
subnet: group.ipv4,
})),
devicesByType: this.groupDevicesByType(targets.devices),
deviceTags: targets.deviceTags.map((tag) => ({
name: tag.name,
deviceCount: tag.devices.length,
created: new Date(tag.createTs * 1000).toISOString(),
})),
};
return {
content: [
{
type: 'text',
text: 'Network Overview:',
},
{
type: 'text',
text: JSON.stringify(overview, null, 2),
},
],
};
} catch (error) {
throw new Error(
`Failed to get network overview: ${error instanceof Error ? error.message : String(error)}`
);
}
}
private async getCloudRules() {
if (!this.api) {
throw new Error(
'Firewalla API not configured. Set environment variables (FIREWALLA_URL, FIREWALLA_TOKEN, FIREWALLA_ID) or use configure_firewalla tool.'
);
}
try {
const rules = await this.api.getCloudRules();
const rulesSummary = Object.values(rules).map((rule) => ({
id: rule.id,
name: rule.name,
type: rule.type,
enabled: !rule.disabled,
ruleCount: rule.count,
lastUpdated: new Date(rule.last_updated * 1000).toISOString(),
scope: rule.scope,
}));
return {
content: [
{
type: 'text',
text: `Found ${rulesSummary.length} cloud security rules:`,
},
{
type: 'text',
text: JSON.stringify(rulesSummary, null, 2),
},
],
};
} catch (error) {
throw new Error(
`Failed to get cloud rules: ${error instanceof Error ? error.message : String(error)}`
);
}
}
private async getTrafficTrends(args: { period?: '24h' | '7d' | '30d' }) {
if (!this.api) {
throw new Error(
'Firewalla API not configured. Set environment variables (FIREWALLA_URL, FIREWALLA_TOKEN, FIREWALLA_ID) or use configure_firewalla tool.'
);
}
try {
const period = args.period || '24h';
const trendData = await this.api.getTrendData(period);
const analysis = {
period,
summary: {
totalUpload: trendData.totalUpload,
totalDownload: trendData.totalDownload,
totalTraffic: trendData.totalUpload + trendData.totalDownload,
totalConnections: trendData.totalConn,
blockedIPs: trendData.totalIpB,
blockedDNS: trendData.totalDnsB,
},
peaks: this.findTrafficPeaks(trendData),
blockingEfficiency: this.calculateBlockingEfficiency(trendData),
};
return {
content: [
{
type: 'text',
text: `Traffic trends analysis for ${period}:`,
},
{
type: 'text',
text: JSON.stringify(analysis, null, 2),
},
],
};
} catch (error) {
throw new Error(
`Failed to get traffic trends: ${error instanceof Error ? error.message : String(error)}`
);
}
}
private async getFirewallaStatus() {
if (!this.api) {
throw new Error(
'Firewalla API not configured. Set environment variables (FIREWALLA_URL, FIREWALLA_TOKEN, FIREWALLA_ID) or use configure_firewalla tool.'
);
}
try {
const boxes = await this.api.getFirewallaBoxes();
const status = boxes.map((box) => ({
name: box.name,
model: box.model,
status: box.status ? 'Online' : 'Offline',
lastActive: new Date(box.activeTs * 1000).toISOString(),
lastSync: new Date(box.syncTs * 1000).toISOString(),
lokiEnabled: box.lokiEnabled,
}));
return {
content: [
{
type: 'text',
text: `Firewalla device status (${boxes.length} device${boxes.length === 1 ? '' : 's'}):`,
},
{
type: 'text',
text: JSON.stringify(status, null, 2),
},
],
};
} catch (error) {
throw new Error(
`Failed to get Firewalla status: ${error instanceof Error ? error.message : String(error)}`
);
}
}
private async searchDevices(args: { query: string; deviceType?: string; online?: boolean }) {
if (!this.api) {
throw new Error(
'Firewalla API not configured. Set environment variables (FIREWALLA_URL, FIREWALLA_TOKEN, FIREWALLA_ID) or use configure_firewalla tool.'
);
}
try {
const devices = await this.api.listDevices();
const query = args.query.toLowerCase();
let filtered = devices.filter(
(device) =>
device.name.toLowerCase().includes(query) ||
device.ip.includes(query) ||
device.mac.toLowerCase().includes(query) ||
(device.deviceType && device.deviceType.toLowerCase().includes(query))
);
if (args.deviceType) {
filtered = filtered.filter((device) => device.deviceType === args.deviceType);
}
if (args.online !== undefined) {
filtered = filtered.filter((device) => device.online === args.online);
}
const results = filtered.map((device) => ({
name: device.name,
ip: device.ip,
mac: device.mac,
deviceType: device.deviceType,
online: device.online,
lastActive: new Date(device.lastActive * 1000).toISOString(),
totalUpload: device.totalUpload,
totalDownload: device.totalDownload,
}));
return {
content: [
{
type: 'text',
text: `Found ${results.length} device${results.length === 1 ? '' : 's'} matching "${args.query}":`,
},
{
type: 'text',
text: JSON.stringify(results, null, 2),
},
],
};
} catch (error) {
throw new Error(
`Failed to search devices: ${error instanceof Error ? error.message : String(error)}`
);
}
}
private async updateDeviceRules(args: {
deviceMac: string;
action: 'allow' | 'block' | 'deny';
target: string;
type?: string;
direction?: 'inbound' | 'outbound';
dnsmasqOnly?: boolean;
notes?: string;
}) {
if (!this.api) {
throw new Error(
'Firewalla API not configured. Set environment variables (FIREWALLA_URL, FIREWALLA_TOKEN, FIREWALLA_ID) or use configure_firewalla tool.'
);
}
try {
// Validate device exists
const device = await this.api.getDevice(args.deviceMac);
// Create the rule
const rule: DeviceRule = {
action: args.action,
type: args.type || 'category',
target: args.target,
dnsmasq_only: args.dnsmasqOnly || false,
direction: args.direction || 'outbound',
scope: [args.deviceMac],
timestamp: Date.now() / 1000,
activatedTime: Date.now() / 1000,
notes: args.notes || '',
autoDeleteWhenExpires: 0,
tag: [],
};
// Get Firewalla device ID for gids
const firewallBoxes = await this.api.getFirewallaBoxes();
if (firewallBoxes.length === 0) {
throw new Error('No Firewalla devices found');
}
const gids = firewallBoxes.map((box) => box.gid);
// Update the rules
const result = await this.api.updateRules([rule], gids);
return {
content: [
{
type: 'text',
text: `Successfully ${args.action === 'allow' ? 'allowed' : 'blocked'} ${args.target} for device "${device.name}" (${args.deviceMac})`,
},
{
type: 'text',
text: `Rule details:\n- Action: ${args.action}\n- Target: ${args.target}\n- Type: ${args.type || 'category'}\n- Direction: ${args.direction || 'outbound'}\n- DNS-only: ${args.dnsmasqOnly || false}`,
},
{
type: 'text',
text: `Response: ${JSON.stringify(result, null, 2)}`,
},
],
};
} catch (error) {
throw new Error(
`Failed to update device rules: ${error instanceof Error ? error.message : String(error)}`
);
}
}
// Helper methods for traffic analysis
private generateTrafficSummary(flows: FlowRecord[]) {
const totalUpload = flows.reduce((sum, flow) => sum + (flow.upload || 0), 0);
const totalDownload = flows.reduce((sum, flow) => sum + (flow.download || 0), 0);
const uniqueDevices = new Set(flows.map((flow) => flow.device)).size;
const blockedFlows = flows.filter((flow) => flow.blocked).length;
return {
totalFlows: flows.length,
totalUpload,
totalDownload,
totalTraffic: totalUpload + totalDownload,
uniqueDevices,
blockedFlows,
protocolDistribution: this.groupFlowsByProtocol(flows),
};
}
private getTopTalkers(flows: FlowRecord[], limit = 10) {
const deviceTraffic = new Map<string, { upload: number; download: number; name: string }>();
flows.forEach((flow) => {
const existing = deviceTraffic.get(flow.device) || {
upload: 0,
download: 0,
name: flow.deviceName || flow.device,
};
existing.upload += flow.upload || 0;
existing.download += flow.download || 0;
deviceTraffic.set(flow.device, existing);
});
return Array.from(deviceTraffic.entries())
.map(([mac, traffic]) => ({ mac, ...traffic, total: traffic.upload + traffic.download }))
.sort((a, b) => b.total - a.total)
.slice(0, limit);
}
private getBlockedTraffic(flows: FlowRecord[]) {
const blocked = flows.filter((flow) => flow.blocked);
const blockedByType = new Map<string, number>();
const blockedByDestination = new Map<string, number>();
blocked.forEach((flow) => {
const type = flow.blockType || 'unknown';
blockedByType.set(type, (blockedByType.get(type) || 0) + 1);
const dest = flow.host || flow.ip;
blockedByDestination.set(dest, (blockedByDestination.get(dest) || 0) + 1);
});
return {
totalBlocked: blocked.length,
byType: Object.fromEntries(blockedByType),
topBlockedDestinations: Array.from(blockedByDestination.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([dest, count]) => ({ destination: dest, count })),
};
}
private getSecurityEvents(flows: FlowRecord[]) {
const securityFlows = flows.filter(
(flow) =>
flow.blocked ||
flow.category === 'intel' ||
flow.flowTags?.includes('security') ||
flow.tags?.some((tag) => tag.toLowerCase().includes('security'))
);
return {
totalEvents: securityFlows.length,
blockedConnections: securityFlows.filter((flow) => flow.blocked).length,
threatIntelEvents: securityFlows.filter((flow) => flow.category === 'intel').length,
eventsByCountry: this.groupFlowsByCountry(securityFlows),
suspiciousPorts: this.getSuspiciousPorts(securityFlows),
};
}
private groupFlowsByProtocol(flows: FlowRecord[]) {
const protocols = new Map<string, number>();
flows.forEach((flow) => {
protocols.set(flow.protocol, (protocols.get(flow.protocol) || 0) + 1);
});
return Object.fromEntries(protocols);
}
private groupFlowsByCountry(flows: FlowRecord[]) {
const countries = new Map<string, number>();
flows.forEach((flow) => {
countries.set(flow.country, (countries.get(flow.country) || 0) + 1);
});
return Object.fromEntries(countries);
}
private getTopDestinations(flows: FlowRecord[], limit = 10) {
const destinations = new Map<string, { count: number; upload: number; download: number }>();
flows.forEach((flow) => {
const dest = flow.host || flow.ip;
const existing = destinations.get(dest) || { count: 0, upload: 0, download: 0 };
existing.count++;
existing.upload += flow.upload || 0;
existing.download += flow.download || 0;
destinations.set(dest, existing);
});
return Array.from(destinations.entries())
.map(([dest, stats]) => ({ destination: dest, ...stats }))
.sort((a, b) => b.count - a.count)
.slice(0, limit);
}
private getSuspiciousPorts(flows: FlowRecord[]) {
const suspiciousPorts = [22, 23, 445, 3389, 1433, 3306, 5432]; // Common attack targets
const portActivity = new Map<number, number>();
flows.forEach((flow) => {
if (suspiciousPorts.includes(flow.port)) {
portActivity.set(flow.port, (portActivity.get(flow.port) || 0) + 1);
}
});
return Object.fromEntries(portActivity);
}
private groupDevicesByType(devices: Device[]) {
const deviceTypes = new Map<string, number>();
devices.forEach((device) => {
const type = device.deviceType || 'unknown';
deviceTypes.set(type, (deviceTypes.get(type) || 0) + 1);
});
return Object.fromEntries(deviceTypes);
}
private findTrafficPeaks(trendData: TrendData) {
const uploads = Object.entries(trendData.upload);
const downloads = Object.entries(trendData.download);
const peakUpload = uploads.reduce(
(max, [time, bytes]) =>
bytes > max.bytes ? { time: new Date(parseInt(time) * 1000).toISOString(), bytes } : max,
{ time: '', bytes: 0 }
);
const peakDownload = downloads.reduce(
(max, [time, bytes]) =>
bytes > max.bytes ? { time: new Date(parseInt(time) * 1000).toISOString(), bytes } : max,
{ time: '', bytes: 0 }
);
return { peakUpload, peakDownload };
}
private calculateBlockingEfficiency(trendData: TrendData) {
const blockData = Object.values(trendData.block);
const totalConnections = blockData.reduce((sum, block) => sum + block.total, 0);
const totalBlocked = blockData.reduce((sum, block) => sum + block.blocked, 0);
return {
totalConnections,
totalBlocked,
efficiencyPercent:
totalConnections > 0 ? ((totalBlocked / totalConnections) * 100).toFixed(2) : '0.00',
averageBlockRate:
blockData.length > 0
? (blockData.reduce((sum, block) => sum + block.percent, 0) / blockData.length).toFixed(2)
: '0.00',
};
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
logger.info('Firewalla MCP server started', { transport: 'stdio' });
// Set up graceful shutdown handlers
this.setupShutdownHandlers();
}
private setupShutdownHandlers(): void {
let isShuttingDown = false;
const shutdown = async (signal: string) => {
if (isShuttingDown) {
logger.warn('Received signal again during shutdown, forcing exit', { signal });
process.exit(1);
}
isShuttingDown = true;
logger.info('Received shutdown signal, shutting down gracefully', { signal });
try {
// Close the MCP server connection
await this.server.close();
logger.info('MCP server closed successfully');
process.exit(0);
} catch (error) {
logger.error('Error during shutdown', {
error: error instanceof Error ? error.message : String(error),
});
process.exit(1);
}
};
// Handle termination signals
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
// Handle uncaught errors gracefully
process.on('uncaughtException', (error) => {
logger.error('Uncaught exception', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
shutdown('uncaughtException');
});
process.on('unhandledRejection', (reason) => {
logger.error('Unhandled rejection', {
reason: reason instanceof Error ? reason.message : String(reason),
});
shutdown('unhandledRejection');
});
}
}
// Only run the server when this file is executed directly (not imported for testing)
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
if (isMainModule) {
const server = new FirewallaMCPServer();
server.run().catch((error) => {
logger.error('Failed to start server', {
error: error instanceof Error ? error.message : String(error),
});
process.exit(1);
});
}