feat(agents+chat): agents feature + live chat UX #57
@@ -32,9 +32,16 @@ import { SecretBackendRotatorLoop } from './services/secret-backend-rotator-loop
|
|||||||
import { registerSecretBackendRotateRoutes } from './routes/secret-backend-rotate.js';
|
import { registerSecretBackendRotateRoutes } from './routes/secret-backend-rotate.js';
|
||||||
import { LlmRepository } from './repositories/llm.repository.js';
|
import { LlmRepository } from './repositories/llm.repository.js';
|
||||||
import { LlmService } from './services/llm.service.js';
|
import { LlmService } from './services/llm.service.js';
|
||||||
|
import { AgentRepository } from './repositories/agent.repository.js';
|
||||||
|
import { ChatRepository } from './repositories/chat.repository.js';
|
||||||
|
import { AgentService } from './services/agent.service.js';
|
||||||
|
import { ChatService } from './services/chat.service.js';
|
||||||
|
import { ChatToolDispatcherImpl } from './services/chat-tool-dispatcher.js';
|
||||||
import { LlmAdapterRegistry } from './services/llm/dispatcher.js';
|
import { LlmAdapterRegistry } from './services/llm/dispatcher.js';
|
||||||
import { registerLlmRoutes } from './routes/llms.js';
|
import { registerLlmRoutes } from './routes/llms.js';
|
||||||
import { registerLlmInferRoutes } from './routes/llm-infer.js';
|
import { registerLlmInferRoutes } from './routes/llm-infer.js';
|
||||||
|
import { registerAgentRoutes } from './routes/agents.js';
|
||||||
|
import { registerAgentChatRoutes } from './routes/agent-chat.js';
|
||||||
import { PromptRepository } from './repositories/prompt.repository.js';
|
import { PromptRepository } from './repositories/prompt.repository.js';
|
||||||
import { PromptRequestRepository } from './repositories/prompt-request.repository.js';
|
import { PromptRequestRepository } from './repositories/prompt-request.repository.js';
|
||||||
import { bootstrapSystemProject } from './bootstrap/system-project.js';
|
import { bootstrapSystemProject } from './bootstrap/system-project.js';
|
||||||
@@ -123,6 +130,21 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
|
|||||||
return { kind: 'resource', resource: 'llms', action: 'run', resourceName: inferMatch[1] };
|
return { kind: 'resource', resource: 'llms', action: 'run', resourceName: inferMatch[1] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /api/v1/agents/:name/chat or /threads* → `run:agents:<name>`.
|
||||||
|
// Driving a turn or managing its history is a "run" on the agent — listing
|
||||||
|
// and CRUD continue to fall through to the default mapping below.
|
||||||
|
const agentRunMatch = url.match(/^\/api\/v1\/agents\/([^/?]+)\/(chat|threads)/);
|
||||||
|
if (agentRunMatch?.[1]) {
|
||||||
|
return { kind: 'resource', resource: 'agents', action: 'run', resourceName: agentRunMatch[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/v1/threads/:id/messages → `view:agents` (we don't carry the agent
|
||||||
|
// name in the URL; the service-level owner check enforces fine-grained
|
||||||
|
// access on top).
|
||||||
|
if (url.startsWith('/api/v1/threads/')) {
|
||||||
|
return { kind: 'resource', resource: 'agents', action: 'view' };
|
||||||
|
}
|
||||||
|
|
||||||
const resourceMap: Record<string, string | undefined> = {
|
const resourceMap: Record<string, string | undefined> = {
|
||||||
'servers': 'servers',
|
'servers': 'servers',
|
||||||
'instances': 'instances',
|
'instances': 'instances',
|
||||||
@@ -139,6 +161,7 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
|
|||||||
'promptrequests': 'promptrequests',
|
'promptrequests': 'promptrequests',
|
||||||
'mcptokens': 'mcptokens',
|
'mcptokens': 'mcptokens',
|
||||||
'llms': 'llms',
|
'llms': 'llms',
|
||||||
|
'agents': 'agents',
|
||||||
};
|
};
|
||||||
|
|
||||||
const resource = resourceMap[segment];
|
const resource = resourceMap[segment];
|
||||||
@@ -324,6 +347,8 @@ async function main(): Promise<void> {
|
|||||||
const secretRepo = new SecretRepository(prisma);
|
const secretRepo = new SecretRepository(prisma);
|
||||||
const secretBackendRepo = new SecretBackendRepository(prisma);
|
const secretBackendRepo = new SecretBackendRepository(prisma);
|
||||||
const llmRepo = new LlmRepository(prisma);
|
const llmRepo = new LlmRepository(prisma);
|
||||||
|
const agentRepo = new AgentRepository(prisma);
|
||||||
|
const chatRepo = new ChatRepository(prisma);
|
||||||
const instanceRepo = new McpInstanceRepository(prisma);
|
const instanceRepo = new McpInstanceRepository(prisma);
|
||||||
const projectRepo = new ProjectRepository(prisma);
|
const projectRepo = new ProjectRepository(prisma);
|
||||||
const auditLogRepo = new AuditLogRepository(prisma);
|
const auditLogRepo = new AuditLogRepository(prisma);
|
||||||
@@ -348,6 +373,7 @@ async function main(): Promise<void> {
|
|||||||
groups: groupRepo,
|
groups: groupRepo,
|
||||||
mcptokens: mcpTokenRepo,
|
mcptokens: mcpTokenRepo,
|
||||||
llms: llmRepo,
|
llms: llmRepo,
|
||||||
|
agents: agentRepo,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Migrate legacy 'admin' role → granular roles
|
// Migrate legacy 'admin' role → granular roles
|
||||||
@@ -391,6 +417,9 @@ async function main(): Promise<void> {
|
|||||||
});
|
});
|
||||||
const llmService = new LlmService(llmRepo, secretService);
|
const llmService = new LlmService(llmRepo, secretService);
|
||||||
const llmAdapters = new LlmAdapterRegistry();
|
const llmAdapters = new LlmAdapterRegistry();
|
||||||
|
// AgentService + ChatService get fully wired below once projectService and
|
||||||
|
// mcpProxyService are constructed (ChatService needs them via the
|
||||||
|
// ChatToolDispatcher bridge).
|
||||||
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretService);
|
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretService);
|
||||||
serverService.setInstanceService(instanceService);
|
serverService.setInstanceService(instanceService);
|
||||||
const projectService = new ProjectService(projectRepo, serverRepo);
|
const projectService = new ProjectService(projectRepo, serverRepo);
|
||||||
@@ -411,6 +440,11 @@ async function main(): Promise<void> {
|
|||||||
const promptRuleRegistry = new ResourceRuleRegistry();
|
const promptRuleRegistry = new ResourceRuleRegistry();
|
||||||
promptRuleRegistry.register(systemPromptVarsRule);
|
promptRuleRegistry.register(systemPromptVarsRule);
|
||||||
const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry);
|
const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry);
|
||||||
|
const agentService = new AgentService(agentRepo, llmService, projectService);
|
||||||
|
// ChatService needs the proxy + project repo via the ChatToolDispatcher
|
||||||
|
// bridge. The dispatcher's logger references `app.log`, which is not
|
||||||
|
// constructed until further down — `chatService` itself is built right
|
||||||
|
// before its routes register, just like `gitBackup`.
|
||||||
const backupService = new BackupService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo, promptRepo, templateRepo);
|
const backupService = new BackupService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo, promptRepo, templateRepo);
|
||||||
const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, secretService, userRepo, groupRepo, rbacDefinitionRepo, promptRepo, templateRepo);
|
const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, secretService, userRepo, groupRepo, rbacDefinitionRepo, promptRepo, templateRepo);
|
||||||
|
|
||||||
@@ -533,6 +567,22 @@ async function main(): Promise<void> {
|
|||||||
registerSecretBackendRotateRoutes(app, secretBackendRotator);
|
registerSecretBackendRotateRoutes(app, secretBackendRotator);
|
||||||
registerSecretMigrateRoutes(app, secretMigrateService);
|
registerSecretMigrateRoutes(app, secretMigrateService);
|
||||||
registerLlmRoutes(app, llmService);
|
registerLlmRoutes(app, llmService);
|
||||||
|
registerAgentRoutes(app, agentService);
|
||||||
|
// ChatService needs an `app.log`-aware tool dispatcher.
|
||||||
|
const chatToolDispatcher = new ChatToolDispatcherImpl({
|
||||||
|
proxy: mcpProxyService,
|
||||||
|
projects: projectRepo,
|
||||||
|
logger: { warn: (obj, msg) => app.log.warn(obj, msg) },
|
||||||
|
});
|
||||||
|
const chatService = new ChatService(
|
||||||
|
agentService,
|
||||||
|
llmService,
|
||||||
|
llmAdapters,
|
||||||
|
chatRepo,
|
||||||
|
promptRepo,
|
||||||
|
chatToolDispatcher,
|
||||||
|
);
|
||||||
|
registerAgentChatRoutes(app, chatService);
|
||||||
registerLlmInferRoutes(app, {
|
registerLlmInferRoutes(app, {
|
||||||
llmService,
|
llmService,
|
||||||
adapters: llmAdapters,
|
adapters: llmAdapters,
|
||||||
|
|||||||
144
src/mcpd/src/routes/agent-chat.ts
Normal file
144
src/mcpd/src/routes/agent-chat.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Agent chat + threads HTTP surface.
|
||||||
|
*
|
||||||
|
* POST /api/v1/agents/:name/chat — chat (non-streaming + SSE)
|
||||||
|
* POST /api/v1/agents/:name/threads — explicit thread create
|
||||||
|
* GET /api/v1/agents/:name/threads — list threads (caller-scoped)
|
||||||
|
* GET /api/v1/threads/:id/messages — replay thread history
|
||||||
|
*
|
||||||
|
* RBAC: chat + threads on a named agent route through `run:agents:<name>` so
|
||||||
|
* a viewer can list them but only callers with `run` rights can drive a turn.
|
||||||
|
* History under `/threads/:id` checks `view:agents` (best we can do without a
|
||||||
|
* thread→agent reverse lookup in the URL) plus a service-level owner check.
|
||||||
|
*
|
||||||
|
* The SSE pattern mirrors `llm-infer.ts` — same headers, same `data: ...\n\n`
|
||||||
|
* frame format, same `[DONE]` terminator. Each ChatService chunk becomes one
|
||||||
|
* frame; final/error chunks close the stream.
|
||||||
|
*/
|
||||||
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||||
|
import type { ChatService, ChatStreamChunk } from '../services/chat.service.js';
|
||||||
|
import { AgentChatRequestSchema } from '../validation/agent.schema.js';
|
||||||
|
import { NotFoundError } from '../services/mcp-server.service.js';
|
||||||
|
|
||||||
|
export function registerAgentChatRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
chat: ChatService,
|
||||||
|
): void {
|
||||||
|
app.post<{ Params: { name: string } }>(
|
||||||
|
'/api/v1/agents/:name/chat',
|
||||||
|
async (request, reply) => {
|
||||||
|
const ownerId = request.userId ?? 'system';
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = AgentChatRequestSchema.parse(request.body ?? {});
|
||||||
|
} catch (err) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: (err as Error).message };
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
threadId, message, messages: messagesOverride, stream,
|
||||||
|
...paramsRest
|
||||||
|
} = parsed;
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
agentName: request.params.name,
|
||||||
|
ownerId,
|
||||||
|
...(threadId !== undefined ? { threadId } : {}),
|
||||||
|
...(message !== undefined ? { userMessage: message } : {}),
|
||||||
|
...(messagesOverride !== undefined
|
||||||
|
? { messagesOverride: messagesOverride.map((m) => ({ role: m.role, content: m.content, ...(m.tool_call_id !== undefined ? { tool_call_id: m.tool_call_id } : {}) })) }
|
||||||
|
: {}),
|
||||||
|
params: paramsRest,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (stream !== true) {
|
||||||
|
try {
|
||||||
|
return await chat.chat(args);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
reply.code(502);
|
||||||
|
return { error: (err as Error).message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streaming — exact same headers as llm-infer.
|
||||||
|
reply.raw.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
for await (const chunk of chat.chatStream(args)) {
|
||||||
|
writeSseChunk(reply, JSON.stringify(chunk));
|
||||||
|
if (chunk.type === 'final' || chunk.type === 'error') break;
|
||||||
|
}
|
||||||
|
writeSseChunk(reply, '[DONE]');
|
||||||
|
} catch (err) {
|
||||||
|
const payload: ChatStreamChunk = { type: 'error', message: (err as Error).message };
|
||||||
|
writeSseChunk(reply, JSON.stringify(payload));
|
||||||
|
writeSseChunk(reply, '[DONE]');
|
||||||
|
} finally {
|
||||||
|
reply.raw.end();
|
||||||
|
}
|
||||||
|
return reply;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { name: string }; Body: { title?: string } }>(
|
||||||
|
'/api/v1/agents/:name/threads',
|
||||||
|
async (request, reply) => {
|
||||||
|
const ownerId = request.userId ?? 'system';
|
||||||
|
try {
|
||||||
|
const t = await chat.createThread(request.params.name, ownerId, request.body?.title);
|
||||||
|
reply.code(201);
|
||||||
|
return t;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{ Params: { name: string } }>(
|
||||||
|
'/api/v1/agents/:name/threads',
|
||||||
|
async (request, reply) => {
|
||||||
|
const ownerId = request.userId ?? undefined;
|
||||||
|
try {
|
||||||
|
return await chat.listThreads(request.params.name, ownerId);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>(
|
||||||
|
'/api/v1/threads/:id/messages',
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return await chat.listMessages(request.params.id);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSseChunk(reply: FastifyReply, data: string): void {
|
||||||
|
reply.raw.write(`data: ${data}\n\n`);
|
||||||
|
}
|
||||||
106
src/mcpd/src/routes/agents.ts
Normal file
106
src/mcpd/src/routes/agents.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* /api/v1/agents — Agent CRUD.
|
||||||
|
*
|
||||||
|
* Mirrors `routes/llms.ts` shape: list / get-by-id-or-name / POST / PUT /
|
||||||
|
* DELETE. RBAC is enforced by the global hook in `main.ts:mapUrlToPermission`
|
||||||
|
* — the resource is `agents`. The chat endpoints live in `agent-chat.ts` and
|
||||||
|
* map to `run:agents:<name>`.
|
||||||
|
*/
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { AgentService } from '../services/agent.service.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../services/mcp-server.service.js';
|
||||||
|
|
||||||
|
export function registerAgentRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
service: AgentService,
|
||||||
|
): void {
|
||||||
|
app.get('/api/v1/agents', async () => {
|
||||||
|
return service.list();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>('/api/v1/agents/:id', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return await getByIdOrName(service, request.params.id);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/agents', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const ownerId = request.userId ?? 'system';
|
||||||
|
const row = await service.create(request.body, ownerId);
|
||||||
|
reply.code(201);
|
||||||
|
return row;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ConflictError) {
|
||||||
|
reply.code(409);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
reply.code(400);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string } }>('/api/v1/agents/:id', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const target = await getByIdOrName(service, request.params.id);
|
||||||
|
return await service.update(target.id, request.body);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>('/api/v1/agents/:id', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const target = await getByIdOrName(service, request.params.id);
|
||||||
|
await service.delete(target.id);
|
||||||
|
reply.code(204);
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v1/projects/:projectName/agents — used by mcplocal's agents
|
||||||
|
// plugin to enumerate agents for the bound project. Matches the existing
|
||||||
|
// /projects/:p/servers endpoint convention.
|
||||||
|
app.get<{ Params: { projectName: string } }>(
|
||||||
|
'/api/v1/projects/:projectName/agents',
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
return await service.listByProject(request.params.projectName);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof NotFoundError) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: err.message };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CUID_RE = /^c[a-z0-9]{24}/i;
|
||||||
|
|
||||||
|
async function getByIdOrName(service: AgentService, idOrName: string) {
|
||||||
|
if (CUID_RE.test(idOrName)) {
|
||||||
|
return service.getById(idOrName);
|
||||||
|
}
|
||||||
|
return service.getByName(idOrName);
|
||||||
|
}
|
||||||
99
src/mcpd/src/services/chat-tool-dispatcher.ts
Normal file
99
src/mcpd/src/services/chat-tool-dispatcher.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Production ChatToolDispatcher — bridges ChatService to McpProxyService.
|
||||||
|
*
|
||||||
|
* For an agent's chat turn, the model sees the union of all tools exposed by
|
||||||
|
* the agent's project's MCP servers. We list them by sending each server an
|
||||||
|
* MCP `tools/list` JSON-RPC request, then translate the result into the
|
||||||
|
* OpenAI function-tool shape with namespaced names (`<server>__<tool>`). Tool
|
||||||
|
* dispatch reverses the namespace and sends an `tools/call` to the right
|
||||||
|
* server through the same proxy path the regular MCP client traffic uses.
|
||||||
|
*
|
||||||
|
* Listing is best-effort: a single server failing to respond does not abort
|
||||||
|
* the whole list — its tools just won't appear that turn. Errors from the
|
||||||
|
* dispatch path of an actual call do propagate to the chat loop, which
|
||||||
|
* persists them as `error` tool turns and lets the model recover.
|
||||||
|
*/
|
||||||
|
import type { McpProxyService } from './mcp-proxy-service.js';
|
||||||
|
import type { IProjectRepository } from '../repositories/project.repository.js';
|
||||||
|
import type { ChatTool, ChatToolDispatcher } from './chat.service.js';
|
||||||
|
import { TOOL_NAME_SEPARATOR } from './chat.service.js';
|
||||||
|
|
||||||
|
export interface McpListToolsResult {
|
||||||
|
tools: Array<{ name: string; description?: string; inputSchema?: Record<string, unknown> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpCallToolResult {
|
||||||
|
content?: Array<{ type: string; text?: string; [k: string]: unknown }>;
|
||||||
|
isError?: boolean;
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatToolDispatcherDeps {
|
||||||
|
proxy: McpProxyService;
|
||||||
|
projects: IProjectRepository;
|
||||||
|
/** Optional logger for "server X failed to list" lines. */
|
||||||
|
logger?: { warn(obj: Record<string, unknown>, msg: string): void };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChatToolDispatcherImpl implements ChatToolDispatcher {
|
||||||
|
constructor(private readonly deps: ChatToolDispatcherDeps) {}
|
||||||
|
|
||||||
|
async listTools(projectId: string | null): Promise<ChatTool[]> {
|
||||||
|
if (projectId === null) return [];
|
||||||
|
const project = await this.deps.projects.findById(projectId);
|
||||||
|
if (project === null) return [];
|
||||||
|
const out: ChatTool[] = [];
|
||||||
|
for (const ps of project.servers) {
|
||||||
|
try {
|
||||||
|
const res = await this.deps.proxy.execute({
|
||||||
|
serverId: ps.serverId,
|
||||||
|
method: 'tools/list',
|
||||||
|
});
|
||||||
|
if (res.error !== undefined) {
|
||||||
|
this.deps.logger?.warn({ serverId: ps.serverId, error: res.error }, 'tools/list failed');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const result = res.result as McpListToolsResult | undefined;
|
||||||
|
if (result?.tools === undefined) continue;
|
||||||
|
for (const t of result.tools) {
|
||||||
|
out.push({
|
||||||
|
name: `${ps.server.name}${TOOL_NAME_SEPARATOR}${t.name}`,
|
||||||
|
description: t.description ?? '',
|
||||||
|
parameters: t.inputSchema ?? { type: 'object', properties: {} },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.deps.logger?.warn(
|
||||||
|
{ serverId: ps.serverId, error: (err as Error).message },
|
||||||
|
'tools/list threw',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async callTool(args: {
|
||||||
|
projectId: string;
|
||||||
|
serverName: string;
|
||||||
|
toolName: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
}): Promise<unknown> {
|
||||||
|
const project = await this.deps.projects.findById(args.projectId);
|
||||||
|
if (project === null) {
|
||||||
|
throw new Error(`Project ${args.projectId} not found`);
|
||||||
|
}
|
||||||
|
const projectServer = project.servers.find((ps) => ps.server.name === args.serverName);
|
||||||
|
if (projectServer === undefined) {
|
||||||
|
throw new Error(`Server '${args.serverName}' is not attached to project '${project.name}'`);
|
||||||
|
}
|
||||||
|
const res = await this.deps.proxy.execute({
|
||||||
|
serverId: projectServer.serverId,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: args.toolName, arguments: args.args },
|
||||||
|
});
|
||||||
|
if (res.error !== undefined) {
|
||||||
|
throw new Error(`tools/call ${args.serverName}/${args.toolName} failed: ${res.error.message}`);
|
||||||
|
}
|
||||||
|
return res.result as McpCallToolResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
256
src/mcpd/tests/agent-routes.test.ts
Normal file
256
src/mcpd/tests/agent-routes.test.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { registerAgentRoutes } from '../src/routes/agents.js';
|
||||||
|
import { registerAgentChatRoutes } from '../src/routes/agent-chat.js';
|
||||||
|
import { errorHandler } from '../src/middleware/error-handler.js';
|
||||||
|
import { ConflictError, NotFoundError } from '../src/services/mcp-server.service.js';
|
||||||
|
import type { AgentService, AgentView } from '../src/services/agent.service.js';
|
||||||
|
import type { ChatService } from '../src/services/chat.service.js';
|
||||||
|
|
||||||
|
const NOW = new Date();
|
||||||
|
|
||||||
|
function makeView(overrides: Partial<AgentView> = {}): AgentView {
|
||||||
|
return {
|
||||||
|
id: 'agent-1',
|
||||||
|
name: 'reviewer',
|
||||||
|
description: '',
|
||||||
|
systemPrompt: '',
|
||||||
|
llm: { id: 'llm-1', name: 'qwen3-thinking' },
|
||||||
|
project: null,
|
||||||
|
proxyModelName: null,
|
||||||
|
defaultParams: {},
|
||||||
|
extras: {},
|
||||||
|
ownerId: 'owner-1',
|
||||||
|
version: 1,
|
||||||
|
createdAt: NOW,
|
||||||
|
updatedAt: NOW,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockAgentService(initial: AgentView[] = []): AgentService {
|
||||||
|
const rows = new Map(initial.map((r) => [r.id, r]));
|
||||||
|
return {
|
||||||
|
list: vi.fn(async () => [...rows.values()]),
|
||||||
|
listByProject: vi.fn(async (projectName: string) =>
|
||||||
|
[...rows.values()].filter((r) => r.project?.name === projectName)),
|
||||||
|
getById: vi.fn(async (id: string) => {
|
||||||
|
const r = rows.get(id);
|
||||||
|
if (!r) throw new NotFoundError(`Agent not found: ${id}`);
|
||||||
|
return r;
|
||||||
|
}),
|
||||||
|
getByName: vi.fn(async (name: string) => {
|
||||||
|
for (const r of rows.values()) if (r.name === name) return r;
|
||||||
|
throw new NotFoundError(`Agent not found: ${name}`);
|
||||||
|
}),
|
||||||
|
create: vi.fn(async (input: unknown) => {
|
||||||
|
const data = input as { name: string };
|
||||||
|
for (const r of rows.values()) if (r.name === data.name) throw new ConflictError(`Agent already exists: ${data.name}`);
|
||||||
|
const v = makeView({ id: `agent-${String(rows.size + 1)}`, name: data.name });
|
||||||
|
rows.set(v.id, v);
|
||||||
|
return v;
|
||||||
|
}),
|
||||||
|
update: vi.fn(async (id: string, input: unknown) => {
|
||||||
|
const existing = rows.get(id);
|
||||||
|
if (!existing) throw new NotFoundError(`Agent not found: ${id}`);
|
||||||
|
const next = { ...existing, ...(input as Partial<AgentView>) };
|
||||||
|
rows.set(id, next);
|
||||||
|
return next;
|
||||||
|
}),
|
||||||
|
delete: vi.fn(async (id: string) => {
|
||||||
|
if (!rows.has(id)) throw new NotFoundError(`Agent not found: ${id}`);
|
||||||
|
rows.delete(id);
|
||||||
|
}),
|
||||||
|
upsertByName: vi.fn(),
|
||||||
|
deleteByName: vi.fn(),
|
||||||
|
} as unknown as AgentService;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockChatService(): ChatService {
|
||||||
|
return {
|
||||||
|
chat: vi.fn(async (args: { agentName: string; userMessage?: string }) => ({
|
||||||
|
threadId: 'thread-1', assistant: `echo: ${args.userMessage ?? ''}`, turnIndex: 1,
|
||||||
|
})),
|
||||||
|
chatStream: vi.fn(async function*() {
|
||||||
|
yield { type: 'text' as const, delta: 'hi' };
|
||||||
|
yield { type: 'final' as const, threadId: 'thread-1', turnIndex: 1 };
|
||||||
|
}),
|
||||||
|
createThread: vi.fn(async () => ({ id: 'thread-2' })),
|
||||||
|
listThreads: vi.fn(async () => [
|
||||||
|
{ id: 'thread-1', title: 't1', lastTurnAt: NOW, createdAt: NOW },
|
||||||
|
]),
|
||||||
|
listMessages: vi.fn(async () => []),
|
||||||
|
} as unknown as ChatService;
|
||||||
|
}
|
||||||
|
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createApp(opts: { agents?: AgentService; chat?: ChatService } = {}): Promise<FastifyInstance> {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
app.setErrorHandler(errorHandler);
|
||||||
|
registerAgentRoutes(app, opts.agents ?? mockAgentService());
|
||||||
|
registerAgentChatRoutes(app, opts.chat ?? mockChatService());
|
||||||
|
await app.ready();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Agent CRUD routes', () => {
|
||||||
|
it('GET /api/v1/agents lists agents', async () => {
|
||||||
|
await createApp({ agents: mockAgentService([makeView()]) });
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/v1/agents' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json<unknown[]>()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/v1/agents/:name resolves by name when not a CUID', async () => {
|
||||||
|
await createApp({ agents: mockAgentService([makeView({ id: 'agent-1', name: 'reviewer' })]) });
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/v1/agents/reviewer' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json<{ name: string }>().name).toBe('reviewer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/v1/agents/:id returns 404 when missing', async () => {
|
||||||
|
await createApp();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/v1/agents/missing' });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/v1/agents creates and returns 201', async () => {
|
||||||
|
await createApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/agents',
|
||||||
|
payload: { name: 'deployer', llm: { name: 'qwen3-thinking' } },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect(res.json<{ name: string }>().name).toBe('deployer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/v1/agents returns 409 on duplicate name', async () => {
|
||||||
|
await createApp({ agents: mockAgentService([makeView({ id: 'a1', name: 'dup' })]) });
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/agents',
|
||||||
|
payload: { name: 'dup', llm: { name: 'qwen3-thinking' } },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT /api/v1/agents/:name updates by name', async () => {
|
||||||
|
await createApp({ agents: mockAgentService([makeView({ id: 'a1', name: 'editable' })]) });
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/agents/editable',
|
||||||
|
payload: { description: 'changed' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json<{ description: string }>().description).toBe('changed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/v1/agents/:name returns 204', async () => {
|
||||||
|
await createApp({ agents: mockAgentService([makeView({ id: 'a1', name: 'doomed' })]) });
|
||||||
|
const res = await app.inject({ method: 'DELETE', url: '/api/v1/agents/doomed' });
|
||||||
|
expect(res.statusCode).toBe(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/v1/projects/:name/agents lists project-scoped agents', async () => {
|
||||||
|
await createApp({
|
||||||
|
agents: mockAgentService([
|
||||||
|
makeView({ id: 'a1', name: 'in', project: { id: 'p1', name: 'mcpctl-dev' } }),
|
||||||
|
makeView({ id: 'a2', name: 'out' }),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/v1/projects/mcpctl-dev/agents' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json<Array<{ name: string }>>().map((a) => a.name)).toEqual(['in']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Chat + threads routes', () => {
|
||||||
|
it('POST /api/v1/agents/:name/chat (non-streaming) returns assistant body', async () => {
|
||||||
|
await createApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/agents/reviewer/chat',
|
||||||
|
payload: { message: 'hi' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json<{ threadId: string; assistant: string }>();
|
||||||
|
expect(body.assistant).toContain('echo');
|
||||||
|
expect(body.threadId).toBe('thread-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/v1/agents/:name/chat rejects empty body with 400', async () => {
|
||||||
|
await createApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/agents/reviewer/chat',
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/v1/agents/:name/chat (streaming) emits SSE frames', async () => {
|
||||||
|
await createApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/agents/reviewer/chat',
|
||||||
|
payload: { message: 'hi', stream: true },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toContain('text/event-stream');
|
||||||
|
const body = res.body;
|
||||||
|
expect(body).toContain('data: ');
|
||||||
|
expect(body).toContain('"type":"text"');
|
||||||
|
expect(body).toContain('"type":"final"');
|
||||||
|
expect(body.endsWith('data: [DONE]\n\n')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/v1/agents/:name/threads returns 201 with new thread id', async () => {
|
||||||
|
await createApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/agents/reviewer/threads',
|
||||||
|
payload: { title: 'kickoff' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
expect(res.json<{ id: string }>().id).toBe('thread-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/v1/agents/:name/threads lists threads', async () => {
|
||||||
|
await createApp();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/v1/agents/reviewer/threads' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json<Array<{ id: string }>>();
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
expect(body[0]!.id).toBe('thread-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/v1/threads/:id/messages returns the message log', async () => {
|
||||||
|
await createApp();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/v1/threads/thread-1/messages' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json<unknown[]>()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapUrlToPermission for agents', () => {
|
||||||
|
// The mapping itself is tested implicitly through main.ts; this asserts the
|
||||||
|
// shape we export for the chat URL → run:agents:<name>.
|
||||||
|
it('routes /agents/:name/chat through run:agents:<name>', async () => {
|
||||||
|
// Smoke check via the route working at all — RBAC integration is exercised
|
||||||
|
// in main.ts tests; this just guards against regressions in the URL shape.
|
||||||
|
await createApp();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/agents/r/chat',
|
||||||
|
payload: { message: 'x' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
185
src/mcpd/tests/chat-tool-dispatcher.test.ts
Normal file
185
src/mcpd/tests/chat-tool-dispatcher.test.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user