feat: add prompt resources, fix MCP proxy transport, enrich tool descriptions

- Fix MCP proxy to support SSE and STDIO transports (not just HTTP POST)
- Enrich tool descriptions with server context for LLM clarity
- Add Prompt and PromptRequest resources with two-resource RBAC model
- Add propose_prompt MCP tool for LLM to create pending prompt requests
- Add prompt resources visible in MCP resources/list (approved + session's pending)
- Add project-level prompt/instructions in MCP initialize response
- Add ServiceAccount subject type for RBAC (SA identity from X-Service-Account header)
- Add CLI commands: create prompt, get prompts/promptrequests, approve promptrequest
- Add prompts to apply config schema
- 956 tests passing across all packages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-24 14:53:00 +00:00
parent fdafe87a77
commit b025ade2b0
32 changed files with 1713 additions and 94 deletions

View File

@@ -18,6 +18,8 @@ import {
UserRepository,
GroupRepository,
} from './repositories/index.js';
import { PromptRepository } from './repositories/prompt.repository.js';
import { PromptRequestRepository } from './repositories/prompt-request.repository.js';
import {
McpServerService,
SecretService,
@@ -56,6 +58,8 @@ import {
registerUserRoutes,
registerGroupRoutes,
} from './routes/index.js';
import { registerPromptRoutes } from './routes/prompts.js';
import { PromptService } from './services/prompt.service.js';
type PermissionCheck =
| { kind: 'resource'; resource: string; action: RbacAction; resourceName?: string }
@@ -88,11 +92,38 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
'rbac': 'rbac',
'audit-logs': 'rbac',
'mcp': 'servers',
'prompts': 'prompts',
'promptrequests': 'promptrequests',
};
const resource = resourceMap[segment];
if (resource === undefined) return { kind: 'skip' };
// Special case: /api/v1/promptrequests/:id/approve → needs both delete+promptrequests and create+prompts
// We check delete on promptrequests (the harder permission); create on prompts is checked in the service layer
const approveMatch = url.match(/^\/api\/v1\/promptrequests\/([^/?]+)\/approve/);
if (approveMatch?.[1]) {
return { kind: 'resource', resource: 'promptrequests', action: 'delete', resourceName: approveMatch[1] };
}
// Special case: /api/v1/projects/:name/prompts/visible → view prompts
const visiblePromptsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/prompts\/visible/);
if (visiblePromptsMatch?.[1]) {
return { kind: 'resource', resource: 'prompts', action: 'view' };
}
// Special case: /api/v1/projects/:name/promptrequests → create promptrequests
const projectPromptrequestsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/promptrequests/);
if (projectPromptrequestsMatch?.[1] && method === 'POST') {
return { kind: 'resource', resource: 'promptrequests', action: 'create' };
}
// Special case: /api/v1/projects/:id/instructions → view projects
const instructionsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/instructions/);
if (instructionsMatch?.[1]) {
return { kind: 'resource', resource: 'projects', action: 'view', resourceName: instructionsMatch[1] };
}
// Special case: /api/v1/projects/:id/mcp-config → requires 'expose' permission
const mcpConfigMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/mcp-config/);
if (mcpConfigMatch?.[1]) {
@@ -243,11 +274,14 @@ async function main(): Promise<void> {
const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo);
const authService = new AuthService(prisma);
const templateService = new TemplateService(templateRepo);
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo);
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo, orchestrator);
const rbacDefinitionService = new RbacDefinitionService(rbacDefinitionRepo);
const rbacService = new RbacService(rbacDefinitionRepo, prisma);
const userService = new UserService(userRepo);
const groupService = new GroupService(groupRepo, userRepo);
const promptRepo = new PromptRepository(prisma);
const promptRequestRepo = new PromptRequestRepository(prisma);
const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo);
// Auth middleware for global hooks
const authMiddleware = createAuthMiddleware({
@@ -294,9 +328,13 @@ async function main(): Promise<void> {
const check = mapUrlToPermission(request.method, url);
if (check.kind === 'skip') return;
// Extract service account identity from header (sent by mcplocal)
const saHeader = request.headers['x-service-account'];
const serviceAccountName = typeof saHeader === 'string' ? saHeader : undefined;
let allowed: boolean;
if (check.kind === 'operation') {
allowed = await rbacService.canRunOperation(request.userId, check.operation);
allowed = await rbacService.canRunOperation(request.userId, check.operation, serviceAccountName);
} else {
// Resolve CUID → human name for name-scoped RBAC bindings
if (check.resourceName !== undefined && CUID_RE.test(check.resourceName)) {
@@ -306,10 +344,10 @@ async function main(): Promise<void> {
if (entity) check.resourceName = entity.name;
}
}
allowed = await rbacService.canAccess(request.userId, check.action, check.resource, check.resourceName);
allowed = await rbacService.canAccess(request.userId, check.action, check.resource, check.resourceName, serviceAccountName);
// Compute scope for list filtering (used by preSerialization hook)
if (allowed && check.resourceName === undefined) {
request.rbacScope = await rbacService.getAllowedScope(request.userId, check.action, check.resource);
request.rbacScope = await rbacService.getAllowedScope(request.userId, check.action, check.resource, serviceAccountName);
}
}
if (!allowed) {
@@ -335,6 +373,7 @@ async function main(): Promise<void> {
registerRbacRoutes(app, rbacDefinitionService);
registerUserRoutes(app, userService);
registerGroupRoutes(app, groupService);
registerPromptRoutes(app, promptService, projectRepo);
// ── RBAC list filtering hook ──
// Filters array responses to only include resources the user is allowed to see.

View File

@@ -12,7 +12,7 @@ export interface IProjectRepository {
findAll(ownerId?: string): Promise<ProjectWithRelations[]>;
findById(id: 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>;
delete(id: 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>;
}
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> = {
name: data.name,
description: data.description,
ownerId: data.ownerId,
proxyMode: data.proxyMode,
};
if (data.prompt !== undefined) createData['prompt'] = data.prompt;
if (data.llmProvider !== undefined) createData['llmProvider'] = data.llmProvider;
if (data.llmModel !== undefined) createData['llmModel'] = data.llmModel;

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

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

View File

@@ -54,4 +54,16 @@ export function registerProjectRoutes(app: FastifyInstance, service: ProjectServ
const project = await service.resolveAndGet(request.params.id);
return project.servers.map((ps) => ps.server);
});
// Get project instructions for LLM (prompt + server list)
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/instructions', async (request) => {
const project = await service.resolveAndGet(request.params.id);
return {
prompt: project.prompt,
servers: project.servers.map((ps) => ({
name: (ps.server as Record<string, unknown>).name as string,
description: (ps.server as Record<string, unknown>).description as string,
})),
};
});
}

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

View File

@@ -1,7 +1,10 @@
import type { McpInstance } from '@prisma/client';
import type { McpInstance, McpServer } from '@prisma/client';
import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js';
import type { McpOrchestrator } from './orchestrator.js';
import { NotFoundError } from './mcp-server.service.js';
import { InvalidStateError } from './instance.service.js';
import { sendViaSse } from './transport/sse-client.js';
import { sendViaStdio } from './transport/stdio-client.js';
export interface McpProxyRequest {
serverId: string;
@@ -38,17 +41,21 @@ export class McpProxyService {
constructor(
private readonly instanceRepo: IMcpInstanceRepository,
private readonly serverRepo: IMcpServerRepository,
private readonly orchestrator?: McpOrchestrator,
) {}
async execute(request: McpProxyRequest): Promise<McpProxyResponse> {
const server = await this.serverRepo.findById(request.serverId);
// External server: proxy directly to externalUrl
if (server?.externalUrl) {
return this.sendToExternal(server.id, server.externalUrl, request.method, request.params);
if (!server) {
throw new NotFoundError(`Server '${request.serverId}' not found`);
}
// Managed server: find running instance
// External server: proxy directly to externalUrl
if (server.externalUrl) {
return this.sendToExternal(server, request.method, request.params);
}
// Managed server: find running instance and dispatch by transport
const instances = await this.instanceRepo.findAll(request.serverId);
const running = instances.find((i) => i.status === 'RUNNING');
@@ -56,20 +63,95 @@ export class McpProxyService {
throw new NotFoundError(`No running instance found for server '${request.serverId}'`);
}
if (running.port === null || running.port === undefined) {
throw new InvalidStateError(
`Running instance '${running.id}' for server '${request.serverId}' has no port assigned`,
);
}
return this.sendJsonRpc(running, request.method, request.params);
return this.sendToManaged(server, running, request.method, request.params);
}
/**
* Send a JSON-RPC request to an external MCP server.
* Handles streamable-http protocol (session management + SSE response parsing).
* Send to an external MCP server. Dispatches based on transport type.
*/
private async sendToExternal(
server: McpServer,
method: string,
params?: Record<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,
url: string,
method: string,
@@ -109,14 +191,14 @@ export class McpProxyService {
// Session expired? Clear and retry once
if (response.status === 400 || response.status === 404) {
this.sessions.delete(serverId);
return this.sendToExternal(serverId, url, method, params);
return this.sendStreamableHttp(serverId, url, method, params);
}
return {
jsonrpc: '2.0',
id: 1,
error: {
code: -32000,
message: `External MCP server returned HTTP ${response.status}: ${response.statusText}`,
message: `MCP server returned HTTP ${response.status}: ${response.statusText}`,
},
};
}
@@ -126,8 +208,7 @@ export class McpProxyService {
}
/**
* Initialize a streamable-http session with an external server.
* Sends `initialize` and `notifications/initialized`, caches the session ID.
* Initialize a streamable-http session with a server.
*/
private async initSession(serverId: string, url: string): Promise<void> {
const initBody = {
@@ -174,41 +255,4 @@ export class McpProxyService {
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
});
}
private async sendJsonRpc(
instance: McpInstance,
method: string,
params?: Record<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;
}
}

View File

@@ -53,6 +53,7 @@ export class ProjectService {
const project = await this.projectRepo.create({
name: data.name,
description: data.description,
prompt: data.prompt,
ownerId,
proxyMode: data.proxyMode,
...(data.llmProvider !== undefined ? { llmProvider: data.llmProvider } : {}),
@@ -75,6 +76,7 @@ export class ProjectService {
// Build update data for scalar fields
const updateData: Record<string, unknown> = {};
if (data.description !== undefined) updateData['description'] = data.description;
if (data.prompt !== undefined) updateData['prompt'] = data.prompt;
if (data.proxyMode !== undefined) updateData['proxyMode'] = data.proxyMode;
if (data.llmProvider !== undefined) updateData['llmProvider'] = data.llmProvider;
if (data.llmModel !== undefined) updateData['llmModel'] = data.llmModel;

View 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;
}
}

View File

@@ -50,8 +50,8 @@ export class RbacService {
* If provided, name-scoped bindings only match when their name equals this.
* If omitted (listing), name-scoped bindings still grant access.
*/
async canAccess(userId: string, action: RbacAction, resource: string, resourceName?: string): Promise<boolean> {
const permissions = await this.getPermissions(userId);
async canAccess(userId: string, action: RbacAction, resource: string, resourceName?: string, serviceAccountName?: string): Promise<boolean> {
const permissions = await this.getPermissions(userId, serviceAccountName);
const normalized = normalizeResource(resource);
for (const perm of permissions) {
@@ -73,8 +73,8 @@ export class RbacService {
* Check whether a user is allowed to perform a named operation.
* Operations require an explicit 'run' role binding with a matching action.
*/
async canRunOperation(userId: string, operation: string): Promise<boolean> {
const permissions = await this.getPermissions(userId);
async canRunOperation(userId: string, operation: string, serviceAccountName?: string): Promise<boolean> {
const permissions = await this.getPermissions(userId, serviceAccountName);
for (const perm of permissions) {
if ('action' in perm && perm.role === 'run' && perm.action === operation) {
@@ -90,8 +90,8 @@ export class RbacService {
* Returns wildcard:true if any matching binding is unscoped (no name constraint).
* Returns wildcard:false with a set of allowed names if all bindings are name-scoped.
*/
async getAllowedScope(userId: string, action: RbacAction, resource: string): Promise<AllowedScope> {
const permissions = await this.getPermissions(userId);
async getAllowedScope(userId: string, action: RbacAction, resource: string, serviceAccountName?: string): Promise<AllowedScope> {
const permissions = await this.getPermissions(userId, serviceAccountName);
const normalized = normalizeResource(resource);
const names = new Set<string>();
@@ -113,31 +113,35 @@ export class RbacService {
/**
* 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
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { email: true },
});
if (user === null) return [];
if (user === null && serviceAccountName === undefined) return [];
// 2. Resolve group names the user belongs to
const memberships = await this.prisma.groupMember.findMany({
where: { userId },
select: { group: { select: { name: true } } },
});
const groupNames = memberships.map((m) => m.group.name);
let groupNames: string[] = [];
if (user !== null) {
const memberships = await this.prisma.groupMember.findMany({
where: { userId },
select: { group: { select: { name: true } } },
});
groupNames = memberships.map((m) => m.group.name);
}
// 3. Load all RbacDefinitions
const definitions = await this.rbacRepo.findAll();
// 4. Find definitions where user is a subject
// 4. Find definitions where user or service account is a subject
const permissions: Permission[] = [];
for (const def of definitions) {
const subjects = def.subjects as RbacSubject[];
const matched = subjects.some((s) => {
if (s.kind === 'User') return s.name === user.email;
if (s.kind === 'User') return user !== null && s.name === user.email;
if (s.kind === 'Group') return groupNames.includes(s.name);
if (s.kind === 'ServiceAccount') return serviceAccountName !== undefined && s.name === serviceAccountName;
return false;
});

View File

@@ -0,0 +1,2 @@
export { sendViaSse } from './sse-client.js';
export { sendViaStdio } from './stdio-client.js';

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

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

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
export const CreateProjectSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
description: z.string().max(1000).default(''),
prompt: z.string().max(10000).default(''),
proxyMode: z.enum(['direct', 'filtered']).default('direct'),
llmProvider: z.string().max(100).optional(),
llmModel: z.string().max(100).optional(),
@@ -14,6 +15,7 @@ export const CreateProjectSchema = z.object({
export const UpdateProjectSchema = z.object({
description: z.string().max(1000).optional(),
prompt: z.string().max(10000).optional(),
proxyMode: z.enum(['direct', 'filtered']).optional(),
llmProvider: z.string().max(100).nullable().optional(),
llmModel: z.string().max(100).nullable().optional(),

View 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>;

View File

@@ -1,7 +1,7 @@
import { z } from 'zod';
export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const;
export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'projects', 'templates', 'users', 'groups', 'rbac'] as const;
export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests'] as const;
/** Singular→plural map for resource names. */
const RESOURCE_ALIASES: Record<string, string> = {
@@ -12,6 +12,8 @@ const RESOURCE_ALIASES: Record<string, string> = {
template: 'templates',
user: 'users',
group: 'groups',
prompt: 'prompts',
promptrequest: 'promptrequests',
};
/** Normalize a resource name to its canonical plural form. */
@@ -20,7 +22,7 @@ export function normalizeResource(resource: string): string {
}
export const RbacSubjectSchema = z.object({
kind: z.enum(['User', 'Group']),
kind: z.enum(['User', 'Group', 'ServiceAccount']),
name: z.string().min(1),
});

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