feat(cli+mcplocal): mcpctl provider <name> {up,down,status} for managed LLMs
Some checks failed
CI/CD / typecheck (pull_request) Successful in 57s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / lint (pull_request) Successful in 3m1s
CI/CD / smoke (pull_request) Failing after 1m47s
CI/CD / build (pull_request) Successful in 5m58s
CI/CD / publish (pull_request) Has been skipped
Some checks failed
CI/CD / typecheck (pull_request) Successful in 57s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / lint (pull_request) Successful in 3m1s
CI/CD / smoke (pull_request) Failing after 1m47s
CI/CD / build (pull_request) Successful in 5m58s
CI/CD / publish (pull_request) Has been skipped
Adds lifecycle control for managed local LLM providers (vllm-managed)
without the nuclear option of restarting mcplocal. Practical use:
mcpctl provider vllm-local down # release GPU memory now
mcpctl provider vllm-local up # warm up before the next chat
mcpctl provider vllm-local status # see state, pid, uptime
mcplocal exposes three new endpoints:
GET /llm/providers/:name/status → returns lifecycle state for
managed providers, { managed: false }
for unmanaged (anthropic, openai, …)
POST /llm/providers/:name/start → calls warmup() (202 + initial state)
POST /llm/providers/:name/stop → calls dispose() (200 + post-stop state)
Stop and start return 400 for non-managed providers — stopping an API-key
provider is meaningless. The CLI surfaces the error verbatim.
Restarting mcplocal would also free the GPU but drops the SSE connection
to mcpd and forces every virtual Llm to re-publish; this is the targeted,
non-disruptive escape hatch.
The completions test gained a `topLevelMarkers` filter so a sub-command
named `status` (under `provider`) doesn't trip the existing "non-project
commands must guard with __mcpctl_has_project" rule.
Tests: cli 437/437, mcplocal 731/731.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
130
src/cli/src/commands/provider.ts
Normal file
130
src/cli/src/commands/provider.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* `mcpctl provider <name> <up|down|status>`
|
||||
*
|
||||
* Lifecycle control for managed local LLM providers (vllm-managed). Talks
|
||||
* to mcplocal's `/llm/providers/:name/{status,start,stop}` HTTP endpoints
|
||||
* — non-managed providers (anthropic, openai, gemini-cli) get a clear
|
||||
* error rather than a no-op.
|
||||
*
|
||||
* Practical use: `mcpctl provider vllm-local down` to release GPU memory
|
||||
* without restarting mcplocal (which would drop the SSE connection to mcpd
|
||||
* and re-publish all virtual Llms).
|
||||
*/
|
||||
import { Command } from 'commander';
|
||||
import http from 'node:http';
|
||||
|
||||
export interface ProviderCommandDeps {
|
||||
log: (...args: string[]) => void;
|
||||
mcplocalUrl?: string;
|
||||
}
|
||||
|
||||
interface ProviderStatusResponse {
|
||||
name: string;
|
||||
managed: boolean;
|
||||
state?: 'stopped' | 'starting' | 'running' | 'error';
|
||||
lastError?: string | null;
|
||||
pid?: number | null;
|
||||
uptime?: number | null;
|
||||
}
|
||||
|
||||
interface ErrorResponse {
|
||||
error: string;
|
||||
}
|
||||
|
||||
function fetchJson<T>(url: string, method: 'GET' | 'POST'): Promise<{ status: number; body: T }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(url, { method, timeout: 10_000 }, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk: Buffer) => { data += chunk.toString(); });
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve({ status: res.statusCode ?? 0, body: JSON.parse(data) as T });
|
||||
} catch {
|
||||
reject(new Error(`Invalid response from mcplocal: ${data.slice(0, 200)}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', () => reject(new Error('Cannot connect to mcplocal. Is it running? (`systemctl --user status mcplocal`)')));
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error('mcplocal request timed out')); });
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function formatStatus(s: ProviderStatusResponse): string {
|
||||
if (!s.managed) {
|
||||
return `${s.name}: unmanaged (no lifecycle — API-key or remote provider)`;
|
||||
}
|
||||
const lines = [`${s.name}: ${s.state ?? 'unknown'}`];
|
||||
if (s.pid !== null && s.pid !== undefined) lines.push(` pid: ${String(s.pid)}`);
|
||||
if (s.uptime !== null && s.uptime !== undefined) {
|
||||
const sec = s.uptime;
|
||||
const fmt = sec < 60 ? `${String(sec)}s`
|
||||
: sec < 3600 ? `${String(Math.floor(sec / 60))}m`
|
||||
: `${String(Math.floor(sec / 3600))}h${String(Math.floor((sec % 3600) / 60))}m`;
|
||||
lines.push(` uptime: ${fmt}`);
|
||||
}
|
||||
if (s.lastError !== null && s.lastError !== undefined) lines.push(` lastError: ${s.lastError}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function createProviderCommand(deps: ProviderCommandDeps): Command {
|
||||
const cmd = new Command('provider')
|
||||
.description('Control local LLM providers (start/stop/status)');
|
||||
|
||||
const mcplocalUrl = deps.mcplocalUrl ?? 'http://localhost:3200';
|
||||
|
||||
cmd
|
||||
.command('status')
|
||||
.description('Show lifecycle state of a provider')
|
||||
.argument('<name>', 'Provider name (e.g. vllm-local)')
|
||||
.action(async (name: string) => {
|
||||
const res = await fetchJson<ProviderStatusResponse | ErrorResponse>(
|
||||
`${mcplocalUrl}/llm/providers/${encodeURIComponent(name)}/status`,
|
||||
'GET',
|
||||
);
|
||||
if (res.status !== 200) {
|
||||
deps.log(`error: ${(res.body as ErrorResponse).error}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
deps.log(formatStatus(res.body as ProviderStatusResponse));
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('up')
|
||||
.description('Start a managed provider (warm up so first chat is fast)')
|
||||
.argument('<name>', 'Provider name (e.g. vllm-local)')
|
||||
.action(async (name: string) => {
|
||||
const res = await fetchJson<ProviderStatusResponse | ErrorResponse>(
|
||||
`${mcplocalUrl}/llm/providers/${encodeURIComponent(name)}/start`,
|
||||
'POST',
|
||||
);
|
||||
if (res.status !== 202 && res.status !== 200) {
|
||||
deps.log(`error: ${(res.body as ErrorResponse).error}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const status = res.body as ProviderStatusResponse;
|
||||
deps.log(`${status.name}: ${status.state ?? 'starting'} (warmup kicked — chat to confirm it's ready)`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('down')
|
||||
.description('Stop a managed provider now (releases GPU memory)')
|
||||
.argument('<name>', 'Provider name (e.g. vllm-local)')
|
||||
.action(async (name: string) => {
|
||||
const res = await fetchJson<ProviderStatusResponse | ErrorResponse>(
|
||||
`${mcplocalUrl}/llm/providers/${encodeURIComponent(name)}/stop`,
|
||||
'POST',
|
||||
);
|
||||
if (res.status !== 200) {
|
||||
deps.log(`error: ${(res.body as ErrorResponse).error}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const status = res.body as ProviderStatusResponse;
|
||||
deps.log(`${status.name}: ${status.state ?? 'stopped'} (GPU released — next chat will trigger restart)`);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { createMcpCommand } from './commands/mcp.js';
|
||||
import { createPatchCommand } from './commands/patch.js';
|
||||
import { createConsoleCommand } from './commands/console/index.js';
|
||||
import { createCacheCommand } from './commands/cache.js';
|
||||
import { createProviderCommand } from './commands/provider.js';
|
||||
import { createChatCommand } from './commands/chat.js';
|
||||
import { createChatLlmCommand } from './commands/chat-llm.js';
|
||||
import { createMigrateCommand } from './commands/migrate.js';
|
||||
@@ -280,6 +281,11 @@ export function createProgram(): Command {
|
||||
mcplocalUrl: config.mcplocalUrl,
|
||||
}));
|
||||
|
||||
program.addCommand(createProviderCommand({
|
||||
log: (...args) => console.log(...args),
|
||||
mcplocalUrl: config.mcplocalUrl,
|
||||
}));
|
||||
|
||||
program.addCommand(createTestCommand({
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
@@ -120,7 +120,16 @@ describe('fish completions', () => {
|
||||
|
||||
it('non-project commands do not show with --project', () => {
|
||||
const nonProjectCmds = ['status', 'login', 'logout', 'config', 'apply', 'backup'];
|
||||
const lines = fishFile.split('\n').filter((l) => l.startsWith('complete') && l.includes('-a '));
|
||||
// Only check top-level command lines — those are the ones whose
|
||||
// visibility is gated on `__mcpctl_has_project`. Lines scoped to a
|
||||
// sub-command (e.g. `provider status`) live under a different
|
||||
// `__fish_seen_subcommand_from <parent>` predicate and don't need
|
||||
// the project guard.
|
||||
const topLevelMarkers = ['$commands', '$project_commands'];
|
||||
const lines = fishFile.split('\n').filter((l) => {
|
||||
if (!l.startsWith('complete') || !l.includes('-a ')) return false;
|
||||
return topLevelMarkers.some((m) => l.includes(m));
|
||||
});
|
||||
|
||||
for (const cmd of nonProjectCmds) {
|
||||
const cmdLines = lines.filter((l) => {
|
||||
|
||||
Reference in New Issue
Block a user