import { EventEmitter } from 'node:events'; import type { FastMCP } from 'fastmcp'; const expressInstances: Array<{ app: { use: jest.Mock; listen: jest.Mock }; use: jest.Mock; listen: jest.Mock; server: { on: jest.Mock; close: jest.Mock }; }> = []; const expressMock = jest.fn(() => { const use = jest.fn(); const on = jest.fn(); const close = jest.fn((callback?: (error?: Error | null) => void) => { callback?.call(server, undefined); }); const server = { on, close }; const listen = jest.fn((port: number, host: string, callback?: () => void) => { callback?.call(server); return server; }); const app = { use, listen }; expressInstances.push({ app, use, listen, server }); return app; }); jest.mock('express', () => ({ __esModule: true, default: expressMock })); const corsMiddleware = jest.fn(); const corsMock = jest.fn(() => corsMiddleware); jest.mock('cors', () => ({ __esModule: true, default: corsMock })); const findAvailablePortMock = jest.fn, [number, ('up' | 'down')?]>(); jest.mock('../findAvailablePort', () => ({ __esModule: true, findAvailablePortSequential: (...args: [number, ('up' | 'down')?]) => findAvailablePortMock(...args) })); import { startFastMcpHttpProxy } from '../startFastMcpHttpProxy'; describe('startFastMcpHttpProxy', () => { beforeEach(() => { expressMock.mockClear(); expressInstances.length = 0; corsMock.mockClear(); corsMiddleware.mockClear(); findAvailablePortMock.mockReset(); }); it('starts FastMCP server, configures express, and wires proxy handler', async () => { const startMock = jest.fn().mockResolvedValue(undefined); const server = { start: startMock } as unknown as FastMCP; const logger = { info: jest.fn(), error: jest.fn() }; const httpRequest = jest.fn((options: any, callback: any) => { const proxyRes: any = new EventEmitter(); proxyRes.statusCode = 200; proxyRes.headers = { 'content-type': 'application/json' }; proxyRes.pipe = jest.fn(); callback(proxyRes); return { on: jest.fn(), destroy: jest.fn(), write: jest.fn(), end: jest.fn() } as any; }); const configureApp = jest.fn(); const result = await startFastMcpHttpProxy({ server, listenPort: 8080, host: '127.0.0.1', logger, proxyPath: '/proxy', httpModule: { request: httpRequest } as any, loopbackPort: 5000, configureApp }); expect(startMock).toHaveBeenCalledWith({ transportType: 'httpStream', httpStream: { port: 5000, host: '127.0.0.1', stateless: true, enableJsonResponse: true } }); expect(logger.info).toHaveBeenCalledWith('✅ FastMCP internal server started on port 5000'); expect(logger.info).toHaveBeenCalledWith('✅ Express server listening on 127.0.0.1:8080'); const instance = expressInstances[0]; expect(result.app).toBe(instance.app); expect(result.expressServer).toBe(instance.server as any); expect(result.mcpPort).toBe(5000); expect(configureApp).toHaveBeenCalledWith(instance.app); expect(instance.listen).toHaveBeenCalledWith(8080, '127.0.0.1', expect.any(Function)); expect(instance.server.on).toHaveBeenCalledWith('error', expect.any(Function)); const proxyHandler = instance.use.mock.calls.find((call) => call[0] === '/proxy')?.[1]; expect(typeof proxyHandler).toBe('function'); const reqListeners: Record void>> = {}; const req: any = { method: 'POST', headers: { 'content-type': 'application/json' }, originalUrl: '/proxy', on: jest.fn((event: string, handler: (...args: any[]) => void) => { reqListeners[event] = reqListeners[event] ?? []; reqListeners[event].push(handler); }) }; const res: any = { writeHead: jest.fn(), headersSent: false, status: jest.fn(function (code: number) { this.statusCode = code; return this; }), json: jest.fn(), setHeader: jest.fn(), end: jest.fn() }; proxyHandler(req, res); reqListeners.data?.forEach((handler) => handler(Buffer.from('{"jsonrpc":"2.0"}'))); reqListeners.end?.forEach((handler) => handler()); expect(httpRequest).toHaveBeenCalledWith({ hostname: '127.0.0.1', port: 5000, path: '/proxy', method: 'POST', headers: req.headers }, expect.any(Function)); }); it('applies cors middleware when options provided and handles proxy errors', async () => { const startMock = jest.fn().mockResolvedValue(undefined); const server = { start: startMock } as unknown as FastMCP; const logger = { info: jest.fn(), error: jest.fn() }; findAvailablePortMock.mockResolvedValue(45000); let proxyErrorHandler: ((error: Error) => void) | undefined; const httpRequest = jest.fn((_options: any, _callback: any) => { return { on: jest.fn((event: string, handler: (error: Error) => void) => { if (event === 'error') { proxyErrorHandler = handler; } }), destroy: jest.fn(), write: jest.fn(), end: jest.fn() } as any; }); const result = await startFastMcpHttpProxy({ server, listenPort: 9000, logger, corsOptions: { origin: '*' }, httpModule: { request: httpRequest } as any }); expect(corsMock).toHaveBeenCalledWith({ origin: '*' }); const instance = expressInstances[0]; expect(instance.use).toHaveBeenCalledWith(corsMiddleware); expect(typeof result.mcpPort).toBe('number'); const proxyHandler = instance.use.mock.calls.find((call) => typeof call[1] === 'function')?.[1]; const reqListeners: Record void>> = {}; const req: any = { method: 'GET', headers: {}, originalUrl: undefined, on: jest.fn((event: string, handler: (...args: any[]) => void) => { reqListeners[event] = reqListeners[event] ?? []; reqListeners[event].push(handler); }) }; const res: any = { headersSent: false, statusCode: 0, writeHead: jest.fn(), setHeader: jest.fn(), end: jest.fn() }; proxyHandler(req, res); reqListeners.end?.forEach((handler) => handler()); proxyErrorHandler?.(new Error('proxy failure')); expect(logger.error).toHaveBeenCalledWith('Proxy error', expect.any(Error)); expect(res.statusCode).toBe(502); expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/json'); expect(res.end).toHaveBeenCalled(); // Verify the response body contains the error const responseBody = res.end.mock.calls[0][0]; const parsedResponse = JSON.parse(responseBody); expect(parsedResponse).toMatchObject({ error: 'Bad Gateway', message: 'Failed to proxy request to MCP server' }); }); it('falls back to scanning ports when allowed and reserved port is busy', async () => { const eaddr = Object.assign(new Error('listen EADDRINUSE'), { code: 'EADDRINUSE' }); findAvailablePortMock.mockResolvedValue(45010); const startMock = jest.fn() .mockRejectedValueOnce(eaddr) .mockResolvedValue(undefined); const server = { start: startMock } as unknown as FastMCP; const logger = { info: jest.fn(), error: jest.fn() }; await startFastMcpHttpProxy({ server, listenPort: 8080, logger, allowLoopbackFallback: true, startPort: 45005, httpModule: { request: jest.fn(() => ({ on: jest.fn(), destroy: jest.fn() })) } as any }); expect(startMock).toHaveBeenCalledTimes(2); expect(findAvailablePortMock).toHaveBeenCalledTimes(2); expect(startMock).toHaveBeenCalledWith(expect.objectContaining({ httpStream: expect.objectContaining({ port: 45010 }) })); }); it('blocks direct convo.* tool access with JSON-RPC error', async () => { // Mock findAvailablePort to return a specific port instead of real port discovery findAvailablePortMock.mockResolvedValue(45020); const startMock = jest.fn().mockResolvedValue(undefined); const server = { start: startMock } as unknown as FastMCP; const logger = { info: jest.fn(), error: jest.fn() }; const httpRequest = jest.fn(); await startFastMcpHttpProxy({ server, listenPort: 7000, logger, httpModule: { request: httpRequest } as any }); const instance = expressInstances[0]; const proxyHandler = instance.use.mock.calls.find((call) => typeof call[1] === 'function')?.[1]; expect(typeof proxyHandler).toBe('function'); const reqListeners: Record void>> = {}; const req: any = { method: 'POST', headers: { 'content-type': 'application/json' }, originalUrl: '/mcp', on: jest.fn((event: string, handler: (...args: any[]) => void) => { reqListeners[event] = reqListeners[event] ?? []; reqListeners[event].push(handler); }) }; const res: any = { headersSent: false, statusCode: 0, setHeader: jest.fn(), end: jest.fn() }; proxyHandler(req, res); const payload = JSON.stringify({ jsonrpc: '2.0', method: 'tools/call', params: { name: 'convo.list' }, id: 1 }); reqListeners.data?.forEach((handler) => handler(Buffer.from(payload))); reqListeners.end?.forEach((handler) => handler()); expect(res.statusCode).toBe(400); expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/json'); expect(res.end).toHaveBeenCalled(); // Verify the response body contains the error const responseBody = res.end.mock.calls[0][0]; const parsedResponse = JSON.parse(responseBody); expect(parsedResponse).toMatchObject({ jsonrpc: '2.0', id: 1, error: { code: -32601, message: expect.stringContaining('convo.list') } }); expect(httpRequest).not.toHaveBeenCalled(); }); it('closes the Express server when FastMCP startup fails', async () => { const startMock = jest.fn().mockRejectedValue(new Error('mcp boom')); const server = { start: startMock } as unknown as FastMCP; const logger = { info: jest.fn(), error: jest.fn(), warn: jest.fn() }; await expect(startFastMcpHttpProxy({ server, listenPort: 6060, logger, httpModule: { request: jest.fn(() => ({ on: jest.fn(), destroy: jest.fn() })) } as any, loopbackPort: 45123, allowLoopbackFallback: false })).rejects.toThrow('mcp boom'); const instance = expressInstances[0]; expect(instance.server.close).toHaveBeenCalledTimes(1); }); });