diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 9554295..bcb4582 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -2,10 +2,10 @@ _mcpctl() { local cur prev words cword _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 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 local has_project=false @@ -78,7 +78,7 @@ _mcpctl() { case "$subcmd" in config) 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 return ;; status) @@ -114,7 +114,7 @@ _mcpctl() { return ;; create) 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 return ;; apply) @@ -150,6 +150,15 @@ _mcpctl() { fi COMPREPLY=($(compgen -W "$names" -- "$cur")) 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) COMPREPLY=($(compgen -W "$commands" -- "$cur")) return ;; diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 5a3ec52..a63b461 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -3,7 +3,7 @@ # Erase any stale completions from previous versions 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 # Disable file completions by default @@ -28,7 +28,7 @@ function __mcpctl_has_project end # 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 set -l tokens (commandline -opc) @@ -36,11 +36,11 @@ function __mcpctl_needs_resource_type for tok in $tokens if $found_cmd # 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 end end - if contains -- $tok get describe delete edit + if contains -- $tok get describe delete edit approve set found_cmd true end end @@ -55,12 +55,12 @@ function __mcpctl_get_resource_type set -l found_cmd false for tok in $tokens 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 return end end - if contains -- $tok get describe delete edit + if contains -- $tok get describe delete edit approve set found_cmd true 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 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 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' # 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' # 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 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 # 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 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 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 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' # 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 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 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 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 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 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 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 diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index 8880cd8..4187b1f 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -196,8 +196,8 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .argument('', 'Project name') .option('-d, --description ', 'Project description', '') .option('--proxy-mode ', 'Proxy mode (direct, filtered)') - .option('--proxy-mode-llm-provider ', 'LLM provider name (for filtered proxy mode)') - .option('--proxy-mode-llm-model ', 'LLM model name (for filtered proxy mode)') + .option('--llm-provider ', 'LLM provider name') + .option('--llm-model ', 'LLM model name') .option('--prompt ', 'Project-level prompt / instructions for the LLM') .option('--server ', 'Server name (repeat for multiple)', collect, []) .option('--force', 'Update if already exists') @@ -208,8 +208,8 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { proxyMode: opts.proxyMode ?? 'direct', }; if (opts.prompt) body.prompt = opts.prompt; - if (opts.proxyModeLlmProvider) body.llmProvider = opts.proxyModeLlmProvider; - if (opts.proxyModeLlmModel) body.llmModel = opts.proxyModeLlmModel; + if (opts.llmProvider) body.llmProvider = opts.llmProvider; + if (opts.llmModel) body.llmModel = opts.llmModel; if (opts.server.length > 0) body.servers = opts.server; try { @@ -379,5 +379,31 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { log(`prompt '${prompt.name}' created (id: ${prompt.id})`); }); + // --- create promptrequest --- + cmd.command('promptrequest') + .description('Create a prompt request (pending proposal that needs approval)') + .argument('', 'Prompt request name (lowercase alphanumeric with hyphens)') + .requiredOption('--project ', 'Project name (required)') + .option('--content ', 'Prompt content text') + .option('--content-file ', '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; } diff --git a/src/cli/tests/commands/project.test.ts b/src/cli/tests/commands/project.test.ts index aeef1db..26777c7 100644 --- a/src/cli/tests/commands/project.test.ts +++ b/src/cli/tests/commands/project.test.ts @@ -30,8 +30,8 @@ describe('project with new fields', () => { 'project', 'smart-home', '-d', 'Smart home project', '--proxy-mode', 'filtered', - '--proxy-mode-llm-provider', 'gemini-cli', - '--proxy-mode-llm-model', 'gemini-2.0-flash', + '--llm-provider', 'gemini-cli', + '--llm-model', 'gemini-2.0-flash', '--server', 'my-grafana', '--server', 'my-ha', ], { from: 'user' }); diff --git a/src/cli/tests/completions.test.ts b/src/cli/tests/completions.test.ts index cf4a349..c6115a6 100644 --- a/src/cli/tests/completions.test.ts +++ b/src/cli/tests/completions.test.ts @@ -15,7 +15,7 @@ describe('fish completions', () => { }); 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 ')); for (const line of lines) { diff --git a/src/mcplocal/src/providers/acp-client.ts b/src/mcplocal/src/providers/acp-client.ts index 037288e..f0e6824 100644 --- a/src/mcplocal/src/providers/acp-client.ts +++ b/src/mcplocal/src/providers/acp-client.ts @@ -205,12 +205,16 @@ export class AcpClient { // Collect text from agent_message_chunk if (update.sessionUpdate === 'agent_message_chunk') { - const content = update.content as Array<{ type: string; text?: string }> | undefined; - if (content) { - for (const block of content) { - if (block.type === 'text' && block.text) { - this.activePromptChunks.push(block.text); - } + const content = update.content; + // Gemini ACP sends content as a single object {type, text} or an array [{type, text}] + const blocks: Array<{ type: string; text?: string }> = Array.isArray(content) + ? content as Array<{ type: string; text?: string }> + : 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); } } } diff --git a/src/mcplocal/tests/acp-client.test.ts b/src/mcplocal/tests/acp-client.test.ts index da7a0d7..6b0ae19 100644 --- a/src/mcplocal/tests/acp-client.test.ts +++ b/src/mcplocal/tests/acp-client.test.ts @@ -230,6 +230,77 @@ describe('AcpClient', () => { 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 () => { createClient(); autoHandshake('sess-auto');