import { describe, it, expect, vi, beforeEach } from 'vitest'; import Fastify from 'fastify'; import type { FastifyInstance } from 'fastify'; import { registerProjectMcpEndpoint } from '../src/http/project-mcp-endpoint.js'; // Mock discovery module — we don't want real HTTP calls vi.mock('../src/discovery.js', () => ({ refreshProjectUpstreams: vi.fn(async () => ['mock-server']), })); import { refreshProjectUpstreams } from '../src/discovery.js'; function mockMcpdClient() { return { baseUrl: 'http://test:3100', token: 'test-token', get: vi.fn(async () => []), post: vi.fn(async () => ({})), put: vi.fn(), delete: vi.fn(), forward: vi.fn(async () => ({ status: 200, body: [] })), }; } describe('registerProjectMcpEndpoint', () => { let app: FastifyInstance; beforeEach(async () => { vi.clearAllMocks(); app = Fastify(); registerProjectMcpEndpoint(app, mockMcpdClient() as any); await app.ready(); }); it('registers POST /projects/:projectName/mcp route', async () => { // The endpoint should exist and attempt to handle MCP protocol const res = await app.inject({ method: 'POST', url: '/projects/smart-home/mcp', payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, headers: { 'content-type': 'application/json' }, }); // The StreamableHTTPServerTransport hijacks the response, // so we may get a 200 or the transport handles it directly expect(res.statusCode).not.toBe(404); }); it('calls refreshProjectUpstreams with project name', async () => { await app.inject({ method: 'POST', url: '/projects/smart-home/mcp', payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, headers: { 'content-type': 'application/json' }, }); expect(refreshProjectUpstreams).toHaveBeenCalledWith( expect.any(Object), // McpRouter instance expect.any(Object), // McpdClient 'smart-home', undefined, // no auth token ); }); it('forwards auth token from Authorization header', async () => { await app.inject({ method: 'POST', url: '/projects/secure-project/mcp', payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, headers: { 'content-type': 'application/json', 'authorization': 'Bearer my-token-123', }, }); expect(refreshProjectUpstreams).toHaveBeenCalledWith( expect.any(Object), expect.any(Object), 'secure-project', 'my-token-123', ); }); it('returns 502 when project discovery fails', async () => { vi.mocked(refreshProjectUpstreams).mockRejectedValueOnce(new Error('Forbidden')); const res = await app.inject({ method: 'POST', url: '/projects/bad-project/mcp', payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, headers: { 'content-type': 'application/json' }, }); expect(res.statusCode).toBe(502); expect(res.json().error).toContain('Failed to load project'); }); it('returns 404 for unknown session ID', async () => { const res = await app.inject({ method: 'POST', url: '/projects/smart-home/mcp', payload: { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }, headers: { 'content-type': 'application/json', 'mcp-session-id': 'nonexistent-session', }, }); expect(res.statusCode).toBe(404); }); it('returns 400 for GET without session', async () => { const res = await app.inject({ method: 'GET', url: '/projects/smart-home/mcp', }); expect(res.statusCode).toBe(400); expect(res.json().error).toContain('session'); }); it('returns 400 for DELETE without session', async () => { const res = await app.inject({ method: 'DELETE', url: '/projects/smart-home/mcp', }); expect(res.statusCode).toBe(400); expect(res.json().error).toContain('session'); }); it('caches project router across requests', async () => { // Two requests to the same project should reuse the router await app.inject({ method: 'POST', url: '/projects/smart-home/mcp', payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, headers: { 'content-type': 'application/json' }, }); await app.inject({ method: 'POST', url: '/projects/smart-home/mcp', payload: { jsonrpc: '2.0', id: 2, method: 'initialize', params: {} }, headers: { 'content-type': 'application/json' }, }); // refreshProjectUpstreams should only be called once (cached) expect(refreshProjectUpstreams).toHaveBeenCalledTimes(1); }); it('creates separate routers for different projects', async () => { await app.inject({ method: 'POST', url: '/projects/project-a/mcp', payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, headers: { 'content-type': 'application/json' }, }); await app.inject({ method: 'POST', url: '/projects/project-b/mcp', payload: { jsonrpc: '2.0', id: 2, method: 'initialize', params: {} }, headers: { 'content-type': 'application/json' }, }); // Two different projects should trigger two refreshes expect(refreshProjectUpstreams).toHaveBeenCalledTimes(2); expect(refreshProjectUpstreams).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), 'project-a', undefined); expect(refreshProjectUpstreams).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), 'project-b', undefined); }); });