feat: add prompt resources, fix MCP proxy transport, enrich tool descriptions
- Fix MCP proxy to support SSE and STDIO transports (not just HTTP POST) - Enrich tool descriptions with server context for LLM clarity - Add Prompt and PromptRequest resources with two-resource RBAC model - Add propose_prompt MCP tool for LLM to create pending prompt requests - Add prompt resources visible in MCP resources/list (approved + session's pending) - Add project-level prompt/instructions in MCP initialize response - Add ServiceAccount subject type for RBAC (SA identity from X-Service-Account header) - Add CLI commands: create prompt, get prompts/promptrequests, approve promptrequest - Add prompts to apply config schema - 956 tests passing across all packages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
248
src/mcplocal/tests/router-prompts.test.ts
Normal file
248
src/mcplocal/tests/router-prompts.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
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' },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user