From c5b8cb60b71c0a398c76570bdaba7550befe332a Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 23 Feb 2026 19:32:18 +0000 Subject: [PATCH] fix: instance completions use server.name, smart attach/detach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Instances have no name field — use server.name for completions - attach-server: show only servers NOT in the project - detach-server: show only servers IN the project - Add helper functions for project-aware server completion - 5 new tests covering all three fixes Co-Authored-By: Claude Opus 4.6 --- completions/mcpctl.bash | 39 +++++++++++++++++++++--- completions/mcpctl.fish | 50 ++++++++++++++++++++++++++++++- src/cli/tests/completions.test.ts | 50 ++++++++++++++++++++++++++----- 3 files changed, 126 insertions(+), 13 deletions(-) diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 2f60e47..d676a7d 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -55,10 +55,26 @@ _mcpctl() { _mcpctl_resource_names() { local rt="$1" if [[ -n "$rt" ]]; then - mcpctl get "$rt" -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null + # Instances don't have a name field — use server.name instead + if [[ "$rt" == "instances" ]]; then + mcpctl get instances -o json 2>/dev/null | jq -r '.[][].server.name' 2>/dev/null + else + mcpctl get "$rt" -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null + fi fi } + # Get the --project value from the command line + _mcpctl_get_project_value() { + local i + for ((i=1; i < cword; i++)); do + if [[ "${words[i]}" == "--project" ]] && (( i+1 < cword )); then + echo "${words[i+1]}" + return + fi + done + } + case "$subcmd" in config) if [[ $((cword - subcmd_pos)) -eq 1 ]]; then @@ -108,9 +124,24 @@ _mcpctl() { restore) COMPREPLY=($(compgen -W "-i --input -p --password -c --conflict -h --help" -- "$cur")) return ;; - attach-server|detach-server) - local names - names=$(_mcpctl_resource_names "servers") + attach-server) + local proj names all_servers proj_servers + proj=$(_mcpctl_get_project_value) + if [[ -n "$proj" ]]; then + all_servers=$(mcpctl get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null) + proj_servers=$(mcpctl --project "$proj" get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null) + names=$(comm -23 <(echo "$all_servers" | sort) <(echo "$proj_servers" | sort)) + else + names=$(_mcpctl_resource_names "servers") + fi + COMPREPLY=($(compgen -W "$names" -- "$cur")) + return ;; + detach-server) + local proj names + proj=$(_mcpctl_get_project_value) + if [[ -n "$proj" ]]; then + names=$(mcpctl --project "$proj" get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null) + fi COMPREPLY=($(compgen -W "$names" -- "$cur")) return ;; help) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 164b771..cf66f80 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -72,7 +72,12 @@ function __mcpctl_resource_names if test -z "$resource" return end - mcpctl get $resource -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null + # Instances don't have a name field — use server.name instead + if test "$resource" = "instances" + mcpctl get instances -o json 2>/dev/null | jq -r '.[][].server.name' 2>/dev/null + else + mcpctl get $resource -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null + end end # Fetch project names for --project value @@ -80,6 +85,43 @@ function __mcpctl_project_names mcpctl get projects -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null end +# Helper: get the --project value from the command line +function __mcpctl_get_project_value + set -l tokens (commandline -opc) + for i in (seq (count $tokens)) + if test "$tokens[$i]" = "--project"; and test $i -lt (count $tokens) + echo $tokens[(math $i + 1)] + return + end + end +end + +# Servers currently attached to the project (for detach-server) +function __mcpctl_project_servers + set -l proj (__mcpctl_get_project_value) + if test -z "$proj" + return + end + mcpctl --project $proj get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null +end + +# Servers NOT attached to the project (for attach-server) +function __mcpctl_available_servers + set -l proj (__mcpctl_get_project_value) + if test -z "$proj" + # No project — show all servers + mcpctl get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null + return + end + set -l all (mcpctl get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null) + set -l attached (mcpctl --project $proj get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null) + for s in $all + if not contains -- $s $attached + echo $s + end + end +end + # --project value completion complete -c mcpctl -l project -xa '(__mcpctl_project_names)' @@ -117,6 +159,12 @@ complete -c mcpctl -n "__fish_seen_subcommand_from edit; and __mcpctl_needs_reso # 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' +# attach-server: show servers NOT in the project +complete -c mcpctl -n "__fish_seen_subcommand_from attach-server" -a '(__mcpctl_available_servers)' -d 'Server' + +# detach-server: show servers IN the project +complete -c mcpctl -n "__fish_seen_subcommand_from detach-server" -a '(__mcpctl_project_servers)' -d 'Server' + # get/describe options complete -c mcpctl -n "__fish_seen_subcommand_from get" -s o -l output -d 'Output format' -xa 'table json yaml' complete -c mcpctl -n "__fish_seen_subcommand_from describe" -s o -l output -d 'Output format' -xa 'detail json yaml' diff --git a/src/cli/tests/completions.test.ts b/src/cli/tests/completions.test.ts index 5a51993..8e08556 100644 --- a/src/cli/tests/completions.test.ts +++ b/src/cli/tests/completions.test.ts @@ -56,16 +56,19 @@ describe('fish completions', () => { expect(fishFile).toContain("complete -c mcpctl -l project"); }); - it('attach-server only shows with --project', () => { - const lines = fishFile.split('\n').filter((l) => l.includes('attach-server') && l.startsWith('complete')); + it('attach-server command only shows with --project', () => { + // Only check lines that OFFER attach-server as a command (via -a attach-server), not argument completions + const lines = fishFile.split('\n').filter((l) => + l.startsWith('complete') && l.includes("-a attach-server")); expect(lines.length).toBeGreaterThan(0); for (const line of lines) { expect(line).toContain('__mcpctl_has_project'); } }); - it('detach-server only shows with --project', () => { - const lines = fishFile.split('\n').filter((l) => l.includes('detach-server') && l.startsWith('complete')); + it('detach-server command only shows with --project', () => { + const lines = fishFile.split('\n').filter((l) => + l.startsWith('complete') && l.includes("-a detach-server")); expect(lines.length).toBeGreaterThan(0); for (const line of lines) { expect(line).toContain('__mcpctl_has_project'); @@ -81,13 +84,31 @@ describe('fish completions', () => { expect(resourceNamesFn, '__mcpctl_resource_names must use jq .[][].name').toContain("jq -r '.[][].name'"); expect(resourceNamesFn, '__mcpctl_resource_names must not use string match on name').not.toMatch(/string match.*"name"/); - // Guard against .[].name (single bracket) which fails on wrapped JSON - expect(resourceNamesFn, '__mcpctl_resource_names must not use .[].name (needs .[][].name)').not.toMatch(/jq.*'\.\[\]\.name'/); expect(projectNamesFn, '__mcpctl_project_names must use jq .[][].name').toContain("jq -r '.[][].name'"); expect(projectNamesFn, '__mcpctl_project_names must not use string match on name').not.toMatch(/string match.*"name"/); }); + it('instances use server.name instead of name', () => { + const resourceNamesFn = fishFile.match(/function __mcpctl_resource_names[\s\S]*?^end/m)?.[0] ?? ''; + expect(resourceNamesFn, 'must handle instances via server.name').toContain('.server.name'); + }); + + it('attach-server completes with available (unattached) servers', () => { + // Find the line that provides argument completions AFTER attach-server is selected + const attachLine = fishFile.split('\n').find((l) => + l.startsWith('complete') && l.includes('__fish_seen_subcommand_from attach-server')); + expect(attachLine, 'attach-server argument completion must exist').toBeDefined(); + expect(attachLine, 'attach-server must use __mcpctl_available_servers').toContain('__mcpctl_available_servers'); + }); + + it('detach-server completes with project servers', () => { + const detachLine = fishFile.split('\n').find((l) => + l.startsWith('complete') && l.includes('__fish_seen_subcommand_from detach-server')); + expect(detachLine, 'detach-server argument completion must exist').toBeDefined(); + expect(detachLine, 'detach-server must use __mcpctl_project_servers').toContain('__mcpctl_project_servers'); + }); + it('non-project commands do not show with --project', () => { const nonProjectCmds = ['status', 'login', 'logout', 'config', 'apply', 'backup', 'restore']; const lines = fishFile.split('\n').filter((l) => l.startsWith('complete') && l.includes('-a ')); @@ -121,8 +142,21 @@ describe('bash completions', () => { expect(bashFile).toMatch(/get\|describe\|delete\)[\s\S]*?_mcpctl_resource_names/); }); - it('offers server names for attach-server/detach-server', () => { - expect(bashFile).toMatch(/attach-server\|detach-server\)[\s\S]*?_mcpctl_resource_names.*servers/); + it('attach-server filters out already-attached servers using project context', () => { + const attachBlock = bashFile.match(/attach-server\)[\s\S]*?return ;;/)?.[0] ?? ''; + expect(attachBlock, 'attach-server must use _mcpctl_get_project_value').toContain('_mcpctl_get_project_value'); + expect(attachBlock, 'attach-server must query project servers to exclude').toContain('--project'); + }); + + it('detach-server shows only project servers', () => { + const detachBlock = bashFile.match(/detach-server\)[\s\S]*?return ;;/)?.[0] ?? ''; + expect(detachBlock, 'detach-server must use _mcpctl_get_project_value').toContain('_mcpctl_get_project_value'); + expect(detachBlock, 'detach-server must query project servers').toContain('--project'); + }); + + it('instances use server.name instead of name', () => { + const fnMatch = bashFile.match(/_mcpctl_resource_names\(\)[\s\S]*?\n\s*\}/)?.[0] ?? ''; + expect(fnMatch, 'must handle instances via .server.name').toContain('.server.name'); }); it('defines --project option', () => {