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:
@@ -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')
|
||||
|
||||
458
src/cli/src/commands/console/audit-app.tsx
Normal file
458
src/cli/src/commands/console/audit-app.tsx
Normal 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();
|
||||
}
|
||||
69
src/cli/src/commands/console/audit-types.ts
Normal file
69
src/cli/src/commands/console/audit-types.ts
Normal 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',
|
||||
};
|
||||
@@ -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}>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)')
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -17,6 +17,8 @@ interface SystemPromptDef {
|
||||
name: string;
|
||||
priority: number;
|
||||
content: string;
|
||||
/** Template variables that must be present when editing (e.g., '{{maxTokens}}'). */
|
||||
requiredVars?: string[];
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPTS: SystemPromptDef[] = [
|
||||
@@ -59,6 +61,63 @@ Examples:
|
||||
|
||||
This will load relevant project context, policies, and guidelines tailored to your work.`,
|
||||
},
|
||||
|
||||
// ── LLM pipeline prompts (priority 5, editable) ──
|
||||
|
||||
{
|
||||
name: 'llm-response-filter',
|
||||
priority: 5,
|
||||
content: `You are a data filtering assistant. Your job is to extract only the relevant information from MCP tool responses.
|
||||
|
||||
Rules:
|
||||
- Remove redundant or verbose fields that aren't useful to the user's query
|
||||
- Keep essential identifiers, names, statuses, and key metrics
|
||||
- Preserve error messages and warnings in full
|
||||
- If the response is already concise, return it unchanged
|
||||
- Output valid JSON only, no markdown or explanations
|
||||
- If you cannot parse the input, return it unchanged`,
|
||||
},
|
||||
{
|
||||
name: 'llm-request-optimization',
|
||||
priority: 5,
|
||||
content: `You are a query optimization assistant. Your job is to optimize MCP tool call parameters.
|
||||
|
||||
Rules:
|
||||
- Add appropriate filters or limits if the query is too broad
|
||||
- Keep the original intent of the request
|
||||
- Output valid JSON with the optimized parameters only, no markdown or explanations
|
||||
- If no optimization is needed, return the original parameters unchanged`,
|
||||
},
|
||||
{
|
||||
name: 'llm-pagination-index',
|
||||
priority: 5,
|
||||
content: `You are a document indexing assistant. Given a large tool response split into pages, generate a concise summary for each page describing what data it contains.
|
||||
|
||||
Rules:
|
||||
- For each page, write 1-2 sentences describing the key content
|
||||
- Be specific: mention entity names, IDs, counts, or key fields visible on that page
|
||||
- If it's JSON, describe the structure and notable entries
|
||||
- If it's text, describe the topics covered
|
||||
- Output valid JSON only: an array of objects with "page" (1-based number) and "summary" (string)
|
||||
- Example output: [{"page": 1, "summary": "Configuration nodes and global settings (inject, debug, function nodes 1-15)"}, {"page": 2, "summary": "HTTP request nodes and API integrations (nodes 16-40)"}]`,
|
||||
},
|
||||
{
|
||||
name: 'llm-gate-context-selector',
|
||||
priority: 5,
|
||||
content: `You are a context selection assistant. Given a developer's task keywords and a list of available project prompts, select which prompts are relevant to their work. Return a JSON object with "selectedNames" (array of prompt names) and "reasoning" (brief explanation). Priority 10 prompts must always be included.`,
|
||||
},
|
||||
{
|
||||
name: 'llm-summarize',
|
||||
priority: 5,
|
||||
requiredVars: ['{{maxTokens}}'],
|
||||
content: `Summarize the following in about {{maxTokens}} tokens. Preserve all items marked MUST, REQUIRED, or CRITICAL verbatim. Be specific — mention names, IDs, counts, key values.`,
|
||||
},
|
||||
{
|
||||
name: 'llm-paginate-titles',
|
||||
priority: 5,
|
||||
requiredVars: ['{{pageCount}}'],
|
||||
content: `Generate exactly {{pageCount}} short descriptive titles (max 60 chars each) for the following {{pageCount}} pages. Return ONLY a JSON array of {{pageCount}} strings. No markdown, no explanation.`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -116,3 +175,15 @@ export async function bootstrapSystemProject(prisma: PrismaClient): Promise<void
|
||||
export function getSystemPromptNames(): string[] {
|
||||
return SYSTEM_PROMPTS.map((p) => p.name);
|
||||
}
|
||||
|
||||
/** Get the required template variables for a system prompt (e.g., ['{{maxTokens}}']). */
|
||||
export function getSystemPromptRequiredVars(name: string): string[] | undefined {
|
||||
const def = SYSTEM_PROMPTS.find((p) => p.name === name);
|
||||
return def?.requiredVars;
|
||||
}
|
||||
|
||||
/** Get the default content for a system prompt (for reset-on-delete). */
|
||||
export function getSystemPromptDefault(name: string): string | undefined {
|
||||
const def = SYSTEM_PROMPTS.find((p) => p.name === name);
|
||||
return def?.content;
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ import {
|
||||
} from './routes/index.js';
|
||||
import { registerPromptRoutes } from './routes/prompts.js';
|
||||
import { PromptService } from './services/prompt.service.js';
|
||||
import { ResourceRuleRegistry } from './validation/resource-rules.js';
|
||||
import { systemPromptVarsRule } from './validation/rules/system-prompt-vars.js';
|
||||
|
||||
type PermissionCheck =
|
||||
| { kind: 'resource'; resource: string; action: RbacAction; resourceName?: string }
|
||||
@@ -290,7 +292,9 @@ async function main(): Promise<void> {
|
||||
const groupService = new GroupService(groupRepo, userRepo);
|
||||
const promptRepo = new PromptRepository(prisma);
|
||||
const promptRequestRepo = new PromptRequestRepository(prisma);
|
||||
const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo);
|
||||
const promptRuleRegistry = new ResourceRuleRegistry();
|
||||
promptRuleRegistry.register(systemPromptVarsRule);
|
||||
const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry);
|
||||
|
||||
// Auth middleware for global hooks
|
||||
const authMiddleware = createAuthMiddleware({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PrismaClient, AuditEvent, Prisma } from '@prisma/client';
|
||||
import type { IAuditEventRepository, AuditEventFilter, AuditEventCreateInput } from './interfaces.js';
|
||||
import type { IAuditEventRepository, AuditEventFilter, AuditEventCreateInput, AuditSessionSummary } from './interfaces.js';
|
||||
|
||||
export class AuditEventRepository implements IAuditEventRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
@@ -39,6 +39,59 @@ export class AuditEventRepository implements IAuditEventRepository {
|
||||
const where = buildWhere(filter);
|
||||
return this.prisma.auditEvent.count({ where });
|
||||
}
|
||||
|
||||
async listSessions(filter?: { projectName?: string; limit?: number; offset?: number }): Promise<AuditSessionSummary[]> {
|
||||
const where: Prisma.AuditEventWhereInput = {};
|
||||
if (filter?.projectName !== undefined) where.projectName = filter.projectName;
|
||||
|
||||
const groups = await this.prisma.auditEvent.groupBy({
|
||||
by: ['sessionId', 'projectName'],
|
||||
where,
|
||||
_min: { timestamp: true },
|
||||
_max: { timestamp: true },
|
||||
_count: true,
|
||||
orderBy: { _max: { timestamp: 'desc' } },
|
||||
take: filter?.limit ?? 50,
|
||||
skip: filter?.offset ?? 0,
|
||||
});
|
||||
|
||||
// Fetch distinct eventKinds per session
|
||||
const sessionIds = groups.map((g) => g.sessionId);
|
||||
const kindRows = sessionIds.length > 0
|
||||
? await this.prisma.auditEvent.findMany({
|
||||
where: { sessionId: { in: sessionIds } },
|
||||
select: { sessionId: true, eventKind: true },
|
||||
distinct: ['sessionId', 'eventKind'],
|
||||
})
|
||||
: [];
|
||||
|
||||
const kindMap = new Map<string, string[]>();
|
||||
for (const row of kindRows) {
|
||||
const list = kindMap.get(row.sessionId) ?? [];
|
||||
list.push(row.eventKind);
|
||||
kindMap.set(row.sessionId, list);
|
||||
}
|
||||
|
||||
return groups.map((g) => ({
|
||||
sessionId: g.sessionId,
|
||||
projectName: g.projectName,
|
||||
firstSeen: g._min.timestamp!,
|
||||
lastSeen: g._max.timestamp!,
|
||||
eventCount: g._count,
|
||||
eventKinds: kindMap.get(g.sessionId) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
async countSessions(filter?: { projectName?: string }): Promise<number> {
|
||||
const where: Prisma.AuditEventWhereInput = {};
|
||||
if (filter?.projectName !== undefined) where.projectName = filter.projectName;
|
||||
|
||||
const groups = await this.prisma.auditEvent.groupBy({
|
||||
by: ['sessionId'],
|
||||
where,
|
||||
});
|
||||
return groups.length;
|
||||
}
|
||||
}
|
||||
|
||||
function buildWhere(filter?: AuditEventFilter): Prisma.AuditEventWhereInput {
|
||||
|
||||
@@ -75,9 +75,20 @@ export interface AuditEventCreateInput {
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AuditSessionSummary {
|
||||
sessionId: string;
|
||||
projectName: string;
|
||||
firstSeen: Date;
|
||||
lastSeen: Date;
|
||||
eventCount: number;
|
||||
eventKinds: string[];
|
||||
}
|
||||
|
||||
export interface IAuditEventRepository {
|
||||
findAll(filter?: AuditEventFilter): Promise<AuditEvent[]>;
|
||||
findById(id: string): Promise<AuditEvent | null>;
|
||||
createMany(events: AuditEventCreateInput[]): Promise<number>;
|
||||
count(filter?: AuditEventFilter): Promise<number>;
|
||||
listSessions(filter?: { projectName?: string; limit?: number; offset?: number }): Promise<AuditSessionSummary[]>;
|
||||
countSessions(filter?: { projectName?: string }): Promise<number>;
|
||||
}
|
||||
|
||||
@@ -56,4 +56,14 @@ export function registerAuditEventRoutes(app: FastifyInstance, service: AuditEve
|
||||
app.get<{ Params: { id: string } }>('/api/v1/audit/events/:id', async (request) => {
|
||||
return service.getById(request.params.id);
|
||||
});
|
||||
|
||||
// GET /api/v1/audit/sessions — list sessions with aggregates
|
||||
app.get<{ Querystring: { projectName?: string; limit?: string; offset?: string } }>('/api/v1/audit/sessions', async (request) => {
|
||||
const q = request.query;
|
||||
const params: { projectName?: string; limit?: number; offset?: number } = {};
|
||||
if (q.projectName !== undefined) params.projectName = q.projectName;
|
||||
if (q.limit !== undefined) params.limit = parseInt(q.limit, 10);
|
||||
if (q.offset !== undefined) params.offset = parseInt(q.offset, 10);
|
||||
return service.listSessions(Object.keys(params).length > 0 ? params : undefined);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -94,9 +94,14 @@ export function registerPromptRoutes(
|
||||
return service.updatePrompt(request.params.id, request.body);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/prompts/:id', async (request, reply) => {
|
||||
await service.deletePrompt(request.params.id);
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/prompts/:id', async (request, reply): Promise<unknown> => {
|
||||
const result = await service.deletePrompt(request.params.id);
|
||||
if (result) {
|
||||
// System prompt was reset to default — return the reset prompt
|
||||
return result;
|
||||
}
|
||||
reply.code(204);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// ── Prompt Requests (pending proposals) ──
|
||||
|
||||
@@ -38,6 +38,22 @@ export class AuditEventService {
|
||||
return this.repo.createMany(events);
|
||||
}
|
||||
|
||||
async listSessions(params?: { projectName?: string; limit?: number; offset?: number }): Promise<{ sessions: Awaited<ReturnType<IAuditEventRepository['listSessions']>>; total: number }> {
|
||||
const filter: { projectName?: string; limit?: number; offset?: number } = {};
|
||||
if (params?.projectName !== undefined) filter.projectName = params.projectName;
|
||||
if (params?.limit !== undefined) filter.limit = params.limit;
|
||||
if (params?.offset !== undefined) filter.offset = params.offset;
|
||||
|
||||
const countFilter: { projectName?: string } = {};
|
||||
if (params?.projectName !== undefined) countFilter.projectName = params.projectName;
|
||||
|
||||
const [sessions, total] = await Promise.all([
|
||||
this.repo.listSessions(Object.keys(filter).length > 0 ? filter : undefined),
|
||||
this.repo.countSessions(Object.keys(countFilter).length > 0 ? countFilter : undefined),
|
||||
]);
|
||||
return { sessions, total };
|
||||
}
|
||||
|
||||
private buildFilter(params?: AuditEventQueryParams): AuditEventFilter | undefined {
|
||||
if (!params) return undefined;
|
||||
const filter: AuditEventFilter = {};
|
||||
|
||||
@@ -5,7 +5,8 @@ import type { IProjectRepository } from '../repositories/project.repository.js';
|
||||
import { CreatePromptSchema, UpdatePromptSchema, CreatePromptRequestSchema, UpdatePromptRequestSchema } from '../validation/prompt.schema.js';
|
||||
import { NotFoundError } from './mcp-server.service.js';
|
||||
import type { PromptSummaryService } from './prompt-summary.service.js';
|
||||
import { SYSTEM_PROJECT_NAME } from '../bootstrap/system-project.js';
|
||||
import { SYSTEM_PROJECT_NAME, getSystemPromptDefault } from '../bootstrap/system-project.js';
|
||||
import type { ResourceRuleRegistry, RuleContext } from '../validation/resource-rules.js';
|
||||
|
||||
export class PromptService {
|
||||
private summaryService: PromptSummaryService | null = null;
|
||||
@@ -14,12 +15,47 @@ export class PromptService {
|
||||
private readonly promptRepo: IPromptRepository,
|
||||
private readonly promptRequestRepo: IPromptRequestRepository,
|
||||
private readonly projectRepo: IProjectRepository,
|
||||
private readonly ruleRegistry?: ResourceRuleRegistry,
|
||||
) {}
|
||||
|
||||
setSummaryService(service: PromptSummaryService): void {
|
||||
this.summaryService = service;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run resource validation rules for a prompt.
|
||||
* Throws 400 if validation fails.
|
||||
*/
|
||||
private async validatePromptRules(
|
||||
name: string,
|
||||
content: string,
|
||||
projectId: string | undefined | null,
|
||||
operation: 'create' | 'update',
|
||||
): Promise<void> {
|
||||
if (!this.ruleRegistry || !projectId) return;
|
||||
|
||||
const project = await this.projectRepo.findById(projectId);
|
||||
const isSystem = project?.name === SYSTEM_PROJECT_NAME;
|
||||
|
||||
const ctx: RuleContext = {
|
||||
findResource: async () => null,
|
||||
};
|
||||
|
||||
const result = await this.ruleRegistry.validate(
|
||||
'prompt',
|
||||
{ name, content },
|
||||
{ projectName: project?.name, isSystemResource: isSystem, operation },
|
||||
ctx,
|
||||
);
|
||||
|
||||
if (!result.valid) {
|
||||
throw Object.assign(
|
||||
new Error(result.errors?.join('; ') ?? 'Validation failed'),
|
||||
{ statusCode: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Prompt CRUD ──
|
||||
|
||||
async listPrompts(projectId?: string): Promise<Prompt[]> {
|
||||
@@ -44,6 +80,8 @@ export class PromptService {
|
||||
if (project === null) throw new NotFoundError(`Project not found: ${data.projectId}`);
|
||||
}
|
||||
|
||||
await this.validatePromptRules(data.name, data.content, data.projectId, 'create');
|
||||
|
||||
const createData: { name: string; content: string; projectId?: string; priority?: number; linkTarget?: string } = {
|
||||
name: data.name,
|
||||
content: data.content,
|
||||
@@ -61,7 +99,12 @@ export class PromptService {
|
||||
|
||||
async updatePrompt(id: string, input: unknown): Promise<Prompt> {
|
||||
const data = UpdatePromptSchema.parse(input);
|
||||
await this.getPrompt(id);
|
||||
const existing = await this.getPrompt(id);
|
||||
|
||||
if (data.content !== undefined) {
|
||||
await this.validatePromptRules(existing.name, data.content, existing.projectId, 'update');
|
||||
}
|
||||
|
||||
const updateData: { content?: string; priority?: number } = {};
|
||||
if (data.content !== undefined) updateData.content = data.content;
|
||||
if (data.priority !== undefined) updateData.priority = data.priority;
|
||||
@@ -87,13 +130,17 @@ export class PromptService {
|
||||
return this.promptRepo.update(id, { summary, chapters });
|
||||
}
|
||||
|
||||
async deletePrompt(id: string): Promise<void> {
|
||||
async deletePrompt(id: string): Promise<Prompt | void> {
|
||||
const prompt = await this.getPrompt(id);
|
||||
// Protect system prompts from deletion
|
||||
// System prompts: reset to codebase default instead of deleting
|
||||
if (prompt.projectId) {
|
||||
const project = await this.projectRepo.findById(prompt.projectId);
|
||||
if (project?.name === SYSTEM_PROJECT_NAME) {
|
||||
throw Object.assign(new Error('Cannot delete system prompts'), { statusCode: 403 });
|
||||
const defaultContent = getSystemPromptDefault(prompt.name);
|
||||
if (defaultContent !== undefined) {
|
||||
return this.promptRepo.update(id, { content: defaultContent });
|
||||
}
|
||||
// Unknown system prompt — allow deletion
|
||||
}
|
||||
}
|
||||
await this.promptRepo.delete(id);
|
||||
|
||||
71
src/mcpd/src/validation/resource-rules.ts
Normal file
71
src/mcpd/src/validation/resource-rules.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Extensible resource validation framework.
|
||||
*
|
||||
* Services register rules that validate resource data before create/update.
|
||||
* Each rule targets a resource kind and optionally matches on specific
|
||||
* instances (e.g., system prompts only). The registry aggregates errors
|
||||
* from all matching rules.
|
||||
*/
|
||||
|
||||
export interface ResourceRule<T = unknown> {
|
||||
/** Rule identifier for error messages. */
|
||||
name: string;
|
||||
/** Resource kind this rule applies to: 'prompt', 'server', 'project', etc. */
|
||||
resource: string;
|
||||
/** Does this rule apply to the given resource instance? */
|
||||
match(data: T, meta: RuleMeta): boolean;
|
||||
/** Validate the resource data. */
|
||||
validate(data: T, ctx: RuleContext): Promise<ValidationResult>;
|
||||
}
|
||||
|
||||
export interface RuleMeta {
|
||||
projectName?: string | undefined;
|
||||
isSystemResource?: boolean | undefined;
|
||||
operation: 'create' | 'update';
|
||||
}
|
||||
|
||||
export interface RuleContext {
|
||||
/** Cross-resource lookups (e.g., check if a secret exists). */
|
||||
findResource(kind: string, name: string): Promise<unknown | null>;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry holding all resource validation rules.
|
||||
* Services call `validate()` which runs all matching rules and aggregates errors.
|
||||
*/
|
||||
export class ResourceRuleRegistry {
|
||||
private rules: ResourceRule[] = [];
|
||||
|
||||
register(rule: ResourceRule): void {
|
||||
this.rules.push(rule);
|
||||
}
|
||||
|
||||
async validate<T>(
|
||||
resource: string,
|
||||
data: T,
|
||||
meta: RuleMeta,
|
||||
ctx: RuleContext,
|
||||
): Promise<ValidationResult> {
|
||||
const allErrors: string[] = [];
|
||||
|
||||
for (const rule of this.rules) {
|
||||
if (rule.resource !== resource) continue;
|
||||
if (!rule.match(data as never, meta)) continue;
|
||||
|
||||
const result = await rule.validate(data as never, ctx);
|
||||
if (!result.valid && result.errors) {
|
||||
allErrors.push(...result.errors);
|
||||
}
|
||||
}
|
||||
|
||||
if (allErrors.length > 0) {
|
||||
return { valid: false, errors: allErrors };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
}
|
||||
41
src/mcpd/src/validation/rules/system-prompt-vars.ts
Normal file
41
src/mcpd/src/validation/rules/system-prompt-vars.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Validation rule: ensure system prompts preserve required template variables.
|
||||
*
|
||||
* When a prompt in the mcpctl-system project is updated, this rule checks
|
||||
* that all required {{var}} placeholders are still present in the content.
|
||||
*/
|
||||
import type { ResourceRule, RuleMeta, RuleContext, ValidationResult } from '../resource-rules.js';
|
||||
import { getSystemPromptRequiredVars } from '../../bootstrap/system-project.js';
|
||||
|
||||
interface PromptData {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const systemPromptVarsRule: ResourceRule<PromptData> = {
|
||||
name: 'system-prompt-template-vars',
|
||||
resource: 'prompt',
|
||||
|
||||
match(_data: PromptData, meta: RuleMeta): boolean {
|
||||
return meta.isSystemResource === true;
|
||||
},
|
||||
|
||||
async validate(data: PromptData, _ctx: RuleContext): Promise<ValidationResult> {
|
||||
const requiredVars = getSystemPromptRequiredVars(data.name);
|
||||
if (!requiredVars || requiredVars.length === 0) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
const missing = requiredVars.filter((v) => !data.content.includes(v));
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: missing.map(
|
||||
(v) => `System prompt '${data.name}' requires template variable ${v}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
},
|
||||
};
|
||||
@@ -12,6 +12,8 @@ function mockRepo(): IAuditEventRepository {
|
||||
findById: vi.fn(async () => null),
|
||||
createMany: vi.fn(async (events: unknown[]) => events.length),
|
||||
count: vi.fn(async () => 0),
|
||||
listSessions: vi.fn(async () => []),
|
||||
countSessions: vi.fn(async () => 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -175,4 +177,59 @@ describe('audit event routes', () => {
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/audit/sessions', () => {
|
||||
it('returns session summaries', async () => {
|
||||
vi.mocked(repo.listSessions).mockResolvedValue([
|
||||
{
|
||||
sessionId: 'sess-1',
|
||||
projectName: 'ha-project',
|
||||
firstSeen: new Date('2026-03-01T12:00:00Z'),
|
||||
lastSeen: new Date('2026-03-01T12:05:00Z'),
|
||||
eventCount: 5,
|
||||
eventKinds: ['gate_decision', 'tool_call_trace'],
|
||||
},
|
||||
]);
|
||||
vi.mocked(repo.countSessions).mockResolvedValue(1);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/audit/sessions',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.payload);
|
||||
expect(body.sessions).toHaveLength(1);
|
||||
expect(body.sessions[0].sessionId).toBe('sess-1');
|
||||
expect(body.sessions[0].eventCount).toBe(5);
|
||||
expect(body.total).toBe(1);
|
||||
});
|
||||
|
||||
it('filters by projectName', async () => {
|
||||
vi.mocked(repo.listSessions).mockResolvedValue([]);
|
||||
vi.mocked(repo.countSessions).mockResolvedValue(0);
|
||||
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/audit/sessions?projectName=ha-project',
|
||||
});
|
||||
|
||||
const call = vi.mocked(repo.listSessions).mock.calls[0]![0] as { projectName?: string };
|
||||
expect(call.projectName).toBe('ha-project');
|
||||
});
|
||||
|
||||
it('supports pagination', async () => {
|
||||
vi.mocked(repo.listSessions).mockResolvedValue([]);
|
||||
vi.mocked(repo.countSessions).mockResolvedValue(10);
|
||||
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/audit/sessions?limit=5&offset=5',
|
||||
});
|
||||
|
||||
const call = vi.mocked(repo.listSessions).mock.calls[0]![0] as { limit?: number; offset?: number };
|
||||
expect(call.limit).toBe(5);
|
||||
expect(call.offset).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,13 +89,18 @@ describe('bootstrapSystemProject', () => {
|
||||
expect(prisma.prompt.create).toHaveBeenCalledTimes(expectedNames.length);
|
||||
});
|
||||
|
||||
it('creates system prompts with priority 10', async () => {
|
||||
it('creates system prompts with expected priorities', async () => {
|
||||
await bootstrapSystemProject(prisma);
|
||||
|
||||
const createCalls = vi.mocked(prisma.prompt.create).mock.calls;
|
||||
for (const call of createCalls) {
|
||||
const data = (call[0] as { data: { priority: number } }).data;
|
||||
expect(data.priority).toBe(10);
|
||||
const data = (call[0] as { data: { name: string; priority: number } }).data;
|
||||
// Gate prompts have priority 10, LLM pipeline prompts have priority 5
|
||||
if (data.name.startsWith('gate-') || data.name === 'session-greeting') {
|
||||
expect(data.priority).toBe(10);
|
||||
} else {
|
||||
expect(data.priority).toBe(5);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
88
src/mcpd/tests/resource-rules.test.ts
Normal file
88
src/mcpd/tests/resource-rules.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ResourceRuleRegistry } from '../src/validation/resource-rules.js';
|
||||
import type { ResourceRule, RuleMeta, RuleContext, ValidationResult } from '../src/validation/resource-rules.js';
|
||||
|
||||
const noopCtx: RuleContext = {
|
||||
findResource: async () => null,
|
||||
};
|
||||
|
||||
function makeRule(
|
||||
name: string,
|
||||
resource: string,
|
||||
matchFn: (data: unknown, meta: RuleMeta) => boolean,
|
||||
validateFn: () => Promise<ValidationResult>,
|
||||
): ResourceRule {
|
||||
return { name, resource, match: matchFn, validate: validateFn };
|
||||
}
|
||||
|
||||
describe('ResourceRuleRegistry', () => {
|
||||
it('returns valid when no rules are registered', async () => {
|
||||
const registry = new ResourceRuleRegistry();
|
||||
const result = await registry.validate('prompt', { name: 'x' }, { operation: 'create' }, noopCtx);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('returns valid when no rules match the resource type', async () => {
|
||||
const registry = new ResourceRuleRegistry();
|
||||
registry.register(makeRule('server-rule', 'server', () => true, async () => ({ valid: false, errors: ['fail'] })));
|
||||
|
||||
const result = await registry.validate('prompt', { name: 'x' }, { operation: 'create' }, noopCtx);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('skips rules that do not match the predicate', async () => {
|
||||
const registry = new ResourceRuleRegistry();
|
||||
registry.register(makeRule('skip-rule', 'prompt', () => false, async () => ({ valid: false, errors: ['fail'] })));
|
||||
|
||||
const result = await registry.validate('prompt', { name: 'x' }, { operation: 'create' }, noopCtx);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('runs matching rules and returns errors', async () => {
|
||||
const registry = new ResourceRuleRegistry();
|
||||
registry.register(makeRule('bad-rule', 'prompt', () => true, async () => ({
|
||||
valid: false,
|
||||
errors: ['missing template var'],
|
||||
})));
|
||||
|
||||
const result = await registry.validate('prompt', { name: 'x' }, { operation: 'update' }, noopCtx);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toEqual(['missing template var']);
|
||||
});
|
||||
|
||||
it('aggregates errors from multiple matching rules', async () => {
|
||||
const registry = new ResourceRuleRegistry();
|
||||
registry.register(makeRule('rule-1', 'prompt', () => true, async () => ({
|
||||
valid: false,
|
||||
errors: ['error A'],
|
||||
})));
|
||||
registry.register(makeRule('rule-2', 'prompt', () => true, async () => ({
|
||||
valid: false,
|
||||
errors: ['error B', 'error C'],
|
||||
})));
|
||||
|
||||
const result = await registry.validate('prompt', { name: 'x' }, { operation: 'create' }, noopCtx);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toEqual(['error A', 'error B', 'error C']);
|
||||
});
|
||||
|
||||
it('returns valid when all matching rules pass', async () => {
|
||||
const registry = new ResourceRuleRegistry();
|
||||
registry.register(makeRule('ok-rule', 'prompt', () => true, async () => ({ valid: true })));
|
||||
|
||||
const result = await registry.validate('prompt', { name: 'x' }, { operation: 'update' }, noopCtx);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('passes meta to match function', async () => {
|
||||
const registry = new ResourceRuleRegistry();
|
||||
let receivedMeta: RuleMeta | null = null;
|
||||
registry.register(makeRule('meta-check', 'prompt', (_data, meta) => {
|
||||
receivedMeta = meta;
|
||||
return false;
|
||||
}, async () => ({ valid: true })));
|
||||
|
||||
await registry.validate('prompt', { name: 'x' }, { operation: 'update', isSystemResource: true, projectName: 'sys' }, noopCtx);
|
||||
expect(receivedMeta).toEqual({ operation: 'update', isSystemResource: true, projectName: 'sys' });
|
||||
});
|
||||
});
|
||||
@@ -195,11 +195,15 @@ describe('PromptService', () => {
|
||||
await expect(service.deletePrompt('nope')).rejects.toThrow('Prompt not found');
|
||||
});
|
||||
|
||||
it('should reject deletion of system prompts', async () => {
|
||||
vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt({ projectId: 'sys-proj' }));
|
||||
it('should reset system prompts to default on delete', async () => {
|
||||
vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt({ name: 'gate-instructions', projectId: 'sys-proj' }));
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'sys-proj', name: 'mcpctl-system' }));
|
||||
|
||||
await expect(service.deletePrompt('prompt-1')).rejects.toThrow('Cannot delete system prompts');
|
||||
const result = await service.deletePrompt('prompt-1');
|
||||
// Should reset via update, not delete
|
||||
expect(promptRepo.update).toHaveBeenCalledWith('prompt-1', expect.objectContaining({ content: expect.any(String) }));
|
||||
expect(promptRepo.delete).not.toHaveBeenCalled();
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow deletion of non-system project prompts', async () => {
|
||||
|
||||
238
src/mcpd/tests/system-prompt-validation.test.ts
Normal file
238
src/mcpd/tests/system-prompt-validation.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { PromptService } from '../src/services/prompt.service.js';
|
||||
import type { IPromptRepository } from '../src/repositories/prompt.repository.js';
|
||||
import type { IPromptRequestRepository } from '../src/repositories/prompt-request.repository.js';
|
||||
import type { IProjectRepository } from '../src/repositories/project.repository.js';
|
||||
import type { Prompt, PromptRequest, Project } from '@prisma/client';
|
||||
import { ResourceRuleRegistry } from '../src/validation/resource-rules.js';
|
||||
import { systemPromptVarsRule } from '../src/validation/rules/system-prompt-vars.js';
|
||||
import {
|
||||
getSystemPromptNames,
|
||||
getSystemPromptRequiredVars,
|
||||
getSystemPromptDefault,
|
||||
} from '../src/bootstrap/system-project.js';
|
||||
|
||||
function makePrompt(overrides: Partial<Prompt> = {}): Prompt {
|
||||
return {
|
||||
id: 'prompt-1',
|
||||
name: 'test-prompt',
|
||||
content: 'Hello world',
|
||||
projectId: null,
|
||||
priority: 5,
|
||||
summary: null,
|
||||
chapters: null,
|
||||
linkTarget: null,
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeProject(overrides: Partial<Project> = {}): Project {
|
||||
return {
|
||||
id: 'proj-1',
|
||||
name: 'test-project',
|
||||
description: '',
|
||||
prompt: '',
|
||||
proxyMode: 'direct',
|
||||
proxyModel: '',
|
||||
gated: true,
|
||||
llmProvider: null,
|
||||
llmModel: null,
|
||||
ownerId: 'user-1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
} as Project;
|
||||
}
|
||||
|
||||
function mockPromptRepo(): IPromptRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findGlobal: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByNameAndProject: vi.fn(async () => null),
|
||||
create: vi.fn(async (data) => makePrompt(data)),
|
||||
update: vi.fn(async (id, data) => makePrompt({ id, ...data })),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockPromptRequestRepo(): IPromptRequestRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findGlobal: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByNameAndProject: vi.fn(async () => null),
|
||||
findBySession: vi.fn(async () => []),
|
||||
create: vi.fn(async (data) => ({ id: 'req-1', ...data }) as PromptRequest),
|
||||
update: vi.fn(async (id, data) => ({ id, ...data }) as PromptRequest),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockProjectRepo(): IProjectRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByName: vi.fn(async () => null),
|
||||
create: vi.fn(async (data) => makeProject(data)),
|
||||
update: vi.fn(async (id, data) => makeProject({ id, ...data })),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('System Prompt Validation', () => {
|
||||
let promptRepo: IPromptRepository;
|
||||
let promptRequestRepo: IPromptRequestRepository;
|
||||
let projectRepo: IProjectRepository;
|
||||
let registry: ResourceRuleRegistry;
|
||||
let service: PromptService;
|
||||
|
||||
const systemProject = makeProject({ id: 'sys-proj', name: 'mcpctl-system' });
|
||||
|
||||
beforeEach(() => {
|
||||
promptRepo = mockPromptRepo();
|
||||
promptRequestRepo = mockPromptRequestRepo();
|
||||
projectRepo = mockProjectRepo();
|
||||
registry = new ResourceRuleRegistry();
|
||||
registry.register(systemPromptVarsRule);
|
||||
service = new PromptService(promptRepo, promptRequestRepo, projectRepo, registry);
|
||||
});
|
||||
|
||||
describe('getSystemPromptNames', () => {
|
||||
it('includes all 11 system prompts (5 gate + 6 LLM)', () => {
|
||||
const names = getSystemPromptNames();
|
||||
expect(names).toContain('gate-instructions');
|
||||
expect(names).toContain('gate-encouragement');
|
||||
expect(names).toContain('gate-intercept-preamble');
|
||||
expect(names).toContain('gate-session-active');
|
||||
expect(names).toContain('session-greeting');
|
||||
expect(names).toContain('llm-response-filter');
|
||||
expect(names).toContain('llm-request-optimization');
|
||||
expect(names).toContain('llm-pagination-index');
|
||||
expect(names).toContain('llm-gate-context-selector');
|
||||
expect(names).toContain('llm-summarize');
|
||||
expect(names).toContain('llm-paginate-titles');
|
||||
expect(names.length).toBe(11);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSystemPromptRequiredVars', () => {
|
||||
it('returns {{maxTokens}} for llm-summarize', () => {
|
||||
expect(getSystemPromptRequiredVars('llm-summarize')).toEqual(['{{maxTokens}}']);
|
||||
});
|
||||
|
||||
it('returns {{pageCount}} for llm-paginate-titles', () => {
|
||||
expect(getSystemPromptRequiredVars('llm-paginate-titles')).toEqual(['{{pageCount}}']);
|
||||
});
|
||||
|
||||
it('returns undefined for prompts without required vars', () => {
|
||||
expect(getSystemPromptRequiredVars('llm-response-filter')).toBeUndefined();
|
||||
expect(getSystemPromptRequiredVars('gate-instructions')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for unknown prompts', () => {
|
||||
expect(getSystemPromptRequiredVars('nonexistent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSystemPromptDefault', () => {
|
||||
it('returns default content for each system prompt', () => {
|
||||
for (const name of getSystemPromptNames()) {
|
||||
const content = getSystemPromptDefault(name);
|
||||
expect(content).toBeDefined();
|
||||
expect(typeof content).toBe('string');
|
||||
expect(content!.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns undefined for unknown prompts', () => {
|
||||
expect(getSystemPromptDefault('nonexistent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePrompt validation', () => {
|
||||
it('rejects edits missing required {{maxTokens}} for llm-summarize', async () => {
|
||||
vi.mocked(promptRepo.findById).mockResolvedValue(
|
||||
makePrompt({ name: 'llm-summarize', projectId: 'sys-proj', content: 'old content with {{maxTokens}}' }),
|
||||
);
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(systemProject);
|
||||
|
||||
await expect(
|
||||
service.updatePrompt('prompt-1', { content: 'Summarize this. No template vars here.' }),
|
||||
).rejects.toThrow("requires template variable {{maxTokens}}");
|
||||
});
|
||||
|
||||
it('rejects edits missing required {{pageCount}} for llm-paginate-titles', async () => {
|
||||
vi.mocked(promptRepo.findById).mockResolvedValue(
|
||||
makePrompt({ name: 'llm-paginate-titles', projectId: 'sys-proj', content: 'old with {{pageCount}}' }),
|
||||
);
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(systemProject);
|
||||
|
||||
await expect(
|
||||
service.updatePrompt('prompt-1', { content: 'Generate titles for pages.' }),
|
||||
).rejects.toThrow("requires template variable {{pageCount}}");
|
||||
});
|
||||
|
||||
it('allows edits for prompts without required vars', async () => {
|
||||
vi.mocked(promptRepo.findById).mockResolvedValue(
|
||||
makePrompt({ name: 'llm-response-filter', projectId: 'sys-proj', content: 'old' }),
|
||||
);
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(systemProject);
|
||||
|
||||
await expect(
|
||||
service.updatePrompt('prompt-1', { content: 'You are a new data filtering assistant.' }),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('allows edits that preserve all required vars', async () => {
|
||||
vi.mocked(promptRepo.findById).mockResolvedValue(
|
||||
makePrompt({ name: 'llm-summarize', projectId: 'sys-proj', content: 'old' }),
|
||||
);
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(systemProject);
|
||||
|
||||
await expect(
|
||||
service.updatePrompt('prompt-1', { content: 'Custom summarize in {{maxTokens}} tokens.' }),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('allows edits to non-system project prompts without validation', async () => {
|
||||
vi.mocked(promptRepo.findById).mockResolvedValue(
|
||||
makePrompt({ name: 'some-prompt', projectId: 'proj-1', content: 'old' }),
|
||||
);
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'proj-1', name: 'my-project' }));
|
||||
|
||||
await expect(
|
||||
service.updatePrompt('prompt-1', { content: 'anything goes' }),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deletePrompt resets system prompts', () => {
|
||||
it('resets system prompt content to default instead of deleting', async () => {
|
||||
vi.mocked(promptRepo.findById).mockResolvedValue(
|
||||
makePrompt({ name: 'llm-response-filter', projectId: 'sys-proj', content: 'custom content' }),
|
||||
);
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(systemProject);
|
||||
|
||||
const result = await service.deletePrompt('prompt-1');
|
||||
expect(result).toBeDefined();
|
||||
expect(promptRepo.update).toHaveBeenCalledWith('prompt-1', {
|
||||
content: getSystemPromptDefault('llm-response-filter'),
|
||||
});
|
||||
expect(promptRepo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows deletion of non-system project prompts', async () => {
|
||||
vi.mocked(promptRepo.findById).mockResolvedValue(
|
||||
makePrompt({ projectId: 'proj-1' }),
|
||||
);
|
||||
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'proj-1', name: 'my-project' }));
|
||||
|
||||
await service.deletePrompt('prompt-1');
|
||||
expect(promptRepo.delete).toHaveBeenCalledWith('prompt-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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(', ')}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? {},
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ function createMockContext(original: string): StageContext {
|
||||
llm: mockLlm,
|
||||
cache: mockCache,
|
||||
log: mockLog,
|
||||
getSystemPrompt: async (_name: string, fallback: string) => fallback,
|
||||
config: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
207
src/mcplocal/tests/smoke/system-prompts.test.ts
Normal file
207
src/mcplocal/tests/smoke/system-prompts.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
160
src/mcplocal/tests/system-prompt-fetching.test.ts
Normal file
160
src/mcplocal/tests/system-prompt-fetching.test.ts
Normal 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}}');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user