Compare commits
22 Commits
feat/proje
...
feat/mcp-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e17a2282e8 | ||
| 01d3c4e02d | |||
|
|
e4affe5962 | ||
| c75e7cdf4d | |||
|
|
65c340a03c | ||
| 677d34b868 | |||
|
|
c5b8cb60b7 | ||
| 9a5deffb8f | |||
|
|
ec7ada5383 | ||
| b81d3be2d5 | |||
|
|
e2c54bfc5c | ||
| 7b7854b007 | |||
|
|
f23dd99662 | ||
| 43af85cb58 | |||
|
|
6d2e3c2eb3 | ||
| ce21db3853 | |||
|
|
767725023e | ||
| 2bd1b55fe8 | |||
|
|
0f2a93f2f0 | ||
| ce81d9d616 | |||
|
|
c6cc39c6f7 | ||
| de074d9a90 |
@@ -2,14 +2,83 @@ _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 help"
|
local commands="status login logout config get describe delete logs create edit apply backup restore mcp help"
|
||||||
local global_opts="-v --version --daemon-url --direct -h --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"
|
||||||
|
|
||||||
case "${words[1]}" in
|
# Check if --project was given
|
||||||
|
local has_project=false
|
||||||
|
local i
|
||||||
|
for ((i=1; i < cword; i++)); do
|
||||||
|
if [[ "${words[i]}" == "--project" ]]; then
|
||||||
|
has_project=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Find the first subcommand (skip --project and its argument, skip flags)
|
||||||
|
local subcmd=""
|
||||||
|
local subcmd_pos=0
|
||||||
|
for ((i=1; i < cword; i++)); do
|
||||||
|
if [[ "${words[i]}" == "--project" || "${words[i]}" == "--daemon-url" ]]; then
|
||||||
|
((i++)) # skip the argument
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ "${words[i]}" != -* ]]; then
|
||||||
|
subcmd="${words[i]}"
|
||||||
|
subcmd_pos=$i
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Find the resource type after get/describe/delete/edit
|
||||||
|
local resource_type=""
|
||||||
|
if [[ -n "$subcmd_pos" ]] && [[ $subcmd_pos -gt 0 ]]; then
|
||||||
|
for ((i=subcmd_pos+1; i < cword; i++)); do
|
||||||
|
if [[ "${words[i]}" != -* ]] && [[ " $resources " == *" ${words[i]} "* ]]; then
|
||||||
|
resource_type="${words[i]}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If completing the --project value
|
||||||
|
if [[ "$prev" == "--project" ]]; then
|
||||||
|
local names
|
||||||
|
names=$(mcpctl get projects -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null)
|
||||||
|
COMPREPLY=($(compgen -W "$names" -- "$cur"))
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fetch resource names dynamically (jq extracts only top-level names)
|
||||||
|
_mcpctl_resource_names() {
|
||||||
|
local rt="$1"
|
||||||
|
if [[ -n "$rt" ]]; then
|
||||||
|
# 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)
|
config)
|
||||||
if [[ $cword -eq 2 ]]; then
|
if [[ $((cword - subcmd_pos)) -eq 1 ]]; then
|
||||||
COMPREPLY=($(compgen -W "view set path reset claude-generate impersonate help" -- "$cur"))
|
COMPREPLY=($(compgen -W "view set path reset claude impersonate help" -- "$cur"))
|
||||||
fi
|
fi
|
||||||
return ;;
|
return ;;
|
||||||
status)
|
status)
|
||||||
@@ -20,35 +89,31 @@ _mcpctl() {
|
|||||||
return ;;
|
return ;;
|
||||||
logout)
|
logout)
|
||||||
return ;;
|
return ;;
|
||||||
get)
|
mcp)
|
||||||
if [[ $cword -eq 2 ]]; then
|
return ;;
|
||||||
|
get|describe|delete)
|
||||||
|
if [[ -z "$resource_type" ]]; then
|
||||||
COMPREPLY=($(compgen -W "$resources" -- "$cur"))
|
COMPREPLY=($(compgen -W "$resources" -- "$cur"))
|
||||||
else
|
else
|
||||||
COMPREPLY=($(compgen -W "-o --output -h --help" -- "$cur"))
|
local names
|
||||||
fi
|
names=$(_mcpctl_resource_names "$resource_type")
|
||||||
return ;;
|
COMPREPLY=($(compgen -W "$names -o --output -h --help" -- "$cur"))
|
||||||
describe)
|
|
||||||
if [[ $cword -eq 2 ]]; then
|
|
||||||
COMPREPLY=($(compgen -W "$resources" -- "$cur"))
|
|
||||||
else
|
|
||||||
COMPREPLY=($(compgen -W "-o --output --show-values -h --help" -- "$cur"))
|
|
||||||
fi
|
|
||||||
return ;;
|
|
||||||
delete)
|
|
||||||
if [[ $cword -eq 2 ]]; then
|
|
||||||
COMPREPLY=($(compgen -W "$resources" -- "$cur"))
|
|
||||||
fi
|
fi
|
||||||
return ;;
|
return ;;
|
||||||
edit)
|
edit)
|
||||||
if [[ $cword -eq 2 ]]; then
|
if [[ -z "$resource_type" ]]; then
|
||||||
COMPREPLY=($(compgen -W "servers projects" -- "$cur"))
|
COMPREPLY=($(compgen -W "servers projects" -- "$cur"))
|
||||||
|
else
|
||||||
|
local names
|
||||||
|
names=$(_mcpctl_resource_names "$resource_type")
|
||||||
|
COMPREPLY=($(compgen -W "$names -h --help" -- "$cur"))
|
||||||
fi
|
fi
|
||||||
return ;;
|
return ;;
|
||||||
logs)
|
logs)
|
||||||
COMPREPLY=($(compgen -W "--tail --since -f --follow -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "--tail --since -f --follow -h --help" -- "$cur"))
|
||||||
return ;;
|
return ;;
|
||||||
create)
|
create)
|
||||||
if [[ $cword -eq 2 ]]; 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 help" -- "$cur"))
|
||||||
fi
|
fi
|
||||||
return ;;
|
return ;;
|
||||||
@@ -61,13 +126,42 @@ _mcpctl() {
|
|||||||
restore)
|
restore)
|
||||||
COMPREPLY=($(compgen -W "-i --input -p --password -c --conflict -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "-i --input -p --password -c --conflict -h --help" -- "$cur"))
|
||||||
return ;;
|
return ;;
|
||||||
|
attach-server)
|
||||||
|
# Only complete if no server arg given yet (first arg after subcmd)
|
||||||
|
if [[ $((cword - subcmd_pos)) -ne 1 ]]; then return; fi
|
||||||
|
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)
|
||||||
|
# Only complete if no server arg given yet (first arg after subcmd)
|
||||||
|
if [[ $((cword - subcmd_pos)) -ne 1 ]]; then return; fi
|
||||||
|
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)
|
help)
|
||||||
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
||||||
return ;;
|
return ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
if [[ $cword -eq 1 ]]; then
|
# No subcommand yet — offer commands based on context
|
||||||
COMPREPLY=($(compgen -W "$commands $global_opts" -- "$cur"))
|
if [[ -z "$subcmd" ]]; then
|
||||||
|
if $has_project; then
|
||||||
|
COMPREPLY=($(compgen -W "$project_commands $global_opts" -- "$cur"))
|
||||||
|
else
|
||||||
|
COMPREPLY=($(compgen -W "$commands $global_opts" -- "$cur"))
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
# mcpctl fish completions
|
# mcpctl fish completions
|
||||||
|
|
||||||
set -l commands status login logout config get describe delete logs create edit apply backup restore help
|
# 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 project_commands attach-server detach-server get describe delete logs create edit help
|
||||||
|
|
||||||
# Disable file completions by default
|
# Disable file completions by default
|
||||||
complete -c mcpctl -f
|
complete -c mcpctl -f
|
||||||
@@ -9,30 +13,179 @@ complete -c mcpctl -f
|
|||||||
complete -c mcpctl -s v -l version -d 'Show version'
|
complete -c mcpctl -s v -l version -d 'Show version'
|
||||||
complete -c mcpctl -l daemon-url -d 'mcplocal daemon URL' -x
|
complete -c mcpctl -l daemon-url -d 'mcplocal daemon URL' -x
|
||||||
complete -c mcpctl -l direct -d 'Bypass mcplocal, connect directly to mcpd'
|
complete -c mcpctl -l direct -d 'Bypass mcplocal, connect directly to mcpd'
|
||||||
|
complete -c mcpctl -l project -d 'Target project context' -x
|
||||||
complete -c mcpctl -s h -l help -d 'Show help'
|
complete -c mcpctl -s h -l help -d 'Show help'
|
||||||
|
|
||||||
# Top-level commands
|
# Helper: check if --project was given
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a status -d 'Show status and connectivity'
|
function __mcpctl_has_project
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a login -d 'Authenticate with mcpd'
|
set -l tokens (commandline -opc)
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a logout -d 'Log out'
|
for i in (seq (count $tokens))
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a config -d 'Manage configuration'
|
if test "$tokens[$i]" = "--project"
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a get -d 'List resources'
|
return 0
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a describe -d 'Show resource details'
|
end
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a delete -d 'Delete a resource'
|
end
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a logs -d 'Get instance logs'
|
return 1
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource'
|
end
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a edit -d 'Edit a resource'
|
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a apply -d 'Apply configuration from file'
|
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a backup -d 'Backup configuration'
|
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a restore -d 'Restore from backup'
|
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a help -d 'Show help'
|
|
||||||
|
|
||||||
# Resource types for 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
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete" -a "$resources" -d 'Resource type'
|
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from edit" -a 'servers projects' -d 'Resource type'
|
|
||||||
|
|
||||||
# get/describe/delete options
|
function __mcpctl_needs_resource_type
|
||||||
|
set -l tokens (commandline -opc)
|
||||||
|
set -l found_cmd false
|
||||||
|
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
|
||||||
|
return 1 # resource type already present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if contains -- $tok get describe delete edit
|
||||||
|
set found_cmd true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if $found_cmd
|
||||||
|
return 0 # command found but no resource type yet
|
||||||
|
end
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
|
||||||
|
function __mcpctl_get_resource_type
|
||||||
|
set -l tokens (commandline -opc)
|
||||||
|
set -l found_cmd false
|
||||||
|
for tok in $tokens
|
||||||
|
if $found_cmd
|
||||||
|
if contains -- $tok servers instances secrets templates projects users groups rbac
|
||||||
|
echo $tok
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if contains -- $tok get describe delete edit
|
||||||
|
set found_cmd true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Fetch resource names dynamically from the API (jq extracts only top-level names)
|
||||||
|
function __mcpctl_resource_names
|
||||||
|
set -l resource (__mcpctl_get_resource_type)
|
||||||
|
if test -z "$resource"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
# 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
|
||||||
|
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)'
|
||||||
|
|
||||||
|
# Top-level commands (without --project)
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a status -d 'Show status and connectivity'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a login -d 'Authenticate with mcpd'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logout -d 'Log out'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a config -d 'Manage configuration'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a get -d 'List resources'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a describe -d 'Show resource details'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a delete -d 'Delete a resource'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logs -d 'Get instance logs'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a edit -d 'Edit a resource'
|
||||||
|
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 help -d 'Show help'
|
||||||
|
|
||||||
|
# Project-scoped commands (with --project)
|
||||||
|
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a attach-server -d 'Attach a server to the project'
|
||||||
|
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a detach-server -d 'Detach a server from the project'
|
||||||
|
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a get -d 'List resources (scoped to project)'
|
||||||
|
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a describe -d 'Show resource details'
|
||||||
|
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a delete -d 'Delete a resource'
|
||||||
|
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a logs -d 'Get instance logs'
|
||||||
|
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a create -d 'Create a resource'
|
||||||
|
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a edit -d 'Edit a resource'
|
||||||
|
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a help -d 'Show help'
|
||||||
|
|
||||||
|
# Resource types — only when resource type not yet selected
|
||||||
|
complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete; and __mcpctl_needs_resource_type" -a "$resources" -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
|
||||||
|
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'
|
||||||
|
|
||||||
|
# Helper: check if attach-server/detach-server already has a server argument
|
||||||
|
function __mcpctl_needs_server_arg
|
||||||
|
set -l tokens (commandline -opc)
|
||||||
|
set -l found_cmd false
|
||||||
|
for tok in $tokens
|
||||||
|
if $found_cmd
|
||||||
|
if not string match -q -- '-*' $tok
|
||||||
|
return 1 # server arg already present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if contains -- $tok attach-server detach-server
|
||||||
|
set found_cmd true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if $found_cmd
|
||||||
|
return 0 # command found but no server arg yet
|
||||||
|
end
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
|
||||||
|
# attach-server: show servers NOT in the project (only if no server arg yet)
|
||||||
|
complete -c mcpctl -n "__fish_seen_subcommand_from attach-server; and __mcpctl_needs_server_arg" -a '(__mcpctl_available_servers)' -d 'Server'
|
||||||
|
|
||||||
|
# detach-server: show servers IN the project (only if no server arg yet)
|
||||||
|
complete -c mcpctl -n "__fish_seen_subcommand_from detach-server; and __mcpctl_needs_server_arg" -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 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'
|
complete -c mcpctl -n "__fish_seen_subcommand_from describe" -s o -l output -d 'Output format' -xa 'detail json yaml'
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from describe" -l show-values -d 'Show secret values'
|
complete -c mcpctl -n "__fish_seen_subcommand_from describe" -l show-values -d 'Show secret values'
|
||||||
@@ -43,12 +196,12 @@ 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-generate impersonate
|
set -l config_cmds view set path reset claude claude-generate 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-generate -d 'Generate .mcp.json'
|
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 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
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ release: "1"
|
|||||||
maintainer: michal
|
maintainer: michal
|
||||||
description: kubectl-like CLI for managing MCP servers
|
description: kubectl-like CLI for managing MCP servers
|
||||||
license: MIT
|
license: MIT
|
||||||
|
depends:
|
||||||
|
- jq
|
||||||
contents:
|
contents:
|
||||||
- src: ./dist/mcpctl
|
- src: ./dist/mcpctl
|
||||||
dst: /usr/bin/mcpctl
|
dst: /usr/bin/mcpctl
|
||||||
|
|||||||
55
pr.sh
Executable file
55
pr.sh
Executable file
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage: bash pr.sh "PR title" "PR body"
|
||||||
|
# Loads GITEA_TOKEN from .env automatically
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Load .env if GITEA_TOKEN not already exported
|
||||||
|
if [ -z "${GITEA_TOKEN:-}" ] && [ -f .env ]; then
|
||||||
|
set -a
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
GITEA_URL="${GITEA_URL:-http://10.0.0.194:3012}"
|
||||||
|
REPO="${GITEA_OWNER:-michal}/mcpctl"
|
||||||
|
|
||||||
|
TITLE="${1:?Usage: pr.sh <title> [body]}"
|
||||||
|
BODY="${2:-}"
|
||||||
|
BASE="${3:-main}"
|
||||||
|
HEAD=$(git rev-parse --abbrev-ref HEAD)
|
||||||
|
|
||||||
|
if [ "$HEAD" = "$BASE" ]; then
|
||||||
|
echo "Error: already on $BASE, switch to a feature branch first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||||
|
echo "Error: GITEA_TOKEN not set and .env not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Push if needed
|
||||||
|
if ! git rev-parse --verify "origin/$HEAD" &>/dev/null; then
|
||||||
|
git push -u origin "$HEAD"
|
||||||
|
else
|
||||||
|
git push
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create PR
|
||||||
|
RESPONSE=$(curl -s -X POST "$GITEA_URL/api/v1/repos/$REPO/pulls" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n --arg t "$TITLE" --arg b "$BODY" --arg h "$HEAD" --arg base "$BASE" \
|
||||||
|
'{title: $t, body: $b, head: $h, base: $base}')")
|
||||||
|
|
||||||
|
PR_NUM=$(echo "$RESPONSE" | jq -r '.number // empty')
|
||||||
|
PR_URL=$(echo "$RESPONSE" | jq -r '.html_url // empty')
|
||||||
|
|
||||||
|
if [ -z "$PR_NUM" ]; then
|
||||||
|
echo "Error creating PR:" >&2
|
||||||
|
echo "$RESPONSE" | jq . 2>/dev/null || echo "$RESPONSE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "PR #$PR_NUM: https://mysources.co.uk/$REPO/pulls/$PR_NUM"
|
||||||
@@ -24,7 +24,10 @@ export class ApiError extends Error {
|
|||||||
function request<T>(method: string, url: string, timeout: number, body?: unknown, token?: string): Promise<ApiResponse<T>> {
|
function request<T>(method: string, url: string, timeout: number, body?: unknown, token?: string): Promise<ApiResponse<T>> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const headers: Record<string, string> = {};
|
||||||
|
if (body !== undefined) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
if (token) {
|
if (token) {
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type { CredentialsDeps, StoredCredentials } from '../auth/index.js';
|
|||||||
import type { ApiClient } from '../api-client.js';
|
import type { ApiClient } from '../api-client.js';
|
||||||
|
|
||||||
interface McpConfig {
|
interface McpConfig {
|
||||||
mcpServers: Record<string, { command: string; args: string[]; env?: Record<string, string> }>;
|
mcpServers: Record<string, { command?: string; args?: string[]; url?: string; env?: Record<string, string> }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigCommandDeps {
|
export interface ConfigCommandDeps {
|
||||||
@@ -84,21 +84,27 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
|
|||||||
log('Configuration reset to defaults');
|
log('Configuration reset to defaults');
|
||||||
});
|
});
|
||||||
|
|
||||||
if (apiDeps) {
|
// claude/claude-generate: generate .mcp.json pointing at mcpctl mcp bridge
|
||||||
const { client, credentialsDeps, log: apiLog } = apiDeps;
|
function registerClaudeCommand(name: string, hidden: boolean): void {
|
||||||
|
const cmd = config
|
||||||
config
|
.command(name)
|
||||||
.command('claude-generate')
|
.description(hidden ? '' : 'Generate .mcp.json that connects a project via mcpctl mcp bridge')
|
||||||
.description('Generate .mcp.json from a project configuration')
|
|
||||||
.requiredOption('--project <name>', 'Project name')
|
.requiredOption('--project <name>', 'Project name')
|
||||||
.option('-o, --output <path>', 'Output file path', '.mcp.json')
|
.option('-o, --output <path>', 'Output file path', '.mcp.json')
|
||||||
.option('--merge', 'Merge with existing .mcp.json instead of overwriting')
|
.option('--merge', 'Merge with existing .mcp.json instead of overwriting')
|
||||||
.option('--stdout', 'Print to stdout instead of writing a file')
|
.option('--stdout', 'Print to stdout instead of writing a file')
|
||||||
.action(async (opts: { project: string; output: string; merge?: boolean; stdout?: boolean }) => {
|
.action((opts: { project: string; output: string; merge?: boolean; stdout?: boolean }) => {
|
||||||
const mcpConfig = await client.get<McpConfig>(`/api/v1/projects/${opts.project}/mcp-config`);
|
const mcpConfig: McpConfig = {
|
||||||
|
mcpServers: {
|
||||||
|
[opts.project]: {
|
||||||
|
command: 'mcpctl',
|
||||||
|
args: ['mcp', '-p', opts.project],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
if (opts.stdout) {
|
if (opts.stdout) {
|
||||||
apiLog(JSON.stringify(mcpConfig, null, 2));
|
log(JSON.stringify(mcpConfig, null, 2));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,8 +127,19 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
|
|||||||
|
|
||||||
writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n');
|
writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n');
|
||||||
const serverCount = Object.keys(finalConfig.mcpServers).length;
|
const serverCount = Object.keys(finalConfig.mcpServers).length;
|
||||||
apiLog(`Wrote ${outputPath} (${serverCount} server(s))`);
|
log(`Wrote ${outputPath} (${serverCount} server(s))`);
|
||||||
});
|
});
|
||||||
|
if (hidden) {
|
||||||
|
// Commander shows empty-description commands but they won't clutter help output
|
||||||
|
void cmd; // suppress unused lint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerClaudeCommand('claude', false);
|
||||||
|
registerClaudeCommand('claude-generate', true); // backward compat
|
||||||
|
|
||||||
|
if (apiDeps) {
|
||||||
|
const { client, credentialsDeps, log: apiLog } = apiDeps;
|
||||||
|
|
||||||
config
|
config
|
||||||
.command('impersonate')
|
.command('impersonate')
|
||||||
|
|||||||
196
src/cli/src/commands/mcp.ts
Normal file
196
src/cli/src/commands/mcp.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import http from 'node:http';
|
||||||
|
import { createInterface } from 'node:readline';
|
||||||
|
|
||||||
|
export interface McpBridgeOptions {
|
||||||
|
projectName: string;
|
||||||
|
mcplocalUrl: string;
|
||||||
|
token?: string | undefined;
|
||||||
|
stdin: NodeJS.ReadableStream;
|
||||||
|
stdout: NodeJS.WritableStream;
|
||||||
|
stderr: NodeJS.WritableStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
function postJsonRpc(
|
||||||
|
url: string,
|
||||||
|
body: string,
|
||||||
|
sessionId: string | undefined,
|
||||||
|
token: string | undefined,
|
||||||
|
): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
};
|
||||||
|
if (sessionId) {
|
||||||
|
headers['mcp-session-id'] = sessionId;
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
port: parsed.port,
|
||||||
|
path: parsed.pathname,
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
timeout: 30_000,
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
res.on('end', () => {
|
||||||
|
resolve({
|
||||||
|
status: res.statusCode ?? 0,
|
||||||
|
headers: res.headers,
|
||||||
|
body: Buffer.concat(chunks).toString('utf-8'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on('error', reject);
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('Request timed out'));
|
||||||
|
});
|
||||||
|
req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendDelete(
|
||||||
|
url: string,
|
||||||
|
sessionId: string,
|
||||||
|
token: string | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'mcp-session-id': sessionId,
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
port: parsed.port,
|
||||||
|
path: parsed.pathname,
|
||||||
|
method: 'DELETE',
|
||||||
|
headers,
|
||||||
|
timeout: 5_000,
|
||||||
|
},
|
||||||
|
() => resolve(),
|
||||||
|
);
|
||||||
|
req.on('error', () => resolve()); // Best effort cleanup
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STDIO-to-Streamable-HTTP MCP bridge.
|
||||||
|
*
|
||||||
|
* Reads JSON-RPC messages line-by-line from stdin, POSTs them to
|
||||||
|
* mcplocal's project endpoint, and writes responses to stdout.
|
||||||
|
*/
|
||||||
|
export async function runMcpBridge(opts: McpBridgeOptions): Promise<void> {
|
||||||
|
const { projectName, mcplocalUrl, token, stdin, stdout, stderr } = opts;
|
||||||
|
const endpointUrl = `${mcplocalUrl.replace(/\/$/, '')}/projects/${encodeURIComponent(projectName)}/mcp`;
|
||||||
|
|
||||||
|
let sessionId: string | undefined;
|
||||||
|
|
||||||
|
const rl = createInterface({ input: stdin, crlfDelay: Infinity });
|
||||||
|
|
||||||
|
for await (const line of rl) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await postJsonRpc(endpointUrl, trimmed, sessionId, token);
|
||||||
|
|
||||||
|
// Capture session ID from first response
|
||||||
|
if (!sessionId) {
|
||||||
|
const sid = result.headers['mcp-session-id'];
|
||||||
|
if (typeof sid === 'string') {
|
||||||
|
sessionId = sid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status >= 400) {
|
||||||
|
stderr.write(`MCP bridge error: HTTP ${result.status}: ${result.body}\n`);
|
||||||
|
// Still forward the response body — it may contain a JSON-RPC error
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout.write(result.body + '\n');
|
||||||
|
} catch (err) {
|
||||||
|
stderr.write(`MCP bridge error: ${err instanceof Error ? err.message : String(err)}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stdin closed — cleanup session
|
||||||
|
if (sessionId) {
|
||||||
|
await sendDelete(endpointUrl, sessionId, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpCommandDeps {
|
||||||
|
getProject: () => string | undefined;
|
||||||
|
configLoader?: () => { mcplocalUrl: string };
|
||||||
|
credentialsLoader?: () => { token: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMcpCommand(deps: McpCommandDeps): Command {
|
||||||
|
const cmd = new Command('mcp')
|
||||||
|
.description('MCP STDIO transport bridge — connects stdin/stdout to a project MCP endpoint')
|
||||||
|
.action(async () => {
|
||||||
|
const projectName = deps.getProject();
|
||||||
|
if (!projectName) {
|
||||||
|
process.stderr.write('Error: --project is required for the mcp command\n');
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mcplocalUrl = 'http://localhost:3200';
|
||||||
|
if (deps.configLoader) {
|
||||||
|
mcplocalUrl = deps.configLoader().mcplocalUrl;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { loadConfig } = await import('../config/index.js');
|
||||||
|
mcplocalUrl = loadConfig().mcplocalUrl;
|
||||||
|
} catch {
|
||||||
|
// Use default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let token: string | undefined;
|
||||||
|
if (deps.credentialsLoader) {
|
||||||
|
token = deps.credentialsLoader()?.token;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { loadCredentials } = await import('../auth/index.js');
|
||||||
|
token = loadCredentials()?.token;
|
||||||
|
} catch {
|
||||||
|
// No credentials
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await runMcpBridge({
|
||||||
|
projectName,
|
||||||
|
mcplocalUrl,
|
||||||
|
token,
|
||||||
|
stdin: process.stdin,
|
||||||
|
stdout: process.stdout,
|
||||||
|
stderr: process.stderr,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return cmd;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { createEditCommand } from './commands/edit.js';
|
|||||||
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
|
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
|
||||||
import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
|
import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
|
||||||
import { createAttachServerCommand, createDetachServerCommand } from './commands/project-ops.js';
|
import { createAttachServerCommand, createDetachServerCommand } from './commands/project-ops.js';
|
||||||
|
import { createMcpCommand } from './commands/mcp.js';
|
||||||
import { ApiClient, ApiError } from './api-client.js';
|
import { ApiClient, ApiError } from './api-client.js';
|
||||||
import { loadConfig } from './config/index.js';
|
import { loadConfig } from './config/index.js';
|
||||||
import { loadCredentials } from './auth/index.js';
|
import { loadCredentials } from './auth/index.js';
|
||||||
@@ -54,6 +55,21 @@ export function createProgram(): Command {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const fetchResource = async (resource: string, nameOrId?: string): Promise<unknown[]> => {
|
const fetchResource = async (resource: string, nameOrId?: string): Promise<unknown[]> => {
|
||||||
|
const projectName = program.opts().project as string | undefined;
|
||||||
|
|
||||||
|
// --project scoping for servers and instances
|
||||||
|
if (projectName && !nameOrId && (resource === 'servers' || resource === 'instances')) {
|
||||||
|
const projectId = await resolveNameOrId(client, 'projects', projectName);
|
||||||
|
if (resource === 'servers') {
|
||||||
|
return client.get<unknown[]>(`/api/v1/projects/${projectId}/servers`);
|
||||||
|
}
|
||||||
|
// instances: fetch project servers, then filter instances by serverId
|
||||||
|
const projectServers = await client.get<Array<{ id: string }>>(`/api/v1/projects/${projectId}/servers`);
|
||||||
|
const serverIds = new Set(projectServers.map((s) => s.id));
|
||||||
|
const allInstances = await client.get<Array<{ serverId: string }>>(`/api/v1/instances`);
|
||||||
|
return allInstances.filter((inst) => serverIds.has(inst.serverId));
|
||||||
|
}
|
||||||
|
|
||||||
if (nameOrId) {
|
if (nameOrId) {
|
||||||
// Glob pattern — use query param filtering
|
// Glob pattern — use query param filtering
|
||||||
if (nameOrId.includes('*')) {
|
if (nameOrId.includes('*')) {
|
||||||
@@ -133,8 +149,11 @@ export function createProgram(): Command {
|
|||||||
log: (...args: string[]) => console.log(...args),
|
log: (...args: string[]) => console.log(...args),
|
||||||
getProject: () => program.opts().project as string | undefined,
|
getProject: () => program.opts().project as string | undefined,
|
||||||
};
|
};
|
||||||
program.addCommand(createAttachServerCommand(projectOpsDeps));
|
program.addCommand(createAttachServerCommand(projectOpsDeps), { hidden: true });
|
||||||
program.addCommand(createDetachServerCommand(projectOpsDeps));
|
program.addCommand(createDetachServerCommand(projectOpsDeps), { hidden: true });
|
||||||
|
program.addCommand(createMcpCommand({
|
||||||
|
getProject: () => program.opts().project as string | undefined,
|
||||||
|
}), { hidden: true });
|
||||||
|
|
||||||
return program;
|
return program;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ beforeAll(async () => {
|
|||||||
res.writeHead(201, { 'Content-Type': 'application/json' });
|
res.writeHead(201, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ id: 'srv-new', ...body }));
|
res.end(JSON.stringify({ id: 'srv-new', ...body }));
|
||||||
});
|
});
|
||||||
|
} else if (req.url === '/api/v1/servers/srv-1' && req.method === 'DELETE') {
|
||||||
|
// Fastify rejects empty body with Content-Type: application/json
|
||||||
|
const ct = req.headers['content-type'] ?? '';
|
||||||
|
if (ct.includes('application/json')) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: "Body cannot be empty when content-type is set to 'application/json'" }));
|
||||||
|
} else {
|
||||||
|
res.writeHead(204);
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
} else if (req.url === '/api/v1/missing' && req.method === 'GET') {
|
} else if (req.url === '/api/v1/missing' && req.method === 'GET') {
|
||||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: 'Not found' }));
|
res.end(JSON.stringify({ error: 'Not found' }));
|
||||||
@@ -75,6 +85,12 @@ describe('ApiClient', () => {
|
|||||||
await expect(client.get('/anything')).rejects.toThrow();
|
await expect(client.get('/anything')).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('performs DELETE without Content-Type header', async () => {
|
||||||
|
const client = new ApiClient({ baseUrl: `http://localhost:${port}` });
|
||||||
|
// Should succeed (204) because no Content-Type is sent on bodyless DELETE
|
||||||
|
await expect(client.delete('/api/v1/servers/srv-1')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('sends Authorization header when token provided', async () => {
|
it('sends Authorization header when token provided', async () => {
|
||||||
// We need a separate server to check the header
|
// We need a separate server to check the header
|
||||||
let receivedAuth = '';
|
let receivedAuth = '';
|
||||||
|
|||||||
@@ -8,19 +8,14 @@ import { saveCredentials, loadCredentials } from '../../src/auth/index.js';
|
|||||||
|
|
||||||
function mockClient(): ApiClient {
|
function mockClient(): ApiClient {
|
||||||
return {
|
return {
|
||||||
get: vi.fn(async () => ({
|
get: vi.fn(async () => ({})),
|
||||||
mcpServers: {
|
|
||||||
'slack--default': { command: 'npx', args: ['-y', '@anthropic/slack-mcp'], env: { WORKSPACE: 'test' } },
|
|
||||||
'github--default': { command: 'npx', args: ['-y', '@anthropic/github-mcp'] },
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
post: vi.fn(async () => ({ token: 'impersonated-tok', user: { email: 'other@test.com' } })),
|
post: vi.fn(async () => ({ token: 'impersonated-tok', user: { email: 'other@test.com' } })),
|
||||||
put: vi.fn(async () => ({})),
|
put: vi.fn(async () => ({})),
|
||||||
delete: vi.fn(async () => {}),
|
delete: vi.fn(async () => {}),
|
||||||
} as unknown as ApiClient;
|
} as unknown as ApiClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('config claude-generate', () => {
|
describe('config claude', () => {
|
||||||
let client: ReturnType<typeof mockClient>;
|
let client: ReturnType<typeof mockClient>;
|
||||||
let output: string[];
|
let output: string[];
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
@@ -36,18 +31,23 @@ describe('config claude-generate', () => {
|
|||||||
rmSync(tmpDir, { recursive: true, force: true });
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates .mcp.json from project config', async () => {
|
it('generates .mcp.json with mcpctl mcp bridge entry', async () => {
|
||||||
const outPath = join(tmpDir, '.mcp.json');
|
const outPath = join(tmpDir, '.mcp.json');
|
||||||
const cmd = createConfigCommand(
|
const cmd = createConfigCommand(
|
||||||
{ configDeps: { configDir: tmpDir }, log },
|
{ configDeps: { configDir: tmpDir }, log },
|
||||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||||
);
|
);
|
||||||
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath], { from: 'user' });
|
await cmd.parseAsync(['claude', '--project', 'homeautomation', '-o', outPath], { from: 'user' });
|
||||||
|
|
||||||
|
// No API call should be made
|
||||||
|
expect(client.get).not.toHaveBeenCalled();
|
||||||
|
|
||||||
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/proj-1/mcp-config');
|
|
||||||
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
||||||
expect(written.mcpServers['slack--default']).toBeDefined();
|
expect(written.mcpServers['homeautomation']).toEqual({
|
||||||
expect(output.join('\n')).toContain('2 server(s)');
|
command: 'mcpctl',
|
||||||
|
args: ['mcp', '-p', 'homeautomation'],
|
||||||
|
});
|
||||||
|
expect(output.join('\n')).toContain('1 server(s)');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prints to stdout with --stdout', async () => {
|
it('prints to stdout with --stdout', async () => {
|
||||||
@@ -55,9 +55,13 @@ describe('config claude-generate', () => {
|
|||||||
{ configDeps: { configDir: tmpDir }, log },
|
{ configDeps: { configDir: tmpDir }, log },
|
||||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||||
);
|
);
|
||||||
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '--stdout'], { from: 'user' });
|
await cmd.parseAsync(['claude', '--project', 'myproj', '--stdout'], { from: 'user' });
|
||||||
|
|
||||||
expect(output[0]).toContain('mcpServers');
|
const parsed = JSON.parse(output[0]);
|
||||||
|
expect(parsed.mcpServers['myproj']).toEqual({
|
||||||
|
command: 'mcpctl',
|
||||||
|
args: ['mcp', '-p', 'myproj'],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('merges with existing .mcp.json', async () => {
|
it('merges with existing .mcp.json', async () => {
|
||||||
@@ -70,12 +74,41 @@ describe('config claude-generate', () => {
|
|||||||
{ configDeps: { configDir: tmpDir }, log },
|
{ configDeps: { configDir: tmpDir }, log },
|
||||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||||
);
|
);
|
||||||
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath, '--merge'], { from: 'user' });
|
await cmd.parseAsync(['claude', '--project', 'proj-1', '-o', outPath, '--merge'], { from: 'user' });
|
||||||
|
|
||||||
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
||||||
expect(written.mcpServers['existing--server']).toBeDefined();
|
expect(written.mcpServers['existing--server']).toBeDefined();
|
||||||
expect(written.mcpServers['slack--default']).toBeDefined();
|
expect(written.mcpServers['proj-1']).toEqual({
|
||||||
expect(output.join('\n')).toContain('3 server(s)');
|
command: 'mcpctl',
|
||||||
|
args: ['mcp', '-p', 'proj-1'],
|
||||||
|
});
|
||||||
|
expect(output.join('\n')).toContain('2 server(s)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backward compat: claude-generate still works', async () => {
|
||||||
|
const outPath = join(tmpDir, '.mcp.json');
|
||||||
|
const cmd = createConfigCommand(
|
||||||
|
{ configDeps: { configDir: tmpDir }, log },
|
||||||
|
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||||
|
);
|
||||||
|
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath], { from: 'user' });
|
||||||
|
|
||||||
|
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
||||||
|
expect(written.mcpServers['proj-1']).toEqual({
|
||||||
|
command: 'mcpctl',
|
||||||
|
args: ['mcp', '-p', 'proj-1'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses project name as the server key', async () => {
|
||||||
|
const outPath = join(tmpDir, '.mcp.json');
|
||||||
|
const cmd = createConfigCommand(
|
||||||
|
{ configDeps: { configDir: tmpDir }, log },
|
||||||
|
);
|
||||||
|
await cmd.parseAsync(['claude', '--project', 'my-fancy-project', '-o', outPath], { from: 'user' });
|
||||||
|
|
||||||
|
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
||||||
|
expect(Object.keys(written.mcpServers)).toEqual(['my-fancy-project']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
414
src/cli/tests/commands/mcp.test.ts
Normal file
414
src/cli/tests/commands/mcp.test.ts
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import http from 'node:http';
|
||||||
|
import { Readable, Writable } from 'node:stream';
|
||||||
|
import { runMcpBridge } from '../../src/commands/mcp.js';
|
||||||
|
|
||||||
|
// ---- Mock MCP server (simulates mcplocal project endpoint) ----
|
||||||
|
|
||||||
|
interface RecordedRequest {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
headers: http.IncomingHttpHeaders;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mockServer: http.Server;
|
||||||
|
let mockPort: number;
|
||||||
|
const recorded: RecordedRequest[] = [];
|
||||||
|
let sessionCounter = 0;
|
||||||
|
|
||||||
|
function makeInitializeResponse(id: number | string) {
|
||||||
|
return JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: {
|
||||||
|
protocolVersion: '2024-11-05',
|
||||||
|
capabilities: { tools: {} },
|
||||||
|
serverInfo: { name: 'test-server', version: '1.0.0' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeToolsListResponse(id: number | string) {
|
||||||
|
return JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: {
|
||||||
|
tools: [
|
||||||
|
{ name: 'grafana/query', description: 'Query Grafana', inputSchema: { type: 'object', properties: {} } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeToolCallResponse(id: number | string) {
|
||||||
|
return JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
result: {
|
||||||
|
content: [{ type: 'text', text: 'tool result' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mockServer = http.createServer((req, res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on('data', (c: Buffer) => chunks.push(c));
|
||||||
|
req.on('end', () => {
|
||||||
|
const body = Buffer.concat(chunks).toString('utf-8');
|
||||||
|
recorded.push({ method: req.method ?? '', url: req.url ?? '', headers: req.headers, body });
|
||||||
|
|
||||||
|
if (req.method === 'DELETE') {
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url?.startsWith('/projects/')) {
|
||||||
|
let sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||||
|
|
||||||
|
// Assign session ID on first request
|
||||||
|
if (!sessionId) {
|
||||||
|
sessionCounter++;
|
||||||
|
sessionId = `session-${sessionCounter}`;
|
||||||
|
}
|
||||||
|
res.setHeader('mcp-session-id', sessionId);
|
||||||
|
|
||||||
|
// Parse JSON-RPC and respond based on method
|
||||||
|
try {
|
||||||
|
const rpc = JSON.parse(body) as { id: number | string; method: string };
|
||||||
|
let responseBody: string;
|
||||||
|
|
||||||
|
switch (rpc.method) {
|
||||||
|
case 'initialize':
|
||||||
|
responseBody = makeInitializeResponse(rpc.id);
|
||||||
|
break;
|
||||||
|
case 'tools/list':
|
||||||
|
responseBody = makeToolsListResponse(rpc.id);
|
||||||
|
break;
|
||||||
|
case 'tools/call':
|
||||||
|
responseBody = makeToolCallResponse(rpc.id);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
responseBody = JSON.stringify({ jsonrpc: '2.0', id: rpc.id, error: { code: -32601, message: 'Method not found' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(responseBody);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
mockServer.listen(0, () => {
|
||||||
|
const addr = mockServer.address();
|
||||||
|
if (addr && typeof addr === 'object') {
|
||||||
|
mockPort = addr.port;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
mockServer.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Helper to run bridge with mock streams ----
|
||||||
|
|
||||||
|
function createMockStreams() {
|
||||||
|
const stdoutChunks: string[] = [];
|
||||||
|
const stderrChunks: string[] = [];
|
||||||
|
|
||||||
|
const stdout = new Writable({
|
||||||
|
write(chunk: Buffer, _encoding, callback) {
|
||||||
|
stdoutChunks.push(chunk.toString());
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stderr = new Writable({
|
||||||
|
write(chunk: Buffer, _encoding, callback) {
|
||||||
|
stderrChunks.push(chunk.toString());
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { stdout, stderr, stdoutChunks, stderrChunks };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushAndEnd(stdin: Readable, lines: string[]) {
|
||||||
|
for (const line of lines) {
|
||||||
|
stdin.push(line + '\n');
|
||||||
|
}
|
||||||
|
stdin.push(null); // EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tests ----
|
||||||
|
|
||||||
|
describe('MCP STDIO Bridge', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
recorded.length = 0;
|
||||||
|
sessionCounter = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards initialize request and returns response', async () => {
|
||||||
|
recorded.length = 0;
|
||||||
|
const stdin = new Readable({ read() {} });
|
||||||
|
const { stdout, stdoutChunks } = createMockStreams();
|
||||||
|
|
||||||
|
const initMsg = JSON.stringify({
|
||||||
|
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||||
|
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
pushAndEnd(stdin, [initMsg]);
|
||||||
|
|
||||||
|
await runMcpBridge({
|
||||||
|
projectName: 'test-project',
|
||||||
|
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||||
|
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify request was made to correct URL
|
||||||
|
expect(recorded.some((r) => r.url === '/projects/test-project/mcp' && r.method === 'POST')).toBe(true);
|
||||||
|
|
||||||
|
// Verify response on stdout
|
||||||
|
const output = stdoutChunks.join('');
|
||||||
|
const parsed = JSON.parse(output.trim());
|
||||||
|
expect(parsed.result.serverInfo.name).toBe('test-server');
|
||||||
|
expect(parsed.result.protocolVersion).toBe('2024-11-05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends session ID on subsequent requests', async () => {
|
||||||
|
recorded.length = 0;
|
||||||
|
const stdin = new Readable({ read() {} });
|
||||||
|
const { stdout, stdoutChunks } = createMockStreams();
|
||||||
|
|
||||||
|
const initMsg = JSON.stringify({
|
||||||
|
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||||
|
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||||
|
});
|
||||||
|
const toolsListMsg = JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
||||||
|
|
||||||
|
pushAndEnd(stdin, [initMsg, toolsListMsg]);
|
||||||
|
|
||||||
|
await runMcpBridge({
|
||||||
|
projectName: 'test-project',
|
||||||
|
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||||
|
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// First POST should NOT have mcp-session-id header
|
||||||
|
const firstPost = recorded.find((r) => r.method === 'POST' && r.body.includes('initialize'));
|
||||||
|
expect(firstPost).toBeDefined();
|
||||||
|
expect(firstPost!.headers['mcp-session-id']).toBeUndefined();
|
||||||
|
|
||||||
|
// Second POST SHOULD have mcp-session-id header
|
||||||
|
const secondPost = recorded.find((r) => r.method === 'POST' && r.body.includes('tools/list'));
|
||||||
|
expect(secondPost).toBeDefined();
|
||||||
|
expect(secondPost!.headers['mcp-session-id']).toMatch(/^session-/);
|
||||||
|
|
||||||
|
// Verify tools/list response
|
||||||
|
const lines = stdoutChunks.join('').trim().split('\n');
|
||||||
|
expect(lines.length).toBe(2);
|
||||||
|
const toolsResponse = JSON.parse(lines[1]);
|
||||||
|
expect(toolsResponse.result.tools[0].name).toBe('grafana/query');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards tools/call and returns result', async () => {
|
||||||
|
recorded.length = 0;
|
||||||
|
const stdin = new Readable({ read() {} });
|
||||||
|
const { stdout, stdoutChunks } = createMockStreams();
|
||||||
|
|
||||||
|
const initMsg = JSON.stringify({
|
||||||
|
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||||
|
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||||
|
});
|
||||||
|
const callMsg = JSON.stringify({
|
||||||
|
jsonrpc: '2.0', id: 2, method: 'tools/call',
|
||||||
|
params: { name: 'grafana/query', arguments: { query: 'test' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
pushAndEnd(stdin, [initMsg, callMsg]);
|
||||||
|
|
||||||
|
await runMcpBridge({
|
||||||
|
projectName: 'test-project',
|
||||||
|
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||||
|
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = stdoutChunks.join('').trim().split('\n');
|
||||||
|
expect(lines.length).toBe(2);
|
||||||
|
const callResponse = JSON.parse(lines[1]);
|
||||||
|
expect(callResponse.result.content[0].text).toBe('tool result');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards Authorization header when token provided', async () => {
|
||||||
|
recorded.length = 0;
|
||||||
|
const stdin = new Readable({ read() {} });
|
||||||
|
const { stdout } = createMockStreams();
|
||||||
|
|
||||||
|
const initMsg = JSON.stringify({
|
||||||
|
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||||
|
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
pushAndEnd(stdin, [initMsg]);
|
||||||
|
|
||||||
|
await runMcpBridge({
|
||||||
|
projectName: 'test-project',
|
||||||
|
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||||
|
token: 'my-secret-token',
|
||||||
|
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = recorded.find((r) => r.method === 'POST');
|
||||||
|
expect(post).toBeDefined();
|
||||||
|
expect(post!.headers['authorization']).toBe('Bearer my-secret-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send Authorization header when no token', async () => {
|
||||||
|
recorded.length = 0;
|
||||||
|
const stdin = new Readable({ read() {} });
|
||||||
|
const { stdout } = createMockStreams();
|
||||||
|
|
||||||
|
const initMsg = JSON.stringify({
|
||||||
|
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||||
|
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
pushAndEnd(stdin, [initMsg]);
|
||||||
|
|
||||||
|
await runMcpBridge({
|
||||||
|
projectName: 'test-project',
|
||||||
|
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||||
|
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = recorded.find((r) => r.method === 'POST');
|
||||||
|
expect(post).toBeDefined();
|
||||||
|
expect(post!.headers['authorization']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends DELETE to clean up session on stdin EOF', async () => {
|
||||||
|
recorded.length = 0;
|
||||||
|
const stdin = new Readable({ read() {} });
|
||||||
|
const { stdout } = createMockStreams();
|
||||||
|
|
||||||
|
const initMsg = JSON.stringify({
|
||||||
|
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||||
|
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
pushAndEnd(stdin, [initMsg]);
|
||||||
|
|
||||||
|
await runMcpBridge({
|
||||||
|
projectName: 'test-project',
|
||||||
|
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||||
|
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should have a DELETE request for session cleanup
|
||||||
|
const deleteReq = recorded.find((r) => r.method === 'DELETE');
|
||||||
|
expect(deleteReq).toBeDefined();
|
||||||
|
expect(deleteReq!.headers['mcp-session-id']).toMatch(/^session-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send DELETE if no session was established', async () => {
|
||||||
|
recorded.length = 0;
|
||||||
|
const stdin = new Readable({ read() {} });
|
||||||
|
const { stdout } = createMockStreams();
|
||||||
|
|
||||||
|
// Push EOF immediately with no messages
|
||||||
|
stdin.push(null);
|
||||||
|
|
||||||
|
await runMcpBridge({
|
||||||
|
projectName: 'test-project',
|
||||||
|
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||||
|
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recorded.filter((r) => r.method === 'DELETE')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes errors to stderr, not stdout', async () => {
|
||||||
|
recorded.length = 0;
|
||||||
|
const stdin = new Readable({ read() {} });
|
||||||
|
const { stdout, stdoutChunks, stderr, stderrChunks } = createMockStreams();
|
||||||
|
|
||||||
|
// Send to a non-existent port to trigger connection error
|
||||||
|
const badMsg = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
|
||||||
|
pushAndEnd(stdin, [badMsg]);
|
||||||
|
|
||||||
|
await runMcpBridge({
|
||||||
|
projectName: 'test-project',
|
||||||
|
mcplocalUrl: 'http://localhost:1', // will fail to connect
|
||||||
|
stdin, stdout, stderr,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error should be on stderr
|
||||||
|
expect(stderrChunks.join('')).toContain('MCP bridge error');
|
||||||
|
// stdout should be empty (no corrupted output)
|
||||||
|
expect(stdoutChunks.join('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips blank lines in stdin', async () => {
|
||||||
|
recorded.length = 0;
|
||||||
|
const stdin = new Readable({ read() {} });
|
||||||
|
const { stdout, stdoutChunks } = createMockStreams();
|
||||||
|
|
||||||
|
const initMsg = JSON.stringify({
|
||||||
|
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||||
|
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
pushAndEnd(stdin, ['', ' ', initMsg, '']);
|
||||||
|
|
||||||
|
await runMcpBridge({
|
||||||
|
projectName: 'test-project',
|
||||||
|
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||||
|
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only one POST (for the actual message)
|
||||||
|
const posts = recorded.filter((r) => r.method === 'POST');
|
||||||
|
expect(posts).toHaveLength(1);
|
||||||
|
|
||||||
|
// One response line
|
||||||
|
const lines = stdoutChunks.join('').trim().split('\n');
|
||||||
|
expect(lines).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('URL-encodes project name', async () => {
|
||||||
|
recorded.length = 0;
|
||||||
|
const stdin = new Readable({ read() {} });
|
||||||
|
const { stdout } = createMockStreams();
|
||||||
|
const { stderr } = createMockStreams();
|
||||||
|
|
||||||
|
const initMsg = JSON.stringify({
|
||||||
|
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||||
|
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
pushAndEnd(stdin, [initMsg]);
|
||||||
|
|
||||||
|
await runMcpBridge({
|
||||||
|
projectName: 'my project',
|
||||||
|
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||||
|
stdin, stdout, stderr,
|
||||||
|
});
|
||||||
|
|
||||||
|
const post = recorded.find((r) => r.method === 'POST');
|
||||||
|
expect(post?.url).toBe('/projects/my%20project/mcp');
|
||||||
|
});
|
||||||
|
});
|
||||||
176
src/cli/tests/completions.test.ts
Normal file
176
src/cli/tests/completions.test.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
||||||
|
const fishFile = readFileSync(join(root, 'completions', 'mcpctl.fish'), 'utf-8');
|
||||||
|
const bashFile = readFileSync(join(root, 'completions', 'mcpctl.bash'), 'utf-8');
|
||||||
|
|
||||||
|
describe('fish completions', () => {
|
||||||
|
it('erases stale completions at the top', () => {
|
||||||
|
const lines = fishFile.split('\n');
|
||||||
|
const firstComplete = lines.findIndex((l) => l.startsWith('complete '));
|
||||||
|
expect(lines[firstComplete]).toContain('-e');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not offer resource types without __mcpctl_needs_resource_type guard', () => {
|
||||||
|
const resourceTypes = ['servers', 'instances', 'secrets', 'templates', 'projects', 'users', 'groups', 'rbac'];
|
||||||
|
const lines = fishFile.split('\n').filter((l) => l.startsWith('complete '));
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Find lines that offer resource types as positional args
|
||||||
|
const offersResourceType = resourceTypes.some((r) => {
|
||||||
|
// Match `-a "...servers..."` or `-a 'servers projects'`
|
||||||
|
const aMatch = line.match(/-a\s+['"]([^'"]+)['"]/);
|
||||||
|
if (!aMatch) return false;
|
||||||
|
return aMatch[1].split(/\s+/).includes(r);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!offersResourceType) continue;
|
||||||
|
|
||||||
|
// Skip the help completions line and the -e line
|
||||||
|
if (line.includes('__fish_seen_subcommand_from help')) continue;
|
||||||
|
// Skip project-scoped command offerings (those offer commands, not resource types)
|
||||||
|
if (line.includes('attach-server') || line.includes('detach-server')) continue;
|
||||||
|
// Skip lines that offer commands (not resource types)
|
||||||
|
if (line.includes("-d 'Show") || line.includes("-d 'Manage") || line.includes("-d 'Authenticate") ||
|
||||||
|
line.includes("-d 'Log out'") || line.includes("-d 'Get instance") || line.includes("-d 'Create a resource'") ||
|
||||||
|
line.includes("-d 'Edit a resource'") || line.includes("-d 'Apply") || line.includes("-d 'Backup") ||
|
||||||
|
line.includes("-d 'Restore") || line.includes("-d 'List resources") || line.includes("-d 'Delete a resource'")) continue;
|
||||||
|
|
||||||
|
// Lines offering resource types MUST have __mcpctl_needs_resource_type in their condition
|
||||||
|
expect(line, `Resource type completion missing guard: ${line}`).toContain('__mcpctl_needs_resource_type');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resource name completions require resource type to be selected', () => {
|
||||||
|
const lines = fishFile.split('\n').filter((l) => l.startsWith('complete') && l.includes('__mcpctl_resource_names'));
|
||||||
|
expect(lines.length).toBeGreaterThan(0);
|
||||||
|
for (const line of lines) {
|
||||||
|
expect(line).toContain('not __mcpctl_needs_resource_type');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defines --project option', () => {
|
||||||
|
expect(fishFile).toContain("complete -c mcpctl -l project");
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resource name functions use jq .[][].name to unwrap wrapped JSON and avoid nested matches', () => {
|
||||||
|
// API returns { "resources": [...] } not [...], so .[].name fails silently.
|
||||||
|
// Must use .[][].name to unwrap the outer object then iterate the array.
|
||||||
|
// Also must not use string match regex which matches nested name fields.
|
||||||
|
const resourceNamesFn = fishFile.match(/function __mcpctl_resource_names[\s\S]*?^end/m)?.[0] ?? '';
|
||||||
|
const projectNamesFn = fishFile.match(/function __mcpctl_project_names[\s\S]*?^end/m)?.[0] ?? '';
|
||||||
|
|
||||||
|
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"/);
|
||||||
|
|
||||||
|
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 and guards against repeat', () => {
|
||||||
|
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');
|
||||||
|
expect(attachLine, 'attach-server must guard with __mcpctl_needs_server_arg').toContain('__mcpctl_needs_server_arg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detach-server completes with project servers and guards against repeat', () => {
|
||||||
|
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');
|
||||||
|
expect(detachLine, 'detach-server must guard with __mcpctl_needs_server_arg').toContain('__mcpctl_needs_server_arg');
|
||||||
|
});
|
||||||
|
|
||||||
|
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 '));
|
||||||
|
|
||||||
|
for (const cmd of nonProjectCmds) {
|
||||||
|
const cmdLines = lines.filter((l) => {
|
||||||
|
const aMatch = l.match(/-a\s+(\S+)/);
|
||||||
|
return aMatch && aMatch[1].replace(/['"]/g, '') === cmd;
|
||||||
|
});
|
||||||
|
for (const line of cmdLines) {
|
||||||
|
expect(line, `${cmd} should require 'not __mcpctl_has_project'`).toContain('not __mcpctl_has_project');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bash completions', () => {
|
||||||
|
it('separates project commands from regular commands', () => {
|
||||||
|
expect(bashFile).toContain('project_commands=');
|
||||||
|
expect(bashFile).toContain('attach-server detach-server');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks has_project before offering project commands', () => {
|
||||||
|
expect(bashFile).toContain('if $has_project');
|
||||||
|
expect(bashFile).toContain('$project_commands');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches resource names dynamically after resource type', () => {
|
||||||
|
expect(bashFile).toContain('_mcpctl_resource_names');
|
||||||
|
// get/describe/delete should use resource_names when resource_type is set
|
||||||
|
expect(bashFile).toMatch(/get\|describe\|delete\)[\s\S]*?_mcpctl_resource_names/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attach-server filters out already-attached servers and guards against repeat', () => {
|
||||||
|
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');
|
||||||
|
expect(attachBlock, 'attach-server must check position to prevent repeat').toContain('cword - subcmd_pos');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detach-server shows only project servers and guards against repeat', () => {
|
||||||
|
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');
|
||||||
|
expect(detachBlock, 'detach-server must check position to prevent repeat').toContain('cword - subcmd_pos');
|
||||||
|
});
|
||||||
|
|
||||||
|
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', () => {
|
||||||
|
expect(bashFile).toContain('--project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resource name function uses jq .[][].name to unwrap wrapped JSON and avoid nested matches', () => {
|
||||||
|
const fnMatch = bashFile.match(/_mcpctl_resource_names\(\)[\s\S]*?\n\s*\}/)?.[0] ?? '';
|
||||||
|
expect(fnMatch, '_mcpctl_resource_names must use jq .[][].name').toContain("jq -r '.[][].name'");
|
||||||
|
expect(fnMatch, '_mcpctl_resource_names must not use grep on name').not.toMatch(/grep.*"name"/);
|
||||||
|
// Guard against .[].name (single bracket) which fails on wrapped JSON
|
||||||
|
expect(fnMatch, '_mcpctl_resource_names must not use .[].name (needs .[][].name)').not.toMatch(/jq.*'\.\[\]\.name'/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { PrismaClient, Project } from '@prisma/client';
|
import type { PrismaClient, Project } from '@prisma/client';
|
||||||
|
|
||||||
export interface ProjectWithRelations extends Project {
|
export interface ProjectWithRelations extends Project {
|
||||||
servers: Array<{ id: string; server: { id: string; name: string } }>;
|
servers: Array<{ id: string; projectId: string; serverId: string; server: Record<string, unknown> & { id: string; name: string } }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROJECT_INCLUDE = {
|
const PROJECT_INCLUDE = {
|
||||||
servers: { include: { server: { select: { id: true, name: true } } } },
|
servers: { include: { server: true } },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export interface IProjectRepository {
|
export interface IProjectRepository {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import type { ProjectService } from '../services/project.service.js';
|
import type { ProjectService } from '../services/project.service.js';
|
||||||
|
|
||||||
export function registerProjectRoutes(app: FastifyInstance, service: ProjectService): void {
|
export function registerProjectRoutes(app: FastifyInstance, service: ProjectService): void {
|
||||||
app.get('/api/v1/projects', async (request) => {
|
app.get('/api/v1/projects', async () => {
|
||||||
// If authenticated, filter by owner; otherwise list all
|
// RBAC preSerialization hook handles access filtering
|
||||||
return service.list(request.userId);
|
return service.list();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => {
|
app.get<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => {
|
||||||
|
|||||||
283
src/mcpd/tests/project-routes.test.ts
Normal file
283
src/mcpd/tests/project-routes.test.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { registerProjectRoutes } from '../src/routes/projects.js';
|
||||||
|
import { ProjectService } from '../src/services/project.service.js';
|
||||||
|
import { errorHandler } from '../src/middleware/error-handler.js';
|
||||||
|
import type { IProjectRepository, ProjectWithRelations } from '../src/repositories/project.repository.js';
|
||||||
|
import type { IMcpServerRepository, ISecretRepository } from '../src/repositories/interfaces.js';
|
||||||
|
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWithRelations {
|
||||||
|
return {
|
||||||
|
id: 'proj-1',
|
||||||
|
name: 'test-project',
|
||||||
|
description: '',
|
||||||
|
ownerId: 'user-1',
|
||||||
|
proxyMode: 'direct',
|
||||||
|
llmProvider: null,
|
||||||
|
llmModel: null,
|
||||||
|
version: 1,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
servers: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockProjectRepo(): IProjectRepository {
|
||||||
|
return {
|
||||||
|
findAll: vi.fn(async () => []),
|
||||||
|
findById: vi.fn(async () => null),
|
||||||
|
findByName: vi.fn(async () => null),
|
||||||
|
create: vi.fn(async (data) => makeProject({
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
ownerId: data.ownerId,
|
||||||
|
proxyMode: data.proxyMode,
|
||||||
|
})),
|
||||||
|
update: vi.fn(async (_id, data) => makeProject({ ...data as Partial<ProjectWithRelations> })),
|
||||||
|
delete: vi.fn(async () => {}),
|
||||||
|
setServers: vi.fn(async () => {}),
|
||||||
|
addServer: vi.fn(async () => {}),
|
||||||
|
removeServer: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockServerRepo(): IMcpServerRepository {
|
||||||
|
return {
|
||||||
|
findAll: vi.fn(async () => []),
|
||||||
|
findById: vi.fn(async () => null),
|
||||||
|
findByName: vi.fn(async () => null),
|
||||||
|
create: vi.fn(async () => ({} as never)),
|
||||||
|
update: vi.fn(async () => ({} as never)),
|
||||||
|
delete: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockSecretRepo(): ISecretRepository {
|
||||||
|
return {
|
||||||
|
findAll: vi.fn(async () => []),
|
||||||
|
findById: vi.fn(async () => null),
|
||||||
|
findByName: vi.fn(async () => null),
|
||||||
|
create: vi.fn(async () => ({} as never)),
|
||||||
|
update: vi.fn(async () => ({} as never)),
|
||||||
|
delete: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function createApp(projectRepo: IProjectRepository, serverRepo?: IMcpServerRepository) {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
app.setErrorHandler(errorHandler);
|
||||||
|
const service = new ProjectService(projectRepo, serverRepo ?? mockServerRepo(), mockSecretRepo());
|
||||||
|
registerProjectRoutes(app, service);
|
||||||
|
return app.ready();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Project Routes', () => {
|
||||||
|
describe('GET /api/v1/projects', () => {
|
||||||
|
it('returns project list', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
vi.mocked(repo.findAll).mockResolvedValue([
|
||||||
|
makeProject({ id: 'p1', name: 'alpha', ownerId: 'user-1' }),
|
||||||
|
makeProject({ id: 'p2', name: 'beta', ownerId: 'user-2' }),
|
||||||
|
]);
|
||||||
|
await createApp(repo);
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/v1/projects' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json<Array<{ name: string }>>();
|
||||||
|
expect(body).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists all projects without ownerId filtering', async () => {
|
||||||
|
// This is the bug fix: the route must call list() without ownerId
|
||||||
|
// so that RBAC (preSerialization) handles access filtering, not the DB query.
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
vi.mocked(repo.findAll).mockResolvedValue([makeProject()]);
|
||||||
|
await createApp(repo);
|
||||||
|
|
||||||
|
await app.inject({ method: 'GET', url: '/api/v1/projects' });
|
||||||
|
// findAll must be called with NO arguments (undefined ownerId)
|
||||||
|
expect(repo.findAll).toHaveBeenCalledWith(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/projects/:id', () => {
|
||||||
|
it('returns 404 when not found', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
await createApp(repo);
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/v1/projects/missing' });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns project when found by ID', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
vi.mocked(repo.findById).mockResolvedValue(makeProject({ id: 'p1', name: 'my-proj' }));
|
||||||
|
await createApp(repo);
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/v1/projects/p1' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json<{ name: string }>().name).toBe('my-proj');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves by name when ID not found', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
vi.mocked(repo.findByName).mockResolvedValue(makeProject({ name: 'my-proj' }));
|
||||||
|
await createApp(repo);
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/v1/projects/my-proj' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json<{ name: string }>().name).toBe('my-proj');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/v1/projects', () => {
|
||||||
|
it('creates a project and returns 201', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
vi.mocked(repo.findById).mockResolvedValue(makeProject({ name: 'new-proj' }));
|
||||||
|
await createApp(repo);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/projects',
|
||||||
|
payload: { name: 'new-proj' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 for invalid input', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
await createApp(repo);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/projects',
|
||||||
|
payload: { name: '' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 409 when name already exists', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
vi.mocked(repo.findByName).mockResolvedValue(makeProject());
|
||||||
|
await createApp(repo);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/projects',
|
||||||
|
payload: { name: 'taken' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /api/v1/projects/:id', () => {
|
||||||
|
it('updates a project', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
vi.mocked(repo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||||
|
await createApp(repo);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/projects/p1',
|
||||||
|
payload: { description: 'Updated' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when not found', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
await createApp(repo);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/projects/missing',
|
||||||
|
payload: { description: 'x' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/v1/projects/:id', () => {
|
||||||
|
it('deletes a project and returns 204', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
vi.mocked(repo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||||
|
await createApp(repo);
|
||||||
|
const res = await app.inject({ method: 'DELETE', url: '/api/v1/projects/p1' });
|
||||||
|
expect(res.statusCode).toBe(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when not found', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
await createApp(repo);
|
||||||
|
const res = await app.inject({ method: 'DELETE', url: '/api/v1/projects/missing' });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/v1/projects/:id/servers (attach)', () => {
|
||||||
|
it('attaches a server to a project', async () => {
|
||||||
|
const projectRepo = mockProjectRepo();
|
||||||
|
const serverRepo = mockServerRepo();
|
||||||
|
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||||
|
vi.mocked(serverRepo.findByName).mockResolvedValue({ id: 'srv-1', name: 'my-ha' } as never);
|
||||||
|
await createApp(projectRepo, serverRepo);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/projects/p1/servers',
|
||||||
|
payload: { server: 'my-ha' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(projectRepo.addServer).toHaveBeenCalledWith('p1', 'srv-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when server field is missing', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
vi.mocked(repo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||||
|
await createApp(repo);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/projects/p1/servers',
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when server not found', async () => {
|
||||||
|
const projectRepo = mockProjectRepo();
|
||||||
|
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||||
|
await createApp(projectRepo);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/projects/p1/servers',
|
||||||
|
payload: { server: 'nonexistent' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/v1/projects/:id/servers/:serverName (detach)', () => {
|
||||||
|
it('detaches a server from a project', async () => {
|
||||||
|
const projectRepo = mockProjectRepo();
|
||||||
|
const serverRepo = mockServerRepo();
|
||||||
|
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||||
|
vi.mocked(serverRepo.findByName).mockResolvedValue({ id: 'srv-1', name: 'my-ha' } as never);
|
||||||
|
await createApp(projectRepo, serverRepo);
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'DELETE', url: '/api/v1/projects/p1/servers/my-ha' });
|
||||||
|
expect(res.statusCode).toBe(204);
|
||||||
|
expect(projectRepo.removeServer).toHaveBeenCalledWith('p1', 'srv-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when server not found', async () => {
|
||||||
|
const projectRepo = mockProjectRepo();
|
||||||
|
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||||
|
await createApp(projectRepo);
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'DELETE', url: '/api/v1/projects/p1/servers/nonexistent' });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,7 +35,7 @@ export async function refreshProjectUpstreams(
|
|||||||
let servers: McpdServer[];
|
let servers: McpdServer[];
|
||||||
if (authToken) {
|
if (authToken) {
|
||||||
// Forward the client's auth token to mcpd so RBAC applies
|
// Forward the client's auth token to mcpd so RBAC applies
|
||||||
const result = await mcpdClient.forward('GET', path, '', undefined);
|
const result = await mcpdClient.forward('GET', path, '', undefined, authToken);
|
||||||
if (result.status >= 400) {
|
if (result.status >= 400) {
|
||||||
throw new Error(`Failed to fetch project servers: ${result.status}`);
|
throw new Error(`Failed to fetch project servers: ${result.status}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ describe('refreshProjectUpstreams', () => {
|
|||||||
const client = mockMcpdClient(servers);
|
const client = mockMcpdClient(servers);
|
||||||
|
|
||||||
await refreshProjectUpstreams(router, client as any, 'smart-home', 'user-token-123');
|
await refreshProjectUpstreams(router, client as any, 'smart-home', 'user-token-123');
|
||||||
expect(client.forward).toHaveBeenCalledWith('GET', '/api/v1/projects/smart-home/servers', '', undefined);
|
expect(client.forward).toHaveBeenCalledWith('GET', '/api/v1/projects/smart-home/servers', '', undefined, 'user-token-123');
|
||||||
expect(router.getUpstreamNames()).toContain('grafana');
|
expect(router.getUpstreamNames()).toContain('grafana');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
82
tests.sh
Executable file
82
tests.sh
Executable file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PATH="$HOME/.npm-global/bin:$PATH"
|
||||||
|
|
||||||
|
SHORT=false
|
||||||
|
FILTER=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--short|-s) SHORT=true; shift ;;
|
||||||
|
--filter|-f) FILTER="$2"; shift 2 ;;
|
||||||
|
*) echo "Usage: tests.sh [--short|-s] [--filter|-f <package>]"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
strip_ansi() {
|
||||||
|
sed $'s/\033\[[0-9;]*m//g'
|
||||||
|
}
|
||||||
|
|
||||||
|
run_tests() {
|
||||||
|
local pkg="$1"
|
||||||
|
local label="$2"
|
||||||
|
|
||||||
|
if $SHORT; then
|
||||||
|
local tmpfile
|
||||||
|
tmpfile=$(mktemp)
|
||||||
|
trap "rm -f $tmpfile" RETURN
|
||||||
|
|
||||||
|
local exit_code=0
|
||||||
|
pnpm --filter "$pkg" test:run >"$tmpfile" 2>&1 || exit_code=$?
|
||||||
|
|
||||||
|
# Parse from cleaned output
|
||||||
|
local clean
|
||||||
|
clean=$(strip_ansi < "$tmpfile")
|
||||||
|
|
||||||
|
local tests_line files_line duration_line
|
||||||
|
tests_line=$(echo "$clean" | grep -oP 'Tests\s+\K.*' | tail -1 | xargs)
|
||||||
|
files_line=$(echo "$clean" | grep -oP 'Test Files\s+\K.*' | tail -1 | xargs)
|
||||||
|
duration_line=$(echo "$clean" | grep -oP 'Duration\s+\K[0-9.]+s' | tail -1)
|
||||||
|
|
||||||
|
if [[ $exit_code -eq 0 ]]; then
|
||||||
|
printf " \033[32mPASS\033[0m %-6s %s | %s | %s\n" "$label" "$files_line" "$tests_line" "$duration_line"
|
||||||
|
else
|
||||||
|
printf " \033[31mFAIL\033[0m %-6s %s | %s | %s\n" "$label" "$files_line" "$tests_line" "$duration_line"
|
||||||
|
echo "$clean" | grep -E 'FAIL |AssertionError|expected .* to' | head -10 | sed 's/^/ /'
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$tmpfile"
|
||||||
|
return $exit_code
|
||||||
|
else
|
||||||
|
echo "=== $label ==="
|
||||||
|
pnpm --filter "$pkg" test:run
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if $SHORT; then
|
||||||
|
echo "Running tests..."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
failed=0
|
||||||
|
|
||||||
|
if [[ -z "$FILTER" || "$FILTER" == "mcpd" ]]; then
|
||||||
|
run_tests mcpd "mcpd" || failed=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$FILTER" || "$FILTER" == "cli" ]]; then
|
||||||
|
run_tests cli "cli" || failed=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $SHORT; then
|
||||||
|
echo ""
|
||||||
|
if [[ $failed -eq 0 ]]; then
|
||||||
|
echo "All tests passed."
|
||||||
|
else
|
||||||
|
echo "Some tests FAILED."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit $failed
|
||||||
Reference in New Issue
Block a user