import { describe, it, expect, vi } from 'vitest'; import { ChatToolDispatcherImpl } from '../src/services/chat-tool-dispatcher.js'; import { TOOL_NAME_SEPARATOR } from '../src/services/chat.service.js'; import type { McpProxyService } from '../src/services/mcp-proxy-service.js'; import type { IProjectRepository, ProjectWithRelations } from '../src/repositories/project.repository.js'; const NOW = new Date(); function makeProject(overrides: Partial = {}): ProjectWithRelations { return { id: 'proj-1', name: 'mcpctl-dev', description: '', prompt: '', proxyModel: '', gated: true, llmProvider: null, llmModel: null, serverOverrides: null, ownerId: 'owner-1', version: 1, createdAt: NOW, updatedAt: NOW, servers: [], ...overrides, }; } function mockProjectRepo(p: ProjectWithRelations | null): IProjectRepository { return { findById: vi.fn(async () => p), findAll: vi.fn(), findByName: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), } as unknown as IProjectRepository; } describe('ChatToolDispatcherImpl', () => { it('returns [] when project has no MCP servers', async () => { const proxy = { execute: vi.fn() } as unknown as McpProxyService; const dispatcher = new ChatToolDispatcherImpl({ proxy, projects: mockProjectRepo(makeProject()), }); const tools = await dispatcher.listTools('proj-1'); expect(tools).toEqual([]); expect(proxy.execute).not.toHaveBeenCalled(); }); it('returns [] when projectId is null (unattached agent)', async () => { const proxy = { execute: vi.fn() } as unknown as McpProxyService; const dispatcher = new ChatToolDispatcherImpl({ proxy, projects: mockProjectRepo(null), }); expect(await dispatcher.listTools(null)).toEqual([]); }); it('namespaces tools as `__` and forwards inputSchema', async () => { const proxy = { execute: vi.fn(async () => ({ jsonrpc: '2.0' as const, id: 1, result: { tools: [ { name: 'query', description: 'do a query', inputSchema: { type: 'object', properties: { q: { type: 'string' } } } }, { name: 'ping' }, ], }, })), } as unknown as McpProxyService; const dispatcher = new ChatToolDispatcherImpl({ proxy, projects: mockProjectRepo(makeProject({ servers: [{ id: 'ps-1', projectId: 'proj-1', serverId: 'srv-grafana', server: { id: 'srv-grafana', name: 'grafana' }, }], })), }); const tools = await dispatcher.listTools('proj-1'); expect(tools.map((t) => t.name)).toEqual([ `grafana${TOOL_NAME_SEPARATOR}query`, `grafana${TOOL_NAME_SEPARATOR}ping`, ]); expect(tools[0]!.parameters).toEqual({ type: 'object', properties: { q: { type: 'string' } } }); // The 'ping' tool with no inputSchema gets a permissive default. expect(tools[1]!.parameters).toEqual({ type: 'object', properties: {} }); }); it('skips servers whose tools/list errors out', async () => { const warn = vi.fn(); const proxy = { execute: vi.fn(async ({ serverId }: { serverId: string }) => { if (serverId === 'srv-bad') { return { jsonrpc: '2.0' as const, id: 1, error: { code: -1, message: 'boom' } }; } return { jsonrpc: '2.0' as const, id: 1, result: { tools: [{ name: 't1' }] }, }; }), } as unknown as McpProxyService; const dispatcher = new ChatToolDispatcherImpl({ proxy, projects: mockProjectRepo(makeProject({ servers: [ { id: 'ps-1', projectId: 'proj-1', serverId: 'srv-bad', server: { id: 'srv-bad', name: 'bad' } }, { id: 'ps-2', projectId: 'proj-1', serverId: 'srv-good', server: { id: 'srv-good', name: 'good' } }, ], })), logger: { warn }, }); const tools = await dispatcher.listTools('proj-1'); expect(tools.map((t) => t.name)).toEqual([`good${TOOL_NAME_SEPARATOR}t1`]); expect(warn).toHaveBeenCalledWith( expect.objectContaining({ serverId: 'srv-bad' }), 'tools/list failed', ); }); it('callTool dispatches `tools/call` to the right serverId', async () => { const execute = vi.fn(async () => ({ jsonrpc: '2.0' as const, id: 1, result: { content: [{ type: 'text', text: 'pong' }] }, })); const dispatcher = new ChatToolDispatcherImpl({ proxy: { execute } as unknown as McpProxyService, projects: mockProjectRepo(makeProject({ servers: [{ id: 'ps-1', projectId: 'proj-1', serverId: 'srv-grafana', server: { id: 'srv-grafana', name: 'grafana' } }], })), }); const result = await dispatcher.callTool({ projectId: 'proj-1', serverName: 'grafana', toolName: 'ping', args: { q: 'cpu' }, }); expect(execute).toHaveBeenCalledWith({ serverId: 'srv-grafana', method: 'tools/call', params: { name: 'ping', arguments: { q: 'cpu' } }, }); expect(result).toEqual({ content: [{ type: 'text', text: 'pong' }] }); }); it('callTool throws when the server is not attached to the project', async () => { const execute = vi.fn(); const dispatcher = new ChatToolDispatcherImpl({ proxy: { execute } as unknown as McpProxyService, projects: mockProjectRepo(makeProject({ servers: [] })), }); await expect(dispatcher.callTool({ projectId: 'proj-1', serverName: 'grafana', toolName: 'ping', args: {}, })).rejects.toThrow(/not attached/); expect(execute).not.toHaveBeenCalled(); }); it('callTool surfaces JSON-RPC errors as exceptions', async () => { const execute = vi.fn(async () => ({ jsonrpc: '2.0' as const, id: 1, error: { code: -1, message: 'tool blew up' }, })); const dispatcher = new ChatToolDispatcherImpl({ proxy: { execute } as unknown as McpProxyService, projects: mockProjectRepo(makeProject({ servers: [{ id: 'ps-1', projectId: 'proj-1', serverId: 'srv-grafana', server: { id: 'srv-grafana', name: 'grafana' } }], })), }); await expect(dispatcher.callTool({ projectId: 'proj-1', serverName: 'grafana', toolName: 'ping', args: {}, })).rejects.toThrow(/tool blew up/); }); });