From cd12782797af6a3016af7e8d0c473b719f3e1f11 Mon Sep 17 00:00:00 2001 From: Michal Date: Wed, 25 Feb 2026 00:03:25 +0000 Subject: [PATCH] fix: LLM health check via mcplocal instead of spawning gemini directly 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 --- src/cli/src/commands/status.ts | 69 +++++++++++++-------------- src/cli/tests/commands/status.test.ts | 12 ++++- src/mcplocal/src/http/server.ts | 28 +++++++++++ 3 files changed, 72 insertions(+), 37 deletions(-) diff --git a/src/cli/src/commands/status.ts b/src/cli/src/commands/status.ts index 8709319..38e7dd0 100644 --- a/src/cli/src/commands/status.ts +++ b/src/cli/src/commands/status.ts @@ -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; - checkLlm: (llm: LlmConfig) => Promise; + /** Check LLM health via mcplocal's /llm/health endpoint */ + checkLlm: (mcplocalUrl: string) => Promise; isTTY: boolean; } @@ -43,34 +40,34 @@ function defaultCheckHealth(url: string): Promise { } /** - * 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 { - 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 { + 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): 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): 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; diff --git a/src/cli/tests/commands/status.test.ts b/src/cli/tests/commands/status.test.ts index 402b466..0c09f45 100644 --- a/src/cli/tests/commands/status.test.ts +++ b/src/cli/tests/commands/status.test.ts @@ -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({ diff --git a/src/mcplocal/src/http/server.ts b/src/mcplocal/src/http/server.ts index d8380bc..043acbb 100644 --- a/src/mcplocal/src/http/server.ts +++ b/src/mcplocal/src/http/server.ts @@ -81,6 +81,34 @@ export async function createHttpServer( reply.code(200).send({ status: 'ok' }); }); + // LLM health check — tests the active provider with a tiny prompt + app.get('/llm/health', async (_request, reply) => { + const provider = deps.providerRegistry?.getActive() ?? null; + if (!provider) { + reply.code(200).send({ status: 'not configured' }); + return; + } + try { + const result = await provider.complete({ + messages: [{ role: 'user', content: 'Respond with exactly: ok' }], + maxTokens: 10, + }); + const ok = result.content.trim().toLowerCase().includes('ok'); + reply.code(200).send({ + status: ok ? 'ok' : 'unexpected response', + provider: provider.name, + response: result.content.trim().slice(0, 100), + }); + } catch (err) { + const msg = (err as Error).message ?? String(err); + reply.code(200).send({ + status: 'error', + provider: provider.name, + error: msg.slice(0, 200), + }); + } + }); + // Proxy management routes to mcpd const mcpdClient = new McpdClient(config.mcpdUrl, config.mcpdToken); registerProxyRoutes(app, mcpdClient);