Skip to main content
Glama
drewrad8

Firewalla MCP Server

by drewrad8
server.test.ts55.5 kB
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { FirewallaAPI, FirewallaMCPServer, FirewallaConfig, FlowRecord, Device, ENV_FIREWALLA_URL, ENV_FIREWALLA_TOKEN, ENV_FIREWALLA_ID, ENV_LOG_LEVEL, FirewallaAPIError, FirewallaAuthError, FirewallaNetworkError, FirewallaRateLimitError, logger, } from './server.js'; // Mock fetch globally const mockFetch = vi.fn(); global.fetch = mockFetch; // Mock stderr.write to capture log output const mockStderrWrite = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); describe('FirewallaAPI', () => { const testConfig: FirewallaConfig = { baseUrl: 'https://my.firewalla.com', bearerToken: 'test-token', firewallId: 'test-device-id', }; beforeEach(() => { mockFetch.mockReset(); mockStderrWrite.mockClear(); }); describe('constructor', () => { it('should create an instance with valid config', () => { const api = new FirewallaAPI(testConfig); expect(api).toBeInstanceOf(FirewallaAPI); }); }); describe('queryFlows', () => { it('should make POST request to /v1/flows/query', async () => { const mockFlows: FlowRecord[] = [ { ts: 1234567890, fd: 'out', count: 1, intf: 'eth0', protocol: 'tcp', port: 443, devicePort: 54321, ip: '1.2.3.4', deviceIP: '192.168.1.100', device: 'AA:BB:CC:DD:EE:FF', country: 'US', tags: [], tagIds: [], networkName: 'LAN', onWan: true, }, ]; mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockFlows, }); const api = new FirewallaAPI(testConfig); const result = await api.queryFlows({ start: 1234567800, end: 1234567900, }); expect(mockFetch).toHaveBeenCalledWith( 'https://my.firewalla.com/v1/flows/query', expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ 'Content-Type': 'application/json; charset=utf-8', Authorization: 'Bearer test-token', 'X-Firewalla-ID': 'test-device-id', }), }) ); expect(result).toEqual(mockFlows); }); it('should throw FirewallaAuthError on 401', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 401, statusText: 'Unauthorized', }); const api = new FirewallaAPI(testConfig); await expect(api.queryFlows({ start: 1234567800, end: 1234567900 })).rejects.toThrow( FirewallaAuthError ); }); it('should throw FirewallaAuthError on 403', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 403, statusText: 'Forbidden', }); const api = new FirewallaAPI(testConfig); await expect(api.queryFlows({ start: 1234567800, end: 1234567900 })).rejects.toThrow( FirewallaAuthError ); }); }); describe('listDevices', () => { it('should make GET request to /v1/device/list', async () => { const mockDevices: Device[] = [ { ip: '192.168.1.100', mac: 'AA:BB:CC:DD:EE:FF', lastActive: 1234567890, firstFound: 1234567800, macVendor: 'Apple', bname: 'iPhone', names: ['iPhone'], policy: { deviceTags: [], userTags: [], tags: [], ssidTags: [] }, name: 'iPhone', deviceType: 'phone', intf: { uuid: 'uuid-1', name: 'LAN' }, totalUpload: 1000, totalDownload: 2000, gid: 'gid-1', online: true, }, ]; mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockDevices, }); const api = new FirewallaAPI(testConfig); const result = await api.listDevices(); expect(mockFetch).toHaveBeenCalledWith( 'https://my.firewalla.com/v1/device/list', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer test-token', }), }) ); expect(result).toEqual(mockDevices); }); }); describe('getDevice', () => { it('should find device by MAC address', async () => { const mockDevices: Device[] = [ { ip: '192.168.1.100', mac: 'AA:BB:CC:DD:EE:FF', lastActive: 1234567890, firstFound: 1234567800, macVendor: 'Apple', bname: 'iPhone', names: ['iPhone'], policy: { deviceTags: [], userTags: [], tags: [], ssidTags: [] }, name: 'iPhone', deviceType: 'phone', intf: { uuid: 'uuid-1', name: 'LAN' }, totalUpload: 1000, totalDownload: 2000, gid: 'gid-1', online: true, }, ]; mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockDevices, }); const api = new FirewallaAPI(testConfig); const result = await api.getDevice('AA:BB:CC:DD:EE:FF'); expect(result.mac).toBe('AA:BB:CC:DD:EE:FF'); expect(result.name).toBe('iPhone'); }); it('should throw error if device not found', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => [], }); const api = new FirewallaAPI(testConfig); await expect(api.getDevice('XX:XX:XX:XX:XX:XX')).rejects.toThrow( 'Device with MAC XX:XX:XX:XX:XX:XX not found' ); }); }); describe('error recovery', () => { it('should retry on 500 server error and succeed', async () => { // First call fails with 500, second succeeds mockFetch .mockResolvedValueOnce({ ok: false, status: 500, statusText: 'Internal Server Error', }) .mockResolvedValueOnce({ ok: true, json: async () => [], }); const api = new FirewallaAPI(testConfig, { baseDelayMs: 10, maxRetries: 2 }); const result = await api.listDevices(); expect(result).toEqual([]); expect(mockFetch).toHaveBeenCalledTimes(2); // Verify structured log was written expect(mockStderrWrite).toHaveBeenCalled(); const logOutput = mockStderrWrite.mock.calls[0][0] as string; const logEntry = JSON.parse(logOutput); expect(logEntry.level).toBe('warn'); expect(logEntry.message).toContain('retrying'); }); it('should retry on 503 and eventually fail after max retries', async () => { // All calls fail with 503 mockFetch.mockResolvedValue({ ok: false, status: 503, statusText: 'Service Unavailable', }); const api = new FirewallaAPI(testConfig, { baseDelayMs: 10, maxRetries: 2 }); await expect(api.listDevices()).rejects.toThrow(FirewallaAPIError); expect(mockFetch).toHaveBeenCalledTimes(3); // Initial + 2 retries }); it('should not retry on 400 client error', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 400, statusText: 'Bad Request', }); const api = new FirewallaAPI(testConfig, { baseDelayMs: 10, maxRetries: 3 }); await expect(api.listDevices()).rejects.toThrow(FirewallaAPIError); expect(mockFetch).toHaveBeenCalledTimes(1); // No retries }); it('should not retry on authentication errors', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 401, statusText: 'Unauthorized', }); const api = new FirewallaAPI(testConfig, { baseDelayMs: 10, maxRetries: 3 }); await expect(api.listDevices()).rejects.toThrow(FirewallaAuthError); expect(mockFetch).toHaveBeenCalledTimes(1); // No retries for auth errors }); it('should handle rate limiting with Retry-After header', async () => { const api = new FirewallaAPI(testConfig, { baseDelayMs: 10, maxRetries: 2 }); mockFetch .mockResolvedValueOnce({ ok: false, status: 429, statusText: 'Too Many Requests', headers: { get: () => '1' }, }) .mockResolvedValueOnce({ ok: true, json: async () => [], }); const result = await api.listDevices(); expect(result).toEqual([]); // Verify rate limit warning was logged expect(mockStderrWrite).toHaveBeenCalled(); const logOutput = mockStderrWrite.mock.calls[0][0] as string; const logEntry = JSON.parse(logOutput); expect(logEntry.level).toBe('warn'); expect(logEntry.message).toContain('Rate limited'); }); it('should handle network errors and retry', async () => { // First call throws network error, second succeeds mockFetch.mockRejectedValueOnce(new TypeError('Failed to fetch')).mockResolvedValueOnce({ ok: true, json: async () => [], }); const api = new FirewallaAPI(testConfig, { baseDelayMs: 10, maxRetries: 2 }); const result = await api.listDevices(); expect(result).toEqual([]); expect(mockFetch).toHaveBeenCalledTimes(2); // Verify network error was logged expect(mockStderrWrite).toHaveBeenCalled(); }); it('should throw FirewallaNetworkError after all network retries fail', async () => { mockFetch.mockRejectedValue(new TypeError('Failed to fetch')); const api = new FirewallaAPI(testConfig, { baseDelayMs: 10, maxRetries: 2 }); await expect(api.listDevices()).rejects.toThrow(FirewallaNetworkError); expect(mockFetch).toHaveBeenCalledTimes(3); // Initial + 2 retries }); it('should accept custom retry configuration', () => { const api = new FirewallaAPI(testConfig, { maxRetries: 5, baseDelayMs: 500, maxDelayMs: 5000, timeoutMs: 60000, }); expect(api).toBeInstanceOf(FirewallaAPI); }); }); }); describe('Error classes', () => { describe('FirewallaAPIError', () => { it('should create error with all properties', () => { const error = new FirewallaAPIError('Test error', 500, '/test', true); expect(error.message).toBe('Test error'); expect(error.statusCode).toBe(500); expect(error.endpoint).toBe('/test'); expect(error.retryable).toBe(true); expect(error.name).toBe('FirewallaAPIError'); }); it('should default retryable to false', () => { const error = new FirewallaAPIError('Test error'); expect(error.retryable).toBe(false); }); }); describe('FirewallaAuthError', () => { it('should set correct properties', () => { const error = new FirewallaAuthError('Auth failed', '/login'); expect(error.statusCode).toBe(401); expect(error.retryable).toBe(false); expect(error.name).toBe('FirewallaAuthError'); }); }); describe('FirewallaNetworkError', () => { it('should be retryable', () => { const error = new FirewallaNetworkError('Network failed', '/api'); expect(error.retryable).toBe(true); expect(error.name).toBe('FirewallaNetworkError'); }); }); describe('FirewallaRateLimitError', () => { it('should include retry-after value', () => { const error = new FirewallaRateLimitError('Rate limited', 60, '/api'); expect(error.statusCode).toBe(429); expect(error.retryAfter).toBe(60); expect(error.retryable).toBe(true); expect(error.name).toBe('FirewallaRateLimitError'); }); }); }); describe('FirewallaMCPServer', () => { const originalEnv = process.env; beforeEach(() => { // Reset environment variables process.env = { ...originalEnv }; delete process.env[ENV_FIREWALLA_URL]; delete process.env[ENV_FIREWALLA_TOKEN]; delete process.env[ENV_FIREWALLA_ID]; mockFetch.mockReset(); mockStderrWrite.mockClear(); }); afterEach(() => { process.env = originalEnv; }); describe('constructor', () => { it('should create an instance without env vars', () => { const server = new FirewallaMCPServer(); expect(server).toBeInstanceOf(FirewallaMCPServer); }); it('should auto-configure from environment variables', () => { process.env[ENV_FIREWALLA_URL] = 'https://my.firewalla.com'; process.env[ENV_FIREWALLA_TOKEN] = 'test-token'; process.env[ENV_FIREWALLA_ID] = 'test-device-id'; const server = new FirewallaMCPServer(); expect(server).toBeInstanceOf(FirewallaMCPServer); // Verify that config message was logged via structured logger expect(mockStderrWrite).toHaveBeenCalled(); const logOutput = mockStderrWrite.mock.calls[0][0] as string; const logEntry = JSON.parse(logOutput); expect(logEntry.level).toBe('info'); expect(logEntry.message).toContain('configured from environment variables'); }); it('should warn about partial configuration', () => { process.env[ENV_FIREWALLA_URL] = 'https://my.firewalla.com'; // Missing TOKEN and ID new FirewallaMCPServer(); // Verify warning was logged expect(mockStderrWrite).toHaveBeenCalled(); const logOutput = mockStderrWrite.mock.calls[0][0] as string; const logEntry = JSON.parse(logOutput); expect(logEntry.level).toBe('warn'); expect(logEntry.message).toContain('Partial Firewalla config'); }); }); }); describe('Logger', () => { beforeEach(() => { mockStderrWrite.mockClear(); }); it('should output structured JSON logs', () => { logger.info('Test message', { key: 'value' }); expect(mockStderrWrite).toHaveBeenCalled(); const logOutput = mockStderrWrite.mock.calls[0][0] as string; const logEntry = JSON.parse(logOutput); expect(logEntry.service).toBe('firewalla-mcp'); expect(logEntry.level).toBe('info'); expect(logEntry.message).toBe('Test message'); expect(logEntry.context).toEqual({ key: 'value' }); expect(logEntry.timestamp).toBeDefined(); }); it('should support different log levels', () => { logger.setLevel('debug'); logger.debug('Debug message'); logger.info('Info message'); logger.warn('Warn message'); logger.error('Error message'); expect(mockStderrWrite).toHaveBeenCalledTimes(4); // Reset to default logger.setLevel('info'); }); it('should filter logs below current level', () => { logger.setLevel('warn'); logger.debug('Debug message'); logger.info('Info message'); logger.warn('Warn message'); logger.error('Error message'); expect(mockStderrWrite).toHaveBeenCalledTimes(2); // Only warn and error // Reset to default logger.setLevel('info'); }); it('should get current log level', () => { const level = logger.getLevel(); expect(['debug', 'info', 'warn', 'error']).toContain(level); }); it('should omit context when empty', () => { logger.info('Test message'); const logOutput = mockStderrWrite.mock.calls[0][0] as string; const logEntry = JSON.parse(logOutput); expect(logEntry.context).toBeUndefined(); }); }); describe('Environment variable constants', () => { it('should export correct environment variable names', () => { expect(ENV_FIREWALLA_URL).toBe('FIREWALLA_URL'); expect(ENV_FIREWALLA_TOKEN).toBe('FIREWALLA_TOKEN'); expect(ENV_FIREWALLA_ID).toBe('FIREWALLA_ID'); expect(ENV_LOG_LEVEL).toBe('LOG_LEVEL'); }); }); describe('Type exports', () => { it('should export FirewallaConfig interface', () => { const config: FirewallaConfig = { baseUrl: 'https://example.com', bearerToken: 'token', firewallId: 'id', }; expect(config.baseUrl).toBe('https://example.com'); }); it('should export FlowRecord interface', () => { const flow: FlowRecord = { ts: 1234567890, fd: 'in', count: 1, intf: 'eth0', protocol: 'tcp', port: 443, devicePort: 12345, ip: '1.2.3.4', deviceIP: '192.168.1.1', device: 'AA:BB:CC:DD:EE:FF', country: 'US', tags: [], tagIds: [], networkName: 'LAN', onWan: true, }; expect(flow.protocol).toBe('tcp'); }); it('should export Device interface', () => { const device: Device = { ip: '192.168.1.100', mac: 'AA:BB:CC:DD:EE:FF', lastActive: 1234567890, firstFound: 1234567800, macVendor: 'Apple', bname: 'iPhone', names: ['iPhone'], policy: { deviceTags: [], userTags: [], tags: [], ssidTags: [] }, name: 'iPhone', deviceType: 'phone', intf: { uuid: 'uuid-1', name: 'LAN' }, totalUpload: 1000, totalDownload: 2000, gid: 'gid-1', online: true, }; expect(device.online).toBe(true); }); }); describe('FirewallaMCPServer tool handlers', () => { const originalEnv = process.env; beforeEach(() => { process.env = { ...originalEnv }; delete process.env[ENV_FIREWALLA_URL]; delete process.env[ENV_FIREWALLA_TOKEN]; delete process.env[ENV_FIREWALLA_ID]; mockFetch.mockReset(); mockStderrWrite.mockClear(); }); afterEach(() => { process.env = originalEnv; }); describe('configureFirewalla', () => { it('should configure API and return success message', async () => { const server = new FirewallaMCPServer(); const result = await (server as any).configureFirewalla({ baseUrl: 'https://my.firewalla.com', bearerToken: 'test-token', firewallId: 'test-id', }); expect(result.content[0].text).toContain('configured successfully'); }); it('should indicate when overriding env config', async () => { process.env[ENV_FIREWALLA_URL] = 'https://my.firewalla.com'; process.env[ENV_FIREWALLA_TOKEN] = 'env-token'; process.env[ENV_FIREWALLA_ID] = 'env-id'; const server = new FirewallaMCPServer(); const result = await (server as any).configureFirewalla({ baseUrl: 'https://my.firewalla.com', bearerToken: 'new-token', firewallId: 'new-id', }); expect(result.content[0].text).toContain('reconfigured'); expect(result.content[0].text).toContain('overriding'); }); }); describe('getConfigStatus', () => { it('should return not configured status when no config', async () => { const server = new FirewallaMCPServer(); const result = await (server as any).getConfigStatus(); expect(result.content[0].text).toContain('not configured'); }); it('should return configured status with env source', async () => { process.env[ENV_FIREWALLA_URL] = 'https://my.firewalla.com'; process.env[ENV_FIREWALLA_TOKEN] = 'test-token'; process.env[ENV_FIREWALLA_ID] = 'test-id'; const server = new FirewallaMCPServer(); const result = await (server as any).getConfigStatus(); expect(result.content[0].text).toContain('configured'); expect(result.content[0].text).toContain('environment variables'); }); it('should show environment variable status', async () => { process.env[ENV_FIREWALLA_URL] = 'https://my.firewalla.com'; const server = new FirewallaMCPServer(); const result = await (server as any).getConfigStatus(); const statusJson = JSON.parse(result.content[1].text); expect(statusJson.environmentVariables.FIREWALLA_URL).toBe('set'); expect(statusJson.environmentVariables.FIREWALLA_TOKEN).toBe('not set'); }); }); describe('queryNetworkFlows', () => { it('should throw error when API not configured', async () => { const server = new FirewallaMCPServer(); await expect( (server as any).queryNetworkFlows({ startTime: 1234567800, endTime: 1234567900, }) ).rejects.toThrow('not configured'); }); it('should return flows when configured', async () => { process.env[ENV_FIREWALLA_URL] = 'https://my.firewalla.com'; process.env[ENV_FIREWALLA_TOKEN] = 'test-token'; process.env[ENV_FIREWALLA_ID] = 'test-id'; const mockFlows: FlowRecord[] = [ { ts: 1234567890, fd: 'out', count: 1, intf: 'eth0', protocol: 'tcp', port: 443, devicePort: 54321, ip: '1.2.3.4', deviceIP: '192.168.1.100', device: 'AA:BB:CC:DD:EE:FF', country: 'US', tags: [], tagIds: [], networkName: 'LAN', onWan: true, }, ]; mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockFlows, }); const server = new FirewallaMCPServer(); const result = await (server as any).queryNetworkFlows({ startTime: 1234567800, endTime: 1234567900, }); expect(result.content[0].text).toContain('Found 1 network flows'); }); }); describe('listDevices', () => { it('should throw error when API not configured', async () => { const server = new FirewallaMCPServer(); await expect((server as any).listDevices()).rejects.toThrow('not configured'); }); it('should return device list when configured', async () => { process.env[ENV_FIREWALLA_URL] = 'https://my.firewalla.com'; process.env[ENV_FIREWALLA_TOKEN] = 'test-token'; process.env[ENV_FIREWALLA_ID] = 'test-id'; const mockDevices: Device[] = [ { ip: '192.168.1.100', mac: 'AA:BB:CC:DD:EE:FF', lastActive: 1234567890, firstFound: 1234567800, macVendor: 'Apple', bname: 'iPhone', names: ['iPhone'], policy: { deviceTags: [], userTags: [], tags: [], ssidTags: [] }, name: 'iPhone', deviceType: 'phone', intf: { uuid: 'uuid-1', name: 'LAN' }, totalUpload: 1000, totalDownload: 2000, gid: 'gid-1', online: true, }, ]; mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockDevices, }); const server = new FirewallaMCPServer(); const result = await (server as any).listDevices(); expect(result.content[0].text).toContain('Found 1 devices'); }); }); describe('getDeviceDetails', () => { it('should throw error when API not configured', async () => { const server = new FirewallaMCPServer(); await expect((server as any).getDeviceDetails({ mac: 'AA:BB:CC:DD:EE:FF' })).rejects.toThrow( 'not configured' ); }); it('should return device details when found', async () => { process.env[ENV_FIREWALLA_URL] = 'https://my.firewalla.com'; process.env[ENV_FIREWALLA_TOKEN] = 'test-token'; process.env[ENV_FIREWALLA_ID] = 'test-id'; const mockDevices: Device[] = [ { ip: '192.168.1.100', mac: 'AA:BB:CC:DD:EE:FF', lastActive: 1234567890, firstFound: 1234567800, macVendor: 'Apple', bname: 'iPhone', names: ['iPhone'], policy: { deviceTags: [], userTags: [], tags: [], ssidTags: [] }, name: 'iPhone', deviceType: 'phone', intf: { uuid: 'uuid-1', name: 'LAN' }, totalUpload: 1000, totalDownload: 2000, gid: 'gid-1', online: true, }, ]; mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockDevices, }); const server = new FirewallaMCPServer(); const result = await (server as any).getDeviceDetails({ mac: 'AA:BB:CC:DD:EE:FF' }); expect(result.content[0].text).toContain('Device details for iPhone'); }); }); describe('searchDevices', () => { it('should throw error when API not configured', async () => { const server = new FirewallaMCPServer(); await expect((server as any).searchDevices({ query: 'iphone' })).rejects.toThrow( 'not configured' ); }); it('should search devices by name', async () => { process.env[ENV_FIREWALLA_URL] = 'https://my.firewalla.com'; process.env[ENV_FIREWALLA_TOKEN] = 'test-token'; process.env[ENV_FIREWALLA_ID] = 'test-id'; const mockDevices: Device[] = [ { ip: '192.168.1.100', mac: 'AA:BB:CC:DD:EE:FF', lastActive: 1234567890, firstFound: 1234567800, macVendor: 'Apple', bname: 'iPhone', names: ['iPhone'], policy: { deviceTags: [], userTags: [], tags: [], ssidTags: [] }, name: 'iPhone', deviceType: 'phone', intf: { uuid: 'uuid-1', name: 'LAN' }, totalUpload: 1000, totalDownload: 2000, gid: 'gid-1', online: true, }, { ip: '192.168.1.101', mac: 'BB:CC:DD:EE:FF:AA', lastActive: 1234567890, firstFound: 1234567800, macVendor: 'Samsung', bname: 'Galaxy', names: ['Galaxy'], policy: { deviceTags: [], userTags: [], tags: [], ssidTags: [] }, name: 'Galaxy', deviceType: 'phone', intf: { uuid: 'uuid-1', name: 'LAN' }, totalUpload: 500, totalDownload: 1000, gid: 'gid-1', online: false, }, ]; mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockDevices, }); const server = new FirewallaMCPServer(); const result = await (server as any).searchDevices({ query: 'iphone' }); expect(result.content[0].text).toContain('Found 1 device'); }); it('should filter by online status', async () => { process.env[ENV_FIREWALLA_URL] = 'https://my.firewalla.com'; process.env[ENV_FIREWALLA_TOKEN] = 'test-token'; process.env[ENV_FIREWALLA_ID] = 'test-id'; const mockDevices: Device[] = [ { ip: '192.168.1.100', mac: 'AA:BB:CC:DD:EE:FF', lastActive: 1234567890, firstFound: 1234567800, macVendor: 'Apple', bname: 'iPhone', names: ['iPhone'], policy: { deviceTags: [], userTags: [], tags: [], ssidTags: [] }, name: 'iPhone', deviceType: 'phone', intf: { uuid: 'uuid-1', name: 'LAN' }, totalUpload: 1000, totalDownload: 2000, gid: 'gid-1', online: true, }, { ip: '192.168.1.101', mac: 'BB:CC:DD:EE:FF:AA', lastActive: 1234567890, firstFound: 1234567800, macVendor: 'Apple', bname: 'iPad', names: ['iPad'], policy: { deviceTags: [], userTags: [], tags: [], ssidTags: [] }, name: 'iPad', deviceType: 'tablet', intf: { uuid: 'uuid-1', name: 'LAN' }, totalUpload: 500, totalDownload: 1000, gid: 'gid-1', online: false, }, ]; mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockDevices, }); const server = new FirewallaMCPServer(); // Search for 'phone' which matches deviceType, filter to online only const result = await (server as any).searchDevices({ query: 'phone', online: true }); expect(result.content[0].text).toContain('Found 1 device'); }); }); }); describe('Traffic analysis helper methods', () => { let server: FirewallaMCPServer; beforeEach(() => { mockStderrWrite.mockClear(); server = new FirewallaMCPServer(); }); const sampleFlows: FlowRecord[] = [ { ts: 1234567890, fd: 'out', count: 1, intf: 'eth0', protocol: 'tcp', port: 443, devicePort: 54321, ip: '1.2.3.4', host: 'google.com', deviceIP: '192.168.1.100', device: 'AA:BB:CC:DD:EE:FF', deviceName: 'iPhone', country: 'US', tags: [], tagIds: [], networkName: 'LAN', onWan: true, upload: 1000, download: 5000, }, { ts: 1234567891, fd: 'out', count: 1, intf: 'eth0', protocol: 'udp', port: 53, devicePort: 54322, ip: '8.8.8.8', host: 'dns.google', deviceIP: '192.168.1.100', device: 'AA:BB:CC:DD:EE:FF', deviceName: 'iPhone', country: 'US', tags: [], tagIds: [], networkName: 'LAN', onWan: true, upload: 100, download: 200, }, { ts: 1234567892, fd: 'out', count: 1, intf: 'eth0', protocol: 'tcp', port: 80, devicePort: 54323, ip: '5.6.7.8', host: 'malware.com', deviceIP: '192.168.1.101', device: 'BB:CC:DD:EE:FF:AA', deviceName: 'Galaxy', country: 'RU', tags: ['security'], tagIds: [], networkName: 'LAN', onWan: true, upload: 500, download: 1000, blocked: true, blockType: 'threat', category: 'intel', }, { ts: 1234567893, fd: 'out', count: 1, intf: 'eth0', protocol: 'tcp', port: 22, devicePort: 54324, ip: '10.0.0.1', deviceIP: '192.168.1.102', device: 'CC:DD:EE:FF:AA:BB', deviceName: 'Server', country: 'CN', tags: [], tagIds: [], networkName: 'LAN', onWan: true, upload: 2000, download: 3000, }, ]; describe('generateTrafficSummary', () => { it('should calculate total traffic correctly', () => { const result = (server as any).generateTrafficSummary(sampleFlows); expect(result.totalFlows).toBe(4); expect(result.totalUpload).toBe(3600); // 1000 + 100 + 500 + 2000 expect(result.totalDownload).toBe(9200); // 5000 + 200 + 1000 + 3000 expect(result.totalTraffic).toBe(12800); }); it('should count unique devices', () => { const result = (server as any).generateTrafficSummary(sampleFlows); expect(result.uniqueDevices).toBe(3); }); it('should count blocked flows', () => { const result = (server as any).generateTrafficSummary(sampleFlows); expect(result.blockedFlows).toBe(1); }); it('should group flows by protocol', () => { const result = (server as any).generateTrafficSummary(sampleFlows); expect(result.protocolDistribution.tcp).toBe(3); expect(result.protocolDistribution.udp).toBe(1); }); }); describe('getTopTalkers', () => { it('should rank devices by total traffic', () => { const result = (server as any).getTopTalkers(sampleFlows); expect(result[0].mac).toBe('AA:BB:CC:DD:EE:FF'); // 6300 bytes total expect(result[0].total).toBe(6300); }); it('should respect limit parameter', () => { const result = (server as any).getTopTalkers(sampleFlows, 2); expect(result.length).toBe(2); }); it('should aggregate traffic per device', () => { const result = (server as any).getTopTalkers(sampleFlows); const iphone = result.find((d: any) => d.mac === 'AA:BB:CC:DD:EE:FF'); expect(iphone.upload).toBe(1100); // 1000 + 100 expect(iphone.download).toBe(5200); // 5000 + 200 }); }); describe('getBlockedTraffic', () => { it('should count total blocked flows', () => { const result = (server as any).getBlockedTraffic(sampleFlows); expect(result.totalBlocked).toBe(1); }); it('should group blocked by type', () => { const result = (server as any).getBlockedTraffic(sampleFlows); expect(result.byType.threat).toBe(1); }); it('should list top blocked destinations', () => { const result = (server as any).getBlockedTraffic(sampleFlows); expect(result.topBlockedDestinations[0].destination).toBe('malware.com'); expect(result.topBlockedDestinations[0].count).toBe(1); }); }); describe('getSecurityEvents', () => { it('should count security events', () => { const result = (server as any).getSecurityEvents(sampleFlows); expect(result.totalEvents).toBeGreaterThanOrEqual(1); }); it('should count blocked connections', () => { const result = (server as any).getSecurityEvents(sampleFlows); expect(result.blockedConnections).toBe(1); }); it('should count threat intel events', () => { const result = (server as any).getSecurityEvents(sampleFlows); expect(result.threatIntelEvents).toBe(1); }); it('should identify suspicious ports in security events', () => { // Add a blocked flow on a suspicious port const flowsWithSuspiciousPort: FlowRecord[] = [ ...sampleFlows, { ts: 1234567894, fd: 'in', count: 1, intf: 'eth0', protocol: 'tcp', port: 22, devicePort: 54325, ip: '10.0.0.2', deviceIP: '192.168.1.103', device: 'DD:EE:FF:AA:BB:CC', deviceName: 'Attacker', country: 'CN', tags: [], tagIds: [], networkName: 'LAN', onWan: true, upload: 100, download: 100, blocked: true, blockType: 'threat', }, ]; const result = (server as any).getSecurityEvents(flowsWithSuspiciousPort); // Port 22 should now be detected as it's in a security event expect(result.suspiciousPorts[22]).toBe(1); }); }); describe('groupFlowsByProtocol', () => { it('should count flows per protocol', () => { const result = (server as any).groupFlowsByProtocol(sampleFlows); expect(result.tcp).toBe(3); expect(result.udp).toBe(1); }); }); describe('groupFlowsByCountry', () => { it('should count flows per country', () => { const result = (server as any).groupFlowsByCountry(sampleFlows); expect(result.US).toBe(2); expect(result.RU).toBe(1); expect(result.CN).toBe(1); }); }); describe('getTopDestinations', () => { it('should rank destinations by connection count', () => { const result = (server as any).getTopDestinations(sampleFlows); expect(result.length).toBeGreaterThan(0); expect(result[0]).toHaveProperty('destination'); expect(result[0]).toHaveProperty('count'); expect(result[0]).toHaveProperty('upload'); expect(result[0]).toHaveProperty('download'); }); it('should respect limit parameter', () => { const result = (server as any).getTopDestinations(sampleFlows, 2); expect(result.length).toBeLessThanOrEqual(2); }); }); describe('getSuspiciousPorts', () => { it('should detect suspicious port activity', () => { const result = (server as any).getSuspiciousPorts(sampleFlows); expect(result[22]).toBe(1); // SSH port }); it('should not count non-suspicious ports', () => { const result = (server as any).getSuspiciousPorts(sampleFlows); expect(result[443]).toBeUndefined(); expect(result[80]).toBeUndefined(); }); }); describe('groupDevicesByType', () => { const devices: Device[] = [ { ip: '192.168.1.100', mac: 'AA:BB:CC:DD:EE:FF', lastActive: 1234567890, firstFound: 1234567800, macVendor: 'Apple', bname: 'iPhone', names: ['iPhone'], policy: { deviceTags: [], userTags: [], tags: [], ssidTags: [] }, name: 'iPhone', deviceType: 'phone', intf: { uuid: 'uuid-1', name: 'LAN' }, totalUpload: 1000, totalDownload: 2000, gid: 'gid-1', online: true, }, { ip: '192.168.1.101', mac: 'BB:CC:DD:EE:FF:AA', lastActive: 1234567890, firstFound: 1234567800, macVendor: 'Apple', bname: 'iPad', names: ['iPad'], policy: { deviceTags: [], userTags: [], tags: [], ssidTags: [] }, name: 'iPad', deviceType: 'tablet', intf: { uuid: 'uuid-1', name: 'LAN' }, totalUpload: 500, totalDownload: 1000, gid: 'gid-1', online: true, }, { ip: '192.168.1.102', mac: 'CC:DD:EE:FF:AA:BB', lastActive: 1234567890, firstFound: 1234567800, macVendor: 'Apple', bname: 'iPhone2', names: ['iPhone2'], policy: { deviceTags: [], userTags: [], tags: [], ssidTags: [] }, name: 'iPhone2', deviceType: 'phone', intf: { uuid: 'uuid-1', name: 'LAN' }, totalUpload: 500, totalDownload: 1000, gid: 'gid-1', online: true, }, ]; it('should group devices by type', () => { const result = (server as any).groupDevicesByType(devices); expect(result.phone).toBe(2); expect(result.tablet).toBe(1); }); it('should handle unknown device types', () => { const devicesWithUnknown = [ ...devices, { ip: '192.168.1.103', mac: 'DD:EE:FF:AA:BB:CC', lastActive: 1234567890, firstFound: 1234567800, macVendor: 'Unknown', bname: 'Unknown', names: ['Unknown'], policy: { deviceTags: [], userTags: [], tags: [], ssidTags: [] }, name: 'Unknown', deviceType: undefined, intf: { uuid: 'uuid-1', name: 'LAN' }, totalUpload: 0, totalDownload: 0, gid: 'gid-1', online: false, }, ]; const result = (server as any).groupDevicesByType(devicesWithUnknown); expect(result.unknown).toBe(1); }); }); }); describe('FirewallaMCPServer additional handlers', () => { const originalEnv = process.env; beforeEach(() => { process.env = { ...originalEnv }; process.env[ENV_FIREWALLA_URL] = 'https://my.firewalla.com'; process.env[ENV_FIREWALLA_TOKEN] = 'test-token'; process.env[ENV_FIREWALLA_ID] = 'test-id'; mockFetch.mockReset(); mockStderrWrite.mockClear(); }); afterEach(() => { process.env = originalEnv; }); describe('getNetworkOverview', () => { it('should throw error when API not configured', async () => { delete process.env[ENV_FIREWALLA_URL]; delete process.env[ENV_FIREWALLA_TOKEN]; delete process.env[ENV_FIREWALLA_ID]; const server = new FirewallaMCPServer(); await expect((server as any).getNetworkOverview()).rejects.toThrow('not configured'); }); it('should return network overview when configured', async () => { const mockNetworkTargets = { devices: [ { ip: '192.168.1.100', mac: 'AA:BB:CC:DD:EE:FF', lastActive: 1234567890, firstFound: 1234567800, macVendor: 'Apple', bname: 'iPhone', names: ['iPhone'], policy: { deviceTags: [], userTags: [], tags: [], ssidTags: [] }, name: 'iPhone', deviceType: 'phone', intf: { uuid: 'uuid-1', name: 'LAN' }, totalUpload: 1000, totalDownload: 2000, gid: 'gid-1', online: true, }, ], networkGroups: [ { meta: { type: 'lan', name: 'LAN', uuid: 'uuid-1' }, enabled: true, ipv4: '192.168.1.0/24', uuid: 'uuid-1', name: 'LAN', gid: 'gid-1', devices: [], }, ], deviceTags: [ { uid: 'tag-1', name: 'IoT Devices', createTs: 1234567800, policy: {}, gid: 'gid-1', devices: [], }, ], }; mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockNetworkTargets, }); const server = new FirewallaMCPServer(); const result = await (server as any).getNetworkOverview(); expect(result.content[0].text).toContain('Network Overview'); const overview = JSON.parse(result.content[1].text); expect(overview.summary.totalDevices).toBe(1); expect(overview.summary.onlineDevices).toBe(1); expect(overview.summary.totalNetworkGroups).toBe(1); }); }); describe('getCloudRules', () => { it('should throw error when API not configured', async () => { delete process.env[ENV_FIREWALLA_URL]; delete process.env[ENV_FIREWALLA_TOKEN]; delete process.env[ENV_FIREWALLA_ID]; const server = new FirewallaMCPServer(); await expect((server as any).getCloudRules()).rejects.toThrow('not configured'); }); it('should return cloud rules when configured', async () => { const mockCloudRules = { 'rule-1': { id: 'rule-1', type: 'category', name: 'Block Ads', source: 'cloud', scope: 'all', beta: false, disabled: false, last_updated: 1234567890, rules: [], notes: 'Block advertising', count: 5, dnsmasq_only: true, }, }; mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockCloudRules, }); const server = new FirewallaMCPServer(); const result = await (server as any).getCloudRules(); expect(result.content[0].text).toContain('Found 1 cloud security rules'); }); }); describe('getTrafficTrends', () => { it('should throw error when API not configured', async () => { delete process.env[ENV_FIREWALLA_URL]; delete process.env[ENV_FIREWALLA_TOKEN]; delete process.env[ENV_FIREWALLA_ID]; const server = new FirewallaMCPServer(); await expect((server as any).getTrafficTrends({ period: '24h' })).rejects.toThrow( 'not configured' ); }); it('should return traffic trends for 24h', async () => { const mockTrendData = { upload: { '1234567800': 1000, '1234567900': 2000 }, download: { '1234567800': 5000, '1234567900': 6000 }, totalUpload: 3000, totalDownload: 11000, block: { '1234567800': { percent: 5, total: 100, blocked: 5 }, '1234567900': { percent: 10, total: 200, blocked: 20 }, }, totalConn: 300, totalIpB: 10, totalDnsB: 15, }; mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockTrendData, }); const server = new FirewallaMCPServer(); const result = await (server as any).getTrafficTrends({ period: '24h' }); expect(result.content[0].text).toContain('Traffic trends analysis for 24h'); const analysis = JSON.parse(result.content[1].text); expect(analysis.summary.totalUpload).toBe(3000); expect(analysis.summary.totalDownload).toBe(11000); }); it('should default to 24h period when not specified', async () => { const mockTrendData = { upload: {}, download: {}, totalUpload: 0, totalDownload: 0, block: {}, totalConn: 0, totalIpB: 0, totalDnsB: 0, }; mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockTrendData, }); const server = new FirewallaMCPServer(); const result = await (server as any).getTrafficTrends({}); expect(result.content[0].text).toContain('24h'); }); }); describe('getFirewallaStatus', () => { it('should throw error when API not configured', async () => { delete process.env[ENV_FIREWALLA_URL]; delete process.env[ENV_FIREWALLA_TOKEN]; delete process.env[ENV_FIREWALLA_ID]; const server = new FirewallaMCPServer(); await expect((server as any).getFirewallaStatus()).rejects.toThrow('not configured'); }); it('should return Firewalla device status', async () => { const mockBoxes = [ { name: 'Firewalla Gold', model: 'gold', gid: 'gid-1', eid: 'eid-1', status: true, activeTs: 1234567890, syncTs: 1234567880, lokiEnabled: true, }, ]; mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockBoxes, }); const server = new FirewallaMCPServer(); const result = await (server as any).getFirewallaStatus(); expect(result.content[0].text).toContain('Firewalla device status (1 device)'); const status = JSON.parse(result.content[1].text); expect(status[0].name).toBe('Firewalla Gold'); expect(status[0].status).toBe('Online'); }); it('should handle multiple devices', async () => { const mockBoxes = [ { name: 'Firewalla Gold', model: 'gold', gid: 'gid-1', eid: 'eid-1', status: true, activeTs: 1234567890, syncTs: 1234567880, lokiEnabled: true, }, { name: 'Firewalla Purple', model: 'purple', gid: 'gid-2', eid: 'eid-2', status: false, activeTs: 1234567800, syncTs: 1234567790, lokiEnabled: false, }, ]; mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockBoxes, }); const server = new FirewallaMCPServer(); const result = await (server as any).getFirewallaStatus(); expect(result.content[0].text).toContain('2 devices'); const status = JSON.parse(result.content[1].text); expect(status[1].status).toBe('Offline'); }); }); describe('updateDeviceRules', () => { it('should throw error when API not configured', async () => { delete process.env[ENV_FIREWALLA_URL]; delete process.env[ENV_FIREWALLA_TOKEN]; delete process.env[ENV_FIREWALLA_ID]; const server = new FirewallaMCPServer(); await expect( (server as any).updateDeviceRules({ deviceMac: 'AA:BB:CC:DD:EE:FF', action: 'block', target: 'youtube', }) ).rejects.toThrow('not configured'); }); it('should update device rules successfully', async () => { const mockDevices: Device[] = [ { ip: '192.168.1.100', mac: 'AA:BB:CC:DD:EE:FF', lastActive: 1234567890, firstFound: 1234567800, macVendor: 'Apple', bname: 'iPhone', names: ['iPhone'], policy: { deviceTags: [], userTags: [], tags: [], ssidTags: [] }, name: 'iPhone', deviceType: 'phone', intf: { uuid: 'uuid-1', name: 'LAN' }, totalUpload: 1000, totalDownload: 2000, gid: 'gid-1', online: true, }, ]; const mockBoxes = [ { name: 'Firewalla Gold', model: 'gold', gid: 'gid-1', eid: 'eid-1', status: true, activeTs: 1234567890, syncTs: 1234567880, lokiEnabled: true, }, ]; mockFetch .mockResolvedValueOnce({ ok: true, json: async () => mockDevices, }) .mockResolvedValueOnce({ ok: true, json: async () => mockBoxes, }) .mockResolvedValueOnce({ ok: true, json: async () => ({ success: true }), }); const server = new FirewallaMCPServer(); const result = await (server as any).updateDeviceRules({ deviceMac: 'AA:BB:CC:DD:EE:FF', action: 'block', target: 'youtube', type: 'category', }); expect(result.content[0].text).toContain('Successfully blocked youtube'); expect(result.content[0].text).toContain('iPhone'); }); it('should throw error when no Firewalla devices found', async () => { const mockDevices: Device[] = [ { ip: '192.168.1.100', mac: 'AA:BB:CC:DD:EE:FF', lastActive: 1234567890, firstFound: 1234567800, macVendor: 'Apple', bname: 'iPhone', names: ['iPhone'], policy: { deviceTags: [], userTags: [], tags: [], ssidTags: [] }, name: 'iPhone', deviceType: 'phone', intf: { uuid: 'uuid-1', name: 'LAN' }, totalUpload: 1000, totalDownload: 2000, gid: 'gid-1', online: true, }, ]; mockFetch .mockResolvedValueOnce({ ok: true, json: async () => mockDevices, }) .mockResolvedValueOnce({ ok: true, json: async () => [], }); const server = new FirewallaMCPServer(); await expect( (server as any).updateDeviceRules({ deviceMac: 'AA:BB:CC:DD:EE:FF', action: 'allow', target: 'netflix', }) ).rejects.toThrow('No Firewalla devices found'); }); }); }); describe('Traffic trend helper methods', () => { let server: FirewallaMCPServer; beforeEach(() => { mockStderrWrite.mockClear(); server = new FirewallaMCPServer(); }); describe('findTrafficPeaks', () => { it('should find peak upload and download times', () => { const trendData = { upload: { '1234567800': 1000, '1234567900': 5000, '1234568000': 2000 }, download: { '1234567800': 3000, '1234567900': 2000, '1234568000': 8000 }, totalUpload: 8000, totalDownload: 13000, block: {}, totalConn: 0, totalIpB: 0, totalDnsB: 0, }; const result = (server as any).findTrafficPeaks(trendData); expect(result.peakUpload.bytes).toBe(5000); expect(result.peakDownload.bytes).toBe(8000); }); it('should handle empty trend data', () => { const trendData = { upload: {}, download: {}, totalUpload: 0, totalDownload: 0, block: {}, totalConn: 0, totalIpB: 0, totalDnsB: 0, }; const result = (server as any).findTrafficPeaks(trendData); expect(result.peakUpload.bytes).toBe(0); expect(result.peakDownload.bytes).toBe(0); }); }); describe('calculateBlockingEfficiency', () => { it('should calculate blocking efficiency correctly', () => { const trendData = { upload: {}, download: {}, totalUpload: 0, totalDownload: 0, block: { '1234567800': { percent: 10, total: 100, blocked: 10 }, '1234567900': { percent: 20, total: 200, blocked: 40 }, }, totalConn: 300, totalIpB: 0, totalDnsB: 0, }; const result = (server as any).calculateBlockingEfficiency(trendData); expect(result.totalConnections).toBe(300); expect(result.totalBlocked).toBe(50); expect(parseFloat(result.efficiencyPercent)).toBeCloseTo(16.67, 1); expect(parseFloat(result.averageBlockRate)).toBeCloseTo(15, 0); }); it('should handle zero connections', () => { const trendData = { upload: {}, download: {}, totalUpload: 0, totalDownload: 0, block: {}, totalConn: 0, totalIpB: 0, totalDnsB: 0, }; const result = (server as any).calculateBlockingEfficiency(trendData); expect(result.totalConnections).toBe(0); expect(result.efficiencyPercent).toBe('0.00'); expect(result.averageBlockRate).toBe('0.00'); }); }); }); describe('FirewallaMCPServer analyze traffic', () => { const originalEnv = process.env; beforeEach(() => { process.env = { ...originalEnv }; process.env[ENV_FIREWALLA_URL] = 'https://my.firewalla.com'; process.env[ENV_FIREWALLA_TOKEN] = 'test-token'; process.env[ENV_FIREWALLA_ID] = 'test-id'; mockFetch.mockReset(); mockStderrWrite.mockClear(); }); afterEach(() => { process.env = originalEnv; }); const mockFlows: FlowRecord[] = [ { ts: 1234567890, fd: 'out', count: 1, intf: 'eth0', protocol: 'tcp', port: 443, devicePort: 54321, ip: '1.2.3.4', deviceIP: '192.168.1.100', device: 'AA:BB:CC:DD:EE:FF', country: 'US', tags: [], tagIds: [], networkName: 'LAN', onWan: true, upload: 1000, download: 5000, }, ]; describe('analyzeNetworkTraffic', () => { it('should analyze traffic summary', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockFlows, }); const server = new FirewallaMCPServer(); const result = await (server as any).analyzeNetworkTraffic({ startTime: 1234567800, endTime: 1234567900, analysisType: 'summary', }); expect(result.content[0].text).toContain('Network traffic analysis'); expect(result.content[0].text).toContain('summary'); }); it('should analyze top talkers', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockFlows, }); const server = new FirewallaMCPServer(); const result = await (server as any).analyzeNetworkTraffic({ startTime: 1234567800, endTime: 1234567900, analysisType: 'top_talkers', }); expect(result.content[0].text).toContain('top_talkers'); }); it('should analyze blocked traffic', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockFlows, }); const server = new FirewallaMCPServer(); const result = await (server as any).analyzeNetworkTraffic({ startTime: 1234567800, endTime: 1234567900, analysisType: 'blocked_traffic', }); expect(result.content[0].text).toContain('blocked_traffic'); }); it('should analyze security events', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: async () => mockFlows, }); const server = new FirewallaMCPServer(); const result = await (server as any).analyzeNetworkTraffic({ startTime: 1234567800, endTime: 1234567900, analysisType: 'security_events', }); expect(result.content[0].text).toContain('security_events'); }); }); describe('getDeviceTraffic', () => { it('should return device traffic analysis', async () => { const mockDevices: Device[] = [ { ip: '192.168.1.100', mac: 'AA:BB:CC:DD:EE:FF', lastActive: 1234567890, firstFound: 1234567800, macVendor: 'Apple', bname: 'iPhone', names: ['iPhone'], policy: { deviceTags: [], userTags: [], tags: [], ssidTags: [] }, name: 'iPhone', deviceType: 'phone', intf: { uuid: 'uuid-1', name: 'LAN' }, totalUpload: 1000, totalDownload: 2000, gid: 'gid-1', online: true, }, ]; mockFetch .mockResolvedValueOnce({ ok: true, json: async () => mockDevices, }) .mockResolvedValueOnce({ ok: true, json: async () => mockFlows, }); const server = new FirewallaMCPServer(); const result = await (server as any).getDeviceTraffic({ mac: 'AA:BB:CC:DD:EE:FF', startTime: 1234567800, endTime: 1234567900, }); expect(result.content[0].text).toContain('Traffic analysis for device iPhone'); }); }); });

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/drewrad8/mcps'

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