249 lines
8.2 KiB
TypeScript
249 lines
8.2 KiB
TypeScript
|
|
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' },
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|