diff --git a/src/cli/src/commands/status.ts b/src/cli/src/commands/status.ts index f6bb88a..a523d9d 100644 --- a/src/cli/src/commands/status.ts +++ b/src/cli/src/commands/status.ts @@ -44,6 +44,22 @@ interface ServerLlm { apiKeyRef?: { name: string; key: string } | null; } +/** + * SecretBackend row as returned by GET /api/v1/secretbackends, trimmed to the + * fields the status view needs. `tokenMeta.lastRotationError` is mcpd's record + * of the last credential-rotation failure (e.g. a dead OpenBao token). + */ +interface SecretBackendInfo { + name: string; + type: string; + isDefault?: boolean; + tokenMeta?: { + lastRotationError?: string | null; + lastRotationAt?: string | null; + validUntil?: string | null; + } | null; +} + /** * Result of a live "say hi" probe against a server LLM. `ok` says we got a * 200 + non-empty content back; `say` is the trimmed first 16 chars of the @@ -81,6 +97,8 @@ export interface StatusCommandDeps { * Always resolves (never throws) so one bad LLM doesn't sink the section. */ probeServerLlm: (mcpdUrl: string, name: string, token: string | null) => Promise; + /** Fetch SecretBackends from mcpd to surface backend health. Null on error. */ + fetchSecretBackends: (mcpdUrl: string, token: string | null) => Promise; isTTY: boolean; } @@ -224,6 +242,39 @@ function defaultFetchServerLlms(mcpdUrl: string, token: string | null): Promise< }); } +/** + * Fetch SecretBackends from mcpd to surface backend health (e.g. an OpenBao + * token that has gone dead). `tokenMeta.lastRotationError` is mcpd's own + * record of the last rotation failure. Returns null on any error so the + * section is simply omitted when mcpd is unreachable / unauthorized. + */ +function defaultFetchSecretBackends(mcpdUrl: string, token: string | null): Promise { + return new Promise((resolve) => { + let req: http.ClientRequest; + const headers: Record = { Accept: 'application/json' }; + if (token !== null) headers['Authorization'] = `Bearer ${token}`; + try { + req = httpDriverFor(mcpdUrl).get(`${mcpdUrl}/api/v1/secretbackends`, { timeout: 5000, headers }, (res) => { + if (res.statusCode !== 200) { resolve(null); res.resume(); return; } + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')) as SecretBackendInfo[]); + } catch { + resolve(null); + } + }); + }); + } catch { + resolve(null); + return; + } + req.on('error', () => resolve(null)); + req.on('timeout', () => { req.destroy(); resolve(null); }); + }); +} + /** * POST a tiny "say hi" prompt to /api/v1/llms//infer and decide if * the LLM actually serves inference. Returns ok=true when the response is @@ -334,6 +385,7 @@ const defaultDeps: StatusCommandDeps = { fetchModels: defaultFetchModels, fetchProviders: defaultFetchProviders, fetchServerLlms: defaultFetchServerLlms, + fetchSecretBackends: defaultFetchSecretBackends, probeServerLlm: defaultProbeServerLlm, isTTY: process.stdout.isTTY ?? false, }; @@ -396,7 +448,7 @@ function formatProviderStatus(name: string, info: ProvidersInfo, ansi: boolean): } export function createStatusCommand(deps?: Partial): Command { - const { configDeps, credentialsDeps, log, write, checkHealth, checkLlm, fetchModels, fetchProviders, fetchServerLlms, probeServerLlm, isTTY } = { ...defaultDeps, ...deps }; + const { configDeps, credentialsDeps, log, write, checkHealth, checkLlm, fetchModels, fetchProviders, fetchServerLlms, probeServerLlm, fetchSecretBackends, isTTY } = { ...defaultDeps, ...deps }; return new Command('status') .description('Show mcpctl status and connectivity') @@ -411,12 +463,13 @@ export function createStatusCommand(deps?: Partial): Command if (opts.output !== 'table') { // JSON/YAML: run everything in parallel, wait, output at once const token = creds?.token ?? null; - const [mcplocalReachable, mcpdReachable, llmStatus, providersInfo, serverLlms] = await Promise.all([ + const [mcplocalReachable, mcpdReachable, llmStatus, providersInfo, serverLlms, secretBackends] = await Promise.all([ checkHealth(config.mcplocalUrl), checkHealth(config.mcpdUrl), llmLabel ? checkLlm(config.mcplocalUrl) : Promise.resolve(null), multiProvider ? fetchProviders(config.mcplocalUrl) : Promise.resolve(null), fetchServerLlms(config.mcpdUrl, token), + fetchSecretBackends(config.mcpdUrl, token), ]); // Probe each server LLM in parallel — adds 0-2 sec to JSON mode but @@ -446,6 +499,7 @@ export function createStatusCommand(deps?: Partial): Command llmStatus, ...(providersInfo ? { providers: providersInfo } : {}), ...(serverLlmsWithHealth !== null ? { serverLlms: serverLlmsWithHealth } : {}), + ...(secretBackends !== null ? { secretBackends: secretBackends.map((b) => ({ name: b.name, type: b.type, healthy: !b.tokenMeta?.lastRotationError, error: b.tokenMeta?.lastRotationError ?? null })) } : {}), }; log(opts.output === 'json' ? formatJson(status) : formatYaml(status)); @@ -472,9 +526,11 @@ export function createStatusCommand(deps?: Partial): Command // a configured client-side provider. const token = creds?.token ?? null; const serverLlmsPromise = fetchServerLlms(config.mcpdUrl, token); + const secretBackendsPromise = fetchSecretBackends(config.mcpdUrl, token); if (!llmLabel) { log(`LLM: not configured (run 'mcpctl config setup')`); + await renderSecretBackendsSection(secretBackendsPromise, isTTY); await renderServerLlmsSection(serverLlmsPromise, config.mcpdUrl, token, isTTY); return; } @@ -539,9 +595,35 @@ export function createStatusCommand(deps?: Partial): Command } } + await renderSecretBackendsSection(secretBackendsPromise, isTTY); await renderServerLlmsSection(serverLlmsPromise, config.mcpdUrl, token, isTTY); }); + /** + * Print a "Secrets:" section listing each SecretBackend with health. A + * backend with a `tokenMeta.lastRotationError` (e.g. a dead OpenBao token) + * renders red with the error inline, so a recurrence is visible at a glance + * from `mcpctl status` instead of only in mcpd logs. Omitted when mcpd is + * unreachable/unauthorized (fetch returns null). + */ + async function renderSecretBackendsSection( + backendsPromise: Promise, + ansi: boolean, + ): Promise { + const backends = await backendsPromise; + if (backends === null || backends.length === 0) return; + const parts = backends.map((b) => { + const err = b.tokenMeta?.lastRotationError; + const tag = b.isDefault ? `${b.name}*` : b.name; + if (err) { + const short = err.split('\n')[0]?.slice(0, 80) ?? 'error'; + return ansi ? `${tag} ${RED}✗ ${short}${RESET}` : `${tag} ✗ ${short}`; + } + return ansi ? `${tag} ${GREEN}✓${RESET}` : `${tag} ✓`; + }); + log(`Secrets: ${parts.join(', ')}`); + } + /** * Print a "Server LLMs:" section listing mcpd-managed Llm rows by tier * with a per-LLM "say hi" liveness probe. Distinct from the mcplocal-side diff --git a/src/cli/tests/commands/status.test.ts b/src/cli/tests/commands/status.test.ts index 4313a7e..d627b55 100644 --- a/src/cli/tests/commands/status.test.ts +++ b/src/cli/tests/commands/status.test.ts @@ -29,6 +29,7 @@ function baseDeps(overrides?: Partial): Partial null, fetchServerLlms: async () => null, probeServerLlm: async () => ({ ok: true, ms: 12, say: 'hi' }), + fetchSecretBackends: async () => null, isTTY: false, ...overrides, }; @@ -45,6 +46,39 @@ afterEach(() => { }); describe('status command', () => { + it('shows a healthy secret backend in the Secrets line', async () => { + const cmd = createStatusCommand(baseDeps({ + fetchSecretBackends: async () => [ + { name: 'bao', type: 'openbao', isDefault: true, tokenMeta: { lastRotationError: null } }, + { name: 'default', type: 'plaintext' }, + ], + })); + await cmd.parseAsync([], { from: 'user' }); + const out = output.join('\n'); + expect(out).toContain('Secrets:'); + expect(out).toContain('bao* ✓'); + expect(out).toContain('default ✓'); + }); + + it('flags a dead secret-backend token in the Secrets line', async () => { + const cmd = createStatusCommand(baseDeps({ + fetchSecretBackends: async () => [ + { name: 'bao', type: 'openbao', isDefault: true, tokenMeta: { lastRotationError: 'BACKEND_TOKEN_DEAD: rejected the stored token\nmore detail' } }, + ], + })); + await cmd.parseAsync([], { from: 'user' }); + const out = output.join('\n'); + expect(out).toContain('bao* ✗'); + expect(out).toContain('BACKEND_TOKEN_DEAD'); + expect(out).not.toContain('more detail'); // only first line, truncated + }); + + it('omits the Secrets line when mcpd returns no backends', async () => { + const cmd = createStatusCommand(baseDeps({ fetchSecretBackends: async () => null })); + await cmd.parseAsync([], { from: 'user' }); + expect(output.join('\n')).not.toContain('Secrets:'); + }); + it('shows status in table format', async () => { const cmd = createStatusCommand(baseDeps()); await cmd.parseAsync([], { from: 'user' });