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>
246 lines
9.5 KiB
TypeScript
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;
|
|
}
|
|
}
|