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

@@ -119,10 +119,10 @@ _mcpctl() {
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
;;
claude)
COMPREPLY=($(compgen -W "--project -o --output --inspect --stdout -h --help" -- "$cur"))
COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout -h --help" -- "$cur"))
;;
claude-generate)
COMPREPLY=($(compgen -W "--project -o --output --inspect --stdout -h --help" -- "$cur"))
COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout -h --help" -- "$cur"))
;;
setup)
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
@@ -138,11 +138,11 @@ _mcpctl() {
return ;;
get)
if [[ -z "$resource_type" ]]; then
COMPREPLY=($(compgen -W "$resources -o --output --project -A --all -h --help" -- "$cur"))
COMPREPLY=($(compgen -W "$resources -o --output -p --project -A --all -h --help" -- "$cur"))
else
local names
names=$(_mcpctl_resource_names "$resource_type")
COMPREPLY=($(compgen -W "$names -o --output --project -A --all -h --help" -- "$cur"))
COMPREPLY=($(compgen -W "$names -o --output -p --project -A --all -h --help" -- "$cur"))
fi
return ;;
describe)
@@ -156,11 +156,11 @@ _mcpctl() {
return ;;
delete)
if [[ -z "$resource_type" ]]; then
COMPREPLY=($(compgen -W "$resources --project -h --help" -- "$cur"))
COMPREPLY=($(compgen -W "$resources -p --project -h --help" -- "$cur"))
else
local names
names=$(_mcpctl_resource_names "$resource_type")
COMPREPLY=($(compgen -W "$names --project -h --help" -- "$cur"))
COMPREPLY=($(compgen -W "$names -p --project -h --help" -- "$cur"))
fi
return ;;
logs)
@@ -197,13 +197,13 @@ _mcpctl() {
COMPREPLY=($(compgen -W "--subject --binding --operation --force -h --help" -- "$cur"))
;;
prompt)
COMPREPLY=($(compgen -W "--project --content --content-file --priority --link -h --help" -- "$cur"))
COMPREPLY=($(compgen -W "-p --project --content --content-file --priority --link -h --help" -- "$cur"))
;;
serverattachment)
COMPREPLY=($(compgen -W "--project -h --help" -- "$cur"))
COMPREPLY=($(compgen -W "-p --project -h --help" -- "$cur"))
;;
promptrequest)
COMPREPLY=($(compgen -W "--project --content --content-file --priority -h --help" -- "$cur"))
COMPREPLY=($(compgen -W "-p --project --content --content-file --priority -h --help" -- "$cur"))
;;
*)
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
@@ -276,9 +276,9 @@ _mcpctl() {
if [[ $((cword - subcmd_pos)) -eq 1 ]]; then
local names
names=$(mcpctl get projects -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null)
COMPREPLY=($(compgen -W "$names --stdin-mcp -h --help" -- "$cur"))
COMPREPLY=($(compgen -W "$names --stdin-mcp --audit -h --help" -- "$cur"))
else
COMPREPLY=($(compgen -W "--stdin-mcp -h --help" -- "$cur"))
COMPREPLY=($(compgen -W "--stdin-mcp --audit -h --help" -- "$cur"))
fi
return ;;
help)

View File

@@ -265,13 +265,13 @@ complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_s
complete -c mcpctl -n "__mcpctl_subcmd_active config view" -s o -l output -d 'output format (json, yaml)' -x
# config claude options
complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l project -d 'Project name' -xa '(__mcpctl_project_names)'
complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)'
complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s o -l output -d 'Output file path' -x
complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l inspect -d 'Include mcpctl-inspect MCP server for traffic monitoring'
complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l stdout -d 'Print to stdout instead of writing a file'
# config claude-generate options
complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l project -d 'Project name' -xa '(__mcpctl_project_names)'
complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)'
complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s o -l output -d 'Output file path' -x
complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l inspect -d 'Include mcpctl-inspect MCP server for traffic monitoring'
complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout -d 'Print to stdout instead of writing a file'
@@ -338,17 +338,17 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create rbac" -l operation -d 'Oper
complete -c mcpctl -n "__mcpctl_subcmd_active create rbac" -l force -d 'Update if already exists'
# create prompt options
complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -l project -d 'Project name to scope the prompt to' -xa '(__mcpctl_project_names)'
complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -s p -l project -d 'Project name to scope the prompt to' -xa '(__mcpctl_project_names)'
complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -l content -d 'Prompt content text' -x
complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -l content-file -d 'Read prompt content from file' -rF
complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -l priority -d 'Priority 1-10 (default: 5, higher = more important)' -x
complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -l link -d 'Link to MCP resource (format: project/server:uri)' -x
# create serverattachment options
complete -c mcpctl -n "__mcpctl_subcmd_active create serverattachment" -l project -d 'Project name' -xa '(__mcpctl_project_names)'
complete -c mcpctl -n "__mcpctl_subcmd_active create serverattachment" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)'
# create promptrequest options
complete -c mcpctl -n "__mcpctl_subcmd_active create promptrequest" -l project -d 'Project name to scope the prompt request to' -xa '(__mcpctl_project_names)'
complete -c mcpctl -n "__mcpctl_subcmd_active create promptrequest" -s p -l project -d 'Project name to scope the prompt request to' -xa '(__mcpctl_project_names)'
complete -c mcpctl -n "__mcpctl_subcmd_active create promptrequest" -l content -d 'Prompt content text' -x
complete -c mcpctl -n "__mcpctl_subcmd_active create promptrequest" -l content-file -d 'Read prompt content from file' -rF
complete -c mcpctl -n "__mcpctl_subcmd_active create promptrequest" -l priority -d 'Priority 1-10 (default: 5, higher = more important)' -x
@@ -361,7 +361,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from login" -l mcpd-url -d 'mcpd U
# get options
complete -c mcpctl -n "__fish_seen_subcommand_from get" -s o -l output -d 'output format (table, json, yaml)' -x
complete -c mcpctl -n "__fish_seen_subcommand_from get" -l project -d 'Filter by project' -xa '(__mcpctl_project_names)'
complete -c mcpctl -n "__fish_seen_subcommand_from get" -s p -l project -d 'Filter by project' -xa '(__mcpctl_project_names)'
complete -c mcpctl -n "__fish_seen_subcommand_from get" -s A -l all -d 'Show all (including project-scoped) resources'
# describe options
@@ -369,7 +369,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from describe" -s o -l output -d '
complete -c mcpctl -n "__fish_seen_subcommand_from describe" -l show-values -d 'Show secret values (default: masked)'
# delete options
complete -c mcpctl -n "__fish_seen_subcommand_from delete" -l project -d 'Project name (for serverattachment)' -xa '(__mcpctl_project_names)'
complete -c mcpctl -n "__fish_seen_subcommand_from delete" -s p -l project -d 'Project name (for serverattachment)' -xa '(__mcpctl_project_names)'
# logs options
complete -c mcpctl -n "__fish_seen_subcommand_from logs" -s t -l tail -d 'Number of lines to show' -x
@@ -391,6 +391,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from restore" -s c -l conflict -d
# console options
complete -c mcpctl -n "__fish_seen_subcommand_from console" -l stdin-mcp -d 'Run inspector as MCP server over stdin/stdout (for Claude)'
complete -c mcpctl -n "__fish_seen_subcommand_from console" -l audit -d 'Browse audit events from mcpd'
# logs: takes a server/instance name
complete -c mcpctl -n "__fish_seen_subcommand_from logs; and __mcpctl_needs_arg_for logs" -a '(__mcpctl_instance_names)' -d 'Server name'

View File

@@ -90,7 +90,7 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
const cmd = config
.command(name)
.description(hidden ? '' : 'Generate .mcp.json that connects a project via mcpctl mcp bridge')
.option('--project <name>', 'Project name')
.option('-p, --project <name>', 'Project name')
.option('-o, --output <path>', 'Output file path', '.mcp.json')
.option('--inspect', 'Include mcpctl-inspect MCP server for traffic monitoring')
.option('--stdout', 'Print to stdout instead of writing a file')

View File

