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>
This commit is contained in:
Michal
2026-03-03 23:50:54 +00:00
parent 89f869f460
commit 5d859ca7d8
42 changed files with 1932 additions and 77 deletions

View File

@@ -17,6 +17,8 @@ interface SystemPromptDef {
name: string;
priority: number;
content: string;
/** Template variables that must be present when editing (e.g., '{{maxTokens}}'). */
requiredVars?: string[];
}
const SYSTEM_PROMPTS: SystemPromptDef[] = [
@@ -59,6 +61,63 @@ Examples:
This will load relevant project context, policies, and guidelines tailored to your work.`,
},
// ── LLM pipeline prompts (priority 5, editable) ──
{
name: 'llm-response-filter',
priority: 5,
content: `You are a data filtering assistant. Your job is to extract only the relevant information from MCP tool responses.
Rules:
- Remove redundant or verbose fields that aren't useful to the user's query
- Keep essential identifiers, names, statuses, and key metrics
- Preserve error messages and warnings in full
- If the response is already concise, return it unchanged
- Output valid JSON only, no markdown or explanations
- If you cannot parse the input, return it unchanged`,
},
{
name: 'llm-request-optimization',
priority: 5,
content: `You are a query optimization assistant. Your job is to optimize MCP tool call parameters.
Rules:
- Add appropriate filters or limits if the query is too broad
- Keep the original intent of the request
- Output valid JSON with the optimized parameters only, no markdown or explanations
- If no optimization is needed, return the original parameters unchanged`,
},
{
name: 'llm-pagination-index',
priority: 5,
content: `You are a document indexing assistant. Given a large tool response split into pages, generate a concise summary for each page describing what data it contains.
Rules:
- For each page, write 1-2 sentences describing the key content
- Be specific: mention entity names, IDs, counts, or key fields visible on that page
- If it's JSON, describe the structure and notable entries
- If it's text, describe the topics covered
- Output valid JSON only: an array of objects with "page" (1-based number) and "summary" (string)
- Example output: [{"page": 1, "summary": "Configuration nodes and global settings (inject, debug, function nodes 1-15)"}, {"page": 2, "summary": "HTTP request nodes and API integrations (nodes 16-40)"}]`,
},
{
name: 'llm-gate-context-selector',
priority: 5,
content: `You are a context selection assistant. Given a developer's task keywords and a list of available project prompts, select which prompts are relevant to their work. Return a JSON object with "selectedNames" (array of prompt names) and "reasoning" (brief explanation). Priority 10 prompts must always be included.`,
},
{
name: 'llm-summarize',
priority: 5,
requiredVars: ['{{maxTokens}}'],
content: `Summarize the following in about {{maxTokens}} tokens. Preserve all items marked MUST, REQUIRED, or CRITICAL verbatim. Be specific — mention names, IDs, counts, key values.`,
},
{
name: 'llm-paginate-titles',
priority: 5,
requiredVars: ['{{pageCount}}'],
content: `Generate exactly {{pageCount}} short descriptive titles (max 60 chars each) for the following {{pageCount}} pages. Return ONLY a JSON array of {{pageCount}} strings. No markdown, no explanation.`,
},
];
/**
@@ -116,3 +175,15 @@ export async function bootstrapSystemProject(prisma: PrismaClient): Promise<void
export function getSystemPromptNames(): string[] {
return SYSTEM_PROMPTS.map((p) => p.name);
}
/** Get the required template variables for a system prompt (e.g., ['{{maxTokens}}']). */
export function getSystemPromptRequiredVars(name: string): string[] | undefined {
const def = SYSTEM_PROMPTS.find((p) => p.name === name);
return def?.requiredVars;
}
/** Get the default content for a system prompt (for reset-on-delete). */
export function getSystemPromptDefault(name: string): string | undefined {
const def = SYSTEM_PROMPTS.find((p) => p.name === name);
return def?.content;
}

View File

