firewalla-mcp-server
Version:
Model Context Protocol (MCP) server for Firewalla MSP API - Provides real-time network monitoring, security analysis, and firewall management through 28 specialized tools compatible with any MCP client
428 lines • 24.4 kB
JavaScript
/**
* Network monitoring and analysis tool handlers
*/
import { BaseToolHandler } from './base.js';
import { ParameterValidator, SafeAccess, ErrorType, } from '../../validation/error-handler.js';
import { unixToISOStringOrNow, safeUnixToISOString, } from '../../utils/timestamp.js';
import { normalizeUnknownFields, sanitizeFieldValue, batchNormalize, sanitizeByteCount, } from '../../utils/data-normalizer.js';
import { ResponseStandardizer } from '../../utils/response-standardizer.js';
import { getLimitValidationConfig } from '../../config/limits.js';
import { withToolTimeout, TimeoutError, createTimeoutErrorResponse, } from '../../utils/timeout-manager.js';
import { StreamingManager, shouldUseStreaming, createStreamingResponse, } from '../../utils/streaming-manager.js';
export class GetFlowDataHandler extends BaseToolHandler {
constructor() {
super({
enableGeoEnrichment: true,
enableFieldNormalization: true,
additionalMeta: {
data_source: 'flows',
entity_type: 'network_flows',
supports_geographic_enrichment: true,
supports_field_normalization: true,
supports_streaming: true,
supports_pagination: true,
standardization_version: '2.0.0',
},
});
this.name = 'get_flow_data';
this.description = 'Query network traffic flows with pagination. Data is cached for 15 seconds for performance. Use force_refresh=true to bypass cache for real-time data.';
this.category = 'network';
}
async execute(rawArgs, firewalla) {
// Early parameter sanitization to prevent null/undefined errors
const sanitizationResult = this.sanitizeParameters(rawArgs);
if ('errorResponse' in sanitizationResult) {
return sanitizationResult.errorResponse;
}
const args = sanitizationResult.sanitizedArgs;
const startTime = Date.now();
try {
// Parameter validation
const limitValidation = ParameterValidator.validateNumber(args?.limit, 'limit', {
required: false,
defaultValue: 200,
...getLimitValidationConfig(this.name),
});
if (!limitValidation.isValid) {
return this.createErrorResponse('Parameter validation failed', ErrorType.VALIDATION_ERROR, undefined, limitValidation.errors);
}
const query = args?.query;
const groupBy = args?.groupBy;
const sortBy = args?.sortBy;
const limit = limitValidation.sanitizedValue;
const cursor = args?.cursor;
// Check if streaming is requested or should be automatically enabled
const enableStreaming = Boolean(args?.stream) || shouldUseStreaming(this.name, limit);
const streamingSessionId = args?.streaming_session_id;
// Validate individual date parameters before building query
const startTimeArg = args?.start_time;
const endTime = args?.end_time;
let finalQuery = query;
// Validate start_time if provided
if (startTimeArg !== undefined) {
const startTimeValidation = ParameterValidator.validateDateFormat(startTimeArg, 'start_time', false);
if (!startTimeValidation.isValid) {
return this.createErrorResponse('Invalid start_time format', ErrorType.VALIDATION_ERROR, {
provided_value: startTimeArg,
documentation: 'See /docs/query-syntax-guide.md for time range examples',
}, startTimeValidation.errors);
}
}
// Validate end_time if provided
if (endTime !== undefined) {
const endTimeValidation = ParameterValidator.validateDateFormat(endTime, 'end_time', false);
if (!endTimeValidation.isValid) {
return this.createErrorResponse('Invalid end_time format', ErrorType.VALIDATION_ERROR, {
provided_value: endTime,
documentation: 'See /docs/query-syntax-guide.md for time range examples',
}, endTimeValidation.errors);
}
}
// Validate cursor format if provided
if (cursor !== undefined) {
const cursorValidation = ParameterValidator.validateCursor(cursor, 'cursor');
if (!cursorValidation.isValid) {
return this.createErrorResponse('Invalid cursor format', ErrorType.VALIDATION_ERROR, {
provided_value: cursor,
documentation: 'Cursors should be obtained from previous response next_cursor field',
}, cursorValidation.errors);
}
}
// Build time range query if both dates are provided and valid
if (startTimeArg && endTime) {
const startDate = new Date(startTimeArg);
const endDate = new Date(endTime);
// Validate time range order (dates are already validated for format above)
if (startDate >= endDate) {
return this.createErrorResponse('Invalid time range order', ErrorType.VALIDATION_ERROR, {
details: 'Start time must be before end time',
received: {
start_time: startTimeArg,
end_time: endTime,
parsed_start: startDate.toISOString(),
parsed_end: endDate.toISOString(),
},
time_difference: `Start is ${Math.abs(startDate.getTime() - endDate.getTime()) / 1000} seconds after end`,
}, [
'Ensure start_time is chronologically before end_time',
'Check timezone handling - times may be in different zones',
'Verify date format includes correct year/month/day values',
'For recent data, try: start_time: "2024-01-01T00:00:00Z", end_time: "2024-01-02T00:00:00Z"',
]);
}
const startTs = Math.floor(startDate.getTime() / 1000);
const endTs = Math.floor(endDate.getTime() / 1000);
const timeQuery = `ts:${startTs}-${endTs}`;
finalQuery = query ? `(${query}) AND ${timeQuery}` : timeQuery;
}
// Handle streaming mode if enabled
if (enableStreaming) {
const streamingManager = StreamingManager.forTool(this.name);
// Define the streaming operation
const streamingOperation = async (params) => {
const response = await withToolTimeout(async () => firewalla.getFlowData(finalQuery, groupBy, sortBy, params.limit || 100, params.cursor), this.name);
// Process flows for this chunk
const processedFlows = SafeAccess.safeArrayMap(response.results, (flow) => ({
timestamp: unixToISOStringOrNow(flow.ts),
source_ip: SafeAccess.getNestedValue(flow, 'source.ip', SafeAccess.getNestedValue(flow, 'device.ip', 'unknown')),
destination_ip: SafeAccess.getNestedValue(flow, 'destination.ip', 'unknown'),
protocol: SafeAccess.getNestedValue(flow, 'protocol', 'unknown'),
bytes: SafeAccess.getNestedValue(flow, 'download', 0) +
SafeAccess.getNestedValue(flow, 'upload', 0),
download: SafeAccess.getNestedValue(flow, 'download', 0),
upload: SafeAccess.getNestedValue(flow, 'upload', 0),
packets: SafeAccess.getNestedValue(flow, 'count', 0),
duration: SafeAccess.getNestedValue(flow, 'duration', 0),
direction: SafeAccess.getNestedValue(flow, 'direction', 'unknown'),
blocked: SafeAccess.getNestedValue(flow, 'block', false),
block_type: SafeAccess.getNestedValue(flow, 'blockType', null),
device: SafeAccess.getNestedValue(flow, 'device', {}),
source: SafeAccess.getNestedValue(flow, 'source', {}),
destination: SafeAccess.getNestedValue(flow, 'destination', {}),
region: SafeAccess.getNestedValue(flow, 'region', null),
category: SafeAccess.getNestedValue(flow, 'category', null),
}));
return {
data: processedFlows,
hasMore: !!response.next_cursor,
nextCursor: response.next_cursor,
total: response.total_count,
};
};
if (streamingSessionId) {
// Continue existing streaming session
const chunk = await streamingManager.continueStreaming(streamingSessionId, streamingOperation);
if (!chunk) {
return this.createErrorResponse('Failed to continue streaming session', ErrorType.API_ERROR);
}
return createStreamingResponse(chunk);
}
// Start new streaming session
const { firstChunk } = await streamingManager.startStreaming(this.name, streamingOperation, {
query: finalQuery,
groupBy,
sortBy,
limit,
start_time: startTimeArg,
end_time: endTime,
});
return createStreamingResponse(firstChunk);
}
const response = await withToolTimeout(async () => firewalla.getFlowData(finalQuery, groupBy, sortBy, limit, cursor), this.name);
const executionTime = Date.now() - startTime;
// Process flow data
let processedFlows = SafeAccess.safeArrayMap(response.results, (flow) => ({
timestamp: unixToISOStringOrNow(flow.ts),
source_ip: SafeAccess.getNestedValue(flow, 'source.ip', SafeAccess.getNestedValue(flow, 'device.ip', 'unknown')),
destination_ip: SafeAccess.getNestedValue(flow, 'destination.ip', 'unknown'),
protocol: SafeAccess.getNestedValue(flow, 'protocol', 'unknown'),
bytes: SafeAccess.getNestedValue(flow, 'download', 0) +
SafeAccess.getNestedValue(flow, 'upload', 0),
download: SafeAccess.getNestedValue(flow, 'download', 0),
upload: SafeAccess.getNestedValue(flow, 'upload', 0),
packets: SafeAccess.getNestedValue(flow, 'count', 0),
duration: SafeAccess.getNestedValue(flow, 'duration', 0),
direction: SafeAccess.getNestedValue(flow, 'direction', 'unknown'),
blocked: SafeAccess.getNestedValue(flow, 'block', false),
block_type: SafeAccess.getNestedValue(flow, 'blockType', null),
device: SafeAccess.getNestedValue(flow, 'device', {}),
source: SafeAccess.getNestedValue(flow, 'source', {}),
destination: SafeAccess.getNestedValue(flow, 'destination', {}),
region: SafeAccess.getNestedValue(flow, 'region', null),
category: SafeAccess.getNestedValue(flow, 'category', null),
}));
// Apply geographic enrichment for IP addresses
processedFlows = await this.enrichGeoIfNeeded(processedFlows, [
'source_ip',
'destination_ip',
]);
// Create metadata for standardized response
const metadata = {
cursor: response.next_cursor,
hasMore: !!response.next_cursor,
limit,
executionTime,
cached: false,
source: 'firewalla_api',
queryParams: {
query: finalQuery,
groupBy,
sortBy,
limit,
cursor,
start_time: startTimeArg,
end_time: endTime,
},
totalCount: response.total_count,
};
// Create standardized response
const standardResponse = ResponseStandardizer.toPaginatedResponse(processedFlows, metadata);
return this.createUnifiedResponse(standardResponse, {
executionTimeMs: executionTime,
});
}
catch (error) {
// Handle timeout errors specifically
if (error instanceof TimeoutError) {
return createTimeoutErrorResponse(this.name, error.duration, 10000 // Default timeout from timeout-manager
);
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
return this.createErrorResponse(`Failed to get flow data: ${errorMessage}`, ErrorType.API_ERROR, { originalError: errorMessage });
}
}
}
export class GetBandwidthUsageHandler extends BaseToolHandler {
constructor() {
super({
enableGeoEnrichment: true,
enableFieldNormalization: true,
additionalMeta: {
data_source: 'bandwidth_usage',
entity_type: 'device_bandwidth',
supports_geographic_enrichment: true,
supports_field_normalization: true,
standardization_version: '2.0.0',
},
});
this.name = 'get_bandwidth_usage';
this.description = 'Get top bandwidth consuming devices by data usage. Requires limit and period parameters. Data is cached for 5 minutes for performance.';
this.category = 'network';
}
async execute(args, firewalla) {
try {
// Parameter validation
const periodValidation = ParameterValidator.validateEnum(args?.period, 'period', ['1h', '24h', '7d', '30d'], true);
const limitValidation = ParameterValidator.validateNumber(args?.limit, 'limit', {
required: false,
defaultValue: 10,
...getLimitValidationConfig(this.name),
});
const validationResult = ParameterValidator.combineValidationResults([
periodValidation,
limitValidation,
]);
if (!validationResult.isValid) {
return this.createErrorResponse('Parameter validation failed', ErrorType.VALIDATION_ERROR, undefined, validationResult.errors);
}
const usageResponse = await withToolTimeout(async () => firewalla.getBandwidthUsage(periodValidation.sanitizedValue, limitValidation.sanitizedValue), this.name);
// Ensure we have results and validate count vs requested limit
const results = usageResponse.results || [];
const requestedLimit = limitValidation.sanitizedValue;
// Note: if we get fewer results than requested, this may be due to
// insufficient data rather than an error
const startTime = Date.now();
// Process bandwidth usage data
const bandwidthData = SafeAccess.safeArrayMap(results, (item) => ({
device_id: SafeAccess.getNestedValue(item, 'device_id', 'unknown'),
device_name: SafeAccess.getNestedValue(item, 'device_name', 'Unknown Device'),
ip: SafeAccess.getNestedValue(item, 'ip', 'unknown'),
bytes_uploaded: SafeAccess.getNestedValue(item, 'bytes_uploaded', 0),
bytes_downloaded: SafeAccess.getNestedValue(item, 'bytes_downloaded', 0),
total_bytes: SafeAccess.getNestedValue(item, 'total_bytes', 0),
total_mb: Math.round((SafeAccess.getNestedValue(item, 'total_bytes', 0) /
(1024 * 1024)) *
100) / 100,
total_gb: Math.round((SafeAccess.getNestedValue(item, 'total_bytes', 0) /
(1024 * 1024 * 1024)) *
100) / 100,
}));
// Apply geographic enrichment for IP addresses
const enrichedBandwidthData = await this.enrichGeoIfNeeded(bandwidthData, ['ip']);
const unifiedResponseData = {
period: periodValidation.sanitizedValue,
top_devices: results.length,
requested_limit: requestedLimit,
bandwidth_usage: enrichedBandwidthData,
};
const executionTime = Date.now() - startTime;
return this.createUnifiedResponse(unifiedResponseData, {
executionTimeMs: executionTime,
});
}
catch (error) {
// Handle timeout errors specifically
if (error instanceof TimeoutError) {
return createTimeoutErrorResponse(this.name, error.duration, 10000 // Default timeout from timeout-manager
);
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
return this.createErrorResponse(`Failed to get bandwidth usage: ${errorMessage}`, ErrorType.API_ERROR, { originalError: errorMessage });
}
}
}
export class GetOfflineDevicesHandler extends BaseToolHandler {
constructor() {
super({
enableGeoEnrichment: true,
enableFieldNormalization: true,
additionalMeta: {
data_source: 'devices',
entity_type: 'offline_devices',
supports_geographic_enrichment: true,
supports_field_normalization: true,
standardization_version: '2.0.0',
},
});
this.name = 'get_offline_devices';
this.description = 'Get all offline devices with last seen timestamps and detailed device information. Requires limit parameter. Data cached for 2 minutes for performance.';
this.category = 'network';
}
async execute(args, firewalla) {
try {
// Parameter validation with standardized limits
const limitValidation = ParameterValidator.validateNumber(args?.limit, 'limit', {
required: false,
defaultValue: 100,
...getLimitValidationConfig(this.name),
});
const sortValidation = ParameterValidator.validateBoolean(args?.sort_by_last_seen, 'sort_by_last_seen', true);
const validationResult = ParameterValidator.combineValidationResults([
limitValidation,
sortValidation,
]);
if (!validationResult.isValid) {
return this.createErrorResponse('Parameter validation failed', ErrorType.VALIDATION_ERROR, undefined, validationResult.errors);
}
const limit = limitValidation.sanitizedValue;
const sortByLastSeen = sortValidation.sanitizedValue ?? true;
// Buffer Strategy: Fetch extra devices to account for post-processing filtering
//
// Problem: When filtering for offline devices, we don't know how many devices
// are offline until after fetching. If we only fetch the requested limit,
// we might get fewer results than requested after filtering.
//
// Solution: Use a "buffer multiplier" strategy where we fetch 3x the requested
// limit to increase the probability of having enough offline devices after
// filtering. This trades some API overhead for more consistent result counts.
//
// The multiplier of 3 is empirically chosen based on typical online/offline
// ratios in network environments (usually 60-80% devices are online).
const fetchLimit = Math.min(limit * 3, 1000); // 3x buffer with 1000 cap for API limits
const allDevicesResponse = await withToolTimeout(async () => firewalla.getDeviceStatus(undefined, undefined, fetchLimit), this.name);
// Normalize device data for consistency first
const deviceResults = SafeAccess.safeArrayAccess(allDevicesResponse.results, (arr) => arr, []);
const normalizedDevices = batchNormalize(deviceResults, {
name: (v) => sanitizeFieldValue(v, 'Unknown Device').value,
ip: (v) => sanitizeFieldValue(v, 'unknown').value,
macVendor: (v) => sanitizeFieldValue(v, 'unknown').value,
network: (v) => (v ? normalizeUnknownFields(v) : null),
group: (v) => (v ? normalizeUnknownFields(v) : null),
online: (v) => Boolean(v), // Ensure consistent boolean handling
});
// Filter to only offline devices with consistent boolean checking
let offlineDevices = SafeAccess.safeArrayFilter(normalizedDevices, (device) => device.online === false);
// Sort by last seen timestamp if requested
if (sortByLastSeen) {
offlineDevices = offlineDevices.sort((a, b) => {
const aTime = Number(SafeAccess.getNestedValue(a, 'lastSeen', 0));
const bTime = Number(SafeAccess.getNestedValue(b, 'lastSeen', 0));
return bTime - aTime; // Most recent first
});
}
// Apply the requested limit
const limitedOfflineDevices = offlineDevices.slice(0, limit);
const responseStartTime = Date.now();
// Process device data
const deviceData = SafeAccess.safeArrayMap(limitedOfflineDevices, (device) => ({
id: SafeAccess.getNestedValue(device, 'id', 'unknown'),
gid: SafeAccess.getNestedValue(device, 'gid', 'unknown'),
name: device.name, // Already normalized
ip: device.ip, // Already normalized
macVendor: device.macVendor, // Already normalized
online: device.online, // Already normalized to false for offline devices
lastSeen: SafeAccess.getNestedValue(device, 'lastSeen', 0),
lastSeenFormatted: safeUnixToISOString(SafeAccess.getNestedValue(device, 'lastSeen', 0), 'Never'),
ipReserved: SafeAccess.getNestedValue(device, 'ipReserved', false),
network: device.network, // Already normalized
group: device.group, // Already normalized
totalDownload: sanitizeByteCount(SafeAccess.getNestedValue(device, 'totalDownload', 0)),
totalUpload: sanitizeByteCount(SafeAccess.getNestedValue(device, 'totalUpload', 0)),
}));
// Apply geographic enrichment for IP addresses
const enrichedDeviceData = await this.enrichGeoIfNeeded(deviceData, [
'ip',
]);
const unifiedResponseData = {
total_offline_devices: offlineDevices.length,
limit_applied: limit,
returned_count: limitedOfflineDevices.length,
devices: enrichedDeviceData,
};
const executionTime = Date.now() - responseStartTime;
return this.createUnifiedResponse(unifiedResponseData, {
executionTimeMs: executionTime,
});
}
catch (error) {
// Handle timeout errors specifically
if (error instanceof TimeoutError) {
return createTimeoutErrorResponse(this.name, error.duration, 10000 // Default timeout from timeout-manager
);
}
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
return this.createErrorResponse(`Failed to get offline devices: ${errorMessage}`, ErrorType.API_ERROR, { originalError: errorMessage });
}
}
}
//# sourceMappingURL=network.js.map