@@ -0,0 +1,458 @@
/**
* AuditConsoleApp — TUI for browsing audit events from mcpd.
*
* Shows sessions in a sidebar and events in a timeline.
* Polls mcpd periodically for new data.
*/
import { useState, useEffect, useCallback } from 'react';
import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
import type { AuditSession, AuditEvent, AuditConsoleState } from './audit-types.js';
import { EVENT_KIND_COLORS, EVENT_KIND_LABELS } from './audit-types.js';
import http from 'node:http';
const POLL_INTERVAL_MS = 3_000;
const MAX_EVENTS = 500;
// ── HTTP helpers ──
function fetchJson<T>(url: string, token?: string): Promise<T> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const headers: Record<string, string> = { 'Accept': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const req = http.get({ hostname: parsed.hostname, port: parsed.port, path: parsed.pathname + parsed.search, headers, timeout: 5000 }, (res) => {
let data = '';
res.on('data', (chunk: Buffer) => { data += chunk.toString(); });
res.on('end', () => {
try {
resolve(JSON.parse(data) as T);
} catch {
reject(new Error(`Invalid JSON from ${url}`));
}
});
});
req.on('error', (err) => reject(err));
req.on('timeout', () => { req.destroy(); reject(new Error('Request timed out')); });
});
}
// ── Format helpers ──
function formatTime(ts: string): string {
const d = new Date(ts);
return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function trunc(s: string, max: number): string {
return s.length > max ? s.slice(0, max - 1) + '\u2026' : s;
}
function formatPayload(payload: Record<string, unknown>): string {
const parts: string[] = [];
for (const [k, v] of Object.entries(payload)) {
if (v === null || v === undefined) continue;
if (typeof v === 'string') {
parts.push(`${k}=${trunc(v, 30)}`);
} else if (typeof v === 'number' || typeof v === 'boolean') {
parts.push(`${k}=${String(v)}`);
}
}
return parts.join(' ');
}
function formatDetailPayload(payload: Record<string, unknown>): string[] {
const lines: string[] = [];
for (const [k, v] of Object.entries(payload)) {
if (v === null || v === undefined) {
lines.push(` ${k}: null`);
} else if (typeof v === 'object') {
lines.push(` ${k}: ${JSON.stringify(v, null, 2).split('\n').join('\n ')}`);
} else {
lines.push(` ${k}: ${String(v)}`);
}
}
return lines;
}
// ── Session Sidebar ──
function AuditSidebar({ sessions, selectedIdx, projectFilter }: { sessions: AuditSession[]; selectedIdx: number; projectFilter: string | null }) {
return (
<Box flexDirection="column" width={30} borderStyle="single" borderColor="gray" paddingX={1}>
<Text bold>Sessions</Text>
<Text dimColor>{projectFilter ? `project: ${projectFilter}` : 'all projects'}</Text>
<Text> </Text>
<Text color={selectedIdx === -1 ? 'cyan' : undefined} bold={selectedIdx === -1}>
{selectedIdx === -1 ? '\u25B8 ' : ' '}All ({sessions.reduce((s, x) => s + x.eventCount, 0)} events)
</Text>
{sessions.map((s, i) => {
const isSel = i === selectedIdx;
return (
<Text key={s.sessionId} color={isSel ? 'cyan' : undefined} bold={isSel} wrap="truncate">
{isSel ? '\u25B8 ' : ' '}{trunc(s.sessionId.slice(0, 12), 12)} <Text dimColor>{s.projectName} ({s.eventCount})</Text>
</Text>
);
})}
{sessions.length === 0 && <Text dimColor> No sessions</Text>}
</Box>
);
}
// ── Event Timeline ──
function AuditTimeline({ events, height, focusedIdx }: { events: AuditEvent[]; height: number; focusedIdx: number }) {
const maxVisible = Math.max(1, height - 2);
let startIdx: number;
if (focusedIdx >= 0) {
startIdx = Math.max(0, Math.min(focusedIdx - Math.floor(maxVisible / 2), events.length - maxVisible));
} else {
startIdx = Math.max(0, events.length - maxVisible);
}
const visible = events.slice(startIdx, startIdx + maxVisible);
return (
<Box flexDirection="column" flexGrow={1} paddingLeft={1}>
<Text bold>
Events <Text dimColor>({events.length}{focusedIdx >= 0 ? ` \u00B7 #${focusedIdx + 1}` : ' \u00B7 following'})</Text>
</Text>
{visible.length === 0 && (
<Box marginTop={1}>
<Text dimColor>{' No audit events yet\u2026'}</Text>
</Box>
)}
{visible.map((event, vi) => {
const absIdx = startIdx + vi;
const isFocused = absIdx === focusedIdx;
const kindColor = EVENT_KIND_COLORS[event.eventKind] ?? 'white';
const kindLabel = EVENT_KIND_LABELS[event.eventKind] ?? event.eventKind.toUpperCase();
const verified = event.verified ? '\u2713' : '\u2717';
const verifiedColor = event.verified ? 'green' : 'red';
const summary = formatPayload(event.payload);
return (
<Text key={event.id} wrap="truncate">
<Text color={isFocused ? 'cyan' : undefined}>{isFocused ? '\u25B8' : ' '}</Text>
<Text dimColor>{formatTime(event.timestamp)} </Text>
<Text color={verifiedColor}>{verified}</Text>
<Text> </Text>
<Text color={kindColor} bold>{trunc(kindLabel, 9).padEnd(9)}</Text>
{event.serverName && <Text color="gray"> [{trunc(event.serverName, 14)}]</Text>}
<Text dimColor> {trunc(summary, 60)}</Text>
</Text>
);
})}
</Box>
);
}
// ── Detail View ──
function AuditDetail({ event, scrollOffset, height }: { event: AuditEvent; scrollOffset: number; height: number }) {
const kindColor = EVENT_KIND_COLORS[event.eventKind] ?? 'white';
const kindLabel = EVENT_KIND_LABELS[event.eventKind] ?? event.eventKind;
const lines = [
`Kind: ${kindLabel}`,
`Session: ${event.sessionId}`,
`Project: ${event.projectName}`,
`Source: ${event.source}`,
`Verified: ${event.verified ? 'yes' : 'no'}`,
`Server: ${event.serverName ?? '-'}`,
`Time: ${new Date(event.timestamp).toLocaleString()}`,
`ID: ${event.id}`,
'',
'Payload:',
...formatDetailPayload(event.payload),
];
const maxVisible = Math.max(1, height - 2);
const visible = lines.slice(scrollOffset, scrollOffset + maxVisible);
return (
<Box flexDirection="column" flexGrow={1} paddingLeft={1}>
<Text bold color={kindColor}>
{kindLabel} Detail <Text dimColor>(line {scrollOffset + 1}/{lines.length})</Text>
</Text>
{visible.map((line, i) => (
<Text key={i} wrap="truncate">{line}</Text>
))}
</Box>
);
}
// ── Main App ──
interface AuditAppProps {
mcpdUrl: string;
token?: string;
projectFilter?: string;
}
function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) {
const { exit } = useApp();
const { stdout } = useStdout();
const [state, setState] = useState<AuditConsoleState>({
phase: 'loading',
error: null,
sessions: [],
selectedSessionIdx: -1,
showSidebar: true,
events: [],
focusedEventIdx: -1,
totalEvents: 0,
detailEvent: null,
detailScrollOffset: 0,
projectFilter: projectFilter ?? null,
kindFilter: null,
});
// Fetch sessions
const fetchSessions = useCallback(async () => {
try {
const params = new URLSearchParams();
if (state.projectFilter) params.set('projectName', state.projectFilter);
params.set('limit', '50');
const url = `${mcpdUrl}/api/v1/audit/sessions?${params.toString()}`;
const data = await fetchJson<{ sessions: AuditSession[]; total: number }>(url, token);
setState((prev) => ({ ...prev, sessions: data.sessions, phase: 'ready' }));
} catch (err) {
setState((prev) => ({ ...prev, phase: 'error', error: err instanceof Error ? err.message : String(err) }));
}
}, [mcpdUrl, token, state.projectFilter]);
// Fetch events
const fetchEvents = useCallback(async () => {
try {
const params = new URLSearchParams();
const selectedSession = state.selectedSessionIdx >= 0 ? state.sessions[state.selectedSessionIdx] : undefined;
if (selectedSession) {
params.set('sessionId', selectedSession.sessionId);
} else if (state.projectFilter) {
params.set('projectName', state.projectFilter);
}
if (state.kindFilter) params.set('eventKind', state.kindFilter);
params.set('limit', String(MAX_EVENTS));
const url = `${mcpdUrl}/api/v1/audit/events?${params.toString()}`;
const data = await fetchJson<{ events: AuditEvent[]; total: number }>(url, token);
// API returns newest first — reverse for timeline display
setState((prev) => ({ ...prev, events: data.events.reverse(), totalEvents: data.total }));
} catch {
// Non-fatal — keep existing events
}
}, [mcpdUrl, token, state.selectedSessionIdx, state.sessions, state.projectFilter, state.kindFilter]);
// Initial load + polling
useEffect(() => {
void fetchSessions();
void fetchEvents();
const timer = setInterval(() => {
void fetchSessions();
void fetchEvents();
}, POLL_INTERVAL_MS);
return () => clearInterval(timer);
}, [fetchSessions, fetchEvents]);
// Keyboard input
useInput((input, key) => {
// Quit
if (input === 'q') {
exit();
return;
}
// Toggle sidebar
if (key.escape) {
setState((prev) => ({ ...prev, showSidebar: !prev.showSidebar }));
return;
}
// Detail view navigation
if (state.detailEvent) {
if (input === 'q' || key.escape) {
setState((prev) => ({ ...prev, detailEvent: null, detailScrollOffset: 0 }));
return;
}
if (key.downArrow) {
setState((prev) => ({ ...prev, detailScrollOffset: prev.detailScrollOffset + 1 }));
return;
}
if (key.upArrow) {
setState((prev) => ({ ...prev, detailScrollOffset: Math.max(0, prev.detailScrollOffset - 1) }));
return;
}
if (key.pageDown) {
const pageSize = Math.max(1, Math.floor(stdout.rows * 0.5));
setState((prev) => ({ ...prev, detailScrollOffset: prev.detailScrollOffset + pageSize }));
return;
}
if (key.pageUp) {
const pageSize = Math.max(1, Math.floor(stdout.rows * 0.5));
setState((prev) => ({ ...prev, detailScrollOffset: Math.max(0, prev.detailScrollOffset - pageSize) }));
return;
}
return;
}
// Sidebar mode: Tab toggles focus between sidebar and timeline
if (key.tab && state.showSidebar) {
// Tab cycles: session list focus ↔ timeline focus
// Use selectedSessionIdx >= -2 as "sidebar focused" indicator
// Actually, let's use a simpler approach: Shift+Tab or 's' focuses sidebar
return;
}
// Session navigation (when sidebar visible)
if (state.showSidebar && (input === 'S' || input === 's')) {
// s/S cycles through sessions
setState((prev) => {
const max = prev.sessions.length - 1;
const next = prev.selectedSessionIdx >= max ? -1 : prev.selectedSessionIdx + 1;
return { ...prev, selectedSessionIdx: next, focusedEventIdx: -1 };
});
return;
}
// Kind filter: k cycles through event kinds
if (input === 'k') {
const kinds = [null, 'tool_call_trace', 'gate_decision', 'pipeline_execution', 'stage_execution', 'prompt_delivery', 'session_bind'];
setState((prev) => {
const currentIdx = kinds.indexOf(prev.kindFilter);
const nextIdx = (currentIdx + 1) % kinds.length;
return { ...prev, kindFilter: kinds[nextIdx]!, focusedEventIdx: -1 };
});
return;
}
// Auto-scroll resume
if (input === 'a') {
setState((prev) => ({ ...prev, focusedEventIdx: -1 }));
return;
}
// Enter: detail view
if (key.return) {
setState((prev) => {
const idx = prev.focusedEventIdx === -1 ? prev.events.length - 1 : prev.focusedEventIdx;
const event = prev.events[idx];
if (!event) return prev;
return { ...prev, detailEvent: event, detailScrollOffset: 0 };
});
return;
}
// Timeline navigation
if (key.downArrow) {
setState((prev) => {
if (prev.focusedEventIdx === -1) return prev;
return { ...prev, focusedEventIdx: Math.min(prev.events.length - 1, prev.focusedEventIdx + 1) };
});
return;
}
if (key.upArrow) {
setState((prev) => {
if (prev.focusedEventIdx === -1) {
return prev.events.length > 0 ? { ...prev, focusedEventIdx: prev.events.length - 1 } : prev;
}
return { ...prev, focusedEventIdx: prev.focusedEventIdx <= 0 ? -1 : prev.focusedEventIdx - 1 };
});
return;
}
if (key.pageDown) {
const pageSize = Math.max(1, stdout.rows - 8);
setState((prev) => {
if (prev.focusedEventIdx === -1) return prev;
return { ...prev, focusedEventIdx: Math.min(prev.events.length - 1, prev.focusedEventIdx + pageSize) };
});
return;
}
if (key.pageUp) {
const pageSize = Math.max(1, stdout.rows - 8);
setState((prev) => {
const current = prev.focusedEventIdx === -1 ? prev.events.length - 1 : prev.focusedEventIdx;
return { ...prev, focusedEventIdx: Math.max(0, current - pageSize) };
});
return;
}
});
const height = stdout.rows - 3; // header + footer
if (state.phase === 'loading') {
return (
<Box flexDirection="column">
<Text bold color="cyan">Audit Console</Text>
<Text dimColor>Connecting to mcpd{'\u2026'}</Text>
</Box>
);
}
if (state.phase === 'error') {
return (
<Box flexDirection="column">
<Text bold color="red">Audit Console Error</Text>
<Text color="red">{state.error}</Text>
<Text dimColor>Check mcpd is running and accessible at {mcpdUrl}</Text>
</Box>
);
}
// Detail view
if (state.detailEvent) {
return (
<Box flexDirection="column" height={stdout.rows}>
<Box flexGrow={1}>
<AuditDetail event={state.detailEvent} scrollOffset={state.detailScrollOffset} height={height} />
</Box>
<Box borderStyle="single" borderColor="gray" paddingX={1}>
<Text dimColor>[{'\u2191\u2193'}] scroll [PgUp/Dn] page [Esc] back [q] quit</Text>
</Box>
</Box>
);
}
// Main view
return (
<Box flexDirection="column" height={stdout.rows}>
{/* Header */}
<Box paddingX={1}>
<Text bold color="cyan">Audit Console</Text>
<Text dimColor> {state.totalEvents} total events</Text>
{state.kindFilter && <Text color="yellow"> filter: {EVENT_KIND_LABELS[state.kindFilter] ?? state.kindFilter}</Text>}
</Box>
{/* Body */}
<Box flexGrow={1}>
{state.showSidebar && (
<AuditSidebar sessions={state.sessions} selectedIdx={state.selectedSessionIdx} projectFilter={state.projectFilter} />
)}
<AuditTimeline events={state.events} height={height} focusedIdx={state.focusedEventIdx} />
</Box>
{/* Footer */}
<Box borderStyle="single" borderColor="gray" paddingX={1}>
<Text dimColor>
{state.focusedEventIdx === -1
? `[\u2191] nav [PgUp] page [s] session [k] kind [Enter] detail [Esc] sidebar [q] quit`
: `[\u2191\u2193] nav [PgUp/Dn] page [a] follow [s] session [k] kind [Enter] detail [Esc] sidebar [q] quit`}
</Text>
</Box>
</Box>
);
}
// ── Render entry point ──
export interface AuditRenderOptions {
mcpdUrl: string;
token?: string;
projectFilter?: string;
}
export async function renderAuditConsole(opts: AuditRenderOptions): Promise<void> {
const instance = render(
<AuditApp mcpdUrl={opts.mcpdUrl} token={opts.token} projectFilter={opts.projectFilter} />,
);
await instance.waitUntilExit();
}

