diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 0a6994f..cd7d919 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -5,8 +5,8 @@ _mcpctl() { local cur prev words cword _init_completion || return - local commands="status statusline login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate" - local project_commands="get describe delete logs create edit attach-server detach-server favourites" + local commands="status statusline login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors trace backup approve review skills console cache provider test migrate rotate" + local project_commands="get describe delete logs create edit trace attach-server detach-server favourites" local global_opts="-v --version --daemon-url --direct -p --project -h --help" local resources="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks all" local resource_aliases="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm task tasks inference-task" @@ -289,6 +289,9 @@ _mcpctl() { errors) COMPREPLY=($(compgen -W "-n --limit -h --help" -- "$cur")) return ;; + trace) + COMPREPLY=($(compgen -W "-o --output --strict -h --help" -- "$cur")) + return ;; backup) local backup_sub=$(_mcpctl_get_subcmd $subcmd_pos) if [[ -z "$backup_sub" ]]; then diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 76f4a8f..6f1241a 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -4,8 +4,8 @@ # Erase any stale completions from previous versions complete -c mcpctl -e -set -l commands status statusline login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate -set -l project_commands get describe delete logs create edit attach-server detach-server favourites +set -l commands status statusline login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors trace backup approve review skills console cache provider test migrate rotate +set -l project_commands get describe delete logs create edit trace attach-server detach-server favourites # Disable file completions by default complete -c mcpctl -f @@ -237,6 +237,7 @@ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a patch -d 'Patch a resource field (e.g. mcpctl patch project myproj llmProvider=none)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a passwd -d 'Change a user password (your own when called without an argument)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a errors -d 'Show recent mcpd error/fatal log entries' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a trace -d 'Show the stage-by-stage timeline for one MCP request' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a backup -d 'Git-based backup status and management' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a approve -d 'Approve a pending prompt request (atomic: delete request, create prompt)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a review -d 'Triage proposed prompts and skills' @@ -255,6 +256,7 @@ complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a logs -d 'Get logs from an MCP server instance' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a create -d 'Create a resource (server, secret, secretbackend, llm, agent, project, user, group, rbac, serverattachment, prompt)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a edit -d 'Edit a resource in your default editor (server, project)' +complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a trace -d 'Show the stage-by-stage timeline for one MCP request' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a attach-server -d 'Attach a server to a project (requires --project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a detach-server -d 'Detach a server from a project (requires --project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a favourites -d 'Inspect / derive a project\'s favourite-index tool shortlist (requires --project)' @@ -678,6 +680,10 @@ complete -c mcpctl -n "__fish_seen_subcommand_from chat-llm" -l async -d 'Enqueu # errors options complete -c mcpctl -n "__fish_seen_subcommand_from errors" -s n -l limit -d 'max entries to show (default 50)' -x +# trace options +complete -c mcpctl -n "__fish_seen_subcommand_from trace" -s o -l output -d 'table (default) or json' -x +complete -c mcpctl -n "__fish_seen_subcommand_from trace" -l strict -d 'exit non-zero if the trace contains an error or a degraded step' + # 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' diff --git a/scripts/generate-completions.ts b/scripts/generate-completions.ts index 497b0a4..639b4f7 100644 --- a/scripts/generate-completions.ts +++ b/scripts/generate-completions.ts @@ -70,7 +70,7 @@ const PROJECT_ONLY_COMMANDS = new Set(['attach-server', 'detach-server', 'favour /** Commands that appear in BOTH project and non-project context. */ const PROJECT_SCOPED_COMMANDS = new Set([ - 'get', 'describe', 'delete', 'logs', 'create', 'edit', 'help', + 'get', 'describe', 'delete', 'logs', 'create', 'edit', 'help', 'trace', ]); /** Completely hidden commands (never shown in completions). */ diff --git a/src/cli/src/commands/trace.ts b/src/cli/src/commands/trace.ts new file mode 100644 index 0000000..9c25042 --- /dev/null +++ b/src/cli/src/commands/trace.ts @@ -0,0 +1,171 @@ +import { Command } from 'commander'; +import type { ApiClient } from '../api-client.js'; + +export interface TraceCommandDeps { + client: ApiClient; + log: (...args: string[]) => void; +} + +/** Mirrors mcplocal's AuditEvent rows as stored by mcpd. */ +interface AuditEvent { + timestamp: string; + sessionId: string; + projectName: string; + eventKind: string; + source: string; + serverName?: string | null; + correlationId?: string | null; + userName?: string | null; + payload: Record; +} + +function num(v: unknown): number | null { + return typeof v === 'number' && Number.isFinite(v) ? v : null; +} + +function str(v: unknown): string | null { + return typeof v === 'string' && v.length > 0 ? v : null; +} + +function clock(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return '--:--:--'; + const p = (n: number): string => String(n).padStart(2, '0'); + return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; +} + +function bytes(n: number): string { + if (n < 1024) return `${String(n)}B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}kB`; + return `${(n / (1024 * 1024)).toFixed(1)}MB`; +} + +/** One line of detail per event kind — what you actually want to see. */ +function detailOf(e: AuditEvent): string { + const p = e.payload; + switch (e.eventKind) { + case 'stage_execution': { + const parts = [str(p['stage']) ?? 'stage']; + const ms = num(p['durationMs']); + if (ms !== null) parts.push(`${String(ms)}ms`); + const inSize = num(p['inputSize']); + const outSize = num(p['outputSize']); + if (inSize !== null && outSize !== null) parts.push(`${bytes(inSize)} → ${bytes(outSize)}`); + const sections = num(p['sectionCount']); + if (sections !== null && sections > 0) parts.push(`${String(sections)} sections`); + return parts.join(' '); + } + case 'pipeline_execution': { + const ms = num(p['totalDurationMs']); + const stages = num(p['stageCount']); + return `${String(stages ?? 0)} stages${ms === null ? '' : `, ${String(ms)}ms total`}`; + } + case 'tool_call_trace': { + const parts = [str(p['toolName']) ?? '(tool)']; + const ms = num(p['durationMs']); + if (ms !== null) parts.push(`${String(ms)}ms`); + const size = num(p['resultSizeBytes']); + if (size !== null) parts.push(bytes(size)); + return parts.join(' '); + } + case 'gate_decision': { + const trigger = str(p['trigger']) ?? 'gate'; + const matched = Array.isArray(p['matchedPrompts']) ? (p['matchedPrompts']).length : 0; + return `${trigger} · ${String(matched)} prompts`; + } + default: + return e.eventKind; + } +} + +function degradationOf(e: AuditEvent): string | null { + if (e.payload['degraded'] !== true) return null; + return str(e.payload['degradedReason']) ?? 'degraded'; +} + +function errorOf(e: AuditEvent): string | null { + return str(e.payload['error']); +} + +export function createTraceCommand(deps?: Partial): Command { + const log = deps?.log ?? ((...args: string[]): void => { console.log(...args); }); + + return new Command('trace') + .argument('', 'trace code from an error message or degradation notice') + .description('Show the stage-by-stage timeline for one MCP request') + .option('-o, --output ', 'table (default) or json') + .option('--strict', 'exit non-zero if the trace contains an error or a degraded step') + .action(async (code: string, opts: { output?: string; strict?: boolean }) => { + const client = deps?.client; + if (!client) throw new Error('trace: no API client configured'); + + const res = await client.get<{ events?: AuditEvent[]; total?: number }>( + `/api/v1/audit/events?correlationId=${encodeURIComponent(code)}&limit=500`, + ); + const events = (res.events ?? []) + .slice() + .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + + if (events.length === 0) { + log(`No trace found for '${code}'.`); + log(''); + log('Traces are written by mcplocal and stored by mcpd. If the request is very'); + log('recent the batch may not have flushed yet (up to 5s); if it is very old it'); + log('may predate trace codes. Check the code was copied exactly — they are 8'); + log('characters, no I/L/O/U.'); + if (opts.strict === true) process.exitCode = 1; + return; + } + + if (opts.output === 'json') { + log(JSON.stringify(events, null, 2)); + return; + } + + const first = events[0]!; + const start = new Date(first.timestamp).getTime(); + const call = events.find((e) => e.eventKind === 'tool_call_trace'); + const headline = call ? detailOf(call) : first.eventKind; + + log(`Trace ${code} project ${first.projectName} session ${first.sessionId.slice(0, 8)}…`); + if (first.userName) log(`user ${first.userName}`); + log(`${headline}`); + log(''); + log(' TIME Δms EVENT DETAIL'); + + let degradedCount = 0; + let errorCount = 0; + let slowest: { name: string; ms: number } | null = null; + + for (const e of events) { + const delta = new Date(e.timestamp).getTime() - start; + const degraded = degradationOf(e); + const err = errorOf(e); + if (degraded !== null) degradedCount++; + if (err !== null) errorCount++; + + const ms = num(e.payload['durationMs']) ?? num(e.payload['totalDurationMs']); + const label = str(e.payload['stage']) ?? e.eventKind; + if (ms !== null && (slowest === null || ms > slowest.ms)) slowest = { name: label, ms }; + + const marks = [ + degraded !== null ? `⚠ ${degraded}` : null, + err !== null ? `✗ ${err}` : null, + ].filter((x): x is string => x !== null).join(' '); + + log( + ` ${clock(e.timestamp)} ${String(delta).padStart(6)} ` + + `${e.eventKind.padEnd(19)} ${detailOf(e)}${marks ? ` ${marks}` : ''}`, + ); + } + + log(''); + const summary: string[] = [`${String(events.length)} events`]; + if (degradedCount > 0) summary.push(`⚠ ${String(degradedCount)} degraded`); + if (errorCount > 0) summary.push(`✗ ${String(errorCount)} error${errorCount === 1 ? '' : 's'}`); + if (slowest !== null) summary.push(`slowest: ${slowest.name} (${String(slowest.ms)}ms)`); + log(summary.join(' · ')); + + if (opts.strict === true && (degradedCount > 0 || errorCount > 0)) process.exitCode = 1; + }); +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 47c2070..bb4ade9 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -29,6 +29,7 @@ import { createSkillsCommand } from './commands/skills.js'; import { createStatuslineCommand } from './commands/statusline.js'; import { createPasswdCommand } from './commands/passwd.js'; import { createErrorsCommand } from './commands/errors.js'; +import { createTraceCommand } from './commands/trace.js'; import { ApiClient, ApiError } from './api-client.js'; import { loadConfig } from './config/index.js'; import { loadCredentials } from './auth/index.js'; @@ -280,6 +281,11 @@ export function createProgram(): Command { log: (...args) => console.log(...args), })); + program.addCommand(createTraceCommand({ + client, + log: (...args) => console.log(...args), + })); + program.addCommand(createBackupCommand({ client, log: (...args) => console.log(...args), diff --git a/src/cli/tests/trace.test.ts b/src/cli/tests/trace.test.ts new file mode 100644 index 0000000..bc9f0df --- /dev/null +++ b/src/cli/tests/trace.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createTraceCommand } from '../src/commands/trace.js'; +import type { ApiClient } from '../src/api-client.js'; + +function clientReturning(events: unknown[]): ApiClient { + return { get: vi.fn(async () => ({ events, total: events.length })) } as unknown as ApiClient; +} + +function run(client: ApiClient, args: string[]): Promise { + const lines: string[] = []; + const cmd = createTraceCommand({ client, log: (...a: string[]) => { lines.push(a.join(' ')); } }); + return cmd.parseAsync(['node', 'trace', ...args]).then(() => lines); +} + +const base = { + sessionId: 'abcdef1234567890', projectName: 'sre', source: 'mcplocal', + correlationId: 'K3P7QW2M', userName: 'michal@itaz.eu', +}; + +describe('mcpctl trace', () => { + it('renders a waterfall ordered by time', async () => { + const lines = await run(clientReturning([ + { ...base, timestamp: '2026-08-25T22:00:02.000Z', eventKind: 'tool_call_trace', + payload: { toolName: 'docmost/search', durationMs: 461, resultSizeBytes: 14091, error: null } }, + { ...base, timestamp: '2026-08-25T22:00:00.000Z', eventKind: 'stage_execution', + payload: { stage: 'paginate', durationMs: 12, inputSize: 14091, outputSize: 5632, sectionCount: 2 } }, + ]), ['K3P7QW2M']); + + const out = lines.join('\n'); + expect(out).toContain('Trace K3P7QW2M'); + expect(out).toContain('project sre'); + expect(out).toContain('docmost/search'); + // Sorted: the stage at :00 must precede the tool_call at :02 even though + // the API returned them newest-first. + expect(out.indexOf('paginate')).toBeLessThan(out.indexOf('tool_call_trace')); + }); + + it('flags degraded steps and names the slowest', async () => { + const lines = await run(clientReturning([ + { ...base, timestamp: '2026-08-25T22:00:00.000Z', eventKind: 'stage_execution', + payload: { stage: 'summarize-tree', durationMs: 30001, inputSize: 100, outputSize: 50, + degraded: true, degradedReason: 'stage LLM budget of 30000ms exhausted' } }, + ]), ['K3P7QW2M']); + + const out = lines.join('\n'); + expect(out).toContain('⚠ stage LLM budget of 30000ms exhausted'); + expect(out).toContain('1 degraded'); + expect(out).toContain('slowest: summarize-tree (30001ms)'); + }); + + it('explains an empty result instead of printing nothing', async () => { + const lines = await run(clientReturning([]), ['ZZZZZZZZ']); + const out = lines.join('\n'); + expect(out).toContain("No trace found for 'ZZZZZZZZ'"); + expect(out).toContain('no I/L/O/U'); + }); + + it('--strict exits non-zero on a degraded trace', async () => { + process.exitCode = 0; + await run(clientReturning([ + { ...base, timestamp: '2026-08-25T22:00:00.000Z', eventKind: 'stage_execution', + payload: { stage: 'paginate', durationMs: 1, degraded: true, degradedReason: 'timed out' } }, + ]), ['K3P7QW2M', '--strict']); + expect(process.exitCode).toBe(1); + process.exitCode = 0; + }); + + it('queries mcpd by correlationId', async () => { + const client = clientReturning([]); + await run(client, ['K3P7QW2M']); + expect(client.get).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/audit/events?correlationId=K3P7QW2M'), + ); + }); +}); diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 013361e..ce2bd17 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -155,6 +155,9 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { if (segment === 'backup') return { kind: 'operation', operation: 'backup' }; if (segment === 'restore') return { kind: 'operation', operation: 'restore' }; if (segment === 'audit-logs' && method === 'DELETE') return { kind: 'operation', operation: 'audit-purge' }; + // Same operation guards the trace-event purge — both are bulk deletes of + // audit history and should not be separately grantable by accident. + if (url.startsWith('/api/v1/audit/events/purge')) return { kind: 'operation', operation: 'audit-purge' }; // /api/v1/secrets/migrate is a bulk cross-backend operation — treat as op, not a plain secret write. if (url.startsWith('/api/v1/secrets/migrate')) return { kind: 'operation', operation: 'migrate-secrets' }; // /api/v1/secretbackends/:id/rotate — manual rotation trigger. Operation so diff --git a/src/mcpd/src/repositories/audit-event.repository.ts b/src/mcpd/src/repositories/audit-event.repository.ts index f5e1aa9..47dd78f 100644 --- a/src/mcpd/src/repositories/audit-event.repository.ts +++ b/src/mcpd/src/repositories/audit-event.repository.ts @@ -170,6 +170,17 @@ export class AuditEventRepository implements IAuditEventRepository { }); return groups.length; } + + /** + * Prune old events. AuditEvent had no retention at all while the table was + * write-only; now that `mcpctl trace` reads it, it is worth bounding. + */ + async deleteOlderThan(cutoff: Date): Promise { + const result = await this.prisma.auditEvent.deleteMany({ + where: { timestamp: { lt: cutoff } }, + }); + return result.count; + } } function buildWhere(filter?: AuditEventFilter): Prisma.AuditEventWhereInput { diff --git a/src/mcpd/src/repositories/interfaces.ts b/src/mcpd/src/repositories/interfaces.ts index 6d94852..7fb87bd 100644 --- a/src/mcpd/src/repositories/interfaces.ts +++ b/src/mcpd/src/repositories/interfaces.ts @@ -108,6 +108,8 @@ export interface IAuditEventRepository { countSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date }): Promise; /** Rank tools by invocation count for a project (from tool_call_trace events). */ toolUsage(projectName: string, from: Date, sampleLimit?: number): Promise; + /** Delete events older than `cutoff`; returns the number removed. */ + deleteOlderThan(cutoff: Date): Promise; } // ── MCP Tokens ── diff --git a/src/mcpd/src/routes/audit-events.ts b/src/mcpd/src/routes/audit-events.ts index 1223ce1..32d56af 100644 --- a/src/mcpd/src/routes/audit-events.ts +++ b/src/mcpd/src/routes/audit-events.ts @@ -58,6 +58,15 @@ export function registerAuditEventRoutes(app: FastifyInstance, service: AuditEve return service.getById(request.params.id); }); + // POST /api/v1/audit/events/purge — drop events past the retention window. + // Mirrors /api/v1/audit-logs/purge: triggered, not scheduled, so an operator + // (or a cron) decides when a potentially large delete runs. + app.post('/api/v1/audit/events/purge', async (_request, reply) => { + const deleted = await service.purgeExpired(); + reply.code(200); + return { deleted }; + }); + // GET /api/v1/audit/tool-usage — rank tools by invocation count (for favourites) app.get<{ Querystring: { projectName?: string; window?: string; limit?: string } }>('/api/v1/audit/tool-usage', async (request, reply) => { const q = request.query; diff --git a/src/mcpd/src/services/audit-event.service.ts b/src/mcpd/src/services/audit-event.service.ts index 488a6a1..63f6f64 100644 --- a/src/mcpd/src/services/audit-event.service.ts +++ b/src/mcpd/src/services/audit-event.service.ts @@ -17,8 +17,31 @@ export interface AuditEventQueryParams { offset?: number; } +/** + * Default retention for trace/audit events. + * + * Shorter than AuditLog's 90 days: these are high-volume per-request telemetry + * (several rows per MCP call), not a record of administrative mutations. + */ +const DEFAULT_RETENTION_DAYS = 30; + export class AuditEventService { - constructor(private readonly repo: IAuditEventRepository) {} + constructor( + private readonly repo: IAuditEventRepository, + private readonly retentionDays: number = + Number(process.env['MCPD_AUDIT_EVENT_RETENTION_DAYS']) || DEFAULT_RETENTION_DAYS, + ) {} + + /** + * Drop events past the retention window. The table previously had no prune + * at all and grew unbounded; `mcpctl trace` now reads it, so bounding it + * matters more than it did when nothing consumed it. + */ + async purgeExpired(): Promise { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - this.retentionDays); + return this.repo.deleteOlderThan(cutoff); + } async list(params?: AuditEventQueryParams): Promise<{ events: AuditEvent[]; total: number }> { const filter = this.buildFilter(params);