Files
mcpctl/src/mcpd/tests/chat-tool-dispatcher.test.ts
Michal 03ae4e15f7 feat(agents): mcpd routes + RBAC + tool dispatcher (Stage 3)
Wires the Stage 2 services into HTTP. New routes:

  GET    /api/v1/agents                — list
  GET    /api/v1/agents/:idOrName       — describe
  POST   /api/v1/agents                 — create
  PUT    /api/v1/agents/:idOrName       — update
  DELETE /api/v1/agents/:idOrName       — delete
  GET    /api/v1/projects/:p/agents     — project-scoped list (mcplocal disco)
  POST   /api/v1/agents/:name/chat      — chat (non-streaming or SSE stream)
  POST   /api/v1/agents/:name/threads   — create thread explicitly
  GET    /api/v1/agents/:name/threads   — list threads
  GET    /api/v1/threads/:id/messages   — replay history

The chat endpoint reuses the SSE pattern from llm-infer.ts (same headers
incl. X-Accel-Buffering:no, same `data: …\n\n` framing, same `[DONE]`
terminator). Each ChatService chunk is one frame. Non-streaming returns
{threadId, assistant, turnIndex} as JSON.

RBAC mapping in main.ts:mapUrlToPermission:
  - /agents/:name/{chat,threads*}  → run:agents:<name>
  - /threads/:id/*                 → view:agents (service-level owner check
    handles fine-grained access since the URL doesn't carry the agent name)
  - /agents and /agents/:idOrName  → default {GET:view, POST:create,
    PUT:edit, DELETE:delete} on resource 'agents'.
'agents' added to nameResolvers so RBAC's CUID→name lookup works.

ChatToolDispatcherImpl bridges ChatService to McpProxyService: it lists a
project's MCP servers, fans out tools/list calls to each, namespaces tool
names as `<server>__<tool>`, and routes tools/call back to the right
serverId on dispatch. tools/list errors on a single server are logged and
that server's tools are dropped from the turn's tool surface — one bad
server doesn't poison the whole list.

Tests:
  agent-routes.test.ts (15) — full HTTP CRUD round-trip, 404/409 paths,
    project-scoped list, non-streaming + SSE chat, thread create/list,
    /threads/:id/messages replay, body-required 400.
  chat-tool-dispatcher.test.ts (7) — empty list when no project / no
    servers, namespacing + inputSchema forwarding, partial-failure
    skipping with audit log, callTool dispatch shape, missing-server
    rejection, JSON-RPC error surfacing.

All 22 new green; mcpd suite now 759/759 (was 737).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:45:15 +01:00

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/);
});
});