View File

@@ -0,0 +1,69 @@
/**
* Types for the audit console — views audit events from mcpd.
*/
export interface AuditSession {
sessionId: string;
projectName: string;
firstSeen: string;
lastSeen: string;
eventCount: number;
eventKinds: string[];
}
export interface AuditEvent {
id: string;
timestamp: string;
sessionId: string;
projectName: string;
eventKind: string;
source: string;
verified: boolean;
serverName: string | null;
correlationId: string | null;
parentEventId: string | null;
payload: Record<string, unknown>;
}
export interface AuditConsoleState {
phase: 'loading' | 'ready' | 'error';
error: string | null;
// Sessions
sessions: AuditSession[];
selectedSessionIdx: number; // -1 = all sessions, 0+ = specific session
showSidebar: boolean;
// Events
events: AuditEvent[];
focusedEventIdx: number; // -1 = auto-scroll
totalEvents: number;
// Detail view
detailEvent: AuditEvent | null;
detailScrollOffset: number;
// Filters
projectFilter: string | null;
kindFilter: string | null;
}
export const EVENT_KIND_COLORS: Record<string, string> = {
'pipeline_execution': 'blue',
'stage_execution': 'cyan',
'gate_decision': 'yellow',
'prompt_delivery': 'magenta',
'tool_call_trace': 'green',
'rbac_decision': 'red',
'session_bind': 'gray',
};
export const EVENT_KIND_LABELS: Record<string, string> = {
'pipeline_execution': 'PIPELINE',
'stage_execution': 'STAGE',
'gate_decision': 'GATE',
'prompt_delivery': 'PROMPT',
'tool_call_trace': 'TOOL',
'rbac_decision': 'RBAC',
'session_bind': 'BIND',
};

