feat: add prompt resources, fix MCP proxy transport, enrich tool descriptions
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled

- 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:
Michal
2026-02-24 14:53:00 +00:00
parent 7829f4fb92
commit 079c7b3dfa
32 changed files with 1713 additions and 94 deletions

View File

@@ -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);

View File

@@ -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',
};

View File

@@ -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);
}
};

View File

@@ -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()];
}

View File

@@ -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 */

View File

@@ -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> {

View File

@@ -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', () => {

View 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' },
);
});
});
});