Compare commits

..

3 Commits

Author SHA1 Message Date
Michal
9481d394a1 feat: completions update, create promptrequest, LLM flag rename, ACP content fix
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Add prompts/promptrequests to shell completions (fish + bash)
- Add approve, setup, prompt, promptrequest commands to completions
- Add `create promptrequest` CLI command (POST /projects/:name/promptrequests)
- Rename --proxy-mode-llm-provider/model to --llm-provider/model
- Fix ACP client: handle single-object content format from real Gemini
- Add tests for single-object content and agent_thought_chunk filtering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 00:21:31 +00:00
Michal
bc769c4eeb 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>
2026-02-25 00:03:25 +00:00
6f534c8ba9 Merge pull request 'feat: persistent Gemini ACP provider + status spinner' (#40) from feat/gemini-acp-provider into main
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
2026-02-24 23:52:31 +00:00
10 changed files with 215 additions and 63 deletions

View File

@@ -2,10 +2,10 @@ _mcpctl() {
local cur prev words cword local cur prev words cword
_init_completion || return _init_completion || return
local commands="status login logout config get describe delete logs create edit apply backup restore mcp help" local commands="status login logout config get describe delete logs create edit apply backup restore mcp approve help"
local project_commands="attach-server detach-server get describe delete logs create edit help" local project_commands="attach-server detach-server get describe delete logs create edit help"
local global_opts="-v --version --daemon-url --direct --project -h --help" local global_opts="-v --version --daemon-url --direct --project -h --help"
local resources="servers instances secrets templates projects users groups rbac" local resources="servers instances secrets templates projects users groups rbac prompts promptrequests"
# Check if --project was given # Check if --project was given
local has_project=false local has_project=false
@@ -78,7 +78,7 @@ _mcpctl() {
case "$subcmd" in case "$subcmd" in
config) config)
if [[ $((cword - subcmd_pos)) -eq 1 ]]; then if [[ $((cword - subcmd_pos)) -eq 1 ]]; then
COMPREPLY=($(compgen -W "view set path reset claude impersonate help" -- "$cur")) COMPREPLY=($(compgen -W "view set path reset claude claude-generate setup impersonate help" -- "$cur"))
fi fi
return ;; return ;;
status) status)
@@ -114,7 +114,7 @@ _mcpctl() {
return ;; return ;;
create) create)
if [[ $((cword - subcmd_pos)) -eq 1 ]]; then if [[ $((cword - subcmd_pos)) -eq 1 ]]; then
COMPREPLY=($(compgen -W "server secret project user group rbac help" -- "$cur")) COMPREPLY=($(compgen -W "server secret project user group rbac prompt promptrequest help" -- "$cur"))
fi fi
return ;; return ;;
apply) apply)
@@ -150,6 +150,15 @@ _mcpctl() {
fi fi
COMPREPLY=($(compgen -W "$names" -- "$cur")) COMPREPLY=($(compgen -W "$names" -- "$cur"))
return ;; return ;;
approve)
if [[ -z "$resource_type" ]]; then
COMPREPLY=($(compgen -W "promptrequest" -- "$cur"))
else
local names
names=$(_mcpctl_resource_names "$resource_type")
COMPREPLY=($(compgen -W "$names" -- "$cur"))
fi
return ;;
help) help)
COMPREPLY=($(compgen -W "$commands" -- "$cur")) COMPREPLY=($(compgen -W "$commands" -- "$cur"))
return ;; return ;;

View File

