@unknown-sh/firewalla-msp-mcp-server
Version:
MCP server for Firewalla MSP API - provides CRUD operations for all Firewalla MSP endpoints
1,004 lines (1,003 loc) β’ 102 kB
JavaScript
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ErrorCode, McpError, } from "@modelcontextprotocol/sdk/types.js";
import axios from "axios";
// Environment validation
const API_KEY = process.env.FIREWALLA_MSP_API_KEY;
const MSP_DOMAIN = process.env.FIREWALLA_MSP_DOMAIN;
if (!API_KEY) {
console.error("Error: FIREWALLA_MSP_API_KEY environment variable is required");
process.exit(1);
}
if (!MSP_DOMAIN) {
console.error("Error: FIREWALLA_MSP_DOMAIN environment variable is required");
process.exit(1);
}
// Create axios instance with authentication
const httpClient = axios.create({
baseURL: `https://${MSP_DOMAIN}/v2`,
headers: {
'Authorization': `Token ${API_KEY}`,
'Content-Type': 'application/json',
},
});
// Schema definitions for API parameters (kept for future use)
// const PaginationSchema = z.object({
// cursor: z.string().optional(),
// limit: z.number().min(1).max(500).default(200),
// });
// const QuerySchema = z.object({
// query: z.string().optional(),
// });
// Server instance
const server = new Server({
name: "firewalla-msp-mcp",
version: "1.2.0",
}, {
capabilities: {
tools: {},
resources: {},
prompts: {},
},
});
// Helper function to format bytes
function formatBytes(bytes) {
if (!bytes || bytes === 0)
return '0B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + sizes[i];
}
// XML utility functions
function escapeXML(str) {
if (typeof str !== 'string')
return String(str);
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function jsonToXML(obj, indent = 2) {
const spaces = ' '.repeat(indent);
if (obj === null || obj === undefined) {
return `${spaces}<value>null</value>`;
}
if (typeof obj === 'string') {
return `${spaces}<value>${escapeXML(obj)}</value>`;
}
if (typeof obj === 'number' || typeof obj === 'boolean') {
return `${spaces}<value>${obj}</value>`;
}
if (Array.isArray(obj)) {
if (obj.length === 0) {
return `${spaces}<array></array>`;
}
const items = obj.map((item, index) => {
const itemXML = jsonToXML(item, indent + 2);
return `${spaces} <item index="${index}">\n${itemXML}\n${spaces} </item>`;
}).join('\n');
return `${spaces}<array>\n${items}\n${spaces}</array>`;
}
if (typeof obj === 'object') {
const entries = Object.entries(obj);
if (entries.length === 0) {
return `${spaces}<object></object>`;
}
const properties = entries.map(([key, value]) => {
const valueXML = jsonToXML(value, indent + 2);
return `${spaces} <${escapeXML(key)}>\n${valueXML}\n${spaces} </${escapeXML(key)}>`;
}).join('\n');
return `${spaces}<object>\n${properties}\n${spaces}</object>`;
}
return `${spaces}<value>${escapeXML(String(obj))}</value>`;
}
function formatAsXML(data, responseType, metadata) {
const timestamp = new Date().toISOString();
const metadataXML = metadata ? Object.entries(metadata).map(([key, value]) => ` <${escapeXML(key)}>${escapeXML(String(value))}</${escapeXML(key)}>`).join('\n') : '';
const dataXML = jsonToXML(data, 2);
return `<firewalla_response>
<metadata>
<response_type>${escapeXML(responseType)}</response_type>
<timestamp>${timestamp}</timestamp>
${metadataXML}
</metadata>
<data>
${dataXML}
</data>
</firewalla_response>`;
}
// Enhanced formatting with presentation layer
class FirewallaResponseFormatter {
static formatDate(timestamp) {
return new Date(timestamp * 1000).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short'
});
}
static formatBytes(bytes) {
if (!bytes || bytes === 0)
return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
static getSeverityEmoji(severity) {
switch (severity.toUpperCase()) {
case 'HIGH': return 'π΄';
case 'MEDIUM': return 'π‘';
case 'LOW': return 'π’';
default: return 'βͺ';
}
}
static getDeviceTypeEmoji(type) {
switch (type) {
case 'desktop': return 'π»';
case 'phone': return 'π±';
case 'tablet': return 'π±';
case 'tv': return 'πΊ';
case 'printer': return 'π¨οΈ';
case 'camera': return 'π·';
case 'router': return 'π§';
case 'ap': return 'π‘';
case 'switch': return 'π';
case 'nas&server': return 'π₯οΈ';
case 'security': return 'π';
case 'automation': return 'π ';
case 'smart speaker': return 'π';
case 'appliance': return 'π';
default: return 'π';
}
}
static formatStatistics(data, metadata) {
const results = data.results || data || [];
const timestamp = new Date().toLocaleString();
const statsType = metadata.stats_type || 'statistics';
let content = `# Firewalla Analytics Dashboard\n`;
content += `*Generated: ${timestamp}*\n\n`;
// Overview section
content += `## π Statistics Report: ${statsType}\n\n`;
if (Array.isArray(results) && results.length > 0) {
switch (statsType) {
case 'topBoxesByBlockedFlows':
content += `### π« Top Boxes by Blocked Flows\n\n`;
results.forEach((item, index) => {
content += `${index + 1}. π¦ **${item.name || item.boxName || 'Unknown Box'}** - ${item.blockedFlows || item.count || 0} blocked flows\n`;
if (item.topBlockedDomain) {
content += ` ββ Top blocked: ${item.topBlockedDomain}\n`;
}
});
break;
case 'topBoxesBySecurityAlarms':
content += `### π¨ Top Boxes by Security Alarms\n\n`;
results.forEach((item, index) => {
content += `${index + 1}. π¦ **${item.name || item.boxName || 'Unknown Box'}** - ${item.alarmCount || item.count || 0} alarms\n`;
if (item.topAlarmType) {
content += ` ββ Most common: ${item.topAlarmType}\n`;
}
});
break;
case 'topRegionsByBlockedFlows':
content += `### π Top Regions by Blocked Flows\n\n`;
results.forEach((item, index) => {
content += `${index + 1}. π **${item.region || item.country || 'Unknown'}** - ${item.blockedFlows || item.count || 0} blocks\n`;
if (item.topCategory) {
content += ` ββ Top category: ${item.topCategory}\n`;
}
});
break;
default:
// Generic formatting for unknown types
content += `### π Results\n\n`;
results.forEach((item, index) => {
content += `${index + 1}. ${JSON.stringify(item, null, 2)}\n`;
});
}
}
else {
content += `### βΉοΈ No data available\n`;
content += `No statistics data found for the requested type.\n`;
}
return content;
}
static formatSimpleStatistics(data, metadata) {
const timestamp = new Date().toLocaleString();
let content = `# Firewalla System Overview\n`;
content += `*Generated: ${timestamp}*\n\n`;
content += `## π Summary Statistics\n\n`;
content += `- **π¦ Online Boxes**: ${data.onlineBoxes || 0}\n`;
content += `- **π¦ Offline Boxes**: ${data.offlineBoxes || 0}\n`;
content += `- **π¨ Active Alarms**: ${data.alarms || 0}\n`;
content += `- **π‘οΈ Total Rules**: ${data.rules || 0}\n`;
if (metadata.group) {
content += `\n*Filtered by group: ${metadata.group}*\n`;
}
return content;
}
static formatTargetLists(data, metadata) {
const lists = data.results || data || [];
const timestamp = new Date().toLocaleString();
let content = `# Firewalla Target Lists\n`;
content += `*Generated: ${timestamp}*\n\n`;
content += `## π Overview\n`;
content += `- **Total Lists**: ${lists.length}\n`;
if (lists.length > 0) {
// Group by category
const categoryCount = {};
let totalTargets = 0;
lists.forEach((list) => {
const category = list.category || 'Uncategorized';
categoryCount[category] = (categoryCount[category] || 0) + 1;
totalTargets += (list.targets || []).length;
});
content += `- **Total Targets**: ${totalTargets}\n`;
content += `- **Categories**: `;
content += Object.entries(categoryCount)
.map(([cat, count]) => `${cat} (${count})`)
.join(', ');
content += `\n\n`;
// List details
content += `## π Target Lists\n\n`;
content += `| Name | Category | Owner | Targets | Notes |\n`;
content += `|------|----------|-------|---------|-------|\n`;
lists.forEach((list) => {
const name = list.name || 'Unnamed';
const category = list.category || 'Uncategorized';
const owner = list.owner || 'System';
const targetCount = (list.targets || []).length;
const notes = list.notes ? list.notes.substring(0, 50) + (list.notes.length > 50 ? '...' : '') : '-';
content += `| **${name}** | ${category} | ${owner} | ${targetCount} | ${notes} |\n`;
});
// Show first few targets for each list
content += `\n## π― Target Details\n`;
lists.forEach((list) => {
if (list.targets && list.targets.length > 0) {
content += `\n### ${list.name}\n`;
const displayTargets = list.targets.slice(0, 5);
displayTargets.forEach((target) => {
content += `- \`${target}\`\n`;
});
if (list.targets.length > 5) {
content += `- _...and ${list.targets.length - 5} more_\n`;
}
}
});
}
else {
content += `\n### βΉοΈ No target lists found\n`;
content += `No target lists have been configured.\n`;
}
return content;
}
static formatGetTrends(data, metadata) {
const trends = data.results || data || [];
const timestamp = new Date().toLocaleString();
const trendType = metadata.trends_type || 'trends';
let content = `# Firewalla ${trendType.charAt(0).toUpperCase() + trendType.slice(1)} Trends\n`;
content += `*Generated: ${timestamp}*\n\n`;
content += `## π Trend Analysis\n`;
content += `- **Data Points**: ${trends.length}\n`;
content += `- **Type**: ${trendType}\n`;
if (metadata.group) {
content += `- **Group Filter**: ${metadata.group}\n`;
}
if (trends.length > 0) {
// Calculate time range
const timestamps = trends.map((t) => t.timestamp || t.ts || 0).filter((ts) => ts > 0);
if (timestamps.length > 0) {
const minTime = Math.min(...timestamps);
const maxTime = Math.max(...timestamps);
content += `- **Time Range**: ${this.formatDate(minTime)} - ${this.formatDate(maxTime)}\n`;
}
content += `\n## π Trend Data\n\n`;
// Format based on trend type
if (trendType === 'flows') {
content += `| Time | Total Flows | Blocked | Allowed | Upload | Download |\n`;
content += `|------|-------------|---------|---------|--------|----------|\n`;
trends.forEach((point) => {
const time = point.timestamp ? this.formatDate(point.timestamp) : 'N/A';
const total = point.total || 0;
const blocked = point.blocked || 0;
const allowed = point.allowed || 0;
const upload = this.formatBytes(point.upload || 0);
const download = this.formatBytes(point.download || 0);
content += `| ${time} | ${total} | ${blocked} | ${allowed} | ${upload} | ${download} |\n`;
});
}
else if (trendType === 'alarms') {
content += `| Time | Total Alarms | High | Medium | Low |\n`;
content += `|------|--------------|------|--------|-----|\n`;
trends.forEach((point) => {
const time = point.timestamp ? this.formatDate(point.timestamp) : 'N/A';
const total = point.total || 0;
const high = point.high || 0;
const medium = point.medium || 0;
const low = point.low || 0;
content += `| ${time} | ${total} | ${high} | ${medium} | ${low} |\n`;
});
}
else if (trendType === 'rules') {
content += `| Time | Total Rules | Active | Paused | Block | Allow |\n`;
content += `|------|-------------|--------|---------|--------|--------|\n`;
trends.forEach((point) => {
const time = point.timestamp ? this.formatDate(point.timestamp) : 'N/A';
const total = point.total || 0;
const active = point.active || 0;
const paused = point.paused || 0;
const block = point.block || 0;
const allow = point.allow || 0;
content += `| ${time} | ${total} | ${active} | ${paused} | ${block} | ${allow} |\n`;
});
}
// Add insights if available
content += `\n## π― Key Insights\n`;
// Calculate trends
if (trends.length >= 2) {
const firstPoint = trends[0];
const lastPoint = trends[trends.length - 1];
if (trendType === 'flows' && firstPoint.total && lastPoint.total) {
const change = ((lastPoint.total - firstPoint.total) / firstPoint.total * 100).toFixed(1);
content += `- Flow volume ${parseFloat(change) > 0 ? 'increased' : 'decreased'} by ${Math.abs(parseFloat(change))}%\n`;
}
else if (trendType === 'alarms' && firstPoint.total && lastPoint.total) {
const change = ((lastPoint.total - firstPoint.total) / firstPoint.total * 100).toFixed(1);
content += `- Alarm count ${parseFloat(change) > 0 ? 'increased' : 'decreased'} by ${Math.abs(parseFloat(change))}%\n`;
}
else if (trendType === 'rules' && firstPoint.total && lastPoint.total) {
const change = lastPoint.total - firstPoint.total;
content += `- ${Math.abs(change)} rules ${change > 0 ? 'added' : 'removed'} during this period\n`;
}
}
}
else {
content += `\n### βΉοΈ No trend data available\n`;
content += `No ${trendType} trend data found for the specified parameters.\n`;
}
return content;
}
static formatSearchGlobal(data, metadata) {
const timestamp = new Date().toLocaleString();
let content = `# Firewalla Global Search Results\n`;
content += `*Generated: ${timestamp}*\n\n`;
content += `## π Search Summary\n`;
content += `- **Query**: "${metadata.query || 'All'}"\n`;
let totalResults = 0;
const resultCounts = {};
// Count results by type
Object.entries(data).forEach(([type, results]) => {
if (Array.isArray(results)) {
resultCounts[type] = results.length;
totalResults += results.length;
}
});
content += `- **Total Results**: ${totalResults}\n`;
content += `- **Result Types**: `;
content += Object.entries(resultCounts)
.filter(([_, count]) => count > 0)
.map(([type, count]) => `${type} (${count})`)
.join(', ');
content += `\n\n`;
// Show results by type
if (data.devices && data.devices.length > 0) {
content += `## π» Devices (${data.devices.length})\n\n`;
content += `| Name | IP | MAC | Type | Status |\n`;
content += `|------|-----|-----|------|--------|\n`;
data.devices.slice(0, 10).forEach((device) => {
const name = device.name || 'Unknown';
const ip = device.ip || device.ipAddress || 'N/A';
const mac = device.mac || 'N/A';
const type = device.type || device.deviceType || 'unknown';
const status = device.online ? 'π’ Online' : 'π΄ Offline';
content += `| **${name}** | \`${ip}\` | \`${mac}\` | ${type} | ${status} |\n`;
});
if (data.devices.length > 10) {
content += `\n_...and ${data.devices.length - 10} more devices_\n`;
}
content += `\n`;
}
if (data.alarms && data.alarms.length > 0) {
content += `## π¨ Alarms (${data.alarms.length})\n\n`;
content += `| Time | Type | Device | Severity |\n`;
content += `|------|------|--------|----------|\n`;
data.alarms.slice(0, 10).forEach((alarm) => {
const time = alarm.ts ? this.formatDate(alarm.ts) : 'N/A';
const type = alarm.alarmType || `Type ${alarm.type}`;
const device = alarm.device?.name || 'Unknown';
const severity = alarm.severity || 'MEDIUM';
content += `| ${time} | ${type} | ${device} | ${this.getSeverityEmoji(severity)} ${severity} |\n`;
});
if (data.alarms.length > 10) {
content += `\n_...and ${data.alarms.length - 10} more alarms_\n`;
}
content += `\n`;
}
if (data.flows && data.flows.length > 0) {
content += `## π Flows (${data.flows.length})\n\n`;
content += `| Time | Device | Direction | Domain/IP | Transfer |\n`;
content += `|------|--------|-----------|-----------|----------|\n`;
data.flows.slice(0, 10).forEach((flow) => {
const time = flow.ts ? new Date(flow.ts * 1000).toLocaleTimeString() : 'N/A';
const device = flow.device?.name || 'Unknown';
const direction = flow.direction === 'in' ? 'β¬οΈ' : flow.direction === 'out' ? 'β¬οΈ' : 'βοΈ';
const destination = flow.domain || flow.ip || 'Unknown';
const transfer = this.formatBytes((flow.upload || 0) + (flow.download || 0));
content += `| ${time} | ${device} | ${direction} | ${destination} | ${transfer} |\n`;
});
if (data.flows.length > 10) {
content += `\n_...and ${data.flows.length - 10} more flows_\n`;
}
content += `\n`;
}
if (data.boxes && data.boxes.length > 0) {
content += `## π¦ Boxes (${data.boxes.length})\n\n`;
content += `| Name | Model | Version | Status |\n`;
content += `|------|-------|---------|--------|\n`;
data.boxes.forEach((box) => {
const name = box.name || 'Unknown';
const model = box.model || 'N/A';
const version = box.version || 'N/A';
const status = box.online ? 'π’ Online' : 'π΄ Offline';
content += `| **${name}** | ${model} | ${version} | ${status} |\n`;
});
content += `\n`;
}
if (totalResults === 0) {
content += `### βΉοΈ No results found\n`;
content += `No items match your search query.\n`;
}
return content;
}
static formatListFlows(data, metadata) {
const flows = data.results || [];
const timestamp = new Date().toLocaleString();
let content = `# Network Traffic Flows\n`;
content += `*Generated: ${timestamp}*\n\n`;
// Overview section
content += `## π Traffic Summary\n`;
content += `- **Total Flows**: ${flows.length}\n`;
if (flows.length > 0) {
// Calculate time range
const timestamps = flows.map((f) => f.ts || 0).filter((ts) => ts > 0);
if (timestamps.length > 0) {
const minTime = Math.min(...timestamps);
const maxTime = Math.max(...timestamps);
content += `- **Time Period**: ${this.formatDate(minTime)} - ${this.formatDate(maxTime)}\n`;
}
// Protocol breakdown
const protocolCount = {};
const directionCount = { inbound: 0, outbound: 0, bidirectional: 0 };
let totalUpload = 0;
let totalDownload = 0;
flows.forEach((flow) => {
const protocol = flow.protocol || 'unknown';
protocolCount[protocol] = (protocolCount[protocol] || 0) + 1;
const direction = flow.direction || 'unknown';
if (direction === 'in')
directionCount.inbound++;
else if (direction === 'out')
directionCount.outbound++;
else if (direction === 'bi')
directionCount.bidirectional++;
totalUpload += flow.upload || 0;
totalDownload += flow.download || 0;
});
// Top protocols
const sortedProtocols = Object.entries(protocolCount)
.sort((a, b) => b[1] - a[1])
.slice(0, 3);
if (sortedProtocols.length > 0) {
content += `- **Top Protocols**: `;
const totalFlows = flows.length;
content += sortedProtocols
.map(([proto, count]) => `${proto.toUpperCase()} (${Math.round(count / totalFlows * 100)}%)`)
.join(', ');
content += `\n`;
}
// Direction breakdown
const totalDirectional = directionCount.inbound + directionCount.outbound + directionCount.bidirectional;
if (totalDirectional > 0) {
content += `- **Direction**: `;
content += `Inbound (${Math.round(directionCount.inbound / totalDirectional * 100)}%), `;
content += `Outbound (${Math.round(directionCount.outbound / totalDirectional * 100)}%), `;
content += `Bidirectional (${Math.round(directionCount.bidirectional / totalDirectional * 100)}%)\n`;
}
content += `- **Total Transfer**: Upload ${this.formatBytes(totalUpload)}, Download ${this.formatBytes(totalDownload)}\n\n`;
// Top destinations by volume
const destinationVolume = {};
flows.forEach((flow) => {
const key = flow.domain || flow.ip || 'unknown';
if (!destinationVolume[key]) {
destinationVolume[key] = {
domain: flow.domain || flow.ip || 'unknown',
country: flow.country || 'Unknown',
volume: 0,
count: 0
};
}
destinationVolume[key].volume += (flow.upload || 0) + (flow.download || 0);
destinationVolume[key].count++;
});
const topDestinations = Object.values(destinationVolume)
.sort((a, b) => b.volume - a.volume)
.slice(0, 10);
if (topDestinations.length > 0) {
content += `## π Top Destinations by Volume\n\n`;
content += `| Domain/IP | Country | Transfer Volume | Flow Count |\n`;
content += `|-----------|---------|-----------------|------------|\n`;
topDestinations.forEach(dest => {
const countryFlag = dest.country && dest.country !== 'Unknown' ? `π ${dest.country}` : 'π Unknown';
content += `| **${dest.domain}** | ${countryFlag} | ${this.formatBytes(dest.volume)} | ${dest.count} |\n`;
});
content += `\n`;
}
// Top source devices
const deviceVolume = {};
flows.forEach((flow) => {
const deviceId = flow.device?.id || flow.deviceMAC || 'unknown';
const deviceName = flow.device?.name || flow.deviceName || deviceId;
if (!deviceVolume[deviceId]) {
deviceVolume[deviceId] = {
name: deviceName,
upload: 0,
download: 0,
count: 0
};
}
deviceVolume[deviceId].upload += flow.upload || 0;
deviceVolume[deviceId].download += flow.download || 0;
deviceVolume[deviceId].count++;
});
const topDevices = Object.values(deviceVolume)
.sort((a, b) => (b.upload + b.download) - (a.upload + a.download))
.slice(0, 10);
if (topDevices.length > 0) {
content += `## π± Top Devices by Traffic\n\n`;
content += `| Device | Upload | Download | Total Flows |\n`;
content += `|--------|--------|----------|-------------|\n`;
topDevices.forEach(device => {
content += `| **${device.name}** | ${this.formatBytes(device.upload)} | ${this.formatBytes(device.download)} | ${device.count} |\n`;
});
content += `\n`;
}
// Recent flows sample
const recentFlows = flows.slice(0, 10);
if (recentFlows.length > 0) {
content += `## π Recent Traffic Flows\n\n`;
content += `| Time | Device | Direction | Domain/IP | Protocol | Transfer |\n`;
content += `|------|--------|-----------|-----------|----------|----------|\n`;
recentFlows.forEach((flow) => {
const time = flow.ts ? new Date(flow.ts * 1000).toLocaleTimeString() : 'N/A';
const device = flow.device?.name || flow.deviceName || 'Unknown';
const direction = flow.direction === 'in' ? 'β¬οΈ In' : flow.direction === 'out' ? 'β¬οΈ Out' : 'βοΈ Bi';
const destination = flow.domain || flow.ip || 'Unknown';
const protocol = (flow.protocol || 'unknown').toUpperCase();
const transfer = this.formatBytes((flow.upload || 0) + (flow.download || 0));
content += `| ${time} | ${device} | ${direction} | ${destination} | ${protocol} | ${transfer} |\n`;
});
}
}
else {
content += `\n### βΉοΈ No flows found\n`;
content += `No network flows match your search criteria.\n`;
}
if (data.next_cursor) {
content += `\n## π Additional Results\n`;
content += `More results are available. Use cursor: \`${data.next_cursor}\` to fetch the next page.\n`;
}
return content;
}
static formatListRules(data, metadata) {
const rules = data.results || data || [];
const timestamp = new Date().toLocaleString();
let content = `# Firewall Rules Configuration\n`;
content += `*Generated: ${timestamp}*\n\n`;
// Overview section
content += `## π Rules Overview\n`;
content += `- **Total Rules**: ${rules.length}\n`;
if (rules.length > 0) {
// Calculate rule statistics
const activeCount = rules.filter((r) => r.status === 'active').length;
const pausedCount = rules.length - activeCount;
const actionCount = {};
rules.forEach((rule) => {
const action = rule.action || 'unknown';
actionCount[action] = (actionCount[action] || 0) + 1;
});
content += `- **Active**: ${activeCount} | **Paused**: ${pausedCount}\n`;
content += `- **Rule Types**: `;
content += Object.entries(actionCount)
.map(([action, count]) => `${action.charAt(0).toUpperCase() + action.slice(1)} (${count})`)
.join(', ');
content += `\n\n`;
// Block rules
const blockRules = rules.filter((r) => r.action === 'block');
if (blockRules.length > 0) {
content += `## π‘οΈ Block Rules\n\n`;
content += `| Name | Target | Scope | Direction | Status |\n`;
content += `|------|--------|-------|-----------|--------|\n`;
blockRules.forEach((rule) => {
const name = rule.name || 'Unnamed Rule';
const target = rule.target ? `${rule.target.type}: ${rule.target.value}` : 'Any';
const scope = rule.scope ? `${rule.scope.type}: ${rule.scope.value}` : 'All devices';
const direction = rule.direction || 'both';
const status = rule.status === 'active' ? 'β
Active' : 'βΈοΈ Paused';
content += `| **${name}** | ${target} | ${scope} | ${direction} | ${status} |\n`;
});
content += `\n`;
}
// Allow rules
const allowRules = rules.filter((r) => r.action === 'allow');
if (allowRules.length > 0) {
content += `## β
Allow Rules\n\n`;
content += `| Name | Target | Scope | Direction | Status |\n`;
content += `|------|--------|-------|-----------|--------|\n`;
allowRules.forEach((rule) => {
const name = rule.name || 'Unnamed Rule';
const target = rule.target ? `${rule.target.type}: ${rule.target.value}` : 'Any';
const scope = rule.scope ? `${rule.scope.type}: ${rule.scope.value}` : 'All devices';
const direction = rule.direction || 'both';
const status = rule.status === 'active' ? 'β
Active' : 'βΈοΈ Paused';
content += `| **${name}** | ${target} | ${scope} | ${direction} | ${status} |\n`;
});
content += `\n`;
}
// Time-limited rules
const timeLimitRules = rules.filter((r) => r.action === 'time_limit');
if (timeLimitRules.length > 0) {
content += `## β° Time-Limited Rules\n\n`;
content += `| Name | Target | Schedule | Status |\n`;
content += `|------|--------|----------|--------|\n`;
timeLimitRules.forEach((rule) => {
const name = rule.name || 'Unnamed Rule';
const target = rule.target ? `${rule.target.type}: ${rule.target.value}` : 'Any';
const schedule = rule.schedule ? rule.schedule.type : 'No schedule';
const status = rule.status === 'active' ? 'β
Active' : 'βΈοΈ Paused';
content += `| **${name}** | ${target} | ${schedule} | ${status} |\n`;
});
}
}
else {
content += `\n### βΉοΈ No rules found\n`;
content += `No security rules are configured.\n`;
}
return content;
}
static formatListDevices(data, metadata) {
const devices = data.results || data || [];
const timestamp = new Date().toLocaleString();
let content = `# Network Device Inventory\n`;
content += `*Generated: ${timestamp}*\n\n`;
// Overview section
content += `## π Device Summary\n`;
content += `- **Total Devices**: ${devices.length}\n`;
if (devices.length > 0) {
// Calculate device statistics
const onlineCount = devices.filter((d) => d.online).length;
const offlineCount = devices.length - onlineCount;
const typeCount = {};
const networkCount = {};
devices.forEach((device) => {
const type = device.type || device.deviceType || 'unknown';
typeCount[type] = (typeCount[type] || 0) + 1;
const network = device.network?.name || 'Unknown Network';
networkCount[network] = (networkCount[network] || 0) + 1;
});
content += `- **Online**: ${onlineCount} | **Offline**: ${offlineCount}\n`;
// Device types breakdown
const sortedTypes = Object.entries(typeCount).sort((a, b) => b[1] - a[1]);
content += `- **Device Types**: `;
content += sortedTypes.map(([type, count]) => `${type} (${count})`).join(', ');
content += `\n\n`;
// Network distribution
content += `## π Network Distribution\n`;
Object.entries(networkCount)
.sort((a, b) => b[1] - a[1])
.forEach(([network, count]) => {
content += `- **${network}**: ${count} devices\n`;
});
content += `\n`;
// Active devices
const activeDevices = devices.filter((d) => d.online);
if (activeDevices.length > 0) {
content += `## π» Active Devices\n\n`;
content += `| Name | IP Address | MAC Address | Type | Network | Last Active |\n`;
content += `|------|------------|-------------|------|---------|-------------|\n`;
activeDevices.forEach((device) => {
const name = device.name || 'Unknown';
const ip = device.ip || device.ipAddress || 'N/A';
const mac = device.mac || 'N/A';
const type = device.type || device.deviceType || 'unknown';
const typeEmoji = this.getDeviceTypeEmoji(type);
const network = device.network?.name || 'Unknown';
const lastSeen = device.lastActiveTime || device.lastSeen ?
this.formatDate(device.lastActiveTime || device.lastSeen) : 'Active';
content += `| ${typeEmoji} **${name}** | \`${ip}\` | \`${mac}\` | ${type} | ${network} | ${lastSeen} |\n`;
});
content += `\n`;
}
// Offline devices
const offlineDevices = devices.filter((d) => !d.online);
if (offlineDevices.length > 0) {
content += `## π΄ Offline Devices\n\n`;
content += `| Name | IP Address | MAC Address | Type | Last Seen |\n`;
content += `|------|------------|-------------|------|------------|\n`;
offlineDevices.forEach((device) => {
const name = device.name || 'Unknown';
const ip = device.ip || device.ipAddress || 'N/A';
const mac = device.mac || 'N/A';
const type = device.type || device.deviceType || 'unknown';
const typeEmoji = this.getDeviceTypeEmoji(type);
const lastSeen = device.lastActiveTime || device.lastSeen ?
this.formatDate(device.lastActiveTime || device.lastSeen) : 'N/A';
content += `| ${typeEmoji} **${name}** | \`${ip}\` | \`${mac}\` | ${type} | ${lastSeen} |\n`;
});
}
}
else {
content += `\n### βΉοΈ No devices found\n`;
content += `No devices match your search criteria.\n`;
}
if (data.next_cursor) {
content += `\n## π Additional Results\n`;
content += `More results are available. Use cursor: \`${data.next_cursor}\` to fetch the next page.\n`;
}
return content;
}
static formatListAlarms(data, metadata) {
const alarms = data.results || [];
const timestamp = new Date().toLocaleString();
let content = `# Firewalla Security Alarms Report\n`;
content += `*Generated: ${timestamp}*\n\n`;
// Overview section
content += `## π Overview\n`;
content += `- **Total Alarms**: ${alarms.length}\n`;
content += `- **Query**: ${metadata.query || 'All alarms'}\n`;
if (alarms.length > 0) {
// Calculate severity breakdown
const severityCount = { HIGH: 0, MEDIUM: 0, LOW: 0 };
const statusCount = { active: 0, acknowledged: 0, resolved: 0 };
const typeCount = {};
alarms.forEach((alarm) => {
const severity = alarm.severity || (alarm.type <= 2 ? 'HIGH' : alarm.type <= 5 ? 'MEDIUM' : 'LOW');
severityCount[severity]++;
const status = alarm.status || 'active';
statusCount[status]++;
const alarmType = alarm.alarmType || `Type ${alarm.type}`;
typeCount[alarmType] = (typeCount[alarmType] || 0) + 1;
});
content += `- **Severity Breakdown**: High (${severityCount.HIGH}), Medium (${severityCount.MEDIUM}), Low (${severityCount.LOW})\n`;
content += `- **Status**: Active (${statusCount.active}), Acknowledged (${statusCount.acknowledged}), Resolved (${statusCount.resolved})\n\n`;
// Alarms by type
content += `## π¨ Alarms by Type\n`;
Object.entries(typeCount).forEach(([type, count]) => {
content += `- **${type}**: ${count} alarms\n`;
});
content += `\n`;
// Detailed alarms
content += `## π Detailed Alarms\n\n`;
alarms.forEach((alarm, index) => {
const severity = alarm.severity || (alarm.type <= 2 ? 'HIGH' : alarm.type <= 5 ? 'MEDIUM' : 'LOW');
const alarmTime = alarm.ts ? this.formatDate(alarm.ts) : 'N/A';
const deviceName = alarm.device?.name || 'Unknown Device';
const deviceIp = alarm.device?.ip || alarm.device?.ipAddress || 'N/A';
const remoteDomain = alarm.remote?.domain || alarm.remote?.ip || 'N/A';
const remoteCountry = alarm.remote?.country || 'Unknown';
const download = this.formatBytes(alarm.transfer?.download);
const upload = this.formatBytes(alarm.transfer?.upload);
const total = this.formatBytes(alarm.transfer?.total);
const status = alarm.status || 'active';
content += `### ${this.getSeverityEmoji(severity)} Alarm #${index + 1} - ${alarm.alarmType || `Type ${alarm.type}`}\n`;
content += `- **Time**: ${alarmTime}\n`;
content += `- **Severity**: ${severity}\n`;
content += `- **Status**: ${status}\n`;
content += `- **Device**: ${deviceName} (IP: ${deviceIp})\n`;
content += `- **Remote**: ${remoteDomain} (${remoteCountry})\n`;
content += `- **Transfer**: β ${download} | β ${upload} | Total: ${total}\n`;
content += `\n`;
});
}
else {
content += `\n### βΉοΈ No alarms found\n`;
content += `No security alarms match your search criteria.\n`;
}
if (data.next_cursor) {
content += `\n## π Additional Results\n`;
content += `More results are available. Use cursor: \`${data.next_cursor}\` to fetch the next page.\n`;
}
return content;
}
static formatEnhancedResponse(data, responseType, metadata) {
const enhancedMetadata = metadata || {};
let presentationContent = '';
let summary = '';
let title = '';
switch (responseType) {
case 'list_alarms':
case 'search_alarms':
presentationContent = this.formatListAlarms(data, enhancedMetadata);
title = enhancedMetadata.query ?
`Firewalla Alarms Report - ${enhancedMetadata.query}` :
'Firewalla Security Alarms Report';
summary = `Found ${(data.results || []).length} alarms${enhancedMetadata.query ? ` matching "${enhancedMetadata.query}"` : ''}.`;
break;
case 'list_devices':
case 'search_devices':
presentationContent = this.formatListDevices(data, enhancedMetadata);
title = enhancedMetadata.query ?
`Network Device Inventory - ${enhancedMetadata.query}` :
'Network Device Inventory';
const deviceData = data.results || data || [];
const onlineCount = deviceData.filter((d) => d.online).length;
summary = `Found ${deviceData.length} devices (${onlineCount} online, ${deviceData.length - onlineCount} offline)${enhancedMetadata.query ? ` matching "${enhancedMetadata.query}"` : ''}.`;
break;
case 'list_rules':
presentationContent = this.formatListRules(data, enhancedMetadata);
title = 'Firewall Rules Configuration';
const ruleData = data.results || data || [];
const activeRules = ruleData.filter((r) => r.status === 'active').length;
summary = `Found ${ruleData.length} rules (${activeRules} active, ${ruleData.length - activeRules} paused).`;
break;
case 'get_statistics':
presentationContent = this.formatStatistics(data, enhancedMetadata);
title = `Firewalla Analytics - ${enhancedMetadata.stats_type || 'Statistics'}`;
const statsData = data.results || data || [];
summary = `Showing top ${statsData.length} results for ${enhancedMetadata.stats_type}.`;
break;
case 'get_simple_statistics':
presentationContent = this.formatSimpleStatistics(data, enhancedMetadata);
title = 'Firewalla System Overview';
summary = `System has ${data.onlineBoxes || 0} online boxes, ${data.alarms || 0} alarms, and ${data.rules || 0} rules.`;
break;
case 'list_flows':
case 'search_flows':
presentationContent = this.formatListFlows(data, enhancedMetadata);
title = enhancedMetadata.query ?
`Network Traffic Flows - ${enhancedMetadata.query}` :
'Network Traffic Flows Report';
const flowData = data.results || [];
const totalTransfer = flowData.reduce((sum, flow) => sum + (flow.upload || 0) + (flow.download || 0), 0);
summary = `Found ${flowData.length} flows with total transfer of ${FirewallaResponseFormatter.formatBytes(totalTransfer)}${enhancedMetadata.query ? ` matching "${enhancedMetadata.query}"` : ''}.`;
break;
case 'list_target_lists':
case 'get_target_list':
presentationContent = this.formatTargetLists(data, enhancedMetadata);
title = 'Firewalla Target Lists Configuration';
const listData = data.results || data || [];
const totalTargets = Array.isArray(listData) ?
listData.reduce((sum, list) => sum + (list.targets || []).length, 0) :
(listData.targets || []).length;
summary = `Found ${Array.isArray(listData) ? listData.length : 1} target lists with ${totalTargets} total targets.`;
break;
case 'get_trends':
presentationContent = this.formatGetTrends(data, enhancedMetadata);
const trendType = enhancedMetadata.trends_type || 'trends';
title = `Firewalla ${trendType.charAt(0).toUpperCase() + trendType.slice(1)} Trends Analysis`;
const trendData = data.results || data || [];
summary = `Showing ${trendData.length} data points for ${trendType} trends.`;
break;
case 'search_global':
presentationContent = this.formatSearchGlobal(data, enhancedMetadata);
title = `Firewalla Global Search - "${enhancedMetadata.query || 'All'}"`;
let globalTotalResults = 0;
Object.entries(data).forEach(([_, results]) => {
if (Array.isArray(results))
globalTotalResults += results.length;
});
summary = `Found ${globalTotalResults} total results across all entity types.`;
break;
// Add more endpoint formatters here
default:
// Fallback to basic formatting
return formatAsXML(data, responseType, metadata);
}
const timestamp = new Date().toISOString();
const metadataXML = metadata ? Object.entries(metadata).map(([key, value]) => ` <${escapeXML(key)}>${escapeXML(String(value))}</${escapeXML(key)}>`).join('\n') : '';
const dataXML = jsonToXML(data, 2);
return `<firewalla_response>
<metadata>
<response_type>${escapeXML(responseType)}</response_type>
<timestamp>${timestamp}</timestamp>
${metadataXML}
</metadata>
<presentation>
<artifact_content type="markdown" title="${escapeXML(title)}">
${escapeXML(presentationContent)}
</artifact_content>
</presentation>
<summary>${escapeXML(summary)}</summary>
<data>
${dataXML}
</data>
</firewalla_response>`;
}
}
// Utility function for paginated requests (kept for future use)
// async function fetchAllPaginated(endpoint: string, params: any = {}) {
// const allResults = [];
// let cursor: string | null = null;
//
// while (true) {
// const response = await httpClient.get(endpoint, {
// params: { ...params, cursor, limit: params.limit || 200 },
// });
//
// const data = response.data as { results: any[]; next_cursor?: string | null };
// const { results, next_cursor } = data;
// allResults.push(...results);
//
// if (!next_cursor) break;
// cursor = next_cursor;
// }
//
// return allResults;
// }
// Define tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
// Boxes API
{
name: "list_boxes",
description: "Get all Firewalla boxes in the MSP",
inputSchema: {
type: "object",
properties: {
group: {
type: "string",
description: "Filter by group ID",
},
},
},
},
// Devices API
{
name: "list_devices",
description: "Get all devices across boxes (returns device name, MAC, IP, type, status, and more)",
inputSchema: {
type: "object",
properties: {
box: {
type: "string",
description: "Filter by specific box ID",
},
group: {
type: "string",
description: "Filter by specific box group ID",
},
},
},
},
// Alarms API
{
name: "list_alarms",
description: "Get alarms with optional filtering",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query (e.g., 'status:active box:boxId type:9')",
},
groupBy: {
type: "string",
description: "Group results (comma-separated)",
},
sortBy: {
type: "string",
description: "Sort results (e.g., 'ts:desc,total:asc')",
},
limit: {
type: "number",
description: "Max results per page (β€500, default 200)",
},
cursor: {
type: "string",