Files
mcpctl/src/mcplocal/tests/router-prompts.test.ts

249 lines
8.2 KiB
TypeScript
Raw Normal View History

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<JsonRpcResponse> => {
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' },
);
});
});
});