@@ -64,6 +64,8 @@ import {
} from './routes/index.js';
import { registerPromptRoutes } from './routes/prompts.js';
import { PromptService } from './services/prompt.service.js';
import { ResourceRuleRegistry } from './validation/resource-rules.js';
import { systemPromptVarsRule } from './validation/rules/system-prompt-vars.js';
type PermissionCheck =
| { kind: 'resource'; resource: string; action: RbacAction; resourceName?: string }
@@ -290,7 +292,9 @@ async function main(): Promise<void> {
const groupService = new GroupService(groupRepo, userRepo);
const promptRepo = new PromptRepository(prisma);
const promptRequestRepo = new PromptRequestRepository(prisma);
const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo);
const promptRuleRegistry = new ResourceRuleRegistry();
promptRuleRegistry.register(systemPromptVarsRule);
const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry);
// Auth middleware for global hooks
const authMiddleware = createAuthMiddleware({

View File

@@ -1,5 +1,5 @@
import type { PrismaClient, AuditEvent, Prisma } from '@prisma/client';
import type { IAuditEventRepository, AuditEventFilter, AuditEventCreateInput } from './interfaces.js';
import type { IAuditEventRepository, AuditEventFilter, AuditEventCreateInput, AuditSessionSummary } from './interfaces.js';
export class AuditEventRepository implements IAuditEventRepository {
constructor(private readonly prisma: PrismaClient) {}
@@ -39,6 +39,59 @@ export class AuditEventRepository implements IAuditEventRepository {
const where = buildWhere(filter);
return this.prisma.auditEvent.count({ where });
}
async listSessions(filter?: { projectName?: string; limit?: number; offset?: number }): Promise<AuditSessionSummary[]> {
const where: Prisma.AuditEventWhereInput = {};
if (filter?.projectName !== undefined) where.projectName = filter.projectName;
const groups = await this.prisma.auditEvent.groupBy({
by: ['sessionId', 'projectName'],
where,
_min: { timestamp: true },
_max: { timestamp: true },
_count: true,
orderBy: { _max: { timestamp: 'desc' } },
take: filter?.limit ?? 50,
skip: filter?.offset ?? 0,
});
// Fetch distinct eventKinds per session
const sessionIds = groups.map((g) => g.sessionId);
const kindRows = sessionIds.length > 0
? await this.prisma.auditEvent.findMany({
where: { sessionId: { in: sessionIds } },
select: { sessionId: true, eventKind: true },
distinct: ['sessionId', 'eventKind'],
})
: [];
const kindMap = new Map<string, string[]>();
for (const row of kindRows) {
const list = kindMap.get(row.sessionId) ?? [];
list.push(row.eventKind);
kindMap.set(row.sessionId, list);
}
return groups.map((g) => ({
sessionId: g.sessionId,
projectName: g.projectName,
firstSeen: g._min.timestamp!,
lastSeen: g._max.timestamp!,
eventCount: g._count,
eventKinds: kindMap.get(g.sessionId) ?? [],
}));
}
async countSessions(filter?: { projectName?: string }): Promise<number> {
const where: Prisma.AuditEventWhereInput = {};
if (filter?.projectName !== undefined) where.projectName = filter.projectName;
const groups = await this.prisma.auditEvent.groupBy({
by: ['sessionId'],
where,
});
return groups.length;
}
}
function buildWhere(filter?: AuditEventFilter): Prisma.AuditEventWhereInput {

View File

@@ -75,9 +75,20 @@ export interface AuditEventCreateInput {
payload: Record<string, unknown>;
}
export interface AuditSessionSummary {
sessionId: string;
projectName: string;
firstSeen: Date;
lastSeen: Date;
eventCount: number;
eventKinds: string[];
}
export interface IAuditEventRepository {
findAll(filter?: AuditEventFilter): Promise<AuditEvent[]>;
findById(id: string): Promise<AuditEvent | null>;
createMany(events: AuditEventCreateInput[]): Promise<number>;
count(filter?: AuditEventFilter): Promise<number>;
listSessions(filter?: { projectName?: string; limit?: number; offset?: number }): Promise<AuditSessionSummary[]>;
countSessions(filter?: { projectName?: string }): Promise<number>;
}

View File

@@ -56,4 +56,14 @@ export function registerAuditEventRoutes(app: FastifyInstance, service: AuditEve
app.get<{ Params: { id: string } }>('/api/v1/audit/events/:id', async (request) => {
return service.getById(request.params.id);
});
// GET /api/v1/audit/sessions — list sessions with aggregates
app.get<{ Querystring: { projectName?: string; limit?: string; offset?: string } }>('/api/v1/audit/sessions', async (request) => {
const q = request.query;
const params: { projectName?: string; limit?: number; offset?: number } = {};
if (q.projectName !== undefined) params.projectName = q.projectName;
if (q.limit !== undefined) params.limit = parseInt(q.limit, 10);
if (q.offset !== undefined) params.offset = parseInt(q.offset, 10);
return service.listSessions(Object.keys(params).length > 0 ? params : undefined);
});
}

View File

@@ -94,9 +94,14 @@ export function registerPromptRoutes(
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);
app.delete<{ Params: { id: string } }>('/api/v1/prompts/:id', async (request, reply): Promise<unknown> => {
const result = await service.deletePrompt(request.params.id);
if (result) {
// System prompt was reset to default — return the reset prompt
return result;
}
reply.code(204);
return undefined;
});
// ── Prompt Requests (pending proposals) ──

View File

@@ -38,6 +38,22 @@ export class AuditEventService {
return this.repo.createMany(events);
}
async listSessions(params?: { projectName?: string; limit?: number; offset?: number }): Promise<{ sessions: Awaited<ReturnType<IAuditEventRepository['listSessions']>>; total: number }> {
const filter: { projectName?: string; limit?: number; offset?: number } = {};
if (params?.projectName !== undefined) filter.projectName = params.projectName;
if (params?.limit !== undefined) filter.limit = params.limit;
if (params?.offset !== undefined) filter.offset = params.offset;
const countFilter: { projectName?: string } = {};
if (params?.projectName !== undefined) countFilter.projectName = params.projectName;
const [sessions, total] = await Promise.all([
this.repo.listSessions(Object.keys(filter).length > 0 ? filter : undefined),
this.repo.countSessions(Object.keys(countFilter).length > 0 ? countFilter : undefined),
]);
return { sessions, total };
}
private buildFilter(params?: AuditEventQueryParams): AuditEventFilter | undefined {
if (!params) return undefined;
const filter: AuditEventFilter = {};

View File

@@ -5,7 +5,8 @@ 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 } from '../bootstrap/system-project.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;
@@ -14,12 +15,47 @@ export class PromptService {
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[]> {
@@ -44,6 +80,8 @@ export class PromptService {
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,
@@ -61,7 +99,12 @@ export class PromptService {
async updatePrompt(id: string, input: unknown): Promise<Prompt> {
const data = UpdatePromptSchema.parse(input);
await this.getPrompt(id);
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;
@@ -87,13 +130,17 @@ export class PromptService {
return this.promptRepo.update(id, { summary, chapters });
}
async deletePrompt(id: string): Promise<void> {
async deletePrompt(id: string): Promise<Prompt | void> {
const prompt = await this.getPrompt(id);
// Protect system prompts from deletion
// 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) {
throw Object.assign(new Error('Cannot delete system prompts'), { statusCode: 403 });
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);

View File

@@ -0,0 +1,71 @@
/**
* Extensible resource validation framework.
*
* Services register rules that validate resource data before create/update.
* Each rule targets a resource kind and optionally matches on specific
* instances (e.g., system prompts only). The registry aggregates errors
* from all matching rules.
*/
export interface ResourceRule<T = unknown> {
/** Rule identifier for error messages. */
name: string;
/** Resource kind this rule applies to: 'prompt', 'server', 'project', etc. */
resource: string;
/** Does this rule apply to the given resource instance? */
match(data: T, meta: RuleMeta): boolean;
/** Validate the resource data. */
validate(data: T, ctx: RuleContext): Promise<ValidationResult>;
}
export interface RuleMeta {
projectName?: string | undefined;
isSystemResource?: boolean | undefined;
operation: 'create' | 'update';
}
export interface RuleContext {
/** Cross-resource lookups (e.g., check if a secret exists). */
findResource(kind: string, name: string): Promise<unknown | null>;
}
export interface ValidationResult {
valid: boolean;
errors?: string[];
}
/**
* Registry holding all resource validation rules.
* Services call `validate()` which runs all matching rules and aggregates errors.
*/
export class ResourceRuleRegistry {
private rules: ResourceRule[] = [];
register(rule: ResourceRule): void {
this.rules.push(rule);
}
async validate<T>(
resource: string,
data: T,
meta: RuleMeta,
ctx: RuleContext,
): Promise<ValidationResult> {
const allErrors: string[] = [];
for (const rule of this.rules) {
if (rule.resource !== resource) continue;
if (!rule.match(data as never, meta)) continue;
const result = await rule.validate(data as never, ctx);
if (!result.valid && result.errors) {
allErrors.push(...result.errors);
}
}
if (allErrors.length > 0) {
return { valid: false, errors: allErrors };
}
return { valid: true };
}
}

View File

@@ -0,0 +1,41 @@
/**
* Validation rule: ensure system prompts preserve required template variables.
*
* When a prompt in the mcpctl-system project is updated, this rule checks
* that all required {{var}} placeholders are still present in the content.
*/
import type { ResourceRule, RuleMeta, RuleContext, ValidationResult } from '../resource-rules.js';
import { getSystemPromptRequiredVars } from '../../bootstrap/system-project.js';
interface PromptData {
name: string;
content: string;
}
export const systemPromptVarsRule: ResourceRule<PromptData> = {
name: 'system-prompt-template-vars',
resource: 'prompt',
match(_data: PromptData, meta: RuleMeta): boolean {
return meta.isSystemResource === true;
},
async validate(data: PromptData, _ctx: RuleContext): Promise<ValidationResult> {
const requiredVars = getSystemPromptRequiredVars(data.name);
if (!requiredVars || requiredVars.length === 0) {
return { valid: true };
}
const missing = requiredVars.filter((v) => !data.content.includes(v));
if (missing.length > 0) {
return {
valid: false,
errors: missing.map(
(v) => `System prompt '${data.name}' requires template variable ${v}`,
),
};
}
return { valid: true };
},
};

View File

@@ -12,6 +12,8 @@ function mockRepo(): IAuditEventRepository {
findById: vi.fn(async () => null),
createMany: vi.fn(async (events: unknown[]) => events.length),
count: vi.fn(async () => 0),
listSessions: vi.fn(async () => []),
countSessions: vi.fn(async () => 0),
};
}
@@ -175,4 +177,59 @@ describe('audit event routes', () => {
expect(res.statusCode).toBe(404);
});
});
describe('GET /api/v1/audit/sessions', () => {
it('returns session summaries', async () => {
vi.mocked(repo.listSessions).mockResolvedValue([
{
sessionId: 'sess-1',
projectName: 'ha-project',
firstSeen: new Date('2026-03-01T12:00:00Z'),
lastSeen: new Date('2026-03-01T12:05:00Z'),
eventCount: 5,
eventKinds: ['gate_decision', 'tool_call_trace'],
},
]);
vi.mocked(repo.countSessions).mockResolvedValue(1);
const res = await app.inject({
method: 'GET',
url: '/api/v1/audit/sessions',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
expect(body.sessions).toHaveLength(1);
expect(body.sessions[0].sessionId).toBe('sess-1');
expect(body.sessions[0].eventCount).toBe(5);
expect(body.total).toBe(1);
});
it('filters by projectName', async () => {
vi.mocked(repo.listSessions).mockResolvedValue([]);
vi.mocked(repo.countSessions).mockResolvedValue(0);
await app.inject({
method: 'GET',
url: '/api/v1/audit/sessions?projectName=ha-project',
});
const call = vi.mocked(repo.listSessions).mock.calls[0]![0] as { projectName?: string };
expect(call.projectName).toBe('ha-project');
});
it('supports pagination', async () => {
vi.mocked(repo.listSessions).mockResolvedValue([]);
vi.mocked(repo.countSessions).mockResolvedValue(10);
await app.inject({
method: 'GET',
url: '/api/v1/audit/sessions?limit=5&offset=5',
});
const call = vi.mocked(repo.listSessions).mock.calls[0]![0] as { limit?: number; offset?: number };
expect(call.limit).toBe(5);
expect(call.offset).toBe(5);
});
});
});

View File

@@ -89,13 +89,18 @@ describe('bootstrapSystemProject', () => {
expect(prisma.prompt.create).toHaveBeenCalledTimes(expectedNames.length);
});
it('creates system prompts with priority 10', async () => {
it('creates system prompts with expected priorities', async () => {
await bootstrapSystemProject(prisma);
const createCalls = vi.mocked(prisma.prompt.create).mock.calls;
for (const call of createCalls) {
const data = (call[0] as { data: { priority: number } }).data;
expect(data.priority).toBe(10);
const data = (call[0] as { data: { name: string; priority: number } }).data;
// Gate prompts have priority 10, LLM pipeline prompts have priority 5
if (data.name.startsWith('gate-') || data.name === 'session-greeting') {
expect(data.priority).toBe(10);
} else {
expect(data.priority).toBe(5);
}
}
});

View File

@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest';
import { ResourceRuleRegistry } from '../src/validation/resource-rules.js';
import type { ResourceRule, RuleMeta, RuleContext, ValidationResult } from '../src/validation/resource-rules.js';
const noopCtx: RuleContext = {
findResource: async () => null,
};
function makeRule(
name: string,
resource: string,
matchFn: (data: unknown, meta: RuleMeta) => boolean,
validateFn: () => Promise<ValidationResult>,
): ResourceRule {
return { name, resource, match: matchFn, validate: validateFn };
}
describe('ResourceRuleRegistry', () => {
it('returns valid when no rules are registered', async () => {
const registry = new ResourceRuleRegistry();
const result = await registry.validate('prompt', { name: 'x' }, { operation: 'create' }, noopCtx);
expect(result.valid).toBe(true);
});
it('returns valid when no rules match the resource type', async () => {
const registry = new ResourceRuleRegistry();
registry.register(makeRule('server-rule', 'server', () => true, async () => ({ valid: false, errors: ['fail'] })));
const result = await registry.validate('prompt', { name: 'x' }, { operation: 'create' }, noopCtx);
expect(result.valid).toBe(true);
});
it('skips rules that do not match the predicate', async () => {
const registry = new ResourceRuleRegistry();
registry.register(makeRule('skip-rule', 'prompt', () => false, async () => ({ valid: false, errors: ['fail'] })));
const result = await registry.validate('prompt', { name: 'x' }, { operation: 'create' }, noopCtx);
expect(result.valid).toBe(true);
});
it('runs matching rules and returns errors', async () => {
const registry = new ResourceRuleRegistry();
registry.register(makeRule('bad-rule', 'prompt', () => true, async () => ({
valid: false,
errors: ['missing template var'],
})));
const result = await registry.validate('prompt', { name: 'x' }, { operation: 'update' }, noopCtx);
expect(result.valid).toBe(false);
expect(result.errors).toEqual(['missing template var']);
});
it('aggregates errors from multiple matching rules', async () => {
const registry = new ResourceRuleRegistry();
registry.register(makeRule('rule-1', 'prompt', () => true, async () => ({
valid: false,
errors: ['error A'],
})));
registry.register(makeRule('rule-2', 'prompt', () => true, async () => ({
valid: false,
errors: ['error B', 'error C'],
})));
const result = await registry.validate('prompt', { name: 'x' }, { operation: 'create' }, noopCtx);
expect(result.valid).toBe(false);
expect(result.errors).toEqual(['error A', 'error B', 'error C']);
});
it('returns valid when all matching rules pass', async () => {
const registry = new ResourceRuleRegistry();
registry.register(makeRule('ok-rule', 'prompt', () => true, async () => ({ valid: true })));
const result = await registry.validate('prompt', { name: 'x' }, { operation: 'update' }, noopCtx);
expect(result.valid).toBe(true);
});
it('passes meta to match function', async () => {
const registry = new ResourceRuleRegistry();
let receivedMeta: RuleMeta | null = null;
registry.register(makeRule('meta-check', 'prompt', (_data, meta) => {
receivedMeta = meta;
return false;
}, async () => ({ valid: true })));
await registry.validate('prompt', { name: 'x' }, { operation: 'update', isSystemResource: true, projectName: 'sys' }, noopCtx);
expect(receivedMeta).toEqual({ operation: 'update', isSystemResource: true, projectName: 'sys' });
});
});

View File

@@ -195,11 +195,15 @@ describe('PromptService', () => {
await expect(service.deletePrompt('nope')).rejects.toThrow('Prompt not found');
});
it('should reject deletion of system prompts', async () => {
vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt({ projectId: 'sys-proj' }));
it('should reset system prompts to default on delete', async () => {
vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt({ name: 'gate-instructions', projectId: 'sys-proj' }));
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'sys-proj', name: 'mcpctl-system' }));
await expect(service.deletePrompt('prompt-1')).rejects.toThrow('Cannot delete system prompts');
const result = await service.deletePrompt('prompt-1');
// Should reset via update, not delete
expect(promptRepo.update).toHaveBeenCalledWith('prompt-1', expect.objectContaining({ content: expect.any(String) }));
expect(promptRepo.delete).not.toHaveBeenCalled();
expect(result).toBeDefined();
});
it('should allow deletion of non-system project prompts', async () => {

View File

@@ -0,0 +1,238 @@
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';
import { ResourceRuleRegistry } from '../src/validation/resource-rules.js';
import { systemPromptVarsRule } from '../src/validation/rules/system-prompt-vars.js';
import {
getSystemPromptNames,
getSystemPromptRequiredVars,
getSystemPromptDefault,
} from '../src/bootstrap/system-project.js';
function makePrompt(overrides: Partial<Prompt> = {}): Prompt {
return {
id: 'prompt-1',
name: 'test-prompt',
content: 'Hello world',
projectId: null,
priority: 5,
summary: null,
chapters: null,
linkTarget: null,
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function makeProject(overrides: Partial<Project> = {}): Project {
return {
id: 'proj-1',
name: 'test-project',
description: '',
prompt: '',
proxyMode: 'direct',
proxyModel: '',
gated: true,
llmProvider: null,
llmModel: null,
ownerId: 'user-1',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
} as Project;
}
function mockPromptRepo(): IPromptRepository {
return {
findAll: vi.fn(async () => []),
findGlobal: 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 () => []),
findGlobal: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByNameAndProject: vi.fn(async () => null),
findBySession: vi.fn(async () => []),
create: vi.fn(async (data) => ({ id: 'req-1', ...data }) as PromptRequest),
update: vi.fn(async (id, data) => ({ id, ...data }) as PromptRequest),
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('System Prompt Validation', () => {
let promptRepo: IPromptRepository;
let promptRequestRepo: IPromptRequestRepository;
let projectRepo: IProjectRepository;
let registry: ResourceRuleRegistry;
let service: PromptService;
const systemProject = makeProject({ id: 'sys-proj', name: 'mcpctl-system' });
beforeEach(() => {
promptRepo = mockPromptRepo();
promptRequestRepo = mockPromptRequestRepo();
projectRepo = mockProjectRepo();
registry = new ResourceRuleRegistry();
registry.register(systemPromptVarsRule);
service = new PromptService(promptRepo, promptRequestRepo, projectRepo, registry);
});
describe('getSystemPromptNames', () => {
it('includes all 11 system prompts (5 gate + 6 LLM)', () => {
const names = getSystemPromptNames();
expect(names).toContain('gate-instructions');
expect(names).toContain('gate-encouragement');
expect(names).toContain('gate-intercept-preamble');
expect(names).toContain('gate-session-active');
expect(names).toContain('session-greeting');
expect(names).toContain('llm-response-filter');
expect(names).toContain('llm-request-optimization');
expect(names).toContain('llm-pagination-index');
expect(names).toContain('llm-gate-context-selector');
expect(names).toContain('llm-summarize');
expect(names).toContain('llm-paginate-titles');
expect(names.length).toBe(11);
});
});
describe('getSystemPromptRequiredVars', () => {
it('returns {{maxTokens}} for llm-summarize', () => {
expect(getSystemPromptRequiredVars('llm-summarize')).toEqual(['{{maxTokens}}']);
});
it('returns {{pageCount}} for llm-paginate-titles', () => {
expect(getSystemPromptRequiredVars('llm-paginate-titles')).toEqual(['{{pageCount}}']);
});
it('returns undefined for prompts without required vars', () => {
expect(getSystemPromptRequiredVars('llm-response-filter')).toBeUndefined();
expect(getSystemPromptRequiredVars('gate-instructions')).toBeUndefined();
});
it('returns undefined for unknown prompts', () => {
expect(getSystemPromptRequiredVars('nonexistent')).toBeUndefined();
});
});
describe('getSystemPromptDefault', () => {
it('returns default content for each system prompt', () => {
for (const name of getSystemPromptNames()) {
const content = getSystemPromptDefault(name);
expect(content).toBeDefined();
expect(typeof content).toBe('string');
expect(content!.length).toBeGreaterThan(0);
}
});
it('returns undefined for unknown prompts', () => {
expect(getSystemPromptDefault('nonexistent')).toBeUndefined();
});
});
describe('updatePrompt validation', () => {
it('rejects edits missing required {{maxTokens}} for llm-summarize', async () => {
vi.mocked(promptRepo.findById).mockResolvedValue(
makePrompt({ name: 'llm-summarize', projectId: 'sys-proj', content: 'old content with {{maxTokens}}' }),
);
vi.mocked(projectRepo.findById).mockResolvedValue(systemProject);
await expect(
service.updatePrompt('prompt-1', { content: 'Summarize this. No template vars here.' }),
).rejects.toThrow("requires template variable {{maxTokens}}");
});
it('rejects edits missing required {{pageCount}} for llm-paginate-titles', async () => {
vi.mocked(promptRepo.findById).mockResolvedValue(
makePrompt({ name: 'llm-paginate-titles', projectId: 'sys-proj', content: 'old with {{pageCount}}' }),
);
vi.mocked(projectRepo.findById).mockResolvedValue(systemProject);
await expect(
service.updatePrompt('prompt-1', { content: 'Generate titles for pages.' }),
).rejects.toThrow("requires template variable {{pageCount}}");
});
it('allows edits for prompts without required vars', async () => {
vi.mocked(promptRepo.findById).mockResolvedValue(
makePrompt({ name: 'llm-response-filter', projectId: 'sys-proj', content: 'old' }),
);
vi.mocked(projectRepo.findById).mockResolvedValue(systemProject);
await expect(
service.updatePrompt('prompt-1', { content: 'You are a new data filtering assistant.' }),
).resolves.toBeDefined();
});
it('allows edits that preserve all required vars', async () => {
vi.mocked(promptRepo.findById).mockResolvedValue(
makePrompt({ name: 'llm-summarize', projectId: 'sys-proj', content: 'old' }),
);
vi.mocked(projectRepo.findById).mockResolvedValue(systemProject);
await expect(
service.updatePrompt('prompt-1', { content: 'Custom summarize in {{maxTokens}} tokens.' }),
).resolves.toBeDefined();
});
it('allows edits to non-system project prompts without validation', async () => {
vi.mocked(promptRepo.findById).mockResolvedValue(
makePrompt({ name: 'some-prompt', projectId: 'proj-1', content: 'old' }),
);
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'proj-1', name: 'my-project' }));
await expect(
service.updatePrompt('prompt-1', { content: 'anything goes' }),
).resolves.toBeDefined();
});
});
describe('deletePrompt resets system prompts', () => {
it('resets system prompt content to default instead of deleting', async () => {
vi.mocked(promptRepo.findById).mockResolvedValue(
makePrompt({ name: 'llm-response-filter', projectId: 'sys-proj', content: 'custom content' }),
);
vi.mocked(projectRepo.findById).mockResolvedValue(systemProject);
const result = await service.deletePrompt('prompt-1');
expect(result).toBeDefined();
expect(promptRepo.update).toHaveBeenCalledWith('prompt-1', {
content: getSystemPromptDefault('llm-response-filter'),
});
expect(promptRepo.delete).not.toHaveBeenCalled();
});
it('allows deletion of non-system project prompts', async () => {
vi.mocked(promptRepo.findById).mockResolvedValue(
makePrompt({ projectId: 'proj-1' }),
);
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'proj-1', name: 'my-project' }));
await service.deletePrompt('prompt-1');
expect(promptRepo.delete).toHaveBeenCalledWith('prompt-1');
});
});
});