Files
mcpctl/src/mcpd/src/services/prompt.service.ts
Michal 5d859ca7d8 feat: audit console TUI, system prompt management, and CLI improvements
Audit Console Phase 1: tool_call_trace emission from mcplocal router,
session_bind/rbac_decision event kinds, GET /audit/sessions endpoint,
full Ink TUI with session sidebar, event timeline, and detail view
(mcpctl console --audit).

System prompts: move 6 hardcoded LLM prompts to mcpctl-system project
with extensible ResourceRuleRegistry validation framework, template
variable enforcement ({{maxTokens}}, {{pageCount}}), and delete-resets-
to-default behavior. All consumers fetch via SystemPromptFetcher with
hardcoded fallbacks.

CLI: -p shorthand for --project across get/create/delete/config commands,
console auto-scroll improvements, shell completions regenerated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 23:50:54 +00:00

246 lines
9.5 KiB
TypeScript

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, UpdatePromptRequestSchema } from '../validation/prompt.schema.js';
import { NotFoundError } from './mcp-server.service.js';
import type { PromptSummaryService } from './prompt-summary.service.js';
import { SYSTEM_PROJECT_NAME, getSystemPromptDefault } from '../bootstrap/system-project.js';
import type { ResourceRuleRegistry, RuleContext } from '../validation/resource-rules.js';
export class PromptService {
private summaryService: PromptSummaryService | null = null;
constructor(
private readonly promptRepo: IPromptRepository,
private readonly promptRequestRepo: IPromptRequestRepository,
private readonly projectRepo: IProjectRepository,
private readonly ruleRegistry?: ResourceRuleRegistry,
) {}
setSummaryService(service: PromptSummaryService): void {
this.summaryService = service;
}
/**
* Run resource validation rules for a prompt.
* Throws 400 if validation fails.
*/
private async validatePromptRules(
name: string,
content: string,
projectId: string | undefined | null,
operation: 'create' | 'update',
): Promise<void> {
if (!this.ruleRegistry || !projectId) return;
const project = await this.projectRepo.findById(projectId);
const isSystem = project?.name === SYSTEM_PROJECT_NAME;
const ctx: RuleContext = {
findResource: async () => null,
};
const result = await this.ruleRegistry.validate(
'prompt',
{ name, content },
{ projectName: project?.name, isSystemResource: isSystem, operation },
ctx,
);
if (!result.valid) {
throw Object.assign(
new Error(result.errors?.join('; ') ?? 'Validation failed'),
{ statusCode: 400 },
);
}
}
// ── Prompt CRUD ──
async listPrompts(projectId?: string): Promise<Prompt[]> {
return this.promptRepo.findAll(projectId);
}
async listGlobalPrompts(): Promise<Prompt[]> {
return this.promptRepo.findGlobal();
}
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}`);
}
await this.validatePromptRules(data.name, data.content, data.projectId, 'create');
const createData: { name: string; content: string; projectId?: string; priority?: number; linkTarget?: string } = {
name: data.name,
content: data.content,
};
if (data.projectId !== undefined) createData.projectId = data.projectId;
if (data.priority !== undefined) createData.priority = data.priority;
if (data.linkTarget !== undefined) createData.linkTarget = data.linkTarget;
const prompt = await this.promptRepo.create(createData);
// Auto-generate summary/chapters (non-blocking — don't fail create if summary fails)
if (this.summaryService && !data.linkTarget) {
this.generateAndStoreSummary(prompt.id, data.content).catch(() => {});
}
return prompt;
}
async updatePrompt(id: string, input: unknown): Promise<Prompt> {
const data = UpdatePromptSchema.parse(input);
const existing = await this.getPrompt(id);
if (data.content !== undefined) {
await this.validatePromptRules(existing.name, data.content, existing.projectId, 'update');
}
const updateData: { content?: string; priority?: number } = {};
if (data.content !== undefined) updateData.content = data.content;
if (data.priority !== undefined) updateData.priority = data.priority;
const prompt = await this.promptRepo.update(id, updateData);
// Regenerate summary when content changes
if (this.summaryService && data.content !== undefined && !prompt.linkTarget) {
this.generateAndStoreSummary(prompt.id, data.content).catch(() => {});
}
return prompt;
}
async regenerateSummary(id: string): Promise<Prompt> {
const prompt = await this.getPrompt(id);
if (!this.summaryService) {
throw new Error('Summary generation not available');
}
return this.generateAndStoreSummary(prompt.id, prompt.content);
}
private async generateAndStoreSummary(id: string, content: string): Promise<Prompt> {
if (!this.summaryService) throw new Error('No summary service');
const { summary, chapters } = await this.summaryService.generateSummary(content);
return this.promptRepo.update(id, { summary, chapters });
}
async deletePrompt(id: string): Promise<Prompt | void> {
const prompt = await this.getPrompt(id);
// System prompts: reset to codebase default instead of deleting
if (prompt.projectId) {
const project = await this.projectRepo.findById(prompt.projectId);
if (project?.name === SYSTEM_PROJECT_NAME) {
const defaultContent = getSystemPromptDefault(prompt.name);
if (defaultContent !== undefined) {
return this.promptRepo.update(id, { content: defaultContent });
}
// Unknown system prompt — allow deletion
}
}
await this.promptRepo.delete(id);
}
// ── PromptRequest CRUD ──
async listPromptRequests(projectId?: string): Promise<PromptRequest[]> {
return this.promptRequestRepo.findAll(projectId);
}
async listGlobalPromptRequests(): Promise<PromptRequest[]> {
return this.promptRequestRepo.findGlobal();
}
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 updatePromptRequest(id: string, input: unknown): Promise<PromptRequest> {
await this.getPromptRequest(id);
const data = UpdatePromptRequestSchema.parse(input);
const updateData: { content?: string; priority?: number } = {};
if (data.content !== undefined) updateData.content = data.content;
if (data.priority !== undefined) updateData.priority = data.priority;
return this.promptRequestRepo.update(id, updateData);
}
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; priority?: number; createdBySession?: string; createdByUserId?: string } = {
name: data.name,
content: data.content,
};
if (data.projectId !== undefined) createData.projectId = data.projectId;
if (data.priority !== undefined) createData.priority = data.priority;
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 (carry priority from request)
const createData: { name: string; content: string; projectId?: string; priority?: number } = {
name: req.name,
content: req.content,
};
if (req.projectId !== null) createData.projectId = req.projectId;
if (req.priority !== 5) createData.priority = req.priority;
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; priority: number; summary: string | null; chapters: string[] | null; linkTarget: string | null; type: 'prompt' | 'promptrequest' }>> {
const results: Array<{ name: string; content: string; priority: number; summary: string | null; chapters: string[] | null; linkTarget: string | null; 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, priority: p.priority, summary: p.summary, chapters: p.chapters as string[] | null, linkTarget: p.linkTarget, 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, priority: 5, summary: null, chapters: null, linkTarget: null, type: 'promptrequest' });
}
}
return results;
}
}