diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts index 6d2aa34..915aeb3 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -76,13 +76,14 @@ const GroupSpecSchema = z.object({ }); const RbacSubjectSchema = z.object({ - kind: z.enum(['User', 'Group']), + kind: z.enum(['User', 'Group', 'ServiceAccount']), name: z.string().min(1), }); const RESOURCE_ALIASES: Record = { server: 'servers', instance: 'instances', secret: 'secrets', project: 'projects', template: 'templates', user: 'users', group: 'groups', + prompt: 'prompts', promptrequest: 'promptrequests', }; const RbacRoleBindingSchema = z.union([ @@ -103,9 +104,16 @@ const RbacBindingSpecSchema = z.object({ roleBindings: z.array(RbacRoleBindingSchema).default([]), }); +const PromptSpecSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), + content: z.string().min(1).max(50000), + projectId: z.string().optional(), +}); + const ProjectSpecSchema = z.object({ name: z.string().min(1), description: z.string().default(''), + prompt: z.string().max(10000).default(''), proxyMode: z.enum(['direct', 'filtered']).default('direct'), llmProvider: z.string().optional(), llmModel: z.string().optional(), @@ -121,6 +129,7 @@ const ApplyConfigSchema = z.object({ templates: z.array(TemplateSpecSchema).default([]), rbacBindings: z.array(RbacBindingSpecSchema).default([]), rbac: z.array(RbacBindingSpecSchema).default([]), + prompts: z.array(PromptSpecSchema).default([]), }).transform((data) => ({ ...data, // Merge rbac into rbacBindings so both keys work @@ -158,6 +167,7 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command { if (config.projects.length > 0) log(` ${config.projects.length} project(s)`); if (config.templates.length > 0) log(` ${config.templates.length} template(s)`); if (config.rbacBindings.length > 0) log(` ${config.rbacBindings.length} rbacBinding(s)`); + if (config.prompts.length > 0) log(` ${config.prompts.length} prompt(s)`); return; } @@ -292,6 +302,22 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args log(`Error applying rbacBinding '${rbacBinding.name}': ${err instanceof Error ? err.message : err}`); } } + + // Apply prompts + for (const prompt of config.prompts) { + try { + const existing = await findByName(client, 'prompts', prompt.name); + if (existing) { + await client.put(`/api/v1/prompts/${(existing as { id: string }).id}`, { content: prompt.content }); + log(`Updated prompt: ${prompt.name}`); + } else { + await client.post('/api/v1/prompts', prompt); + log(`Created prompt: ${prompt.name}`); + } + } catch (err) { + log(`Error applying prompt '${prompt.name}': ${err instanceof Error ? err.message : err}`); + } + } } async function findByName(client: ApiClient, resource: string, name: string): Promise { diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index 3a69f4d..8880cd8 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -198,6 +198,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .option('--proxy-mode ', 'Proxy mode (direct, filtered)') .option('--proxy-mode-llm-provider ', 'LLM provider name (for filtered proxy mode)') .option('--proxy-mode-llm-model ', 'LLM model name (for filtered proxy mode)') + .option('--prompt ', 'Project-level prompt / instructions for the LLM') .option('--server ', 'Server name (repeat for multiple)', collect, []) .option('--force', 'Update if already exists') .action(async (name: string, opts) => { @@ -206,6 +207,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { description: opts.description, proxyMode: opts.proxyMode ?? 'direct', }; + if (opts.prompt) body.prompt = opts.prompt; if (opts.proxyModeLlmProvider) body.llmProvider = opts.proxyModeLlmProvider; if (opts.proxyModeLlmModel) body.llmModel = opts.proxyModeLlmModel; if (opts.server.length > 0) body.servers = opts.server; @@ -347,5 +349,35 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { } }); + // --- create prompt --- + cmd.command('prompt') + .description('Create an approved prompt') + .argument('', 'Prompt name (lowercase alphanumeric with hyphens)') + .option('--project ', 'Project name to scope the prompt to') + .option('--content ', 'Prompt content text') + .option('--content-file ', 'Read prompt content from file') + .action(async (name: string, opts) => { + let content = opts.content as string | undefined; + if (opts.contentFile) { + const fs = await import('node:fs/promises'); + content = await fs.readFile(opts.contentFile as string, 'utf-8'); + } + if (!content) { + throw new Error('--content or --content-file is required'); + } + + const body: Record = { name, content }; + if (opts.project) { + // Resolve project name to ID + const projects = await client.get>('/api/v1/projects'); + const project = projects.find((p) => p.name === opts.project); + if (!project) throw new Error(`Project '${opts.project as string}' not found`); + body.projectId = project.id; + } + + const prompt = await client.post<{ id: string; name: string }>('/api/v1/prompts', body); + log(`prompt '${prompt.name}' created (id: ${prompt.id})`); + }); + return cmd; } diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index 4fe33ff..74cdc14 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -130,6 +130,36 @@ const templateColumns: Column[] = [ { header: 'DESCRIPTION', key: 'description', width: 50 }, ]; +interface PromptRow { + id: string; + name: string; + projectId: string | null; + createdAt: string; +} + +interface PromptRequestRow { + id: string; + name: string; + projectId: string | null; + createdBySession: string | null; + createdAt: string; +} + +const promptColumns: Column[] = [ + { header: 'NAME', key: 'name' }, + { header: 'PROJECT', key: (r) => r.projectId ?? '-', width: 20 }, + { header: 'CREATED', key: (r) => new Date(r.createdAt).toLocaleString(), width: 20 }, + { header: 'ID', key: 'id' }, +]; + +const promptRequestColumns: Column[] = [ + { header: 'NAME', key: 'name' }, + { header: 'PROJECT', key: (r) => r.projectId ?? '-', width: 20 }, + { header: 'SESSION', key: (r) => r.createdBySession ? r.createdBySession.slice(0, 12) : '-', width: 14 }, + { header: 'CREATED', key: (r) => new Date(r.createdAt).toLocaleString(), width: 20 }, + { header: 'ID', key: 'id' }, +]; + const instanceColumns: Column[] = [ { header: 'NAME', key: (r) => r.server?.name ?? '-', width: 20 }, { header: 'STATUS', key: 'status', width: 10 }, @@ -157,6 +187,10 @@ function getColumnsForResource(resource: string): Column return groupColumns as unknown as Column>[]; case 'rbac': return rbacColumns as unknown as Column>[]; + case 'prompts': + return promptColumns as unknown as Column>[]; + case 'promptrequests': + return promptRequestColumns as unknown as Column>[]; default: return [ { header: 'ID', key: 'id' as keyof Record }, diff --git a/src/cli/src/commands/project-ops.ts b/src/cli/src/commands/project-ops.ts index dde5fd2..f8c4a8c 100644 --- a/src/cli/src/commands/project-ops.ts +++ b/src/cli/src/commands/project-ops.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import type { ApiClient } from '../api-client.js'; -import { resolveNameOrId } from './shared.js'; +import { resolveNameOrId, resolveResource } from './shared.js'; export interface ProjectOpsDeps { client: ApiClient; @@ -45,3 +45,22 @@ export function createDetachServerCommand(deps: ProjectOpsDeps): Command { log(`server '${serverName}' detached from project '${projectName}'`); }); } + +export function createApproveCommand(deps: ProjectOpsDeps): Command { + const { client, log } = deps; + + return new Command('approve') + .description('Approve a pending prompt request (atomic: delete request, create prompt)') + .argument('', 'Resource type (promptrequest)') + .argument('', 'Prompt request name or ID') + .action(async (resourceArg: string, nameOrId: string) => { + const resource = resolveResource(resourceArg); + if (resource !== 'promptrequests') { + throw new Error(`approve is only supported for 'promptrequest', got '${resourceArg}'`); + } + + const id = await resolveNameOrId(client, 'promptrequests', nameOrId); + const prompt = await client.post<{ id: string; name: string }>(`/api/v1/promptrequests/${id}/approve`, {}); + log(`prompt request approved → prompt '${prompt.name}' created (id: ${prompt.id})`); + }); +} diff --git a/src/cli/src/commands/shared.ts b/src/cli/src/commands/shared.ts index f06ce89..84ae677 100644 --- a/src/cli/src/commands/shared.ts +++ b/src/cli/src/commands/shared.ts @@ -16,6 +16,11 @@ export const RESOURCE_ALIASES: Record = { rbac: 'rbac', 'rbac-definition': 'rbac', 'rbac-binding': 'rbac', + prompt: 'prompts', + prompts: 'prompts', + promptrequest: 'promptrequests', + promptrequests: 'promptrequests', + pr: 'promptrequests', }; export function resolveResource(name: string): string { diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 17a5632..d36b5f1 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -12,7 +12,7 @@ import { createCreateCommand } from './commands/create.js'; import { createEditCommand } from './commands/edit.js'; import { createBackupCommand, createRestoreCommand } from './commands/backup.js'; import { createLoginCommand, createLogoutCommand } from './commands/auth.js'; -import { createAttachServerCommand, createDetachServerCommand } from './commands/project-ops.js'; +import { createAttachServerCommand, createDetachServerCommand, createApproveCommand } from './commands/project-ops.js'; import { createMcpCommand } from './commands/mcp.js'; import { ApiClient, ApiError } from './api-client.js'; import { loadConfig } from './config/index.js'; @@ -151,6 +151,7 @@ export function createProgram(): Command { }; program.addCommand(createAttachServerCommand(projectOpsDeps), { hidden: true }); program.addCommand(createDetachServerCommand(projectOpsDeps), { hidden: true }); + program.addCommand(createApproveCommand(projectOpsDeps)); program.addCommand(createMcpCommand({ getProject: () => program.opts().project as string | undefined, }), { hidden: true }); diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index 13243ae..c88fd15 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -170,6 +170,7 @@ model Project { id String @id @default(cuid()) name String @unique description String @default("") + prompt String @default("") proxyMode String @default("direct") llmProvider String? llmModel String? @@ -178,8 +179,10 @@ model Project { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) - servers ProjectServer[] + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + servers ProjectServer[] + prompts Prompt[] + promptRequests PromptRequest[] @@index([name]) @@index([ownerId]) @@ -227,6 +230,41 @@ enum InstanceStatus { ERROR } +// ── Prompts (approved content resources) ── + +model Prompt { + id String @id @default(cuid()) + name String + content String @db.Text + projectId String? + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([name, projectId]) + @@index([projectId]) +} + +// ── Prompt Requests (pending proposals from LLM sessions) ── + +model PromptRequest { + id String @id @default(cuid()) + name String + content String @db.Text + projectId String? + createdBySession String? + createdByUserId String? + createdAt DateTime @default(now()) + + project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@unique([name, projectId]) + @@index([projectId]) + @@index([createdBySession]) +} + // ── Audit Logs ── model AuditLog { diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 31e5af4..a0b2b6d 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -18,6 +18,8 @@ import { UserRepository, GroupRepository, } from './repositories/index.js'; +import { PromptRepository } from './repositories/prompt.repository.js'; +import { PromptRequestRepository } from './repositories/prompt-request.repository.js'; import { McpServerService, SecretService, @@ -56,6 +58,8 @@ import { registerUserRoutes, registerGroupRoutes, } from './routes/index.js'; +import { registerPromptRoutes } from './routes/prompts.js'; +import { PromptService } from './services/prompt.service.js'; type PermissionCheck = | { kind: 'resource'; resource: string; action: RbacAction; resourceName?: string } @@ -88,11 +92,38 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { 'rbac': 'rbac', 'audit-logs': 'rbac', 'mcp': 'servers', + 'prompts': 'prompts', + 'promptrequests': 'promptrequests', }; const resource = resourceMap[segment]; if (resource === undefined) return { kind: 'skip' }; + // Special case: /api/v1/promptrequests/:id/approve → needs both delete+promptrequests and create+prompts + // We check delete on promptrequests (the harder permission); create on prompts is checked in the service layer + const approveMatch = url.match(/^\/api\/v1\/promptrequests\/([^/?]+)\/approve/); + if (approveMatch?.[1]) { + return { kind: 'resource', resource: 'promptrequests', action: 'delete', resourceName: approveMatch[1] }; + } + + // Special case: /api/v1/projects/:name/prompts/visible → view prompts + const visiblePromptsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/prompts\/visible/); + if (visiblePromptsMatch?.[1]) { + return { kind: 'resource', resource: 'prompts', action: 'view' }; + } + + // Special case: /api/v1/projects/:name/promptrequests → create promptrequests + const projectPromptrequestsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/promptrequests/); + if (projectPromptrequestsMatch?.[1] && method === 'POST') { + return { kind: 'resource', resource: 'promptrequests', action: 'create' }; + } + + // Special case: /api/v1/projects/:id/instructions → view projects + const instructionsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/instructions/); + if (instructionsMatch?.[1]) { + return { kind: 'resource', resource: 'projects', action: 'view', resourceName: instructionsMatch[1] }; + } + // Special case: /api/v1/projects/:id/mcp-config → requires 'expose' permission const mcpConfigMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/mcp-config/); if (mcpConfigMatch?.[1]) { @@ -243,11 +274,14 @@ async function main(): Promise { const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo); const authService = new AuthService(prisma); const templateService = new TemplateService(templateRepo); - const mcpProxyService = new McpProxyService(instanceRepo, serverRepo); + const mcpProxyService = new McpProxyService(instanceRepo, serverRepo, orchestrator); const rbacDefinitionService = new RbacDefinitionService(rbacDefinitionRepo); const rbacService = new RbacService(rbacDefinitionRepo, prisma); const userService = new UserService(userRepo); const groupService = new GroupService(groupRepo, userRepo); + const promptRepo = new PromptRepository(prisma); + const promptRequestRepo = new PromptRequestRepository(prisma); + const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo); // Auth middleware for global hooks const authMiddleware = createAuthMiddleware({ @@ -294,9 +328,13 @@ async function main(): Promise { const check = mapUrlToPermission(request.method, url); if (check.kind === 'skip') return; + // Extract service account identity from header (sent by mcplocal) + const saHeader = request.headers['x-service-account']; + const serviceAccountName = typeof saHeader === 'string' ? saHeader : undefined; + let allowed: boolean; if (check.kind === 'operation') { - allowed = await rbacService.canRunOperation(request.userId, check.operation); + allowed = await rbacService.canRunOperation(request.userId, check.operation, serviceAccountName); } else { // Resolve CUID → human name for name-scoped RBAC bindings if (check.resourceName !== undefined && CUID_RE.test(check.resourceName)) { @@ -306,10 +344,10 @@ async function main(): Promise { if (entity) check.resourceName = entity.name; } } - allowed = await rbacService.canAccess(request.userId, check.action, check.resource, check.resourceName); + allowed = await rbacService.canAccess(request.userId, check.action, check.resource, check.resourceName, serviceAccountName); // Compute scope for list filtering (used by preSerialization hook) if (allowed && check.resourceName === undefined) { - request.rbacScope = await rbacService.getAllowedScope(request.userId, check.action, check.resource); + request.rbacScope = await rbacService.getAllowedScope(request.userId, check.action, check.resource, serviceAccountName); } } if (!allowed) { @@ -335,6 +373,7 @@ async function main(): Promise { registerRbacRoutes(app, rbacDefinitionService); registerUserRoutes(app, userService); registerGroupRoutes(app, groupService); + registerPromptRoutes(app, promptService, projectRepo); // ── RBAC list filtering hook ── // Filters array responses to only include resources the user is allowed to see. diff --git a/src/mcpd/src/repositories/project.repository.ts b/src/mcpd/src/repositories/project.repository.ts index 43f7861..f782cb3 100644 --- a/src/mcpd/src/repositories/project.repository.ts +++ b/src/mcpd/src/repositories/project.repository.ts @@ -12,7 +12,7 @@ export interface IProjectRepository { findAll(ownerId?: string): Promise; findById(id: string): Promise; findByName(name: string): Promise; - create(data: { name: string; description: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string }): Promise; + create(data: { name: string; description: string; prompt?: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string }): Promise; update(id: string, data: Record): Promise; delete(id: string): Promise; setServers(projectId: string, serverIds: string[]): Promise; @@ -36,13 +36,14 @@ export class ProjectRepository implements IProjectRepository { return this.prisma.project.findUnique({ where: { name }, include: PROJECT_INCLUDE }) as unknown as Promise; } - async create(data: { name: string; description: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string }): Promise { + async create(data: { name: string; description: string; prompt?: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string }): Promise { const createData: Record = { name: data.name, description: data.description, ownerId: data.ownerId, proxyMode: data.proxyMode, }; + if (data.prompt !== undefined) createData['prompt'] = data.prompt; if (data.llmProvider !== undefined) createData['llmProvider'] = data.llmProvider; if (data.llmModel !== undefined) createData['llmModel'] = data.llmModel; diff --git a/src/mcpd/src/repositories/prompt-request.repository.ts b/src/mcpd/src/repositories/prompt-request.repository.ts new file mode 100644 index 0000000..49fddd6 --- /dev/null +++ b/src/mcpd/src/repositories/prompt-request.repository.ts @@ -0,0 +1,53 @@ +import type { PrismaClient, PromptRequest } from '@prisma/client'; + +export interface IPromptRequestRepository { + findAll(projectId?: string): Promise; + findById(id: string): Promise; + findByNameAndProject(name: string, projectId: string | null): Promise; + findBySession(sessionId: string, projectId?: string): Promise; + create(data: { name: string; content: string; projectId?: string; createdBySession?: string; createdByUserId?: string }): Promise; + delete(id: string): Promise; +} + +export class PromptRequestRepository implements IPromptRequestRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(projectId?: string): Promise { + if (projectId !== undefined) { + return this.prisma.promptRequest.findMany({ + where: { OR: [{ projectId }, { projectId: null }] }, + orderBy: { createdAt: 'desc' }, + }); + } + return this.prisma.promptRequest.findMany({ orderBy: { createdAt: 'desc' } }); + } + + async findById(id: string): Promise { + return this.prisma.promptRequest.findUnique({ where: { id } }); + } + + async findByNameAndProject(name: string, projectId: string | null): Promise { + return this.prisma.promptRequest.findUnique({ + where: { name_projectId: { name, projectId: projectId ?? '' } }, + }); + } + + async findBySession(sessionId: string, projectId?: string): Promise { + const where: Record = { createdBySession: sessionId }; + if (projectId !== undefined) { + where['OR'] = [{ projectId }, { projectId: null }]; + } + return this.prisma.promptRequest.findMany({ + where, + orderBy: { createdAt: 'desc' }, + }); + } + + async create(data: { name: string; content: string; projectId?: string; createdBySession?: string; createdByUserId?: string }): Promise { + return this.prisma.promptRequest.create({ data }); + } + + async delete(id: string): Promise { + await this.prisma.promptRequest.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/repositories/prompt.repository.ts b/src/mcpd/src/repositories/prompt.repository.ts new file mode 100644 index 0000000..b09eb9f --- /dev/null +++ b/src/mcpd/src/repositories/prompt.repository.ts @@ -0,0 +1,47 @@ +import type { PrismaClient, Prompt } from '@prisma/client'; + +export interface IPromptRepository { + findAll(projectId?: string): Promise; + findById(id: string): Promise; + findByNameAndProject(name: string, projectId: string | null): Promise; + create(data: { name: string; content: string; projectId?: string }): Promise; + update(id: string, data: { content?: string }): Promise; + delete(id: string): Promise; +} + +export class PromptRepository implements IPromptRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(projectId?: string): Promise { + if (projectId !== undefined) { + // Project-scoped + global prompts + return this.prisma.prompt.findMany({ + where: { OR: [{ projectId }, { projectId: null }] }, + orderBy: { name: 'asc' }, + }); + } + return this.prisma.prompt.findMany({ orderBy: { name: 'asc' } }); + } + + async findById(id: string): Promise { + return this.prisma.prompt.findUnique({ where: { id } }); + } + + async findByNameAndProject(name: string, projectId: string | null): Promise { + return this.prisma.prompt.findUnique({ + where: { name_projectId: { name, projectId: projectId ?? '' } }, + }); + } + + async create(data: { name: string; content: string; projectId?: string }): Promise { + return this.prisma.prompt.create({ data }); + } + + async update(id: string, data: { content?: string }): Promise { + return this.prisma.prompt.update({ where: { id }, data }); + } + + async delete(id: string): Promise { + await this.prisma.prompt.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/routes/projects.ts b/src/mcpd/src/routes/projects.ts index 8e4c269..c49ed37 100644 --- a/src/mcpd/src/routes/projects.ts +++ b/src/mcpd/src/routes/projects.ts @@ -54,4 +54,16 @@ export function registerProjectRoutes(app: FastifyInstance, service: ProjectServ const project = await service.resolveAndGet(request.params.id); return project.servers.map((ps) => ps.server); }); + + // Get project instructions for LLM (prompt + server list) + app.get<{ Params: { id: string } }>('/api/v1/projects/:id/instructions', async (request) => { + const project = await service.resolveAndGet(request.params.id); + return { + prompt: project.prompt, + servers: project.servers.map((ps) => ({ + name: (ps.server as Record).name as string, + description: (ps.server as Record).description as string, + })), + }; + }); } diff --git a/src/mcpd/src/routes/prompts.ts b/src/mcpd/src/routes/prompts.ts new file mode 100644 index 0000000..afb3b46 --- /dev/null +++ b/src/mcpd/src/routes/prompts.ts @@ -0,0 +1,86 @@ +import type { FastifyInstance } from 'fastify'; +import type { PromptService } from '../services/prompt.service.js'; +import type { IProjectRepository } from '../repositories/project.repository.js'; + +export function registerPromptRoutes( + app: FastifyInstance, + service: PromptService, + projectRepo: IProjectRepository, +): void { + // ── Prompts (approved) ── + + app.get('/api/v1/prompts', async () => { + return service.listPrompts(); + }); + + app.get<{ Params: { id: string } }>('/api/v1/prompts/:id', async (request) => { + return service.getPrompt(request.params.id); + }); + + app.post('/api/v1/prompts', async (request, reply) => { + const prompt = await service.createPrompt(request.body); + reply.code(201); + return prompt; + }); + + app.put<{ Params: { id: string } }>('/api/v1/prompts/:id', async (request) => { + return service.updatePrompt(request.params.id, request.body); + }); + + app.delete<{ Params: { id: string } }>('/api/v1/prompts/:id', async (request, reply) => { + await service.deletePrompt(request.params.id); + reply.code(204); + }); + + // ── Prompt Requests (pending proposals) ── + + app.get('/api/v1/promptrequests', async () => { + return service.listPromptRequests(); + }); + + app.get<{ Params: { id: string } }>('/api/v1/promptrequests/:id', async (request) => { + return service.getPromptRequest(request.params.id); + }); + + app.delete<{ Params: { id: string } }>('/api/v1/promptrequests/:id', async (request, reply) => { + await service.deletePromptRequest(request.params.id); + reply.code(204); + }); + + // Approve: atomic delete request → create prompt + app.post<{ Params: { id: string } }>('/api/v1/promptrequests/:id/approve', async (request) => { + return service.approve(request.params.id); + }); + + // ── Project-scoped endpoints (for mcplocal) ── + + // Visible prompts: approved + session's pending requests + app.get<{ Params: { name: string }; Querystring: { session?: string } }>( + '/api/v1/projects/:name/prompts/visible', + async (request) => { + const project = await projectRepo.findByName(request.params.name); + if (!project) { + throw Object.assign(new Error(`Project not found: ${request.params.name}`), { statusCode: 404 }); + } + return service.getVisiblePrompts(project.id, request.query.session); + }, + ); + + // LLM propose: create a PromptRequest for a project + app.post<{ Params: { name: string } }>( + '/api/v1/projects/:name/promptrequests', + async (request, reply) => { + const project = await projectRepo.findByName(request.params.name); + if (!project) { + throw Object.assign(new Error(`Project not found: ${request.params.name}`), { statusCode: 404 }); + } + const body = request.body as Record; + const req = await service.propose({ + ...body, + projectId: project.id, + }); + reply.code(201); + return req; + }, + ); +} diff --git a/src/mcpd/src/services/mcp-proxy-service.ts b/src/mcpd/src/services/mcp-proxy-service.ts index dfee93d..b6ab0a4 100644 --- a/src/mcpd/src/services/mcp-proxy-service.ts +++ b/src/mcpd/src/services/mcp-proxy-service.ts @@ -1,7 +1,10 @@ -import type { McpInstance } from '@prisma/client'; +import type { McpInstance, McpServer } from '@prisma/client'; import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js'; +import type { McpOrchestrator } from './orchestrator.js'; import { NotFoundError } from './mcp-server.service.js'; import { InvalidStateError } from './instance.service.js'; +import { sendViaSse } from './transport/sse-client.js'; +import { sendViaStdio } from './transport/stdio-client.js'; export interface McpProxyRequest { serverId: string; @@ -38,17 +41,21 @@ export class McpProxyService { constructor( private readonly instanceRepo: IMcpInstanceRepository, private readonly serverRepo: IMcpServerRepository, + private readonly orchestrator?: McpOrchestrator, ) {} async execute(request: McpProxyRequest): Promise { const server = await this.serverRepo.findById(request.serverId); - - // External server: proxy directly to externalUrl - if (server?.externalUrl) { - return this.sendToExternal(server.id, server.externalUrl, request.method, request.params); + if (!server) { + throw new NotFoundError(`Server '${request.serverId}' not found`); } - // Managed server: find running instance + // External server: proxy directly to externalUrl + if (server.externalUrl) { + return this.sendToExternal(server, request.method, request.params); + } + + // Managed server: find running instance and dispatch by transport const instances = await this.instanceRepo.findAll(request.serverId); const running = instances.find((i) => i.status === 'RUNNING'); @@ -56,20 +63,95 @@ export class McpProxyService { throw new NotFoundError(`No running instance found for server '${request.serverId}'`); } - if (running.port === null || running.port === undefined) { - throw new InvalidStateError( - `Running instance '${running.id}' for server '${request.serverId}' has no port assigned`, - ); - } - - return this.sendJsonRpc(running, request.method, request.params); + return this.sendToManaged(server, running, request.method, request.params); } /** - * Send a JSON-RPC request to an external MCP server. - * Handles streamable-http protocol (session management + SSE response parsing). + * Send to an external MCP server. Dispatches based on transport type. */ private async sendToExternal( + server: McpServer, + method: string, + params?: Record, + ): Promise { + const url = server.externalUrl as string; + + if (server.transport === 'SSE') { + return sendViaSse(url, method, params); + } + + // STREAMABLE_HTTP (default for external) + return this.sendStreamableHttp(server.id, url, method, params); + } + + /** + * Send to a managed (containerized) MCP server. Dispatches based on transport type. + */ + private async sendToManaged( + server: McpServer, + instance: McpInstance, + method: string, + params?: Record, + ): Promise { + const transport = server.transport as string; + + // STDIO: use docker exec + if (transport === 'STDIO') { + if (!this.orchestrator) { + throw new InvalidStateError('Orchestrator required for STDIO transport'); + } + if (!instance.containerId) { + throw new InvalidStateError(`Instance '${instance.id}' has no container ID`); + } + const packageName = server.packageName as string | null; + if (!packageName) { + throw new InvalidStateError(`Server '${server.id}' has no package name for STDIO transport`); + } + return sendViaStdio(this.orchestrator, instance.containerId, packageName, method, params); + } + + // SSE or STREAMABLE_HTTP: need a base URL + const baseUrl = await this.resolveBaseUrl(instance, server); + + if (transport === 'SSE') { + return sendViaSse(baseUrl, method, params); + } + + // STREAMABLE_HTTP (default) + return this.sendStreamableHttp(server.id, baseUrl, method, params); + } + + /** + * Resolve the base URL for an HTTP-based managed server. + * Prefers container internal IP on Docker network, falls back to localhost:port. + */ + private async resolveBaseUrl(instance: McpInstance, server: McpServer): Promise { + const containerPort = (server.containerPort as number | null) ?? 3000; + + if (this.orchestrator && instance.containerId) { + try { + const containerInfo = await this.orchestrator.inspectContainer(instance.containerId); + if (containerInfo.ip) { + return `http://${containerInfo.ip}:${containerPort}`; + } + } catch { + // Fall through to localhost + } + } + + if (instance.port !== null && instance.port !== undefined) { + return `http://localhost:${instance.port}`; + } + + throw new InvalidStateError( + `Cannot resolve URL for instance '${instance.id}': no container IP or host port`, + ); + } + + /** + * Send via streamable-http protocol with session management. + */ + private async sendStreamableHttp( serverId: string, url: string, method: string, @@ -109,14 +191,14 @@ export class McpProxyService { // Session expired? Clear and retry once if (response.status === 400 || response.status === 404) { this.sessions.delete(serverId); - return this.sendToExternal(serverId, url, method, params); + return this.sendStreamableHttp(serverId, url, method, params); } return { jsonrpc: '2.0', id: 1, error: { code: -32000, - message: `External MCP server returned HTTP ${response.status}: ${response.statusText}`, + message: `MCP server returned HTTP ${response.status}: ${response.statusText}`, }, }; } @@ -126,8 +208,7 @@ export class McpProxyService { } /** - * Initialize a streamable-http session with an external server. - * Sends `initialize` and `notifications/initialized`, caches the session ID. + * Initialize a streamable-http session with a server. */ private async initSession(serverId: string, url: string): Promise { const initBody = { @@ -174,41 +255,4 @@ export class McpProxyService { body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }), }); } - - private async sendJsonRpc( - instance: McpInstance, - method: string, - params?: Record, - ): Promise { - const url = `http://localhost:${instance.port}`; - - const body: Record = { - jsonrpc: '2.0', - id: 1, - method, - }; - if (params !== undefined) { - body.params = params; - } - - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - return { - jsonrpc: '2.0', - id: 1, - error: { - code: -32000, - message: `MCP server returned HTTP ${response.status}: ${response.statusText}`, - }, - }; - } - - const result = (await response.json()) as McpProxyResponse; - return result; - } } diff --git a/src/mcpd/src/services/project.service.ts b/src/mcpd/src/services/project.service.ts index 8f0ec12..8c35492 100644 --- a/src/mcpd/src/services/project.service.ts +++ b/src/mcpd/src/services/project.service.ts @@ -53,6 +53,7 @@ export class ProjectService { const project = await this.projectRepo.create({ name: data.name, description: data.description, + prompt: data.prompt, ownerId, proxyMode: data.proxyMode, ...(data.llmProvider !== undefined ? { llmProvider: data.llmProvider } : {}), @@ -75,6 +76,7 @@ export class ProjectService { // Build update data for scalar fields const updateData: Record = {}; if (data.description !== undefined) updateData['description'] = data.description; + if (data.prompt !== undefined) updateData['prompt'] = data.prompt; if (data.proxyMode !== undefined) updateData['proxyMode'] = data.proxyMode; if (data.llmProvider !== undefined) updateData['llmProvider'] = data.llmProvider; if (data.llmModel !== undefined) updateData['llmModel'] = data.llmModel; diff --git a/src/mcpd/src/services/prompt.service.ts b/src/mcpd/src/services/prompt.service.ts new file mode 100644 index 0000000..b815cc7 --- /dev/null +++ b/src/mcpd/src/services/prompt.service.ts @@ -0,0 +1,137 @@ +import type { Prompt, PromptRequest } from '@prisma/client'; +import type { IPromptRepository } from '../repositories/prompt.repository.js'; +import type { IPromptRequestRepository } from '../repositories/prompt-request.repository.js'; +import type { IProjectRepository } from '../repositories/project.repository.js'; +import { CreatePromptSchema, UpdatePromptSchema, CreatePromptRequestSchema } from '../validation/prompt.schema.js'; +import { NotFoundError } from './mcp-server.service.js'; + +export class PromptService { + constructor( + private readonly promptRepo: IPromptRepository, + private readonly promptRequestRepo: IPromptRequestRepository, + private readonly projectRepo: IProjectRepository, + ) {} + + // ── Prompt CRUD ── + + async listPrompts(projectId?: string): Promise { + return this.promptRepo.findAll(projectId); + } + + async getPrompt(id: string): Promise { + const prompt = await this.promptRepo.findById(id); + if (prompt === null) throw new NotFoundError(`Prompt not found: ${id}`); + return prompt; + } + + async createPrompt(input: unknown): Promise { + const data = CreatePromptSchema.parse(input); + + if (data.projectId) { + const project = await this.projectRepo.findById(data.projectId); + if (project === null) throw new NotFoundError(`Project not found: ${data.projectId}`); + } + + const createData: { name: string; content: string; projectId?: string } = { + name: data.name, + content: data.content, + }; + if (data.projectId !== undefined) createData.projectId = data.projectId; + return this.promptRepo.create(createData); + } + + async updatePrompt(id: string, input: unknown): Promise { + const data = UpdatePromptSchema.parse(input); + await this.getPrompt(id); + const updateData: { content?: string } = {}; + if (data.content !== undefined) updateData.content = data.content; + return this.promptRepo.update(id, updateData); + } + + async deletePrompt(id: string): Promise { + await this.getPrompt(id); + await this.promptRepo.delete(id); + } + + // ── PromptRequest CRUD ── + + async listPromptRequests(projectId?: string): Promise { + return this.promptRequestRepo.findAll(projectId); + } + + async getPromptRequest(id: string): Promise { + const req = await this.promptRequestRepo.findById(id); + if (req === null) throw new NotFoundError(`PromptRequest not found: ${id}`); + return req; + } + + async deletePromptRequest(id: string): Promise { + await this.getPromptRequest(id); + await this.promptRequestRepo.delete(id); + } + + // ── Propose (LLM creates a PromptRequest) ── + + async propose(input: unknown): Promise { + const data = CreatePromptRequestSchema.parse(input); + + if (data.projectId) { + const project = await this.projectRepo.findById(data.projectId); + if (project === null) throw new NotFoundError(`Project not found: ${data.projectId}`); + } + + const createData: { name: string; content: string; projectId?: string; createdBySession?: string; createdByUserId?: string } = { + name: data.name, + content: data.content, + }; + if (data.projectId !== undefined) createData.projectId = data.projectId; + if (data.createdBySession !== undefined) createData.createdBySession = data.createdBySession; + if (data.createdByUserId !== undefined) createData.createdByUserId = data.createdByUserId; + return this.promptRequestRepo.create(createData); + } + + // ── Approve (delete PromptRequest → create Prompt) ── + + async approve(requestId: string): Promise { + const req = await this.getPromptRequest(requestId); + + // Create the approved prompt + const createData: { name: string; content: string; projectId?: string } = { + name: req.name, + content: req.content, + }; + if (req.projectId !== null) createData.projectId = req.projectId; + + const prompt = await this.promptRepo.create(createData); + + // Delete the request + await this.promptRequestRepo.delete(requestId); + + return prompt; + } + + // ── Visibility for MCP (approved prompts + session's pending requests) ── + + async getVisiblePrompts( + projectId?: string, + sessionId?: string, + ): Promise> { + const results: Array<{ name: string; content: string; type: 'prompt' | 'promptrequest' }> = []; + + // Approved prompts (project-scoped + global) + const prompts = await this.promptRepo.findAll(projectId); + for (const p of prompts) { + results.push({ name: p.name, content: p.content, type: 'prompt' }); + } + + // Session's own pending requests + if (sessionId) { + const requests = await this.promptRequestRepo.findBySession(sessionId, projectId); + for (const r of requests) { + results.push({ name: r.name, content: r.content, type: 'promptrequest' }); + } + } + + return results; + } +} diff --git a/src/mcpd/src/services/rbac.service.ts b/src/mcpd/src/services/rbac.service.ts index ce5663f..bbb437d 100644 --- a/src/mcpd/src/services/rbac.service.ts +++ b/src/mcpd/src/services/rbac.service.ts @@ -50,8 +50,8 @@ export class RbacService { * If provided, name-scoped bindings only match when their name equals this. * If omitted (listing), name-scoped bindings still grant access. */ - async canAccess(userId: string, action: RbacAction, resource: string, resourceName?: string): Promise { - const permissions = await this.getPermissions(userId); + async canAccess(userId: string, action: RbacAction, resource: string, resourceName?: string, serviceAccountName?: string): Promise { + const permissions = await this.getPermissions(userId, serviceAccountName); const normalized = normalizeResource(resource); for (const perm of permissions) { @@ -73,8 +73,8 @@ export class RbacService { * Check whether a user is allowed to perform a named operation. * Operations require an explicit 'run' role binding with a matching action. */ - async canRunOperation(userId: string, operation: string): Promise { - const permissions = await this.getPermissions(userId); + async canRunOperation(userId: string, operation: string, serviceAccountName?: string): Promise { + const permissions = await this.getPermissions(userId, serviceAccountName); for (const perm of permissions) { if ('action' in perm && perm.role === 'run' && perm.action === operation) { @@ -90,8 +90,8 @@ export class RbacService { * Returns wildcard:true if any matching binding is unscoped (no name constraint). * Returns wildcard:false with a set of allowed names if all bindings are name-scoped. */ - async getAllowedScope(userId: string, action: RbacAction, resource: string): Promise { - const permissions = await this.getPermissions(userId); + async getAllowedScope(userId: string, action: RbacAction, resource: string, serviceAccountName?: string): Promise { + const permissions = await this.getPermissions(userId, serviceAccountName); const normalized = normalizeResource(resource); const names = new Set(); @@ -113,31 +113,35 @@ export class RbacService { /** * Collect all permissions for a user across all matching RbacDefinitions. */ - async getPermissions(userId: string): Promise { + async getPermissions(userId: string, serviceAccountName?: string): Promise { // 1. Resolve user email const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { email: true }, }); - if (user === null) return []; + if (user === null && serviceAccountName === undefined) return []; // 2. Resolve group names the user belongs to - const memberships = await this.prisma.groupMember.findMany({ - where: { userId }, - select: { group: { select: { name: true } } }, - }); - const groupNames = memberships.map((m) => m.group.name); + let groupNames: string[] = []; + if (user !== null) { + const memberships = await this.prisma.groupMember.findMany({ + where: { userId }, + select: { group: { select: { name: true } } }, + }); + groupNames = memberships.map((m) => m.group.name); + } // 3. Load all RbacDefinitions const definitions = await this.rbacRepo.findAll(); - // 4. Find definitions where user is a subject + // 4. Find definitions where user or service account is a subject const permissions: Permission[] = []; for (const def of definitions) { const subjects = def.subjects as RbacSubject[]; const matched = subjects.some((s) => { - if (s.kind === 'User') return s.name === user.email; + if (s.kind === 'User') return user !== null && s.name === user.email; if (s.kind === 'Group') return groupNames.includes(s.name); + if (s.kind === 'ServiceAccount') return serviceAccountName !== undefined && s.name === serviceAccountName; return false; }); diff --git a/src/mcpd/src/services/transport/index.ts b/src/mcpd/src/services/transport/index.ts new file mode 100644 index 0000000..9cc7c30 --- /dev/null +++ b/src/mcpd/src/services/transport/index.ts @@ -0,0 +1,2 @@ +export { sendViaSse } from './sse-client.js'; +export { sendViaStdio } from './stdio-client.js'; diff --git a/src/mcpd/src/services/transport/sse-client.ts b/src/mcpd/src/services/transport/sse-client.ts new file mode 100644 index 0000000..d860aec --- /dev/null +++ b/src/mcpd/src/services/transport/sse-client.ts @@ -0,0 +1,150 @@ +import type { McpProxyResponse } from '../mcp-proxy-service.js'; + +/** + * SSE transport client for MCP servers using the legacy SSE protocol. + * + * Protocol: GET /sse → endpoint event with messages URL → POST to messages URL. + * Responses come back on the SSE stream, matched by JSON-RPC request ID. + * + * Each call opens a fresh SSE connection, initializes, sends the request, + * reads the response, and closes. Session caching may be added later. + */ +export async function sendViaSse( + baseUrl: string, + method: string, + params?: Record, + timeoutMs = 30_000, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + // 1. GET /sse → SSE stream + const sseResp = await fetch(`${baseUrl}/sse`, { + method: 'GET', + headers: { 'Accept': 'text/event-stream' }, + signal: controller.signal, + }); + + if (!sseResp.ok) { + return errorResponse(`SSE connect failed: HTTP ${sseResp.status}`); + } + + const reader = sseResp.body?.getReader(); + if (!reader) { + return errorResponse('No SSE stream body'); + } + + // 2. Read until we get the endpoint event with messages URL + const decoder = new TextDecoder(); + let buffer = ''; + let messagesUrl = ''; + + while (!messagesUrl) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + for (const line of buffer.split('\n')) { + if (line.startsWith('data: ') && buffer.includes('event: endpoint')) { + const endpoint = line.slice(6).trim(); + messagesUrl = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`; + } + } + const lines = buffer.split('\n'); + buffer = lines[lines.length - 1] ?? ''; + } + + if (!messagesUrl) { + reader.cancel(); + return errorResponse('No endpoint event from SSE stream'); + } + + const postHeaders = { 'Content-Type': 'application/json' }; + + // 3. Initialize + const initResp = await fetch(messagesUrl, { + method: 'POST', + headers: postHeaders, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'mcpctl-proxy', version: '0.1.0' }, + }, + }), + signal: controller.signal, + }); + + if (!initResp.ok) { + reader.cancel(); + return errorResponse(`SSE initialize failed: HTTP ${initResp.status}`); + } + + // 4. Send notifications/initialized + await fetch(messagesUrl, { + method: 'POST', + headers: postHeaders, + body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }), + signal: controller.signal, + }); + + // 5. Send the actual request + const requestId = 2; + await fetch(messagesUrl, { + method: 'POST', + headers: postHeaders, + body: JSON.stringify({ + jsonrpc: '2.0', + id: requestId, + method, + ...(params !== undefined ? { params } : {}), + }), + signal: controller.signal, + }); + + // 6. Read response from SSE stream (matched by request ID) + let responseBuffer = ''; + const readTimeout = setTimeout(() => reader.cancel(), 5000); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + responseBuffer += decoder.decode(value, { stream: true }); + + for (const line of responseBuffer.split('\n')) { + if (line.startsWith('data: ')) { + try { + const parsed = JSON.parse(line.slice(6)) as McpProxyResponse; + if (parsed.id === requestId) { + clearTimeout(readTimeout); + reader.cancel(); + return parsed; + } + } catch { + // Not valid JSON, skip + } + } + } + const respLines = responseBuffer.split('\n'); + responseBuffer = respLines[respLines.length - 1] ?? ''; + } + + clearTimeout(readTimeout); + reader.cancel(); + return errorResponse('No response received from SSE stream'); + } finally { + clearTimeout(timer); + } +} + +function errorResponse(message: string): McpProxyResponse { + return { + jsonrpc: '2.0', + id: 1, + error: { code: -32000, message }, + }; +} diff --git a/src/mcpd/src/services/transport/stdio-client.ts b/src/mcpd/src/services/transport/stdio-client.ts new file mode 100644 index 0000000..bebb9ec --- /dev/null +++ b/src/mcpd/src/services/transport/stdio-client.ts @@ -0,0 +1,118 @@ +import type { McpOrchestrator } from '../orchestrator.js'; +import type { McpProxyResponse } from '../mcp-proxy-service.js'; + +/** + * STDIO transport client for MCP servers running as Docker containers. + * + * Runs `docker exec` with an inline Node.js script that spawns the MCP server + * binary, pipes JSON-RPC messages via stdin/stdout, and returns the response. + * + * Each call is self-contained: initialize → notifications/initialized → request → response. + */ +export async function sendViaStdio( + orchestrator: McpOrchestrator, + containerId: string, + packageName: string, + method: string, + params?: Record, + timeoutMs = 30_000, +): Promise { + const initMsg = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'mcpctl-proxy', version: '0.1.0' }, + }, + }); + const initializedMsg = JSON.stringify({ + jsonrpc: '2.0', + method: 'notifications/initialized', + }); + + const requestBody: Record = { + jsonrpc: '2.0', + id: 2, + method, + }; + if (params !== undefined) { + requestBody.params = params; + } + const requestMsg = JSON.stringify(requestBody); + + // Inline Node.js script that: + // 1. Spawns the MCP server binary via npx + // 2. Sends initialize → initialized → actual request via stdin + // 3. Reads stdout for JSON-RPC response with id: 2 + // 4. Outputs the full JSON-RPC response to stdout + const probeScript = ` +const { spawn } = require('child_process'); +const proc = spawn('npx', ['--prefer-offline', '-y', ${JSON.stringify(packageName)}], { stdio: ['pipe', 'pipe', 'pipe'] }); +let output = ''; +let responded = false; +proc.stdout.on('data', d => { + output += d; + const lines = output.split('\\n'); + for (const line of lines) { + if (!line.trim()) continue; + try { + const msg = JSON.parse(line); + if (msg.id === 2) { + responded = true; + process.stdout.write(JSON.stringify(msg)); + proc.kill(); + process.exit(0); + } + } catch {} + } + output = lines[lines.length - 1] || ''; +}); +proc.stderr.on('data', () => {}); +proc.on('error', e => { process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:2,error:{code:-32000,message:e.message}})); process.exit(1); }); +proc.on('exit', (code) => { if (!responded) { process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:2,error:{code:-32000,message:'process exited '+code}})); process.exit(1); } }); +setTimeout(() => { if (!responded) { process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:2,error:{code:-32000,message:'timeout'}})); proc.kill(); process.exit(1); } }, ${timeoutMs - 2000}); +proc.stdin.write(${JSON.stringify(initMsg)} + '\\n'); +setTimeout(() => { + proc.stdin.write(${JSON.stringify(initializedMsg)} + '\\n'); + setTimeout(() => { + proc.stdin.write(${JSON.stringify(requestMsg)} + '\\n'); + }, 500); +}, 500); +`.trim(); + + try { + const result = await orchestrator.execInContainer( + containerId, + ['node', '-e', probeScript], + { timeoutMs }, + ); + + if (result.exitCode === 0 && result.stdout.trim()) { + try { + return JSON.parse(result.stdout.trim()) as McpProxyResponse; + } catch { + return errorResponse(`Failed to parse STDIO response: ${result.stdout.slice(0, 200)}`); + } + } + + // Try to parse error response from stdout + try { + return JSON.parse(result.stdout.trim()) as McpProxyResponse; + } catch { + const errorMsg = result.stderr.trim() || `docker exec exit code ${result.exitCode}`; + return errorResponse(errorMsg); + } + } catch (err) { + return errorResponse(err instanceof Error ? err.message : String(err)); + } +} + +function errorResponse(message: string): McpProxyResponse { + return { + jsonrpc: '2.0', + id: 2, + error: { code: -32000, message }, + }; +} diff --git a/src/mcpd/src/validation/project.schema.ts b/src/mcpd/src/validation/project.schema.ts index dc2b4de..acf7c32 100644 --- a/src/mcpd/src/validation/project.schema.ts +++ b/src/mcpd/src/validation/project.schema.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; export const CreateProjectSchema = z.object({ name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), description: z.string().max(1000).default(''), + prompt: z.string().max(10000).default(''), proxyMode: z.enum(['direct', 'filtered']).default('direct'), llmProvider: z.string().max(100).optional(), llmModel: z.string().max(100).optional(), @@ -14,6 +15,7 @@ export const CreateProjectSchema = z.object({ export const UpdateProjectSchema = z.object({ description: z.string().max(1000).optional(), + prompt: z.string().max(10000).optional(), proxyMode: z.enum(['direct', 'filtered']).optional(), llmProvider: z.string().max(100).nullable().optional(), llmModel: z.string().max(100).nullable().optional(), diff --git a/src/mcpd/src/validation/prompt.schema.ts b/src/mcpd/src/validation/prompt.schema.ts new file mode 100644 index 0000000..1b8484f --- /dev/null +++ b/src/mcpd/src/validation/prompt.schema.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +export const CreatePromptSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), + content: z.string().min(1).max(50000), + projectId: z.string().optional(), +}); + +export const UpdatePromptSchema = z.object({ + content: z.string().min(1).max(50000).optional(), +}); + +export const CreatePromptRequestSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), + content: z.string().min(1).max(50000), + projectId: z.string().optional(), + createdBySession: z.string().optional(), + createdByUserId: z.string().optional(), +}); + +export type CreatePromptInput = z.infer; +export type UpdatePromptInput = z.infer; +export type CreatePromptRequestInput = z.infer; diff --git a/src/mcpd/src/validation/rbac-definition.schema.ts b/src/mcpd/src/validation/rbac-definition.schema.ts index 554fdba..2982b59 100644 --- a/src/mcpd/src/validation/rbac-definition.schema.ts +++ b/src/mcpd/src/validation/rbac-definition.schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const; -export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'projects', 'templates', 'users', 'groups', 'rbac'] as const; +export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests'] as const; /** Singular→plural map for resource names. */ const RESOURCE_ALIASES: Record = { @@ -12,6 +12,8 @@ const RESOURCE_ALIASES: Record = { template: 'templates', user: 'users', group: 'groups', + prompt: 'prompts', + promptrequest: 'promptrequests', }; /** Normalize a resource name to its canonical plural form. */ @@ -20,7 +22,7 @@ export function normalizeResource(resource: string): string { } export const RbacSubjectSchema = z.object({ - kind: z.enum(['User', 'Group']), + kind: z.enum(['User', 'Group', 'ServiceAccount']), name: z.string().min(1), }); diff --git a/src/mcpd/tests/services/prompt-service.test.ts b/src/mcpd/tests/services/prompt-service.test.ts new file mode 100644 index 0000000..ac83e5c --- /dev/null +++ b/src/mcpd/tests/services/prompt-service.test.ts @@ -0,0 +1,302 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { PromptService } from '../../src/services/prompt.service.js'; +import type { IPromptRepository } from '../../src/repositories/prompt.repository.js'; +import type { IPromptRequestRepository } from '../../src/repositories/prompt-request.repository.js'; +import type { IProjectRepository } from '../../src/repositories/project.repository.js'; +import type { Prompt, PromptRequest, Project } from '@prisma/client'; + +function makePrompt(overrides: Partial = {}): Prompt { + return { + id: 'prompt-1', + name: 'test-prompt', + content: 'Hello world', + projectId: null, + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function makePromptRequest(overrides: Partial = {}): PromptRequest { + return { + id: 'req-1', + name: 'test-request', + content: 'Proposed content', + projectId: null, + createdBySession: 'session-abc', + createdByUserId: null, + createdAt: new Date(), + ...overrides, + }; +} + +function makeProject(overrides: Partial = {}): Project { + return { + id: 'proj-1', + name: 'test-project', + description: '', + prompt: '', + proxyMode: 'direct', + llmProvider: null, + llmModel: null, + ownerId: 'user-1', + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as Project; +} + +function mockPromptRepo(): IPromptRepository { + return { + findAll: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByNameAndProject: vi.fn(async () => null), + create: vi.fn(async (data) => makePrompt(data)), + update: vi.fn(async (id, data) => makePrompt({ id, ...data })), + delete: vi.fn(async () => {}), + }; +} + +function mockPromptRequestRepo(): IPromptRequestRepository { + return { + findAll: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByNameAndProject: vi.fn(async () => null), + findBySession: vi.fn(async () => []), + create: vi.fn(async (data) => makePromptRequest(data)), + delete: vi.fn(async () => {}), + }; +} + +function mockProjectRepo(): IProjectRepository { + return { + findAll: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByName: vi.fn(async () => null), + create: vi.fn(async (data) => makeProject(data)), + update: vi.fn(async (id, data) => makeProject({ id, ...data })), + delete: vi.fn(async () => {}), + }; +} + +describe('PromptService', () => { + let promptRepo: IPromptRepository; + let promptRequestRepo: IPromptRequestRepository; + let projectRepo: IProjectRepository; + let service: PromptService; + + beforeEach(() => { + promptRepo = mockPromptRepo(); + promptRequestRepo = mockPromptRequestRepo(); + projectRepo = mockProjectRepo(); + service = new PromptService(promptRepo, promptRequestRepo, projectRepo); + }); + + // ── Prompt CRUD ── + + describe('listPrompts', () => { + it('should return all prompts', async () => { + const prompts = [makePrompt(), makePrompt({ id: 'prompt-2', name: 'other' })]; + vi.mocked(promptRepo.findAll).mockResolvedValue(prompts); + + const result = await service.listPrompts(); + expect(result).toEqual(prompts); + expect(promptRepo.findAll).toHaveBeenCalledWith(undefined); + }); + + it('should filter by projectId', async () => { + await service.listPrompts('proj-1'); + expect(promptRepo.findAll).toHaveBeenCalledWith('proj-1'); + }); + }); + + describe('getPrompt', () => { + it('should return a prompt by id', async () => { + const prompt = makePrompt(); + vi.mocked(promptRepo.findById).mockResolvedValue(prompt); + + const result = await service.getPrompt('prompt-1'); + expect(result).toEqual(prompt); + }); + + it('should throw NotFoundError for missing prompt', async () => { + await expect(service.getPrompt('nope')).rejects.toThrow('Prompt not found: nope'); + }); + }); + + describe('createPrompt', () => { + it('should create a prompt', async () => { + const result = await service.createPrompt({ name: 'new-prompt', content: 'stuff' }); + expect(promptRepo.create).toHaveBeenCalledWith({ name: 'new-prompt', content: 'stuff' }); + expect(result.name).toBe('new-prompt'); + }); + + it('should validate project exists when projectId given', async () => { + vi.mocked(projectRepo.findById).mockResolvedValue(makeProject()); + await service.createPrompt({ name: 'scoped', content: 'x', projectId: 'proj-1' }); + expect(projectRepo.findById).toHaveBeenCalledWith('proj-1'); + }); + + it('should throw when project not found', async () => { + await expect( + service.createPrompt({ name: 'bad', content: 'x', projectId: 'nope' }), + ).rejects.toThrow('Project not found: nope'); + }); + + it('should reject invalid name format', async () => { + await expect( + service.createPrompt({ name: 'INVALID_NAME', content: 'x' }), + ).rejects.toThrow(); + }); + }); + + describe('updatePrompt', () => { + it('should update prompt content', async () => { + vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt()); + await service.updatePrompt('prompt-1', { content: 'updated' }); + expect(promptRepo.update).toHaveBeenCalledWith('prompt-1', { content: 'updated' }); + }); + + it('should throw for missing prompt', async () => { + await expect(service.updatePrompt('nope', { content: 'x' })).rejects.toThrow('Prompt not found'); + }); + }); + + describe('deletePrompt', () => { + it('should delete an existing prompt', async () => { + vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt()); + await service.deletePrompt('prompt-1'); + expect(promptRepo.delete).toHaveBeenCalledWith('prompt-1'); + }); + + it('should throw for missing prompt', async () => { + await expect(service.deletePrompt('nope')).rejects.toThrow('Prompt not found'); + }); + }); + + // ── PromptRequest CRUD ── + + describe('listPromptRequests', () => { + it('should return all prompt requests', async () => { + const reqs = [makePromptRequest()]; + vi.mocked(promptRequestRepo.findAll).mockResolvedValue(reqs); + + const result = await service.listPromptRequests(); + expect(result).toEqual(reqs); + }); + }); + + describe('getPromptRequest', () => { + it('should return a prompt request by id', async () => { + const req = makePromptRequest(); + vi.mocked(promptRequestRepo.findById).mockResolvedValue(req); + + const result = await service.getPromptRequest('req-1'); + expect(result).toEqual(req); + }); + + it('should throw for missing request', async () => { + await expect(service.getPromptRequest('nope')).rejects.toThrow('PromptRequest not found'); + }); + }); + + describe('deletePromptRequest', () => { + it('should delete an existing request', async () => { + vi.mocked(promptRequestRepo.findById).mockResolvedValue(makePromptRequest()); + await service.deletePromptRequest('req-1'); + expect(promptRequestRepo.delete).toHaveBeenCalledWith('req-1'); + }); + }); + + // ── Propose ── + + describe('propose', () => { + it('should create a prompt request', async () => { + const result = await service.propose({ + name: 'my-prompt', + content: 'proposal', + createdBySession: 'sess-1', + }); + expect(promptRequestRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ name: 'my-prompt', content: 'proposal', createdBySession: 'sess-1' }), + ); + expect(result.name).toBe('my-prompt'); + }); + + it('should validate project exists when projectId given', async () => { + vi.mocked(projectRepo.findById).mockResolvedValue(makeProject()); + await service.propose({ + name: 'scoped', + content: 'x', + projectId: 'proj-1', + }); + expect(projectRepo.findById).toHaveBeenCalledWith('proj-1'); + }); + }); + + // ── Approve ── + + describe('approve', () => { + it('should delete request and create prompt (atomic)', async () => { + const req = makePromptRequest({ id: 'req-1', name: 'approved', content: 'good stuff', projectId: 'proj-1' }); + vi.mocked(promptRequestRepo.findById).mockResolvedValue(req); + + const result = await service.approve('req-1'); + + expect(promptRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ name: 'approved', content: 'good stuff', projectId: 'proj-1' }), + ); + expect(promptRequestRepo.delete).toHaveBeenCalledWith('req-1'); + expect(result.name).toBe('approved'); + }); + + it('should throw for missing request', async () => { + await expect(service.approve('nope')).rejects.toThrow('PromptRequest not found'); + }); + + it('should handle global prompt (no projectId)', async () => { + const req = makePromptRequest({ id: 'req-2', name: 'global', content: 'stuff', projectId: null }); + vi.mocked(promptRequestRepo.findById).mockResolvedValue(req); + + await service.approve('req-2'); + + // Should NOT include projectId in the create call + const createArg = vi.mocked(promptRepo.create).mock.calls[0]![0]; + expect(createArg).not.toHaveProperty('projectId'); + }); + }); + + // ── Visibility ── + + describe('getVisiblePrompts', () => { + it('should return approved prompts and session requests', async () => { + vi.mocked(promptRepo.findAll).mockResolvedValue([ + makePrompt({ name: 'approved-1', content: 'A' }), + ]); + vi.mocked(promptRequestRepo.findBySession).mockResolvedValue([ + makePromptRequest({ name: 'pending-1', content: 'B' }), + ]); + + const result = await service.getVisiblePrompts('proj-1', 'sess-1'); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ name: 'approved-1', content: 'A', type: 'prompt' }); + expect(result[1]).toEqual({ name: 'pending-1', content: 'B', type: 'promptrequest' }); + }); + + it('should not include pending requests without sessionId', async () => { + vi.mocked(promptRepo.findAll).mockResolvedValue([makePrompt()]); + + const result = await service.getVisiblePrompts('proj-1'); + + expect(result).toHaveLength(1); + expect(promptRequestRepo.findBySession).not.toHaveBeenCalled(); + }); + + it('should return empty when no prompts or requests', async () => { + const result = await service.getVisiblePrompts(); + expect(result).toEqual([]); + }); + }); +}); diff --git a/src/mcplocal/src/discovery.ts b/src/mcplocal/src/discovery.ts index 9b01506..d8a1619 100644 --- a/src/mcplocal/src/discovery.ts +++ b/src/mcplocal/src/discovery.ts @@ -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); diff --git a/src/mcplocal/src/http/mcpd-client.ts b/src/mcplocal/src/http/mcpd-client.ts index 1b31026..3a0de50 100644 --- a/src/mcplocal/src/http/mcpd-client.ts +++ b/src/mcplocal/src/http/mcpd-client.ts @@ -23,11 +23,21 @@ export class ConnectionError extends Error { export class McpdClient { private readonly baseUrl: string; private readonly token: string; + private readonly extraHeaders: Record; - constructor(baseUrl: string, token: string) { + constructor(baseUrl: string, token: string, extraHeaders?: Record) { // 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): McpdClient { + return new McpdClient(this.baseUrl, this.token, { ...this.extraHeaders, ...headers }); } async get(path: string): Promise { @@ -62,6 +72,7 @@ export class McpdClient { ): Promise<{ status: number; body: unknown }> { const url = `${this.baseUrl}${path}${query ? `?${query}` : ''}`; const headers: Record = { + ...this.extraHeaders, 'Authorization': `Bearer ${authOverride ?? this.token}`, 'Accept': 'application/json', }; diff --git a/src/mcplocal/src/http/project-mcp-endpoint.ts b/src/mcplocal/src/http/project-mcp-endpoint.ts index 4b5ae54..9627a79 100644 --- a/src/mcplocal/src/http/project-mcp-endpoint.ts +++ b/src/mcplocal/src/http/project-mcp-endpoint.ts @@ -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); } }; diff --git a/src/mcplocal/src/router.ts b/src/mcplocal/src/router.ts index 7df1a7a..681569c 100644 --- a/src/mcplocal/src/router.ts +++ b/src/mcplocal/src/router.ts @@ -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(); 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(); 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 { + async route(request: JsonRpcRequest, context?: RouteContext): Promise { 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>( + `/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 | 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 { + private async routeToolCall(request: JsonRpcRequest, context?: RouteContext): Promise { const params = request.params as Record | 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 { + 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 | undefined; + const args = (params?.['arguments'] ?? {}) as Record; + 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 = { 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()]; } diff --git a/src/mcplocal/src/types.ts b/src/mcplocal/src/types.ts index 785a210..ae6b840 100644 --- a/src/mcplocal/src/types.ts +++ b/src/mcplocal/src/types.ts @@ -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; /** Disconnect from the upstream */ diff --git a/src/mcplocal/src/upstream/mcpd.ts b/src/mcplocal/src/upstream/mcpd.ts index eba54b6..e17b732 100644 --- a/src/mcplocal/src/upstream/mcpd.ts +++ b/src/mcplocal/src/upstream/mcpd.ts @@ -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 { diff --git a/src/mcplocal/tests/project-mcp-endpoint.test.ts b/src/mcplocal/tests/project-mcp-endpoint.test.ts index 1551d32..f188c64 100644 --- a/src/mcplocal/tests/project-mcp-endpoint.test.ts +++ b/src/mcplocal/tests/project-mcp-endpoint.test.ts @@ -11,7 +11,7 @@ vi.mock('../src/discovery.js', () => ({ import { refreshProjectUpstreams } from '../src/discovery.js'; function mockMcpdClient() { - return { + const client: Record = { 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).mockReturnValue(client); + return client; } describe('registerProjectMcpEndpoint', () => { diff --git a/src/mcplocal/tests/router-prompts.test.ts b/src/mcplocal/tests/router-prompts.test.ts new file mode 100644 index 0000000..a31ffb7 --- /dev/null +++ b/src/mcplocal/tests/router-prompts.test.ts @@ -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 => { + 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' }, + ); + }); + }); +});