import { describe, it, expect, afterAll, afterEach } from 'vitest'; import http from 'node:http'; import { McpdClient, ConnectionError } from '../src/http/mcpd-client.js'; /** * Create a local HTTP server for testing McpdClient behavior. * Returns the server and its URL. */ function createTestServer( handler: (req: http.IncomingMessage, res: http.ServerResponse) => void, ): Promise<{ server: http.Server; url: string }> { return new Promise((resolve) => { const server = http.createServer(handler); server.listen(0, '127.0.0.1', () => { const addr = server.address() as { port: number }; resolve({ server, url: `http://127.0.0.1:${addr.port}` }); }); }); } describe('McpdClient', () => { const servers: http.Server[] = []; afterEach(() => { for (const s of servers) s.close(); servers.length = 0; }); afterAll(() => { for (const s of servers) s.close(); }); it('makes GET requests with auth header', async () => { let capturedAuth = ''; const { server, url } = await createTestServer((req, res) => { capturedAuth = req.headers['authorization'] ?? ''; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); }); servers.push(server); const client = new McpdClient(url, 'my-token'); const result = await client.get<{ ok: boolean }>('/api/v1/test'); expect(result).toEqual({ ok: true }); expect(capturedAuth).toBe('Bearer my-token'); }); it('makes POST requests with JSON body', async () => { let capturedBody = ''; const { server, url } = await createTestServer((req, res) => { const chunks: Buffer[] = []; req.on('data', (c: Buffer) => chunks.push(c)); req.on('end', () => { capturedBody = Buffer.concat(chunks).toString(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ received: true })); }); }); servers.push(server); const client = new McpdClient(url, 'tok'); const result = await client.post<{ received: boolean }>('/api/v1/proxy', { serverId: 's1' }); expect(result).toEqual({ received: true }); expect(JSON.parse(capturedBody)).toEqual({ serverId: 's1' }); }); it('throws ConnectionError on connection refused', async () => { const client = new McpdClient('http://127.0.0.1:1', 'tok'); await expect(client.get('/test')).rejects.toThrow(ConnectionError); }); it('throws on 4xx/5xx responses', async () => { const { server, url } = await createTestServer((_req, res) => { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'internal' })); }); servers.push(server); const client = new McpdClient(url, 'tok'); await expect(client.get('/test')).rejects.toThrow(/mcpd returned 500/); }); // ── Timeout behavior ── it('times out on slow responses and throws ConnectionError', async () => { const { server, url } = await createTestServer((_req, _res) => { // Never respond — simulates a hanging upstream tool call }); servers.push(server); // Use a very short timeout for the test const client = new McpdClient(url, 'tok', undefined, 500); const start = Date.now(); await expect(client.post('/api/v1/mcp/proxy', { serverId: 's1' })).rejects.toThrow( /timed out/, ); const elapsed = Date.now() - start; // Should have timed out around 500ms, not hung for seconds expect(elapsed).toBeGreaterThanOrEqual(450); expect(elapsed).toBeLessThan(3000); }); it('timeout error is a ConnectionError with descriptive message', async () => { const { server, url } = await createTestServer((_req, _res) => { // Never respond }); servers.push(server); const client = new McpdClient(url, 'tok', undefined, 200); try { await client.get('/test'); expect.unreachable('Should have thrown'); } catch (err) { expect(err).toBeInstanceOf(ConnectionError); expect((err as Error).message).toContain('Request timed out after 200ms'); } }); it('fast responses succeed within the timeout window', async () => { const { server, url } = await createTestServer((_req, res) => { // Respond immediately res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ fast: true })); }); servers.push(server); // Short timeout, but response is immediate — should work const client = new McpdClient(url, 'tok', undefined, 500); const result = await client.get<{ fast: boolean }>('/test'); expect(result).toEqual({ fast: true }); }); it('withHeaders preserves timeout', async () => { const { server, url } = await createTestServer((_req, _res) => { // Never respond }); servers.push(server); const client = new McpdClient(url, 'tok', undefined, 300); const derived = client.withHeaders({ 'X-Custom': 'val' }); const start = Date.now(); await expect(derived.get('/test')).rejects.toThrow(/timed out/); const elapsed = Date.now() - start; expect(elapsed).toBeLessThan(2000); }); it('default timeout is 30 seconds', async () => { // We can't wait 30s in a test, but we can verify the error message format // when a custom timeout is not set. Use a fast-failing server instead. const { server, url } = await createTestServer((_req, res) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); }); servers.push(server); // Default constructor — should work for fast responses const client = new McpdClient(url, 'tok'); const result = await client.get<{ ok: boolean }>('/test'); expect(result).toEqual({ ok: true }); }); });