From 5d859ca7d8b9ba8cfff86e048f9a4993852cb231 Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 3 Mar 2026 23:50:54 +0000 Subject: [PATCH] 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 --- completions/mcpctl.bash | 22 +- completions/mcpctl.fish | 15 +- src/cli/src/commands/config.ts | 2 +- src/cli/src/commands/console/audit-app.tsx | 458 ++++++++++++++++++ src/cli/src/commands/console/audit-types.ts | 69 +++ .../commands/console/components/timeline.tsx | 2 +- src/cli/src/commands/console/index.ts | 17 +- src/cli/src/commands/console/unified-app.tsx | 60 ++- src/cli/src/commands/create.ts | 6 +- src/cli/src/commands/delete.ts | 2 +- src/cli/src/commands/get.ts | 2 +- src/mcpd/src/bootstrap/system-project.ts | 71 +++ src/mcpd/src/main.ts | 6 +- .../repositories/audit-event.repository.ts | 55 ++- src/mcpd/src/repositories/interfaces.ts | 11 + src/mcpd/src/routes/audit-events.ts | 10 + src/mcpd/src/routes/prompts.ts | 9 +- src/mcpd/src/services/audit-event.service.ts | 16 + src/mcpd/src/services/prompt.service.ts | 57 ++- src/mcpd/src/validation/resource-rules.ts | 71 +++ .../validation/rules/system-prompt-vars.ts | 41 ++ src/mcpd/tests/audit-event-routes.test.ts | 57 +++ .../tests/bootstrap-system-project.test.ts | 11 +- src/mcpd/tests/resource-rules.test.ts | 88 ++++ .../tests/services/prompt-service.test.ts | 10 +- .../tests/system-prompt-validation.test.ts | 238 +++++++++ src/mcplocal/src/audit/types.ts | 4 +- src/mcplocal/src/gate/llm-selector.ts | 7 +- src/mcplocal/src/http/project-mcp-endpoint.ts | 18 +- src/mcplocal/src/llm/pagination.ts | 8 +- src/mcplocal/src/llm/processor.ts | 14 +- src/mcplocal/src/proxymodel/executor.ts | 7 +- src/mcplocal/src/proxymodel/plugins/gate.ts | 3 +- .../src/proxymodel/stages/paginate.ts | 8 +- .../src/proxymodel/stages/summarize-tree.ts | 8 +- src/mcplocal/src/proxymodel/types.ts | 6 + src/mcplocal/src/router.ts | 62 ++- src/mcplocal/tests/proxymodel-stages.test.ts | 1 + src/mcplocal/tests/proxymodel-types.test.ts | 1 + src/mcplocal/tests/router.test.ts | 89 ++++ .../tests/smoke/system-prompts.test.ts | 207 ++++++++ .../tests/system-prompt-fetching.test.ts | 160 ++++++ 42 files changed, 1932 insertions(+), 77 deletions(-) create mode 100644 src/cli/src/commands/console/audit-app.tsx create mode 100644 src/cli/src/commands/console/audit-types.ts create mode 100644 src/mcpd/src/validation/resource-rules.ts create mode 100644 src/mcpd/src/validation/rules/system-prompt-vars.ts create mode 100644 src/mcpd/tests/resource-rules.test.ts create mode 100644 src/mcpd/tests/system-prompt-validation.test.ts create mode 100644 src/mcplocal/tests/smoke/system-prompts.test.ts create mode 100644 src/mcplocal/tests/system-prompt-fetching.test.ts diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index f02fcc4..f35cb50 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -119,10 +119,10 @@ _mcpctl() { COMPREPLY=($(compgen -W "-h --help" -- "$cur")) ;; claude) - COMPREPLY=($(compgen -W "--project -o --output --inspect --stdout -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout -h --help" -- "$cur")) ;; claude-generate) - COMPREPLY=($(compgen -W "--project -o --output --inspect --stdout -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout -h --help" -- "$cur")) ;; setup) COMPREPLY=($(compgen -W "-h --help" -- "$cur")) @@ -138,11 +138,11 @@ _mcpctl() { return ;; get) if [[ -z "$resource_type" ]]; then - COMPREPLY=($(compgen -W "$resources -o --output --project -A --all -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "$resources -o --output -p --project -A --all -h --help" -- "$cur")) else local names names=$(_mcpctl_resource_names "$resource_type") - COMPREPLY=($(compgen -W "$names -o --output --project -A --all -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "$names -o --output -p --project -A --all -h --help" -- "$cur")) fi return ;; describe) @@ -156,11 +156,11 @@ _mcpctl() { return ;; delete) if [[ -z "$resource_type" ]]; then - COMPREPLY=($(compgen -W "$resources --project -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "$resources -p --project -h --help" -- "$cur")) else local names names=$(_mcpctl_resource_names "$resource_type") - COMPREPLY=($(compgen -W "$names --project -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "$names -p --project -h --help" -- "$cur")) fi return ;; logs) @@ -197,13 +197,13 @@ _mcpctl() { COMPREPLY=($(compgen -W "--subject --binding --operation --force -h --help" -- "$cur")) ;; prompt) - COMPREPLY=($(compgen -W "--project --content --content-file --priority --link -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project --content --content-file --priority --link -h --help" -- "$cur")) ;; serverattachment) - COMPREPLY=($(compgen -W "--project -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -h --help" -- "$cur")) ;; promptrequest) - COMPREPLY=($(compgen -W "--project --content --content-file --priority -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project --content --content-file --priority -h --help" -- "$cur")) ;; *) COMPREPLY=($(compgen -W "-h --help" -- "$cur")) @@ -276,9 +276,9 @@ _mcpctl() { if [[ $((cword - subcmd_pos)) -eq 1 ]]; then local names names=$(mcpctl get projects -o json 2>/dev/null | jq -r '.[].name' 2>/dev/null) - COMPREPLY=($(compgen -W "$names --stdin-mcp -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "$names --stdin-mcp --audit -h --help" -- "$cur")) else - COMPREPLY=($(compgen -W "--stdin-mcp -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "--stdin-mcp --audit -h --help" -- "$cur")) fi return ;; help) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index bbb11c7..136ed6c 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -265,13 +265,13 @@ complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_s complete -c mcpctl -n "__mcpctl_subcmd_active config view" -s o -l output -d 'output format (json, yaml)' -x # config claude options -complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l project -d 'Project name' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s o -l output -d 'Output file path' -x complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l inspect -d 'Include mcpctl-inspect MCP server for traffic monitoring' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l stdout -d 'Print to stdout instead of writing a file' # config claude-generate options -complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l project -d 'Project name' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s o -l output -d 'Output file path' -x complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l inspect -d 'Include mcpctl-inspect MCP server for traffic monitoring' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout -d 'Print to stdout instead of writing a file' @@ -338,17 +338,17 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create rbac" -l operation -d 'Oper complete -c mcpctl -n "__mcpctl_subcmd_active create rbac" -l force -d 'Update if already exists' # create prompt options -complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -l project -d 'Project name to scope the prompt to' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -s p -l project -d 'Project name to scope the prompt to' -xa '(__mcpctl_project_names)' complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -l content -d 'Prompt content text' -x complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -l content-file -d 'Read prompt content from file' -rF complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -l priority -d 'Priority 1-10 (default: 5, higher = more important)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -l link -d 'Link to MCP resource (format: project/server:uri)' -x # create serverattachment options -complete -c mcpctl -n "__mcpctl_subcmd_active create serverattachment" -l project -d 'Project name' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active create serverattachment" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' # create promptrequest options -complete -c mcpctl -n "__mcpctl_subcmd_active create promptrequest" -l project -d 'Project name to scope the prompt request to' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active create promptrequest" -s p -l project -d 'Project name to scope the prompt request to' -xa '(__mcpctl_project_names)' complete -c mcpctl -n "__mcpctl_subcmd_active create promptrequest" -l content -d 'Prompt content text' -x complete -c mcpctl -n "__mcpctl_subcmd_active create promptrequest" -l content-file -d 'Read prompt content from file' -rF complete -c mcpctl -n "__mcpctl_subcmd_active create promptrequest" -l priority -d 'Priority 1-10 (default: 5, higher = more important)' -x @@ -361,7 +361,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from login" -l mcpd-url -d 'mcpd U # get options complete -c mcpctl -n "__fish_seen_subcommand_from get" -s o -l output -d 'output format (table, json, yaml)' -x -complete -c mcpctl -n "__fish_seen_subcommand_from get" -l project -d 'Filter by project' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__fish_seen_subcommand_from get" -s p -l project -d 'Filter by project' -xa '(__mcpctl_project_names)' complete -c mcpctl -n "__fish_seen_subcommand_from get" -s A -l all -d 'Show all (including project-scoped) resources' # describe options @@ -369,7 +369,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from describe" -s o -l output -d ' complete -c mcpctl -n "__fish_seen_subcommand_from describe" -l show-values -d 'Show secret values (default: masked)' # delete options -complete -c mcpctl -n "__fish_seen_subcommand_from delete" -l project -d 'Project name (for serverattachment)' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__fish_seen_subcommand_from delete" -s p -l project -d 'Project name (for serverattachment)' -xa '(__mcpctl_project_names)' # logs options complete -c mcpctl -n "__fish_seen_subcommand_from logs" -s t -l tail -d 'Number of lines to show' -x @@ -391,6 +391,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from restore" -s c -l conflict -d # console options complete -c mcpctl -n "__fish_seen_subcommand_from console" -l stdin-mcp -d 'Run inspector as MCP server over stdin/stdout (for Claude)' +complete -c mcpctl -n "__fish_seen_subcommand_from console" -l audit -d 'Browse audit events from mcpd' # logs: takes a server/instance name complete -c mcpctl -n "__fish_seen_subcommand_from logs; and __mcpctl_needs_arg_for logs" -a '(__mcpctl_instance_names)' -d 'Server name' diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 3e35dd0..f2b0938 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -90,7 +90,7 @@ export function createConfigCommand(deps?: Partial, apiDeps?: const cmd = config .command(name) .description(hidden ? '' : 'Generate .mcp.json that connects a project via mcpctl mcp bridge') - .option('--project ', 'Project name') + .option('-p, --project ', 'Project name') .option('-o, --output ', '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') diff --git a/src/cli/src/commands/console/audit-app.tsx b/src/cli/src/commands/console/audit-app.tsx new file mode 100644 index 0000000..adacf00 --- /dev/null +++ b/src/cli/src/commands/console/audit-app.tsx @@ -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(url: string, token?: string): Promise { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const headers: Record = { '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 { + 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[] { + 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 ( + + Sessions + {projectFilter ? `project: ${projectFilter}` : 'all projects'} + + + {selectedIdx === -1 ? '\u25B8 ' : ' '}All ({sessions.reduce((s, x) => s + x.eventCount, 0)} events) + + {sessions.map((s, i) => { + const isSel = i === selectedIdx; + return ( + + {isSel ? '\u25B8 ' : ' '}{trunc(s.sessionId.slice(0, 12), 12)} {s.projectName} ({s.eventCount}) + + ); + })} + {sessions.length === 0 && No sessions} + + ); +} + +// ── 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 ( + + + Events ({events.length}{focusedIdx >= 0 ? ` \u00B7 #${focusedIdx + 1}` : ' \u00B7 following'}) + + {visible.length === 0 && ( + + {' No audit events yet\u2026'} + + )} + {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 ( + + {isFocused ? '\u25B8' : ' '} + {formatTime(event.timestamp)} + {verified} + + {trunc(kindLabel, 9).padEnd(9)} + {event.serverName && [{trunc(event.serverName, 14)}]} + {trunc(summary, 60)} + + ); + })} + + ); +} + +// ── 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 ( + + + {kindLabel} Detail (line {scrollOffset + 1}/{lines.length}) + + {visible.map((line, i) => ( + {line} + ))} + + ); +} + +// ── 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({ + 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 ( + + Audit Console + Connecting to mcpd{'\u2026'} + + ); + } + + if (state.phase === 'error') { + return ( + + Audit Console — Error + {state.error} + Check mcpd is running and accessible at {mcpdUrl} + + ); + } + + // Detail view + if (state.detailEvent) { + return ( + + + + + + [{'\u2191\u2193'}] scroll [PgUp/Dn] page [Esc] back [q] quit + + + ); + } + + // Main view + return ( + + {/* Header */} + + Audit Console + {state.totalEvents} total events + {state.kindFilter && filter: {EVENT_KIND_LABELS[state.kindFilter] ?? state.kindFilter}} + + + {/* Body */} + + {state.showSidebar && ( + + )} + + + + {/* Footer */} + + + {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`} + + + + ); +} + +// ── Render entry point ── + +export interface AuditRenderOptions { + mcpdUrl: string; + token?: string; + projectFilter?: string; +} + +export async function renderAuditConsole(opts: AuditRenderOptions): Promise { + const instance = render( + , + ); + await instance.waitUntilExit(); +} diff --git a/src/cli/src/commands/console/audit-types.ts b/src/cli/src/commands/console/audit-types.ts new file mode 100644 index 0000000..90a79a7 --- /dev/null +++ b/src/cli/src/commands/console/audit-types.ts @@ -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; +} + +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 = { + '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 = { + 'pipeline_execution': 'PIPELINE', + 'stage_execution': 'STAGE', + 'gate_decision': 'GATE', + 'prompt_delivery': 'PROMPT', + 'tool_call_trace': 'TOOL', + 'rbac_decision': 'RBAC', + 'session_bind': 'BIND', +}; diff --git a/src/cli/src/commands/console/components/timeline.tsx b/src/cli/src/commands/console/components/timeline.tsx index 1be2851..8e649ed 100644 --- a/src/cli/src/commands/console/components/timeline.tsx +++ b/src/cli/src/commands/console/components/timeline.tsx @@ -37,7 +37,7 @@ export function Timeline({ events, height, focusedIdx, showProject }: TimelinePr return ( - Timeline ({events.length} events{focusedIdx >= 0 ? ` \u00B7 #${focusedIdx + 1}` : ''}) + Timeline ({events.length} events{focusedIdx >= 0 ? ` \u00B7 #${focusedIdx + 1}` : ' \u00B7 following'}) {visible.length === 0 && ( diff --git a/src/cli/src/commands/console/index.ts b/src/cli/src/commands/console/index.ts index 0a1a8bd..4a8e057 100644 --- a/src/cli/src/commands/console/index.ts +++ b/src/cli/src/commands/console/index.ts @@ -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) { diff --git a/src/cli/src/commands/console/unified-app.tsx b/src/cli/src/commands/console/unified-app.tsx index a67e618..3c16030 100644 --- a/src/cli/src/commands/console/unified-app.tsx +++ b/src/cli/src/commands/console/unified-app.tsx @@ -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 {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'} diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index bc06d0c..04ae514 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -384,7 +384,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { cmd.command('prompt') .description('Create an approved prompt') .argument('', 'Prompt name (lowercase alphanumeric with hyphens)') - .option('--project ', 'Project name to scope the prompt to') + .option('-p, --project ', 'Project name to scope the prompt to') .option('--content ', 'Prompt content text') .option('--content-file ', 'Read prompt content from file') .option('--priority ', '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 name') - .option('--project ', 'Project name') + .option('-p, --project ', '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('', 'Prompt request name (lowercase alphanumeric with hyphens)') - .option('--project ', 'Project name to scope the prompt request to') + .option('-p, --project ', 'Project name to scope the prompt request to') .option('--content ', 'Prompt content text') .option('--content-file ', 'Read prompt content from file') .option('--priority ', 'Priority 1-10 (default: 5, higher = more important)') diff --git a/src/cli/src/commands/delete.ts b/src/cli/src/commands/delete.ts index 45a15bd..cb73fd2 100644 --- a/src/cli/src/commands/delete.ts +++ b/src/cli/src/commands/delete.ts @@ -14,7 +14,7 @@ export function createDeleteCommand(deps: DeleteCommandDeps): Command { .description('Delete a resource (server, instance, secret, project, user, group, rbac)') .argument('', 'resource type') .argument('', 'resource ID or name') - .option('--project ', 'Project name (for serverattachment)') + .option('-p, --project ', 'Project name (for serverattachment)') .action(async (resourceArg: string, idOrName: string, opts: { project?: string }) => { const resource = resolveResource(resourceArg); diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index 16bd43b..2ebf8e1 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -276,7 +276,7 @@ export function createGetCommand(deps: GetCommandDeps): Command { .argument('', 'resource type (servers, projects, instances, all)') .argument('[id]', 'specific resource ID or name') .option('-o, --output ', 'output format (table, json, yaml)', 'table') - .option('--project ', 'Filter by project') + .option('-p, --project ', '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); diff --git a/src/mcpd/src/bootstrap/system-project.ts b/src/mcpd/src/bootstrap/system-project.ts index 806dd58..5530490 100644 --- a/src/mcpd/src/bootstrap/system-project.ts +++ b/src/mcpd/src/bootstrap/system-project.ts @@ -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 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; +} diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index d57b5b1..547c369 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -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 { 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({ diff --git a/src/mcpd/src/repositories/audit-event.repository.ts b/src/mcpd/src/repositories/audit-event.repository.ts index c2f4645..19f9b9c 100644 --- a/src/mcpd/src/repositories/audit-event.repository.ts +++ b/src/mcpd/src/repositories/audit-event.repository.ts @@ -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 { + 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(); + 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 { + 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 { diff --git a/src/mcpd/src/repositories/interfaces.ts b/src/mcpd/src/repositories/interfaces.ts index a08daeb..98bebbe 100644 --- a/src/mcpd/src/repositories/interfaces.ts +++ b/src/mcpd/src/repositories/interfaces.ts @@ -75,9 +75,20 @@ export interface AuditEventCreateInput { payload: Record; } +export interface AuditSessionSummary { + sessionId: string; + projectName: string; + firstSeen: Date; + lastSeen: Date; + eventCount: number; + eventKinds: string[]; +} + export interface IAuditEventRepository { findAll(filter?: AuditEventFilter): Promise; findById(id: string): Promise; createMany(events: AuditEventCreateInput[]): Promise; count(filter?: AuditEventFilter): Promise; + listSessions(filter?: { projectName?: string; limit?: number; offset?: number }): Promise; + countSessions(filter?: { projectName?: string }): Promise; } diff --git a/src/mcpd/src/routes/audit-events.ts b/src/mcpd/src/routes/audit-events.ts index 0c6ed86..6fca15c 100644 --- a/src/mcpd/src/routes/audit-events.ts +++ b/src/mcpd/src/routes/audit-events.ts @@ -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); + }); } diff --git a/src/mcpd/src/routes/prompts.ts b/src/mcpd/src/routes/prompts.ts index cf912ae..61ddf08 100644 --- a/src/mcpd/src/routes/prompts.ts +++ b/src/mcpd/src/routes/prompts.ts @@ -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 => { + 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) ── diff --git a/src/mcpd/src/services/audit-event.service.ts b/src/mcpd/src/services/audit-event.service.ts index 13f8a0c..a3383d7 100644 --- a/src/mcpd/src/services/audit-event.service.ts +++ b/src/mcpd/src/services/audit-event.service.ts @@ -38,6 +38,22 @@ export class AuditEventService { return this.repo.createMany(events); } + async listSessions(params?: { projectName?: string; limit?: number; offset?: number }): Promise<{ sessions: Awaited>; 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 = {}; diff --git a/src/mcpd/src/services/prompt.service.ts b/src/mcpd/src/services/prompt.service.ts index 2c69e47..29f2cdd 100644 --- a/src/mcpd/src/services/prompt.service.ts +++ b/src/mcpd/src/services/prompt.service.ts @@ -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 { + 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 { @@ -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 { 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 { + async deletePrompt(id: string): Promise { 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); diff --git a/src/mcpd/src/validation/resource-rules.ts b/src/mcpd/src/validation/resource-rules.ts new file mode 100644 index 0000000..ff20074 --- /dev/null +++ b/src/mcpd/src/validation/resource-rules.ts @@ -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 { + /** 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; +} + +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; +} + +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( + resource: string, + data: T, + meta: RuleMeta, + ctx: RuleContext, + ): Promise { + 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 }; + } +} diff --git a/src/mcpd/src/validation/rules/system-prompt-vars.ts b/src/mcpd/src/validation/rules/system-prompt-vars.ts new file mode 100644 index 0000000..4b88df1 --- /dev/null +++ b/src/mcpd/src/validation/rules/system-prompt-vars.ts @@ -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 = { + name: 'system-prompt-template-vars', + resource: 'prompt', + + match(_data: PromptData, meta: RuleMeta): boolean { + return meta.isSystemResource === true; + }, + + async validate(data: PromptData, _ctx: RuleContext): Promise { + 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 }; + }, +}; diff --git a/src/mcpd/tests/audit-event-routes.test.ts b/src/mcpd/tests/audit-event-routes.test.ts index 88ea834..b2b307f 100644 --- a/src/mcpd/tests/audit-event-routes.test.ts +++ b/src/mcpd/tests/audit-event-routes.test.ts @@ -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); + }); + }); }); diff --git a/src/mcpd/tests/bootstrap-system-project.test.ts b/src/mcpd/tests/bootstrap-system-project.test.ts index e4f97c3..f857c36 100644 --- a/src/mcpd/tests/bootstrap-system-project.test.ts +++ b/src/mcpd/tests/bootstrap-system-project.test.ts @@ -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); + } } }); diff --git a/src/mcpd/tests/resource-rules.test.ts b/src/mcpd/tests/resource-rules.test.ts new file mode 100644 index 0000000..a57e363 --- /dev/null +++ b/src/mcpd/tests/resource-rules.test.ts @@ -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, +): 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' }); + }); +}); diff --git a/src/mcpd/tests/services/prompt-service.test.ts b/src/mcpd/tests/services/prompt-service.test.ts index ccdeda2..80a2f9d 100644 --- a/src/mcpd/tests/services/prompt-service.test.ts +++ b/src/mcpd/tests/services/prompt-service.test.ts @@ -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 () => { diff --git a/src/mcpd/tests/system-prompt-validation.test.ts b/src/mcpd/tests/system-prompt-validation.test.ts new file mode 100644 index 0000000..95f96e5 --- /dev/null +++ b/src/mcpd/tests/system-prompt-validation.test.ts @@ -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 { + 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 { + 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'); + }); + }); +}); diff --git a/src/mcplocal/src/audit/types.ts b/src/mcplocal/src/audit/types.ts index 42a2bfd..280c408 100644 --- a/src/mcplocal/src/audit/types.ts +++ b/src/mcplocal/src/audit/types.ts @@ -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'; diff --git a/src/mcplocal/src/gate/llm-selector.ts b/src/mcplocal/src/gate/llm-selector.ts index 1d61931..016b211 100644 --- a/src/mcplocal/src/gate/llm-selector.ts +++ b/src/mcplocal/src/gate/llm-selector.ts @@ -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 { - 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(', ')} diff --git a/src/mcplocal/src/http/project-mcp-endpoint.ts b/src/mcplocal/src/http/project-mcp-endpoint.ts index 07c0033..cfeb833 100644 --- a/src/mcplocal/src/http/project-mcp-endpoint.ts +++ b/src/mcplocal/src/http/project-mcp-endpoint.ts @@ -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 }, + }); }, }); diff --git a/src/mcplocal/src/llm/pagination.ts b/src/mcplocal/src/llm/pagination.ts index ac6d553..33c2b81 100644 --- a/src/mcplocal/src/llm/pagination.ts +++ b/src/mcplocal/src/llm/pagination.ts @@ -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 = {}, 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, diff --git a/src/mcplocal/src/llm/processor.ts b/src/mcplocal/src/llm/processor.ts index 8faf220..d75f942 100644 --- a/src/mcplocal/src/llm/processor.ts +++ b/src/mcplocal/src/llm/processor.ts @@ -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, diff --git a/src/mcplocal/src/proxymodel/executor.ts b/src/mcplocal/src/proxymodel/executor.ts index 32816dc..9b3f69d 100644 --- a/src/mcplocal/src/proxymodel/executor.ts +++ b/src/mcplocal/src/proxymodel/executor.ts @@ -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 fallback; + const ctx: StageContext = { contentType: opts.contentType, sourceName: opts.sourceName, @@ -81,6 +85,7 @@ export async function executePipeline(opts: ExecuteOptions): Promise { 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 }, ); }); diff --git a/src/mcplocal/src/proxymodel/types.ts b/src/mcplocal/src/proxymodel/types.ts index 5c46a57..83fbf67 100644 --- a/src/mcplocal/src/proxymodel/types.ts +++ b/src/mcplocal/src/proxymodel/types.ts @@ -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; + // ── 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; } diff --git a/src/mcplocal/src/router.ts b/src/mcplocal/src/router.ts index bc0566c..119cf34 100644 --- a/src/mcplocal/src/router.ts +++ b/src/mcplocal/src/router.ts @@ -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 { + 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 | undefined; const toolName = params?.['name'] as string | undefined; const toolArgs = (params?.['arguments'] ?? {}) as Record; + 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; } diff --git a/src/mcplocal/tests/proxymodel-stages.test.ts b/src/mcplocal/tests/proxymodel-stages.test.ts index cfeb6fc..d58a8b7 100644 --- a/src/mcplocal/tests/proxymodel-stages.test.ts +++ b/src/mcplocal/tests/proxymodel-stages.test.ts @@ -47,6 +47,7 @@ function mockCtx(original: string, config: Record = {}, llmAvai llm: mockLlm, cache: mockCache, log: mockLog, + getSystemPrompt: async (_name: string, fallback: string) => fallback, config, }; } diff --git a/src/mcplocal/tests/proxymodel-types.test.ts b/src/mcplocal/tests/proxymodel-types.test.ts index 3132c9a..2983475 100644 --- a/src/mcplocal/tests/proxymodel-types.test.ts +++ b/src/mcplocal/tests/proxymodel-types.test.ts @@ -136,6 +136,7 @@ function createMockContext(original: string): StageContext { llm: mockLlm, cache: mockCache, log: mockLog, + getSystemPrompt: async (_name: string, fallback: string) => fallback, config: {}, }; } diff --git a/src/mcplocal/tests/router.test.ts b/src/mcplocal/tests/router.test.ts index 59f74d7..623b968 100644 --- a/src/mcplocal/tests/router.test.ts +++ b/src/mcplocal/tests/router.test.ts @@ -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> = []; + const mockCollector = { emit: vi.fn((e: Record) => 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; + 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 => { + 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> = []; + const mockCollector = { emit: vi.fn((e: Record) => 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; + expect(payload['error']).toBe('Something broke'); + expect(payload['argKeys']).toBe('a, b'); + }); + }); }); diff --git a/src/mcplocal/tests/smoke/system-prompts.test.ts b/src/mcplocal/tests/smoke/system-prompts.test.ts new file mode 100644 index 0000000..dd878a1 --- /dev/null +++ b/src/mcplocal/tests/smoke/system-prompts.test.ts @@ -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(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 = { + '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>('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('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('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('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('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('GET', `/api/v1/prompts/${prompt!.id}`); + expect(getResult.status).toBe(200); + expect(getResult.data.name).toBe('llm-gate-context-selector'); + }); +}); diff --git a/src/mcplocal/tests/system-prompt-fetching.test.ts b/src/mcplocal/tests/system-prompt-fetching.test.ts new file mode 100644 index 0000000..545b34b --- /dev/null +++ b/src/mcplocal/tests/system-prompt-fetching.test.ts @@ -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 = {}, + 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(); + 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}}'); + }); + }); +});