Integrate skills + v7-visibility + mcpctl passwd (deployed) #75
@@ -44,6 +44,22 @@ interface ServerLlm {
|
|||||||
apiKeyRef?: { name: string; key: string } | null;
|
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
|
* 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
|
* 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.
|
* Always resolves (never throws) so one bad LLM doesn't sink the section.
|
||||||
*/
|
*/
|
||||||
probeServerLlm: (mcpdUrl: string, name: string, token: string | null) => Promise<ServerLlmHealth>;
|
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;
|
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
|
* 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
|
* the LLM actually serves inference. Returns ok=true when the response is
|
||||||
@@ -334,6 +385,7 @@ const defaultDeps: StatusCommandDeps = {
|
|||||||
fetchModels: defaultFetchModels,
|
fetchModels: defaultFetchModels,
|
||||||
fetchProviders: defaultFetchProviders,
|
fetchProviders: defaultFetchProviders,
|
||||||
fetchServerLlms: defaultFetchServerLlms,
|
fetchServerLlms: defaultFetchServerLlms,
|
||||||
|
fetchSecretBackends: defaultFetchSecretBackends,
|
||||||
probeServerLlm: defaultProbeServerLlm,
|
probeServerLlm: defaultProbeServerLlm,
|
||||||
isTTY: process.stdout.isTTY ?? false,
|
isTTY: process.stdout.isTTY ?? false,
|
||||||
};
|
};
|
||||||
@@ -396,7 +448,7 @@ function formatProviderStatus(name: string, info: ProvidersInfo, ansi: boolean):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command {
|
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')
|
return new Command('status')
|
||||||
.description('Show mcpctl status and connectivity')
|
.description('Show mcpctl status and connectivity')
|
||||||
@@ -411,12 +463,13 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
|
|||||||
if (opts.output !== 'table') {
|
if (opts.output !== 'table') {
|
||||||
// JSON/YAML: run everything in parallel, wait, output at once
|
// JSON/YAML: run everything in parallel, wait, output at once
|
||||||
const token = creds?.token ?? null;
|
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.mcplocalUrl),
|
||||||
checkHealth(config.mcpdUrl),
|
checkHealth(config.mcpdUrl),
|
||||||
llmLabel ? checkLlm(config.mcplocalUrl) : Promise.resolve(null),
|
llmLabel ? checkLlm(config.mcplocalUrl) : Promise.resolve(null),
|
||||||
multiProvider ? fetchProviders(config.mcplocalUrl) : Promise.resolve(null),
|
multiProvider ? fetchProviders(config.mcplocalUrl) : Promise.resolve(null),
|
||||||
fetchServerLlms(config.mcpdUrl, token),
|
fetchServerLlms(config.mcpdUrl, token),
|
||||||
|
fetchSecretBackends(config.mcpdUrl, token),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Probe each server LLM in parallel — adds 0-2 sec to JSON mode but
|
// 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,
|
llmStatus,
|
||||||
...(providersInfo ? { providers: providersInfo } : {}),
|
...(providersInfo ? { providers: providersInfo } : {}),
|
||||||
...(serverLlmsWithHealth !== null ? { serverLlms: serverLlmsWithHealth } : {}),
|
...(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));
|
log(opts.output === 'json' ? formatJson(status) : formatYaml(status));
|
||||||
@@ -472,9 +526,11 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
|
|||||||
// a configured client-side provider.
|
// a configured client-side provider.
|
||||||
const token = creds?.token ?? null;
|
const token = creds?.token ?? null;
|
||||||
const serverLlmsPromise = fetchServerLlms(config.mcpdUrl, token);
|
const serverLlmsPromise = fetchServerLlms(config.mcpdUrl, token);
|
||||||
|
const secretBackendsPromise = fetchSecretBackends(config.mcpdUrl, token);
|
||||||
|
|
||||||
if (!llmLabel) {
|
if (!llmLabel) {
|
||||||
log(`LLM: not configured (run 'mcpctl config setup')`);
|
log(`LLM: not configured (run 'mcpctl config setup')`);
|
||||||
|
await renderSecretBackendsSection(secretBackendsPromise, isTTY);
|
||||||
await renderServerLlmsSection(serverLlmsPromise, config.mcpdUrl, token, isTTY);
|
await renderServerLlmsSection(serverLlmsPromise, config.mcpdUrl, token, isTTY);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -539,9 +595,35 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await renderSecretBackendsSection(secretBackendsPromise, isTTY);
|
||||||
await renderServerLlmsSection(serverLlmsPromise, config.mcpdUrl, token, 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
|
* 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
|
* 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,
|
fetchProviders: async () => null,
|
||||||
fetchServerLlms: async () => null,
|
fetchServerLlms: async () => null,
|
||||||
probeServerLlm: async () => ({ ok: true, ms: 12, say: 'hi' }),
|
probeServerLlm: async () => ({ ok: true, ms: 12, say: 'hi' }),
|
||||||
|
fetchSecretBackends: async () => null,
|
||||||
isTTY: false,
|
isTTY: false,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -45,6 +46,39 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('status command', () => {
|
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 () => {
|
it('shows status in table format', async () => {
|
||||||
const cmd = createStatusCommand(baseDeps());
|
const cmd = createStatusCommand(baseDeps());
|
||||||
await cmd.parseAsync([], { from: 'user' });
|
await cmd.parseAsync([], { from: 'user' });
|
||||||
|
|||||||
Reference in New Issue
Block a user