@@ -3,7 +3,7 @@
# Erase any stale completions from previous versions # Erase any stale completions from previous versions
complete -c mcpctl -e complete -c mcpctl -e
set -l commands status login logout config get describe delete logs create edit apply backup restore mcp help set -l commands status login logout config get describe delete logs create edit apply backup restore mcp approve help
set -l project_commands attach-server detach-server get describe delete logs create edit help set -l project_commands attach-server detach-server get describe delete logs create edit help
# Disable file completions by default # Disable file completions by default
@@ -28,7 +28,7 @@ function __mcpctl_has_project
end end
# Helper: check if a resource type has been selected after get/describe/delete/edit # Helper: check if a resource type has been selected after get/describe/delete/edit
set -l resources servers instances secrets templates projects users groups rbac set -l resources servers instances secrets templates projects users groups rbac prompts promptrequests
function __mcpctl_needs_resource_type function __mcpctl_needs_resource_type
set -l tokens (commandline -opc) set -l tokens (commandline -opc)
@@ -36,11 +36,11 @@ function __mcpctl_needs_resource_type
for tok in $tokens for tok in $tokens
if $found_cmd if $found_cmd
# Check if next token after get/describe/delete/edit is a resource type # Check if next token after get/describe/delete/edit is a resource type
if contains -- $tok servers instances secrets templates projects users groups rbac if contains -- $tok servers instances secrets templates projects users groups rbac prompts promptrequests
return 1 # resource type already present return 1 # resource type already present
end end
end end
if contains -- $tok get describe delete edit if contains -- $tok get describe delete edit approve
set found_cmd true set found_cmd true
end end
end end
@@ -55,12 +55,12 @@ function __mcpctl_get_resource_type
set -l found_cmd false set -l found_cmd false
for tok in $tokens for tok in $tokens
if $found_cmd if $found_cmd
if contains -- $tok servers instances secrets templates projects users groups rbac if contains -- $tok servers instances secrets templates projects users groups rbac prompts promptrequests
echo $tok echo $tok
return return
end end
end end
if contains -- $tok get describe delete edit if contains -- $tok get describe delete edit approve
set found_cmd true set found_cmd true
end end
end end
@@ -139,6 +139,7 @@ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a apply -d 'Apply configuration from file' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a apply -d 'Apply configuration from file'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a backup -d 'Backup configuration' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a backup -d 'Backup configuration'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a restore -d 'Restore from backup' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a restore -d 'Restore from backup'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a approve -d 'Approve a prompt request'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a help -d 'Show help' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a help -d 'Show help'
# Project-scoped commands (with --project) # Project-scoped commands (with --project)
@@ -157,7 +158,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete; and __mc
complete -c mcpctl -n "__fish_seen_subcommand_from edit; and __mcpctl_needs_resource_type" -a 'servers projects' -d 'Resource type' complete -c mcpctl -n "__fish_seen_subcommand_from edit; and __mcpctl_needs_resource_type" -a 'servers projects' -d 'Resource type'
# Resource names — after resource type is selected # Resource names — after resource type is selected
complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete edit; and not __mcpctl_needs_resource_type" -a '(__mcpctl_resource_names)' -d 'Resource name' complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete edit approve; and not __mcpctl_needs_resource_type" -a '(__mcpctl_resource_names)' -d 'Resource name'
# Helper: check if attach-server/detach-server already has a server argument # Helper: check if attach-server/detach-server already has a server argument
function __mcpctl_needs_server_arg function __mcpctl_needs_server_arg
@@ -196,22 +197,25 @@ complete -c mcpctl -n "__fish_seen_subcommand_from login" -l email -d 'Email add
complete -c mcpctl -n "__fish_seen_subcommand_from login" -l password -d 'Password' -x complete -c mcpctl -n "__fish_seen_subcommand_from login" -l password -d 'Password' -x
# config subcommands # config subcommands
set -l config_cmds view set path reset claude claude-generate impersonate set -l config_cmds view set path reset claude claude-generate setup impersonate
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a view -d 'Show configuration' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a view -d 'Show configuration'
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a set -d 'Set a config value' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a set -d 'Set a config value'
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a path -d 'Show config file path' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a path -d 'Show config file path'
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a reset -d 'Reset to defaults' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a reset -d 'Reset to defaults'
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude -d 'Generate .mcp.json for project' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude -d 'Generate .mcp.json for project'
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a setup -d 'Configure LLM provider'
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a impersonate -d 'Impersonate a user' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a impersonate -d 'Impersonate a user'
# create subcommands # create subcommands
set -l create_cmds server secret project user group rbac set -l create_cmds server secret project user group rbac prompt promptrequest
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a server -d 'Create a server' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a server -d 'Create a server'
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a secret -d 'Create a secret' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a secret -d 'Create a secret'
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a project -d 'Create a project' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a project -d 'Create a project'
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a user -d 'Create a user' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a user -d 'Create a user'
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a group -d 'Create a group' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a group -d 'Create a group'
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a rbac -d 'Create an RBAC binding' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a rbac -d 'Create an RBAC binding'
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a prompt -d 'Create an approved prompt'
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a promptrequest -d 'Create a prompt request'
# logs options # logs options
complete -c mcpctl -n "__fish_seen_subcommand_from logs" -l tail -d 'Number of lines' -x complete -c mcpctl -n "__fish_seen_subcommand_from logs" -l tail -d 'Number of lines' -x
@@ -227,6 +231,9 @@ complete -c mcpctl -n "__fish_seen_subcommand_from restore" -s i -l input -d 'In
complete -c mcpctl -n "__fish_seen_subcommand_from restore" -s p -l password -d 'Decryption password' -x complete -c mcpctl -n "__fish_seen_subcommand_from restore" -s p -l password -d 'Decryption password' -x
complete -c mcpctl -n "__fish_seen_subcommand_from restore" -s c -l conflict -d 'Conflict strategy' -xa 'skip overwrite fail' complete -c mcpctl -n "__fish_seen_subcommand_from restore" -s c -l conflict -d 'Conflict strategy' -xa 'skip overwrite fail'
# approve: first arg is resource type (promptrequest only), second is name
complete -c mcpctl -n "__fish_seen_subcommand_from approve; and __mcpctl_needs_resource_type" -a 'promptrequest' -d 'Resource type'
# apply takes a file # apply takes a file
complete -c mcpctl -n "__fish_seen_subcommand_from apply" -s f -l file -d 'Configuration file' -rF complete -c mcpctl -n "__fish_seen_subcommand_from apply" -s f -l file -d 'Configuration file' -rF
complete -c mcpctl -n "__fish_seen_subcommand_from apply" -F complete -c mcpctl -n "__fish_seen_subcommand_from apply" -F

