import { describe, it, expect, vi, beforeEach } from 'vitest'; import { McpRouter } from '../src/router.js'; import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from '../src/types.js'; import type { McpdClient } from '../src/http/mcpd-client.js'; function mockUpstream(name: string, opts?: { tools?: Array<{ name: string; description?: string; inputSchema?: unknown }>; }): UpstreamConnection { return { name, isAlive: vi.fn(() => true), close: vi.fn(async () => {}), onNotification: vi.fn(), send: vi.fn(async (req: JsonRpcRequest): Promise => { if (req.method === 'tools/list') { return { jsonrpc: '2.0', id: req.id, result: { tools: opts?.tools ?? [] } }; } if (req.method === 'resources/list') { return { jsonrpc: '2.0', id: req.id, result: { resources: [] } }; } return { jsonrpc: '2.0', id: req.id, result: {} }; }), }; } function mockMcpdClient(): McpdClient { return { get: vi.fn(async () => []), post: vi.fn(async () => ({})), put: vi.fn(async () => ({})), delete: vi.fn(async () => {}), forward: vi.fn(async () => ({ status: 200, body: {} })), withHeaders: vi.fn(function (this: McpdClient) { return this; }), } as unknown as McpdClient; } describe('McpRouter - Prompt Integration', () => { let router: McpRouter; let mcpdClient: McpdClient; beforeEach(() => { router = new McpRouter(); mcpdClient = mockMcpdClient(); }); describe('propose_prompt tool', () => { it('should include propose_prompt in tools/list when prompt config is set', async () => { router.setPromptConfig(mcpdClient, 'test-project'); router.addUpstream(mockUpstream('server1')); const response = await router.route({ jsonrpc: '2.0', id: 1, method: 'tools/list', }); const tools = (response.result as { tools: Array<{ name: string }> }).tools; expect(tools.some((t) => t.name === 'propose_prompt')).toBe(true); }); it('should NOT include propose_prompt when no prompt config', async () => { router.addUpstream(mockUpstream('server1')); const response = await router.route({ jsonrpc: '2.0', id: 1, method: 'tools/list', }); const tools = (response.result as { tools: Array<{ name: string }> }).tools; expect(tools.some((t) => t.name === 'propose_prompt')).toBe(false); }); it('should call mcpd to create a prompt request', async () => { router.setPromptConfig(mcpdClient, 'my-project'); const response = await router.route( { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'propose_prompt', arguments: { name: 'my-prompt', content: 'Hello world' }, }, }, { sessionId: 'sess-123' }, ); expect(response.error).toBeUndefined(); expect(mcpdClient.post).toHaveBeenCalledWith( '/api/v1/projects/my-project/promptrequests', { name: 'my-prompt', content: 'Hello world', createdBySession: 'sess-123' }, ); }); it('should return error when name or content missing', async () => { router.setPromptConfig(mcpdClient, 'proj'); const response = await router.route({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'propose_prompt', arguments: { name: 'only-name' }, }, }); expect(response.error?.code).toBe(-32602); expect(response.error?.message).toContain('Missing required arguments'); }); it('should return error when mcpd call fails', async () => { router.setPromptConfig(mcpdClient, 'proj'); vi.mocked(mcpdClient.post).mockRejectedValue(new Error('mcpd returned 409')); const response = await router.route({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'propose_prompt', arguments: { name: 'dup', content: 'x' }, }, }); expect(response.error?.code).toBe(-32603); expect(response.error?.message).toContain('mcpd returned 409'); }); }); describe('prompt resources', () => { it('should include prompt resources in resources/list', async () => { router.setPromptConfig(mcpdClient, 'test-project'); vi.mocked(mcpdClient.get).mockResolvedValue([ { name: 'approved-prompt', content: 'Content A', type: 'prompt' }, { name: 'pending-req', content: 'Content B', type: 'promptrequest' }, ]); const response = await router.route( { jsonrpc: '2.0', id: 1, method: 'resources/list' }, { sessionId: 'sess-1' }, ); const resources = (response.result as { resources: Array<{ uri: string; description?: string }> }).resources; expect(resources).toHaveLength(2); expect(resources[0]!.uri).toBe('mcpctl://prompts/approved-prompt'); expect(resources[0]!.description).toContain('Approved'); expect(resources[1]!.uri).toBe('mcpctl://prompts/pending-req'); expect(resources[1]!.description).toContain('Pending'); }); it('should pass session ID when fetching visible prompts', async () => { router.setPromptConfig(mcpdClient, 'proj'); vi.mocked(mcpdClient.get).mockResolvedValue([]); await router.route( { jsonrpc: '2.0', id: 1, method: 'resources/list' }, { sessionId: 'my-session' }, ); expect(mcpdClient.get).toHaveBeenCalledWith( '/api/v1/projects/proj/prompts/visible?session=my-session', ); }); it('should read mcpctl resource content', async () => { router.setPromptConfig(mcpdClient, 'proj'); vi.mocked(mcpdClient.get).mockResolvedValue([ { name: 'my-prompt', content: 'The content here', type: 'prompt' }, ]); // First list to populate cache await router.route({ jsonrpc: '2.0', id: 1, method: 'resources/list' }); // Then read const response = await router.route({ jsonrpc: '2.0', id: 2, method: 'resources/read', params: { uri: 'mcpctl://prompts/my-prompt' }, }); expect(response.error).toBeUndefined(); const contents = (response.result as { contents: Array<{ text: string }> }).contents; expect(contents[0]!.text).toBe('The content here'); }); it('should return error for unknown mcpctl resource', async () => { router.setPromptConfig(mcpdClient, 'proj'); const response = await router.route({ jsonrpc: '2.0', id: 3, method: 'resources/read', params: { uri: 'mcpctl://prompts/nonexistent' }, }); expect(response.error?.code).toBe(-32602); expect(response.error?.message).toContain('Resource not found'); }); it('should not fail when mcpd is unavailable', async () => { router.setPromptConfig(mcpdClient, 'proj'); vi.mocked(mcpdClient.get).mockRejectedValue(new Error('Connection refused')); const response = await router.route({ jsonrpc: '2.0', id: 1, method: 'resources/list' }); // Should succeed with empty resources (upstream errors are swallowed) expect(response.error).toBeUndefined(); const resources = (response.result as { resources: unknown[] }).resources; expect(resources).toEqual([]); }); }); describe('session isolation', () => { it('should not include session parameter when no sessionId in context', async () => { router.setPromptConfig(mcpdClient, 'proj'); vi.mocked(mcpdClient.get).mockResolvedValue([]); await router.route({ jsonrpc: '2.0', id: 1, method: 'resources/list' }); expect(mcpdClient.get).toHaveBeenCalledWith( '/api/v1/projects/proj/prompts/visible', ); }); it('should not include session in propose when no context', async () => { router.setPromptConfig(mcpdClient, 'proj'); await router.route({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'propose_prompt', arguments: { name: 'test', content: 'stuff' }, }, }); expect(mcpdClient.post).toHaveBeenCalledWith( '/api/v1/projects/proj/promptrequests', { name: 'test', content: 'stuff' }, ); }); }); });