Integrate skills + v7-visibility + mcpctl passwd (deployed) #75
@@ -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
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user