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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,12 @@
|
|||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { execFile } from 'node:child_process';
|
|
||||||
import { promisify } from 'node:util';
|
|
||||||
import { loadConfig } from '../config/index.js';
|
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 { loadCredentials } from '../auth/index.js';
|
||||||
import type { CredentialsDeps } from '../auth/index.js';
|
import type { CredentialsDeps } from '../auth/index.js';
|
||||||
import { formatJson, formatYaml } from '../formatters/index.js';
|
import { formatJson, formatYaml } from '../formatters/index.js';
|
||||||
import { APP_VERSION } from '@mcpctl/shared';
|
import { APP_VERSION } from '@mcpctl/shared';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
|
|
||||||
// ANSI helpers
|
// ANSI helpers
|
||||||
const GREEN = '\x1b[32m';
|
const GREEN = '\x1b[32m';
|
||||||
const RED = '\x1b[31m';
|
const RED = '\x1b[31m';
|
||||||
@@ -24,7 +20,8 @@ export interface StatusCommandDeps {
|
|||||||
log: (...args: string[]) => void;
|
log: (...args: string[]) => void;
|
||||||
write: (text: string) => void;
|
write: (text: string) => void;
|
||||||
checkHealth: (url: string) => Promise<boolean>;
|
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;
|
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> {
|
function defaultCheckLlm(mcplocalUrl: string): Promise<string> {
|
||||||
if (llm.provider === 'gemini-cli') {
|
return new Promise((resolve) => {
|
||||||
const bin = llm.binaryPath ?? 'gemini';
|
const req = http.get(`${mcplocalUrl}/llm/health`, { timeout: 30000 }, (res) => {
|
||||||
try {
|
const chunks: Buffer[] = [];
|
||||||
const { stdout } = await execFileAsync(bin, ['-p', 'respond with exactly: ok', '-m', llm.model ?? 'gemini-2.5-flash', '-o', 'text'], { timeout: 15000 });
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
return stdout.trim().toLowerCase().includes('ok') ? 'ok' : 'unexpected response';
|
res.on('end', () => {
|
||||||
} catch (err) {
|
try {
|
||||||
const msg = (err as Error).message;
|
const body = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as { status: string; error?: string };
|
||||||
if (msg.includes('ENOENT')) return 'binary not found';
|
if (body.status === 'ok') {
|
||||||
if (msg.includes('auth') || msg.includes('token') || msg.includes('login') || msg.includes('401')) return 'not authenticated';
|
resolve('ok');
|
||||||
return `error: ${msg.slice(0, 80)}`;
|
} else if (body.status === 'not configured') {
|
||||||
}
|
resolve('not configured');
|
||||||
}
|
} else if (body.error) {
|
||||||
|
resolve(body.error.slice(0, 80));
|
||||||
if (llm.provider === 'ollama') {
|
} else {
|
||||||
const url = llm.url ?? 'http://localhost:11434';
|
resolve(body.status);
|
||||||
try {
|
}
|
||||||
const ok = await defaultCheckHealth(url);
|
} catch {
|
||||||
return ok ? 'ok' : 'unreachable';
|
resolve('invalid response');
|
||||||
} catch {
|
}
|
||||||
return 'unreachable';
|
});
|
||||||
}
|
});
|
||||||
}
|
req.on('error', () => resolve('mcplocal unreachable'));
|
||||||
|
req.on('timeout', () => { req.destroy(); resolve('timeout'); });
|
||||||
// For API-key providers, we don't want to make a billable call on every status check
|
});
|
||||||
return 'ok (key stored)';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||||
@@ -104,7 +101,7 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
|
|||||||
const [mcplocalReachable, mcpdReachable, llmStatus] = await Promise.all([
|
const [mcplocalReachable, mcpdReachable, llmStatus] = await Promise.all([
|
||||||
checkHealth(config.mcplocalUrl),
|
checkHealth(config.mcplocalUrl),
|
||||||
checkHealth(config.mcpdUrl),
|
checkHealth(config.mcpdUrl),
|
||||||
llmLabel ? checkLlm(config.llm!) : Promise.resolve(null),
|
llmLabel ? checkLlm(config.mcplocalUrl) : Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const llm = llmLabel
|
const llm = llmLabel
|
||||||
@@ -148,8 +145,8 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// LLM check with spinner
|
// LLM check with spinner — queries mcplocal's /llm/health endpoint
|
||||||
const llmPromise = checkLlm(config.llm!);
|
const llmPromise = checkLlm(config.mcplocalUrl);
|
||||||
|
|
||||||
if (isTTY) {
|
if (isTTY) {
|
||||||
let frame = 0;
|
let frame = 0;
|
||||||
|
|||||||
@@ -134,13 +134,23 @@ describe('status command', () => {
|
|||||||
expect(out).toContain('✗ not authenticated');
|
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 });
|
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
|
||||||
const cmd = createStatusCommand(baseDeps({ checkLlm: async () => 'binary not found' }));
|
const cmd = createStatusCommand(baseDeps({ checkLlm: async () => 'binary not found' }));
|
||||||
await cmd.parseAsync([], { from: 'user' });
|
await cmd.parseAsync([], { from: 'user' });
|
||||||
expect(output.join('\n')).toContain('✗ binary not found');
|
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 () => {
|
it('uses spinner on TTY and writes final result', async () => {
|
||||||
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
|
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
|
||||||
const cmd = createStatusCommand(baseDeps({
|
const cmd = createStatusCommand(baseDeps({
|
||||||
|
|||||||
@@ -81,6 +81,34 @@ export async function createHttpServer(
|
|||||||
reply.code(200).send({ status: 'ok' });
|
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
|
// Proxy management routes to mcpd
|
||||||
const mcpdClient = new McpdClient(config.mcpdUrl, config.mcpdToken);
|
const mcpdClient = new McpdClient(config.mcpdUrl, config.mcpdToken);
|
||||||
registerProxyRoutes(app, mcpdClient);
|
registerProxyRoutes(app, mcpdClient);
|
||||||
|
|||||||
Reference in New Issue
Block a user