View File

@@ -196,8 +196,8 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.argument('<name>', 'Project name') .argument('<name>', 'Project name')
.option('-d, --description <text>', 'Project description', '') .option('-d, --description <text>', 'Project description', '')
.option('--proxy-mode <mode>', 'Proxy mode (direct, filtered)') .option('--proxy-mode <mode>', 'Proxy mode (direct, filtered)')
.option('--proxy-mode-llm-provider <name>', 'LLM provider name (for filtered proxy mode)') .option('--llm-provider <name>', 'LLM provider name')
.option('--proxy-mode-llm-model <name>', 'LLM model name (for filtered proxy mode)') .option('--llm-model <name>', 'LLM model name')
.option('--prompt <text>', 'Project-level prompt / instructions for the LLM') .option('--prompt <text>', 'Project-level prompt / instructions for the LLM')
.option('--server <name>', 'Server name (repeat for multiple)', collect, []) .option('--server <name>', 'Server name (repeat for multiple)', collect, [])
.option('--force', 'Update if already exists') .option('--force', 'Update if already exists')
@@ -208,8 +208,8 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
proxyMode: opts.proxyMode ?? 'direct', proxyMode: opts.proxyMode ?? 'direct',
}; };
if (opts.prompt) body.prompt = opts.prompt; if (opts.prompt) body.prompt = opts.prompt;
if (opts.proxyModeLlmProvider) body.llmProvider = opts.proxyModeLlmProvider; if (opts.llmProvider) body.llmProvider = opts.llmProvider;
if (opts.proxyModeLlmModel) body.llmModel = opts.proxyModeLlmModel; if (opts.llmModel) body.llmModel = opts.llmModel;
if (opts.server.length > 0) body.servers = opts.server; if (opts.server.length > 0) body.servers = opts.server;
try { try {
@@ -379,5 +379,31 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
log(`prompt '${prompt.name}' created (id: ${prompt.id})`); log(`prompt '${prompt.name}' created (id: ${prompt.id})`);
}); });
// --- create promptrequest ---
cmd.command('promptrequest')
.description('Create a prompt request (pending proposal that needs approval)')
.argument('<name>', 'Prompt request name (lowercase alphanumeric with hyphens)')
.requiredOption('--project <name>', 'Project name (required)')
.option('--content <text>', 'Prompt content text')
.option('--content-file <path>', 'Read prompt content from file')
.action(async (name: string, opts) => {
let content = opts.content as string | undefined;
if (opts.contentFile) {
const fs = await import('node:fs/promises');
content = await fs.readFile(opts.contentFile as string, 'utf-8');
}
if (!content) {
throw new Error('--content or --content-file is required');
}
const projectName = opts.project as string;
const pr = await client.post<{ id: string; name: string }>(
`/api/v1/projects/${encodeURIComponent(projectName)}/promptrequests`,
{ name, content },
);
log(`prompt request '${pr.name}' created (id: ${pr.id})`);
log(` approve with: mcpctl approve promptrequest ${pr.name}`);
});
return cmd; return cmd;
} }

