Merge pull request 'feat: prompt resources, proxy transport fix, enriched descriptions' (#36) from feat/prompt-resources-and-proxy-transport into main
This commit was merged in pull request #36.
This commit is contained in:
@@ -76,13 +76,14 @@ const GroupSpecSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const RbacSubjectSchema = z.object({
|
const RbacSubjectSchema = z.object({
|
||||||
kind: z.enum(['User', 'Group']),
|
kind: z.enum(['User', 'Group', 'ServiceAccount']),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
const RESOURCE_ALIASES: Record<string, string> = {
|
const RESOURCE_ALIASES: Record<string, string> = {
|
||||||
server: 'servers', instance: 'instances', secret: 'secrets',
|
server: 'servers', instance: 'instances', secret: 'secrets',
|
||||||
project: 'projects', template: 'templates', user: 'users', group: 'groups',
|
project: 'projects', template: 'templates', user: 'users', group: 'groups',
|
||||||
|
prompt: 'prompts', promptrequest: 'promptrequests',
|
||||||
};
|
};
|
||||||
|
|
||||||
const RbacRoleBindingSchema = z.union([
|
const RbacRoleBindingSchema = z.union([
|
||||||
@@ -103,9 +104,16 @@ const RbacBindingSpecSchema = z.object({
|
|||||||
roleBindings: z.array(RbacRoleBindingSchema).default([]),
|
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({
|
const ProjectSpecSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
description: z.string().default(''),
|
description: z.string().default(''),
|
||||||
|
prompt: z.string().max(10000).default(''),
|
||||||
proxyMode: z.enum(['direct', 'filtered']).default('direct'),
|
proxyMode: z.enum(['direct', 'filtered']).default('direct'),
|
||||||
llmProvider: z.string().optional(),
|
llmProvider: z.string().optional(),
|
||||||
llmModel: z.string().optional(),
|
llmModel: z.string().optional(),
|
||||||
@@ -121,6 +129,7 @@ const ApplyConfigSchema = z.object({
|
|||||||
templates: z.array(TemplateSpecSchema).default([]),
|
templates: z.array(TemplateSpecSchema).default([]),
|
||||||
rbacBindings: z.array(RbacBindingSpecSchema).default([]),
|
rbacBindings: z.array(RbacBindingSpecSchema).default([]),
|
||||||
rbac: z.array(RbacBindingSpecSchema).default([]),
|
rbac: z.array(RbacBindingSpecSchema).default([]),
|
||||||
|
prompts: z.array(PromptSpecSchema).default([]),
|
||||||
}).transform((data) => ({
|
}).transform((data) => ({
|
||||||
...data,
|
...data,
|
||||||
// Merge rbac into rbacBindings so both keys work
|
// 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.projects.length > 0) log(` ${config.projects.length} project(s)`);
|
||||||
if (config.templates.length > 0) log(` ${config.templates.length} template(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.rbacBindings.length > 0) log(` ${config.rbacBindings.length} rbacBinding(s)`);
|
||||||
|
if (config.prompts.length > 0) log(` ${config.prompts.length} prompt(s)`);
|
||||||
return;
|
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}`);
|
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<unknown | null> {
|
async function findByName(client: ApiClient, resource: string, name: string): Promise<unknown | null> {
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
|||||||
.option('--proxy-mode <mode>', 'Proxy mode (direct, filtered)')
|
.option('--proxy-mode <mode>', 'Proxy mode (direct, filtered)')
|
||||||
.option('--proxy-mode-llm-provider <name>', 'LLM provider name (for filtered proxy mode)')
|
.option('--proxy-mode-llm-provider <name>', 'LLM provider name (for filtered proxy mode)')
|
||||||
.option('--proxy-mode-llm-model <name>', 'LLM model name (for filtered proxy mode)')
|
.option('--proxy-mode-llm-model <name>', 'LLM model name (for filtered proxy mode)')
|
||||||
|
.option('--prompt <text>', 'Project-level prompt / instructions for the LLM')
|
||||||
.option('--server <name>', 'Server name (repeat for multiple)', collect, [])
|
.option('--server <name>', 'Server name (repeat for multiple)', collect, [])
|
||||||
.option('--force', 'Update if already exists')
|
.option('--force', 'Update if already exists')
|
||||||
.action(async (name: string, opts) => {
|
.action(async (name: string, opts) => {
|
||||||
@@ -206,6 +207,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
|||||||
description: opts.description,
|
description: opts.description,
|
||||||
proxyMode: opts.proxyMode ?? 'direct',
|
proxyMode: opts.proxyMode ?? 'direct',
|
||||||
};
|
};
|
||||||
|
if (opts.prompt) body.prompt = opts.prompt;
|
||||||
if (opts.proxyModeLlmProvider) body.llmProvider = opts.proxyModeLlmProvider;
|
if (opts.proxyModeLlmProvider) body.llmProvider = opts.proxyModeLlmProvider;
|
||||||
if (opts.proxyModeLlmModel) body.llmModel = opts.proxyModeLlmModel;
|
if (opts.proxyModeLlmModel) body.llmModel = opts.proxyModeLlmModel;
|
||||||
if (opts.server.length > 0) body.servers = opts.server;
|
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('<name>', 'Prompt name (lowercase alphanumeric with hyphens)')
|
||||||
|
.option('--project <name>', 'Project name to scope the prompt to')
|
||||||
|
.option('--content <text>', 'Prompt content text')
|
||||||
|
.option('--content-file <path>', '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<string, unknown> = { name, content };
|
||||||
|
if (opts.project) {
|
||||||
|
// Resolve project name to ID
|
||||||
|
const projects = await client.get<Array<{ id: string; name: string }>>('/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;
|
return cmd;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,36 @@ const templateColumns: Column<TemplateRow>[] = [
|
|||||||
{ header: 'DESCRIPTION', key: 'description', width: 50 },
|
{ 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<PromptRow>[] = [
|
||||||
|
{ 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<PromptRequestRow>[] = [
|
||||||
|
{ 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<InstanceRow>[] = [
|
const instanceColumns: Column<InstanceRow>[] = [
|
||||||
{ header: 'NAME', key: (r) => r.server?.name ?? '-', width: 20 },
|
{ header: 'NAME', key: (r) => r.server?.name ?? '-', width: 20 },
|
||||||
{ header: 'STATUS', key: 'status', width: 10 },
|
{ header: 'STATUS', key: 'status', width: 10 },
|
||||||
@@ -157,6 +187,10 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
|
|||||||
return groupColumns as unknown as Column<Record<string, unknown>>[];
|
return groupColumns as unknown as Column<Record<string, unknown>>[];
|
||||||
case 'rbac':
|
case 'rbac':
|
||||||
return rbacColumns as unknown as Column<Record<string, unknown>>[];
|
return rbacColumns as unknown as Column<Record<string, unknown>>[];
|
||||||
|
case 'prompts':
|
||||||
|
return promptColumns as unknown as Column<Record<string, unknown>>[];
|
||||||
|
case 'promptrequests':
|
||||||
|
return promptRequestColumns as unknown as Column<Record<string, unknown>>[];
|
||||||
default:
|
default:
|
||||||
return [
|
return [
|
||||||
{ header: 'ID', key: 'id' as keyof Record<string, unknown> },
|
{ header: 'ID', key: 'id' as keyof Record<string, unknown> },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import type { ApiClient } from '../api-client.js';
|
import type { ApiClient } from '../api-client.js';
|
||||||
import { resolveNameOrId } from './shared.js';
|
import { resolveNameOrId, resolveResource } from './shared.js';
|
||||||
|
|
||||||
export interface ProjectOpsDeps {
|
export interface ProjectOpsDeps {
|
||||||
client: ApiClient;
|
client: ApiClient;
|
||||||
@@ -45,3 +45,22 @@ export function createDetachServerCommand(deps: ProjectOpsDeps): Command {
|
|||||||
log(`server '${serverName}' detached from project '${projectName}'`);
|
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>', 'Resource type (promptrequest)')
|
||||||
|
.argument('<name>', '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})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export const RESOURCE_ALIASES: Record<string, string> = {
|
|||||||
rbac: 'rbac',
|
rbac: 'rbac',
|
||||||
'rbac-definition': 'rbac',
|
'rbac-definition': 'rbac',
|
||||||
'rbac-binding': 'rbac',
|
'rbac-binding': 'rbac',
|
||||||
|
prompt: 'prompts',
|
||||||
|
prompts: 'prompts',
|
||||||
|
promptrequest: 'promptrequests',
|
||||||
|
promptrequests: 'promptrequests',
|
||||||
|
pr: 'promptrequests',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resolveResource(name: string): string {
|
export function resolveResource(name: string): string {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { createCreateCommand } from './commands/create.js';
|
|||||||
import { createEditCommand } from './commands/edit.js';
|
import { createEditCommand } from './commands/edit.js';
|
||||||
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
|
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
|
||||||
import { createLoginCommand, createLogoutCommand } from './commands/auth.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 { createMcpCommand } from './commands/mcp.js';
|
||||||
import { ApiClient, ApiError } from './api-client.js';
|
import { ApiClient, ApiError } from './api-client.js';
|
||||||
import { loadConfig } from './config/index.js';
|
import { loadConfig } from './config/index.js';
|
||||||
@@ -151,6 +151,7 @@ export function createProgram(): Command {
|
|||||||
};
|
};
|
||||||
program.addCommand(createAttachServerCommand(projectOpsDeps), { hidden: true });
|
program.addCommand(createAttachServerCommand(projectOpsDeps), { hidden: true });
|
||||||
program.addCommand(createDetachServerCommand(projectOpsDeps), { hidden: true });
|
program.addCommand(createDetachServerCommand(projectOpsDeps), { hidden: true });
|
||||||
|
program.addCommand(createApproveCommand(projectOpsDeps));
|
||||||
program.addCommand(createMcpCommand({
|
program.addCommand(createMcpCommand({
|
||||||
getProject: () => program.opts().project as string | undefined,
|
getProject: () => program.opts().project as string | undefined,
|
||||||
}), { hidden: true });
|
}), { hidden: true });
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ model Project {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
description String @default("")
|
description String @default("")
|
||||||
|
prompt String @default("")
|
||||||
proxyMode String @default("direct")
|
proxyMode String @default("direct")
|
||||||
llmProvider String?
|
llmProvider String?
|
||||||
llmModel String?
|
llmModel String?
|
||||||
@@ -178,8 +179,10 @@ model Project {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
servers ProjectServer[]
|
servers ProjectServer[]
|
||||||
|
prompts Prompt[]
|
||||||
|
promptRequests PromptRequest[]
|
||||||
|
|
||||||
@@index([name])
|
@@index([name])
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@ -227,6 +230,41 @@ enum InstanceStatus {
|
|||||||
ERROR
|
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 ──
|
// ── Audit Logs ──
|
||||||
|
|
||||||
model AuditLog {
|
model AuditLog {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
UserRepository,
|
UserRepository,
|
||||||
GroupRepository,
|
GroupRepository,
|
||||||
} from './repositories/index.js';
|
} from './repositories/index.js';
|
||||||
|
import { PromptRepository } from './repositories/prompt.repository.js';
|
||||||
|
import { PromptRequestRepository } from './repositories/prompt-request.repository.js';
|
||||||
import {
|
import {
|
||||||
McpServerService,
|
McpServerService,
|
||||||
SecretService,
|
SecretService,
|
||||||
@@ -56,6 +58,8 @@ import {
|
|||||||
registerUserRoutes,
|
registerUserRoutes,
|
||||||
registerGroupRoutes,
|
registerGroupRoutes,
|
||||||
} from './routes/index.js';
|
} from './routes/index.js';
|
||||||
|
import { registerPromptRoutes } from './routes/prompts.js';
|
||||||
|
import { PromptService } from './services/prompt.service.js';
|
||||||
|
|
||||||
type PermissionCheck =
|
type PermissionCheck =
|
||||||
| { kind: 'resource'; resource: string; action: RbacAction; resourceName?: string }
|
| { kind: 'resource'; resource: string; action: RbacAction; resourceName?: string }
|
||||||
@@ -88,11 +92,38 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
|
|||||||
'rbac': 'rbac',
|
'rbac': 'rbac',
|
||||||
'audit-logs': 'rbac',
|
'audit-logs': 'rbac',
|
||||||
'mcp': 'servers',
|
'mcp': 'servers',
|
||||||
|
'prompts': 'prompts',
|
||||||
|
'promptrequests': 'promptrequests',
|
||||||
};
|
};
|
||||||
|
|
||||||
const resource = resourceMap[segment];
|
const resource = resourceMap[segment];
|
||||||
if (resource === undefined) return { kind: 'skip' };
|
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
|
// Special case: /api/v1/projects/:id/mcp-config → requires 'expose' permission
|
||||||
const mcpConfigMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/mcp-config/);
|
const mcpConfigMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/mcp-config/);
|
||||||
if (mcpConfigMatch?.[1]) {
|
if (mcpConfigMatch?.[1]) {
|
||||||
@@ -243,11 +274,14 @@ async function main(): Promise<void> {
|
|||||||
const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo);
|
const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo);
|
||||||
const authService = new AuthService(prisma);
|
const authService = new AuthService(prisma);
|
||||||
const templateService = new TemplateService(templateRepo);
|
const templateService = new TemplateService(templateRepo);
|
||||||
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo);
|
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo, orchestrator);
|
||||||
const rbacDefinitionService = new RbacDefinitionService(rbacDefinitionRepo);
|
const rbacDefinitionService = new RbacDefinitionService(rbacDefinitionRepo);
|
||||||
const rbacService = new RbacService(rbacDefinitionRepo, prisma);
|
const rbacService = new RbacService(rbacDefinitionRepo, prisma);
|
||||||
const userService = new UserService(userRepo);
|
const userService = new UserService(userRepo);
|
||||||
const groupService = new GroupService(groupRepo, 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
|
// Auth middleware for global hooks
|
||||||
const authMiddleware = createAuthMiddleware({
|
const authMiddleware = createAuthMiddleware({
|
||||||
@@ -294,9 +328,13 @@ async function main(): Promise<void> {
|
|||||||
const check = mapUrlToPermission(request.method, url);
|
const check = mapUrlToPermission(request.method, url);
|
||||||
if (check.kind === 'skip') return;
|
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;
|
let allowed: boolean;
|
||||||
if (check.kind === 'operation') {
|
if (check.kind === 'operation') {
|
||||||
allowed = await rbacService.canRunOperation(request.userId, check.operation);
|
allowed = await rbacService.canRunOperation(request.userId, check.operation, serviceAccountName);
|
||||||
} else {
|
} else {
|
||||||
// Resolve CUID → human name for name-scoped RBAC bindings
|
// Resolve CUID → human name for name-scoped RBAC bindings
|
||||||
if (check.resourceName !== undefined && CUID_RE.test(check.resourceName)) {
|
if (check.resourceName !== undefined && CUID_RE.test(check.resourceName)) {
|
||||||
@@ -306,10 +344,10 @@ async function main(): Promise<void> {
|
|||||||
if (entity) check.resourceName = entity.name;
|
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)
|
// Compute scope for list filtering (used by preSerialization hook)
|
||||||
if (allowed && check.resourceName === undefined) {
|
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) {
|
if (!allowed) {
|
||||||
@@ -335,6 +373,7 @@ async function main(): Promise<void> {
|
|||||||
registerRbacRoutes(app, rbacDefinitionService);
|
registerRbacRoutes(app, rbacDefinitionService);
|
||||||
registerUserRoutes(app, userService);
|
registerUserRoutes(app, userService);
|
||||||
registerGroupRoutes(app, groupService);
|
registerGroupRoutes(app, groupService);
|
||||||
|
registerPromptRoutes(app, promptService, projectRepo);
|
||||||
|
|
||||||
// ── RBAC list filtering hook ──
|
// ── RBAC list filtering hook ──
|
||||||
// Filters array responses to only include resources the user is allowed to see.
|
// Filters array responses to only include resources the user is allowed to see.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface IProjectRepository {
|
|||||||
findAll(ownerId?: string): Promise<ProjectWithRelations[]>;
|
findAll(ownerId?: string): Promise<ProjectWithRelations[]>;
|
||||||
findById(id: string): Promise<ProjectWithRelations | null>;
|
findById(id: string): Promise<ProjectWithRelations | null>;
|
||||||
findByName(name: string): Promise<ProjectWithRelations | null>;
|
findByName(name: string): Promise<ProjectWithRelations | null>;
|
||||||
create(data: { name: string; description: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string }): Promise<ProjectWithRelations>;
|
create(data: { name: string; description: string; prompt?: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string }): Promise<ProjectWithRelations>;
|
||||||
update(id: string, data: Record<string, unknown>): Promise<ProjectWithRelations>;
|
update(id: string, data: Record<string, unknown>): Promise<ProjectWithRelations>;
|
||||||
delete(id: string): Promise<void>;
|
delete(id: string): Promise<void>;
|
||||||
setServers(projectId: string, serverIds: string[]): Promise<void>;
|
setServers(projectId: string, serverIds: string[]): Promise<void>;
|
||||||
@@ -36,13 +36,14 @@ export class ProjectRepository implements IProjectRepository {
|
|||||||
return this.prisma.project.findUnique({ where: { name }, include: PROJECT_INCLUDE }) as unknown as Promise<ProjectWithRelations | null>;
|
return this.prisma.project.findUnique({ where: { name }, include: PROJECT_INCLUDE }) as unknown as Promise<ProjectWithRelations | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(data: { name: string; description: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string }): Promise<ProjectWithRelations> {
|
async create(data: { name: string; description: string; prompt?: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string }): Promise<ProjectWithRelations> {
|
||||||
const createData: Record<string, unknown> = {
|
const createData: Record<string, unknown> = {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
ownerId: data.ownerId,
|
ownerId: data.ownerId,
|
||||||
proxyMode: data.proxyMode,
|
proxyMode: data.proxyMode,
|
||||||
};
|
};
|
||||||
|
if (data.prompt !== undefined) createData['prompt'] = data.prompt;
|
||||||
if (data.llmProvider !== undefined) createData['llmProvider'] = data.llmProvider;
|
if (data.llmProvider !== undefined) createData['llmProvider'] = data.llmProvider;
|
||||||
if (data.llmModel !== undefined) createData['llmModel'] = data.llmModel;
|
if (data.llmModel !== undefined) createData['llmModel'] = data.llmModel;
|
||||||
|
|
||||||
|
|||||||
53
src/mcpd/src/repositories/prompt-request.repository.ts
Normal file
53
src/mcpd/src/repositories/prompt-request.repository.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { PrismaClient, PromptRequest } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface IPromptRequestRepository {
|
||||||
|
findAll(projectId?: string): Promise<PromptRequest[]>;
|
||||||
|
findById(id: string): Promise<PromptRequest | null>;
|
||||||
|
findByNameAndProject(name: string, projectId: string | null): Promise<PromptRequest | null>;
|
||||||
|
findBySession(sessionId: string, projectId?: string): Promise<PromptRequest[]>;
|
||||||
|
create(data: { name: string; content: string; projectId?: string; createdBySession?: string; createdByUserId?: string }): Promise<PromptRequest>;
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PromptRequestRepository implements IPromptRequestRepository {
|
||||||
|
constructor(private readonly prisma: PrismaClient) {}
|
||||||
|
|
||||||
|
async findAll(projectId?: string): Promise<PromptRequest[]> {
|
||||||
|
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<PromptRequest | null> {
|
||||||
|
return this.prisma.promptRequest.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByNameAndProject(name: string, projectId: string | null): Promise<PromptRequest | null> {
|
||||||
|
return this.prisma.promptRequest.findUnique({
|
||||||
|
where: { name_projectId: { name, projectId: projectId ?? '' } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findBySession(sessionId: string, projectId?: string): Promise<PromptRequest[]> {
|
||||||
|
const where: Record<string, unknown> = { 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<PromptRequest> {
|
||||||
|
return this.prisma.promptRequest.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.prisma.promptRequest.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/mcpd/src/repositories/prompt.repository.ts
Normal file
47
src/mcpd/src/repositories/prompt.repository.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { PrismaClient, Prompt } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface IPromptRepository {
|
||||||
|
findAll(projectId?: string): Promise<Prompt[]>;
|
||||||
|
findById(id: string): Promise<Prompt | null>;
|
||||||
|
findByNameAndProject(name: string, projectId: string | null): Promise<Prompt | null>;
|
||||||
|
create(data: { name: string; content: string; projectId?: string }): Promise<Prompt>;
|
||||||
|
update(id: string, data: { content?: string }): Promise<Prompt>;
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PromptRepository implements IPromptRepository {
|
||||||
|
constructor(private readonly prisma: PrismaClient) {}
|
||||||
|
|
||||||
|
async findAll(projectId?: string): Promise<Prompt[]> {
|
||||||
|
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<Prompt | null> {
|
||||||
|
return this.prisma.prompt.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByNameAndProject(name: string, projectId: string | null): Promise<Prompt | null> {
|
||||||
|
return this.prisma.prompt.findUnique({
|
||||||
|
where: { name_projectId: { name, projectId: projectId ?? '' } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: { name: string; content: string; projectId?: string }): Promise<Prompt> {
|
||||||
|
return this.prisma.prompt.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: { content?: string }): Promise<Prompt> {
|
||||||
|
return this.prisma.prompt.update({ where: { id }, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.prisma.prompt.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,4 +54,16 @@ export function registerProjectRoutes(app: FastifyInstance, service: ProjectServ
|
|||||||
const project = await service.resolveAndGet(request.params.id);
|
const project = await service.resolveAndGet(request.params.id);
|
||||||
return project.servers.map((ps) => ps.server);
|
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<string, unknown>).name as string,
|
||||||
|
description: (ps.server as Record<string, unknown>).description as string,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
86
src/mcpd/src/routes/prompts.ts
Normal file
86
src/mcpd/src/routes/prompts.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
const req = await service.propose({
|
||||||
|
...body,
|
||||||
|
projectId: project.id,
|
||||||
|
});
|
||||||
|
reply.code(201);
|
||||||
|
return req;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js';
|
||||||
|
import type { McpOrchestrator } from './orchestrator.js';
|
||||||
import { NotFoundError } from './mcp-server.service.js';
|
import { NotFoundError } from './mcp-server.service.js';
|
||||||
import { InvalidStateError } from './instance.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 {
|
export interface McpProxyRequest {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -38,17 +41,21 @@ export class McpProxyService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly instanceRepo: IMcpInstanceRepository,
|
private readonly instanceRepo: IMcpInstanceRepository,
|
||||||
private readonly serverRepo: IMcpServerRepository,
|
private readonly serverRepo: IMcpServerRepository,
|
||||||
|
private readonly orchestrator?: McpOrchestrator,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(request: McpProxyRequest): Promise<McpProxyResponse> {
|
async execute(request: McpProxyRequest): Promise<McpProxyResponse> {
|
||||||
const server = await this.serverRepo.findById(request.serverId);
|
const server = await this.serverRepo.findById(request.serverId);
|
||||||
|
if (!server) {
|
||||||
// External server: proxy directly to externalUrl
|
throw new NotFoundError(`Server '${request.serverId}' not found`);
|
||||||
if (server?.externalUrl) {
|
|
||||||
return this.sendToExternal(server.id, server.externalUrl, request.method, request.params);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 instances = await this.instanceRepo.findAll(request.serverId);
|
||||||
const running = instances.find((i) => i.status === 'RUNNING');
|
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}'`);
|
throw new NotFoundError(`No running instance found for server '${request.serverId}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (running.port === null || running.port === undefined) {
|
return this.sendToManaged(server, running, request.method, request.params);
|
||||||
throw new InvalidStateError(
|
|
||||||
`Running instance '${running.id}' for server '${request.serverId}' has no port assigned`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.sendJsonRpc(running, request.method, request.params);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a JSON-RPC request to an external MCP server.
|
* Send to an external MCP server. Dispatches based on transport type.
|
||||||
* Handles streamable-http protocol (session management + SSE response parsing).
|
|
||||||
*/
|
*/
|
||||||
private async sendToExternal(
|
private async sendToExternal(
|
||||||
|
server: McpServer,
|
||||||
|
method: string,
|
||||||
|
params?: Record<string, unknown>,
|
||||||
|
): Promise<McpProxyResponse> {
|
||||||
|
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<string, unknown>,
|
||||||
|
): Promise<McpProxyResponse> {
|
||||||
|
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<string> {
|
||||||
|
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,
|
serverId: string,
|
||||||
url: string,
|
url: string,
|
||||||
method: string,
|
method: string,
|
||||||
@@ -109,14 +191,14 @@ export class McpProxyService {
|
|||||||
// Session expired? Clear and retry once
|
// Session expired? Clear and retry once
|
||||||
if (response.status === 400 || response.status === 404) {
|
if (response.status === 400 || response.status === 404) {
|
||||||
this.sessions.delete(serverId);
|
this.sessions.delete(serverId);
|
||||||
return this.sendToExternal(serverId, url, method, params);
|
return this.sendStreamableHttp(serverId, url, method, params);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
id: 1,
|
id: 1,
|
||||||
error: {
|
error: {
|
||||||
code: -32000,
|
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.
|
* Initialize a streamable-http session with a server.
|
||||||
* Sends `initialize` and `notifications/initialized`, caches the session ID.
|
|
||||||
*/
|
*/
|
||||||
private async initSession(serverId: string, url: string): Promise<void> {
|
private async initSession(serverId: string, url: string): Promise<void> {
|
||||||
const initBody = {
|
const initBody = {
|
||||||
@@ -174,41 +255,4 @@ export class McpProxyService {
|
|||||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
|
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendJsonRpc(
|
|
||||||
instance: McpInstance,
|
|
||||||
method: string,
|
|
||||||
params?: Record<string, unknown>,
|
|
||||||
): Promise<McpProxyResponse> {
|
|
||||||
const url = `http://localhost:${instance.port}`;
|
|
||||||
|
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export class ProjectService {
|
|||||||
const project = await this.projectRepo.create({
|
const project = await this.projectRepo.create({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
|
prompt: data.prompt,
|
||||||
ownerId,
|
ownerId,
|
||||||
proxyMode: data.proxyMode,
|
proxyMode: data.proxyMode,
|
||||||
...(data.llmProvider !== undefined ? { llmProvider: data.llmProvider } : {}),
|
...(data.llmProvider !== undefined ? { llmProvider: data.llmProvider } : {}),
|
||||||
@@ -75,6 +76,7 @@ export class ProjectService {
|
|||||||
// Build update data for scalar fields
|
// Build update data for scalar fields
|
||||||
const updateData: Record<string, unknown> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
if (data.description !== undefined) updateData['description'] = data.description;
|
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.proxyMode !== undefined) updateData['proxyMode'] = data.proxyMode;
|
||||||
if (data.llmProvider !== undefined) updateData['llmProvider'] = data.llmProvider;
|
if (data.llmProvider !== undefined) updateData['llmProvider'] = data.llmProvider;
|
||||||
if (data.llmModel !== undefined) updateData['llmModel'] = data.llmModel;
|
if (data.llmModel !== undefined) updateData['llmModel'] = data.llmModel;
|
||||||
|
|||||||
137
src/mcpd/src/services/prompt.service.ts
Normal file
137
src/mcpd/src/services/prompt.service.ts
Normal file
@@ -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<Prompt[]> {
|
||||||
|
return this.promptRepo.findAll(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPrompt(id: string): Promise<Prompt> {
|
||||||
|
const prompt = await this.promptRepo.findById(id);
|
||||||
|
if (prompt === null) throw new NotFoundError(`Prompt not found: ${id}`);
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPrompt(input: unknown): Promise<Prompt> {
|
||||||
|
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<Prompt> {
|
||||||
|
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<void> {
|
||||||
|
await this.getPrompt(id);
|
||||||
|
await this.promptRepo.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PromptRequest CRUD ──
|
||||||
|
|
||||||
|
async listPromptRequests(projectId?: string): Promise<PromptRequest[]> {
|
||||||
|
return this.promptRequestRepo.findAll(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPromptRequest(id: string): Promise<PromptRequest> {
|
||||||
|
const req = await this.promptRequestRepo.findById(id);
|
||||||
|
if (req === null) throw new NotFoundError(`PromptRequest not found: ${id}`);
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePromptRequest(id: string): Promise<void> {
|
||||||
|
await this.getPromptRequest(id);
|
||||||
|
await this.promptRequestRepo.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Propose (LLM creates a PromptRequest) ──
|
||||||
|
|
||||||
|
async propose(input: unknown): Promise<PromptRequest> {
|
||||||
|
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<Prompt> {
|
||||||
|
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<Array<{ name: string; content: string; type: 'prompt' | 'promptrequest' }>> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,8 +50,8 @@ export class RbacService {
|
|||||||
* If provided, name-scoped bindings only match when their name equals this.
|
* If provided, name-scoped bindings only match when their name equals this.
|
||||||
* If omitted (listing), name-scoped bindings still grant access.
|
* If omitted (listing), name-scoped bindings still grant access.
|
||||||
*/
|
*/
|
||||||
async canAccess(userId: string, action: RbacAction, resource: string, resourceName?: string): Promise<boolean> {
|
async canAccess(userId: string, action: RbacAction, resource: string, resourceName?: string, serviceAccountName?: string): Promise<boolean> {
|
||||||
const permissions = await this.getPermissions(userId);
|
const permissions = await this.getPermissions(userId, serviceAccountName);
|
||||||
const normalized = normalizeResource(resource);
|
const normalized = normalizeResource(resource);
|
||||||
|
|
||||||
for (const perm of permissions) {
|
for (const perm of permissions) {
|
||||||
@@ -73,8 +73,8 @@ export class RbacService {
|
|||||||
* Check whether a user is allowed to perform a named operation.
|
* Check whether a user is allowed to perform a named operation.
|
||||||
* Operations require an explicit 'run' role binding with a matching action.
|
* Operations require an explicit 'run' role binding with a matching action.
|
||||||
*/
|
*/
|
||||||
async canRunOperation(userId: string, operation: string): Promise<boolean> {
|
async canRunOperation(userId: string, operation: string, serviceAccountName?: string): Promise<boolean> {
|
||||||
const permissions = await this.getPermissions(userId);
|
const permissions = await this.getPermissions(userId, serviceAccountName);
|
||||||
|
|
||||||
for (const perm of permissions) {
|
for (const perm of permissions) {
|
||||||
if ('action' in perm && perm.role === 'run' && perm.action === operation) {
|
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: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.
|
* Returns wildcard:false with a set of allowed names if all bindings are name-scoped.
|
||||||
*/
|
*/
|
||||||
async getAllowedScope(userId: string, action: RbacAction, resource: string): Promise<AllowedScope> {
|
async getAllowedScope(userId: string, action: RbacAction, resource: string, serviceAccountName?: string): Promise<AllowedScope> {
|
||||||
const permissions = await this.getPermissions(userId);
|
const permissions = await this.getPermissions(userId, serviceAccountName);
|
||||||
const normalized = normalizeResource(resource);
|
const normalized = normalizeResource(resource);
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
|
|
||||||
@@ -113,31 +113,35 @@ export class RbacService {
|
|||||||
/**
|
/**
|
||||||
* Collect all permissions for a user across all matching RbacDefinitions.
|
* Collect all permissions for a user across all matching RbacDefinitions.
|
||||||
*/
|
*/
|
||||||
async getPermissions(userId: string): Promise<Permission[]> {
|
async getPermissions(userId: string, serviceAccountName?: string): Promise<Permission[]> {
|
||||||
// 1. Resolve user email
|
// 1. Resolve user email
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: { email: true },
|
select: { email: true },
|
||||||
});
|
});
|
||||||
if (user === null) return [];
|
if (user === null && serviceAccountName === undefined) return [];
|
||||||
|
|
||||||
// 2. Resolve group names the user belongs to
|
// 2. Resolve group names the user belongs to
|
||||||
const memberships = await this.prisma.groupMember.findMany({
|
let groupNames: string[] = [];
|
||||||
where: { userId },
|
if (user !== null) {
|
||||||
select: { group: { select: { name: true } } },
|
const memberships = await this.prisma.groupMember.findMany({
|
||||||
});
|
where: { userId },
|
||||||
const groupNames = memberships.map((m) => m.group.name);
|
select: { group: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
groupNames = memberships.map((m) => m.group.name);
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Load all RbacDefinitions
|
// 3. Load all RbacDefinitions
|
||||||
const definitions = await this.rbacRepo.findAll();
|
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[] = [];
|
const permissions: Permission[] = [];
|
||||||
for (const def of definitions) {
|
for (const def of definitions) {
|
||||||
const subjects = def.subjects as RbacSubject[];
|
const subjects = def.subjects as RbacSubject[];
|
||||||
const matched = subjects.some((s) => {
|
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 === 'Group') return groupNames.includes(s.name);
|
||||||
|
if (s.kind === 'ServiceAccount') return serviceAccountName !== undefined && s.name === serviceAccountName;
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
2
src/mcpd/src/services/transport/index.ts
Normal file
2
src/mcpd/src/services/transport/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { sendViaSse } from './sse-client.js';
|
||||||
|
export { sendViaStdio } from './stdio-client.js';
|
||||||
150
src/mcpd/src/services/transport/sse-client.ts
Normal file
150
src/mcpd/src/services/transport/sse-client.ts
Normal file
@@ -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<string, unknown>,
|
||||||
|
timeoutMs = 30_000,
|
||||||
|
): Promise<McpProxyResponse> {
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
118
src/mcpd/src/services/transport/stdio-client.ts
Normal file
118
src/mcpd/src/services/transport/stdio-client.ts
Normal file
@@ -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<string, unknown>,
|
||||||
|
timeoutMs = 30_000,
|
||||||
|
): Promise<McpProxyResponse> {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||||||
export const CreateProjectSchema = z.object({
|
export const CreateProjectSchema = z.object({
|
||||||
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
|
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
|
||||||
description: z.string().max(1000).default(''),
|
description: z.string().max(1000).default(''),
|
||||||
|
prompt: z.string().max(10000).default(''),
|
||||||
proxyMode: z.enum(['direct', 'filtered']).default('direct'),
|
proxyMode: z.enum(['direct', 'filtered']).default('direct'),
|
||||||
llmProvider: z.string().max(100).optional(),
|
llmProvider: z.string().max(100).optional(),
|
||||||
llmModel: 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({
|
export const UpdateProjectSchema = z.object({
|
||||||
description: z.string().max(1000).optional(),
|
description: z.string().max(1000).optional(),
|
||||||
|
prompt: z.string().max(10000).optional(),
|
||||||
proxyMode: z.enum(['direct', 'filtered']).optional(),
|
proxyMode: z.enum(['direct', 'filtered']).optional(),
|
||||||
llmProvider: z.string().max(100).nullable().optional(),
|
llmProvider: z.string().max(100).nullable().optional(),
|
||||||
llmModel: z.string().max(100).nullable().optional(),
|
llmModel: z.string().max(100).nullable().optional(),
|
||||||
|
|||||||
23
src/mcpd/src/validation/prompt.schema.ts
Normal file
23
src/mcpd/src/validation/prompt.schema.ts
Normal file
@@ -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<typeof CreatePromptSchema>;
|
||||||
|
export type UpdatePromptInput = z.infer<typeof UpdatePromptSchema>;
|
||||||
|
export type CreatePromptRequestInput = z.infer<typeof CreatePromptRequestSchema>;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const;
|
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. */
|
/** Singular→plural map for resource names. */
|
||||||
const RESOURCE_ALIASES: Record<string, string> = {
|
const RESOURCE_ALIASES: Record<string, string> = {
|
||||||
@@ -12,6 +12,8 @@ const RESOURCE_ALIASES: Record<string, string> = {
|
|||||||
template: 'templates',
|
template: 'templates',
|
||||||
user: 'users',
|
user: 'users',
|
||||||
group: 'groups',
|
group: 'groups',
|
||||||
|
prompt: 'prompts',
|
||||||
|
promptrequest: 'promptrequests',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Normalize a resource name to its canonical plural form. */
|
/** Normalize a resource name to its canonical plural form. */
|
||||||
@@ -20,7 +22,7 @@ export function normalizeResource(resource: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RbacSubjectSchema = z.object({
|
export const RbacSubjectSchema = z.object({
|
||||||
kind: z.enum(['User', 'Group']),
|
kind: z.enum(['User', 'Group', 'ServiceAccount']),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
302
src/mcpd/tests/services/prompt-service.test.ts
Normal file
302
src/mcpd/tests/services/prompt-service.test.ts
Normal file
@@ -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> = {}): 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> = {}): 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> = {}): 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import { McpdUpstream } from './upstream/mcpd.js';
|
|||||||
interface McpdServer {
|
interface McpdServer {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
description?: string;
|
||||||
transport: string;
|
transport: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
@@ -63,7 +64,7 @@ function syncUpstreams(router: McpRouter, mcpdClient: McpdClient, servers: McpdS
|
|||||||
// Add/update upstreams for each server
|
// Add/update upstreams for each server
|
||||||
for (const server of servers) {
|
for (const server of servers) {
|
||||||
if (!currentNames.has(server.name)) {
|
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);
|
router.addUpstream(upstream);
|
||||||
}
|
}
|
||||||
registered.push(server.name);
|
registered.push(server.name);
|
||||||
|
|||||||
@@ -23,11 +23,21 @@ export class ConnectionError extends Error {
|
|||||||
export class McpdClient {
|
export class McpdClient {
|
||||||
private readonly baseUrl: string;
|
private readonly baseUrl: string;
|
||||||
private readonly token: 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
|
// Strip trailing slash for consistent URL joining
|
||||||
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
||||||
this.token = token;
|
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> {
|
async get<T>(path: string): Promise<T> {
|
||||||
@@ -62,6 +72,7 @@ export class McpdClient {
|
|||||||
): Promise<{ status: number; body: unknown }> {
|
): Promise<{ status: number; body: unknown }> {
|
||||||
const url = `${this.baseUrl}${path}${query ? `?${query}` : ''}`;
|
const url = `${this.baseUrl}${path}${query ? `?${query}` : ''}`;
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
|
...this.extraHeaders,
|
||||||
'Authorization': `Bearer ${authOverride ?? this.token}`,
|
'Authorization': `Bearer ${authOverride ?? this.token}`,
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,6 +44,32 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
|
|||||||
const router = existing?.router ?? new McpRouter();
|
const router = existing?.router ?? new McpRouter();
|
||||||
await refreshProjectUpstreams(router, mcpdClient, projectName, authToken);
|
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 });
|
projectCache.set(projectName, { router, lastRefresh: now });
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
@@ -84,7 +110,8 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
|
|||||||
|
|
||||||
transport.onmessage = async (message: JSONRPCMessage) => {
|
transport.onmessage = async (message: JSONRPCMessage) => {
|
||||||
if ('method' in message && 'id' in message) {
|
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);
|
await transport.send(response as unknown as JSONRPCMessage);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from './types.js';
|
import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from './types.js';
|
||||||
import type { LlmProcessor } from './llm/processor.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.
|
* Routes MCP requests to the appropriate upstream server.
|
||||||
@@ -17,11 +22,24 @@ export class McpRouter {
|
|||||||
private promptToServer = new Map<string, string>();
|
private promptToServer = new Map<string, string>();
|
||||||
private notificationHandler: ((notification: JsonRpcNotification) => void) | null = null;
|
private notificationHandler: ((notification: JsonRpcNotification) => void) | null = null;
|
||||||
private llmProcessor: LlmProcessor | 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 {
|
setLlmProcessor(processor: LlmProcessor): void {
|
||||||
this.llmProcessor = processor;
|
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 {
|
addUpstream(connection: UpstreamConnection): void {
|
||||||
this.upstreams.set(connection.name, connection);
|
this.upstreams.set(connection.name, connection);
|
||||||
if (this.notificationHandler && connection.onNotification) {
|
if (this.notificationHandler && connection.onNotification) {
|
||||||
@@ -87,10 +105,18 @@ export class McpRouter {
|
|||||||
for (const tool of tools) {
|
for (const tool of tools) {
|
||||||
const namespacedName = `${serverName}/${tool.name}`;
|
const namespacedName = `${serverName}/${tool.name}`;
|
||||||
this.toolToServer.set(namespacedName, serverName);
|
this.toolToServer.set(namespacedName, serverName);
|
||||||
allTools.push({
|
// Enrich description with server context if available
|
||||||
|
const entry: { name: string; description?: string; inputSchema?: unknown } = {
|
||||||
...tool,
|
...tool,
|
||||||
name: namespacedName,
|
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 {
|
} catch {
|
||||||
@@ -223,7 +249,7 @@ export class McpRouter {
|
|||||||
* Route a generic request. Handles protocol-level methods locally,
|
* Route a generic request. Handles protocol-level methods locally,
|
||||||
* delegates tool/resource/prompt calls to upstreams.
|
* delegates tool/resource/prompt calls to upstreams.
|
||||||
*/
|
*/
|
||||||
async route(request: JsonRpcRequest): Promise<JsonRpcResponse> {
|
async route(request: JsonRpcRequest, context?: RouteContext): Promise<JsonRpcResponse> {
|
||||||
switch (request.method) {
|
switch (request.method) {
|
||||||
case 'initialize':
|
case 'initialize':
|
||||||
return {
|
return {
|
||||||
@@ -240,11 +266,27 @@ export class McpRouter {
|
|||||||
resources: {},
|
resources: {},
|
||||||
prompts: {},
|
prompts: {},
|
||||||
},
|
},
|
||||||
|
...(this.instructions ? { instructions: this.instructions } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
case 'tools/list': {
|
case 'tools/list': {
|
||||||
const tools = await this.discoverTools();
|
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 {
|
return {
|
||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
id: request.id,
|
id: request.id,
|
||||||
@@ -253,10 +295,32 @@ export class McpRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'tools/call':
|
case 'tools/call':
|
||||||
return this.routeToolCall(request);
|
return this.routeToolCall(request, context);
|
||||||
|
|
||||||
case 'resources/list': {
|
case 'resources/list': {
|
||||||
const resources = await this.discoverResources();
|
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 {
|
return {
|
||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
id: request.id,
|
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);
|
return this.routeNamespacedCall(request, 'uri', this.resourceToServer);
|
||||||
|
}
|
||||||
|
|
||||||
case 'resources/subscribe':
|
case 'resources/subscribe':
|
||||||
case 'resources/unsubscribe':
|
case 'resources/unsubscribe':
|
||||||
@@ -295,10 +379,15 @@ export class McpRouter {
|
|||||||
/**
|
/**
|
||||||
* Route a tools/call request, optionally applying LLM pre/post-processing.
|
* 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 params = request.params as Record<string, unknown> | undefined;
|
||||||
const toolName = params?.['name'] as string | 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 no processor or tool shouldn't be processed, route directly
|
||||||
if (!this.llmProcessor || !toolName || !this.llmProcessor.shouldProcess('tools/call', toolName)) {
|
if (!this.llmProcessor || !toolName || !this.llmProcessor.shouldProcess('tools/call', toolName)) {
|
||||||
return this.routeNamespacedCall(request, 'name', this.toolToServer);
|
return this.routeNamespacedCall(request, 'name', this.toolToServer);
|
||||||
@@ -323,6 +412,61 @@ export class McpRouter {
|
|||||||
return response;
|
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[] {
|
getUpstreamNames(): string[] {
|
||||||
return [...this.upstreams.keys()];
|
return [...this.upstreams.keys()];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export interface ProxyConfig {
|
|||||||
export interface UpstreamConnection {
|
export interface UpstreamConnection {
|
||||||
/** Server name */
|
/** Server name */
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Human-readable description of the server's purpose */
|
||||||
|
description?: string;
|
||||||
/** Send a JSON-RPC request and get a response */
|
/** Send a JSON-RPC request and get a response */
|
||||||
send(request: JsonRpcRequest): Promise<JsonRpcResponse>;
|
send(request: JsonRpcRequest): Promise<JsonRpcResponse>;
|
||||||
/** Disconnect from the upstream */
|
/** Disconnect from the upstream */
|
||||||
|
|||||||
@@ -18,14 +18,17 @@ interface McpdProxyResponse {
|
|||||||
*/
|
*/
|
||||||
export class McpdUpstream implements UpstreamConnection {
|
export class McpdUpstream implements UpstreamConnection {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
|
readonly description?: string;
|
||||||
private alive = true;
|
private alive = true;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private serverId: string,
|
private serverId: string,
|
||||||
serverName: string,
|
serverName: string,
|
||||||
private mcpdClient: McpdClient,
|
private mcpdClient: McpdClient,
|
||||||
|
serverDescription?: string,
|
||||||
) {
|
) {
|
||||||
this.name = serverName;
|
this.name = serverName;
|
||||||
|
if (serverDescription !== undefined) this.description = serverDescription;
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(request: JsonRpcRequest): Promise<JsonRpcResponse> {
|
async send(request: JsonRpcRequest): Promise<JsonRpcResponse> {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ vi.mock('../src/discovery.js', () => ({
|
|||||||
import { refreshProjectUpstreams } from '../src/discovery.js';
|
import { refreshProjectUpstreams } from '../src/discovery.js';
|
||||||
|
|
||||||
function mockMcpdClient() {
|
function mockMcpdClient() {
|
||||||
return {
|
const client: Record<string, unknown> = {
|
||||||
baseUrl: 'http://test:3100',
|
baseUrl: 'http://test:3100',
|
||||||
token: 'test-token',
|
token: 'test-token',
|
||||||
get: vi.fn(async () => []),
|
get: vi.fn(async () => []),
|
||||||
@@ -19,7 +19,11 @@ function mockMcpdClient() {
|
|||||||
put: vi.fn(),
|
put: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
forward: vi.fn(async () => ({ status: 200, body: [] })),
|
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', () => {
|
describe('registerProjectMcpEndpoint', () => {
|
||||||
|
|||||||
248
src/mcplocal/tests/router-prompts.test.ts
Normal file
248
src/mcplocal/tests/router-prompts.test.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { McpRouter } from '../src/router.js';
|
||||||
|
import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from '../src/types.js';
|
||||||
|
import type { McpdClient } from '../src/http/mcpd-client.js';
|
||||||
|
|
||||||
|
function mockUpstream(name: string, opts?: {
|
||||||
|
tools?: Array<{ name: string; description?: string; inputSchema?: unknown }>;
|
||||||
|
}): UpstreamConnection {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
isAlive: vi.fn(() => true),
|
||||||
|
close: vi.fn(async () => {}),
|
||||||
|
onNotification: vi.fn(),
|
||||||
|
send: vi.fn(async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
|
||||||
|
if (req.method === 'tools/list') {
|
||||||
|
return { jsonrpc: '2.0', id: req.id, result: { tools: opts?.tools ?? [] } };
|
||||||
|
}
|
||||||
|
if (req.method === 'resources/list') {
|
||||||
|
return { jsonrpc: '2.0', id: req.id, result: { resources: [] } };
|
||||||
|
}
|
||||||
|
return { jsonrpc: '2.0', id: req.id, result: {} };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockMcpdClient(): McpdClient {
|
||||||
|
return {
|
||||||
|
get: vi.fn(async () => []),
|
||||||
|
post: vi.fn(async () => ({})),
|
||||||
|
put: vi.fn(async () => ({})),
|
||||||
|
delete: vi.fn(async () => {}),
|
||||||
|
forward: vi.fn(async () => ({ status: 200, body: {} })),
|
||||||
|
withHeaders: vi.fn(function (this: McpdClient) { return this; }),
|
||||||
|
} as unknown as McpdClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('McpRouter - Prompt Integration', () => {
|
||||||
|
let router: McpRouter;
|
||||||
|
let mcpdClient: McpdClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
router = new McpRouter();
|
||||||
|
mcpdClient = mockMcpdClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('propose_prompt tool', () => {
|
||||||
|
it('should include propose_prompt in tools/list when prompt config is set', async () => {
|
||||||
|
router.setPromptConfig(mcpdClient, 'test-project');
|
||||||
|
router.addUpstream(mockUpstream('server1'));
|
||||||
|
|
||||||
|
const response = await router.route({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'tools/list',
|
||||||
|
});
|
||||||
|
|
||||||
|
const tools = (response.result as { tools: Array<{ name: string }> }).tools;
|
||||||
|
expect(tools.some((t) => t.name === 'propose_prompt')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT include propose_prompt when no prompt config', async () => {
|
||||||
|
router.addUpstream(mockUpstream('server1'));
|
||||||
|
|
||||||
|
const response = await router.route({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1,
|
||||||
|
method: 'tools/list',
|
||||||
|
});
|
||||||
|
|
||||||
|
const tools = (response.result as { tools: Array<{ name: string }> }).tools;
|
||||||
|
expect(tools.some((t) => t.name === 'propose_prompt')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call mcpd to create a prompt request', async () => {
|
||||||
|
router.setPromptConfig(mcpdClient, 'my-project');
|
||||||
|
|
||||||
|
const response = await router.route(
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 2,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'propose_prompt',
|
||||||
|
arguments: { name: 'my-prompt', content: 'Hello world' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ sessionId: 'sess-123' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.error).toBeUndefined();
|
||||||
|
expect(mcpdClient.post).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/projects/my-project/promptrequests',
|
||||||
|
{ name: 'my-prompt', content: 'Hello world', createdBySession: 'sess-123' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error when name or content missing', async () => {
|
||||||
|
router.setPromptConfig(mcpdClient, 'proj');
|
||||||
|
|
||||||
|
const response = await router.route({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 3,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'propose_prompt',
|
||||||
|
arguments: { name: 'only-name' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.error?.code).toBe(-32602);
|
||||||
|
expect(response.error?.message).toContain('Missing required arguments');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error when mcpd call fails', async () => {
|
||||||
|
router.setPromptConfig(mcpdClient, 'proj');
|
||||||
|
vi.mocked(mcpdClient.post).mockRejectedValue(new Error('mcpd returned 409'));
|
||||||
|
|
||||||
|
const response = await router.route({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 4,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'propose_prompt',
|
||||||
|
arguments: { name: 'dup', content: 'x' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.error?.code).toBe(-32603);
|
||||||
|
expect(response.error?.message).toContain('mcpd returned 409');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prompt resources', () => {
|
||||||
|
it('should include prompt resources in resources/list', async () => {
|
||||||
|
router.setPromptConfig(mcpdClient, 'test-project');
|
||||||
|
vi.mocked(mcpdClient.get).mockResolvedValue([
|
||||||
|
{ name: 'approved-prompt', content: 'Content A', type: 'prompt' },
|
||||||
|
{ name: 'pending-req', content: 'Content B', type: 'promptrequest' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const response = await router.route(
|
||||||
|
{ jsonrpc: '2.0', id: 1, method: 'resources/list' },
|
||||||
|
{ sessionId: 'sess-1' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const resources = (response.result as { resources: Array<{ uri: string; description?: string }> }).resources;
|
||||||
|
expect(resources).toHaveLength(2);
|
||||||
|
expect(resources[0]!.uri).toBe('mcpctl://prompts/approved-prompt');
|
||||||
|
expect(resources[0]!.description).toContain('Approved');
|
||||||
|
expect(resources[1]!.uri).toBe('mcpctl://prompts/pending-req');
|
||||||
|
expect(resources[1]!.description).toContain('Pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass session ID when fetching visible prompts', async () => {
|
||||||
|
router.setPromptConfig(mcpdClient, 'proj');
|
||||||
|
vi.mocked(mcpdClient.get).mockResolvedValue([]);
|
||||||
|
|
||||||
|
await router.route(
|
||||||
|
{ jsonrpc: '2.0', id: 1, method: 'resources/list' },
|
||||||
|
{ sessionId: 'my-session' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mcpdClient.get).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/projects/proj/prompts/visible?session=my-session',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should read mcpctl resource content', async () => {
|
||||||
|
router.setPromptConfig(mcpdClient, 'proj');
|
||||||
|
vi.mocked(mcpdClient.get).mockResolvedValue([
|
||||||
|
{ name: 'my-prompt', content: 'The content here', type: 'prompt' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// First list to populate cache
|
||||||
|
await router.route({ jsonrpc: '2.0', id: 1, method: 'resources/list' });
|
||||||
|
|
||||||
|
// Then read
|
||||||
|
const response = await router.route({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 2,
|
||||||
|
method: 'resources/read',
|
||||||
|
params: { uri: 'mcpctl://prompts/my-prompt' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.error).toBeUndefined();
|
||||||
|
const contents = (response.result as { contents: Array<{ text: string }> }).contents;
|
||||||
|
expect(contents[0]!.text).toBe('The content here');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error for unknown mcpctl resource', async () => {
|
||||||
|
router.setPromptConfig(mcpdClient, 'proj');
|
||||||
|
|
||||||
|
const response = await router.route({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 3,
|
||||||
|
method: 'resources/read',
|
||||||
|
params: { uri: 'mcpctl://prompts/nonexistent' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.error?.code).toBe(-32602);
|
||||||
|
expect(response.error?.message).toContain('Resource not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not fail when mcpd is unavailable', async () => {
|
||||||
|
router.setPromptConfig(mcpdClient, 'proj');
|
||||||
|
vi.mocked(mcpdClient.get).mockRejectedValue(new Error('Connection refused'));
|
||||||
|
|
||||||
|
const response = await router.route({ jsonrpc: '2.0', id: 1, method: 'resources/list' });
|
||||||
|
|
||||||
|
// Should succeed with empty resources (upstream errors are swallowed)
|
||||||
|
expect(response.error).toBeUndefined();
|
||||||
|
const resources = (response.result as { resources: unknown[] }).resources;
|
||||||
|
expect(resources).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('session isolation', () => {
|
||||||
|
it('should not include session parameter when no sessionId in context', async () => {
|
||||||
|
router.setPromptConfig(mcpdClient, 'proj');
|
||||||
|
vi.mocked(mcpdClient.get).mockResolvedValue([]);
|
||||||
|
|
||||||
|
await router.route({ jsonrpc: '2.0', id: 1, method: 'resources/list' });
|
||||||
|
|
||||||
|
expect(mcpdClient.get).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/projects/proj/prompts/visible',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include session in propose when no context', async () => {
|
||||||
|
router.setPromptConfig(mcpdClient, 'proj');
|
||||||
|
|
||||||
|
await router.route({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 2,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'propose_prompt',
|
||||||
|
arguments: { name: 'test', content: 'stuff' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mcpdClient.post).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/projects/proj/promptrequests',
|
||||||
|
{ name: 'test', content: 'stuff' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user