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:
@@ -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