fix: LLM health check via mcplocal instead of spawning gemini directly
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions

Status command now queries mcplocal's /llm/health endpoint instead of
spawning the gemini binary. This uses the persistent ACP connection
(fast) and works for any configured provider, not just gemini-cli.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-25 00:03:25 +00:00
parent 6f534c8ba9
commit bc769c4eeb
3 changed files with 72 additions and 37 deletions

View File

@@ -1,16 +1,12 @@
import { Command } from 'commander';
import http from 'node:http';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { loadConfig } from '../config/index.js';
import type { ConfigLoaderDeps, LlmConfig } from '../config/index.js';
import type { ConfigLoaderDeps } from '../config/index.js';
import { loadCredentials } from '../auth/index.js';
import type { CredentialsDeps } from '../auth/index.js';
import { formatJson, formatYaml } from '../formatters/index.js';
import { APP_VERSION } from '@mcpctl/shared';
const execFileAsync = promisify(execFile);
// ANSI helpers
const GREEN = '\x1b[32m';
const RED = '\x1b[31m';
@@ -24,7 +20,8 @@ export interface StatusCommandDeps {
log: (...args: string[]) => void;
write: (text: string) => void;
checkHealth: (url: string) => Promise<boolean>;
checkLlm: (llm: LlmConfig) => Promise<string>;
/** Check LLM health via mcplocal's /llm/health endpoint */
checkLlm: (mcplocalUrl: string) => Promise<string>;
isTTY: boolean;
}
@@ -43,34 +40,34 @@ function defaultCheckHealth(url: string): Promise<boolean> {
}
/**
* Quick LLM health check. Returns 'ok', 'binary not found', 'auth error', etc.
* Check LLM health by querying mcplocal's /llm/health endpoint.
* This tests the actual provider running inside the daemon (uses persistent ACP for gemini, etc.)
*/
async function defaultCheckLlm(llm: LlmConfig): Promise<string> {
if (llm.provider === 'gemini-cli') {
const bin = llm.binaryPath ?? 'gemini';
try {
const { stdout } = await execFileAsync(bin, ['-p', 'respond with exactly: ok', '-m', llm.model ?? 'gemini-2.5-flash', '-o', 'text'], { timeout: 15000 });
return stdout.trim().toLowerCase().includes('ok') ? 'ok' : 'unexpected response';
} catch (err) {
const msg = (err as Error).message;
if (msg.includes('ENOENT')) return 'binary not found';
if (msg.includes('auth') || msg.includes('token') || msg.includes('login') || msg.includes('401')) return 'not authenticated';
return `error: ${msg.slice(0, 80)}`;
}
}
if (llm.provider === 'ollama') {
const url = llm.url ?? 'http://localhost:11434';
try {
const ok = await defaultCheckHealth(url);
return ok ? 'ok' : 'unreachable';
} catch {
return 'unreachable';
}
}
// For API-key providers, we don't want to make a billable call on every status check
return 'ok (key stored)';
function defaultCheckLlm(mcplocalUrl: string): Promise<string> {
return new Promise((resolve) => {
const req = http.get(`${mcplocalUrl}/llm/health`, { timeout: 30000 }, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
try {
const body = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as { status: string; error?: string };
if (body.status === 'ok') {
resolve('ok');
} else if (body.status === 'not configured') {
resolve('not configured');
} else if (body.error) {
resolve(body.error.slice(0, 80));
} else {
resolve(body.status);
}
} catch {
resolve('invalid response');
}
});
});
req.on('error', () => resolve('mcplocal unreachable'));
req.on('timeout', () => { req.destroy(); resolve('timeout'); });
});
}
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
@@ -104,7 +101,7 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
const [mcplocalReachable, mcpdReachable, llmStatus] = await Promise.all([
checkHealth(config.mcplocalUrl),
checkHealth(config.mcpdUrl),
llmLabel ? checkLlm(config.llm!) : Promise.resolve(null),
llmLabel ? checkLlm(config.mcplocalUrl) : Promise.resolve(null),
]);
const llm = llmLabel
@@ -148,8 +145,8 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
return;
}
// LLM check with spinner
const llmPromise = checkLlm(config.llm!);
// LLM check with spinner — queries mcplocal's /llm/health endpoint
const llmPromise = checkLlm(config.mcplocalUrl);
if (isTTY) {
let frame = 0;

View File

@@ -134,13 +134,23 @@ describe('status command', () => {
expect(out).toContain('✗ not authenticated');
});
it('shows binary not found error', async () => {
it('shows error message from mcplocal', async () => {
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
const cmd = createStatusCommand(baseDeps({ checkLlm: async () => 'binary not found' }));
await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('✗ binary not found');
});
it('queries mcplocal URL for LLM health', async () => {
saveConfig({ ...DEFAULT_CONFIG, mcplocalUrl: 'http://custom:9999', llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
let queriedUrl = '';
const cmd = createStatusCommand(baseDeps({
checkLlm: async (url) => { queriedUrl = url; return 'ok'; },
}));
await cmd.parseAsync([], { from: 'user' });
expect(queriedUrl).toBe('http://custom:9999');
});
it('uses spinner on TTY and writes final result', async () => {
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
const cmd = createStatusCommand(baseDeps({