186 lines
6.3 KiB
TypeScript
186 lines
6.3 KiB
TypeScript
|
|
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> = {}): 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 `<server>__<tool>` 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/);
|
||
|
|
});
|
||
|
|
});
|