feat(status): show SecretBackend health in mcpctl status
Some checks failed
CI/CD / lint (pull_request) Successful in 1m3s
CI/CD / test (pull_request) Successful in 1m17s
CI/CD / typecheck (pull_request) Successful in 2m33s
CI/CD / smoke (pull_request) Failing after 1m52s
CI/CD / build (pull_request) Successful in 4m55s
CI/CD / publish (pull_request) Has been skipped

Adds a 'Secrets:' line to mcpctl status (and a secretBackends array to JSON
output) showing each backend healthy (✓) or, when tokenMeta.lastRotationError
is set (e.g. a dead OpenBao token), red ✗ with the error inline. Makes a
recurrence of BACKEND_TOKEN_DEAD visible at a glance instead of only in mcpd
logs. Verified live: 'Secrets: bao* ✓, default ✓'. +3 tests (28 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-06-16 23:13:50 +01:00
parent 4c7e648771
commit 467757b966
2 changed files with 118 additions and 2 deletions

View File

@@ -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<ServerLlmHealth>;
/** Fetch SecretBackends from mcpd to surface backend health. Null on error. */
fetchSecretBackends: (mcpdUrl: string, token: string | null) => Promise<SecretBackendInfo[] | null>;
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<SecretBackendInfo[] | null> {
return new Promise((resolve) => {
let req: http.ClientRequest;
const headers: Record<string, string> = { 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/<name>/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<StatusCommandDeps>): 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<StatusCommandDeps>): 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<StatusCommandDeps>): 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<StatusCommandDeps>): 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<StatusCommandDeps>): 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<SecretBackendInfo[] | null>,
ansi: boolean,
): Promise<void> {
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

View File

@@ -29,6 +29,7 @@ function baseDeps(overrides?: Partial<StatusCommandDeps>): Partial<StatusCommand
fetchProviders: async () => 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' });