Skip to main content
Glama

firewalla-mcp-server

server.ts40.3 kB
#!/usr/bin/env node /** * @fileoverview Firewalla MCP Server * * This file implements the primary MCP server class that provides Claude with access to * Firewalla firewall data through 28 tools that map to Firewalla API endpoints. * Tools include parameter validation and error handling. * * Architecture: * - 23 Direct API Endpoints * - 5 Convenience Wrappers * - Limits set to API maximum (500) * - Required parameters for proper API calls * - CRUD operations for all resources * - Dual transport support (stdio and HTTP) * * @version 1.2.0 * @author Alex Mittell <mittell@me.com> (https://github.com/amittell) * @since 2025-06-21 */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { ListToolsRequestSchema, isInitializeRequest, } from '@modelcontextprotocol/sdk/types.js'; import { randomUUID } from 'node:crypto'; import { createServer, type IncomingMessage, type ServerResponse, } from 'node:http'; import { config } from './config/config.js'; import { FirewallaClient } from './firewalla/client.js'; import { setupTools } from './tools/index.js'; import { setupResources } from './resources/index.js'; import { setupPrompts } from './prompts/index.js'; import { logger } from './monitoring/logger.js'; /** * UUID v4 validation regex pattern */ const UUID_V4_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; /** * Validates that a string is a properly formatted UUID v4 * * @param value - The string to validate * @returns True if the value is a valid UUID v4, false otherwise */ function isValidUUID(value: string): boolean { return UUID_V4_REGEX.test(value); } /** * Main MCP Server class for Firewalla integration with 28-tool architecture */ export class FirewallaMCPServer { private static signalHandlersRegistered = false; private server: Server; private firewalla: FirewallaClient; constructor() { this.server = new Server( { name: 'firewalla-mcp-server', version: '1.2.0', }, { capabilities: { tools: {}, resources: {}, prompts: {}, }, } ); this.firewalla = new FirewallaClient(config); this.setupHandlers(); } /** * Sets up MCP protocol request handlers for 29-tool architecture */ private setupHandlers(): void { // List available tools - 28-Tool Complete API Coverage this.server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ // Direct API Endpoints (24 tools) { name: 'get_active_alarms', description: 'Retrieve current security alerts and alarms from Firewalla firewall', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query for filtering alarms (default: status:1 for active). Use type:N where N is: 1=Security Activity, 2=Abnormal Upload, 3=Large Bandwidth Usage, 4=Monthly Data Plan, 5=New Device, 6=Device Back Online, 7=Device Offline, 8=Video Activity, 9=Gaming Activity, 10=Porn Activity, 11=VPN Activity, 12=VPN Connection Restored, 13=VPN Connection Error, 14=Open Port, 15=Internet Connectivity Update, 16=Large Upload. Examples: type:8 (video), type:10 (porn), region:US, source_ip:*', }, groupBy: { type: 'string', description: 'Group alarms by field (e.g., type, box)', }, sortBy: { type: 'string', description: 'Sort alarms (default: ts:desc)', }, limit: { type: 'number', description: 'Results per page (optional, default: 200, API maximum: 500)', minimum: 1, maximum: 500, default: 200, }, cursor: { type: 'string', description: 'Pagination cursor from previous response', }, }, required: [], }, }, { name: 'get_specific_alarm', description: 'Get detailed information for a specific Firewalla alarm', inputSchema: { type: 'object', properties: { alarm_id: { type: 'string', description: 'Alarm ID (required for API call)', }, }, required: ['alarm_id'], }, }, // Disabled: delete_alarm tool commented out because the Firewalla MSP API // returns false success responses but doesn't actually delete alarms // { // name: 'delete_alarm', // description: 'Delete/dismiss a specific Firewalla alarm', // inputSchema: { // type: 'object', // properties: { // alarm_id: { // type: 'string', // description: 'Alarm ID (required for API call)', // }, // }, // required: ['alarm_id'], // }, // }, { name: 'get_flow_data', description: 'Query network traffic flows from Firewalla firewall', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query for flows. Supports region:US for geographic filtering, protocol:tcp, blocked:true, domain:*, category:social, etc.', }, groupBy: { type: 'string', description: 'Group flows by specified values (e.g., "domain,box")', }, sortBy: { type: 'string', description: 'Sort flows (default: "ts:desc")', }, limit: { type: 'number', description: 'Maximum results (optional, default: 200, API maximum: 500)', minimum: 1, maximum: 500, default: 200, }, cursor: { type: 'string', description: 'Pagination cursor from previous response', }, }, required: [], }, }, { name: 'get_device_status', description: 'Check online/offline status of devices on Firewalla network', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Maximum number of devices to return (required)', minimum: 1, maximum: 1000, }, box: { type: 'string', description: 'Get devices under a specific Firewalla box (requires box ID)', }, group: { type: 'string', description: 'Get devices under a specific box group (requires group ID)', }, }, required: ['limit'], }, }, { name: 'get_network_rules', description: 'Retrieve firewall rules and conditions', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Maximum number of rules to return (required)', minimum: 1, maximum: 1000, }, query: { type: 'string', description: 'Search conditions for filtering rules', }, }, required: ['limit'], }, }, { name: 'pause_rule', description: 'Temporarily disable an active firewall rule for a specified duration', inputSchema: { type: 'object', properties: { rule_id: { type: 'string', description: 'Rule ID to pause', }, duration: { type: 'number', description: 'Duration in minutes to pause the rule (optional, default: 60, range: 1-1440)', minimum: 1, maximum: 1440, default: 60, }, box: { type: 'string', description: 'Box GID for context (required by API)', }, }, required: ['rule_id', 'box'], }, }, { name: 'resume_rule', description: 'Resume a previously paused firewall rule, restoring it to active state', inputSchema: { type: 'object', properties: { rule_id: { type: 'string', description: 'Rule ID to resume', }, box: { type: 'string', description: 'Box GID for context (required by API)', }, }, required: ['rule_id', 'box'], }, }, { name: 'get_target_lists', description: 'Retrieve all target lists from Firewalla', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Maximum number of target lists to return (required)', minimum: 1, maximum: 1000, }, }, required: ['limit'], }, }, { name: 'get_specific_target_list', description: 'Retrieve a specific target list by ID', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Target list ID (required)', }, }, required: ['id'], }, }, { name: 'create_target_list', description: 'Create a new target list', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Target list name (required, max 24 chars)', maxLength: 24, }, owner: { type: 'string', description: 'Owner: "global" or box GID (required)', }, targets: { type: 'array', items: { type: 'string', }, description: 'Array of domains, IPs, or CIDR ranges (required)', }, category: { type: 'string', enum: [ 'ad', 'edu', 'games', 'gamble', 'intel', 'p2p', 'porn', 'private', 'social', 'shopping', 'video', 'vpn', ], description: 'Content category (optional)', }, notes: { type: 'string', description: 'Additional description (optional)', }, }, required: ['name', 'owner', 'targets'], }, }, { name: 'update_target_list', description: 'Update an existing target list', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Target list ID (required)', }, name: { type: 'string', description: 'Updated target list name (max 24 chars)', maxLength: 24, }, targets: { type: 'array', items: { type: 'string', }, description: 'Updated array of domains, IPs, or CIDR ranges', }, category: { type: 'string', enum: [ 'ad', 'edu', 'games', 'gamble', 'intel', 'p2p', 'porn', 'private', 'social', 'shopping', 'video', 'vpn', ], description: 'Updated content category', }, notes: { type: 'string', description: 'Updated description', }, }, required: ['id'], }, }, { name: 'delete_target_list', description: 'Delete a target list', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Target list ID to delete (required)', }, }, required: ['id'], }, }, { name: 'search_flows', description: 'Search network flows with advanced query filters. Use this for: historical analysis, specific time ranges, complex filtering, or when you need more than 50 flows. Supports pagination, time-based queries (e.g., "ts:>1h" for last hour), and all flow fields including geographic filtering. For quick "what\'s happening now" snapshots, use get_recent_flow_activity instead.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query using Firewalla syntax. Supported fields: protocol:tcp/udp, direction:inbound/outbound/local, blocked:true/false, bytes:>1MB, domain:*.example.com, region:US (country code), category:social/games/porn/etc, gid:box_id, device.ip:192.168.*, source_ip:*, destination_ip:*. Examples: "region:US AND protocol:tcp", "blocked:true AND bytes:>1MB", "category:social OR category:games"', }, groupBy: { type: 'string', description: 'Group flows by specified values (e.g., "domain,box")', }, sortBy: { type: 'string', description: 'Sort flows (default: "ts:desc")', }, limit: { type: 'number', description: 'Maximum results (optional, default: 200, API maximum: 500)', minimum: 1, maximum: 500, default: 200, }, cursor: { type: 'string', description: 'Pagination cursor from previous response', }, }, required: [], }, }, { name: 'search_alarms', description: 'Search alarms using full-text or field filters. Alarm types: 1=Security Activity, 2=Abnormal Upload, 3=Large Bandwidth Usage, 4=Monthly Data Plan, 5=New Device, 6=Device Back Online, 7=Device Offline, 8=Video Activity, 9=Gaming Activity, 10=Porn Activity, 11=VPN Activity, 12=VPN Connection Restored, 13=VPN Connection Error, 14=Open Port, 15=Internet Connectivity Update, 16=Large Upload.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query using Firewalla syntax. Supported fields: type:1-16 (see alarm types above), resolved:true/false, status:1/2 (active/archived), source_ip:192.168.*, region:US (country code), gid:box_id, device.name:*, message:"text search". Examples: "type:8 AND region:US" (video from US), "type:10 AND status:1" (active porn alerts), "source_ip:192.168.* AND NOT resolved:true"', }, groupBy: { type: 'string', description: 'Group alarms by specified fields (comma-separated)', }, sortBy: { type: 'string', description: 'Sort alarms (default: ts:desc)', }, limit: { type: 'number', description: 'Maximum results (optional, default: 200, API maximum: 500)', minimum: 1, maximum: 500, default: 200, }, cursor: { type: 'string', description: 'Pagination cursor from previous response', }, }, required: [], }, }, { name: 'search_rules', description: 'Search firewall rules by target, action or status. Supports all rule fields.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query using Firewalla syntax. Supported fields: action:allow/block/timelimit, target.type:domain/ip/device, target.value:*.facebook.com, status:active/paused, direction:bidirection/inbound/outbound, protocol:tcp/udp, gid:box_id, scope.type:device/network, notes:"description text". Examples: "action:block AND target.value:*.social.com", "status:paused", "target.type:domain AND action:block"', }, }, required: [], }, }, { name: 'get_boxes', description: 'Retrieve list of Firewalla boxes', inputSchema: { type: 'object', properties: { group: { type: 'string', description: 'Get boxes within a specific group (requires group ID)', }, }, required: [], }, }, { name: 'get_simple_statistics', description: 'Retrieve basic statistics overview', inputSchema: { type: 'object', properties: { group: { type: 'string', description: 'Get statistics for specific box group', }, }, required: [], }, }, { name: 'get_statistics_by_region', description: 'Retrieve statistics by region (top regions by blocked flows)', inputSchema: { type: 'object', properties: { group: { type: 'string', description: 'Get statistics for specific box group', }, limit: { type: 'number', description: 'Maximum number of results (optional, default: 5)', minimum: 1, default: 5, }, }, required: [], }, }, { name: 'get_statistics_by_box', description: 'Get statistics for each Firewalla box (top boxes by blocked flows or security alarms)', inputSchema: { type: 'object', properties: { type: { type: 'string', enum: ['topBoxesByBlockedFlows', 'topBoxesBySecurityAlarms'], description: 'Statistics type to retrieve', default: 'topBoxesByBlockedFlows', }, group: { type: 'string', description: 'Get statistics for specific box group', }, limit: { type: 'number', description: 'Maximum number of results (optional, default: 5)', minimum: 1, default: 5, }, }, required: [], }, }, { name: 'get_recent_flow_activity', description: 'Get recent network flow activity snapshot (last 10-20 minutes). Returns up to 50 most recent flows for immediate analysis. CRITICAL: This is a quick snapshot tool only. Use this for: "what\'s happening right now?", current security threats, immediate network issues. DO NOT use for: historical analysis (use search_flows), getting more than 50 flows (use search_flows with limit), daily/weekly patterns (use search_flows with time queries like "ts:>24h"). For comprehensive analysis, always prefer search_flows.', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'get_flow_insights', description: 'Get category-based flow analysis including top content categories, bandwidth consumers, and blocked traffic. Ideal for answering questions like "what porn sites were accessed" or "what social media was used". Replaces time-based trends with actionable insights.', inputSchema: { type: 'object', properties: { period: { type: 'string', enum: ['1h', '24h', '7d', '30d'], description: 'Time period for analysis (default: 24h)', default: '24h', }, categories: { type: 'array', items: { type: 'string', enum: [ 'ad', 'edu', 'games', 'gamble', 'intel', 'p2p', 'porn', 'private', 'social', 'shopping', 'video', 'vpn', ], }, description: 'Filter to specific content categories (optional)', }, include_blocked: { type: 'boolean', description: 'Include blocked traffic analysis (default: false)', default: false, }, }, required: [], }, }, { name: 'get_alarm_trends', description: 'Get historical alarm trend data (alarms generated per day)', inputSchema: { type: 'object', properties: { group: { type: 'string', description: 'Get trends for a specific box group', }, }, required: [], }, }, { name: 'get_rule_trends', description: 'Get historical rule trend data (rules created per day)', inputSchema: { type: 'object', properties: { group: { type: 'string', description: 'Get trends for a specific box group', }, }, required: [], }, }, // Convenience Wrappers (5 tools) { name: 'get_bandwidth_usage', description: 'Get top bandwidth consuming devices (convenience wrapper around get_device_status)', inputSchema: { type: 'object', properties: { period: { type: 'string', description: 'Time period for bandwidth calculation', enum: ['1h', '24h', '7d', '30d'], }, limit: { type: 'number', description: 'Number of top devices to return', minimum: 1, maximum: 500, default: 10, }, box: { type: 'string', description: 'Filter devices under a specific Firewalla box', }, }, required: ['period'], }, }, { name: 'get_offline_devices', description: 'Get all offline devices (convenience wrapper around get_device_status)', inputSchema: { type: 'object', properties: { limit: { type: 'number', description: 'Maximum number of offline devices to return', minimum: 1, maximum: 500, default: 100, }, sort_by_last_seen: { type: 'boolean', description: 'Sort devices by last seen time (default: true)', default: true, }, box: { type: 'string', description: 'Filter devices under a specific Firewalla box', }, }, required: [], }, }, { name: 'search_devices', description: 'Search devices by name, IP, MAC or status (convenience wrapper with client-side filtering)', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query using Firewalla syntax. Supported fields: mac:AA:BB:CC:DD:EE:FF, ip:192.168.1.*, name:*iPhone*, online:true/false, vendor:Apple, gid:box_id, network.name:*, group.name:*. Examples: "online:false AND vendor:Apple", "ip:192.168.1.* AND name:*laptop*", "mac:AA:* OR name:*phone*"', }, status: { type: 'string', enum: ['online', 'offline', 'any'], default: 'any', description: 'Filter by online status', }, limit: { type: 'number', minimum: 1, maximum: 500, default: 50, description: 'Maximum number of devices to return', }, box: { type: 'string', description: 'Filter devices under a specific Firewalla box', }, }, required: [], }, }, { name: 'search_target_lists', description: 'Search target lists with client-side filtering (convenience wrapper around get_target_lists)', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query for target lists. Supported fields: name:*Social*, owner:global/box_gid, category:social/games/ad/porn/etc, targets:*.facebook.com, notes:"description text". Examples: "category:social", "owner:global AND name:*Block*", "targets:*.gaming.com"', }, category: { type: 'string', description: 'Filter by category', }, owner: { type: 'string', description: 'Filter by owner (global or box gid)', }, limit: { type: 'number', minimum: 1, maximum: 500, default: 100, description: 'Maximum number of target lists to return', }, }, required: [], }, }, { name: 'get_network_rules_summary', description: 'Get overview statistics and counts of network rules by category (convenience wrapper)', inputSchema: { type: 'object', properties: { active_only: { type: 'boolean', description: 'Only include active rules in summary (default: true)', default: true, }, rule_type: { type: 'string', description: 'Filter by rule type', }, }, required: [], }, }, ], }; }); // Set up tool handlers using the registry setupTools(this.server, this.firewalla); // Set up resources setupResources(this.server, this.firewalla); // Set up prompts setupPrompts(this.server, this.firewalla); } /** * Starts the MCP server using configured transport (stdio or HTTP) */ async start(): Promise<void> { const transportType = config.transport.type; if (transportType === 'stdio') { await this.startStdioTransport(); } else if (transportType === 'http') { await this.startHttpTransport(); } // Note: TypeScript type system ensures transportType is 'stdio' | 'http' // No else block needed - config validation ensures only valid values reach here } /** * Starts the MCP server using stdio transport */ private async startStdioTransport(): Promise<void> { const transport = new StdioServerTransport(); await this.server.connect(transport); logger.info( 'Firewalla MCP Server running with 28 tools on stdio transport' ); } /** * Starts the MCP server using HTTP transport with StreamableHTTP */ private async startHttpTransport(): Promise<void> { const { port, path } = config.transport; // Map to store transports by session ID const transports = new Map<string, StreamableHTTPServerTransport>(); // Helper function to parse JSON body from request with size limit const parseJsonBody = async (req: IncomingMessage): Promise<unknown> => { const MAX_BODY_SIZE = 1024 * 1024; // 1MB limit to prevent memory exhaustion return new Promise((resolve, reject) => { let body = ''; let size = 0; req.on('data', chunk => { size += chunk.length; if (size > MAX_BODY_SIZE) { req.destroy(); reject(new Error('Request body too large (max 1MB)')); return; } body += chunk.toString(); }); req.on('end', () => { try { resolve(body ? JSON.parse(body) : null); } catch (_error) { reject(new Error('Invalid JSON in request body')); } }); req.on('error', reject); }); }; // Create HTTP server const httpServer = createServer( (req: IncomingMessage, res: ServerResponse) => { void (async () => { // Only handle requests to our configured path if (!req.url?.startsWith(path)) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Not found' })); return; } const sessionId = req.headers['mcp-session-id'] as string | undefined; // Validate session ID format if present if (sessionId && !isValidUUID(sessionId)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Invalid session ID format (must be UUID v4)', }, id: null, }) ); return; } try { if (req.method === 'POST') { // Handle POST requests for MCP messages const parsedBody = await parseJsonBody(req); let transport: StreamableHTTPServerTransport; if (sessionId && transports.has(sessionId)) { // Reuse existing transport for this session transport = transports.get(sessionId)!; } else if (!sessionId && isInitializeRequest(parsedBody)) { // New initialization request - create new transport // Generate session ID immediately to prevent race condition const newSessionId = randomUUID(); transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => newSessionId, onsessioninitialized: (initializedSessionId: string) => { logger.info( `HTTP session initialized: ${initializedSessionId}` ); // Transport already in map, no need to add again }, }); // Store transport immediately to prevent race condition // This ensures the transport is available before handleRequest is called transports.set(newSessionId, transport); // Set up cleanup handler transport.onclose = () => { const sid = transport.sessionId; if (sid && transports.has(sid)) { logger.info(`HTTP session closed: ${sid}`); transports.delete(sid); } }; // Connect transport to server await this.server.connect(transport); } else { // Invalid request - no session ID or not initialization request res.writeHead(400, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: No valid session ID provided', }, id: null, }) ); return; } // Handle the request await transport.handleRequest(req, res, parsedBody); } else if (req.method === 'GET') { // Handle GET requests for SSE streams if (!sessionId || !transports.has(sessionId)) { res.writeHead(400, { 'Content-Type': 'text/plain' }); res.end('Invalid or missing session ID'); return; } const transport = transports.get(sessionId)!; await transport.handleRequest(req, res); } else if (req.method === 'DELETE') { // Handle DELETE requests for session termination if (!sessionId || !transports.has(sessionId)) { res.writeHead(400, { 'Content-Type': 'text/plain' }); res.end('Invalid or missing session ID'); return; } const transport = transports.get(sessionId)!; await transport.handleRequest(req, res); } else { // Unsupported method res.writeHead(405, { 'Content-Type': 'text/plain' }); res.end('Method Not Allowed'); } } catch (error) { logger.error( 'Error handling HTTP request:', error instanceof Error ? error : new Error(String(error)) ); if (!res.headersSent) { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error', }, id: null, }) ); } } })(); } ); // Start listening with error handling await new Promise<void>((resolve, reject) => { httpServer.on('error', err => { logger.error( 'HTTP server error (port conflict or permission issue):', err instanceof Error ? err : new Error(String(err)) ); reject(err); }); httpServer.listen(port, () => { logger.info( `Firewalla MCP Server running with 28 tools on HTTP transport` ); logger.info(`HTTP server listening on http://localhost:${port}${path}`); resolve(); }); }); // Handle graceful shutdown let isShuttingDown = false; const shutdown = () => { // Prevent duplicate shutdown sequences if (isShuttingDown) { logger.warn('Shutdown already in progress, ignoring signal'); return; } isShuttingDown = true; void (async () => { logger.info('Shutting down HTTP server...'); // Close all active transports for (const [sessionId, transport] of transports.entries()) { try { await transport.close(); transports.delete(sessionId); } catch (error) { logger.error( `Error closing transport for session ${sessionId}:`, error instanceof Error ? error : new Error(String(error)) ); } } // Close HTTP server with error handling and timeout const shutdownTimeout = setTimeout(() => { logger.error('HTTP server shutdown timed out, forcing exit'); process.exit(1); }, 10000); // 10 second timeout httpServer.close(err => { clearTimeout(shutdownTimeout); if (err) { logger.error( 'Error during HTTP server shutdown:', err instanceof Error ? err : new Error(String(err)) ); process.exit(1); } else { logger.info('HTTP server shut down complete'); process.exit(0); } }); })(); }; // Track signal handler registration to prevent duplicates if (!FirewallaMCPServer.signalHandlersRegistered) { process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown); FirewallaMCPServer.signalHandlersRegistered = true; } } } // Start the server if this file is run directly if (import.meta.url === `file://${process.argv[1]}`) { const server = new FirewallaMCPServer(); server.start().catch((error: unknown) => { logger.error( 'Failed to start server:', error instanceof Error ? error : new Error(String(error)) ); process.exit(1); }); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/amittell/firewalla-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server