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:
@@ -5,6 +5,7 @@ import { McpdUpstream } from './upstream/mcpd.js';
|
||||
interface McpdServer {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
transport: string;
|
||||
status?: string;
|
||||
}
|
||||
@@ -63,7 +64,7 @@ function syncUpstreams(router: McpRouter, mcpdClient: McpdClient, servers: McpdS
|
||||
// Add/update upstreams for each server
|
||||
for (const server of servers) {
|
||||
if (!currentNames.has(server.name)) {
|
||||
const upstream = new McpdUpstream(server.id, server.name, mcpdClient);
|
||||
const upstream = new McpdUpstream(server.id, server.name, mcpdClient, server.description);
|
||||
router.addUpstream(upstream);
|
||||
}
|
||||
registered.push(server.name);
|
||||
|
||||
@@ -23,11 +23,21 @@ export class ConnectionError extends Error {
|
||||
export class McpdClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly token: string;
|
||||
private readonly extraHeaders: Record<string, string>;
|
||||
|
||||
constructor(baseUrl: string, token: string) {
|
||||
constructor(baseUrl: string, token: string, extraHeaders?: Record<string, string>) {
|
||||
// Strip trailing slash for consistent URL joining
|
||||
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
||||
this.token = token;
|
||||
this.extraHeaders = extraHeaders ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new client with additional default headers.
|
||||
* Inherits base URL and token from the current client.
|
||||
*/
|
||||
withHeaders(headers: Record<string, string>): McpdClient {
|
||||
return new McpdClient(this.baseUrl, this.token, { ...this.extraHeaders, ...headers });
|
||||
}
|
||||
|
||||
async get<T>(path: string): Promise<T> {
|
||||
@@ -62,6 +72,7 @@ export class McpdClient {
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
const url = `${this.baseUrl}${path}${query ? `?${query}` : ''}`;
|
||||
const headers: Record<string, string> = {
|
||||
...this.extraHeaders,
|
||||
'Authorization': `Bearer ${authOverride ?? this.token}`,
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
@@ -44,6 +44,32 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
|
||||
const router = existing?.router ?? new McpRouter();
|
||||
await refreshProjectUpstreams(router, mcpdClient, projectName, authToken);
|
||||
|
||||
// Configure prompt resources with SA-scoped client for RBAC
|
||||
const saClient = mcpdClient.withHeaders({ 'X-Service-Account': `project:${projectName}` });
|
||||
router.setPromptConfig(saClient, projectName);
|
||||
|
||||
// Fetch project instructions and set on router
|
||||
try {
|
||||
const instructions = await mcpdClient.get<{ prompt: string; servers: Array<{ name: string; description: string }> }>(
|
||||
`/api/v1/projects/${encodeURIComponent(projectName)}/instructions`,
|
||||
);
|
||||
const parts: string[] = [];
|
||||
if (instructions.prompt) {
|
||||
parts.push(instructions.prompt);
|
||||
}
|
||||
if (instructions.servers.length > 0) {
|
||||
parts.push('Available MCP servers:');
|
||||
for (const s of instructions.servers) {
|
||||
parts.push(`- ${s.name}${s.description ? `: ${s.description}` : ''}`);
|
||||
}
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
router.setInstructions(parts.join('\n'));
|
||||
}
|
||||
} catch {
|
||||
// Instructions are optional — don't fail if endpoint is unavailable
|
||||
}
|
||||
|
||||
projectCache.set(projectName, { router, lastRefresh: now });
|
||||
return router;
|
||||
}
|
||||
@@ -84,7 +110,8 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
|
||||
|
||||
transport.onmessage = async (message: JSONRPCMessage) => {
|
||||
if ('method' in message && 'id' in message) {
|
||||
const response = await router.route(message as unknown as JsonRpcRequest);
|
||||
const ctx = transport.sessionId ? { sessionId: transport.sessionId } : undefined;
|
||||
const response = await router.route(message as unknown as JsonRpcRequest, ctx);
|
||||
await transport.send(response as unknown as JSONRPCMessage);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from './types.js';
|
||||
import type { LlmProcessor } from './llm/processor.js';
|
||||
import type { McpdClient } from './http/mcpd-client.js';
|
||||
|
||||
export interface RouteContext {
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes MCP requests to the appropriate upstream server.
|
||||
@@ -17,11 +22,24 @@ export class McpRouter {
|
||||
private promptToServer = new Map<string, string>();
|
||||
private notificationHandler: ((notification: JsonRpcNotification) => void) | null = null;
|
||||
private llmProcessor: LlmProcessor | null = null;
|
||||
private instructions: string | null = null;
|
||||
private mcpdClient: McpdClient | null = null;
|
||||
private projectName: string | null = null;
|
||||
private mcpctlResourceContents = new Map<string, string>();
|
||||
|
||||
setLlmProcessor(processor: LlmProcessor): void {
|
||||
this.llmProcessor = processor;
|
||||
}
|
||||
|
||||
setInstructions(instructions: string): void {
|
||||
this.instructions = instructions;
|
||||
}
|
||||
|
||||
setPromptConfig(mcpdClient: McpdClient, projectName: string): void {
|
||||
this.mcpdClient = mcpdClient;
|
||||
this.projectName = projectName;
|
||||
}
|
||||
|
||||
addUpstream(connection: UpstreamConnection): void {
|
||||
this.upstreams.set(connection.name, connection);
|
||||
if (this.notificationHandler && connection.onNotification) {
|
||||
@@ -87,10 +105,18 @@ export class McpRouter {
|
||||
for (const tool of tools) {
|
||||
const namespacedName = `${serverName}/${tool.name}`;
|
||||
this.toolToServer.set(namespacedName, serverName);
|
||||
allTools.push({
|
||||
// Enrich description with server context if available
|
||||
const entry: { name: string; description?: string; inputSchema?: unknown } = {
|
||||
...tool,
|
||||
name: namespacedName,
|
||||
});
|
||||
};
|
||||
if (upstream.description && tool.description) {
|
||||
entry.description = `[${upstream.description}] ${tool.description}`;
|
||||
} else if (upstream.description) {
|
||||
entry.description = `[${upstream.description}]`;
|
||||
}
|
||||
// If neither upstream.description nor tool.description, keep tool.description (may be undefined — that's fine, just don't set it)
|
||||
allTools.push(entry);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -223,7 +249,7 @@ export class McpRouter {
|
||||
* Route a generic request. Handles protocol-level methods locally,
|
||||
* delegates tool/resource/prompt calls to upstreams.
|
||||
*/
|
||||
async route(request: JsonRpcRequest): Promise<JsonRpcResponse> {
|
||||
async route(request: JsonRpcRequest, context?: RouteContext): Promise<JsonRpcResponse> {
|
||||
switch (request.method) {
|
||||
case 'initialize':
|
||||
return {
|
||||
@@ -240,11 +266,27 @@ export class McpRouter {
|
||||
resources: {},
|
||||
prompts: {},
|
||||
},
|
||||
...(this.instructions ? { instructions: this.instructions } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
case 'tools/list': {
|
||||
const tools = await this.discoverTools();
|
||||
// Append propose_prompt tool if prompt config is set
|
||||
if (this.mcpdClient && this.projectName) {
|
||||
tools.push({
|
||||
name: 'propose_prompt',
|
||||
description: 'Propose a new prompt for this project. Creates a pending request that must be approved by a user before becoming active.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Prompt name (lowercase alphanumeric with hyphens, e.g. "debug-guide")' },
|
||||
content: { type: 'string', description: 'Prompt content text' },
|
||||
},
|
||||
required: ['name', 'content'],
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
@@ -253,10 +295,32 @@ export class McpRouter {
|
||||
}
|
||||
|
||||
case 'tools/call':
|
||||
return this.routeToolCall(request);
|
||||
return this.routeToolCall(request, context);
|
||||
|
||||
case 'resources/list': {
|
||||
const resources = await this.discoverResources();
|
||||
// Append mcpctl prompt resources
|
||||
if (this.mcpdClient && this.projectName) {
|
||||
try {
|
||||
const sessionParam = context?.sessionId ? `?session=${encodeURIComponent(context.sessionId)}` : '';
|
||||
const visible = await this.mcpdClient.get<Array<{ name: string; content: string; type: string }>>(
|
||||
`/api/v1/projects/${encodeURIComponent(this.projectName)}/prompts/visible${sessionParam}`,
|
||||
);
|
||||
this.mcpctlResourceContents.clear();
|
||||
for (const p of visible) {
|
||||
const uri = `mcpctl://prompts/${p.name}`;
|
||||
resources.push({
|
||||
uri,
|
||||
name: p.name,
|
||||
description: p.type === 'promptrequest' ? `[Pending proposal] ${p.name}` : `[Approved prompt] ${p.name}`,
|
||||
mimeType: 'text/plain',
|
||||
});
|
||||
this.mcpctlResourceContents.set(uri, p.content);
|
||||
}
|
||||
} catch {
|
||||
// Prompt resources are optional — don't fail discovery
|
||||
}
|
||||
}
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
@@ -264,8 +328,28 @@ export class McpRouter {
|
||||
};
|
||||
}
|
||||
|
||||
case 'resources/read':
|
||||
case 'resources/read': {
|
||||
const params = request.params as Record<string, unknown> | undefined;
|
||||
const uri = params?.['uri'] as string | undefined;
|
||||
if (uri?.startsWith('mcpctl://')) {
|
||||
const content = this.mcpctlResourceContents.get(uri);
|
||||
if (content !== undefined) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
result: {
|
||||
contents: [{ uri, mimeType: 'text/plain', text: content }],
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
error: { code: -32602, message: `Resource not found: ${uri}` },
|
||||
};
|
||||
}
|
||||
return this.routeNamespacedCall(request, 'uri', this.resourceToServer);
|
||||
}
|
||||
|
||||
case 'resources/subscribe':
|
||||
case 'resources/unsubscribe':
|
||||
@@ -295,10 +379,15 @@ export class McpRouter {
|
||||
/**
|
||||
* Route a tools/call request, optionally applying LLM pre/post-processing.
|
||||
*/
|
||||
private async routeToolCall(request: JsonRpcRequest): Promise<JsonRpcResponse> {
|
||||
private async routeToolCall(request: JsonRpcRequest, context?: RouteContext): Promise<JsonRpcResponse> {
|
||||
const params = request.params as Record<string, unknown> | undefined;
|
||||
const toolName = params?.['name'] as string | undefined;
|
||||
|
||||
// Handle built-in propose_prompt tool
|
||||
if (toolName === 'propose_prompt') {
|
||||
return this.handleProposePrompt(request, context);
|
||||
}
|
||||
|
||||
// If no processor or tool shouldn't be processed, route directly
|
||||
if (!this.llmProcessor || !toolName || !this.llmProcessor.shouldProcess('tools/call', toolName)) {
|
||||
return this.routeNamespacedCall(request, 'name', this.toolToServer);
|
||||
@@ -323,6 +412,61 @@ export class McpRouter {
|
||||
return response;
|
||||
}
|
||||
|
||||
private async handleProposePrompt(request: JsonRpcRequest, context?: RouteContext): Promise<JsonRpcResponse> {
|
||||
if (!this.mcpdClient || !this.projectName) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
error: { code: -32603, message: 'Prompt config not set — propose_prompt unavailable' },
|
||||
};
|
||||
}
|
||||
|
||||
const params = request.params as Record<string, unknown> | undefined;
|
||||
const args = (params?.['arguments'] ?? {}) as Record<string, unknown>;
|
||||
const name = args['name'] as string | undefined;
|
||||
const content = args['content'] as string | undefined;
|
||||
|
||||
if (!name || !content) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
error: { code: -32602, message: 'Missing required arguments: name and content' },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = { name, content };
|
||||
if (context?.sessionId) {
|
||||
body['createdBySession'] = context.sessionId;
|
||||
}
|
||||
await this.mcpdClient.post(
|
||||
`/api/v1/projects/${encodeURIComponent(this.projectName)}/promptrequests`,
|
||||
body,
|
||||
);
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Prompt request "${name}" created successfully. It will be visible to you as a resource at mcpctl://prompts/${name}. A user must approve it before it becomes permanent.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: request.id,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: `Failed to propose prompt: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getUpstreamNames(): string[] {
|
||||
return [...this.upstreams.keys()];
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ export interface ProxyConfig {
|
||||
export interface UpstreamConnection {
|
||||
/** Server name */
|
||||
name: string;
|
||||
/** Human-readable description of the server's purpose */
|
||||
description?: string;
|
||||
/** Send a JSON-RPC request and get a response */
|
||||
send(request: JsonRpcRequest): Promise<JsonRpcResponse>;
|
||||
/** Disconnect from the upstream */
|
||||
|
||||
@@ -18,14 +18,17 @@ interface McpdProxyResponse {
|
||||
*/
|
||||
export class McpdUpstream implements UpstreamConnection {
|
||||
readonly name: string;
|
||||
readonly description?: string;
|
||||
private alive = true;
|
||||
|
||||
constructor(
|
||||
private serverId: string,
|
||||
serverName: string,
|
||||
private mcpdClient: McpdClient,
|
||||
serverDescription?: string,
|
||||
) {
|
||||
this.name = serverName;
|
||||
if (serverDescription !== undefined) this.description = serverDescription;
|
||||
}
|
||||
|
||||
async send(request: JsonRpcRequest): Promise<JsonRpcResponse> {
|
||||
|
||||
@@ -11,7 +11,7 @@ vi.mock('../src/discovery.js', () => ({
|
||||
import { refreshProjectUpstreams } from '../src/discovery.js';
|
||||
|
||||
function mockMcpdClient() {
|
||||
return {
|
||||
const client: Record<string, unknown> = {
|
||||
baseUrl: 'http://test:3100',
|
||||
token: 'test-token',
|
||||
get: vi.fn(async () => []),
|
||||
@@ -19,7 +19,11 @@ function mockMcpdClient() {
|
||||
put: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
forward: vi.fn(async () => ({ status: 200, body: [] })),
|
||||
withHeaders: vi.fn(),
|
||||
};
|
||||
// withHeaders returns a new client-like object (returns self for simplicity)
|
||||
(client.withHeaders as ReturnType<typeof vi.fn>).mockReturnValue(client);
|
||||
return client;
|
||||
}
|
||||
|
||||
describe('registerProjectMcpEndpoint', () => {
|
||||
|
||||
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