View File

@@ -37,7 +37,7 @@ export function Timeline({ events, height, focusedIdx, showProject }: TimelinePr
return (
<Box flexDirection="column" flexGrow={1} paddingLeft={1}>
<Text bold>
Timeline <Text dimColor>({events.length} events{focusedIdx >= 0 ? ` \u00B7 #${focusedIdx + 1}` : ''})</Text>
Timeline <Text dimColor>({events.length} events{focusedIdx >= 0 ? ` \u00B7 #${focusedIdx + 1}` : ' \u00B7 following'})</Text>
</Text>
{visible.length === 0 && (
<Box marginTop={1}>

View File

@@ -11,7 +11,8 @@ export function createConsoleCommand(deps: ConsoleCommandDeps): Command {
.description('Interactive MCP console — unified timeline with tools, provenance, and lab replay')
.argument('[project]', 'Project name to connect to')
.option('--stdin-mcp', 'Run inspector as MCP server over stdin/stdout (for Claude)')
.action(async (projectName: string | undefined, opts: { stdinMcp?: boolean }) => {
.option('--audit', 'Browse audit events from mcpd')
.action(async (projectName: string | undefined, opts: { stdinMcp?: boolean; audit?: boolean }) => {
let mcplocalUrl = 'http://localhost:3200';
if (deps.configLoader) {
mcplocalUrl = deps.configLoader().mcplocalUrl;
@@ -43,6 +44,20 @@ export function createConsoleCommand(deps: ConsoleCommandDeps): Command {
}
}
// --audit: browse audit events from mcpd
if (opts.audit) {
let mcpdUrl = 'http://localhost:3100';
try {
const { loadConfig } = await import('../../config/index.js');
mcpdUrl = loadConfig().mcpdUrl;
} catch {
// Use default
}
const { renderAuditConsole } = await import('./audit-app.js');
await renderAuditConsole({ mcpdUrl, token, projectFilter: projectName });
return;
}
// Build endpoint URL only if project specified
let endpointUrl: string | undefined;
if (projectName) {

View File

@@ -728,13 +728,13 @@ function UnifiedApp({ projectName, endpointUrl, mcplocalUrl, token }: UnifiedApp
return;
}
if (key.pageDown) {
const nextIdx = Math.min(filteredEvents.length - 1, s.action.eventIdx + 1);
setState((prev) => ({ ...prev, focusedEventIdx: nextIdx, action: { type: 'detail', eventIdx: nextIdx, scrollOffset: 0, horizontalOffset: 0, searchMode: false, searchQuery: '', searchMatches: [], searchMatchIdx: -1 } }));
const pageSize = Math.max(1, Math.floor(stdout.rows * 0.35));
setState((prev) => ({ ...prev, action: { ...prev.action, scrollOffset: (prev.action as { scrollOffset: number }).scrollOffset + pageSize } as ActionState }));
return;
}
if (key.pageUp) {
const prevIdx = Math.max(0, s.action.eventIdx - 1);
setState((prev) => ({ ...prev, focusedEventIdx: prevIdx, action: { type: 'detail', eventIdx: prevIdx, scrollOffset: 0, horizontalOffset: 0, searchMode: false, searchQuery: '', searchMatches: [], searchMatchIdx: -1 } }));
const pageSize = Math.max(1, Math.floor(stdout.rows * 0.35));
setState((prev) => ({ ...prev, action: { ...prev.action, scrollOffset: Math.max(0, (prev.action as { scrollOffset: number }).scrollOffset - pageSize) } as ActionState }));
return;
}
if (input === 'p') {
@@ -1451,19 +1451,47 @@ function UnifiedApp({ projectName, endpointUrl, mcplocalUrl, token }: UnifiedApp
return;
}
// "a" resumes auto-scroll (follow newest events)
if (input === 'a') {
setState((prev) => ({ ...prev, focusedEventIdx: -1 }));
return;
}
// Arrows control event navigation when sidebar is hidden
if (key.downArrow) {
setState((prev) => ({
...prev,
focusedEventIdx: Math.min(filteredEvents.length - 1, prev.focusedEventIdx + 1),
}));
setState((prev) => {
if (prev.focusedEventIdx === -1) return prev; // already at bottom (auto-scroll)
return { ...prev, focusedEventIdx: Math.min(filteredEvents.length - 1, prev.focusedEventIdx + 1) };
});
return;
}
if (key.upArrow) {
setState((prev) => ({
...prev,
focusedEventIdx: prev.focusedEventIdx <= 0 ? -1 : prev.focusedEventIdx - 1,
}));
setState((prev) => {
if (prev.focusedEventIdx === -1) {
// Leave auto-scroll, focus last event
return filteredEvents.length > 0 ? { ...prev, focusedEventIdx: filteredEvents.length - 1 } : prev;
}
return { ...prev, focusedEventIdx: prev.focusedEventIdx <= 0 ? -1 : prev.focusedEventIdx - 1 };
});
return;
}
// PageDown/PageUp: jump by a page (vim-style)
if (key.pageDown) {
const pageSize = Math.max(1, stdout.rows - 8);
setState((prev) => {
if (prev.focusedEventIdx === -1) return prev; // already at bottom
const next = Math.min(filteredEvents.length - 1, prev.focusedEventIdx + pageSize);
return { ...prev, focusedEventIdx: next };
});
return;
}
if (key.pageUp) {
const pageSize = Math.max(1, stdout.rows - 8);
setState((prev) => {
const current = prev.focusedEventIdx === -1 ? filteredEvents.length - 1 : prev.focusedEventIdx;
const next = Math.max(0, current - pageSize);
return { ...prev, focusedEventIdx: next };
});
return;
}
if (input === 'G') {
@@ -1687,8 +1715,12 @@ function UnifiedApp({ projectName, endpointUrl, mcplocalUrl, token }: UnifiedApp
<Text dimColor>
{state.action.type === 'none'
? hasInteractiveSession
? `[\u2191\u2193] nav [Enter] detail [p] prov [Tab] toolbar [Esc] sidebar [q] quit`
: `[\u2191\u2193] nav [Enter] detail [p] prov [Esc] sidebar [q] quit`
? state.focusedEventIdx === -1
? `[\u2191] nav [PgUp] page [Enter] detail [p] prov [Tab] toolbar [Esc] sidebar [q] quit`
: `[\u2191\u2193] nav [PgUp/Dn] page [a] follow [Enter] detail [p] prov [Tab] toolbar [Esc] sidebar [q] quit`
: state.focusedEventIdx === -1
? `[\u2191] nav [PgUp] page [Enter] detail [p] prov [Esc] sidebar [q] quit`
: `[\u2191\u2193] nav [PgUp/Dn] page [a] follow [Enter] detail [p] prov [Esc] sidebar [q] quit`
: '[Esc] back'}
</Text>
</Box>

View File

@@ -384,7 +384,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
cmd.command('prompt')
.description('Create an approved prompt')
.argument('<name>', 'Prompt name (lowercase alphanumeric with hyphens)')
.option('--project <name>', 'Project name to scope the prompt to')
.option('-p, --project <name>', 'Project name to scope the prompt to')
.option('--content <text>', 'Prompt content text')
.option('--content-file <path>', 'Read prompt content from file')
.option('--priority <number>', 'Priority 1-10 (default: 5, higher = more important)')
@@ -431,7 +431,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.alias('sa')
.description('Attach a server to a project')
.argument('<server>', 'Server name')
.option('--project <name>', 'Project name')
.option('-p, --project <name>', 'Project name')
.action(async (serverName: string, opts) => {
const projectName = opts.project as string | undefined;
if (!projectName) {
@@ -446,7 +446,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
cmd.command('promptrequest')
.description('Create a prompt request (pending proposal that needs approval)')
.argument('<name>', 'Prompt request name (lowercase alphanumeric with hyphens)')
.option('--project <name>', 'Project name to scope the prompt request to')
.option('-p, --project <name>', 'Project name to scope the prompt request to')
.option('--content <text>', 'Prompt content text')
.option('--content-file <path>', 'Read prompt content from file')
.option('--priority <number>', 'Priority 1-10 (default: 5, higher = more important)')

View File

@@ -14,7 +14,7 @@ export function createDeleteCommand(deps: DeleteCommandDeps): Command {
.description('Delete a resource (server, instance, secret, project, user, group, rbac)')
.argument('<resource>', 'resource type')
.argument('<id>', 'resource ID or name')
.option('--project <name>', 'Project name (for serverattachment)')
.option('-p, --project <name>', 'Project name (for serverattachment)')
.action(async (resourceArg: string, idOrName: string, opts: { project?: string }) => {
const resource = resolveResource(resourceArg);

View File

@@ -276,7 +276,7 @@ export function createGetCommand(deps: GetCommandDeps): Command {
.argument('<resource>', 'resource type (servers, projects, instances, all)')
.argument('[id]', 'specific resource ID or name')
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
.option('--project <name>', 'Filter by project')
.option('-p, --project <name>', 'Filter by project')
.option('-A, --all', 'Show all (including project-scoped) resources')
.action(async (resourceArg: string, id: string | undefined, opts: { output: string; project?: string; all?: true }) => {
const resource = resolveResource(resourceArg);

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

View File

@@ -15,7 +15,9 @@ export type AuditEventKind =
| 'stage_execution' // Individual stage detail
| 'gate_decision' // Gate open/close with intent
| 'prompt_delivery' // Which prompts were sent to client
| 'tool_call_trace'; // Tool call with server + timing
| 'tool_call_trace' // Tool call with server + timing
| 'rbac_decision' // RBAC allow/deny with subject + binding
| 'session_bind'; // Client session bound to project
export type AuditSource = 'client' | 'mcplocal' | 'mcpd';

View File

@@ -6,6 +6,7 @@
*/
import type { ProviderRegistry } from '../providers/registry.js';
import type { SystemPromptFetcher } from '../proxymodel/types.js';
export interface PromptIndexForLlm {
name: string;
@@ -28,8 +29,12 @@ export class LlmPromptSelector {
async selectPrompts(
tags: string[],
promptIndex: PromptIndexForLlm[],
getSystemPromptFn?: SystemPromptFetcher,
): Promise<LlmSelectionResult> {
const systemPrompt = `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.`;
const DEFAULT_SYSTEM_PROMPT = `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.`;
const systemPrompt = getSystemPromptFn
? await getSystemPromptFn('llm-gate-context-selector', DEFAULT_SYSTEM_PROMPT)
: DEFAULT_SYSTEM_PROMPT;
const userPrompt = `Task keywords: ${tags.join(', ')}

View File

@@ -61,13 +61,16 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
const llmDisabled = mcpdConfig.llmProvider === 'none' || localOverride?.provider === 'none';
const effectiveRegistry = llmDisabled ? null : (providerRegistry ?? null);
// Wire pagination support with LLM provider and project model override
router.setPaginator(new ResponsePaginator(effectiveRegistry, {}, resolvedModel));
// Configure prompt resources with SA-scoped client for RBAC
const saClient = mcpdClient.withHeaders({ 'X-Service-Account': `project:${projectName}` });
router.setPromptConfig(saClient, projectName);
// System prompt fetcher for LLM consumers (uses router's cached fetcher)
const getSystemPrompt = router.getSystemPromptFn();
// Wire pagination support with LLM provider and project model override
router.setPaginator(new ResponsePaginator(effectiveRegistry, {}, resolvedModel, getSystemPrompt));
// Wire proxymodel pipeline (model resolved lazily from disk for hot-reload)
const proxyModelName = mcpdConfig.proxyModel ?? 'default';
const llmAdapter = effectiveRegistry ? new LLMProviderAdapter(effectiveRegistry) : {
@@ -167,6 +170,15 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
eventType: 'session_created',
body: null,
});
// Audit: session_bind
router.getAuditCollector()?.emit({
timestamp: new Date().toISOString(),
sessionId: id,
eventKind: 'session_bind',
source: 'mcplocal',
verified: true,
payload: { projectName },
});
},
});

View File

@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto';
import type { ProviderRegistry } from '../providers/registry.js';
import { estimateTokens } from './token-counter.js';
import type { SystemPromptFetcher } from '../proxymodel/types.js';
// --- Configuration ---
@@ -106,6 +107,7 @@ export class ResponsePaginator {
private providers: ProviderRegistry | null,
config: Partial<PaginationConfig> = {},
private modelOverride?: string,
private readonly getSystemPrompt?: SystemPromptFetcher,
) {
this.config = { ...DEFAULT_PAGINATION_CONFIG, ...config };
}
@@ -254,9 +256,13 @@ export class ResponsePaginator {
return `--- Page ${String(i + 1)} (chars ${String(p.startChar)}-${String(p.endChar)}, ~${String(p.estimatedTokens)} tokens) ---\n${preview}${truncated}`;
}).join('\n\n');
const systemPrompt = this.getSystemPrompt
? await this.getSystemPrompt('llm-pagination-index', PAGINATION_INDEX_SYSTEM_PROMPT)
: PAGINATION_INDEX_SYSTEM_PROMPT;
const result = await provider.complete({
messages: [
{ role: 'system', content: PAGINATION_INDEX_SYSTEM_PROMPT },
{ role: 'system', content: systemPrompt },
{ role: 'user', content: `Tool: ${toolName}\nTotal size: ${String(raw.length)} chars, ${String(pages.length)} pages\n\n${previews}` },
],
maxTokens: this.config.indexMaxTokens,

View File

@@ -5,6 +5,7 @@ import { estimateTokens } from './token-counter.js';
import { FilterCache } from './filter-cache.js';
import type { FilterCacheConfig } from './filter-cache.js';
import { FilterMetrics } from './metrics.js';
import type { SystemPromptFetcher } from '../proxymodel/types.js';
export interface LlmProcessorConfig {
/** Enable request preprocessing */
@@ -58,6 +59,7 @@ export class LlmProcessor {
constructor(
private providers: ProviderRegistry,
private config: LlmProcessorConfig = DEFAULT_PROCESSOR_CONFIG,
private readonly getSystemPrompt?: SystemPromptFetcher,
) {
this.filterCache = new FilterCache(config.filterCache);
this.metrics = new FilterMetrics();
@@ -112,9 +114,13 @@ export class LlmProcessor {
}
try {
const systemPrompt = this.getSystemPrompt
? await this.getSystemPrompt('llm-request-optimization', REQUEST_OPTIMIZATION_SYSTEM_PROMPT)
: REQUEST_OPTIMIZATION_SYSTEM_PROMPT;
const result = await provider.complete({
messages: [
{ role: 'system', content: REQUEST_OPTIMIZATION_SYSTEM_PROMPT },
{ role: 'system', content: systemPrompt },
{ role: 'user', content: `Tool: ${toolName}\nParameters: ${JSON.stringify(params)}` },
],
maxTokens: this.config.maxTokens,
@@ -176,9 +182,13 @@ export class LlmProcessor {
const startTime = performance.now();
try {
const systemPrompt = this.getSystemPrompt
? await this.getSystemPrompt('llm-response-filter', RESPONSE_FILTER_SYSTEM_PROMPT)
: RESPONSE_FILTER_SYSTEM_PROMPT;
const result = await provider.complete({
messages: [
{ role: 'system', content: RESPONSE_FILTER_SYSTEM_PROMPT },
{ role: 'system', content: systemPrompt },
{ role: 'user', content: `Tool: ${toolName}\nResponse (${raw.length} chars):\n${raw}` },
],
maxTokens: this.config.maxTokens,

View File

@@ -3,7 +3,7 @@
* Runs content through a sequence of stages defined by a ProxyModel.
* Each stage receives the output of the previous stage as input.
*/
import type { StageContext, StageResult, StageLogger, Section, ContentType, LLMProvider, CacheProvider } from './types.js';
import type { StageContext, StageResult, StageLogger, Section, ContentType, LLMProvider, CacheProvider, SystemPromptFetcher } from './types.js';
import type { ProxyModelDefinition } from './schema.js';
import { getStage } from './stage-registry.js';
import type { AuditCollector } from '../audit/collector.js';
@@ -27,6 +27,8 @@ export interface ExecuteOptions {
cache: CacheProvider;
/** Optional logger override (defaults to console). */
log?: StageLogger;
/** Optional system prompt fetcher for stages that use editable prompts. */
getSystemPrompt?: SystemPromptFetcher;
/** Optional audit collector for pipeline/stage event emission. */
auditCollector?: AuditCollector;
/** Server name for per-server audit tagging. */
@@ -72,6 +74,8 @@ export async function executePipeline(opts: ExecuteOptions): Promise<StageResult
continue;
}
const defaultFetcher: SystemPromptFetcher = async (_name, fallback) => fallback;
const ctx: StageContext = {
contentType: opts.contentType,
sourceName: opts.sourceName,
@@ -81,6 +85,7 @@ export async function executePipeline(opts: ExecuteOptions): Promise<StageResult
llm,
cache,
log: consoleLogger(stageSpec.type),
getSystemPrompt: opts.getSystemPrompt ?? defaultFetcher,
config: stageSpec.config ?? {},
};

View File

@@ -244,7 +244,8 @@ async function handleBeginSession(
summary: p.summary,
chapters: p.chapters,
}));
const llmResult = await llmSelector.selectPrompts(tags, llmIndex);
const getSystemPromptFn = ctx.getSystemPrompt.bind(ctx);
const llmResult = await llmSelector.selectPrompts(tags, llmIndex, getSystemPromptFn);
reasoning = llmResult.reasoning;
const selectedSet = new Set(llmResult.selectedNames);

View File

@@ -63,10 +63,12 @@ async function generatePageTitles(pages: string[], ctx: StageContext): Promise<s
return `--- Page ${i + 1} (${page.length} chars) ---\n${preview}`;
}).join('\n\n');
const DEFAULT_PROMPT = `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.`;
const template = await ctx.getSystemPrompt('llm-paginate-titles', DEFAULT_PROMPT);
const prompt = template.replaceAll('{{pageCount}}', String(pages.length));
const result = await ctx.llm.complete(
`Generate exactly ${pages.length} short descriptive titles (max 60 chars each) for the following ${pages.length} pages. ` +
`Return ONLY a JSON array of ${pages.length} strings. No markdown, no explanation.\n\n` +
`${previews}`,
`${prompt}\n\n${previews}`,
{ maxTokens: pages.length * 30 },
);

View File

@@ -128,10 +128,12 @@ async function cachedSummarize(
): Promise<string> {
const key = `summary:${ctx.cache.hash(content)}:${maxTokens}`;
return ctx.cache.getOrCompute(key, async () => {
const DEFAULT_PROMPT = `Summarize the following in about {{maxTokens}} tokens. Preserve all items marked MUST, REQUIRED, or CRITICAL verbatim. Be specific — mention names, IDs, counts, key values.`;
const template = await ctx.getSystemPrompt('llm-summarize', DEFAULT_PROMPT);
const prompt = template.replaceAll('{{maxTokens}}', String(maxTokens));
return ctx.llm.complete(
`Summarize the following in about ${maxTokens} tokens. ` +
`Preserve all items marked MUST, REQUIRED, or CRITICAL verbatim. ` +
`Be specific — mention names, IDs, counts, key values.\n\n${content}`,
`${prompt}\n\n${content}`,
{ maxTokens },
);
});

View File

@@ -10,6 +10,9 @@
* SessionController — method-level hooks with per-session state
*/
/** Fetches a system prompt by name, falling back to the provided default. */
export type SystemPromptFetcher = (name: string, fallback: string) => Promise<string>;
// ── Content Stage Contract ──────────────────────────────────────────
/**
@@ -40,6 +43,9 @@ export interface StageContext {
cache: CacheProvider;
log: StageLogger;
/** Fetch a system prompt from mcpctl-system, with hardcoded fallback. */
getSystemPrompt: SystemPromptFetcher;
/** Stage-specific configuration from the proxymodel YAML */
config: Record<string, unknown>;
}

View File

@@ -91,6 +91,10 @@ export class McpRouter {
this.auditCollector = collector;
}
getAuditCollector(): AuditCollector | null {
return this.auditCollector;
}
setLlmProcessor(processor: LlmProcessor): void {
this.llmProcessor = processor;
}
@@ -105,6 +109,11 @@ export class McpRouter {
this.linkResolver = new LinkResolver(mcpdClient);
}
/** Return a bound system prompt fetcher for external consumers (e.g. ResponsePaginator). */
getSystemPromptFn(): (name: string, fallback: string) => Promise<string> {
return (name, fallback) => this.getSystemPrompt(name, fallback);
}
/** Set the plugin for this router. When set, plugin hooks are dispatched. */
setPlugin(plugin: ProxyModelPlugin): void {
this.plugin = plugin;
@@ -149,6 +158,7 @@ export class McpRouter {
proxyModel: proxyModelDef,
llm: effectiveLlm,
cache: effectiveCache,
getSystemPrompt: (name, fallback) => this.getSystemPrompt(name, fallback),
...(this.auditCollector ? { auditCollector: this.auditCollector } : {}),
...(serverName !== undefined ? { serverName } : {}),
});
@@ -683,6 +693,30 @@ export class McpRouter {
const params = request.params as Record<string, unknown> | undefined;
const toolName = params?.['name'] as string | undefined;
const toolArgs = (params?.['arguments'] ?? {}) as Record<string, unknown>;
const startMs = Date.now();
const emitTrace = (response: JsonRpcResponse, serverName?: string): void => {
if (this.auditCollector && context?.sessionId && toolName) {
const durationMs = Date.now() - startMs;
const resultSize = JSON.stringify(response.result ?? response.error ?? {}).length;
const resolved = serverName ?? this.toolToServer.get(toolName);
const base = {
timestamp: new Date().toISOString(),
sessionId: context.sessionId,
eventKind: 'tool_call_trace' as const,
source: 'mcplocal' as const,
verified: true,
payload: {
toolName,
argKeys: Object.keys(toolArgs).join(', '),
resultSizeBytes: resultSize,
durationMs,
error: response.error?.message ?? null,
},
};
this.auditCollector.emit(resolved ? { ...base, serverName: resolved } : base);
}
};
// Plugin path
if (this.plugin && context?.sessionId) {
@@ -693,10 +727,14 @@ export class McpRouter {
if (virtualTool) {
try {
const result = await virtualTool.handler(toolArgs, ctx);
return { jsonrpc: '2.0', id: request.id, result: result as JsonRpcResponse['result'] };
const resp = { jsonrpc: '2.0' as const, id: request.id, result: result as JsonRpcResponse['result'] };
emitTrace(resp, 'virtual');
return resp;
} catch (err) {
const code = (err as { code?: number }).code ?? -32603;
return { jsonrpc: '2.0', id: request.id, error: { code, message: err instanceof Error ? err.message : String(err) } };
const resp = { jsonrpc: '2.0' as const, id: request.id, error: { code, message: err instanceof Error ? err.message : String(err) } };
emitTrace(resp, 'virtual');
return resp;
}
}
@@ -714,6 +752,7 @@ export class McpRouter {
response = await this.plugin.onToolCallAfter(toolName, toolArgs, response, ctx);
}
emitTrace(response);
return response;
}
@@ -743,7 +782,9 @@ export class McpRouter {
// If no LLM processor or tool shouldn't be processed, route directly
if (!this.llmProcessor || !toolName || !this.llmProcessor.shouldProcess('tools/call', toolName)) {
const response = await this.routeNamespacedCall(request, 'name', this.toolToServer);
return this.maybePaginate(toolName, response);
const paginated = await this.maybePaginate(toolName, response);
emitTrace(paginated);
return paginated;
}
// Preprocess request params
@@ -757,14 +798,23 @@ export class McpRouter {
// Try pagination
const paginated = await this.maybePaginate(toolName, response);
if (paginated !== response) return paginated;
if (paginated !== response) {
emitTrace(paginated);
return paginated;
}
// Filter response
if (response.error) return response;
if (response.error) {
emitTrace(response);
return response;
}
const filtered = await this.llmProcessor.filterResponse(toolName, response);
if (filtered.filtered) {
return { ...response, result: filtered.result };
const filteredResp = { ...response, result: filtered.result };
emitTrace(filteredResp);
return filteredResp;
}
emitTrace(response);
return response;
}

View File

@@ -47,6 +47,7 @@ function mockCtx(original: string, config: Record<string, unknown> = {}, llmAvai
llm: mockLlm,
cache: mockCache,
log: mockLog,
getSystemPrompt: async (_name: string, fallback: string) => fallback,
config,
};
}

View File

@@ -136,6 +136,7 @@ function createMockContext(original: string): StageContext {
llm: mockLlm,
cache: mockCache,
log: mockLog,
getSystemPrompt: async (_name: string, fallback: string) => fallback,
config: {},
};
}

View File

@@ -580,4 +580,93 @@ describe('McpRouter', () => {
expect(config!).toHaveProperty('llm', haLlm);
});
});
describe('tool_call_trace audit emission', () => {
it('emits tool_call_trace on successful tool call', async () => {
const alpha = mockUpstream('alpha', { tools: [{ name: 'do_thing' }] });
router.addUpstream(alpha);
await router.discoverTools();
const emitted: Array<Record<string, unknown>> = [];
const mockCollector = { emit: vi.fn((e: Record<string, unknown>) => emitted.push(e)) };
router.setAuditCollector(mockCollector as never);
await router.route(
{ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'alpha/do_thing', arguments: { key: 'val' } } },
{ sessionId: 'sess-1' },
);
expect(mockCollector.emit).toHaveBeenCalledOnce();
const event = emitted[0]!;
expect(event['eventKind']).toBe('tool_call_trace');
expect(event['sessionId']).toBe('sess-1');
expect(event['serverName']).toBe('alpha');
expect(event['verified']).toBe(true);
const payload = event['payload'] as Record<string, unknown>;
expect(payload['toolName']).toBe('alpha/do_thing');
expect(payload['argKeys']).toBe('key');
expect(payload['durationMs']).toBeTypeOf('number');
expect(payload['resultSizeBytes']).toBeTypeOf('number');
expect(payload['error']).toBeNull();
});
it('does not emit when auditCollector is not set', async () => {
const alpha = mockUpstream('alpha', { tools: [{ name: 'do_thing' }] });
router.addUpstream(alpha);
await router.discoverTools();
// No setAuditCollector call — should not throw
const resp = await router.route(
{ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'alpha/do_thing', arguments: {} } },
{ sessionId: 'sess-1' },
);
expect(resp.result).toBeDefined();
});
it('does not emit when sessionId is missing', async () => {
const alpha = mockUpstream('alpha', { tools: [{ name: 'do_thing' }] });
router.addUpstream(alpha);
await router.discoverTools();
const mockCollector = { emit: vi.fn() };
router.setAuditCollector(mockCollector as never);
await router.route(
{ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'alpha/do_thing', arguments: {} } },
);
expect(mockCollector.emit).not.toHaveBeenCalled();
});
it('captures error in trace when upstream returns error', async () => {
const failing: UpstreamConnection = {
name: 'fail-srv',
isAlive: vi.fn(() => true),
close: vi.fn(async () => {}),
onNotification: vi.fn(),
send: vi.fn(async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
if (req.method === 'tools/list') {
return { jsonrpc: '2.0', id: req.id, result: { tools: [{ name: 'fail_tool' }] } };
}
return { jsonrpc: '2.0', id: req.id, error: { code: -32000, message: 'Something broke' } };
}),
};
router.addUpstream(failing);
await router.discoverTools();
const emitted: Array<Record<string, unknown>> = [];
const mockCollector = { emit: vi.fn((e: Record<string, unknown>) => emitted.push(e)) };
router.setAuditCollector(mockCollector as never);
await router.route(
{ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'fail-srv/fail_tool', arguments: { a: 1, b: 2 } } },
{ sessionId: 'sess-err' },
);
expect(mockCollector.emit).toHaveBeenCalledOnce();
const payload = emitted[0]!['payload'] as Record<string, unknown>;
expect(payload['error']).toBe('Something broke');
expect(payload['argKeys']).toBe('a, b');
});
});
});

View File

@@ -0,0 +1,207 @@
/**
* Smoke tests: System prompts (LLM pipeline).
*
* Validates that the 6 LLM system prompts are created in mcpctl-system,
* that validation rejects edits missing required template variables,
* and that deletion resets to defaults.
*
* Run with: pnpm test:smoke
*/
import { describe, it, expect, beforeAll } from 'vitest';
import http from 'node:http';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { isMcplocalRunning, getMcpdUrl } from './mcp-client.js';
const MCPD_URL = getMcpdUrl();
function loadMcpdCredentials(): { token: string; url: string } {
try {
const raw = readFileSync(join(homedir(), '.mcpctl', 'credentials'), 'utf-8');
const parsed = JSON.parse(raw) as { token?: string; mcpdUrl?: string };
return {
token: parsed.token ?? '',
url: parsed.mcpdUrl ?? MCPD_URL,
};
} catch {
return { token: '', url: MCPD_URL };
}
}
const MCPD_CREDS = loadMcpdCredentials();
const MCPD_EFFECTIVE_URL = MCPD_CREDS.url || MCPD_URL;
interface Prompt {
id: string;
name: string;
content: string;
priority: number;
projectId: string;
}
function mcpdRequest<T>(method: string, path: string, body?: unknown): Promise<{ status: number; data: T }> {
return new Promise((resolve, reject) => {
const url = new URL(path, MCPD_EFFECTIVE_URL);
const headers: Record<string, string> = {
'Accept': 'application/json',
};
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (MCPD_CREDS.token) headers['Authorization'] = `Bearer ${MCPD_CREDS.token}`;
const bodyStr = body !== undefined ? JSON.stringify(body) : undefined;
if (bodyStr) headers['Content-Length'] = String(Buffer.byteLength(bodyStr));
const req = http.request(url, { method, timeout: 10_000, headers }, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString();
try {
resolve({ status: res.statusCode ?? 500, data: raw ? JSON.parse(raw) as T : (undefined as T) });
} catch {
resolve({ status: res.statusCode ?? 500, data: raw as unknown as T });
}
});
});
req.on('error', reject);
req.on('timeout', () => reject(new Error('Request timeout')));
if (bodyStr) req.write(bodyStr);
req.end();
});
}
const LLM_PROMPT_NAMES = [
'llm-response-filter',
'llm-request-optimization',
'llm-pagination-index',
'llm-gate-context-selector',
'llm-summarize',
'llm-paginate-titles',
];
describe('Smoke: System Prompts', () => {
let available = false;
let systemProjectId = '';
let prompts: Prompt[] = [];
beforeAll(async () => {
console.log('');
console.log(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(' Smoke Test: System Prompts (LLM pipeline)');
console.log(' ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
available = await isMcplocalRunning();
if (!available) {
console.log('\n ✗ mcplocal not running — all tests will be skipped\n');
return;
}
// Find mcpctl-system project
const projectsResult = await mcpdRequest<Array<{ id: string; name: string }>>('GET', '/api/v1/projects');
const systemProject = projectsResult.data.find((p) => p.name === 'mcpctl-system');
if (!systemProject) {
console.log('\n ✗ mcpctl-system project not found — tests will fail\n');
return;
}
systemProjectId = systemProject.id;
// Fetch all prompts for system project (API uses project name, not ID)
const promptsResult = await mcpdRequest<Prompt[]>('GET', `/api/v1/prompts?project=mcpctl-system`);
prompts = promptsResult.data;
console.log(`\n ✓ Found ${prompts.length} system prompts\n`);
});
it('all 6 LLM prompts exist in mcpctl-system', () => {
if (!available) return;
const promptNames = prompts.map((p) => p.name);
for (const name of LLM_PROMPT_NAMES) {
expect(promptNames, `Missing system prompt: ${name}`).toContain(name);
}
});
it('edit a prompt with no required vars succeeds', async () => {
if (!available) return;
const prompt = prompts.find((p) => p.name === 'llm-response-filter');
expect(prompt).toBeDefined();
const newContent = prompt!.content + '\n- Additional custom rule';
const result = await mcpdRequest<Prompt>('PUT', `/api/v1/prompts/${prompt!.id}`, {
content: newContent,
});
expect(result.status).toBe(200);
expect(result.data.content).toBe(newContent);
// Restore original
await mcpdRequest('PUT', `/api/v1/prompts/${prompt!.id}`, {
content: prompt!.content,
});
});
it('edit llm-summarize removing {{maxTokens}} is rejected with 400', async () => {
if (!available) return;
const prompt = prompts.find((p) => p.name === 'llm-summarize');
expect(prompt).toBeDefined();
const result = await mcpdRequest<{ message?: string }>('PUT', `/api/v1/prompts/${prompt!.id}`, {
content: 'Summarize this content briefly.',
});
expect(result.status).toBe(400);
});
it('edit llm-paginate-titles removing {{pageCount}} is rejected with 400', async () => {
if (!available) return;
const prompt = prompts.find((p) => p.name === 'llm-paginate-titles');
expect(prompt).toBeDefined();
const result = await mcpdRequest<{ message?: string }>('PUT', `/api/v1/prompts/${prompt!.id}`, {
content: 'Generate some titles for pages.',
});
expect(result.status).toBe(400);
});
it('edit with required vars present succeeds', async () => {
if (!available) return;
const prompt = prompts.find((p) => p.name === 'llm-summarize');
expect(prompt).toBeDefined();
const newContent = 'Custom: Summarize in about {{maxTokens}} tokens. Keep it concise.';
const result = await mcpdRequest<Prompt>('PUT', `/api/v1/prompts/${prompt!.id}`, {
content: newContent,
});
expect(result.status).toBe(200);
expect(result.data.content).toBe(newContent);
// Restore original
await mcpdRequest('PUT', `/api/v1/prompts/${prompt!.id}`, {
content: prompt!.content,
});
});
it('delete a system prompt resets to default', async () => {
if (!available) return;
const prompt = prompts.find((p) => p.name === 'llm-gate-context-selector');
expect(prompt).toBeDefined();
// First, modify the prompt
await mcpdRequest('PUT', `/api/v1/prompts/${prompt!.id}`, {
content: 'Temporarily customized content.',
});
// Delete should reset to default, not actually delete
const deleteResult = await mcpdRequest<Prompt>('DELETE', `/api/v1/prompts/${prompt!.id}`);
expect(deleteResult.status).toBe(200);
expect(deleteResult.data.content).toContain('context selection assistant');
// Prompt should still exist
const getResult = await mcpdRequest<Prompt>('GET', `/api/v1/prompts/${prompt!.id}`);
expect(getResult.status).toBe(200);
expect(getResult.data.name).toBe('llm-gate-context-selector');
});
});

View File

@@ -0,0 +1,160 @@
import { describe, it, expect, vi } from 'vitest';
import type { StageContext, LLMProvider, CacheProvider, StageLogger, SystemPromptFetcher } from '../src/proxymodel/types.js';
import paginate from '../src/proxymodel/stages/paginate.js';
import summarizeTree from '../src/proxymodel/stages/summarize-tree.js';
function mockCtx(
original: string,
config: Record<string, unknown> = {},
opts: { llmAvailable?: boolean; getSystemPrompt?: SystemPromptFetcher } = {},
): StageContext {
const mockLlm: LLMProvider = {
async complete(prompt) {
// For paginate: return JSON array of titles
if (prompt.includes('short descriptive titles') || prompt.includes('JSON array')) {
return '["Title A", "Title B"]';
}
// For summarize: return a summary
return `Summary of: ${prompt.slice(0, 40)}...`;
},
available: () => opts.llmAvailable ?? false,
};
const cache = new Map<string, string>();
const mockCache: CacheProvider = {
async getOrCompute(key, compute) {
if (cache.has(key)) return cache.get(key)!;
const val = await compute();
cache.set(key, val);
return val;
},
hash(content) { return content.slice(0, 8); },
async get(key) { return cache.get(key) ?? null; },
async set(key, value) { cache.set(key, value); },
};
const mockLog: StageLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
return {
contentType: 'toolResult',
sourceName: 'test/tool',
projectName: 'test',
sessionId: 'sess-1',
originalContent: original,
llm: mockLlm,
cache: mockCache,
log: mockLog,
getSystemPrompt: opts.getSystemPrompt ?? (async (_name, fallback) => fallback),
config,
};
}
describe('System prompt fetching in stages', () => {
describe('paginate stage', () => {
it('uses getSystemPrompt to fetch paginate-titles prompt', async () => {
const fetchSpy = vi.fn(async (_name: string, fallback: string) => fallback);
const content = 'A'.repeat(9000); // Larger than default pageSize (8000)
const ctx = mockCtx(content, {}, { llmAvailable: true, getSystemPrompt: fetchSpy });
await paginate(content, ctx);
expect(fetchSpy).toHaveBeenCalledWith(
'llm-paginate-titles',
expect.stringContaining('{{pageCount}}'),
);
});
it('falls back to hardcoded default when fetcher returns fallback', async () => {
const content = 'B'.repeat(9000);
const ctx = mockCtx(content, {}, { llmAvailable: true });
const result = await paginate(content, ctx);
// Should still produce paginated output (uses default prompt)
expect(result.content).toContain('pages');
});
it('interpolates {{pageCount}} in the fetched template', async () => {
let capturedPrompt = '';
const customFetcher: SystemPromptFetcher = async (name, fallback) => {
if (name === 'llm-paginate-titles') {
return 'Custom: generate {{pageCount}} titles please';
}
return fallback;
};
const mockLlm: LLMProvider = {
async complete(prompt) {
capturedPrompt = prompt;
return '["A", "B"]';
},
available: () => true,
};
const content = 'C'.repeat(9000);
const ctx = mockCtx(content, {}, { llmAvailable: true, getSystemPrompt: customFetcher });
// Override llm to capture the prompt
(ctx as { llm: LLMProvider }).llm = mockLlm;
await paginate(content, ctx);
expect(capturedPrompt).toContain('Custom: generate 2 titles please');
expect(capturedPrompt).not.toContain('{{pageCount}}');
});
});
describe('summarize-tree stage', () => {
it('uses getSystemPrompt to fetch llm-summarize prompt', async () => {
const fetchSpy = vi.fn(async (_name: string, fallback: string) => fallback);
// Need prose content > 2000 chars with headers to trigger LLM summary
const sections = [
'# Section 1\n' + 'Word '.repeat(500),
'# Section 2\n' + 'Text '.repeat(500),
].join('\n\n');
const ctx = mockCtx(sections, {}, { llmAvailable: true, getSystemPrompt: fetchSpy });
await summarizeTree(sections, ctx);
expect(fetchSpy).toHaveBeenCalledWith(
'llm-summarize',
expect.stringContaining('{{maxTokens}}'),
);
});
it('interpolates {{maxTokens}} in the fetched template', async () => {
let capturedPrompt = '';
const customFetcher: SystemPromptFetcher = async (name, fallback) => {
if (name === 'llm-summarize') {
return 'Custom summary in {{maxTokens}} tokens max';
}
return fallback;
};
const mockLlm: LLMProvider = {
async complete(prompt) {
capturedPrompt = prompt;
return 'A brief summary';
},
available: () => true,
};
const sections = [
'# Part A\n' + 'Content '.repeat(500),
'# Part B\n' + 'More '.repeat(500),
].join('\n\n');
const ctx = mockCtx(sections, {}, { llmAvailable: true, getSystemPrompt: customFetcher });
(ctx as { llm: LLMProvider }).llm = mockLlm;
await summarizeTree(sections, ctx);
expect(capturedPrompt).toContain('Custom summary in 200 tokens max');
expect(capturedPrompt).not.toContain('{{maxTokens}}');
});
});
});