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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) ──
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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);
|
||||
|
||||
71
src/mcpd/src/validation/resource-rules.ts
Normal file
71
src/mcpd/src/validation/resource-rules.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
41
src/mcpd/src/validation/rules/system-prompt-vars.ts
Normal file
41
src/mcpd/src/validation/rules/system-prompt-vars.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
88
src/mcpd/tests/resource-rules.test.ts
Normal file
88
src/mcpd/tests/resource-rules.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
238
src/mcpd/tests/system-prompt-validation.test.ts
Normal file
238
src/mcpd/tests/system-prompt-validation.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user