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

@@ -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');
});
});
});