View File

@@ -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;

View File

@@ -30,8 +30,8 @@ describe('project with new fields', () => {
'project', 'smart-home', 'project', 'smart-home',
'-d', 'Smart home project', '-d', 'Smart home project',
'--proxy-mode', 'filtered', '--proxy-mode', 'filtered',
'--proxy-mode-llm-provider', 'gemini-cli', '--llm-provider', 'gemini-cli',
'--proxy-mode-llm-model', 'gemini-2.0-flash', '--llm-model', 'gemini-2.0-flash',
'--server', 'my-grafana', '--server', 'my-grafana',
'--server', 'my-ha', '--server', 'my-ha',
], { from: 'user' }); ], { from: 'user' });

View File

@@ -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({

View File

@@ -15,7 +15,7 @@ describe('fish completions', () => {
}); });
it('does not offer resource types without __mcpctl_needs_resource_type guard', () => { it('does not offer resource types without __mcpctl_needs_resource_type guard', () => {
const resourceTypes = ['servers', 'instances', 'secrets', 'templates', 'projects', 'users', 'groups', 'rbac']; const resourceTypes = ['servers', 'instances', 'secrets', 'templates', 'projects', 'users', 'groups', 'rbac', 'prompts', 'promptrequests'];
const lines = fishFile.split('\n').filter((l) => l.startsWith('complete ')); const lines = fishFile.split('\n').filter((l) => l.startsWith('complete '));
for (const line of lines) { for (const line of lines) {

View File

@@ -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);

View File

@@ -205,12 +205,16 @@ export class AcpClient {
// Collect text from agent_message_chunk // Collect text from agent_message_chunk
if (update.sessionUpdate === 'agent_message_chunk') { if (update.sessionUpdate === 'agent_message_chunk') {
const content = update.content as Array<{ type: string; text?: string }> | undefined; const content = update.content;
if (content) { // Gemini ACP sends content as a single object {type, text} or an array [{type, text}]
for (const block of content) { const blocks: Array<{ type: string; text?: string }> = Array.isArray(content)
if (block.type === 'text' && block.text) { ? content as Array<{ type: string; text?: string }>
this.activePromptChunks.push(block.text); : content && typeof content === 'object'
} ? [content as { type: string; text?: string }]
: [];
for (const block of blocks) {
if (block.type === 'text' && block.text) {
this.activePromptChunks.push(block.text);
} }
} }
} }

View File

@@ -230,6 +230,77 @@ describe('AcpClient', () => {
expect(result).toBe('Part A Part B'); expect(result).toBe('Part A Part B');
}); });
it('handles single-object content (real Gemini ACP format)', async () => {
createClient();
autoHandshake('sess-1');
await client.ensureReady();
mock.stdin.write.mockImplementation((data: string) => {
const msg = JSON.parse(data.trim()) as { id: number; method: string };
if (msg.method === 'session/prompt') {
setImmediate(() => {
// Real Gemini ACP sends content as a single object, not an array
mock.sendLine({
jsonrpc: '2.0',
method: 'session/update',
params: {
sessionId: 'sess-1',
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'ok' },
},
},
});
mock.sendResponse(msg.id, { stopReason: 'end_turn' });
});
}
});
const result = await client.prompt('test');
expect(result).toBe('ok');
});
it('ignores agent_thought_chunk notifications', async () => {
createClient();
autoHandshake('sess-1');
await client.ensureReady();
mock.stdin.write.mockImplementation((data: string) => {
const msg = JSON.parse(data.trim()) as { id: number; method: string };
if (msg.method === 'session/prompt') {
setImmediate(() => {
// Gemini sends thought chunks before message chunks
mock.sendLine({
jsonrpc: '2.0',
method: 'session/update',
params: {
sessionId: 'sess-1',
update: {
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text: 'Thinking about it...' },
},
},
});
mock.sendLine({
jsonrpc: '2.0',
method: 'session/update',
params: {
sessionId: 'sess-1',
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'ok' },
},
},
});
mock.sendResponse(msg.id, { stopReason: 'end_turn' });
});
}
});
const result = await client.prompt('test');
expect(result).toBe('ok');
});
it('calls ensureReady automatically (lazy init)', async () => { it('calls ensureReady automatically (lazy init)', async () => {
createClient(); createClient();
autoHandshake('sess-auto'); autoHandshake('sess-auto');