Compare commits
79 Commits
main
...
fix/stdio-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cb0c5ce24 | ||
| 06230ec034 | |||
|
|
079c7b3dfa | ||
|
|
7829f4fb92 | ||
|
|
fa6240107f | ||
| b34ea63d3d | |||
|
|
e17a2282e8 | ||
| 01d3c4e02d | |||
|
|
e4affe5962 | ||
| c75e7cdf4d | |||
|
|
65c340a03c | ||
| 677d34b868 | |||
|
|
c5b8cb60b7 | ||
| 9a5deffb8f | |||
|
|
ec7ada5383 | ||
| b81d3be2d5 | |||
|
|
e2c54bfc5c | ||
| 7b7854b007 | |||
|
|
f23dd99662 | ||
| 43af85cb58 | |||
|
|
6d2e3c2eb3 | ||
| ce21db3853 | |||
|
|
767725023e | ||
| 2bd1b55fe8 | |||
|
|
0f2a93f2f0 | ||
| ce81d9d616 | |||
|
|
c6cc39c6f7 | ||
| de074d9a90 | |||
|
|
783cf15179 | ||
| 5844d6c73f | |||
|
|
604bd76d60 | ||
| da14bb8c23 | |||
|
|
9e9a2f4a54 | ||
| c8cdd7f514 | |||
|
|
ec1dfe7438 | ||
| 50b4112398 | |||
|
|
bb17a892d6 | ||
| a8117091a1 | |||
|
|
dcda93d179 | ||
| a6b5e24a8d | |||
|
|
3a6e58274c | ||
|
|
c819b65175 | ||
|
|
c3ef5a664f | ||
|
|
4c2927a16e | ||
| 79dd6e723d | |||
|
|
cde1c59fd6 | ||
| daa5860ed2 | |||
|
|
ecbf48dd49 | ||
| d38b5aac60 | |||
|
|
d07d4d11dd | ||
| fa58c1b5ed | |||
|
|
dd1dfc629d | ||
| 7b3dab142e | |||
|
|
4c127a7dc3 | ||
| c1e3e4aed6 | |||
|
|
e45c6079c1 | ||
| e4aef3acf1 | |||
|
|
a2cda38850 | ||
| 081e90de0f | |||
|
|
4e3d896ef6 | ||
| 0823e965bf | |||
|
|
c97219f85e | ||
| 93adcd4be7 | |||
|
|
d58e6e153f | ||
|
|
1e8847bb63 | ||
|
|
2a0deaa225 | ||
| 4eef6e38a2 | |||
|
|
ca02340a4c | ||
|
|
02254f2aac | ||
|
|
540dd6fd63 | ||
| a05a4c4816 | |||
|
|
97ade470df | ||
|
|
b25ff98374 | ||
|
|
22fe9c3435 | ||
| 72643fceda | |||
|
|
467357c2c6 | ||
| d6a80fc03d | |||
|
|
c07da826a0 | ||
|
|
0482944056 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,6 +9,8 @@ dist/
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
stack/.env
|
||||
.portainer_password
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
@@ -35,3 +37,4 @@ pgdata/
|
||||
|
||||
# Prisma
|
||||
src/db/prisma/migrations/*.sql.backup
|
||||
logs.sh
|
||||
|
||||
@@ -2,91 +2,166 @@ _mcpctl() {
|
||||
local cur prev words cword
|
||||
_init_completion || return
|
||||
|
||||
local commands="config status get describe instance instances apply setup claude project projects backup restore help"
|
||||
local global_opts="-v --version -o --output --daemon-url -h --help"
|
||||
local resources="servers profiles projects instances"
|
||||
local commands="status login logout config get describe delete logs create edit apply backup restore mcp 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"
|
||||
|
||||
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)
|
||||
COMPREPLY=($(compgen -W "view set path reset help" -- "$cur"))
|
||||
if [[ $((cword - subcmd_pos)) -eq 1 ]]; then
|
||||
COMPREPLY=($(compgen -W "view set path reset claude impersonate help" -- "$cur"))
|
||||
fi
|
||||
return ;;
|
||||
status)
|
||||
COMPREPLY=($(compgen -W "--daemon-url -h --help" -- "$cur"))
|
||||
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
|
||||
return ;;
|
||||
get)
|
||||
if [[ $cword -eq 2 ]]; then
|
||||
login)
|
||||
COMPREPLY=($(compgen -W "--url --email --password -h --help" -- "$cur"))
|
||||
return ;;
|
||||
logout)
|
||||
return ;;
|
||||
mcp)
|
||||
return ;;
|
||||
get|describe|delete)
|
||||
if [[ -z "$resource_type" ]]; then
|
||||
COMPREPLY=($(compgen -W "$resources" -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W "-o --output --daemon-url -h --help" -- "$cur"))
|
||||
local names
|
||||
names=$(_mcpctl_resource_names "$resource_type")
|
||||
COMPREPLY=($(compgen -W "$names -o --output -h --help" -- "$cur"))
|
||||
fi
|
||||
return ;;
|
||||
describe)
|
||||
if [[ $cword -eq 2 ]]; then
|
||||
COMPREPLY=($(compgen -W "$resources" -- "$cur"))
|
||||
edit)
|
||||
if [[ -z "$resource_type" ]]; then
|
||||
COMPREPLY=($(compgen -W "servers projects" -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W "-o --output --daemon-url -h --help" -- "$cur"))
|
||||
local names
|
||||
names=$(_mcpctl_resource_names "$resource_type")
|
||||
COMPREPLY=($(compgen -W "$names -h --help" -- "$cur"))
|
||||
fi
|
||||
return ;;
|
||||
instance|instances)
|
||||
if [[ $cword -eq 2 ]]; then
|
||||
COMPREPLY=($(compgen -W "list ls start stop restart remove rm logs inspect help" -- "$cur"))
|
||||
else
|
||||
case "${words[2]}" in
|
||||
logs)
|
||||
COMPREPLY=($(compgen -W "--tail --since -h --help" -- "$cur"))
|
||||
;;
|
||||
start)
|
||||
COMPREPLY=($(compgen -W "--env --image -h --help" -- "$cur"))
|
||||
;;
|
||||
list|ls)
|
||||
COMPREPLY=($(compgen -W "--server-id -o --output -h --help" -- "$cur"))
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
logs)
|
||||
COMPREPLY=($(compgen -W "--tail --since -f --follow -h --help" -- "$cur"))
|
||||
return ;;
|
||||
claude)
|
||||
if [[ $cword -eq 2 ]]; then
|
||||
COMPREPLY=($(compgen -W "generate show add remove help" -- "$cur"))
|
||||
else
|
||||
case "${words[2]}" in
|
||||
generate|show|add|remove)
|
||||
COMPREPLY=($(compgen -W "--path -p -h --help" -- "$cur"))
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
return ;;
|
||||
project|projects)
|
||||
if [[ $cword -eq 2 ]]; then
|
||||
COMPREPLY=($(compgen -W "list ls create delete rm show profiles set-profiles help" -- "$cur"))
|
||||
else
|
||||
case "${words[2]}" in
|
||||
create)
|
||||
COMPREPLY=($(compgen -W "--description -d -h --help" -- "$cur"))
|
||||
;;
|
||||
list|ls)
|
||||
COMPREPLY=($(compgen -W "-o --output -h --help" -- "$cur"))
|
||||
;;
|
||||
esac
|
||||
create)
|
||||
if [[ $((cword - subcmd_pos)) -eq 1 ]]; then
|
||||
COMPREPLY=($(compgen -W "server secret project user group rbac help" -- "$cur"))
|
||||
fi
|
||||
return ;;
|
||||
apply)
|
||||
COMPREPLY=($(compgen -f -- "$cur"))
|
||||
return ;;
|
||||
backup)
|
||||
COMPREPLY=($(compgen -W "-o --output -p --password -r --resources -h --help" -- "$cur"))
|
||||
COMPREPLY=($(compgen -W "-o --output -p --password -h --help" -- "$cur"))
|
||||
return ;;
|
||||
restore)
|
||||
COMPREPLY=($(compgen -W "-i --input -p --password -c --conflict -h --help" -- "$cur"))
|
||||
return ;;
|
||||
setup)
|
||||
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)
|
||||
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
||||
return ;;
|
||||
esac
|
||||
|
||||
if [[ $cword -eq 1 ]]; then
|
||||
COMPREPLY=($(compgen -W "$commands $global_opts" -- "$cur"))
|
||||
# No subcommand yet — offer commands based on context
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,73 +1,226 @@
|
||||
# mcpctl fish completions
|
||||
|
||||
set -l commands config status get describe instance instances apply setup claude project projects 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
|
||||
complete -c mcpctl -f
|
||||
|
||||
# Global options
|
||||
complete -c mcpctl -s v -l version -d 'Show version'
|
||||
complete -c mcpctl -s o -l output -d 'Output format' -xa 'table json yaml'
|
||||
complete -c mcpctl -l daemon-url -d 'mcpd 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 project -d 'Target project context' -x
|
||||
complete -c mcpctl -s h -l help -d 'Show help'
|
||||
|
||||
# Top-level commands
|
||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a config -d 'Manage configuration'
|
||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a status -d 'Show status and connectivity'
|
||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a get -d 'List resources'
|
||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a describe -d 'Show resource details'
|
||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a instance -d 'Manage instances'
|
||||
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 setup -d 'Interactive setup wizard'
|
||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a claude -d 'Manage Claude .mcp.json'
|
||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a project -d 'Manage projects'
|
||||
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'
|
||||
# Helper: check if --project was given
|
||||
function __mcpctl_has_project
|
||||
set -l tokens (commandline -opc)
|
||||
for i in (seq (count $tokens))
|
||||
if test "$tokens[$i]" = "--project"
|
||||
return 0
|
||||
end
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
# get/describe resources
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from get describe" -a 'servers profiles projects instances' -d 'Resource type'
|
||||
# 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
|
||||
|
||||
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 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'
|
||||
|
||||
# login options
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from login" -l url -d 'mcpd URL' -x
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from login" -l email -d 'Email address' -x
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from login" -l password -d 'Password' -x
|
||||
|
||||
# config subcommands
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from view set path reset" -a view -d 'Show configuration'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from view set path reset" -a set -d 'Set a config value'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from view set path reset" -a path -d 'Show config file path'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from view set path reset" -a reset -d 'Reset to defaults'
|
||||
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 set -d 'Set a config value'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a path -d 'Show config file path'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a reset -d 'Reset to defaults'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude -d 'Generate .mcp.json for project'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a impersonate -d 'Impersonate a user'
|
||||
|
||||
# instance subcommands
|
||||
set -l instance_cmds list ls start stop restart remove rm logs inspect
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from instance instances; and not __fish_seen_subcommand_from $instance_cmds" -a list -d 'List instances'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from instance instances; and not __fish_seen_subcommand_from $instance_cmds" -a start -d 'Start instance'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from instance instances; and not __fish_seen_subcommand_from $instance_cmds" -a stop -d 'Stop instance'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from instance instances; and not __fish_seen_subcommand_from $instance_cmds" -a restart -d 'Restart instance'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from instance instances; and not __fish_seen_subcommand_from $instance_cmds" -a remove -d 'Remove instance'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from instance instances; and not __fish_seen_subcommand_from $instance_cmds" -a logs -d 'Get logs'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from instance instances; and not __fish_seen_subcommand_from $instance_cmds" -a inspect -d 'Inspect container'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from instance instances; and __fish_seen_subcommand_from logs" -l tail -d 'Number of lines' -x
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from instance instances; and __fish_seen_subcommand_from logs" -l since -d 'Since timestamp' -x
|
||||
# create subcommands
|
||||
set -l create_cmds server secret project user group rbac
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a server -d 'Create a server'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a secret -d 'Create a secret'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a project -d 'Create a project'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a user -d 'Create a user'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a group -d 'Create a group'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a rbac -d 'Create an RBAC binding'
|
||||
|
||||
# claude subcommands
|
||||
set -l claude_cmds generate show add remove
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from claude; and not __fish_seen_subcommand_from $claude_cmds" -a generate -d 'Generate .mcp.json'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from claude; and not __fish_seen_subcommand_from $claude_cmds" -a show -d 'Show .mcp.json'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from claude; and not __fish_seen_subcommand_from $claude_cmds" -a add -d 'Add server entry'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from claude; and not __fish_seen_subcommand_from $claude_cmds" -a remove -d 'Remove server entry'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from claude; and __fish_seen_subcommand_from $claude_cmds" -s p -l path -d 'Path to .mcp.json' -rF
|
||||
|
||||
# project subcommands
|
||||
set -l project_cmds list ls create delete rm show profiles set-profiles
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from project projects; and not __fish_seen_subcommand_from $project_cmds" -a list -d 'List projects'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from project projects; and not __fish_seen_subcommand_from $project_cmds" -a create -d 'Create project'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from project projects; and not __fish_seen_subcommand_from $project_cmds" -a delete -d 'Delete project'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from project projects; and not __fish_seen_subcommand_from $project_cmds" -a show -d 'Show project'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from project projects; and not __fish_seen_subcommand_from $project_cmds" -a profiles -d 'List profiles'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from project projects; and not __fish_seen_subcommand_from $project_cmds" -a set-profiles -d 'Set profiles'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from project projects; and __fish_seen_subcommand_from create" -s d -l description -d 'Description' -x
|
||||
# logs options
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from logs" -l tail -d 'Number of lines' -x
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from logs" -l since -d 'Since timestamp' -x
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from logs" -s f -l follow -d 'Follow log output'
|
||||
|
||||
# backup options
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from backup" -s o -l output -d 'Output file' -rF
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from backup" -s p -l password -d 'Encryption password' -x
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from backup" -s r -l resources -d 'Resources to backup' -xa 'servers profiles projects'
|
||||
|
||||
# restore options
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from restore" -s i -l input -d 'Input file' -rF
|
||||
@@ -75,6 +228,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from restore" -s p -l password -d
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from restore" -s c -l conflict -d 'Conflict strategy' -xa 'skip overwrite fail'
|
||||
|
||||
# apply takes a file
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from apply" -s f -l file -d 'Configuration file' -rF
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from apply" -F
|
||||
|
||||
# help completions
|
||||
|
||||
398
deploy.sh
Executable file
398
deploy.sh
Executable file
@@ -0,0 +1,398 @@
|
||||
#!/bin/bash
|
||||
# Deploy mcpctl stack to Portainer
|
||||
# Usage: ./deploy.sh [--dry-run]
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
STACK_DIR="$SCRIPT_DIR/stack"
|
||||
COMPOSE_FILE="$STACK_DIR/docker-compose.yml"
|
||||
ENV_FILE="$STACK_DIR/.env"
|
||||
|
||||
# Portainer configuration
|
||||
PORTAINER_URL="${PORTAINER_URL:-http://10.0.0.194:9000}"
|
||||
PORTAINER_USER="${PORTAINER_USER:-michal}"
|
||||
STACK_NAME="mcpctl"
|
||||
ENDPOINT_ID="2"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $1" >&2; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1" >&2; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
|
||||
|
||||
check_files() {
|
||||
if [[ ! -f "$COMPOSE_FILE" ]]; then
|
||||
log_error "Compose file not found: $COMPOSE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
log_error "Environment file not found: $ENV_FILE"
|
||||
exit 1
|
||||
fi
|
||||
log_info "Found compose file: $COMPOSE_FILE"
|
||||
log_info "Found env file: $ENV_FILE"
|
||||
}
|
||||
|
||||
get_password() {
|
||||
if [[ -n "$PORTAINER_PASSWORD" ]]; then
|
||||
echo "$PORTAINER_PASSWORD"
|
||||
return
|
||||
fi
|
||||
if [[ -f "$SCRIPT_DIR/.portainer_password" ]]; then
|
||||
cat "$SCRIPT_DIR/.portainer_password"
|
||||
return
|
||||
fi
|
||||
if [[ -f "$HOME/.portainer_password" ]]; then
|
||||
cat "$HOME/.portainer_password"
|
||||
return
|
||||
fi
|
||||
read -s -p "Enter Portainer password for $PORTAINER_USER: " password
|
||||
echo >&2
|
||||
echo "$password"
|
||||
}
|
||||
|
||||
get_jwt_token() {
|
||||
local password="$1"
|
||||
log_info "Authenticating to Portainer..."
|
||||
|
||||
local escaped_password
|
||||
escaped_password=$(printf '%s' "$password" | jq -Rs .)
|
||||
|
||||
local response
|
||||
response=$(curl -s -X POST "$PORTAINER_URL/api/auth" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"Username\":\"$PORTAINER_USER\",\"Password\":$escaped_password}")
|
||||
|
||||
local token
|
||||
token=$(echo "$response" | jq -r '.jwt // empty')
|
||||
|
||||
if [[ -z "$token" ]]; then
|
||||
log_error "Authentication failed: $(echo "$response" | jq -r '.message // "Unknown error"')"
|
||||
exit 1
|
||||
fi
|
||||
echo "$token"
|
||||
}
|
||||
|
||||
parse_env_to_json() {
|
||||
local env_file="$1"
|
||||
local json_array="["
|
||||
local first=true
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
[[ "$line" =~ ^#.*$ ]] && continue
|
||||
[[ -z "$line" ]] && continue
|
||||
|
||||
local name="${line%%=*}"
|
||||
local value="${line#*=}"
|
||||
[[ "$name" == "$line" ]] && continue
|
||||
|
||||
if [[ "$first" == "true" ]]; then
|
||||
first=false
|
||||
else
|
||||
json_array+=","
|
||||
fi
|
||||
|
||||
value=$(echo "$value" | sed 's/\\/\\\\/g; s/"/\\"/g')
|
||||
json_array+="{\"name\":\"$name\",\"value\":\"$value\"}"
|
||||
done < "$env_file"
|
||||
|
||||
json_array+="]"
|
||||
echo "$json_array"
|
||||
}
|
||||
|
||||
# Find existing stack by name
|
||||
find_stack_id() {
|
||||
local token="$1"
|
||||
local response
|
||||
response=$(curl -s -X GET "$PORTAINER_URL/api/stacks" \
|
||||
-H "Authorization: Bearer $token")
|
||||
|
||||
echo "$response" | jq -r --arg name "$STACK_NAME" \
|
||||
'.[] | select(.Name == $name) | .Id // empty'
|
||||
}
|
||||
|
||||
get_stack_info() {
|
||||
local token="$1"
|
||||
local stack_id="$2"
|
||||
curl -s -X GET "$PORTAINER_URL/api/stacks/$stack_id" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H "Content-Type: application/json"
|
||||
}
|
||||
|
||||
get_stack_file() {
|
||||
local token="$1"
|
||||
local stack_id="$2"
|
||||
local response
|
||||
response=$(curl -s -X GET "$PORTAINER_URL/api/stacks/$stack_id/file" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H "Content-Type: application/json")
|
||||
|
||||
if echo "$response" | jq -e '.StackFileContent' > /dev/null 2>&1; then
|
||||
echo "$response" | jq -r '.StackFileContent'
|
||||
else
|
||||
echo "# Could not retrieve current compose file"
|
||||
fi
|
||||
}
|
||||
|
||||
show_diff() {
|
||||
local token="$1"
|
||||
local stack_id="$2"
|
||||
local env_json="$3"
|
||||
|
||||
log_info "Fetching current state from Portainer..."
|
||||
|
||||
local current_compose
|
||||
current_compose=$(get_stack_file "$token" "$stack_id")
|
||||
|
||||
local current_env
|
||||
local stack_info
|
||||
stack_info=$(get_stack_info "$token" "$stack_id")
|
||||
current_env=$(echo "$stack_info" | jq -r 'if .Env then .Env[] | "\(.name)=\(.value)" else empty end' 2>/dev/null | sort)
|
||||
|
||||
local new_env
|
||||
new_env=$(echo "$env_json" | jq -r '.[] | "\(.name)=\(.value)"' | sort)
|
||||
|
||||
local tmp_dir
|
||||
tmp_dir=$(mktemp -d)
|
||||
|
||||
echo "$current_compose" > "$tmp_dir/current_compose.yml"
|
||||
cat "$COMPOSE_FILE" > "$tmp_dir/new_compose.yml"
|
||||
echo "$current_env" > "$tmp_dir/current_env.txt"
|
||||
echo "$new_env" > "$tmp_dir/new_env.txt"
|
||||
|
||||
echo ""
|
||||
echo "=== ENVIRONMENT VARIABLES DIFF ==="
|
||||
echo ""
|
||||
|
||||
if diff -u "$tmp_dir/current_env.txt" "$tmp_dir/new_env.txt" > "$tmp_dir/env_diff.txt" 2>&1; then
|
||||
echo -e "${GREEN}No changes in environment variables${NC}"
|
||||
else
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" == ---* ]] || [[ "$line" == +++* ]] || [[ "$line" == @@* ]]; then
|
||||
echo -e "${YELLOW}$line${NC}"
|
||||
elif [[ "$line" == -* ]]; then
|
||||
echo -e "${RED}$line${NC}"
|
||||
elif [[ "$line" == +* ]]; then
|
||||
echo -e "${GREEN}$line${NC}"
|
||||
else
|
||||
echo "$line"
|
||||
fi
|
||||
done < "$tmp_dir/env_diff.txt"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== COMPOSE FILE DIFF ==="
|
||||
echo ""
|
||||
|
||||
if diff -u "$tmp_dir/current_compose.yml" "$tmp_dir/new_compose.yml" > "$tmp_dir/compose_diff.txt" 2>&1; then
|
||||
echo -e "${GREEN}No changes in compose file${NC}"
|
||||
else
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" == ---* ]] || [[ "$line" == +++* ]] || [[ "$line" == @@* ]]; then
|
||||
echo -e "${YELLOW}$line${NC}"
|
||||
elif [[ "$line" == -* ]]; then
|
||||
echo -e "${RED}$line${NC}"
|
||||
elif [[ "$line" == +* ]]; then
|
||||
echo -e "${GREEN}$line${NC}"
|
||||
else
|
||||
echo "$line"
|
||||
fi
|
||||
done < "$tmp_dir/compose_diff.txt"
|
||||
fi
|
||||
|
||||
rm -rf "$tmp_dir"
|
||||
}
|
||||
|
||||
create_stack() {
|
||||
local token="$1"
|
||||
local env_json="$2"
|
||||
|
||||
local compose_content
|
||||
compose_content=$(cat "$COMPOSE_FILE")
|
||||
|
||||
local compose_escaped
|
||||
compose_escaped=$(echo "$compose_content" | jq -Rs .)
|
||||
|
||||
log_info "Creating new stack '$STACK_NAME'..."
|
||||
|
||||
local payload
|
||||
payload=$(jq -n \
|
||||
--arg name "$STACK_NAME" \
|
||||
--argjson env "$env_json" \
|
||||
--argjson stackFileContent "$compose_escaped" \
|
||||
'{
|
||||
"name": $name,
|
||||
"env": $env,
|
||||
"stackFileContent": $stackFileContent
|
||||
}')
|
||||
|
||||
local response
|
||||
response=$(curl -s -X POST "$PORTAINER_URL/api/stacks?type=2&method=string&endpointId=$ENDPOINT_ID" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload")
|
||||
|
||||
local error_msg
|
||||
error_msg=$(echo "$response" | jq -r '.message // empty')
|
||||
|
||||
if [[ -n "$error_msg" ]]; then
|
||||
log_error "Stack creation failed: $error_msg"
|
||||
echo "$response" | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local new_id
|
||||
new_id=$(echo "$response" | jq -r '.Id')
|
||||
log_info "Stack created successfully! (ID: $new_id)"
|
||||
echo "$response" | jq '{Id, Name, Status, CreationDate}'
|
||||
}
|
||||
|
||||
update_stack() {
|
||||
local token="$1"
|
||||
local stack_id="$2"
|
||||
local dry_run="$3"
|
||||
|
||||
local compose_content
|
||||
compose_content=$(cat "$COMPOSE_FILE")
|
||||
|
||||
local env_json
|
||||
env_json=$(parse_env_to_json "$ENV_FILE")
|
||||
|
||||
if [[ "$dry_run" == "true" ]]; then
|
||||
log_warn "DRY RUN - Not actually deploying"
|
||||
show_diff "$token" "$stack_id" "$env_json"
|
||||
echo ""
|
||||
log_warn "DRY RUN complete - no changes made"
|
||||
log_info "Run without --dry-run to apply these changes"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local env_count
|
||||
env_count=$(echo "$env_json" | jq 'length')
|
||||
log_info "Deploying $env_count environment variables"
|
||||
log_info "Updating stack '$STACK_NAME' (ID: $stack_id)..."
|
||||
|
||||
local compose_escaped
|
||||
compose_escaped=$(echo "$compose_content" | jq -Rs .)
|
||||
|
||||
local payload
|
||||
payload=$(jq -n \
|
||||
--argjson env "$env_json" \
|
||||
--argjson stackFileContent "$compose_escaped" \
|
||||
'{
|
||||
"env": $env,
|
||||
"stackFileContent": $stackFileContent,
|
||||
"prune": true,
|
||||
"pullImage": true
|
||||
}')
|
||||
|
||||
local response
|
||||
response=$(curl -s -X PUT "$PORTAINER_URL/api/stacks/$stack_id?endpointId=$ENDPOINT_ID" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload")
|
||||
|
||||
local error_msg
|
||||
error_msg=$(echo "$response" | jq -r '.message // empty')
|
||||
|
||||
if [[ -n "$error_msg" ]]; then
|
||||
log_error "Deployment failed: $error_msg"
|
||||
echo "$response" | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info "Stack updated successfully!"
|
||||
echo "$response" | jq '{Id, Name, Status, CreationDate, UpdateDate}'
|
||||
}
|
||||
|
||||
main() {
|
||||
local dry_run=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--dry-run)
|
||||
dry_run=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--dry-run]"
|
||||
echo ""
|
||||
echo "Deploy mcpctl stack to Portainer"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --dry-run Show what would be deployed without actually deploying"
|
||||
echo " --help Show this help message"
|
||||
echo ""
|
||||
echo "Environment variables:"
|
||||
echo " PORTAINER_URL Portainer URL (default: http://10.0.0.194:9000)"
|
||||
echo " PORTAINER_USER Portainer username (default: michal)"
|
||||
echo " PORTAINER_PASSWORD Portainer password (or store in ~/.portainer_password)"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "========================================"
|
||||
echo " mcpctl Stack Deployment"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
check_files
|
||||
|
||||
local password
|
||||
password=$(get_password)
|
||||
|
||||
local token
|
||||
token=$(get_jwt_token "$password")
|
||||
log_info "Authentication successful"
|
||||
|
||||
# Find or create stack
|
||||
local stack_id
|
||||
stack_id=$(find_stack_id "$token")
|
||||
|
||||
if [[ -z "$stack_id" ]]; then
|
||||
if [[ "$dry_run" == "true" ]]; then
|
||||
log_warn "Stack '$STACK_NAME' does not exist yet"
|
||||
log_info "A real deploy would create it"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "Stack '$STACK_NAME' not found, creating..."
|
||||
local env_json
|
||||
env_json=$(parse_env_to_json "$ENV_FILE")
|
||||
create_stack "$token" "$env_json"
|
||||
else
|
||||
local stack_info
|
||||
stack_info=$(get_stack_info "$token" "$stack_id")
|
||||
local status_code
|
||||
status_code=$(echo "$stack_info" | jq -r '.Status // 0')
|
||||
local status_text="Unknown"
|
||||
case "$status_code" in
|
||||
1) status_text="Active" ;;
|
||||
2) status_text="Inactive" ;;
|
||||
esac
|
||||
log_info "Current stack status: $status_text (ID: $stack_id, Env vars: $(echo "$stack_info" | jq '.Env | length'))"
|
||||
|
||||
echo ""
|
||||
update_stack "$token" "$stack_id" "$dry_run"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
log_info "Done!"
|
||||
|
||||
if [[ "$dry_run" == "false" ]]; then
|
||||
log_info "Check Portainer UI to verify containers are running"
|
||||
log_info "URL: $PORTAINER_URL/#!/$ENDPOINT_ID/docker/stacks/$STACK_NAME"
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -49,6 +49,9 @@ COPY --from=builder /app/src/shared/dist/ src/shared/dist/
|
||||
COPY --from=builder /app/src/db/dist/ src/db/dist/
|
||||
COPY --from=builder /app/src/mcpd/dist/ src/mcpd/dist/
|
||||
|
||||
# Copy templates for seeding
|
||||
COPY templates/ templates/
|
||||
|
||||
# Copy entrypoint
|
||||
COPY deploy/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
13
deploy/Dockerfile.node-runner
Normal file
13
deploy/Dockerfile.node-runner
Normal file
@@ -0,0 +1,13 @@
|
||||
# Base container for npm-based MCP servers (STDIO transport).
|
||||
# mcpd uses this image to run `npx -y <packageName>` when a server
|
||||
# has packageName but no dockerImage.
|
||||
# Using slim (Debian) instead of alpine for better npm package compatibility.
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /mcp
|
||||
|
||||
# Pre-warm npx cache directory
|
||||
RUN mkdir -p /root/.npm
|
||||
|
||||
# Default entrypoint — overridden by mcpd via container command
|
||||
ENTRYPOINT ["npx", "-y"]
|
||||
@@ -30,6 +30,8 @@ services:
|
||||
MCPD_PORT: "3100"
|
||||
MCPD_HOST: "0.0.0.0"
|
||||
MCPD_LOG_LEVEL: info
|
||||
MCPD_NODE_RUNNER_IMAGE: mcpctl-node-runner:latest
|
||||
MCPD_MCP_NETWORK: mcp-servers
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -48,6 +50,16 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# Base image for npm-based MCP servers (built once, used by mcpd)
|
||||
node-runner:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile.node-runner
|
||||
image: mcpctl-node-runner:latest
|
||||
profiles:
|
||||
- build
|
||||
entrypoint: ["echo", "Image built successfully"]
|
||||
|
||||
postgres-test:
|
||||
image: postgres:16-alpine
|
||||
container_name: mcpctl-postgres-test
|
||||
@@ -71,8 +83,11 @@ networks:
|
||||
mcpctl:
|
||||
driver: bridge
|
||||
mcp-servers:
|
||||
name: mcp-servers
|
||||
driver: bridge
|
||||
internal: true
|
||||
# Not internal — MCP servers need outbound access to reach external APIs
|
||||
# (e.g., Grafana, Home Assistant). Isolation is enforced by not binding
|
||||
# host ports on MCP server containers; only mcpd can reach them.
|
||||
|
||||
volumes:
|
||||
mcpctl-pgdata:
|
||||
|
||||
@@ -4,8 +4,8 @@ set -e
|
||||
echo "mcpd: pushing database schema..."
|
||||
pnpm -F @mcpctl/db exec prisma db push --schema=prisma/schema.prisma --accept-data-loss 2>&1
|
||||
|
||||
echo "mcpd: seeding default data..."
|
||||
node src/mcpd/dist/seed-runner.js
|
||||
echo "mcpd: seeding templates..."
|
||||
TEMPLATES_DIR=templates node src/mcpd/dist/seed-runner.js
|
||||
|
||||
echo "mcpd: starting server..."
|
||||
exec node src/mcpd/dist/main.js
|
||||
|
||||
15
deploy/mcplocal.service
Normal file
15
deploy/mcplocal.service
Normal file
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=mcpctl local MCP proxy
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/mcpctl-local
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
Environment=MCPLOCAL_MCPD_URL=http://10.0.0.194:3100
|
||||
Environment=MCPLOCAL_HTTP_PORT=3200
|
||||
Environment=MCPLOCAL_HTTP_HOST=127.0.0.1
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -96,10 +96,12 @@ servers:
|
||||
description: Slack MCP server
|
||||
transport: STDIO
|
||||
packageName: "@anthropic/slack-mcp"
|
||||
envTemplate:
|
||||
env:
|
||||
- name: SLACK_TOKEN
|
||||
description: Slack bot token
|
||||
isSecret: true
|
||||
valueFrom:
|
||||
secretRef:
|
||||
name: slack-secrets
|
||||
key: token
|
||||
|
||||
- name: github
|
||||
description: GitHub MCP server
|
||||
|
||||
28
examples/ha-mcp.yaml
Normal file
28
examples/ha-mcp.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
servers:
|
||||
- name: ha-mcp
|
||||
description: "Home Assistant MCP - smart home control via MCP"
|
||||
dockerImage: "ghcr.io/homeassistant-ai/ha-mcp:2.4"
|
||||
transport: STREAMABLE_HTTP
|
||||
containerPort: 3000
|
||||
# For mcpd-managed containers:
|
||||
command:
|
||||
- python
|
||||
- "-c"
|
||||
- "from ha_mcp.server import HomeAssistantSmartMCPServer; s = HomeAssistantSmartMCPServer(); s.mcp.run(transport='sse', host='0.0.0.0', port=3000)"
|
||||
# For connecting to an already-running instance (host.containers.internal for container-to-host):
|
||||
externalUrl: "http://host.containers.internal:8086/mcp"
|
||||
env:
|
||||
- name: HOMEASSISTANT_URL
|
||||
value: ""
|
||||
- name: HOMEASSISTANT_TOKEN
|
||||
valueFrom:
|
||||
secretRef:
|
||||
name: ha-secrets
|
||||
key: token
|
||||
|
||||
profiles:
|
||||
- name: production
|
||||
server: ha-mcp
|
||||
envOverrides:
|
||||
HOMEASSISTANT_URL: "https://ha.itaz.eu"
|
||||
HOMEASSISTANT_TOKEN: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIyNjFlZTRhOWI2MGM0YTllOGJkNTIxN2Q3YmVmZDkzNSIsImlhdCI6MTc3MDA3NjYzOCwiZXhwIjoyMDg1NDM2NjM4fQ.17mAQxIrCBrQx3ogqAUetwEt-cngRmJiH-e7sLt-3FY"
|
||||
35
fulldeploy.sh
Executable file
35
fulldeploy.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# Full deployment: Docker image → Portainer stack → RPM build/publish/install
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Load .env
|
||||
if [ -f .env ]; then
|
||||
set -a; source .env; set +a
|
||||
fi
|
||||
|
||||
echo "========================================"
|
||||
echo " mcpctl Full Deploy"
|
||||
echo "========================================"
|
||||
|
||||
echo ""
|
||||
echo ">>> Step 1/3: Build & push mcpd Docker image"
|
||||
echo ""
|
||||
bash scripts/build-mcpd.sh "$@"
|
||||
|
||||
echo ""
|
||||
echo ">>> Step 2/3: Deploy stack to production"
|
||||
echo ""
|
||||
bash deploy.sh
|
||||
|
||||
echo ""
|
||||
echo ">>> Step 3/3: Build, publish & install RPM"
|
||||
echo ""
|
||||
bash scripts/release.sh
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " Full deploy complete!"
|
||||
echo "========================================"
|
||||
26
installlocal.sh
Executable file
26
installlocal.sh
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
# Build (if needed) and install mcpctl RPM locally
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
RPM_FILE=$(ls dist/mcpctl-*.rpm 2>/dev/null | head -1)
|
||||
|
||||
# Build if no RPM exists or if source is newer than the RPM
|
||||
if [[ -z "$RPM_FILE" ]] || [[ $(find src/ -name '*.ts' -newer "$RPM_FILE" 2>/dev/null | head -1) ]]; then
|
||||
echo "==> Building RPM..."
|
||||
bash scripts/build-rpm.sh
|
||||
RPM_FILE=$(ls dist/mcpctl-*.rpm 2>/dev/null | head -1)
|
||||
else
|
||||
echo "==> RPM is up to date: $RPM_FILE"
|
||||
fi
|
||||
|
||||
echo "==> Installing $RPM_FILE..."
|
||||
sudo rpm -Uvh --force "$RPM_FILE"
|
||||
|
||||
echo "==> Reloading systemd user units..."
|
||||
systemctl --user daemon-reload
|
||||
|
||||
echo "==> Done!"
|
||||
echo " Enable mcplocal: systemctl --user enable --now mcplocal"
|
||||
10
nfpm.yaml
10
nfpm.yaml
@@ -5,11 +5,21 @@ release: "1"
|
||||
maintainer: michal
|
||||
description: kubectl-like CLI for managing MCP servers
|
||||
license: MIT
|
||||
depends:
|
||||
- jq
|
||||
contents:
|
||||
- src: ./dist/mcpctl
|
||||
dst: /usr/bin/mcpctl
|
||||
file_info:
|
||||
mode: 0755
|
||||
- src: ./dist/mcpctl-local
|
||||
dst: /usr/bin/mcpctl-local
|
||||
file_info:
|
||||
mode: 0755
|
||||
- src: ./deploy/mcplocal.service
|
||||
dst: /usr/lib/systemd/user/mcplocal.service
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: ./completions/mcpctl.bash
|
||||
dst: /usr/share/bash-completion/completions/mcpctl
|
||||
file_info:
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
"typecheck": "tsc --build",
|
||||
"rpm:build": "bash scripts/build-rpm.sh",
|
||||
"rpm:publish": "bash scripts/publish-rpm.sh",
|
||||
"release": "bash scripts/release.sh"
|
||||
"release": "bash scripts/release.sh",
|
||||
"mcpd:build": "bash scripts/build-mcpd.sh",
|
||||
"mcpd:deploy": "bash deploy.sh",
|
||||
"mcpd:deploy-dry": "bash deploy.sh --dry-run",
|
||||
"mcpd:logs": "bash logs.sh"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -112,6 +112,9 @@ importers:
|
||||
fastify:
|
||||
specifier: ^5.0.0
|
||||
version: 5.7.4
|
||||
js-yaml:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.1
|
||||
zod:
|
||||
specifier: ^3.24.0
|
||||
version: 3.25.76
|
||||
@@ -122,6 +125,9 @@ importers:
|
||||
'@types/dockerode':
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
'@types/js-yaml':
|
||||
specifier: ^4.0.9
|
||||
version: 4.0.9
|
||||
'@types/node':
|
||||
specifier: ^25.3.0
|
||||
version: 25.3.0
|
||||
|
||||
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"
|
||||
32
scripts/build-mcpd.sh
Executable file
32
scripts/build-mcpd.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
# Build mcpd Docker image and push to Gitea container registry
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Load .env for GITEA_TOKEN
|
||||
if [ -f .env ]; then
|
||||
set -a; source .env; set +a
|
||||
fi
|
||||
|
||||
# Push directly to internal address (external proxy has body size limit)
|
||||
REGISTRY="10.0.0.194:3012"
|
||||
IMAGE="mcpd"
|
||||
TAG="${1:-latest}"
|
||||
|
||||
echo "==> Building mcpd image..."
|
||||
podman build -t "$IMAGE:$TAG" -f deploy/Dockerfile.mcpd .
|
||||
|
||||
echo "==> Tagging as $REGISTRY/michal/$IMAGE:$TAG..."
|
||||
podman tag "$IMAGE:$TAG" "$REGISTRY/michal/$IMAGE:$TAG"
|
||||
|
||||
echo "==> Logging in to $REGISTRY..."
|
||||
podman login --tls-verify=false -u michal -p "$GITEA_TOKEN" "$REGISTRY"
|
||||
|
||||
echo "==> Pushing to $REGISTRY/michal/$IMAGE:$TAG..."
|
||||
podman push --tls-verify=false "$REGISTRY/michal/$IMAGE:$TAG"
|
||||
|
||||
echo "==> Done!"
|
||||
echo " Image: $REGISTRY/michal/$IMAGE:$TAG"
|
||||
@@ -16,10 +16,11 @@ export PATH="$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH"
|
||||
echo "==> Building TypeScript..."
|
||||
pnpm build
|
||||
|
||||
echo "==> Bundling standalone binary..."
|
||||
echo "==> Bundling standalone binaries..."
|
||||
mkdir -p dist
|
||||
rm -f dist/mcpctl dist/mcpctl-*.rpm
|
||||
rm -f dist/mcpctl dist/mcpctl-local dist/mcpctl-*.rpm
|
||||
bun build src/cli/src/index.ts --compile --outfile dist/mcpctl
|
||||
bun build src/mcplocal/src/main.ts --compile --outfile dist/mcpctl-local
|
||||
|
||||
echo "==> Packaging RPM..."
|
||||
nfpm pkg --packager rpm --target dist/
|
||||
|
||||
@@ -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>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
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) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,22 @@ import yaml from 'js-yaml';
|
||||
import { z } from 'zod';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
|
||||
const HealthCheckSchema = z.object({
|
||||
tool: z.string().min(1),
|
||||
arguments: z.record(z.unknown()).default({}),
|
||||
intervalSeconds: z.number().int().min(5).max(3600).default(60),
|
||||
timeoutSeconds: z.number().int().min(1).max(120).default(10),
|
||||
failureThreshold: z.number().int().min(1).max(20).default(3),
|
||||
});
|
||||
|
||||
const ServerEnvEntrySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
value: z.string().optional(),
|
||||
valueFrom: z.object({
|
||||
secretRef: z.object({ name: z.string(), key: z.string() }),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
const ServerSpecSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().default(''),
|
||||
@@ -11,31 +27,114 @@ const ServerSpecSchema = z.object({
|
||||
dockerImage: z.string().optional(),
|
||||
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
|
||||
repositoryUrl: z.string().url().optional(),
|
||||
envTemplate: z.array(z.object({
|
||||
name: z.string(),
|
||||
description: z.string().default(''),
|
||||
isSecret: z.boolean().default(false),
|
||||
})).default([]),
|
||||
externalUrl: z.string().url().optional(),
|
||||
command: z.array(z.string()).optional(),
|
||||
containerPort: z.number().int().min(1).max(65535).optional(),
|
||||
replicas: z.number().int().min(0).max(10).default(1),
|
||||
env: z.array(ServerEnvEntrySchema).default([]),
|
||||
healthCheck: HealthCheckSchema.optional(),
|
||||
});
|
||||
|
||||
const ProfileSpecSchema = z.object({
|
||||
const SecretSpecSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
server: z.string().min(1),
|
||||
permissions: z.array(z.string()).default([]),
|
||||
envOverrides: z.record(z.string()).default({}),
|
||||
data: z.record(z.string()).default({}),
|
||||
});
|
||||
|
||||
const TemplateEnvEntrySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
defaultValue: z.string().optional(),
|
||||
});
|
||||
|
||||
const TemplateSpecSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
version: z.string().default('1.0.0'),
|
||||
description: z.string().default(''),
|
||||
packageName: z.string().optional(),
|
||||
dockerImage: z.string().optional(),
|
||||
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
|
||||
repositoryUrl: z.string().optional(),
|
||||
externalUrl: z.string().optional(),
|
||||
command: z.array(z.string()).optional(),
|
||||
containerPort: z.number().int().min(1).max(65535).optional(),
|
||||
replicas: z.number().int().min(0).max(10).default(1),
|
||||
env: z.array(TemplateEnvEntrySchema).default([]),
|
||||
healthCheck: HealthCheckSchema.optional(),
|
||||
});
|
||||
|
||||
const UserSpecSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
name: z.string().optional(),
|
||||
});
|
||||
|
||||
const GroupSpecSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().default(''),
|
||||
members: z.array(z.string().email()).default([]),
|
||||
});
|
||||
|
||||
const RbacSubjectSchema = z.object({
|
||||
kind: z.enum(['User', 'Group', 'ServiceAccount']),
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
const RESOURCE_ALIASES: Record<string, string> = {
|
||||
server: 'servers', instance: 'instances', secret: 'secrets',
|
||||
project: 'projects', template: 'templates', user: 'users', group: 'groups',
|
||||
prompt: 'prompts', promptrequest: 'promptrequests',
|
||||
};
|
||||
|
||||
const RbacRoleBindingSchema = z.union([
|
||||
z.object({
|
||||
role: z.enum(['edit', 'view', 'create', 'delete', 'run', 'expose']),
|
||||
resource: z.string().min(1).transform((r) => RESOURCE_ALIASES[r] ?? r),
|
||||
name: z.string().min(1).optional(),
|
||||
}),
|
||||
z.object({
|
||||
role: z.literal('run'),
|
||||
action: z.string().min(1),
|
||||
}),
|
||||
]);
|
||||
|
||||
const RbacBindingSpecSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
subjects: z.array(RbacSubjectSchema).default([]),
|
||||
roleBindings: z.array(RbacRoleBindingSchema).default([]),
|
||||
});
|
||||
|
||||
const PromptSpecSchema = z.object({
|
||||
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
|
||||
content: z.string().min(1).max(50000),
|
||||
projectId: z.string().optional(),
|
||||
});
|
||||
|
||||
const ProjectSpecSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().default(''),
|
||||
profiles: z.array(z.string()).default([]),
|
||||
prompt: z.string().max(10000).default(''),
|
||||
proxyMode: z.enum(['direct', 'filtered']).default('direct'),
|
||||
llmProvider: z.string().optional(),
|
||||
llmModel: z.string().optional(),
|
||||
servers: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
const ApplyConfigSchema = z.object({
|
||||
secrets: z.array(SecretSpecSchema).default([]),
|
||||
servers: z.array(ServerSpecSchema).default([]),
|
||||
profiles: z.array(ProfileSpecSchema).default([]),
|
||||
users: z.array(UserSpecSchema).default([]),
|
||||
groups: z.array(GroupSpecSchema).default([]),
|
||||
projects: z.array(ProjectSpecSchema).default([]),
|
||||
});
|
||||
templates: z.array(TemplateSpecSchema).default([]),
|
||||
rbacBindings: z.array(RbacBindingSpecSchema).default([]),
|
||||
rbac: z.array(RbacBindingSpecSchema).default([]),
|
||||
prompts: z.array(PromptSpecSchema).default([]),
|
||||
}).transform((data) => ({
|
||||
...data,
|
||||
// Merge rbac into rbacBindings so both keys work
|
||||
rbacBindings: [...data.rbacBindings, ...data.rbac],
|
||||
}));
|
||||
|
||||
export type ApplyConfig = z.infer<typeof ApplyConfigSchema>;
|
||||
|
||||
@@ -49,16 +148,26 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command {
|
||||
|
||||
return new Command('apply')
|
||||
.description('Apply declarative configuration from a YAML or JSON file')
|
||||
.argument('<file>', 'Path to config file (.yaml, .yml, or .json)')
|
||||
.argument('[file]', 'Path to config file (.yaml, .yml, or .json)')
|
||||
.option('-f, --file <file>', 'Path to config file (alternative to positional arg)')
|
||||
.option('--dry-run', 'Validate and show changes without applying')
|
||||
.action(async (file: string, opts: { dryRun?: boolean }) => {
|
||||
.action(async (fileArg: string | undefined, opts: { file?: string; dryRun?: boolean }) => {
|
||||
const file = fileArg ?? opts.file;
|
||||
if (!file) {
|
||||
throw new Error('File path required. Usage: mcpctl apply <file> or mcpctl apply -f <file>');
|
||||
}
|
||||
const config = loadConfigFile(file);
|
||||
|
||||
if (opts.dryRun) {
|
||||
log('Dry run - would apply:');
|
||||
if (config.secrets.length > 0) log(` ${config.secrets.length} secret(s)`);
|
||||
if (config.servers.length > 0) log(` ${config.servers.length} server(s)`);
|
||||
if (config.profiles.length > 0) log(` ${config.profiles.length} profile(s)`);
|
||||
if (config.users.length > 0) log(` ${config.users.length} user(s)`);
|
||||
if (config.groups.length > 0) log(` ${config.groups.length} group(s)`);
|
||||
if (config.projects.length > 0) log(` ${config.projects.length} project(s)`);
|
||||
if (config.templates.length > 0) log(` ${config.templates.length} template(s)`);
|
||||
if (config.rbacBindings.length > 0) log(` ${config.rbacBindings.length} rbacBinding(s)`);
|
||||
if (config.prompts.length > 0) log(` ${config.prompts.length} prompt(s)`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -80,7 +189,25 @@ function loadConfigFile(path: string): ApplyConfig {
|
||||
}
|
||||
|
||||
async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args: unknown[]) => void): Promise<void> {
|
||||
// Apply servers first (profiles depend on servers)
|
||||
// Apply order: secrets, servers, users, groups, projects, templates, rbacBindings
|
||||
|
||||
// Apply secrets
|
||||
for (const secret of config.secrets) {
|
||||
try {
|
||||
const existing = await findByName(client, 'secrets', secret.name);
|
||||
if (existing) {
|
||||
await client.put(`/api/v1/secrets/${(existing as { id: string }).id}`, { data: secret.data });
|
||||
log(`Updated secret: ${secret.name}`);
|
||||
} else {
|
||||
await client.post('/api/v1/secrets', secret);
|
||||
log(`Created secret: ${secret.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Error applying secret '${secret.name}': ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply servers
|
||||
for (const server of config.servers) {
|
||||
try {
|
||||
const existing = await findByName(client, 'servers', server.name);
|
||||
@@ -96,57 +223,101 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
|
||||
}
|
||||
}
|
||||
|
||||
// Apply profiles (need server IDs)
|
||||
for (const profile of config.profiles) {
|
||||
// Apply users (matched by email)
|
||||
for (const user of config.users) {
|
||||
try {
|
||||
const server = await findByName(client, 'servers', profile.server);
|
||||
if (!server) {
|
||||
log(`Skipping profile '${profile.name}': server '${profile.server}' not found`);
|
||||
continue;
|
||||
}
|
||||
const serverId = (server as { id: string }).id;
|
||||
|
||||
const existing = await findProfile(client, serverId, profile.name);
|
||||
const existing = await findByField(client, 'users', 'email', user.email);
|
||||
if (existing) {
|
||||
await client.put(`/api/v1/profiles/${(existing as { id: string }).id}`, {
|
||||
permissions: profile.permissions,
|
||||
envOverrides: profile.envOverrides,
|
||||
});
|
||||
log(`Updated profile: ${profile.name} (server: ${profile.server})`);
|
||||
await client.put(`/api/v1/users/${(existing as { id: string }).id}`, user);
|
||||
log(`Updated user: ${user.email}`);
|
||||
} else {
|
||||
await client.post('/api/v1/profiles', {
|
||||
name: profile.name,
|
||||
serverId,
|
||||
permissions: profile.permissions,
|
||||
envOverrides: profile.envOverrides,
|
||||
});
|
||||
log(`Created profile: ${profile.name} (server: ${profile.server})`);
|
||||
await client.post('/api/v1/users', user);
|
||||
log(`Created user: ${user.email}`);
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Error applying profile '${profile.name}': ${err instanceof Error ? err.message : err}`);
|
||||
log(`Error applying user '${user.email}': ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply projects
|
||||
// Apply groups
|
||||
for (const group of config.groups) {
|
||||
try {
|
||||
const existing = await findByName(client, 'groups', group.name);
|
||||
if (existing) {
|
||||
await client.put(`/api/v1/groups/${(existing as { id: string }).id}`, group);
|
||||
log(`Updated group: ${group.name}`);
|
||||
} else {
|
||||
await client.post('/api/v1/groups', group);
|
||||
log(`Created group: ${group.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Error applying group '${group.name}': ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply projects (send full spec including servers)
|
||||
for (const project of config.projects) {
|
||||
try {
|
||||
const existing = await findByName(client, 'projects', project.name);
|
||||
if (existing) {
|
||||
await client.put(`/api/v1/projects/${(existing as { id: string }).id}`, {
|
||||
description: project.description,
|
||||
});
|
||||
await client.put(`/api/v1/projects/${(existing as { id: string }).id}`, project);
|
||||
log(`Updated project: ${project.name}`);
|
||||
} else {
|
||||
await client.post('/api/v1/projects', {
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
});
|
||||
await client.post('/api/v1/projects', project);
|
||||
log(`Created project: ${project.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Error applying project '${project.name}': ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply templates
|
||||
for (const template of config.templates) {
|
||||
try {
|
||||
const existing = await findByName(client, 'templates', template.name);
|
||||
if (existing) {
|
||||
await client.put(`/api/v1/templates/${(existing as { id: string }).id}`, template);
|
||||
log(`Updated template: ${template.name}`);
|
||||
} else {
|
||||
await client.post('/api/v1/templates', template);
|
||||
log(`Created template: ${template.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Error applying template '${template.name}': ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply RBAC bindings
|
||||
for (const rbacBinding of config.rbacBindings) {
|
||||
try {
|
||||
const existing = await findByName(client, 'rbac', rbacBinding.name);
|
||||
if (existing) {
|
||||
await client.put(`/api/v1/rbac/${(existing as { id: string }).id}`, rbacBinding);
|
||||
log(`Updated rbacBinding: ${rbacBinding.name}`);
|
||||
} else {
|
||||
await client.post('/api/v1/rbac', rbacBinding);
|
||||
log(`Created rbacBinding: ${rbacBinding.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Error applying rbacBinding '${rbacBinding.name}': ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply prompts
|
||||
for (const prompt of config.prompts) {
|
||||
try {
|
||||
const existing = await findByName(client, 'prompts', prompt.name);
|
||||
if (existing) {
|
||||
await client.put(`/api/v1/prompts/${(existing as { id: string }).id}`, { content: prompt.content });
|
||||
log(`Updated prompt: ${prompt.name}`);
|
||||
} else {
|
||||
await client.post('/api/v1/prompts', prompt);
|
||||
log(`Created prompt: ${prompt.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Error applying prompt '${prompt.name}': ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function findByName(client: ApiClient, resource: string, name: string): Promise<unknown | null> {
|
||||
@@ -158,12 +329,10 @@ async function findByName(client: ApiClient, resource: string, name: string): Pr
|
||||
}
|
||||
}
|
||||
|
||||
async function findProfile(client: ApiClient, serverId: string, name: string): Promise<unknown | null> {
|
||||
async function findByField<T extends string>(client: ApiClient, resource: string, field: T, value: string): Promise<unknown | null> {
|
||||
try {
|
||||
const profiles = await client.get<Array<{ name: string; serverId: string }>>(
|
||||
`/api/v1/profiles?serverId=${serverId}`,
|
||||
);
|
||||
return profiles.find((p) => p.name === name) ?? null;
|
||||
const items = await client.get<Array<Record<string, unknown>>>(`/api/v1/${resource}`);
|
||||
return items.find((item) => item[field] === value) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ export interface PromptDeps {
|
||||
password(message: string): Promise<string>;
|
||||
}
|
||||
|
||||
export interface StatusResponse {
|
||||
hasUsers: boolean;
|
||||
}
|
||||
|
||||
export interface AuthCommandDeps {
|
||||
configDeps: Partial<ConfigLoaderDeps>;
|
||||
credentialsDeps: Partial<CredentialsDeps>;
|
||||
@@ -17,6 +21,8 @@ export interface AuthCommandDeps {
|
||||
log: (...args: string[]) => void;
|
||||
loginRequest: (mcpdUrl: string, email: string, password: string) => Promise<LoginResponse>;
|
||||
logoutRequest: (mcpdUrl: string, token: string) => Promise<void>;
|
||||
statusRequest: (mcpdUrl: string) => Promise<StatusResponse>;
|
||||
bootstrapRequest: (mcpdUrl: string, email: string, password: string, name?: string) => Promise<LoginResponse>;
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
@@ -80,6 +86,70 @@ function defaultLogoutRequest(mcpdUrl: string, token: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function defaultStatusRequest(mcpdUrl: string): Promise<StatusResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL('/api/v1/auth/status', mcpdUrl);
|
||||
const opts: http.RequestOptions = {
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
method: 'GET',
|
||||
timeout: 10000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
const req = http.request(opts, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf-8');
|
||||
if ((res.statusCode ?? 0) >= 400) {
|
||||
reject(new Error(`Status check failed (${res.statusCode}): ${raw}`));
|
||||
return;
|
||||
}
|
||||
resolve(JSON.parse(raw) as StatusResponse);
|
||||
});
|
||||
});
|
||||
req.on('error', (err) => reject(new Error(`Cannot reach mcpd: ${err.message}`)));
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error('Status request timed out')); });
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function defaultBootstrapRequest(mcpdUrl: string, email: string, password: string, name?: string): Promise<LoginResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL('/api/v1/auth/bootstrap', mcpdUrl);
|
||||
const payload: Record<string, string> = { email, password };
|
||||
if (name) {
|
||||
payload['name'] = name;
|
||||
}
|
||||
const body = JSON.stringify(payload);
|
||||
const opts: http.RequestOptions = {
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
timeout: 10000,
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
|
||||
};
|
||||
const req = http.request(opts, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf-8');
|
||||
if ((res.statusCode ?? 0) >= 400) {
|
||||
reject(new Error(`Bootstrap failed (${res.statusCode}): ${raw}`));
|
||||
return;
|
||||
}
|
||||
resolve(JSON.parse(raw) as LoginResponse);
|
||||
});
|
||||
});
|
||||
req.on('error', (err) => reject(new Error(`Cannot reach mcpd: ${err.message}`)));
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error('Bootstrap request timed out')); });
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function defaultInput(message: string): Promise<string> {
|
||||
const { default: inquirer } = await import('inquirer');
|
||||
const { answer } = await inquirer.prompt([{ type: 'input', name: 'answer', message }]);
|
||||
@@ -99,10 +169,12 @@ const defaultDeps: AuthCommandDeps = {
|
||||
log: (...args) => console.log(...args),
|
||||
loginRequest: defaultLoginRequest,
|
||||
logoutRequest: defaultLogoutRequest,
|
||||
statusRequest: defaultStatusRequest,
|
||||
bootstrapRequest: defaultBootstrapRequest,
|
||||
};
|
||||
|
||||
export function createLoginCommand(deps?: Partial<AuthCommandDeps>): Command {
|
||||
const { configDeps, credentialsDeps, prompt, log, loginRequest } = { ...defaultDeps, ...deps };
|
||||
const { configDeps, credentialsDeps, prompt, log, loginRequest, statusRequest, bootstrapRequest } = { ...defaultDeps, ...deps };
|
||||
|
||||
return new Command('login')
|
||||
.description('Authenticate with mcpd')
|
||||
@@ -111,17 +183,36 @@ export function createLoginCommand(deps?: Partial<AuthCommandDeps>): Command {
|
||||
const config = loadConfig(configDeps);
|
||||
const mcpdUrl = opts.mcpdUrl ?? config.mcpdUrl;
|
||||
|
||||
const email = await prompt.input('Email:');
|
||||
const password = await prompt.password('Password:');
|
||||
|
||||
try {
|
||||
const result = await loginRequest(mcpdUrl, email, password);
|
||||
saveCredentials({
|
||||
token: result.token,
|
||||
mcpdUrl,
|
||||
user: result.user.email,
|
||||
}, credentialsDeps);
|
||||
log(`Logged in as ${result.user.email}`);
|
||||
const status = await statusRequest(mcpdUrl);
|
||||
|
||||
if (!status.hasUsers) {
|
||||
log('No users configured. Creating first admin account.');
|
||||
const email = await prompt.input('Email:');
|
||||
const password = await prompt.password('Password:');
|
||||
const name = await prompt.input('Name (optional):');
|
||||
|
||||
const result = name
|
||||
? await bootstrapRequest(mcpdUrl, email, password, name)
|
||||
: await bootstrapRequest(mcpdUrl, email, password);
|
||||
saveCredentials({
|
||||
token: result.token,
|
||||
mcpdUrl,
|
||||
user: result.user.email,
|
||||
}, credentialsDeps);
|
||||
log(`Logged in as ${result.user.email} (admin)`);
|
||||
} else {
|
||||
const email = await prompt.input('Email:');
|
||||
const password = await prompt.password('Password:');
|
||||
|
||||
const result = await loginRequest(mcpdUrl, email, password);
|
||||
saveCredentials({
|
||||
token: result.token,
|
||||
mcpdUrl,
|
||||
user: result.user.email,
|
||||
}, credentialsDeps);
|
||||
log(`Logged in as ${result.user.email}`);
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Login failed: ${(err as Error).message}`);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { Command } from 'commander';
|
||||
import { writeFileSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
|
||||
interface McpConfig {
|
||||
mcpServers: Record<string, { command: string; args: string[]; env?: Record<string, string> }>;
|
||||
}
|
||||
|
||||
export interface ClaudeCommandDeps {
|
||||
client: ApiClient;
|
||||
log: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export function createClaudeCommand(deps: ClaudeCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
const cmd = new Command('claude')
|
||||
.description('Manage Claude MCP configuration (.mcp.json)');
|
||||
|
||||
cmd
|
||||
.command('generate <projectId>')
|
||||
.description('Generate .mcp.json from a project configuration')
|
||||
.option('-o, --output <path>', 'Output file path', '.mcp.json')
|
||||
.option('--merge', 'Merge with existing .mcp.json instead of overwriting')
|
||||
.option('--stdout', 'Print to stdout instead of writing a file')
|
||||
.action(async (projectId: string, opts: { output: string; merge?: boolean; stdout?: boolean }) => {
|
||||
const config = await client.get<McpConfig>(`/api/v1/projects/${projectId}/mcp-config`);
|
||||
|
||||
if (opts.stdout) {
|
||||
log(JSON.stringify(config, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const outputPath = resolve(opts.output);
|
||||
let finalConfig = config;
|
||||
|
||||
if (opts.merge && existsSync(outputPath)) {
|
||||
try {
|
||||
const existing = JSON.parse(readFileSync(outputPath, 'utf-8')) as McpConfig;
|
||||
finalConfig = {
|
||||
mcpServers: {
|
||||
...existing.mcpServers,
|
||||
...config.mcpServers,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
// If existing file is invalid, just overwrite
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n');
|
||||
const serverCount = Object.keys(finalConfig.mcpServers).length;
|
||||
log(`Wrote ${outputPath} (${serverCount} server(s))`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('show')
|
||||
.description('Show current .mcp.json configuration')
|
||||
.option('-p, --path <path>', 'Path to .mcp.json', '.mcp.json')
|
||||
.action((opts: { path: string }) => {
|
||||
const filePath = resolve(opts.path);
|
||||
if (!existsSync(filePath)) {
|
||||
log(`No .mcp.json found at ${filePath}`);
|
||||
return;
|
||||
}
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
try {
|
||||
const config = JSON.parse(content) as McpConfig;
|
||||
const servers = Object.entries(config.mcpServers ?? {});
|
||||
if (servers.length === 0) {
|
||||
log('No MCP servers configured.');
|
||||
return;
|
||||
}
|
||||
log(`MCP servers in ${filePath}:\n`);
|
||||
for (const [name, server] of servers) {
|
||||
log(` ${name}`);
|
||||
log(` command: ${server.command} ${server.args.join(' ')}`);
|
||||
if (server.env) {
|
||||
const envKeys = Object.keys(server.env);
|
||||
log(` env: ${envKeys.join(', ')}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
log(`Invalid JSON in ${filePath}`);
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('add <name>')
|
||||
.description('Add an MCP server entry to .mcp.json')
|
||||
.requiredOption('-c, --command <cmd>', 'Command to run')
|
||||
.option('-a, --args <args...>', 'Command arguments')
|
||||
.option('-e, --env <key=value...>', 'Environment variables')
|
||||
.option('-p, --path <path>', 'Path to .mcp.json', '.mcp.json')
|
||||
.action((name: string, opts: { command: string; args?: string[]; env?: string[]; path: string }) => {
|
||||
const filePath = resolve(opts.path);
|
||||
let config: McpConfig = { mcpServers: {} };
|
||||
|
||||
if (existsSync(filePath)) {
|
||||
try {
|
||||
config = JSON.parse(readFileSync(filePath, 'utf-8')) as McpConfig;
|
||||
} catch {
|
||||
// Start fresh
|
||||
}
|
||||
}
|
||||
|
||||
const entry: { command: string; args: string[]; env?: Record<string, string> } = {
|
||||
command: opts.command,
|
||||
args: opts.args ?? [],
|
||||
};
|
||||
|
||||
if (opts.env && opts.env.length > 0) {
|
||||
const env: Record<string, string> = {};
|
||||
for (const pair of opts.env) {
|
||||
const eqIdx = pair.indexOf('=');
|
||||
if (eqIdx > 0) {
|
||||
env[pair.slice(0, eqIdx)] = pair.slice(eqIdx + 1);
|
||||
}
|
||||
}
|
||||
entry.env = env;
|
||||
}
|
||||
|
||||
config.mcpServers[name] = entry;
|
||||
writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
|
||||
log(`Added '${name}' to ${filePath}`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('remove <name>')
|
||||
.description('Remove an MCP server entry from .mcp.json')
|
||||
.option('-p, --path <path>', 'Path to .mcp.json', '.mcp.json')
|
||||
.action((name: string, opts: { path: string }) => {
|
||||
const filePath = resolve(opts.path);
|
||||
if (!existsSync(filePath)) {
|
||||
log(`No .mcp.json found at ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = JSON.parse(readFileSync(filePath, 'utf-8')) as McpConfig;
|
||||
if (!(name in config.mcpServers)) {
|
||||
log(`Server '${name}' not found in ${filePath}`);
|
||||
return;
|
||||
}
|
||||
delete config.mcpServers[name];
|
||||
writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
|
||||
log(`Removed '${name}' from ${filePath}`);
|
||||
} catch {
|
||||
log(`Invalid JSON in ${filePath}`);
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -1,19 +1,35 @@
|
||||
import { Command } from 'commander';
|
||||
import { writeFileSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../config/index.js';
|
||||
import type { McpctlConfig, ConfigLoaderDeps } from '../config/index.js';
|
||||
import { formatJson, formatYaml } from '../formatters/index.js';
|
||||
import { saveCredentials, loadCredentials } from '../auth/index.js';
|
||||
import type { CredentialsDeps, StoredCredentials } from '../auth/index.js';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
|
||||
interface McpConfig {
|
||||
mcpServers: Record<string, { command?: string; args?: string[]; url?: string; env?: Record<string, string> }>;
|
||||
}
|
||||
|
||||
export interface ConfigCommandDeps {
|
||||
configDeps: Partial<ConfigLoaderDeps>;
|
||||
log: (...args: string[]) => void;
|
||||
}
|
||||
|
||||
export interface ConfigApiDeps {
|
||||
client: ApiClient;
|
||||
credentialsDeps: Partial<CredentialsDeps>;
|
||||
log: (...args: string[]) => void;
|
||||
}
|
||||
|
||||
const defaultDeps: ConfigCommandDeps = {
|
||||
configDeps: {},
|
||||
log: (...args) => console.log(...args),
|
||||
};
|
||||
|
||||
export function createConfigCommand(deps?: Partial<ConfigCommandDeps>): Command {
|
||||
export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?: ConfigApiDeps): Command {
|
||||
const { configDeps, log } = { ...defaultDeps, ...deps };
|
||||
|
||||
const config = new Command('config').description('Manage mcpctl configuration');
|
||||
@@ -68,5 +84,132 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>): Command
|
||||
log('Configuration reset to defaults');
|
||||
});
|
||||
|
||||
// claude/claude-generate: generate .mcp.json pointing at mcpctl mcp bridge
|
||||
function registerClaudeCommand(name: string, hidden: boolean): void {
|
||||
const cmd = config
|
||||
.command(name)
|
||||
.description(hidden ? '' : 'Generate .mcp.json that connects a project via mcpctl mcp bridge')
|
||||
.requiredOption('--project <name>', 'Project name')
|
||||
.option('-o, --output <path>', 'Output file path', '.mcp.json')
|
||||
.option('--merge', 'Merge with existing .mcp.json instead of overwriting')
|
||||
.option('--stdout', 'Print to stdout instead of writing a file')
|
||||
.action((opts: { project: string; output: string; merge?: boolean; stdout?: boolean }) => {
|
||||
const mcpConfig: McpConfig = {
|
||||
mcpServers: {
|
||||
[opts.project]: {
|
||||
command: 'mcpctl',
|
||||
args: ['mcp', '-p', opts.project],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (opts.stdout) {
|
||||
log(JSON.stringify(mcpConfig, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const outputPath = resolve(opts.output);
|
||||
let finalConfig = mcpConfig;
|
||||
|
||||
if (opts.merge && existsSync(outputPath)) {
|
||||
try {
|
||||
const existing = JSON.parse(readFileSync(outputPath, 'utf-8')) as McpConfig;
|
||||
finalConfig = {
|
||||
mcpServers: {
|
||||
...existing.mcpServers,
|
||||
...mcpConfig.mcpServers,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
// If existing file is invalid, just overwrite
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n');
|
||||
const serverCount = Object.keys(finalConfig.mcpServers).length;
|
||||
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
|
||||
.command('impersonate')
|
||||
.description('Impersonate another user or return to original identity')
|
||||
.argument('[email]', 'Email of user to impersonate')
|
||||
.option('--quit', 'Stop impersonating and return to original identity')
|
||||
.action(async (email: string | undefined, opts: { quit?: boolean }) => {
|
||||
const configDir = credentialsDeps?.configDir ?? join(homedir(), '.mcpctl');
|
||||
const backupPath = join(configDir, 'credentials-backup');
|
||||
|
||||
if (opts.quit) {
|
||||
if (!existsSync(backupPath)) {
|
||||
apiLog('No impersonation session to quit');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const backupRaw = readFileSync(backupPath, 'utf-8');
|
||||
const backup = JSON.parse(backupRaw) as StoredCredentials;
|
||||
saveCredentials(backup, credentialsDeps);
|
||||
|
||||
// Remove backup file
|
||||
const { unlinkSync } = await import('node:fs');
|
||||
unlinkSync(backupPath);
|
||||
|
||||
apiLog(`Returned to ${backup.user}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
apiLog('Email is required when not using --quit');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Save current credentials as backup
|
||||
const currentCreds = loadCredentials(credentialsDeps);
|
||||
if (!currentCreds) {
|
||||
apiLog('Not logged in. Run "mcpctl login" first.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
writeFileSync(backupPath, JSON.stringify(currentCreds, null, 2) + '\n', 'utf-8');
|
||||
|
||||
try {
|
||||
const result = await client.post<{ token: string; user: { email: string } }>(
|
||||
'/api/v1/auth/impersonate',
|
||||
{ email },
|
||||
);
|
||||
|
||||
saveCredentials({
|
||||
token: result.token,
|
||||
mcpdUrl: currentCreds.mcpdUrl,
|
||||
user: result.user.email,
|
||||
}, credentialsDeps);
|
||||
|
||||
apiLog(`Impersonating ${result.user.email}. Use 'mcpctl config impersonate --quit' to return.`);
|
||||
} catch (err) {
|
||||
// Restore backup on failure
|
||||
const backup = JSON.parse(readFileSync(backupPath, 'utf-8')) as StoredCredentials;
|
||||
saveCredentials(backup, credentialsDeps);
|
||||
const { unlinkSync } = await import('node:fs');
|
||||
unlinkSync(backupPath);
|
||||
|
||||
apiLog(`Impersonate failed: ${(err as Error).message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
383
src/cli/src/commands/create.ts
Normal file
383
src/cli/src/commands/create.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { Command } from 'commander';
|
||||
import { type ApiClient, ApiError } from '../api-client.js';
|
||||
export interface CreateCommandDeps {
|
||||
client: ApiClient;
|
||||
log: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
function collect(value: string, prev: string[]): string[] {
|
||||
return [...prev, value];
|
||||
}
|
||||
|
||||
interface ServerEnvEntry {
|
||||
name: string;
|
||||
value?: string;
|
||||
valueFrom?: { secretRef: { name: string; key: string } };
|
||||
}
|
||||
|
||||
function parseServerEnv(entries: string[]): ServerEnvEntry[] {
|
||||
return entries.map((entry) => {
|
||||
const eqIdx = entry.indexOf('=');
|
||||
if (eqIdx === -1) {
|
||||
throw new Error(`Invalid env format '${entry}'. Expected KEY=value or KEY=secretRef:SECRET:KEY`);
|
||||
}
|
||||
const envName = entry.slice(0, eqIdx);
|
||||
const rhs = entry.slice(eqIdx + 1);
|
||||
|
||||
if (rhs.startsWith('secretRef:')) {
|
||||
const parts = rhs.split(':');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error(`Invalid secretRef format '${entry}'. Expected KEY=secretRef:SECRET_NAME:SECRET_KEY`);
|
||||
}
|
||||
return {
|
||||
name: envName,
|
||||
valueFrom: { secretRef: { name: parts[1]!, key: parts[2]! } },
|
||||
};
|
||||
}
|
||||
|
||||
return { name: envName, value: rhs };
|
||||
});
|
||||
}
|
||||
|
||||
function parseEnvEntries(entries: string[]): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const entry of entries) {
|
||||
const eqIdx = entry.indexOf('=');
|
||||
if (eqIdx === -1) {
|
||||
throw new Error(`Invalid env format '${entry}'. Expected KEY=value`);
|
||||
}
|
||||
result[entry.slice(0, eqIdx)] = entry.slice(eqIdx + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
const cmd = new Command('create')
|
||||
.description('Create a resource (server, secret, project, user, group, rbac)');
|
||||
|
||||
// --- create server ---
|
||||
cmd.command('server')
|
||||
.description('Create an MCP server definition')
|
||||
.argument('<name>', 'Server name (lowercase, hyphens allowed)')
|
||||
.option('-d, --description <text>', 'Server description')
|
||||
.option('--package-name <name>', 'NPM package name')
|
||||
.option('--docker-image <image>', 'Docker image')
|
||||
.option('--transport <type>', 'Transport type (STDIO, SSE, STREAMABLE_HTTP)')
|
||||
.option('--repository-url <url>', 'Source repository URL')
|
||||
.option('--external-url <url>', 'External endpoint URL')
|
||||
.option('--command <arg>', 'Command argument (repeat for multiple)', collect, [])
|
||||
.option('--container-port <port>', 'Container port number')
|
||||
.option('--replicas <count>', 'Number of replicas')
|
||||
.option('--env <entry>', 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, repeat for multiple)', collect, [])
|
||||
.option('--from-template <name>', 'Create from template (name or name:version)')
|
||||
.option('--force', 'Update if already exists')
|
||||
.action(async (name: string, opts) => {
|
||||
let base: Record<string, unknown> = {};
|
||||
|
||||
// If --from-template, fetch template and use as base
|
||||
if (opts.fromTemplate) {
|
||||
const tplRef = opts.fromTemplate as string;
|
||||
const [tplName, tplVersion] = tplRef.includes(':')
|
||||
? [tplRef.slice(0, tplRef.indexOf(':')), tplRef.slice(tplRef.indexOf(':') + 1)]
|
||||
: [tplRef, undefined];
|
||||
|
||||
const templates = await client.get<Array<Record<string, unknown>>>(`/api/v1/templates?name=${encodeURIComponent(tplName)}`);
|
||||
let template: Record<string, unknown> | undefined;
|
||||
if (tplVersion) {
|
||||
template = templates.find((t) => t.name === tplName && t.version === tplVersion);
|
||||
if (!template) throw new Error(`Template '${tplName}' version '${tplVersion}' not found`);
|
||||
} else {
|
||||
template = templates.find((t) => t.name === tplName);
|
||||
if (!template) throw new Error(`Template '${tplName}' not found`);
|
||||
}
|
||||
|
||||
// Copy template fields as base (strip template-only, internal, and null fields)
|
||||
const { id: _id, createdAt: _c, updatedAt: _u, version: _v, name: _n, ...tplFields } = template;
|
||||
base = {};
|
||||
for (const [k, v] of Object.entries(tplFields)) {
|
||||
if (v !== null && v !== undefined) base[k] = v;
|
||||
}
|
||||
|
||||
// Convert template env (description/required) to server env (name/value/valueFrom)
|
||||
const tplEnv = template.env as Array<{ name: string; description?: string; required?: boolean; defaultValue?: string }> | undefined;
|
||||
if (tplEnv && tplEnv.length > 0) {
|
||||
base.env = tplEnv.map((e) => ({ name: e.name, value: e.defaultValue ?? '' }));
|
||||
}
|
||||
|
||||
// Track template origin
|
||||
base.templateName = tplName;
|
||||
base.templateVersion = (template.version as string) ?? '1.0.0';
|
||||
}
|
||||
|
||||
// Build body: template base → CLI overrides (last wins)
|
||||
const body: Record<string, unknown> = {
|
||||
...base,
|
||||
name,
|
||||
};
|
||||
if (opts.description !== undefined) body.description = opts.description;
|
||||
if (opts.transport) body.transport = opts.transport;
|
||||
if (opts.replicas) body.replicas = parseInt(opts.replicas, 10);
|
||||
if (opts.packageName) body.packageName = opts.packageName;
|
||||
if (opts.dockerImage) body.dockerImage = opts.dockerImage;
|
||||
if (opts.repositoryUrl) body.repositoryUrl = opts.repositoryUrl;
|
||||
if (opts.externalUrl) body.externalUrl = opts.externalUrl;
|
||||
if (opts.command.length > 0) body.command = opts.command;
|
||||
if (opts.containerPort) body.containerPort = parseInt(opts.containerPort, 10);
|
||||
if (opts.env.length > 0) {
|
||||
// Merge: CLI env entries override template env entries by name
|
||||
const cliEnv = parseServerEnv(opts.env);
|
||||
const existing = (body.env as ServerEnvEntry[] | undefined) ?? [];
|
||||
const merged = [...existing];
|
||||
for (const entry of cliEnv) {
|
||||
const idx = merged.findIndex((e) => e.name === entry.name);
|
||||
if (idx >= 0) {
|
||||
merged[idx] = entry;
|
||||
} else {
|
||||
merged.push(entry);
|
||||
}
|
||||
}
|
||||
body.env = merged;
|
||||
}
|
||||
|
||||
// Defaults when no template
|
||||
if (!opts.fromTemplate) {
|
||||
if (body.description === undefined) body.description = '';
|
||||
if (!body.transport) body.transport = 'STDIO';
|
||||
if (!body.replicas) body.replicas = 1;
|
||||
}
|
||||
|
||||
try {
|
||||
const server = await client.post<{ id: string; name: string }>('/api/v1/servers', body);
|
||||
log(`server '${server.name}' created (id: ${server.id})`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409 && opts.force) {
|
||||
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/servers')).find((s) => s.name === name);
|
||||
if (!existing) throw err;
|
||||
const { name: _n, ...updateBody } = body;
|
||||
await client.put(`/api/v1/servers/${existing.id}`, updateBody);
|
||||
log(`server '${name}' updated (id: ${existing.id})`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- create secret ---
|
||||
cmd.command('secret')
|
||||
.description('Create a secret')
|
||||
.argument('<name>', 'Secret name (lowercase, hyphens allowed)')
|
||||
.option('--data <entry>', 'Secret data KEY=value (repeat for multiple)', collect, [])
|
||||
.option('--force', 'Update if already exists')
|
||||
.action(async (name: string, opts) => {
|
||||
const data = parseEnvEntries(opts.data);
|
||||
try {
|
||||
const secret = await client.post<{ id: string; name: string }>('/api/v1/secrets', {
|
||||
name,
|
||||
data,
|
||||
});
|
||||
log(`secret '${secret.name}' created (id: ${secret.id})`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409 && opts.force) {
|
||||
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/secrets')).find((s) => s.name === name);
|
||||
if (!existing) throw err;
|
||||
await client.put(`/api/v1/secrets/${existing.id}`, { data });
|
||||
log(`secret '${name}' updated (id: ${existing.id})`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- create project ---
|
||||
cmd.command('project')
|
||||
.description('Create a project')
|
||||
.argument('<name>', 'Project name')
|
||||
.option('-d, --description <text>', 'Project description', '')
|
||||
.option('--proxy-mode <mode>', 'Proxy mode (direct, filtered)')
|
||||
.option('--proxy-mode-llm-provider <name>', 'LLM provider name (for filtered proxy mode)')
|
||||
.option('--proxy-mode-llm-model <name>', 'LLM model name (for filtered proxy mode)')
|
||||
.option('--prompt <text>', 'Project-level prompt / instructions for the LLM')
|
||||
.option('--server <name>', 'Server name (repeat for multiple)', collect, [])
|
||||
.option('--force', 'Update if already exists')
|
||||
.action(async (name: string, opts) => {
|
||||
const body: Record<string, unknown> = {
|
||||
name,
|
||||
description: opts.description,
|
||||
proxyMode: opts.proxyMode ?? 'direct',
|
||||
};
|
||||
if (opts.prompt) body.prompt = opts.prompt;
|
||||
if (opts.proxyModeLlmProvider) body.llmProvider = opts.proxyModeLlmProvider;
|
||||
if (opts.proxyModeLlmModel) body.llmModel = opts.proxyModeLlmModel;
|
||||
if (opts.server.length > 0) body.servers = opts.server;
|
||||
|
||||
try {
|
||||
const project = await client.post<{ id: string; name: string }>('/api/v1/projects', body);
|
||||
log(`project '${project.name}' created (id: ${project.id})`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409 && opts.force) {
|
||||
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/projects')).find((p) => p.name === name);
|
||||
if (!existing) throw err;
|
||||
const { name: _n, ...updateBody } = body;
|
||||
await client.put(`/api/v1/projects/${existing.id}`, updateBody);
|
||||
log(`project '${name}' updated (id: ${existing.id})`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- create user ---
|
||||
cmd.command('user')
|
||||
.description('Create a user')
|
||||
.argument('<email>', 'User email address')
|
||||
.option('--password <pass>', 'User password')
|
||||
.option('--name <name>', 'User display name')
|
||||
.option('--force', 'Update if already exists')
|
||||
.action(async (email: string, opts) => {
|
||||
if (!opts.password) {
|
||||
throw new Error('--password is required');
|
||||
}
|
||||
const body: Record<string, unknown> = {
|
||||
email,
|
||||
password: opts.password,
|
||||
};
|
||||
if (opts.name) body.name = opts.name;
|
||||
|
||||
try {
|
||||
const user = await client.post<{ id: string; email: string }>('/api/v1/users', body);
|
||||
log(`user '${user.email}' created (id: ${user.id})`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409 && opts.force) {
|
||||
const existing = (await client.get<Array<{ id: string; email: string }>>('/api/v1/users')).find((u) => u.email === email);
|
||||
if (!existing) throw err;
|
||||
const { email: _e, ...updateBody } = body;
|
||||
await client.put(`/api/v1/users/${existing.id}`, updateBody);
|
||||
log(`user '${email}' updated (id: ${existing.id})`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- create group ---
|
||||
cmd.command('group')
|
||||
.description('Create a group')
|
||||
.argument('<name>', 'Group name')
|
||||
.option('--description <text>', 'Group description')
|
||||
.option('--member <email>', 'Member email (repeat for multiple)', collect, [])
|
||||
.option('--force', 'Update if already exists')
|
||||
.action(async (name: string, opts) => {
|
||||
const body: Record<string, unknown> = {
|
||||
name,
|
||||
members: opts.member,
|
||||
};
|
||||
if (opts.description) body.description = opts.description;
|
||||
|
||||
try {
|
||||
const group = await client.post<{ id: string; name: string }>('/api/v1/groups', body);
|
||||
log(`group '${group.name}' created (id: ${group.id})`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409 && opts.force) {
|
||||
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/groups')).find((g) => g.name === name);
|
||||
if (!existing) throw err;
|
||||
const { name: _n, ...updateBody } = body;
|
||||
await client.put(`/api/v1/groups/${existing.id}`, updateBody);
|
||||
log(`group '${name}' updated (id: ${existing.id})`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- create rbac ---
|
||||
cmd.command('rbac')
|
||||
.description('Create an RBAC binding definition')
|
||||
.argument('<name>', 'RBAC binding name')
|
||||
.option('--subject <entry>', 'Subject as Kind:name (repeat for multiple)', collect, [])
|
||||
.option('--binding <entry>', 'Role binding as role:resource (e.g. edit:servers, run:projects)', collect, [])
|
||||
.option('--operation <action>', 'Operation binding (e.g. logs, backup)', collect, [])
|
||||
.option('--force', 'Update if already exists')
|
||||
.action(async (name: string, opts) => {
|
||||
const subjects = (opts.subject as string[]).map((entry: string) => {
|
||||
const colonIdx = entry.indexOf(':');
|
||||
if (colonIdx === -1) {
|
||||
throw new Error(`Invalid subject format '${entry}'. Expected Kind:name (e.g. User:alice@example.com)`);
|
||||
}
|
||||
return { kind: entry.slice(0, colonIdx), name: entry.slice(colonIdx + 1) };
|
||||
});
|
||||
|
||||
const roleBindings: Array<Record<string, string>> = [];
|
||||
|
||||
// Resource bindings from --binding flag (role:resource or role:resource:name)
|
||||
for (const entry of opts.binding as string[]) {
|
||||
const parts = entry.split(':');
|
||||
if (parts.length === 2) {
|
||||
roleBindings.push({ role: parts[0]!, resource: parts[1]! });
|
||||
} else if (parts.length === 3) {
|
||||
roleBindings.push({ role: parts[0]!, resource: parts[1]!, name: parts[2]! });
|
||||
} else {
|
||||
throw new Error(`Invalid binding format '${entry}'. Expected role:resource or role:resource:name (e.g. edit:servers, view:servers:my-ha)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Operation bindings from --operation flag
|
||||
for (const action of opts.operation as string[]) {
|
||||
roleBindings.push({ role: 'run', action });
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
name,
|
||||
subjects,
|
||||
roleBindings,
|
||||
};
|
||||
|
||||
try {
|
||||
const rbac = await client.post<{ id: string; name: string }>('/api/v1/rbac', body);
|
||||
log(`rbac '${rbac.name}' created (id: ${rbac.id})`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409 && opts.force) {
|
||||
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/rbac')).find((r) => r.name === name);
|
||||
if (!existing) throw err;
|
||||
const { name: _n, ...updateBody } = body;
|
||||
await client.put(`/api/v1/rbac/${existing.id}`, updateBody);
|
||||
log(`rbac '${name}' updated (id: ${existing.id})`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- create prompt ---
|
||||
cmd.command('prompt')
|
||||
.description('Create an approved prompt')
|
||||
.argument('<name>', 'Prompt name (lowercase alphanumeric with hyphens)')
|
||||
.option('--project <name>', 'Project name to scope the prompt to')
|
||||
.option('--content <text>', 'Prompt content text')
|
||||
.option('--content-file <path>', 'Read prompt content from file')
|
||||
.action(async (name: string, opts) => {
|
||||
let content = opts.content as string | undefined;
|
||||
if (opts.contentFile) {
|
||||
const fs = await import('node:fs/promises');
|
||||
content = await fs.readFile(opts.contentFile as string, 'utf-8');
|
||||
}
|
||||
if (!content) {
|
||||
throw new Error('--content or --content-file is required');
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = { name, content };
|
||||
if (opts.project) {
|
||||
// Resolve project name to ID
|
||||
const projects = await client.get<Array<{ id: string; name: string }>>('/api/v1/projects');
|
||||
const project = projects.find((p) => p.name === opts.project);
|
||||
if (!project) throw new Error(`Project '${opts.project as string}' not found`);
|
||||
body.projectId = project.id;
|
||||
}
|
||||
|
||||
const prompt = await client.post<{ id: string; name: string }>('/api/v1/prompts', body);
|
||||
log(`prompt '${prompt.name}' created (id: ${prompt.id})`);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
33
src/cli/src/commands/delete.ts
Normal file
33
src/cli/src/commands/delete.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Command } from 'commander';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
import { resolveResource, resolveNameOrId } from './shared.js';
|
||||
|
||||
export interface DeleteCommandDeps {
|
||||
client: ApiClient;
|
||||
log: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export function createDeleteCommand(deps: DeleteCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
return new Command('delete')
|
||||
.description('Delete a resource (server, instance, secret, project, user, group, rbac)')
|
||||
.argument('<resource>', 'resource type')
|
||||
.argument('<id>', 'resource ID or name')
|
||||
.action(async (resourceArg: string, idOrName: string) => {
|
||||
const resource = resolveResource(resourceArg);
|
||||
|
||||
// Resolve name → ID for any resource type
|
||||
let id: string;
|
||||
try {
|
||||
id = await resolveNameOrId(client, resource, idOrName);
|
||||
} catch {
|
||||
id = idOrName; // Fall through with original
|
||||
}
|
||||
|
||||
await client.delete(`/api/v1/${resource}/${id}`);
|
||||
|
||||
const singular = resource.replace(/s$/, '');
|
||||
log(`${singular} '${idOrName}' deleted.`);
|
||||
});
|
||||
}
|
||||
@@ -1,74 +1,618 @@
|
||||
import { Command } from 'commander';
|
||||
import { formatJson, formatYaml } from '../formatters/output.js';
|
||||
import { resolveResource, resolveNameOrId } from './shared.js';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
|
||||
export interface DescribeCommandDeps {
|
||||
client: ApiClient;
|
||||
fetchResource: (resource: string, id: string) => Promise<unknown>;
|
||||
fetchInspect?: (id: string) => Promise<unknown>;
|
||||
log: (...args: string[]) => void;
|
||||
}
|
||||
|
||||
const RESOURCE_ALIASES: Record<string, string> = {
|
||||
server: 'servers',
|
||||
srv: 'servers',
|
||||
profile: 'profiles',
|
||||
prof: 'profiles',
|
||||
project: 'projects',
|
||||
proj: 'projects',
|
||||
instance: 'instances',
|
||||
inst: 'instances',
|
||||
};
|
||||
|
||||
function resolveResource(name: string): string {
|
||||
const lower = name.toLowerCase();
|
||||
return RESOURCE_ALIASES[lower] ?? lower;
|
||||
function pad(label: string, width = 18): string {
|
||||
return label.padEnd(width);
|
||||
}
|
||||
|
||||
function formatDetail(obj: Record<string, unknown>, indent = 0): string {
|
||||
const pad = ' '.repeat(indent);
|
||||
function formatServerDetail(server: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`=== Server: ${server.name} ===`);
|
||||
lines.push(`${pad('Name:')}${server.name}`);
|
||||
lines.push(`${pad('Transport:')}${server.transport ?? '-'}`);
|
||||
lines.push(`${pad('Replicas:')}${server.replicas ?? 1}`);
|
||||
if (server.dockerImage) lines.push(`${pad('Docker Image:')}${server.dockerImage}`);
|
||||
if (server.packageName) lines.push(`${pad('Package:')}${server.packageName}`);
|
||||
if (server.externalUrl) lines.push(`${pad('External URL:')}${server.externalUrl}`);
|
||||
if (server.repositoryUrl) lines.push(`${pad('Repository:')}${server.repositoryUrl}`);
|
||||
if (server.containerPort) lines.push(`${pad('Container Port:')}${server.containerPort}`);
|
||||
if (server.description) lines.push(`${pad('Description:')}${server.description}`);
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value === null || value === undefined) {
|
||||
lines.push(`${pad}${key}: -`);
|
||||
} else if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
lines.push(`${pad}${key}: []`);
|
||||
} else if (typeof value[0] === 'object') {
|
||||
lines.push(`${pad}${key}:`);
|
||||
for (const item of value) {
|
||||
lines.push(`${pad} - ${JSON.stringify(item)}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`${pad}${key}: ${value.join(', ')}`);
|
||||
const command = server.command as string[] | null;
|
||||
if (command && command.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Command:');
|
||||
lines.push(` ${command.join(' ')}`);
|
||||
}
|
||||
|
||||
const env = server.env as Array<{ name: string; value?: string; valueFrom?: { secretRef: { name: string; key: string } } }> | undefined;
|
||||
if (env && env.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Environment:');
|
||||
const nameW = Math.max(6, ...env.map((e) => e.name.length)) + 2;
|
||||
lines.push(` ${'NAME'.padEnd(nameW)}SOURCE`);
|
||||
for (const e of env) {
|
||||
if (e.value !== undefined) {
|
||||
lines.push(` ${e.name.padEnd(nameW)}${e.value}`);
|
||||
} else if (e.valueFrom?.secretRef) {
|
||||
const ref = e.valueFrom.secretRef;
|
||||
lines.push(` ${e.name.padEnd(nameW)}secret:${ref.name}/${ref.key}`);
|
||||
}
|
||||
} else if (typeof value === 'object') {
|
||||
lines.push(`${pad}${key}:`);
|
||||
lines.push(formatDetail(value as Record<string, unknown>, indent + 1));
|
||||
} else {
|
||||
lines.push(`${pad}${key}: ${String(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const hc = server.healthCheck as { tool: string; arguments?: Record<string, unknown>; intervalSeconds?: number; timeoutSeconds?: number; failureThreshold?: number } | null;
|
||||
if (hc) {
|
||||
lines.push('');
|
||||
lines.push('Health Check:');
|
||||
lines.push(` ${pad('Tool:', 22)}${hc.tool}`);
|
||||
if (hc.arguments && Object.keys(hc.arguments).length > 0) {
|
||||
lines.push(` ${pad('Arguments:', 22)}${JSON.stringify(hc.arguments)}`);
|
||||
}
|
||||
lines.push(` ${pad('Interval:', 22)}${hc.intervalSeconds ?? 60}s`);
|
||||
lines.push(` ${pad('Timeout:', 22)}${hc.timeoutSeconds ?? 10}s`);
|
||||
lines.push(` ${pad('Failure Threshold:', 22)}${hc.failureThreshold ?? 3}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Metadata:');
|
||||
lines.push(` ${pad('ID:', 12)}${server.id}`);
|
||||
if (server.createdAt) lines.push(` ${pad('Created:', 12)}${server.createdAt}`);
|
||||
if (server.updatedAt) lines.push(` ${pad('Updated:', 12)}${server.updatedAt}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
const server = instance.server as { name: string } | undefined;
|
||||
lines.push(`=== Instance: ${server?.name ?? instance.id} ===`);
|
||||
lines.push(`${pad('Status:')}${instance.status}`);
|
||||
lines.push(`${pad('Server:')}${server?.name ?? String(instance.serverId)}`);
|
||||
lines.push(`${pad('Container ID:')}${instance.containerId ?? '-'}`);
|
||||
lines.push(`${pad('Port:')}${instance.port ?? '-'}`);
|
||||
|
||||
// Health section
|
||||
const healthStatus = instance.healthStatus as string | null;
|
||||
const lastHealthCheck = instance.lastHealthCheck as string | null;
|
||||
if (healthStatus || lastHealthCheck) {
|
||||
lines.push('');
|
||||
lines.push('Health:');
|
||||
lines.push(` ${pad('Status:', 16)}${healthStatus ?? 'unknown'}`);
|
||||
if (lastHealthCheck) lines.push(` ${pad('Last Check:', 16)}${lastHealthCheck}`);
|
||||
}
|
||||
|
||||
const metadata = instance.metadata as Record<string, unknown> | undefined;
|
||||
if (metadata && Object.keys(metadata).length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Metadata:');
|
||||
for (const [key, value] of Object.entries(metadata)) {
|
||||
lines.push(` ${pad(key + ':', 16)}${String(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (inspect) {
|
||||
lines.push('');
|
||||
lines.push('Container:');
|
||||
for (const [key, value] of Object.entries(inspect)) {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
lines.push(` ${key}: ${JSON.stringify(value)}`);
|
||||
} else {
|
||||
lines.push(` ${pad(key + ':', 16)}${String(value)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Events section (k8s-style)
|
||||
const events = instance.events as Array<{ timestamp: string; type: string; message: string }> | undefined;
|
||||
if (events && events.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Events:');
|
||||
const tsW = 26;
|
||||
const typeW = 10;
|
||||
lines.push(` ${'TIMESTAMP'.padEnd(tsW)}${'TYPE'.padEnd(typeW)}MESSAGE`);
|
||||
for (const ev of events) {
|
||||
lines.push(` ${(ev.timestamp ?? '').padEnd(tsW)}${(ev.type ?? '').padEnd(typeW)}${ev.message ?? ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push(` ${pad('ID:', 12)}${instance.id}`);
|
||||
if (instance.createdAt) lines.push(` ${pad('Created:', 12)}${instance.createdAt}`);
|
||||
if (instance.updatedAt) lines.push(` ${pad('Updated:', 12)}${instance.updatedAt}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatProjectDetail(project: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`=== Project: ${project.name} ===`);
|
||||
lines.push(`${pad('Name:')}${project.name}`);
|
||||
if (project.description) lines.push(`${pad('Description:')}${project.description}`);
|
||||
|
||||
// Proxy config section
|
||||
const proxyMode = project.proxyMode as string | undefined;
|
||||
const llmProvider = project.llmProvider as string | undefined;
|
||||
const llmModel = project.llmModel as string | undefined;
|
||||
if (proxyMode || llmProvider || llmModel) {
|
||||
lines.push('');
|
||||
lines.push('Proxy Config:');
|
||||
lines.push(` ${pad('Mode:', 18)}${proxyMode ?? 'direct'}`);
|
||||
if (llmProvider) lines.push(` ${pad('LLM Provider:', 18)}${llmProvider}`);
|
||||
if (llmModel) lines.push(` ${pad('LLM Model:', 18)}${llmModel}`);
|
||||
}
|
||||
|
||||
// Servers section
|
||||
const servers = project.servers as Array<{ server: { name: string } }> | undefined;
|
||||
if (servers && servers.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Servers:');
|
||||
lines.push(' NAME');
|
||||
for (const s of servers) {
|
||||
lines.push(` ${s.server.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Metadata:');
|
||||
lines.push(` ${pad('ID:', 12)}${project.id}`);
|
||||
if (project.ownerId) lines.push(` ${pad('Owner:', 12)}${project.ownerId}`);
|
||||
if (project.createdAt) lines.push(` ${pad('Created:', 12)}${project.createdAt}`);
|
||||
if (project.updatedAt) lines.push(` ${pad('Updated:', 12)}${project.updatedAt}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatSecretDetail(secret: Record<string, unknown>, showValues: boolean): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`=== Secret: ${secret.name} ===`);
|
||||
lines.push(`${pad('Name:')}${secret.name}`);
|
||||
|
||||
const data = secret.data as Record<string, string> | undefined;
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Data:');
|
||||
const keyW = Math.max(4, ...Object.keys(data).map((k) => k.length)) + 2;
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const display = showValues ? value : '***';
|
||||
lines.push(` ${key.padEnd(keyW)}${display}`);
|
||||
}
|
||||
if (!showValues) {
|
||||
lines.push('');
|
||||
lines.push(' (use --show-values to reveal)');
|
||||
}
|
||||
} else {
|
||||
lines.push(`${pad('Data:')}(empty)`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Metadata:');
|
||||
lines.push(` ${pad('ID:', 12)}${secret.id}`);
|
||||
if (secret.createdAt) lines.push(` ${pad('Created:', 12)}${secret.createdAt}`);
|
||||
if (secret.updatedAt) lines.push(` ${pad('Updated:', 12)}${secret.updatedAt}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatTemplateDetail(template: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`=== Template: ${template.name} ===`);
|
||||
lines.push(`${pad('Name:')}${template.name}`);
|
||||
lines.push(`${pad('Version:')}${template.version ?? '1.0.0'}`);
|
||||
lines.push(`${pad('Transport:')}${template.transport ?? 'STDIO'}`);
|
||||
lines.push(`${pad('Replicas:')}${template.replicas ?? 1}`);
|
||||
if (template.dockerImage) lines.push(`${pad('Docker Image:')}${template.dockerImage}`);
|
||||
if (template.packageName) lines.push(`${pad('Package:')}${template.packageName}`);
|
||||
if (template.externalUrl) lines.push(`${pad('External URL:')}${template.externalUrl}`);
|
||||
if (template.repositoryUrl) lines.push(`${pad('Repository:')}${template.repositoryUrl}`);
|
||||
if (template.containerPort) lines.push(`${pad('Container Port:')}${template.containerPort}`);
|
||||
if (template.description) lines.push(`${pad('Description:')}${template.description}`);
|
||||
|
||||
const command = template.command as string[] | null;
|
||||
if (command && command.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Command:');
|
||||
lines.push(` ${command.join(' ')}`);
|
||||
}
|
||||
|
||||
const env = template.env as Array<{ name: string; description?: string; required?: boolean; defaultValue?: string }> | undefined;
|
||||
if (env && env.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Environment Variables:');
|
||||
const nameW = Math.max(6, ...env.map((e) => e.name.length)) + 2;
|
||||
lines.push(` ${'NAME'.padEnd(nameW)}${'REQUIRED'.padEnd(10)}DESCRIPTION`);
|
||||
for (const e of env) {
|
||||
const req = e.required ? 'yes' : 'no';
|
||||
const desc = e.description ?? '';
|
||||
lines.push(` ${e.name.padEnd(nameW)}${req.padEnd(10)}${desc}`);
|
||||
}
|
||||
}
|
||||
|
||||
const hc = template.healthCheck as { tool: string; arguments?: Record<string, unknown>; intervalSeconds?: number; timeoutSeconds?: number; failureThreshold?: number } | null;
|
||||
if (hc) {
|
||||
lines.push('');
|
||||
lines.push('Health Check:');
|
||||
lines.push(` ${pad('Tool:', 22)}${hc.tool}`);
|
||||
if (hc.arguments && Object.keys(hc.arguments).length > 0) {
|
||||
lines.push(` ${pad('Arguments:', 22)}${JSON.stringify(hc.arguments)}`);
|
||||
}
|
||||
lines.push(` ${pad('Interval:', 22)}${hc.intervalSeconds ?? 60}s`);
|
||||
lines.push(` ${pad('Timeout:', 22)}${hc.timeoutSeconds ?? 10}s`);
|
||||
lines.push(` ${pad('Failure Threshold:', 22)}${hc.failureThreshold ?? 3}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Usage:');
|
||||
lines.push(` mcpctl create server my-${template.name} --from-template=${template.name}`);
|
||||
|
||||
lines.push('');
|
||||
lines.push('Metadata:');
|
||||
lines.push(` ${pad('ID:', 12)}${template.id}`);
|
||||
if (template.createdAt) lines.push(` ${pad('Created:', 12)}${template.createdAt}`);
|
||||
if (template.updatedAt) lines.push(` ${pad('Updated:', 12)}${template.updatedAt}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
interface RbacBinding { role: string; resource?: string; action?: string; name?: string }
|
||||
interface RbacDef { name: string; subjects: Array<{ kind: string; name: string }>; roleBindings: RbacBinding[] }
|
||||
interface PermissionSet { source: string; bindings: RbacBinding[] }
|
||||
|
||||
function formatPermissionSections(sections: PermissionSet[]): string[] {
|
||||
const lines: string[] = [];
|
||||
for (const section of sections) {
|
||||
const bindings = section.bindings;
|
||||
if (bindings.length === 0) continue;
|
||||
|
||||
const resourceBindings = bindings.filter((b) => 'resource' in b && b.resource !== undefined);
|
||||
const operationBindings = bindings.filter((b) => 'action' in b && b.action !== undefined);
|
||||
|
||||
if (resourceBindings.length > 0) {
|
||||
lines.push('');
|
||||
lines.push(`${section.source} — Resources:`);
|
||||
const roleW = Math.max(6, ...resourceBindings.map((b) => b.role.length)) + 2;
|
||||
const resW = Math.max(10, ...resourceBindings.map((b) => (b.resource ?? '').length)) + 2;
|
||||
const hasName = resourceBindings.some((b) => b.name);
|
||||
if (hasName) {
|
||||
lines.push(` ${'ROLE'.padEnd(roleW)}${'RESOURCE'.padEnd(resW)}NAME`);
|
||||
} else {
|
||||
lines.push(` ${'ROLE'.padEnd(roleW)}RESOURCE`);
|
||||
}
|
||||
for (const b of resourceBindings) {
|
||||
if (hasName) {
|
||||
lines.push(` ${b.role.padEnd(roleW)}${(b.resource ?? '').padEnd(resW)}${b.name ?? '*'}`);
|
||||
} else {
|
||||
lines.push(` ${b.role.padEnd(roleW)}${b.resource}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (operationBindings.length > 0) {
|
||||
lines.push('');
|
||||
lines.push(`${section.source} — Operations:`);
|
||||
lines.push(` ${'ACTION'.padEnd(20)}ROLE`);
|
||||
for (const b of operationBindings) {
|
||||
lines.push(` ${(b.action ?? '').padEnd(20)}${b.role}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function collectBindingsForSubject(
|
||||
rbacDefs: RbacDef[],
|
||||
kind: string,
|
||||
name: string,
|
||||
): { rbacName: string; bindings: RbacBinding[] }[] {
|
||||
const results: { rbacName: string; bindings: RbacBinding[] }[] = [];
|
||||
for (const def of rbacDefs) {
|
||||
const matched = def.subjects.some((s) => s.kind === kind && s.name === name);
|
||||
if (matched) {
|
||||
results.push({ rbacName: def.name, bindings: def.roleBindings });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function formatUserDetail(
|
||||
user: Record<string, unknown>,
|
||||
rbacDefs?: RbacDef[],
|
||||
userGroups?: string[],
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`=== User: ${user.email} ===`);
|
||||
lines.push(`${pad('Email:')}${user.email}`);
|
||||
lines.push(`${pad('Name:')}${(user.name as string | null) ?? '-'}`);
|
||||
lines.push(`${pad('Provider:')}${(user.provider as string | null) ?? 'local'}`);
|
||||
|
||||
if (userGroups && userGroups.length > 0) {
|
||||
lines.push(`${pad('Groups:')}${userGroups.join(', ')}`);
|
||||
}
|
||||
|
||||
if (rbacDefs) {
|
||||
const email = user.email as string;
|
||||
|
||||
// Direct permissions (User:email subjects)
|
||||
const directMatches = collectBindingsForSubject(rbacDefs, 'User', email);
|
||||
const directBindings = directMatches.flatMap((m) => m.bindings);
|
||||
const directSources = directMatches.map((m) => m.rbacName).join(', ');
|
||||
|
||||
// Inherited permissions (Group:name subjects)
|
||||
const inheritedSections: PermissionSet[] = [];
|
||||
if (userGroups) {
|
||||
for (const groupName of userGroups) {
|
||||
const groupMatches = collectBindingsForSubject(rbacDefs, 'Group', groupName);
|
||||
const groupBindings = groupMatches.flatMap((m) => m.bindings);
|
||||
if (groupBindings.length > 0) {
|
||||
inheritedSections.push({ source: `Inherited (${groupName})`, bindings: groupBindings });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sections: PermissionSet[] = [];
|
||||
if (directBindings.length > 0) {
|
||||
sections.push({ source: `Direct (${directSources})`, bindings: directBindings });
|
||||
}
|
||||
sections.push(...inheritedSections);
|
||||
|
||||
if (sections.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Access:');
|
||||
lines.push(...formatPermissionSections(sections));
|
||||
} else {
|
||||
lines.push('');
|
||||
lines.push('Access: (none)');
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Metadata:');
|
||||
lines.push(` ${pad('ID:', 12)}${user.id}`);
|
||||
if (user.createdAt) lines.push(` ${pad('Created:', 12)}${user.createdAt}`);
|
||||
if (user.updatedAt) lines.push(` ${pad('Updated:', 12)}${user.updatedAt}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatGroupDetail(group: Record<string, unknown>, rbacDefs?: RbacDef[]): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`=== Group: ${group.name} ===`);
|
||||
lines.push(`${pad('Name:')}${group.name}`);
|
||||
if (group.description) lines.push(`${pad('Description:')}${group.description}`);
|
||||
|
||||
const members = group.members as Array<{ user: { email: string }; createdAt?: string }> | undefined;
|
||||
if (members && members.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Members:');
|
||||
const emailW = Math.max(6, ...members.map((m) => m.user.email.length)) + 2;
|
||||
lines.push(` ${'EMAIL'.padEnd(emailW)}ADDED`);
|
||||
for (const m of members) {
|
||||
const added = (m.createdAt as string | undefined) ?? '-';
|
||||
lines.push(` ${m.user.email.padEnd(emailW)}${added}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (rbacDefs) {
|
||||
const groupName = group.name as string;
|
||||
const matches = collectBindingsForSubject(rbacDefs, 'Group', groupName);
|
||||
const allBindings = matches.flatMap((m) => m.bindings);
|
||||
const sources = matches.map((m) => m.rbacName).join(', ');
|
||||
|
||||
if (allBindings.length > 0) {
|
||||
const sections: PermissionSet[] = [{ source: `Granted (${sources})`, bindings: allBindings }];
|
||||
lines.push('');
|
||||
lines.push('Access:');
|
||||
lines.push(...formatPermissionSections(sections));
|
||||
} else {
|
||||
lines.push('');
|
||||
lines.push('Access: (none)');
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Metadata:');
|
||||
lines.push(` ${pad('ID:', 12)}${group.id}`);
|
||||
if (group.createdAt) lines.push(` ${pad('Created:', 12)}${group.createdAt}`);
|
||||
if (group.updatedAt) lines.push(` ${pad('Updated:', 12)}${group.updatedAt}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatRbacDetail(rbac: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`=== RBAC: ${rbac.name} ===`);
|
||||
lines.push(`${pad('Name:')}${rbac.name}`);
|
||||
|
||||
const subjects = rbac.subjects as Array<{ kind: string; name: string }> | undefined;
|
||||
if (subjects && subjects.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Subjects:');
|
||||
const kindW = Math.max(6, ...subjects.map((s) => s.kind.length)) + 2;
|
||||
lines.push(` ${'KIND'.padEnd(kindW)}NAME`);
|
||||
for (const s of subjects) {
|
||||
lines.push(` ${s.kind.padEnd(kindW)}${s.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const roleBindings = rbac.roleBindings as Array<{ role: string; resource?: string; action?: string; name?: string }> | undefined;
|
||||
if (roleBindings && roleBindings.length > 0) {
|
||||
// Separate resource bindings from operation bindings
|
||||
const resourceBindings = roleBindings.filter((b) => 'resource' in b && b.resource !== undefined);
|
||||
const operationBindings = roleBindings.filter((b) => 'action' in b && b.action !== undefined);
|
||||
|
||||
if (resourceBindings.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Resource Bindings:');
|
||||
const roleW = Math.max(6, ...resourceBindings.map((b) => b.role.length)) + 2;
|
||||
const resW = Math.max(10, ...resourceBindings.map((b) => (b.resource ?? '').length)) + 2;
|
||||
const hasName = resourceBindings.some((b) => b.name);
|
||||
if (hasName) {
|
||||
lines.push(` ${'ROLE'.padEnd(roleW)}${'RESOURCE'.padEnd(resW)}NAME`);
|
||||
} else {
|
||||
lines.push(` ${'ROLE'.padEnd(roleW)}RESOURCE`);
|
||||
}
|
||||
for (const b of resourceBindings) {
|
||||
if (hasName) {
|
||||
lines.push(` ${b.role.padEnd(roleW)}${(b.resource ?? '').padEnd(resW)}${b.name ?? '*'}`);
|
||||
} else {
|
||||
lines.push(` ${b.role.padEnd(roleW)}${b.resource}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (operationBindings.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Operations:');
|
||||
lines.push(` ${'ACTION'.padEnd(20)}ROLE`);
|
||||
for (const b of operationBindings) {
|
||||
lines.push(` ${(b.action ?? '').padEnd(20)}${b.role}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Metadata:');
|
||||
lines.push(` ${pad('ID:', 12)}${rbac.id}`);
|
||||
if (rbac.createdAt) lines.push(` ${pad('Created:', 12)}${rbac.createdAt}`);
|
||||
if (rbac.updatedAt) lines.push(` ${pad('Updated:', 12)}${rbac.updatedAt}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatGenericDetail(obj: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value === null || value === undefined) {
|
||||
lines.push(`${pad(key + ':')} -`);
|
||||
} else if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
lines.push(`${pad(key + ':')} []`);
|
||||
} else {
|
||||
lines.push(`${key}:`);
|
||||
for (const item of value) {
|
||||
lines.push(` - ${typeof item === 'object' ? JSON.stringify(item) : String(item)}`);
|
||||
}
|
||||
}
|
||||
} else if (typeof value === 'object') {
|
||||
lines.push(`${key}:`);
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
lines.push(` ${pad(k + ':')}${String(v)}`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`${pad(key + ':')}${String(value)}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function createDescribeCommand(deps: DescribeCommandDeps): Command {
|
||||
return new Command('describe')
|
||||
.description('Show detailed information about a resource')
|
||||
.argument('<resource>', 'resource type (server, profile, project, instance)')
|
||||
.argument('<id>', 'resource ID')
|
||||
.argument('<resource>', 'resource type (server, project, instance)')
|
||||
.argument('<id>', 'resource ID or name')
|
||||
.option('-o, --output <format>', 'output format (detail, json, yaml)', 'detail')
|
||||
.action(async (resourceArg: string, id: string, opts: { output: string }) => {
|
||||
.option('--show-values', 'Show secret values (default: masked)')
|
||||
.action(async (resourceArg: string, idOrName: string, opts: { output: string; showValues?: boolean }) => {
|
||||
const resource = resolveResource(resourceArg);
|
||||
const item = await deps.fetchResource(resource, id);
|
||||
|
||||
// Resolve name → ID
|
||||
let id: string;
|
||||
if (resource === 'instances') {
|
||||
// Instances: accept instance ID or server name (resolve to first running instance)
|
||||
try {
|
||||
id = await resolveNameOrId(deps.client, resource, idOrName);
|
||||
} catch {
|
||||
// Not an instance ID — try as server name
|
||||
const servers = await deps.client.get<Array<{ id: string; name: string }>>('/api/v1/servers');
|
||||
const server = servers.find((s) => s.name === idOrName || s.id === idOrName);
|
||||
if (server) {
|
||||
const instances = await deps.client.get<Array<{ id: string; status: string }>>(`/api/v1/instances?serverId=${server.id}`);
|
||||
const running = instances.find((i) => i.status === 'RUNNING') ?? instances[0];
|
||||
if (running) {
|
||||
id = running.id;
|
||||
} else {
|
||||
throw new Error(`No instances found for server '${idOrName}'`);
|
||||
}
|
||||
} else {
|
||||
id = idOrName;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
id = await resolveNameOrId(deps.client, resource, idOrName);
|
||||
} catch {
|
||||
id = idOrName;
|
||||
}
|
||||
}
|
||||
|
||||
const item = await deps.fetchResource(resource, id) as Record<string, unknown>;
|
||||
|
||||
// Enrich instances with container inspect data
|
||||
let inspect: Record<string, unknown> | undefined;
|
||||
if (resource === 'instances' && deps.fetchInspect && item.containerId) {
|
||||
try {
|
||||
inspect = await deps.fetchInspect(id) as Record<string, unknown>;
|
||||
item.containerInspect = inspect;
|
||||
} catch {
|
||||
// Container may not be available
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.output === 'json') {
|
||||
deps.log(formatJson(item));
|
||||
} else if (opts.output === 'yaml') {
|
||||
deps.log(formatYaml(item));
|
||||
} else {
|
||||
const typeName = resource.replace(/s$/, '').charAt(0).toUpperCase() + resource.replace(/s$/, '').slice(1);
|
||||
deps.log(`--- ${typeName} ---`);
|
||||
deps.log(formatDetail(item as Record<string, unknown>));
|
||||
// Visually clean sectioned output
|
||||
switch (resource) {
|
||||
case 'servers':
|
||||
deps.log(formatServerDetail(item));
|
||||
break;
|
||||
case 'instances':
|
||||
deps.log(formatInstanceDetail(item, inspect));
|
||||
break;
|
||||
case 'secrets':
|
||||
deps.log(formatSecretDetail(item, opts.showValues === true));
|
||||
break;
|
||||
case 'templates':
|
||||
deps.log(formatTemplateDetail(item));
|
||||
break;
|
||||
case 'projects':
|
||||
deps.log(formatProjectDetail(item));
|
||||
break;
|
||||
case 'users': {
|
||||
// Fetch RBAC definitions and groups to show permissions
|
||||
const [rbacDefsForUser, allGroupsForUser] = await Promise.all([
|
||||
deps.client.get<RbacDef[]>('/api/v1/rbac').catch(() => [] as RbacDef[]),
|
||||
deps.client.get<Array<{ name: string; members?: Array<{ user: { email: string } }> }>>('/api/v1/groups').catch(() => []),
|
||||
]);
|
||||
const userEmail = item.email as string;
|
||||
const userGroupNames = allGroupsForUser
|
||||
.filter((g) => g.members?.some((m) => m.user.email === userEmail))
|
||||
.map((g) => g.name);
|
||||
deps.log(formatUserDetail(item, rbacDefsForUser, userGroupNames));
|
||||
break;
|
||||
}
|
||||
case 'groups': {
|
||||
const rbacDefsForGroup = await deps.client.get<RbacDef[]>('/api/v1/rbac').catch(() => [] as RbacDef[]);
|
||||
deps.log(formatGroupDetail(item, rbacDefsForGroup));
|
||||
break;
|
||||
}
|
||||
case 'rbac':
|
||||
deps.log(formatRbacDetail(item));
|
||||
break;
|
||||
default:
|
||||
deps.log(formatGenericDetail(item));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
114
src/cli/src/commands/edit.ts
Normal file
114
src/cli/src/commands/edit.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Command } from 'commander';
|
||||
import { writeFileSync, readFileSync, unlinkSync, mkdtempSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execSync } from 'node:child_process';
|
||||
import yaml from 'js-yaml';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
import { resolveResource, resolveNameOrId, stripInternalFields } from './shared.js';
|
||||
|
||||
export interface EditCommandDeps {
|
||||
client: ApiClient;
|
||||
log: (...args: unknown[]) => void;
|
||||
/** Override for testing — return editor binary name. */
|
||||
getEditor?: () => string;
|
||||
/** Override for testing — simulate opening the editor. */
|
||||
openEditor?: (filePath: string, editor: string) => void;
|
||||
}
|
||||
|
||||
function getEditor(deps: EditCommandDeps): string {
|
||||
if (deps.getEditor) return deps.getEditor();
|
||||
return process.env.VISUAL ?? process.env.EDITOR ?? 'vi';
|
||||
}
|
||||
|
||||
function openEditor(filePath: string, editor: string, deps: EditCommandDeps): void {
|
||||
if (deps.openEditor) {
|
||||
deps.openEditor(filePath, editor);
|
||||
return;
|
||||
}
|
||||
execSync(`${editor} "${filePath}"`, { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
export function createEditCommand(deps: EditCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
return new Command('edit')
|
||||
.description('Edit a resource in your default editor (server, project)')
|
||||
.argument('<resource>', 'Resource type (server, project)')
|
||||
.argument('<name-or-id>', 'Resource name or ID')
|
||||
.action(async (resourceArg: string, nameOrId: string) => {
|
||||
const resource = resolveResource(resourceArg);
|
||||
|
||||
// Instances are immutable
|
||||
if (resource === 'instances') {
|
||||
log('Error: instances are immutable and cannot be edited.');
|
||||
log('To change an instance, update the server definition and let reconciliation handle it.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const validResources = ['servers', 'secrets', 'projects', 'groups', 'rbac'];
|
||||
if (!validResources.includes(resource)) {
|
||||
log(`Error: unknown resource type '${resourceArg}'`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve name → ID
|
||||
const id = await resolveNameOrId(client, resource, nameOrId);
|
||||
|
||||
// Fetch current state
|
||||
const current = await client.get<Record<string, unknown>>(`/api/v1/${resource}/${id}`);
|
||||
|
||||
// Strip read-only fields for editor
|
||||
const editable = stripInternalFields(current);
|
||||
|
||||
// Serialize to YAML
|
||||
const singular = resource.replace(/s$/, '');
|
||||
const header = `# Editing ${singular}: ${nameOrId}\n# Save and close to apply changes. Clear the file to cancel.\n`;
|
||||
const originalYaml = yaml.dump(editable, { lineWidth: 120, noRefs: true });
|
||||
const content = header + originalYaml;
|
||||
|
||||
// Write to temp file
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-edit-'));
|
||||
const tmpFile = join(tmpDir, `${singular}-${nameOrId}.yaml`);
|
||||
writeFileSync(tmpFile, content, 'utf-8');
|
||||
|
||||
try {
|
||||
// Open editor
|
||||
const editor = getEditor(deps);
|
||||
openEditor(tmpFile, editor, deps);
|
||||
|
||||
// Read back
|
||||
const modified = readFileSync(tmpFile, 'utf-8');
|
||||
|
||||
// Strip comments for comparison
|
||||
const modifiedClean = modified
|
||||
.split('\n')
|
||||
.filter((line) => !line.startsWith('#'))
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
if (!modifiedClean) {
|
||||
log('Edit cancelled (empty file).');
|
||||
return;
|
||||
}
|
||||
|
||||
if (modifiedClean === originalYaml.trim()) {
|
||||
log(`${singular} '${nameOrId}' unchanged.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse and apply
|
||||
const updates = yaml.load(modifiedClean) as Record<string, unknown>;
|
||||
await client.put(`/api/v1/${resource}/${id}`, updates);
|
||||
log(`${singular} '${nameOrId}' updated.`);
|
||||
} finally {
|
||||
try {
|
||||
unlinkSync(tmpFile);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Command } from 'commander';
|
||||
import { formatTable } from '../formatters/table.js';
|
||||
import { formatJson, formatYaml } from '../formatters/output.js';
|
||||
import type { Column } from '../formatters/table.js';
|
||||
import { resolveResource, stripInternalFields } from './shared.js';
|
||||
|
||||
export interface GetCommandDeps {
|
||||
fetchResource: (resource: string, id?: string) => Promise<unknown[]>;
|
||||
@@ -16,41 +17,38 @@ interface ServerRow {
|
||||
dockerImage: string | null;
|
||||
}
|
||||
|
||||
interface ProfileRow {
|
||||
id: string;
|
||||
name: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
interface ProjectRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
proxyMode: string;
|
||||
ownerId: string;
|
||||
servers?: Array<{ server: { name: string } }>;
|
||||
}
|
||||
|
||||
interface SecretRow {
|
||||
id: string;
|
||||
name: string;
|
||||
data: Record<string, string>;
|
||||
}
|
||||
|
||||
interface TemplateRow {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
transport: string;
|
||||
packageName: string | null;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface InstanceRow {
|
||||
id: string;
|
||||
serverId: string;
|
||||
server?: { name: string };
|
||||
status: string;
|
||||
containerId: string | null;
|
||||
port: number | null;
|
||||
}
|
||||
|
||||
const RESOURCE_ALIASES: Record<string, string> = {
|
||||
server: 'servers',
|
||||
srv: 'servers',
|
||||
profile: 'profiles',
|
||||
prof: 'profiles',
|
||||
project: 'projects',
|
||||
proj: 'projects',
|
||||
instance: 'instances',
|
||||
inst: 'instances',
|
||||
};
|
||||
|
||||
function resolveResource(name: string): string {
|
||||
const lower = name.toLowerCase();
|
||||
return RESOURCE_ALIASES[lower] ?? lower;
|
||||
healthStatus: string | null;
|
||||
}
|
||||
|
||||
const serverColumns: Column<ServerRow>[] = [
|
||||
@@ -61,22 +59,111 @@ const serverColumns: Column<ServerRow>[] = [
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
|
||||
const profileColumns: Column<ProfileRow>[] = [
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ header: 'SERVER ID', key: 'serverId' },
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
interface UserRow {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
provider: string | null;
|
||||
}
|
||||
|
||||
interface GroupRow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
members?: Array<{ user: { email: string } }>;
|
||||
}
|
||||
|
||||
interface RbacRow {
|
||||
id: string;
|
||||
name: string;
|
||||
subjects: Array<{ kind: string; name: string }>;
|
||||
roleBindings: Array<{ role: string; resource?: string; action?: string; name?: string }>;
|
||||
}
|
||||
|
||||
const projectColumns: Column<ProjectRow>[] = [
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ header: 'MODE', key: (r) => r.proxyMode ?? 'direct', width: 10 },
|
||||
{ header: 'SERVERS', key: (r) => r.servers ? String(r.servers.length) : '0', width: 8 },
|
||||
{ header: 'DESCRIPTION', key: 'description', width: 30 },
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
|
||||
const userColumns: Column<UserRow>[] = [
|
||||
{ header: 'EMAIL', key: 'email' },
|
||||
{ header: 'NAME', key: (r) => r.name ?? '-' },
|
||||
{ header: 'PROVIDER', key: (r) => r.provider ?? 'local', width: 10 },
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
|
||||
const groupColumns: Column<GroupRow>[] = [
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ header: 'MEMBERS', key: (r) => r.members ? String(r.members.length) : '0', width: 8 },
|
||||
{ header: 'DESCRIPTION', key: 'description', width: 40 },
|
||||
{ header: 'OWNER', key: 'ownerId' },
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
|
||||
const rbacColumns: Column<RbacRow>[] = [
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ header: 'SUBJECTS', key: (r) => r.subjects.map((s) => `${s.kind}:${s.name}`).join(', '), width: 30 },
|
||||
{ header: 'BINDINGS', key: (r) => r.roleBindings.map((b) => {
|
||||
if ('action' in b && b.action !== undefined) return `run>${b.action}`;
|
||||
if ('resource' in b && b.resource !== undefined) {
|
||||
const base = `${b.role}:${b.resource}`;
|
||||
return b.name ? `${base}:${b.name}` : base;
|
||||
}
|
||||
return b.role;
|
||||
}).join(', '), width: 40 },
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
|
||||
const secretColumns: Column<SecretRow>[] = [
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ header: 'KEYS', key: (r) => Object.keys(r.data).join(', ') || '-', width: 40 },
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
|
||||
const templateColumns: Column<TemplateRow>[] = [
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ header: 'VERSION', key: 'version', width: 10 },
|
||||
{ header: 'TRANSPORT', key: 'transport', width: 16 },
|
||||
{ header: 'PACKAGE', key: (r) => r.packageName ?? '-' },
|
||||
{ header: 'DESCRIPTION', key: 'description', width: 50 },
|
||||
];
|
||||
|
||||
interface PromptRow {
|
||||
id: string;
|
||||
name: string;
|
||||
projectId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface PromptRequestRow {
|
||||
id: string;
|
||||
name: string;
|
||||
projectId: string | null;
|
||||
createdBySession: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const promptColumns: Column<PromptRow>[] = [
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ header: 'PROJECT', key: (r) => r.projectId ?? '-', width: 20 },
|
||||
{ header: 'CREATED', key: (r) => new Date(r.createdAt).toLocaleString(), width: 20 },
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
|
||||
const promptRequestColumns: Column<PromptRequestRow>[] = [
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ header: 'PROJECT', key: (r) => r.projectId ?? '-', width: 20 },
|
||||
{ header: 'SESSION', key: (r) => r.createdBySession ? r.createdBySession.slice(0, 12) : '-', width: 14 },
|
||||
{ header: 'CREATED', key: (r) => new Date(r.createdAt).toLocaleString(), width: 20 },
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
|
||||
const instanceColumns: Column<InstanceRow>[] = [
|
||||
{ header: 'NAME', key: (r) => r.server?.name ?? '-', width: 20 },
|
||||
{ header: 'STATUS', key: 'status', width: 10 },
|
||||
{ header: 'SERVER ID', key: 'serverId' },
|
||||
{ header: 'HEALTH', key: (r) => r.healthStatus ?? '-', width: 10 },
|
||||
{ header: 'PORT', key: (r) => r.port != null ? String(r.port) : '-', width: 6 },
|
||||
{ header: 'CONTAINER', key: (r) => r.containerId ? r.containerId.slice(0, 12) : '-', width: 14 },
|
||||
{ header: 'ID', key: 'id' },
|
||||
@@ -86,12 +173,24 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
|
||||
switch (resource) {
|
||||
case 'servers':
|
||||
return serverColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'profiles':
|
||||
return profileColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'projects':
|
||||
return projectColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'secrets':
|
||||
return secretColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'templates':
|
||||
return templateColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'instances':
|
||||
return instanceColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'users':
|
||||
return userColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'groups':
|
||||
return groupColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'rbac':
|
||||
return rbacColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'prompts':
|
||||
return promptColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'promptrequests':
|
||||
return promptRequestColumns as unknown as Column<Record<string, unknown>>[];
|
||||
default:
|
||||
return [
|
||||
{ header: 'ID', key: 'id' as keyof Record<string, unknown> },
|
||||
@@ -100,21 +199,38 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform API response items into apply-compatible format.
|
||||
* Strips internal fields and wraps in the resource key.
|
||||
*/
|
||||
function toApplyFormat(resource: string, items: unknown[]): Record<string, unknown[]> {
|
||||
const cleaned = items.map((item) => {
|
||||
return stripInternalFields(item as Record<string, unknown>);
|
||||
});
|
||||
return { [resource]: cleaned };
|
||||
}
|
||||
|
||||
export function createGetCommand(deps: GetCommandDeps): Command {
|
||||
return new Command('get')
|
||||
.description('List resources (servers, profiles, projects, instances)')
|
||||
.argument('<resource>', 'resource type (servers, profiles, projects, instances)')
|
||||
.argument('[id]', 'specific resource ID')
|
||||
.description('List resources (servers, projects, instances)')
|
||||
.argument('<resource>', 'resource type (servers, projects, instances)')
|
||||
.argument('[id]', 'specific resource ID or name')
|
||||
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
|
||||
.action(async (resourceArg: string, id: string | undefined, opts: { output: string }) => {
|
||||
const resource = resolveResource(resourceArg);
|
||||
const items = await deps.fetchResource(resource, id);
|
||||
|
||||
if (opts.output === 'json') {
|
||||
deps.log(formatJson(items.length === 1 ? items[0] : items));
|
||||
// Apply-compatible JSON wrapped in resource key
|
||||
deps.log(formatJson(toApplyFormat(resource, items)));
|
||||
} else if (opts.output === 'yaml') {
|
||||
deps.log(formatYaml(items.length === 1 ? items[0] : items));
|
||||
// Apply-compatible YAML wrapped in resource key
|
||||
deps.log(formatYaml(toApplyFormat(resource, items)));
|
||||
} else {
|
||||
if (items.length === 0) {
|
||||
deps.log(`No ${resource} found.`);
|
||||
return;
|
||||
}
|
||||
const columns = getColumnsForResource(resource);
|
||||
deps.log(formatTable(items as Record<string, unknown>[], columns));
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { Command } from 'commander';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
|
||||
interface Instance {
|
||||
id: string;
|
||||
serverId: string;
|
||||
status: string;
|
||||
containerId: string | null;
|
||||
port: number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface InstanceCommandDeps {
|
||||
client: ApiClient;
|
||||
log: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export function createInstanceCommands(deps: InstanceCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
const cmd = new Command('instance')
|
||||
.alias('instances')
|
||||
.alias('inst')
|
||||
.description('Manage MCP server instances');
|
||||
|
||||
cmd
|
||||
.command('list')
|
||||
.alias('ls')
|
||||
.description('List running instances')
|
||||
.option('-s, --server <id>', 'Filter by server ID')
|
||||
.option('-o, --output <format>', 'Output format (table, json)', 'table')
|
||||
.action(async (opts: { server?: string; output: string }) => {
|
||||
let url = '/api/v1/instances';
|
||||
if (opts.server) {
|
||||
url += `?serverId=${encodeURIComponent(opts.server)}`;
|
||||
}
|
||||
const instances = await client.get<Instance[]>(url);
|
||||
if (opts.output === 'json') {
|
||||
log(JSON.stringify(instances, null, 2));
|
||||
return;
|
||||
}
|
||||
if (instances.length === 0) {
|
||||
log('No instances found.');
|
||||
return;
|
||||
}
|
||||
log('ID\tSERVER\tSTATUS\tPORT\tCONTAINER');
|
||||
for (const inst of instances) {
|
||||
const cid = inst.containerId ? inst.containerId.slice(0, 12) : '-';
|
||||
const port = inst.port ?? '-';
|
||||
log(`${inst.id}\t${inst.serverId}\t${inst.status}\t${port}\t${cid}`);
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('start <serverId>')
|
||||
.description('Start a new MCP server instance')
|
||||
.option('-p, --port <port>', 'Host port to bind')
|
||||
.option('-o, --output <format>', 'Output format (table, json)', 'table')
|
||||
.action(async (serverId: string, opts: { port?: string; output: string }) => {
|
||||
const body: Record<string, unknown> = { serverId };
|
||||
if (opts.port !== undefined) {
|
||||
body.hostPort = parseInt(opts.port, 10);
|
||||
}
|
||||
const instance = await client.post<Instance>('/api/v1/instances', body);
|
||||
if (opts.output === 'json') {
|
||||
log(JSON.stringify(instance, null, 2));
|
||||
return;
|
||||
}
|
||||
log(`Instance ${instance.id} started (status: ${instance.status})`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('stop <id>')
|
||||
.description('Stop a running instance')
|
||||
.action(async (id: string) => {
|
||||
const instance = await client.post<Instance>(`/api/v1/instances/${id}/stop`);
|
||||
log(`Instance ${id} stopped (status: ${instance.status})`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('restart <id>')
|
||||
.description('Restart an instance (stop, remove, start fresh)')
|
||||
.action(async (id: string) => {
|
||||
const instance = await client.post<Instance>(`/api/v1/instances/${id}/restart`);
|
||||
log(`Instance restarted as ${instance.id} (status: ${instance.status})`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('remove <id>')
|
||||
.alias('rm')
|
||||
.description('Remove an instance and its container')
|
||||
.action(async (id: string) => {
|
||||
await client.delete(`/api/v1/instances/${id}`);
|
||||
log(`Instance ${id} removed.`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('logs <id>')
|
||||
.description('Get logs from an instance')
|
||||
.option('-t, --tail <lines>', 'Number of lines to show')
|
||||
.action(async (id: string, opts: { tail?: string }) => {
|
||||
let url = `/api/v1/instances/${id}/logs`;
|
||||
if (opts.tail) {
|
||||
url += `?tail=${opts.tail}`;
|
||||
}
|
||||
const logs = await client.get<{ stdout: string; stderr: string }>(url);
|
||||
if (logs.stdout) {
|
||||
log(logs.stdout);
|
||||
}
|
||||
if (logs.stderr) {
|
||||
process.stderr.write(logs.stderr);
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('inspect <id>')
|
||||
.description('Get detailed container info for an instance')
|
||||
.action(async (id: string) => {
|
||||
const info = await client.get(`/api/v1/instances/${id}/inspect`);
|
||||
log(JSON.stringify(info, null, 2));
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
98
src/cli/src/commands/logs.ts
Normal file
98
src/cli/src/commands/logs.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Command } from 'commander';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
|
||||
export interface LogsCommandDeps {
|
||||
client: ApiClient;
|
||||
log: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
interface InstanceInfo {
|
||||
id: string;
|
||||
status: string;
|
||||
containerId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a name/ID to an instance ID.
|
||||
* Accepts: instance ID, server name, or server ID.
|
||||
* For servers with multiple replicas, picks by --instance index or first RUNNING.
|
||||
*/
|
||||
async function resolveInstance(
|
||||
client: ApiClient,
|
||||
nameOrId: string,
|
||||
instanceIndex?: number,
|
||||
): Promise<{ instanceId: string; serverName?: string; replicaInfo?: string }> {
|
||||
// Try as instance ID first
|
||||
try {
|
||||
await client.get(`/api/v1/instances/${nameOrId}`);
|
||||
return { instanceId: nameOrId };
|
||||
} catch {
|
||||
// Not a valid instance ID
|
||||
}
|
||||
|
||||
// Try as server name/ID → find its instances
|
||||
const servers = await client.get<Array<{ id: string; name: string }>>('/api/v1/servers');
|
||||
const server = servers.find((s) => s.name === nameOrId || s.id === nameOrId);
|
||||
if (!server) {
|
||||
throw new Error(`Instance or server '${nameOrId}' not found`);
|
||||
}
|
||||
|
||||
const instances = await client.get<InstanceInfo[]>(`/api/v1/instances?serverId=${server.id}`);
|
||||
if (instances.length === 0) {
|
||||
throw new Error(`No instances found for server '${server.name}'`);
|
||||
}
|
||||
|
||||
// Select by index or pick first running
|
||||
let selected: InstanceInfo | undefined;
|
||||
if (instanceIndex !== undefined) {
|
||||
if (instanceIndex < 0 || instanceIndex >= instances.length) {
|
||||
throw new Error(`Instance index ${instanceIndex} out of range (server '${server.name}' has ${instances.length} instance${instances.length > 1 ? 's' : ''})`);
|
||||
}
|
||||
selected = instances[instanceIndex];
|
||||
} else {
|
||||
selected = instances.find((i) => i.status === 'RUNNING') ?? instances[0];
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
throw new Error(`No instances found for server '${server.name}'`);
|
||||
}
|
||||
|
||||
const result: { instanceId: string; serverName?: string; replicaInfo?: string } = {
|
||||
instanceId: selected.id,
|
||||
serverName: server.name,
|
||||
};
|
||||
if (instances.length > 1) {
|
||||
result.replicaInfo = `instance ${instances.indexOf(selected) + 1}/${instances.length}`;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createLogsCommand(deps: LogsCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
return new Command('logs')
|
||||
.description('Get logs from an MCP server instance')
|
||||
.argument('<name>', 'Server name, server ID, or instance ID')
|
||||
.option('-t, --tail <lines>', 'Number of lines to show')
|
||||
.option('-i, --instance <index>', 'Instance/replica index (0-based, for servers with multiple replicas)')
|
||||
.action(async (nameOrId: string, opts: { tail?: string; instance?: string }) => {
|
||||
const instanceIndex = opts.instance !== undefined ? parseInt(opts.instance, 10) : undefined;
|
||||
const { instanceId, serverName, replicaInfo } = await resolveInstance(client, nameOrId, instanceIndex);
|
||||
|
||||
if (replicaInfo) {
|
||||
process.stderr.write(`Showing logs for ${serverName} (${replicaInfo})\n`);
|
||||
}
|
||||
|
||||
let url = `/api/v1/instances/${instanceId}/logs`;
|
||||
if (opts.tail) {
|
||||
url += `?tail=${opts.tail}`;
|
||||
}
|
||||
const logs = await client.get<{ stdout: string; stderr: string }>(url);
|
||||
if (logs.stdout) {
|
||||
log(logs.stdout);
|
||||
}
|
||||
if (logs.stderr) {
|
||||
process.stderr.write(logs.stderr);
|
||||
}
|
||||
});
|
||||
}
|
||||
224
src/cli/src/commands/mcp.ts
Normal file
224
src/cli/src/commands/mcp.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
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, text/event-stream',
|
||||
};
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract JSON-RPC messages from an HTTP response body.
|
||||
* Handles both plain JSON and SSE (text/event-stream) formats.
|
||||
*/
|
||||
function extractJsonRpcMessages(contentType: string | undefined, body: string): string[] {
|
||||
if (contentType?.includes('text/event-stream')) {
|
||||
// Parse SSE: extract data: lines
|
||||
const messages: string[] = [];
|
||||
for (const line of body.split('\n')) {
|
||||
if (line.startsWith('data: ')) {
|
||||
messages.push(line.slice(6));
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
// Plain JSON response
|
||||
return [body];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`);
|
||||
}
|
||||
|
||||
// Handle both plain JSON and SSE responses
|
||||
const messages = extractJsonRpcMessages(result.headers['content-type'], result.body);
|
||||
for (const msg of messages) {
|
||||
const trimmedMsg = msg.trim();
|
||||
if (trimmedMsg) {
|
||||
stdout.write(trimmedMsg + '\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')
|
||||
.passThroughOptions()
|
||||
.option('-p, --project <name>', 'Project name')
|
||||
.action(async (opts: { project?: string }) => {
|
||||
// Accept -p/--project on the command itself, or fall back to global --project
|
||||
const projectName = opts.project ?? 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;
|
||||
}
|
||||
66
src/cli/src/commands/project-ops.ts
Normal file
66
src/cli/src/commands/project-ops.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Command } from 'commander';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
import { resolveNameOrId, resolveResource } from './shared.js';
|
||||
|
||||
export interface ProjectOpsDeps {
|
||||
client: ApiClient;
|
||||
log: (...args: string[]) => void;
|
||||
getProject: () => string | undefined;
|
||||
}
|
||||
|
||||
function requireProject(deps: ProjectOpsDeps): string {
|
||||
const project = deps.getProject();
|
||||
if (!project) {
|
||||
deps.log('Error: --project <name> is required for this command.');
|
||||
process.exitCode = 1;
|
||||
throw new Error('--project required');
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
export function createAttachServerCommand(deps: ProjectOpsDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
return new Command('attach-server')
|
||||
.description('Attach a server to a project (requires --project)')
|
||||
.argument('<server-name>', 'Server name to attach')
|
||||
.action(async (serverName: string) => {
|
||||
const projectName = requireProject(deps);
|
||||
const projectId = await resolveNameOrId(client, 'projects', projectName);
|
||||
await client.post(`/api/v1/projects/${projectId}/servers`, { server: serverName });
|
||||
log(`server '${serverName}' attached to project '${projectName}'`);
|
||||
});
|
||||
}
|
||||
|
||||
export function createDetachServerCommand(deps: ProjectOpsDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
return new Command('detach-server')
|
||||
.description('Detach a server from a project (requires --project)')
|
||||
.argument('<server-name>', 'Server name to detach')
|
||||
.action(async (serverName: string) => {
|
||||
const projectName = requireProject(deps);
|
||||
const projectId = await resolveNameOrId(client, 'projects', projectName);
|
||||
await client.delete(`/api/v1/projects/${projectId}/servers/${serverName}`);
|
||||
log(`server '${serverName}' detached from project '${projectName}'`);
|
||||
});
|
||||
}
|
||||
|
||||
export function createApproveCommand(deps: ProjectOpsDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
return new Command('approve')
|
||||
.description('Approve a pending prompt request (atomic: delete request, create prompt)')
|
||||
.argument('<resource>', 'Resource type (promptrequest)')
|
||||
.argument('<name>', 'Prompt request name or ID')
|
||||
.action(async (resourceArg: string, nameOrId: string) => {
|
||||
const resource = resolveResource(resourceArg);
|
||||
if (resource !== 'promptrequests') {
|
||||
throw new Error(`approve is only supported for 'promptrequest', got '${resourceArg}'`);
|
||||
}
|
||||
|
||||
const id = await resolveNameOrId(client, 'promptrequests', nameOrId);
|
||||
const prompt = await client.post<{ id: string; name: string }>(`/api/v1/promptrequests/${id}/approve`, {});
|
||||
log(`prompt request approved → prompt '${prompt.name}' created (id: ${prompt.id})`);
|
||||
});
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { Command } from 'commander';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Profile {
|
||||
id: string;
|
||||
name: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export interface ProjectCommandDeps {
|
||||
client: ApiClient;
|
||||
log: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export function createProjectCommand(deps: ProjectCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
const cmd = new Command('project')
|
||||
.alias('projects')
|
||||
.alias('proj')
|
||||
.description('Manage mcpctl projects');
|
||||
|
||||
cmd
|
||||
.command('list')
|
||||
.alias('ls')
|
||||
.description('List all projects')
|
||||
.option('-o, --output <format>', 'Output format (table, json)', 'table')
|
||||
.action(async (opts: { output: string }) => {
|
||||
const projects = await client.get<Project[]>('/api/v1/projects');
|
||||
if (opts.output === 'json') {
|
||||
log(JSON.stringify(projects, null, 2));
|
||||
return;
|
||||
}
|
||||
if (projects.length === 0) {
|
||||
log('No projects found.');
|
||||
return;
|
||||
}
|
||||
log('ID\tNAME\tDESCRIPTION');
|
||||
for (const p of projects) {
|
||||
log(`${p.id}\t${p.name}\t${p.description || '-'}`);
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('create <name>')
|
||||
.description('Create a new project')
|
||||
.option('-d, --description <text>', 'Project description', '')
|
||||
.action(async (name: string, opts: { description: string }) => {
|
||||
const project = await client.post<Project>('/api/v1/projects', {
|
||||
name,
|
||||
description: opts.description,
|
||||
});
|
||||
log(`Project '${project.name}' created (id: ${project.id})`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('delete <id>')
|
||||
.alias('rm')
|
||||
.description('Delete a project')
|
||||
.action(async (id: string) => {
|
||||
await client.delete(`/api/v1/projects/${id}`);
|
||||
log(`Project '${id}' deleted.`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('show <id>')
|
||||
.description('Show project details')
|
||||
.action(async (id: string) => {
|
||||
const project = await client.get<Project>(`/api/v1/projects/${id}`);
|
||||
log(`Name: ${project.name}`);
|
||||
log(`ID: ${project.id}`);
|
||||
log(`Description: ${project.description || '-'}`);
|
||||
log(`Owner: ${project.ownerId}`);
|
||||
log(`Created: ${project.createdAt}`);
|
||||
|
||||
try {
|
||||
const profiles = await client.get<Profile[]>(`/api/v1/projects/${id}/profiles`);
|
||||
if (profiles.length > 0) {
|
||||
log('\nProfiles:');
|
||||
for (const p of profiles) {
|
||||
log(` - ${p.name} (id: ${p.id})`);
|
||||
}
|
||||
} else {
|
||||
log('\nNo profiles assigned.');
|
||||
}
|
||||
} catch {
|
||||
// Profiles endpoint may not be available
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('profiles <id>')
|
||||
.description('List profiles assigned to a project')
|
||||
.option('-o, --output <format>', 'Output format (table, json)', 'table')
|
||||
.action(async (id: string, opts: { output: string }) => {
|
||||
const profiles = await client.get<Profile[]>(`/api/v1/projects/${id}/profiles`);
|
||||
if (opts.output === 'json') {
|
||||
log(JSON.stringify(profiles, null, 2));
|
||||
return;
|
||||
}
|
||||
if (profiles.length === 0) {
|
||||
log('No profiles assigned.');
|
||||
return;
|
||||
}
|
||||
log('ID\tNAME\tSERVER');
|
||||
for (const p of profiles) {
|
||||
log(`${p.id}\t${p.name}\t${p.serverId}`);
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('set-profiles <id>')
|
||||
.description('Set the profiles assigned to a project')
|
||||
.argument('<profileIds...>', 'Profile IDs to assign')
|
||||
.action(async (id: string, profileIds: string[]) => {
|
||||
await client.put(`/api/v1/projects/${id}/profiles`, { profileIds });
|
||||
log(`Set ${profileIds.length} profile(s) for project '${id}'.`);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { Command } from 'commander';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
|
||||
export interface SetupPromptDeps {
|
||||
input: (message: string) => Promise<string>;
|
||||
password: (message: string) => Promise<string>;
|
||||
select: <T extends string>(message: string, choices: Array<{ name: string; value: T }>) => Promise<T>;
|
||||
confirm: (message: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface SetupCommandDeps {
|
||||
client: ApiClient;
|
||||
prompt: SetupPromptDeps;
|
||||
log: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export function createSetupCommand(deps: SetupCommandDeps): Command {
|
||||
const { client, prompt, log } = deps;
|
||||
|
||||
return new Command('setup')
|
||||
.description('Interactive wizard for configuring an MCP server')
|
||||
.argument('[server-name]', 'Server name to set up (will prompt if not given)')
|
||||
.action(async (serverName?: string) => {
|
||||
log('MCP Server Setup Wizard\n');
|
||||
|
||||
// Step 1: Server name
|
||||
const name = serverName ?? await prompt.input('Server name (lowercase, hyphens allowed):');
|
||||
if (!name) {
|
||||
log('Setup cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Transport
|
||||
const transport = await prompt.select('Transport type:', [
|
||||
{ name: 'STDIO (command-line process)', value: 'STDIO' as const },
|
||||
{ name: 'SSE (Server-Sent Events over HTTP)', value: 'SSE' as const },
|
||||
{ name: 'Streamable HTTP', value: 'STREAMABLE_HTTP' as const },
|
||||
]);
|
||||
|
||||
// Step 3: Package or image
|
||||
const packageName = await prompt.input('NPM package name (or leave empty):');
|
||||
const dockerImage = await prompt.input('Docker image (or leave empty):');
|
||||
|
||||
// Step 4: Description
|
||||
const description = await prompt.input('Description:');
|
||||
|
||||
// Step 5: Create the server
|
||||
const serverData: Record<string, unknown> = {
|
||||
name,
|
||||
transport,
|
||||
description,
|
||||
};
|
||||
if (packageName) serverData.packageName = packageName;
|
||||
if (dockerImage) serverData.dockerImage = dockerImage;
|
||||
|
||||
let server: { id: string; name: string };
|
||||
try {
|
||||
server = await client.post<{ id: string; name: string }>('/api/v1/servers', serverData);
|
||||
log(`\nServer '${server.name}' created.`);
|
||||
} catch (err) {
|
||||
log(`\nFailed to create server: ${err instanceof Error ? err.message : err}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 6: Create a profile with env vars
|
||||
const createProfile = await prompt.confirm('Create a profile with environment variables?');
|
||||
if (!createProfile) {
|
||||
log('\nSetup complete!');
|
||||
return;
|
||||
}
|
||||
|
||||
const profileName = await prompt.input('Profile name:') || 'default';
|
||||
|
||||
// Collect env vars
|
||||
const envOverrides: Record<string, string> = {};
|
||||
let addMore = true;
|
||||
while (addMore) {
|
||||
const envName = await prompt.input('Environment variable name (empty to finish):');
|
||||
if (!envName) break;
|
||||
|
||||
const isSecret = await prompt.confirm(`Is '${envName}' a secret (e.g., API key)?`);
|
||||
const envValue = isSecret
|
||||
? await prompt.password(`Value for ${envName}:`)
|
||||
: await prompt.input(`Value for ${envName}:`);
|
||||
|
||||
envOverrides[envName] = envValue;
|
||||
addMore = await prompt.confirm('Add another environment variable?');
|
||||
}
|
||||
|
||||
try {
|
||||
await client.post('/api/v1/profiles', {
|
||||
name: profileName,
|
||||
serverId: server.id,
|
||||
envOverrides,
|
||||
});
|
||||
log(`Profile '${profileName}' created for server '${name}'.`);
|
||||
} catch (err) {
|
||||
log(`Failed to create profile: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
log('\nSetup complete!');
|
||||
});
|
||||
}
|
||||
68
src/cli/src/commands/shared.ts
Normal file
68
src/cli/src/commands/shared.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
|
||||
export const RESOURCE_ALIASES: Record<string, string> = {
|
||||
server: 'servers',
|
||||
srv: 'servers',
|
||||
project: 'projects',
|
||||
proj: 'projects',
|
||||
instance: 'instances',
|
||||
inst: 'instances',
|
||||
secret: 'secrets',
|
||||
sec: 'secrets',
|
||||
template: 'templates',
|
||||
tpl: 'templates',
|
||||
user: 'users',
|
||||
group: 'groups',
|
||||
rbac: 'rbac',
|
||||
'rbac-definition': 'rbac',
|
||||
'rbac-binding': 'rbac',
|
||||
prompt: 'prompts',
|
||||
prompts: 'prompts',
|
||||
promptrequest: 'promptrequests',
|
||||
promptrequests: 'promptrequests',
|
||||
pr: 'promptrequests',
|
||||
};
|
||||
|
||||
export function resolveResource(name: string): string {
|
||||
const lower = name.toLowerCase();
|
||||
return RESOURCE_ALIASES[lower] ?? lower;
|
||||
}
|
||||
|
||||
/** Resolve a name-or-ID to an ID. CUIDs pass through; names are looked up. */
|
||||
export async function resolveNameOrId(
|
||||
client: ApiClient,
|
||||
resource: string,
|
||||
nameOrId: string,
|
||||
): Promise<string> {
|
||||
// CUIDs start with 'c' followed by 24+ alphanumeric chars
|
||||
if (/^c[a-z0-9]{24}/.test(nameOrId)) {
|
||||
return nameOrId;
|
||||
}
|
||||
// Users resolve by email, not name
|
||||
if (resource === 'users') {
|
||||
const items = await client.get<Array<{ id: string; email: string }>>(`/api/v1/${resource}`);
|
||||
const match = items.find((item) => item.email === nameOrId);
|
||||
if (match) return match.id;
|
||||
throw new Error(`user '${nameOrId}' not found`);
|
||||
}
|
||||
const items = await client.get<Array<Record<string, unknown>>>(`/api/v1/${resource}`);
|
||||
const match = items.find((item) => {
|
||||
// Instances use server.name, other resources use name directly
|
||||
if (resource === 'instances') {
|
||||
const server = item.server as { name?: string } | undefined;
|
||||
return server?.name === nameOrId;
|
||||
}
|
||||
return item.name === nameOrId;
|
||||
});
|
||||
if (match) return match.id as string;
|
||||
throw new Error(`${resource.replace(/s$/, '')} '${nameOrId}' not found`);
|
||||
}
|
||||
|
||||
/** Strip internal/read-only fields from an API response to make it apply-compatible. */
|
||||
export function stripInternalFields(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
const result = { ...obj };
|
||||
for (const key of ['id', 'createdAt', 'updatedAt', 'version', 'ownerId']) {
|
||||
delete result[key];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -5,27 +5,30 @@ import { createConfigCommand } from './commands/config.js';
|
||||
import { createStatusCommand } from './commands/status.js';
|
||||
import { createGetCommand } from './commands/get.js';
|
||||
import { createDescribeCommand } from './commands/describe.js';
|
||||
import { createInstanceCommands } from './commands/instances.js';
|
||||
import { createDeleteCommand } from './commands/delete.js';
|
||||
import { createLogsCommand } from './commands/logs.js';
|
||||
import { createApplyCommand } from './commands/apply.js';
|
||||
import { createSetupCommand } from './commands/setup.js';
|
||||
import { createClaudeCommand } from './commands/claude.js';
|
||||
import { createProjectCommand } from './commands/project.js';
|
||||
import { createCreateCommand } from './commands/create.js';
|
||||
import { createEditCommand } from './commands/edit.js';
|
||||
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
|
||||
import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
|
||||
import { ApiClient } from './api-client.js';
|
||||
import { createAttachServerCommand, createDetachServerCommand, createApproveCommand } from './commands/project-ops.js';
|
||||
import { createMcpCommand } from './commands/mcp.js';
|
||||
import { ApiClient, ApiError } from './api-client.js';
|
||||
import { loadConfig } from './config/index.js';
|
||||
import { loadCredentials } from './auth/index.js';
|
||||
import { resolveNameOrId } from './commands/shared.js';
|
||||
|
||||
export function createProgram(): Command {
|
||||
const program = new Command()
|
||||
.name(APP_NAME)
|
||||
.description('Manage MCP servers like kubectl manages containers')
|
||||
.version(APP_VERSION, '-v, --version')
|
||||
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
|
||||
.enablePositionalOptions()
|
||||
.option('--daemon-url <url>', 'mcplocal daemon URL')
|
||||
.option('--direct', 'bypass mcplocal and connect directly to mcpd');
|
||||
.option('--direct', 'bypass mcplocal and connect directly to mcpd')
|
||||
.option('--project <name>', 'Target project for project commands');
|
||||
|
||||
program.addCommand(createConfigCommand());
|
||||
program.addCommand(createStatusCommand());
|
||||
program.addCommand(createLoginCommand());
|
||||
program.addCommand(createLogoutCommand());
|
||||
@@ -45,15 +48,52 @@ export function createProgram(): Command {
|
||||
|
||||
const client = new ApiClient({ baseUrl, token: creds?.token ?? undefined });
|
||||
|
||||
const fetchResource = async (resource: string, id?: string): Promise<unknown[]> => {
|
||||
if (id) {
|
||||
program.addCommand(createConfigCommand(undefined, {
|
||||
client,
|
||||
credentialsDeps: {},
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
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) {
|
||||
// Glob pattern — use query param filtering
|
||||
if (nameOrId.includes('*')) {
|
||||
return client.get<unknown[]>(`/api/v1/${resource}?name=${encodeURIComponent(nameOrId)}`);
|
||||
}
|
||||
let id: string;
|
||||
try {
|
||||
id = await resolveNameOrId(client, resource, nameOrId);
|
||||
} catch {
|
||||
id = nameOrId;
|
||||
}
|
||||
const item = await client.get(`/api/v1/${resource}/${id}`);
|
||||
return [item];
|
||||
}
|
||||
return client.get<unknown[]>(`/api/v1/${resource}`);
|
||||
};
|
||||
|
||||
const fetchSingleResource = async (resource: string, id: string): Promise<unknown> => {
|
||||
const fetchSingleResource = async (resource: string, nameOrId: string): Promise<unknown> => {
|
||||
let id: string;
|
||||
try {
|
||||
id = await resolveNameOrId(client, resource, nameOrId);
|
||||
} catch {
|
||||
id = nameOrId;
|
||||
}
|
||||
return client.get(`/api/v1/${resource}/${id}`);
|
||||
};
|
||||
|
||||
@@ -63,11 +103,28 @@ export function createProgram(): Command {
|
||||
}));
|
||||
|
||||
program.addCommand(createDescribeCommand({
|
||||
client,
|
||||
fetchResource: fetchSingleResource,
|
||||
fetchInspect: async (id: string) => client.get(`/api/v1/instances/${id}/inspect`),
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
program.addCommand(createInstanceCommands({
|
||||
program.addCommand(createDeleteCommand({
|
||||
client,
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
program.addCommand(createLogsCommand({
|
||||
client,
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
program.addCommand(createCreateCommand({
|
||||
client,
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
program.addCommand(createEditCommand({
|
||||
client,
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
@@ -77,43 +134,6 @@ export function createProgram(): Command {
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
program.addCommand(createSetupCommand({
|
||||
client,
|
||||
prompt: {
|
||||
async input(message) {
|
||||
const { default: inquirer } = await import('inquirer');
|
||||
const { answer } = await inquirer.prompt([{ type: 'input', name: 'answer', message }]);
|
||||
return answer as string;
|
||||
},
|
||||
async password(message) {
|
||||
const { default: inquirer } = await import('inquirer');
|
||||
const { answer } = await inquirer.prompt([{ type: 'password', name: 'answer', message }]);
|
||||
return answer as string;
|
||||
},
|
||||
async select(message, choices) {
|
||||
const { default: inquirer } = await import('inquirer');
|
||||
const { answer } = await inquirer.prompt([{ type: 'list', name: 'answer', message, choices }]);
|
||||
return answer;
|
||||
},
|
||||
async confirm(message) {
|
||||
const { default: inquirer } = await import('inquirer');
|
||||
const { answer } = await inquirer.prompt([{ type: 'confirm', name: 'answer', message }]);
|
||||
return answer as boolean;
|
||||
},
|
||||
},
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
program.addCommand(createClaudeCommand({
|
||||
client,
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
program.addCommand(createProjectCommand({
|
||||
client,
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
program.addCommand(createBackupCommand({
|
||||
client,
|
||||
log: (...args) => console.log(...args),
|
||||
@@ -124,6 +144,18 @@ export function createProgram(): Command {
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
const projectOpsDeps = {
|
||||
client,
|
||||
log: (...args: string[]) => console.log(...args),
|
||||
getProject: () => program.opts().project as string | undefined,
|
||||
};
|
||||
program.addCommand(createAttachServerCommand(projectOpsDeps), { hidden: true });
|
||||
program.addCommand(createDetachServerCommand(projectOpsDeps), { hidden: true });
|
||||
program.addCommand(createApproveCommand(projectOpsDeps));
|
||||
program.addCommand(createMcpCommand({
|
||||
getProject: () => program.opts().project as string | undefined,
|
||||
}), { hidden: true });
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
@@ -134,5 +166,35 @@ const isDirectRun =
|
||||
import.meta.url === `file://${process.argv[1]}`;
|
||||
|
||||
if (isDirectRun) {
|
||||
createProgram().parseAsync(process.argv);
|
||||
createProgram().parseAsync(process.argv).catch((err: unknown) => {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 401) {
|
||||
console.error("Error: you need to log in. Run 'mcpctl login' to authenticate.");
|
||||
} else if (err.status === 403) {
|
||||
console.error('Error: permission denied. You do not have access to this resource.');
|
||||
} else {
|
||||
let msg: string;
|
||||
try {
|
||||
const parsed = JSON.parse(err.body) as { error?: string; message?: string; details?: unknown };
|
||||
msg = parsed.error ?? parsed.message ?? err.body;
|
||||
if (parsed.details && Array.isArray(parsed.details)) {
|
||||
const issues = parsed.details as Array<{ message?: string; path?: string[] }>;
|
||||
const detail = issues.map((i) => {
|
||||
const path = i.path?.join('.') ?? '';
|
||||
return path ? `${path}: ${i.message}` : (i.message ?? '');
|
||||
}).filter(Boolean).join('; ');
|
||||
if (detail) msg += `: ${detail}`;
|
||||
}
|
||||
} catch {
|
||||
msg = err.body;
|
||||
}
|
||||
console.error(`Error: ${msg}`);
|
||||
}
|
||||
} else if (err instanceof Error) {
|
||||
console.error(`Error: ${err.message}`);
|
||||
} else {
|
||||
console.error(`Error: ${String(err)}`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,6 +21,16 @@ beforeAll(async () => {
|
||||
res.writeHead(201, { 'Content-Type': 'application/json' });
|
||||
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') {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Not found' }));
|
||||
@@ -75,6 +85,12 @@ describe('ApiClient', () => {
|
||||
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 () => {
|
||||
// We need a separate server to check the header
|
||||
let receivedAuth = '';
|
||||
|
||||
@@ -24,9 +24,10 @@ describe('createProgram', () => {
|
||||
expect(status).toBeDefined();
|
||||
});
|
||||
|
||||
it('has output option', () => {
|
||||
it('subcommands have output option', () => {
|
||||
const program = createProgram();
|
||||
const opt = program.options.find((o) => o.long === '--output');
|
||||
const get = program.commands.find((c) => c.name() === 'get');
|
||||
const opt = get?.options.find((o) => o.long === '--output');
|
||||
expect(opt).toBeDefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -86,9 +86,6 @@ servers:
|
||||
servers:
|
||||
- name: test
|
||||
transport: STDIO
|
||||
profiles:
|
||||
- name: default
|
||||
server: test
|
||||
`);
|
||||
|
||||
const cmd = createApplyCommand({ client, log });
|
||||
@@ -97,52 +94,51 @@ profiles:
|
||||
expect(client.post).not.toHaveBeenCalled();
|
||||
expect(output.join('\n')).toContain('Dry run');
|
||||
expect(output.join('\n')).toContain('1 server(s)');
|
||||
expect(output.join('\n')).toContain('1 profile(s)');
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('applies profiles with server lookup', async () => {
|
||||
it('applies secrets', async () => {
|
||||
const configPath = join(tmpDir, 'config.yaml');
|
||||
writeFileSync(configPath, `
|
||||
secrets:
|
||||
- name: ha-creds
|
||||
data:
|
||||
TOKEN: abc123
|
||||
URL: https://ha.local
|
||||
`);
|
||||
|
||||
const cmd = createApplyCommand({ client, log });
|
||||
await cmd.parseAsync([configPath], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', expect.objectContaining({
|
||||
name: 'ha-creds',
|
||||
data: { TOKEN: 'abc123', URL: 'https://ha.local' },
|
||||
}));
|
||||
expect(output.join('\n')).toContain('Created secret: ha-creds');
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('updates existing secrets', async () => {
|
||||
vi.mocked(client.get).mockImplementation(async (url: string) => {
|
||||
if (url === '/api/v1/servers') return [{ id: 'srv-1', name: 'slack' }];
|
||||
if (url === '/api/v1/secrets') return [{ id: 'sec-1', name: 'ha-creds' }];
|
||||
return [];
|
||||
});
|
||||
|
||||
const configPath = join(tmpDir, 'config.yaml');
|
||||
writeFileSync(configPath, `
|
||||
profiles:
|
||||
- name: default
|
||||
server: slack
|
||||
envOverrides:
|
||||
SLACK_TOKEN: "xoxb-test"
|
||||
secrets:
|
||||
- name: ha-creds
|
||||
data:
|
||||
TOKEN: new-token
|
||||
`);
|
||||
|
||||
const cmd = createApplyCommand({ client, log });
|
||||
await cmd.parseAsync([configPath], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({
|
||||
name: 'default',
|
||||
serverId: 'srv-1',
|
||||
envOverrides: { SLACK_TOKEN: 'xoxb-test' },
|
||||
}));
|
||||
expect(output.join('\n')).toContain('Created profile: default');
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('skips profiles when server not found', async () => {
|
||||
const configPath = join(tmpDir, 'config.yaml');
|
||||
writeFileSync(configPath, `
|
||||
profiles:
|
||||
- name: default
|
||||
server: nonexistent
|
||||
`);
|
||||
|
||||
const cmd = createApplyCommand({ client, log });
|
||||
await cmd.parseAsync([configPath], { from: 'user' });
|
||||
|
||||
expect(client.post).not.toHaveBeenCalled();
|
||||
expect(output.join('\n')).toContain("Skipping profile 'default'");
|
||||
expect(client.put).toHaveBeenCalledWith('/api/v1/secrets/sec-1', { data: { TOKEN: 'new-token' } });
|
||||
expect(output.join('\n')).toContain('Updated secret: ha-creds');
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
@@ -163,4 +159,347 @@ projects:
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('applies users (no role field)', async () => {
|
||||
const configPath = join(tmpDir, 'config.yaml');
|
||||
writeFileSync(configPath, `
|
||||
users:
|
||||
- email: alice@test.com
|
||||
password: password123
|
||||
name: Alice
|
||||
`);
|
||||
|
||||
const cmd = createApplyCommand({ client, log });
|
||||
await cmd.parseAsync([configPath], { from: 'user' });
|
||||
|
||||
const callBody = vi.mocked(client.post).mock.calls[0]![1] as Record<string, unknown>;
|
||||
expect(callBody).toEqual(expect.objectContaining({
|
||||
email: 'alice@test.com',
|
||||
password: 'password123',
|
||||
name: 'Alice',
|
||||
}));
|
||||
expect(callBody).not.toHaveProperty('role');
|
||||
expect(output.join('\n')).toContain('Created user: alice@test.com');
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('updates existing users matched by email', async () => {
|
||||
vi.mocked(client.get).mockImplementation(async (url: string) => {
|
||||
if (url === '/api/v1/users') return [{ id: 'usr-1', email: 'alice@test.com' }];
|
||||
return [];
|
||||
});
|
||||
|
||||
const configPath = join(tmpDir, 'config.yaml');
|
||||
writeFileSync(configPath, `
|
||||
users:
|
||||
- email: alice@test.com
|
||||
password: newpassword
|
||||
name: Alice Updated
|
||||
`);
|
||||
|
||||
const cmd = createApplyCommand({ client, log });
|
||||
await cmd.parseAsync([configPath], { from: 'user' });
|
||||
|
||||
expect(client.put).toHaveBeenCalledWith('/api/v1/users/usr-1', expect.objectContaining({
|
||||
email: 'alice@test.com',
|
||||
name: 'Alice Updated',
|
||||
}));
|
||||
expect(output.join('\n')).toContain('Updated user: alice@test.com');
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('applies groups', async () => {
|
||||
const configPath = join(tmpDir, 'config.yaml');
|
||||
writeFileSync(configPath, `
|
||||
groups:
|
||||
- name: dev-team
|
||||
description: Development team
|
||||
members:
|
||||
- alice@test.com
|
||||
- bob@test.com
|
||||
`);
|
||||
|
||||
const cmd = createApplyCommand({ client, log });
|
||||
await cmd.parseAsync([configPath], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/groups', expect.objectContaining({
|
||||
name: 'dev-team',
|
||||
description: 'Development team',
|
||||
members: ['alice@test.com', 'bob@test.com'],
|
||||
}));
|
||||
expect(output.join('\n')).toContain('Created group: dev-team');
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('updates existing groups', async () => {
|
||||
vi.mocked(client.get).mockImplementation(async (url: string) => {
|
||||
if (url === '/api/v1/groups') return [{ id: 'grp-1', name: 'dev-team' }];
|
||||
return [];
|
||||
});
|
||||
|
||||
const configPath = join(tmpDir, 'config.yaml');
|
||||
writeFileSync(configPath, `
|
||||
groups:
|
||||
- name: dev-team
|
||||
description: Updated devs
|
||||
members:
|
||||
- new@test.com
|
||||
`);
|
||||
|
||||
const cmd = createApplyCommand({ client, log });
|
||||
await cmd.parseAsync([configPath], { from: 'user' });
|
||||
|
||||
expect(client.put).toHaveBeenCalledWith('/api/v1/groups/grp-1', expect.objectContaining({
|
||||
name: 'dev-team',
|
||||
description: 'Updated devs',
|
||||
}));
|
||||
expect(output.join('\n')).toContain('Updated group: dev-team');
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('applies rbacBindings', async () => {
|
||||
const configPath = join(tmpDir, 'config.yaml');
|
||||
writeFileSync(configPath, `
|
||||
rbac:
|
||||
- name: developers
|
||||
subjects:
|
||||
- kind: User
|
||||
name: alice@test.com
|
||||
- kind: Group
|
||||
name: dev-team
|
||||
roleBindings:
|
||||
- role: edit
|
||||
resource: servers
|
||||
- role: view
|
||||
resource: instances
|
||||
`);
|
||||
|
||||
const cmd = createApplyCommand({ client, log });
|
||||
await cmd.parseAsync([configPath], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', expect.objectContaining({
|
||||
name: 'developers',
|
||||
subjects: [
|
||||
{ kind: 'User', name: 'alice@test.com' },
|
||||
{ kind: 'Group', name: 'dev-team' },
|
||||
],
|
||||
roleBindings: [
|
||||
{ role: 'edit', resource: 'servers' },
|
||||
{ role: 'view', resource: 'instances' },
|
||||
],
|
||||
}));
|
||||
expect(output.join('\n')).toContain('Created rbacBinding: developers');
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('updates existing rbacBindings', async () => {
|
||||
vi.mocked(client.get).mockImplementation(async (url: string) => {
|
||||
if (url === '/api/v1/rbac') return [{ id: 'rbac-1', name: 'developers' }];
|
||||
return [];
|
||||
});
|
||||
|
||||
const configPath = join(tmpDir, 'config.yaml');
|
||||
writeFileSync(configPath, `
|
||||
rbacBindings:
|
||||
- name: developers
|
||||
subjects:
|
||||
- kind: User
|
||||
name: new@test.com
|
||||
roleBindings:
|
||||
- role: edit
|
||||
resource: "*"
|
||||
`);
|
||||
|
||||
const cmd = createApplyCommand({ client, log });
|
||||
await cmd.parseAsync([configPath], { from: 'user' });
|
||||
|
||||
expect(client.put).toHaveBeenCalledWith('/api/v1/rbac/rbac-1', expect.objectContaining({
|
||||
name: 'developers',
|
||||
}));
|
||||
expect(output.join('\n')).toContain('Updated rbacBinding: developers');
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('applies projects with servers', async () => {
|
||||
const configPath = join(tmpDir, 'config.yaml');
|
||||
writeFileSync(configPath, `
|
||||
projects:
|
||||
- name: smart-home
|
||||
description: Home automation
|
||||
proxyMode: filtered
|
||||
llmProvider: gemini-cli
|
||||
llmModel: gemini-2.0-flash
|
||||
servers:
|
||||
- my-grafana
|
||||
- my-ha
|
||||
`);
|
||||
|
||||
const cmd = createApplyCommand({ client, log });
|
||||
await cmd.parseAsync([configPath], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
|
||||
name: 'smart-home',
|
||||
proxyMode: 'filtered',
|
||||
llmProvider: 'gemini-cli',
|
||||
llmModel: 'gemini-2.0-flash',
|
||||
servers: ['my-grafana', 'my-ha'],
|
||||
}));
|
||||
expect(output.join('\n')).toContain('Created project: smart-home');
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('dry-run shows all new resource types', async () => {
|
||||
const configPath = join(tmpDir, 'config.yaml');
|
||||
writeFileSync(configPath, `
|
||||
secrets:
|
||||
- name: creds
|
||||
data:
|
||||
TOKEN: abc
|
||||
users:
|
||||
- email: alice@test.com
|
||||
password: password123
|
||||
groups:
|
||||
- name: dev-team
|
||||
members: []
|
||||
projects:
|
||||
- name: my-proj
|
||||
description: A project
|
||||
rbacBindings:
|
||||
- name: admins
|
||||
subjects:
|
||||
- kind: User
|
||||
name: admin@test.com
|
||||
roleBindings:
|
||||
- role: edit
|
||||
resource: "*"
|
||||
`);
|
||||
|
||||
const cmd = createApplyCommand({ client, log });
|
||||
await cmd.parseAsync([configPath, '--dry-run'], { from: 'user' });
|
||||
|
||||
expect(client.post).not.toHaveBeenCalled();
|
||||
const text = output.join('\n');
|
||||
expect(text).toContain('Dry run');
|
||||
expect(text).toContain('1 secret(s)');
|
||||
expect(text).toContain('1 user(s)');
|
||||
expect(text).toContain('1 group(s)');
|
||||
expect(text).toContain('1 project(s)');
|
||||
expect(text).toContain('1 rbacBinding(s)');
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('applies resources in correct order', async () => {
|
||||
const callOrder: string[] = [];
|
||||
vi.mocked(client.post).mockImplementation(async (url: string) => {
|
||||
callOrder.push(url);
|
||||
return { id: 'new-id', name: 'test' };
|
||||
});
|
||||
|
||||
const configPath = join(tmpDir, 'config.yaml');
|
||||
writeFileSync(configPath, `
|
||||
rbacBindings:
|
||||
- name: admins
|
||||
subjects:
|
||||
- kind: User
|
||||
name: admin@test.com
|
||||
roleBindings:
|
||||
- role: edit
|
||||
resource: "*"
|
||||
users:
|
||||
- email: admin@test.com
|
||||
password: password123
|
||||
secrets:
|
||||
- name: creds
|
||||
data:
|
||||
KEY: val
|
||||
groups:
|
||||
- name: dev-team
|
||||
servers:
|
||||
- name: my-server
|
||||
transport: STDIO
|
||||
projects:
|
||||
- name: my-proj
|
||||
`);
|
||||
|
||||
const cmd = createApplyCommand({ client, log });
|
||||
await cmd.parseAsync([configPath], { from: 'user' });
|
||||
|
||||
// Apply order: secrets → servers → users → groups → projects → templates → rbacBindings
|
||||
expect(callOrder[0]).toBe('/api/v1/secrets');
|
||||
expect(callOrder[1]).toBe('/api/v1/servers');
|
||||
expect(callOrder[2]).toBe('/api/v1/users');
|
||||
expect(callOrder[3]).toBe('/api/v1/groups');
|
||||
expect(callOrder[4]).toBe('/api/v1/projects');
|
||||
expect(callOrder[5]).toBe('/api/v1/rbac');
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('applies rbac with operation bindings', async () => {
|
||||
const configPath = join(tmpDir, 'config.yaml');
|
||||
writeFileSync(configPath, `
|
||||
rbac:
|
||||
- name: ops-team
|
||||
subjects:
|
||||
- kind: Group
|
||||
name: ops
|
||||
roleBindings:
|
||||
- role: edit
|
||||
resource: servers
|
||||
- role: run
|
||||
action: backup
|
||||
- role: run
|
||||
action: logs
|
||||
`);
|
||||
|
||||
const cmd = createApplyCommand({ client, log });
|
||||
await cmd.parseAsync([configPath], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', expect.objectContaining({
|
||||
name: 'ops-team',
|
||||
roleBindings: [
|
||||
{ role: 'edit', resource: 'servers' },
|
||||
{ role: 'run', action: 'backup' },
|
||||
{ role: 'run', action: 'logs' },
|
||||
],
|
||||
}));
|
||||
expect(output.join('\n')).toContain('Created rbacBinding: ops-team');
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('applies rbac with name-scoped resource binding', async () => {
|
||||
const configPath = join(tmpDir, 'config.yaml');
|
||||
writeFileSync(configPath, `
|
||||
rbac:
|
||||
- name: ha-viewer
|
||||
subjects:
|
||||
- kind: User
|
||||
name: alice@test.com
|
||||
roleBindings:
|
||||
- role: view
|
||||
resource: servers
|
||||
name: my-ha
|
||||
`);
|
||||
|
||||
const cmd = createApplyCommand({ client, log });
|
||||
await cmd.parseAsync([configPath], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', expect.objectContaining({
|
||||
name: 'ha-viewer',
|
||||
roleBindings: [
|
||||
{ role: 'view', resource: 'servers', name: 'my-ha' },
|
||||
],
|
||||
}));
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,8 @@ describe('login command', () => {
|
||||
user: { email },
|
||||
}),
|
||||
logoutRequest: async () => {},
|
||||
statusRequest: async () => ({ hasUsers: true }),
|
||||
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
|
||||
});
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
expect(output[0]).toContain('Logged in as alice@test.com');
|
||||
@@ -58,6 +60,8 @@ describe('login command', () => {
|
||||
log,
|
||||
loginRequest: async () => { throw new Error('Invalid credentials'); },
|
||||
logoutRequest: async () => {},
|
||||
statusRequest: async () => ({ hasUsers: true }),
|
||||
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
|
||||
});
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
expect(output[0]).toContain('Login failed');
|
||||
@@ -83,6 +87,8 @@ describe('login command', () => {
|
||||
return { token: 'tok', user: { email } };
|
||||
},
|
||||
logoutRequest: async () => {},
|
||||
statusRequest: async () => ({ hasUsers: true }),
|
||||
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
|
||||
});
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
expect(capturedUrl).toBe('http://custom:3100');
|
||||
@@ -103,12 +109,74 @@ describe('login command', () => {
|
||||
return { token: 'tok', user: { email } };
|
||||
},
|
||||
logoutRequest: async () => {},
|
||||
statusRequest: async () => ({ hasUsers: true }),
|
||||
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
|
||||
});
|
||||
await cmd.parseAsync(['--mcpd-url', 'http://override:3100'], { from: 'user' });
|
||||
expect(capturedUrl).toBe('http://override:3100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('login bootstrap flow', () => {
|
||||
it('bootstraps first admin when no users exist', async () => {
|
||||
let bootstrapCalled = false;
|
||||
const cmd = createLoginCommand({
|
||||
configDeps: { configDir: tempDir },
|
||||
credentialsDeps: { configDir: tempDir },
|
||||
prompt: {
|
||||
input: async (msg) => {
|
||||
if (msg.includes('Name')) return 'Admin User';
|
||||
return 'admin@test.com';
|
||||
},
|
||||
password: async () => 'admin-pass',
|
||||
},
|
||||
log,
|
||||
loginRequest: async () => ({ token: '', user: { email: '' } }),
|
||||
logoutRequest: async () => {},
|
||||
statusRequest: async () => ({ hasUsers: false }),
|
||||
bootstrapRequest: async (_url, email, _password) => {
|
||||
bootstrapCalled = true;
|
||||
return { token: 'admin-token', user: { email } };
|
||||
},
|
||||
});
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
|
||||
expect(bootstrapCalled).toBe(true);
|
||||
expect(output.join('\n')).toContain('No users configured');
|
||||
expect(output.join('\n')).toContain('admin@test.com');
|
||||
expect(output.join('\n')).toContain('admin');
|
||||
|
||||
const creds = loadCredentials({ configDir: tempDir });
|
||||
expect(creds).not.toBeNull();
|
||||
expect(creds!.token).toBe('admin-token');
|
||||
expect(creds!.user).toBe('admin@test.com');
|
||||
});
|
||||
|
||||
it('falls back to normal login when users exist', async () => {
|
||||
let loginCalled = false;
|
||||
const cmd = createLoginCommand({
|
||||
configDeps: { configDir: tempDir },
|
||||
credentialsDeps: { configDir: tempDir },
|
||||
prompt: {
|
||||
input: async () => 'alice@test.com',
|
||||
password: async () => 'secret',
|
||||
},
|
||||
log,
|
||||
loginRequest: async (_url, email) => {
|
||||
loginCalled = true;
|
||||
return { token: 'session-tok', user: { email } };
|
||||
},
|
||||
logoutRequest: async () => {},
|
||||
statusRequest: async () => ({ hasUsers: true }),
|
||||
bootstrapRequest: async () => { throw new Error('Should not be called'); },
|
||||
});
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
|
||||
expect(loginCalled).toBe(true);
|
||||
expect(output.join('\n')).not.toContain('No users configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout command', () => {
|
||||
it('removes credentials on logout', async () => {
|
||||
saveCredentials({ token: 'tok', mcpdUrl: 'http://x:3100', user: 'alice' }, { configDir: tempDir });
|
||||
@@ -120,6 +188,8 @@ describe('logout command', () => {
|
||||
log,
|
||||
loginRequest: async () => ({ token: '', user: { email: '' } }),
|
||||
logoutRequest: async () => { logoutCalled = true; },
|
||||
statusRequest: async () => ({ hasUsers: true }),
|
||||
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
|
||||
});
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
expect(output[0]).toContain('Logged out successfully');
|
||||
@@ -137,6 +207,8 @@ describe('logout command', () => {
|
||||
log,
|
||||
loginRequest: async () => ({ token: '', user: { email: '' } }),
|
||||
logoutRequest: async () => {},
|
||||
statusRequest: async () => ({ hasUsers: true }),
|
||||
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
|
||||
});
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
expect(output[0]).toContain('Not logged in');
|
||||
|
||||
@@ -1,158 +1,192 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createClaudeCommand } from '../../src/commands/claude.js';
|
||||
import { createConfigCommand } from '../../src/commands/config.js';
|
||||
import type { ApiClient } from '../../src/api-client.js';
|
||||
import { saveCredentials, loadCredentials } from '../../src/auth/index.js';
|
||||
|
||||
function mockClient(): ApiClient {
|
||||
return {
|
||||
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 () => ({})),
|
||||
get: vi.fn(async () => ({})),
|
||||
post: vi.fn(async () => ({ token: 'impersonated-tok', user: { email: 'other@test.com' } })),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ApiClient;
|
||||
}
|
||||
|
||||
describe('claude command', () => {
|
||||
describe('config claude', () => {
|
||||
let client: ReturnType<typeof mockClient>;
|
||||
let output: string[];
|
||||
let tmpDir: string;
|
||||
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
|
||||
const log = (...args: string[]) => output.push(args.join(' '));
|
||||
|
||||
beforeEach(() => {
|
||||
client = mockClient();
|
||||
output = [];
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-claude-'));
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-claude-'));
|
||||
});
|
||||
|
||||
describe('generate', () => {
|
||||
it('generates .mcp.json from project config', async () => {
|
||||
const outPath = join(tmpDir, '.mcp.json');
|
||||
const cmd = createClaudeCommand({ client, log });
|
||||
await cmd.parseAsync(['generate', 'proj-1', '-o', outPath], { from: 'user' });
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/proj-1/mcp-config');
|
||||
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
||||
expect(written.mcpServers['slack--default']).toBeDefined();
|
||||
expect(output.join('\n')).toContain('2 server(s)');
|
||||
it('generates .mcp.json with mcpctl mcp bridge entry', async () => {
|
||||
const outPath = join(tmpDir, '.mcp.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['claude', '--project', 'homeautomation', '-o', outPath], { from: 'user' });
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
// No API call should be made
|
||||
expect(client.get).not.toHaveBeenCalled();
|
||||
|
||||
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
||||
expect(written.mcpServers['homeautomation']).toEqual({
|
||||
command: 'mcpctl',
|
||||
args: ['mcp', '-p', 'homeautomation'],
|
||||
});
|
||||
expect(output.join('\n')).toContain('1 server(s)');
|
||||
});
|
||||
|
||||
it('prints to stdout with --stdout', async () => {
|
||||
const cmd = createClaudeCommand({ client, log });
|
||||
await cmd.parseAsync(['generate', 'proj-1', '--stdout'], { from: 'user' });
|
||||
it('prints to stdout with --stdout', async () => {
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['claude', '--project', 'myproj', '--stdout'], { from: 'user' });
|
||||
|
||||
expect(output[0]).toContain('mcpServers');
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('merges with existing .mcp.json', async () => {
|
||||
const outPath = join(tmpDir, '.mcp.json');
|
||||
writeFileSync(outPath, JSON.stringify({
|
||||
mcpServers: { 'existing--server': { command: 'echo', args: [] } },
|
||||
}));
|
||||
|
||||
const cmd = createClaudeCommand({ client, log });
|
||||
await cmd.parseAsync(['generate', 'proj-1', '-o', outPath, '--merge'], { from: 'user' });
|
||||
|
||||
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
||||
expect(written.mcpServers['existing--server']).toBeDefined();
|
||||
expect(written.mcpServers['slack--default']).toBeDefined();
|
||||
expect(output.join('\n')).toContain('3 server(s)');
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
const parsed = JSON.parse(output[0]);
|
||||
expect(parsed.mcpServers['myproj']).toEqual({
|
||||
command: 'mcpctl',
|
||||
args: ['mcp', '-p', 'myproj'],
|
||||
});
|
||||
});
|
||||
|
||||
describe('show', () => {
|
||||
it('shows servers in .mcp.json', () => {
|
||||
const filePath = join(tmpDir, '.mcp.json');
|
||||
writeFileSync(filePath, JSON.stringify({
|
||||
mcpServers: {
|
||||
'slack': { command: 'npx', args: ['-y', '@anthropic/slack-mcp'], env: { TOKEN: 'x' } },
|
||||
},
|
||||
}));
|
||||
it('merges with existing .mcp.json', async () => {
|
||||
const outPath = join(tmpDir, '.mcp.json');
|
||||
writeFileSync(outPath, JSON.stringify({
|
||||
mcpServers: { 'existing--server': { command: 'echo', args: [] } },
|
||||
}));
|
||||
|
||||
const cmd = createClaudeCommand({ client, log });
|
||||
cmd.parseAsync(['show', '-p', filePath], { from: 'user' });
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['claude', '--project', 'proj-1', '-o', outPath, '--merge'], { from: 'user' });
|
||||
|
||||
expect(output.join('\n')).toContain('slack');
|
||||
expect(output.join('\n')).toContain('npx -y @anthropic/slack-mcp');
|
||||
expect(output.join('\n')).toContain('TOKEN');
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
||||
expect(written.mcpServers['existing--server']).toBeDefined();
|
||||
expect(written.mcpServers['proj-1']).toEqual({
|
||||
command: 'mcpctl',
|
||||
args: ['mcp', '-p', 'proj-1'],
|
||||
});
|
||||
expect(output.join('\n')).toContain('2 server(s)');
|
||||
});
|
||||
|
||||
it('handles missing file', () => {
|
||||
const cmd = createClaudeCommand({ client, log });
|
||||
cmd.parseAsync(['show', '-p', join(tmpDir, 'nonexistent.json')], { from: 'user' });
|
||||
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' });
|
||||
|
||||
expect(output.join('\n')).toContain('No .mcp.json found');
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
||||
expect(written.mcpServers['proj-1']).toEqual({
|
||||
command: 'mcpctl',
|
||||
args: ['mcp', '-p', 'proj-1'],
|
||||
});
|
||||
});
|
||||
|
||||
describe('add', () => {
|
||||
it('adds a server entry', () => {
|
||||
const filePath = join(tmpDir, '.mcp.json');
|
||||
const cmd = createClaudeCommand({ client, log });
|
||||
cmd.parseAsync(['add', 'my-server', '-c', 'npx', '-a', '-y', 'my-pkg', '-p', filePath], { from: 'user' });
|
||||
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(filePath, 'utf-8'));
|
||||
expect(written.mcpServers['my-server']).toEqual({
|
||||
command: 'npx',
|
||||
args: ['-y', 'my-pkg'],
|
||||
});
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('adds server with env vars', () => {
|
||||
const filePath = join(tmpDir, '.mcp.json');
|
||||
const cmd = createClaudeCommand({ client, log });
|
||||
cmd.parseAsync(['add', 'my-server', '-c', 'node', '-e', 'KEY=val', 'SECRET=abc', '-p', filePath], { from: 'user' });
|
||||
|
||||
const written = JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||
expect(written.mcpServers['my-server'].env).toEqual({ KEY: 'val', SECRET: 'abc' });
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('removes a server entry', () => {
|
||||
const filePath = join(tmpDir, '.mcp.json');
|
||||
writeFileSync(filePath, JSON.stringify({
|
||||
mcpServers: { 'slack': { command: 'npx', args: [] }, 'github': { command: 'npx', args: [] } },
|
||||
}));
|
||||
|
||||
const cmd = createClaudeCommand({ client, log });
|
||||
cmd.parseAsync(['remove', 'slack', '-p', filePath], { from: 'user' });
|
||||
|
||||
const written = JSON.parse(readFileSync(filePath, 'utf-8'));
|
||||
expect(written.mcpServers['slack']).toBeUndefined();
|
||||
expect(written.mcpServers['github']).toBeDefined();
|
||||
expect(output.join('\n')).toContain("Removed 'slack'");
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports when server not found', () => {
|
||||
const filePath = join(tmpDir, '.mcp.json');
|
||||
writeFileSync(filePath, JSON.stringify({ mcpServers: {} }));
|
||||
|
||||
const cmd = createClaudeCommand({ client, log });
|
||||
cmd.parseAsync(['remove', 'nonexistent', '-p', filePath], { from: 'user' });
|
||||
|
||||
expect(output.join('\n')).toContain('not found');
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
|
||||
expect(Object.keys(written.mcpServers)).toEqual(['my-fancy-project']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config impersonate', () => {
|
||||
let client: ReturnType<typeof mockClient>;
|
||||
let output: string[];
|
||||
let tmpDir: string;
|
||||
const log = (...args: string[]) => output.push(args.join(' '));
|
||||
|
||||
beforeEach(() => {
|
||||
client = mockClient();
|
||||
output = [];
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-impersonate-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('impersonates a user and saves backup', async () => {
|
||||
saveCredentials({ token: 'admin-tok', mcpdUrl: 'http://localhost:3100', user: 'admin@test.com' }, { configDir: tmpDir });
|
||||
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['impersonate', 'other@test.com'], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/auth/impersonate', { email: 'other@test.com' });
|
||||
expect(output.join('\n')).toContain('Impersonating other@test.com');
|
||||
|
||||
const creds = loadCredentials({ configDir: tmpDir });
|
||||
expect(creds!.user).toBe('other@test.com');
|
||||
expect(creds!.token).toBe('impersonated-tok');
|
||||
|
||||
// Backup exists
|
||||
const backup = JSON.parse(readFileSync(join(tmpDir, 'credentials-backup'), 'utf-8'));
|
||||
expect(backup.user).toBe('admin@test.com');
|
||||
});
|
||||
|
||||
it('quits impersonation and restores backup', async () => {
|
||||
// Set up current (impersonated) credentials
|
||||
saveCredentials({ token: 'impersonated-tok', mcpdUrl: 'http://localhost:3100', user: 'other@test.com' }, { configDir: tmpDir });
|
||||
// Set up backup (original) credentials
|
||||
writeFileSync(join(tmpDir, 'credentials-backup'), JSON.stringify({
|
||||
token: 'admin-tok', mcpdUrl: 'http://localhost:3100', user: 'admin@test.com',
|
||||
}));
|
||||
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['impersonate', '--quit'], { from: 'user' });
|
||||
|
||||
expect(output.join('\n')).toContain('Returned to admin@test.com');
|
||||
|
||||
const creds = loadCredentials({ configDir: tmpDir });
|
||||
expect(creds!.user).toBe('admin@test.com');
|
||||
expect(creds!.token).toBe('admin-tok');
|
||||
});
|
||||
|
||||
it('errors when not logged in', async () => {
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['impersonate', 'other@test.com'], { from: 'user' });
|
||||
|
||||
expect(output.join('\n')).toContain('Not logged in');
|
||||
});
|
||||
|
||||
it('errors when quitting with no backup', async () => {
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['impersonate', '--quit'], { from: 'user' });
|
||||
|
||||
expect(output.join('\n')).toContain('No impersonation session to quit');
|
||||
});
|
||||
});
|
||||
|
||||
450
src/cli/tests/commands/create.test.ts
Normal file
450
src/cli/tests/commands/create.test.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createCreateCommand } from '../../src/commands/create.js';
|
||||
import { type ApiClient, ApiError } from '../../src/api-client.js';
|
||||
|
||||
function mockClient(): ApiClient {
|
||||
return {
|
||||
get: vi.fn(async () => []),
|
||||
post: vi.fn(async () => ({ id: 'new-id', name: 'test' })),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ApiClient;
|
||||
}
|
||||
|
||||
describe('create command', () => {
|
||||
let client: ReturnType<typeof mockClient>;
|
||||
let output: string[];
|
||||
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
|
||||
|
||||
beforeEach(() => {
|
||||
client = mockClient();
|
||||
output = [];
|
||||
});
|
||||
|
||||
describe('create server', () => {
|
||||
it('creates a server with minimal flags', async () => {
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync(['server', 'my-server'], { from: 'user' });
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({
|
||||
name: 'my-server',
|
||||
transport: 'STDIO',
|
||||
replicas: 1,
|
||||
}));
|
||||
expect(output.join('\n')).toContain("server 'test' created");
|
||||
});
|
||||
|
||||
it('creates a server with all flags', async () => {
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync([
|
||||
'server', 'ha-mcp',
|
||||
'-d', 'Home Assistant MCP',
|
||||
'--docker-image', 'ghcr.io/ha-mcp:latest',
|
||||
'--transport', 'STREAMABLE_HTTP',
|
||||
'--external-url', 'http://localhost:8086/mcp',
|
||||
'--container-port', '3000',
|
||||
'--replicas', '2',
|
||||
'--command', 'python',
|
||||
'--command', '-c',
|
||||
'--command', 'print("hello")',
|
||||
'--env', 'API_KEY=secretRef:creds:API_KEY',
|
||||
'--env', 'BASE_URL=http://localhost',
|
||||
], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', {
|
||||
name: 'ha-mcp',
|
||||
description: 'Home Assistant MCP',
|
||||
dockerImage: 'ghcr.io/ha-mcp:latest',
|
||||
transport: 'STREAMABLE_HTTP',
|
||||
externalUrl: 'http://localhost:8086/mcp',
|
||||
containerPort: 3000,
|
||||
replicas: 2,
|
||||
command: ['python', '-c', 'print("hello")'],
|
||||
env: [
|
||||
{ name: 'API_KEY', valueFrom: { secretRef: { name: 'creds', key: 'API_KEY' } } },
|
||||
{ name: 'BASE_URL', value: 'http://localhost' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults transport to STDIO', async () => {
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync(['server', 'test'], { from: 'user' });
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({
|
||||
transport: 'STDIO',
|
||||
}));
|
||||
});
|
||||
|
||||
it('strips null values from template when using --from-template', async () => {
|
||||
vi.mocked(client.get).mockResolvedValueOnce([{
|
||||
id: 'tpl-1',
|
||||
name: 'grafana',
|
||||
version: '1.0.0',
|
||||
description: 'Grafana MCP',
|
||||
packageName: '@leval/mcp-grafana',
|
||||
dockerImage: null,
|
||||
transport: 'STDIO',
|
||||
repositoryUrl: 'https://github.com/test',
|
||||
externalUrl: null,
|
||||
command: null,
|
||||
containerPort: null,
|
||||
replicas: 1,
|
||||
env: [{ name: 'TOKEN', required: true, description: 'A token' }],
|
||||
healthCheck: { tool: 'test', arguments: {} },
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
}] as never);
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync([
|
||||
'server', 'my-grafana', '--from-template=grafana',
|
||||
'--env', 'TOKEN=secretRef:creds:TOKEN',
|
||||
], { from: 'user' });
|
||||
const call = vi.mocked(client.post).mock.calls[0]![1] as Record<string, unknown>;
|
||||
// null fields from template should NOT be in the body
|
||||
expect(call).not.toHaveProperty('dockerImage');
|
||||
expect(call).not.toHaveProperty('externalUrl');
|
||||
expect(call).not.toHaveProperty('command');
|
||||
expect(call).not.toHaveProperty('containerPort');
|
||||
// non-null fields should be present
|
||||
expect(call.packageName).toBe('@leval/mcp-grafana');
|
||||
expect(call.healthCheck).toEqual({ tool: 'test', arguments: {} });
|
||||
expect(call.templateName).toBe('grafana');
|
||||
});
|
||||
|
||||
it('throws on 409 without --force', async () => {
|
||||
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Server already exists: my-server"}'));
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await expect(cmd.parseAsync(['server', 'my-server'], { from: 'user' })).rejects.toThrow('API error 409');
|
||||
});
|
||||
|
||||
it('updates existing server on 409 with --force', async () => {
|
||||
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Server already exists"}'));
|
||||
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'srv-1', name: 'my-server' }] as never);
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync(['server', 'my-server', '--force'], { from: 'user' });
|
||||
expect(client.put).toHaveBeenCalledWith('/api/v1/servers/srv-1', expect.objectContaining({
|
||||
transport: 'STDIO',
|
||||
}));
|
||||
expect(output.join('\n')).toContain("server 'my-server' updated");
|
||||
});
|
||||
});
|
||||
|
||||
describe('create secret', () => {
|
||||
it('creates a secret with --data flags', async () => {
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync([
|
||||
'secret', 'ha-creds',
|
||||
'--data', 'TOKEN=abc123',
|
||||
'--data', 'URL=https://ha.local',
|
||||
], { from: 'user' });
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', {
|
||||
name: 'ha-creds',
|
||||
data: { TOKEN: 'abc123', URL: 'https://ha.local' },
|
||||
});
|
||||
expect(output.join('\n')).toContain("secret 'test' created");
|
||||
});
|
||||
|
||||
it('creates a secret with empty data', async () => {
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync(['secret', 'empty-secret'], { from: 'user' });
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', {
|
||||
name: 'empty-secret',
|
||||
data: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on 409 without --force', async () => {
|
||||
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Secret already exists: my-creds"}'));
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await expect(cmd.parseAsync(['secret', 'my-creds', '--data', 'KEY=val'], { from: 'user' })).rejects.toThrow('API error 409');
|
||||
});
|
||||
|
||||
it('updates existing secret on 409 with --force', async () => {
|
||||
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Secret already exists"}'));
|
||||
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'sec-1', name: 'my-creds' }] as never);
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync(['secret', 'my-creds', '--data', 'KEY=val', '--force'], { from: 'user' });
|
||||
expect(client.put).toHaveBeenCalledWith('/api/v1/secrets/sec-1', { data: { KEY: 'val' } });
|
||||
expect(output.join('\n')).toContain("secret 'my-creds' updated");
|
||||
});
|
||||
});
|
||||
|
||||
describe('create project', () => {
|
||||
it('creates a project', async () => {
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync(['project', 'my-project', '-d', 'A test project'], { from: 'user' });
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
|
||||
name: 'my-project',
|
||||
description: 'A test project',
|
||||
proxyMode: 'direct',
|
||||
});
|
||||
expect(output.join('\n')).toContain("project 'test' created");
|
||||
});
|
||||
|
||||
it('creates a project with no description', async () => {
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync(['project', 'minimal'], { from: 'user' });
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
|
||||
name: 'minimal',
|
||||
description: '',
|
||||
proxyMode: 'direct',
|
||||
});
|
||||
});
|
||||
|
||||
it('updates existing project on 409 with --force', async () => {
|
||||
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Project already exists"}'));
|
||||
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'proj-1', name: 'my-proj' }] as never);
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync(['project', 'my-proj', '-d', 'updated', '--force'], { from: 'user' });
|
||||
expect(client.put).toHaveBeenCalledWith('/api/v1/projects/proj-1', { description: 'updated', proxyMode: 'direct' });
|
||||
expect(output.join('\n')).toContain("project 'my-proj' updated");
|
||||
});
|
||||
});
|
||||
|
||||
describe('create user', () => {
|
||||
it('creates a user with password and name', async () => {
|
||||
vi.mocked(client.post).mockResolvedValueOnce({ id: 'usr-1', email: 'alice@test.com' });
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync([
|
||||
'user', 'alice@test.com',
|
||||
'--password', 'secret123',
|
||||
'--name', 'Alice',
|
||||
], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/users', {
|
||||
email: 'alice@test.com',
|
||||
password: 'secret123',
|
||||
name: 'Alice',
|
||||
});
|
||||
expect(output.join('\n')).toContain("user 'alice@test.com' created");
|
||||
});
|
||||
|
||||
it('does not send role field (RBAC is the auth mechanism)', async () => {
|
||||
vi.mocked(client.post).mockResolvedValueOnce({ id: 'usr-1', email: 'admin@test.com' });
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync([
|
||||
'user', 'admin@test.com',
|
||||
'--password', 'pass123',
|
||||
], { from: 'user' });
|
||||
|
||||
const callBody = vi.mocked(client.post).mock.calls[0]![1] as Record<string, unknown>;
|
||||
expect(callBody).not.toHaveProperty('role');
|
||||
});
|
||||
|
||||
it('requires --password', async () => {
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await expect(cmd.parseAsync(['user', 'alice@test.com'], { from: 'user' })).rejects.toThrow('--password is required');
|
||||
});
|
||||
|
||||
it('throws on 409 without --force', async () => {
|
||||
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"User already exists"}'));
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await expect(
|
||||
cmd.parseAsync(['user', 'alice@test.com', '--password', 'pass'], { from: 'user' }),
|
||||
).rejects.toThrow('API error 409');
|
||||
});
|
||||
|
||||
it('updates existing user on 409 with --force', async () => {
|
||||
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"User already exists"}'));
|
||||
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'usr-1', email: 'alice@test.com' }] as never);
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync([
|
||||
'user', 'alice@test.com', '--password', 'newpass', '--name', 'Alice New', '--force',
|
||||
], { from: 'user' });
|
||||
|
||||
expect(client.put).toHaveBeenCalledWith('/api/v1/users/usr-1', {
|
||||
password: 'newpass',
|
||||
name: 'Alice New',
|
||||
});
|
||||
expect(output.join('\n')).toContain("user 'alice@test.com' updated");
|
||||
});
|
||||
});
|
||||
|
||||
describe('create group', () => {
|
||||
it('creates a group with members', async () => {
|
||||
vi.mocked(client.post).mockResolvedValueOnce({ id: 'grp-1', name: 'dev-team' });
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync([
|
||||
'group', 'dev-team',
|
||||
'--description', 'Development team',
|
||||
'--member', 'alice@test.com',
|
||||
'--member', 'bob@test.com',
|
||||
], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/groups', {
|
||||
name: 'dev-team',
|
||||
description: 'Development team',
|
||||
members: ['alice@test.com', 'bob@test.com'],
|
||||
});
|
||||
expect(output.join('\n')).toContain("group 'dev-team' created");
|
||||
});
|
||||
|
||||
it('creates a group with no members', async () => {
|
||||
vi.mocked(client.post).mockResolvedValueOnce({ id: 'grp-1', name: 'empty-group' });
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync(['group', 'empty-group'], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/groups', {
|
||||
name: 'empty-group',
|
||||
members: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on 409 without --force', async () => {
|
||||
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Group already exists"}'));
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await expect(
|
||||
cmd.parseAsync(['group', 'dev-team'], { from: 'user' }),
|
||||
).rejects.toThrow('API error 409');
|
||||
});
|
||||
|
||||
it('updates existing group on 409 with --force', async () => {
|
||||
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Group already exists"}'));
|
||||
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'grp-1', name: 'dev-team' }] as never);
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync([
|
||||
'group', 'dev-team', '--member', 'new@test.com', '--force',
|
||||
], { from: 'user' });
|
||||
|
||||
expect(client.put).toHaveBeenCalledWith('/api/v1/groups/grp-1', {
|
||||
members: ['new@test.com'],
|
||||
});
|
||||
expect(output.join('\n')).toContain("group 'dev-team' updated");
|
||||
});
|
||||
});
|
||||
|
||||
describe('create rbac', () => {
|
||||
it('creates an RBAC definition with subjects and bindings', async () => {
|
||||
vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'developers' });
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync([
|
||||
'rbac', 'developers',
|
||||
'--subject', 'User:alice@test.com',
|
||||
'--subject', 'Group:dev-team',
|
||||
'--binding', 'edit:servers',
|
||||
'--binding', 'view:instances',
|
||||
], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', {
|
||||
name: 'developers',
|
||||
subjects: [
|
||||
{ kind: 'User', name: 'alice@test.com' },
|
||||
{ kind: 'Group', name: 'dev-team' },
|
||||
],
|
||||
roleBindings: [
|
||||
{ role: 'edit', resource: 'servers' },
|
||||
{ role: 'view', resource: 'instances' },
|
||||
],
|
||||
});
|
||||
expect(output.join('\n')).toContain("rbac 'developers' created");
|
||||
});
|
||||
|
||||
it('creates an RBAC definition with wildcard resource', async () => {
|
||||
vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'admins' });
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync([
|
||||
'rbac', 'admins',
|
||||
'--subject', 'User:admin@test.com',
|
||||
'--binding', 'edit:*',
|
||||
], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', {
|
||||
name: 'admins',
|
||||
subjects: [{ kind: 'User', name: 'admin@test.com' }],
|
||||
roleBindings: [{ role: 'edit', resource: '*' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('creates an RBAC definition with empty subjects and bindings', async () => {
|
||||
vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'empty' });
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync(['rbac', 'empty'], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', {
|
||||
name: 'empty',
|
||||
subjects: [],
|
||||
roleBindings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on invalid subject format', async () => {
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await expect(
|
||||
cmd.parseAsync(['rbac', 'bad', '--subject', 'no-colon'], { from: 'user' }),
|
||||
).rejects.toThrow('Invalid subject format');
|
||||
});
|
||||
|
||||
it('throws on invalid binding format', async () => {
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await expect(
|
||||
cmd.parseAsync(['rbac', 'bad', '--binding', 'no-colon'], { from: 'user' }),
|
||||
).rejects.toThrow('Invalid binding format');
|
||||
});
|
||||
|
||||
it('throws on 409 without --force', async () => {
|
||||
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"RBAC already exists"}'));
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await expect(
|
||||
cmd.parseAsync(['rbac', 'developers', '--subject', 'User:a@b.com', '--binding', 'edit:servers'], { from: 'user' }),
|
||||
).rejects.toThrow('API error 409');
|
||||
});
|
||||
|
||||
it('updates existing RBAC on 409 with --force', async () => {
|
||||
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"RBAC already exists"}'));
|
||||
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'rbac-1', name: 'developers' }] as never);
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync([
|
||||
'rbac', 'developers',
|
||||
'--subject', 'User:new@test.com',
|
||||
'--binding', 'edit:*',
|
||||
'--force',
|
||||
], { from: 'user' });
|
||||
|
||||
expect(client.put).toHaveBeenCalledWith('/api/v1/rbac/rbac-1', {
|
||||
subjects: [{ kind: 'User', name: 'new@test.com' }],
|
||||
roleBindings: [{ role: 'edit', resource: '*' }],
|
||||
});
|
||||
expect(output.join('\n')).toContain("rbac 'developers' updated");
|
||||
});
|
||||
|
||||
it('creates an RBAC definition with operation bindings', async () => {
|
||||
vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'ops' });
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync([
|
||||
'rbac', 'ops',
|
||||
'--subject', 'Group:ops-team',
|
||||
'--binding', 'edit:servers',
|
||||
'--operation', 'logs',
|
||||
'--operation', 'backup',
|
||||
], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', {
|
||||
name: 'ops',
|
||||
subjects: [{ kind: 'Group', name: 'ops-team' }],
|
||||
roleBindings: [
|
||||
{ role: 'edit', resource: 'servers' },
|
||||
{ role: 'run', action: 'logs' },
|
||||
{ role: 'run', action: 'backup' },
|
||||
],
|
||||
});
|
||||
expect(output.join('\n')).toContain("rbac 'ops' created");
|
||||
});
|
||||
|
||||
it('creates an RBAC definition with name-scoped binding', async () => {
|
||||
vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'ha-viewer' });
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync([
|
||||
'rbac', 'ha-viewer',
|
||||
'--subject', 'User:alice@test.com',
|
||||
'--binding', 'view:servers:my-ha',
|
||||
], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', {
|
||||
name: 'ha-viewer',
|
||||
subjects: [{ kind: 'User', name: 'alice@test.com' }],
|
||||
roleBindings: [
|
||||
{ role: 'view', resource: 'servers', name: 'my-ha' },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,42 +1,59 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createDescribeCommand } from '../../src/commands/describe.js';
|
||||
import type { DescribeCommandDeps } from '../../src/commands/describe.js';
|
||||
import type { ApiClient } from '../../src/api-client.js';
|
||||
|
||||
function mockClient(): ApiClient {
|
||||
return {
|
||||
get: vi.fn(async () => []),
|
||||
post: vi.fn(async () => ({})),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ApiClient;
|
||||
}
|
||||
|
||||
function makeDeps(item: unknown = {}): DescribeCommandDeps & { output: string[] } {
|
||||
const output: string[] = [];
|
||||
return {
|
||||
output,
|
||||
client: mockClient(),
|
||||
fetchResource: vi.fn(async () => item),
|
||||
log: (...args: string[]) => output.push(args.join(' ')),
|
||||
};
|
||||
}
|
||||
|
||||
describe('describe command', () => {
|
||||
it('shows detailed server info', async () => {
|
||||
it('shows detailed server info with sections', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'srv-1',
|
||||
name: 'slack',
|
||||
transport: 'STDIO',
|
||||
packageName: '@slack/mcp',
|
||||
dockerImage: null,
|
||||
envTemplate: [],
|
||||
env: [],
|
||||
createdAt: '2025-01-01',
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
|
||||
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1');
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('--- Server ---');
|
||||
expect(text).toContain('name: slack');
|
||||
expect(text).toContain('transport: STDIO');
|
||||
expect(text).toContain('dockerImage: -');
|
||||
expect(text).toContain('=== Server: slack ===');
|
||||
expect(text).toContain('Name:');
|
||||
expect(text).toContain('slack');
|
||||
expect(text).toContain('Transport:');
|
||||
expect(text).toContain('STDIO');
|
||||
expect(text).toContain('Package:');
|
||||
expect(text).toContain('@slack/mcp');
|
||||
expect(text).toContain('Metadata:');
|
||||
expect(text).toContain('ID:');
|
||||
});
|
||||
|
||||
it('resolves resource aliases', async () => {
|
||||
const deps = makeDeps({ id: 'p1' });
|
||||
const deps = makeDeps({ id: 's1' });
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'prof', 'p1']);
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('profiles', 'p1');
|
||||
await cmd.parseAsync(['node', 'test', 'sec', 's1']);
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('secrets', 's1');
|
||||
});
|
||||
|
||||
it('outputs JSON format', async () => {
|
||||
@@ -55,31 +72,625 @@ describe('describe command', () => {
|
||||
expect(deps.output[0]).toContain('name: slack');
|
||||
});
|
||||
|
||||
it('formats nested objects', async () => {
|
||||
it('shows project detail', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'srv-1',
|
||||
name: 'slack',
|
||||
metadata: { version: '1.0', nested: { deep: true } },
|
||||
id: 'proj-1',
|
||||
name: 'my-project',
|
||||
description: 'A test project',
|
||||
ownerId: 'user-1',
|
||||
createdAt: '2025-01-01',
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
|
||||
await cmd.parseAsync(['node', 'test', 'project', 'proj-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('metadata:');
|
||||
expect(text).toContain('version: 1.0');
|
||||
expect(text).toContain('=== Project: my-project ===');
|
||||
expect(text).toContain('A test project');
|
||||
expect(text).toContain('user-1');
|
||||
});
|
||||
|
||||
it('formats arrays correctly', async () => {
|
||||
it('shows secret detail with masked values', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'sec-1',
|
||||
name: 'ha-creds',
|
||||
data: { TOKEN: 'abc123', URL: 'https://ha.local' },
|
||||
createdAt: '2025-01-01',
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'secret', 'sec-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== Secret: ha-creds ===');
|
||||
expect(text).toContain('TOKEN');
|
||||
expect(text).toContain('***');
|
||||
expect(text).not.toContain('abc123');
|
||||
expect(text).toContain('use --show-values to reveal');
|
||||
});
|
||||
|
||||
it('shows secret detail with revealed values when --show-values', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'sec-1',
|
||||
name: 'ha-creds',
|
||||
data: { TOKEN: 'abc123' },
|
||||
createdAt: '2025-01-01',
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'secret', 'sec-1', '--show-values']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('abc123');
|
||||
expect(text).not.toContain('***');
|
||||
});
|
||||
|
||||
it('shows instance detail with container info', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'inst-1',
|
||||
serverId: 'srv-1',
|
||||
status: 'RUNNING',
|
||||
containerId: 'abc123',
|
||||
port: 3000,
|
||||
createdAt: '2025-01-01',
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'instance', 'inst-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== Instance: inst-1 ===');
|
||||
expect(text).toContain('RUNNING');
|
||||
expect(text).toContain('abc123');
|
||||
});
|
||||
|
||||
it('resolves server name to instance for describe instance', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'inst-1',
|
||||
serverId: 'srv-1',
|
||||
server: { name: 'my-grafana' },
|
||||
status: 'RUNNING',
|
||||
containerId: 'abc123',
|
||||
port: 3000,
|
||||
});
|
||||
// resolveNameOrId will throw (not a CUID, name won't match instances)
|
||||
vi.mocked(deps.client.get)
|
||||
.mockResolvedValueOnce([] as never) // instances list (no name match)
|
||||
.mockResolvedValueOnce([{ id: 'srv-1', name: 'my-grafana' }] as never) // servers list
|
||||
.mockResolvedValueOnce([{ id: 'inst-1', status: 'RUNNING' }] as never); // instances for server
|
||||
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'instance', 'my-grafana']);
|
||||
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('instances', 'inst-1');
|
||||
});
|
||||
|
||||
it('resolves server name and picks running instance over stopped', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'inst-2',
|
||||
serverId: 'srv-1',
|
||||
server: { name: 'my-ha' },
|
||||
status: 'RUNNING',
|
||||
containerId: 'def456',
|
||||
});
|
||||
vi.mocked(deps.client.get)
|
||||
.mockResolvedValueOnce([] as never) // instances list
|
||||
.mockResolvedValueOnce([{ id: 'srv-1', name: 'my-ha' }] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'inst-1', status: 'ERROR' },
|
||||
{ id: 'inst-2', status: 'RUNNING' },
|
||||
] as never);
|
||||
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'instance', 'my-ha']);
|
||||
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('instances', 'inst-2');
|
||||
});
|
||||
|
||||
it('throws when no instances found for server name', async () => {
|
||||
const deps = makeDeps();
|
||||
vi.mocked(deps.client.get)
|
||||
.mockResolvedValueOnce([] as never) // instances list
|
||||
.mockResolvedValueOnce([{ id: 'srv-1', name: 'my-server' }] as never)
|
||||
.mockResolvedValueOnce([] as never); // no instances
|
||||
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await expect(cmd.parseAsync(['node', 'test', 'instance', 'my-server'])).rejects.toThrow(
|
||||
/No instances found/,
|
||||
);
|
||||
});
|
||||
|
||||
it('shows instance with server name in header', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'inst-1',
|
||||
serverId: 'srv-1',
|
||||
server: { name: 'my-grafana' },
|
||||
status: 'RUNNING',
|
||||
containerId: 'abc123',
|
||||
port: 3000,
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'instance', 'inst-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== Instance: my-grafana ===');
|
||||
});
|
||||
|
||||
it('shows instance health and events', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'inst-1',
|
||||
serverId: 'srv-1',
|
||||
server: { name: 'my-grafana' },
|
||||
status: 'RUNNING',
|
||||
containerId: 'abc123',
|
||||
healthStatus: 'healthy',
|
||||
lastHealthCheck: '2025-01-15T10:30:00Z',
|
||||
events: [
|
||||
{ timestamp: '2025-01-15T10:30:00Z', type: 'Normal', message: 'Health check passed (45ms)' },
|
||||
],
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'instance', 'inst-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('Health:');
|
||||
expect(text).toContain('healthy');
|
||||
expect(text).toContain('Events:');
|
||||
expect(text).toContain('Health check passed');
|
||||
});
|
||||
|
||||
it('shows server healthCheck section', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'srv-1',
|
||||
permissions: ['read', 'write'],
|
||||
envTemplate: [],
|
||||
name: 'my-grafana',
|
||||
transport: 'STDIO',
|
||||
healthCheck: {
|
||||
tool: 'list_datasources',
|
||||
arguments: {},
|
||||
intervalSeconds: 60,
|
||||
timeoutSeconds: 10,
|
||||
failureThreshold: 3,
|
||||
},
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('permissions: read, write');
|
||||
expect(text).toContain('envTemplate: []');
|
||||
expect(text).toContain('Health Check:');
|
||||
expect(text).toContain('list_datasources');
|
||||
expect(text).toContain('60s');
|
||||
expect(text).toContain('Failure Threshold:');
|
||||
});
|
||||
|
||||
it('shows template detail with healthCheck and usage', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'tpl-1',
|
||||
name: 'grafana',
|
||||
transport: 'STDIO',
|
||||
version: '1.0.0',
|
||||
packageName: '@leval/mcp-grafana',
|
||||
env: [
|
||||
{ name: 'GRAFANA_URL', required: true, description: 'Grafana instance URL' },
|
||||
],
|
||||
healthCheck: {
|
||||
tool: 'list_datasources',
|
||||
arguments: {},
|
||||
intervalSeconds: 60,
|
||||
timeoutSeconds: 10,
|
||||
failureThreshold: 3,
|
||||
},
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'template', 'tpl-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== Template: grafana ===');
|
||||
expect(text).toContain('@leval/mcp-grafana');
|
||||
expect(text).toContain('GRAFANA_URL');
|
||||
expect(text).toContain('Health Check:');
|
||||
expect(text).toContain('list_datasources');
|
||||
expect(text).toContain('mcpctl create server my-grafana --from-template=grafana');
|
||||
});
|
||||
|
||||
it('shows user detail (no Role field — RBAC is the auth mechanism)', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'usr-1',
|
||||
email: 'alice@test.com',
|
||||
name: 'Alice Smith',
|
||||
provider: null,
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-15',
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'user', 'usr-1']);
|
||||
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('users', 'usr-1');
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== User: alice@test.com ===');
|
||||
expect(text).toContain('Email:');
|
||||
expect(text).toContain('alice@test.com');
|
||||
expect(text).toContain('Name:');
|
||||
expect(text).toContain('Alice Smith');
|
||||
expect(text).not.toContain('Role:');
|
||||
expect(text).toContain('Provider:');
|
||||
expect(text).toContain('local');
|
||||
expect(text).toContain('ID:');
|
||||
expect(text).toContain('usr-1');
|
||||
});
|
||||
|
||||
it('shows user with no name as dash', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'usr-2',
|
||||
email: 'bob@test.com',
|
||||
name: null,
|
||||
provider: 'oidc',
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'user', 'usr-2']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== User: bob@test.com ===');
|
||||
expect(text).toContain('Name:');
|
||||
expect(text).toContain('-');
|
||||
expect(text).not.toContain('Role:');
|
||||
expect(text).toContain('oidc');
|
||||
});
|
||||
|
||||
it('shows group detail with members', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'grp-1',
|
||||
name: 'dev-team',
|
||||
description: 'Development team',
|
||||
members: [
|
||||
{ user: { email: 'alice@test.com' }, createdAt: '2025-01-01' },
|
||||
{ user: { email: 'bob@test.com' }, createdAt: '2025-01-02' },
|
||||
],
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-15',
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'group', 'grp-1']);
|
||||
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('groups', 'grp-1');
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== Group: dev-team ===');
|
||||
expect(text).toContain('Name:');
|
||||
expect(text).toContain('dev-team');
|
||||
expect(text).toContain('Description:');
|
||||
expect(text).toContain('Development team');
|
||||
expect(text).toContain('Members:');
|
||||
expect(text).toContain('EMAIL');
|
||||
expect(text).toContain('ADDED');
|
||||
expect(text).toContain('alice@test.com');
|
||||
expect(text).toContain('bob@test.com');
|
||||
expect(text).toContain('ID:');
|
||||
expect(text).toContain('grp-1');
|
||||
});
|
||||
|
||||
it('shows group detail with no members', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'grp-2',
|
||||
name: 'empty-group',
|
||||
description: '',
|
||||
members: [],
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'group', 'grp-2']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== Group: empty-group ===');
|
||||
// No Members section when empty
|
||||
expect(text).not.toContain('EMAIL');
|
||||
});
|
||||
|
||||
it('shows RBAC detail with subjects and bindings', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'rbac-1',
|
||||
name: 'developers',
|
||||
subjects: [
|
||||
{ kind: 'User', name: 'alice@test.com' },
|
||||
{ kind: 'Group', name: 'dev-team' },
|
||||
],
|
||||
roleBindings: [
|
||||
{ role: 'edit', resource: 'servers' },
|
||||
{ role: 'view', resource: 'instances' },
|
||||
{ role: 'view', resource: 'projects' },
|
||||
],
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-15',
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1']);
|
||||
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', 'rbac-1');
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== RBAC: developers ===');
|
||||
expect(text).toContain('Name:');
|
||||
expect(text).toContain('developers');
|
||||
// Subjects section
|
||||
expect(text).toContain('Subjects:');
|
||||
expect(text).toContain('KIND');
|
||||
expect(text).toContain('NAME');
|
||||
expect(text).toContain('User');
|
||||
expect(text).toContain('alice@test.com');
|
||||
expect(text).toContain('Group');
|
||||
expect(text).toContain('dev-team');
|
||||
// Role Bindings section
|
||||
expect(text).toContain('Resource Bindings:');
|
||||
expect(text).toContain('ROLE');
|
||||
expect(text).toContain('RESOURCE');
|
||||
expect(text).toContain('edit');
|
||||
expect(text).toContain('servers');
|
||||
expect(text).toContain('view');
|
||||
expect(text).toContain('instances');
|
||||
expect(text).toContain('projects');
|
||||
expect(text).toContain('ID:');
|
||||
expect(text).toContain('rbac-1');
|
||||
});
|
||||
|
||||
it('shows RBAC detail with wildcard resource', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'rbac-2',
|
||||
name: 'admins',
|
||||
subjects: [{ kind: 'User', name: 'admin@test.com' }],
|
||||
roleBindings: [{ role: 'edit', resource: '*' }],
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-2']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== RBAC: admins ===');
|
||||
expect(text).toContain('edit');
|
||||
expect(text).toContain('*');
|
||||
});
|
||||
|
||||
it('shows RBAC detail with empty subjects and bindings', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'rbac-3',
|
||||
name: 'empty-rbac',
|
||||
subjects: [],
|
||||
roleBindings: [],
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-3']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== RBAC: empty-rbac ===');
|
||||
// No Subjects or Role Bindings sections when empty
|
||||
expect(text).not.toContain('KIND');
|
||||
expect(text).not.toContain('ROLE');
|
||||
expect(text).not.toContain('RESOURCE');
|
||||
});
|
||||
|
||||
it('shows RBAC detail with mixed resource and operation bindings', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'rbac-1',
|
||||
name: 'admin-access',
|
||||
subjects: [{ kind: 'Group', name: 'admin' }],
|
||||
roleBindings: [
|
||||
{ role: 'edit', resource: '*' },
|
||||
{ role: 'run', resource: 'projects' },
|
||||
{ role: 'run', action: 'logs' },
|
||||
{ role: 'run', action: 'backup' },
|
||||
],
|
||||
createdAt: '2025-01-01',
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('Resource Bindings:');
|
||||
expect(text).toContain('edit');
|
||||
expect(text).toContain('*');
|
||||
expect(text).toContain('run');
|
||||
expect(text).toContain('projects');
|
||||
expect(text).toContain('Operations:');
|
||||
expect(text).toContain('ACTION');
|
||||
expect(text).toContain('logs');
|
||||
expect(text).toContain('backup');
|
||||
});
|
||||
|
||||
it('shows RBAC detail with name-scoped resource binding', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'rbac-1',
|
||||
name: 'ha-viewer',
|
||||
subjects: [{ kind: 'User', name: 'alice@test.com' }],
|
||||
roleBindings: [
|
||||
{ role: 'view', resource: 'servers', name: 'my-ha' },
|
||||
{ role: 'edit', resource: 'secrets' },
|
||||
],
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('Resource Bindings:');
|
||||
expect(text).toContain('NAME');
|
||||
expect(text).toContain('my-ha');
|
||||
expect(text).toContain('view');
|
||||
expect(text).toContain('servers');
|
||||
});
|
||||
|
||||
it('shows user with direct RBAC permissions', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'usr-1',
|
||||
email: 'alice@test.com',
|
||||
name: 'Alice',
|
||||
provider: null,
|
||||
});
|
||||
vi.mocked(deps.client.get)
|
||||
.mockResolvedValueOnce([] as never) // users list (resolveNameOrId)
|
||||
.mockResolvedValueOnce([ // RBAC defs
|
||||
{
|
||||
name: 'dev-access',
|
||||
subjects: [{ kind: 'User', name: 'alice@test.com' }],
|
||||
roleBindings: [
|
||||
{ role: 'edit', resource: 'servers' },
|
||||
{ role: 'run', action: 'logs' },
|
||||
],
|
||||
},
|
||||
] as never)
|
||||
.mockResolvedValueOnce([] as never); // groups
|
||||
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'user', 'usr-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== User: alice@test.com ===');
|
||||
expect(text).toContain('Access:');
|
||||
expect(text).toContain('Direct (dev-access)');
|
||||
expect(text).toContain('Resources:');
|
||||
expect(text).toContain('edit');
|
||||
expect(text).toContain('servers');
|
||||
expect(text).toContain('Operations:');
|
||||
expect(text).toContain('logs');
|
||||
});
|
||||
|
||||
it('shows user with inherited group permissions', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'usr-1',
|
||||
email: 'bob@test.com',
|
||||
name: 'Bob',
|
||||
provider: null,
|
||||
});
|
||||
vi.mocked(deps.client.get)
|
||||
.mockResolvedValueOnce([] as never) // users list
|
||||
.mockResolvedValueOnce([ // RBAC defs
|
||||
{
|
||||
name: 'team-perms',
|
||||
subjects: [{ kind: 'Group', name: 'dev-team' }],
|
||||
roleBindings: [
|
||||
{ role: 'view', resource: '*' },
|
||||
{ role: 'run', action: 'backup' },
|
||||
],
|
||||
},
|
||||
] as never)
|
||||
.mockResolvedValueOnce([ // groups
|
||||
{ name: 'dev-team', members: [{ user: { email: 'bob@test.com' } }] },
|
||||
] as never);
|
||||
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'user', 'usr-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('Groups:');
|
||||
expect(text).toContain('dev-team');
|
||||
expect(text).toContain('Access:');
|
||||
expect(text).toContain('Inherited (dev-team)');
|
||||
expect(text).toContain('view');
|
||||
expect(text).toContain('*');
|
||||
expect(text).toContain('backup');
|
||||
});
|
||||
|
||||
it('shows user with no permissions', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'usr-1',
|
||||
email: 'nobody@test.com',
|
||||
name: null,
|
||||
provider: null,
|
||||
});
|
||||
vi.mocked(deps.client.get)
|
||||
.mockResolvedValueOnce([] as never)
|
||||
.mockResolvedValueOnce([] as never)
|
||||
.mockResolvedValueOnce([] as never);
|
||||
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'user', 'usr-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('Access: (none)');
|
||||
});
|
||||
|
||||
it('shows group with RBAC permissions', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'grp-1',
|
||||
name: 'admin',
|
||||
description: 'Admin group',
|
||||
members: [{ user: { email: 'alice@test.com' } }],
|
||||
});
|
||||
vi.mocked(deps.client.get)
|
||||
.mockResolvedValueOnce([] as never) // groups list (resolveNameOrId)
|
||||
.mockResolvedValueOnce([ // RBAC defs
|
||||
{
|
||||
name: 'admin-access',
|
||||
subjects: [{ kind: 'Group', name: 'admin' }],
|
||||
roleBindings: [
|
||||
{ role: 'edit', resource: '*' },
|
||||
{ role: 'run', action: 'backup' },
|
||||
{ role: 'run', action: 'restore' },
|
||||
],
|
||||
},
|
||||
] as never);
|
||||
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'group', 'grp-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== Group: admin ===');
|
||||
expect(text).toContain('Access:');
|
||||
expect(text).toContain('Granted (admin-access)');
|
||||
expect(text).toContain('edit');
|
||||
expect(text).toContain('*');
|
||||
expect(text).toContain('backup');
|
||||
expect(text).toContain('restore');
|
||||
});
|
||||
|
||||
it('shows group with name-scoped permissions', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'grp-1',
|
||||
name: 'ha-team',
|
||||
description: 'HA team',
|
||||
members: [],
|
||||
});
|
||||
vi.mocked(deps.client.get)
|
||||
.mockResolvedValueOnce([] as never)
|
||||
.mockResolvedValueOnce([ // RBAC defs
|
||||
{
|
||||
name: 'ha-access',
|
||||
subjects: [{ kind: 'Group', name: 'ha-team' }],
|
||||
roleBindings: [
|
||||
{ role: 'edit', resource: 'servers', name: 'my-ha' },
|
||||
{ role: 'view', resource: 'secrets' },
|
||||
],
|
||||
},
|
||||
] as never);
|
||||
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'group', 'grp-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('Access:');
|
||||
expect(text).toContain('Granted (ha-access)');
|
||||
expect(text).toContain('my-ha');
|
||||
expect(text).toContain('NAME');
|
||||
});
|
||||
|
||||
it('outputs user detail as JSON', async () => {
|
||||
const deps = makeDeps({ id: 'usr-1', email: 'alice@test.com', name: 'Alice', role: 'ADMIN' });
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'user', 'usr-1', '-o', 'json']);
|
||||
|
||||
const parsed = JSON.parse(deps.output[0] ?? '');
|
||||
expect(parsed.email).toBe('alice@test.com');
|
||||
expect(parsed.role).toBe('ADMIN');
|
||||
});
|
||||
|
||||
it('outputs group detail as YAML', async () => {
|
||||
const deps = makeDeps({ id: 'grp-1', name: 'dev-team', description: 'Devs' });
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'group', 'grp-1', '-o', 'yaml']);
|
||||
|
||||
expect(deps.output[0]).toContain('name: dev-team');
|
||||
});
|
||||
|
||||
it('outputs rbac detail as JSON', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'rbac-1',
|
||||
name: 'devs',
|
||||
subjects: [{ kind: 'User', name: 'a@b.com' }],
|
||||
roleBindings: [{ role: 'edit', resource: 'servers' }],
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1', '-o', 'json']);
|
||||
|
||||
const parsed = JSON.parse(deps.output[0] ?? '');
|
||||
expect(parsed.subjects).toHaveLength(1);
|
||||
expect(parsed.roleBindings[0].role).toBe('edit');
|
||||
});
|
||||
});
|
||||
|
||||
153
src/cli/tests/commands/edit.test.ts
Normal file
153
src/cli/tests/commands/edit.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import yaml from 'js-yaml';
|
||||
import { createEditCommand } from '../../src/commands/edit.js';
|
||||
import type { ApiClient } from '../../src/api-client.js';
|
||||
|
||||
function mockClient(): ApiClient {
|
||||
return {
|
||||
get: vi.fn(async () => ({})),
|
||||
post: vi.fn(async () => ({})),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ApiClient;
|
||||
}
|
||||
|
||||
describe('edit command', () => {
|
||||
let client: ReturnType<typeof mockClient>;
|
||||
let output: string[];
|
||||
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
|
||||
|
||||
beforeEach(() => {
|
||||
client = mockClient();
|
||||
output = [];
|
||||
});
|
||||
|
||||
it('fetches server, opens editor, applies changes on save', async () => {
|
||||
// GET /api/v1/servers returns list for resolveNameOrId
|
||||
vi.mocked(client.get).mockImplementation(async (path: string) => {
|
||||
if (path === '/api/v1/servers') {
|
||||
return [{ id: 'srv-1', name: 'ha-mcp' }];
|
||||
}
|
||||
// GET /api/v1/servers/srv-1 returns full server
|
||||
return {
|
||||
id: 'srv-1',
|
||||
name: 'ha-mcp',
|
||||
description: 'Old desc',
|
||||
transport: 'STDIO',
|
||||
replicas: 1,
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
version: 1,
|
||||
};
|
||||
});
|
||||
|
||||
const cmd = createEditCommand({
|
||||
client,
|
||||
log,
|
||||
getEditor: () => 'vi',
|
||||
openEditor: (filePath) => {
|
||||
// Simulate user editing the file
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const modified = content
|
||||
.replace('Old desc', 'New desc')
|
||||
.replace('replicas: 1', 'replicas: 3');
|
||||
writeFileSync(filePath, modified, 'utf-8');
|
||||
},
|
||||
});
|
||||
|
||||
await cmd.parseAsync(['server', 'ha-mcp'], { from: 'user' });
|
||||
|
||||
expect(client.put).toHaveBeenCalledWith('/api/v1/servers/srv-1', expect.objectContaining({
|
||||
description: 'New desc',
|
||||
replicas: 3,
|
||||
}));
|
||||
expect(output.join('\n')).toContain("server 'ha-mcp' updated");
|
||||
});
|
||||
|
||||
it('detects no changes and skips PUT', async () => {
|
||||
vi.mocked(client.get).mockImplementation(async (path: string) => {
|
||||
if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }];
|
||||
return {
|
||||
id: 'srv-1', name: 'test', description: '', transport: 'STDIO',
|
||||
createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1,
|
||||
};
|
||||
});
|
||||
|
||||
const cmd = createEditCommand({
|
||||
client,
|
||||
log,
|
||||
getEditor: () => 'vi',
|
||||
openEditor: () => {
|
||||
// Don't modify the file
|
||||
},
|
||||
});
|
||||
|
||||
await cmd.parseAsync(['server', 'test'], { from: 'user' });
|
||||
|
||||
expect(client.put).not.toHaveBeenCalled();
|
||||
expect(output.join('\n')).toContain("unchanged");
|
||||
});
|
||||
|
||||
it('handles empty file as cancel', async () => {
|
||||
vi.mocked(client.get).mockImplementation(async (path: string) => {
|
||||
if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }];
|
||||
return { id: 'srv-1', name: 'test', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1 };
|
||||
});
|
||||
|
||||
const cmd = createEditCommand({
|
||||
client,
|
||||
log,
|
||||
getEditor: () => 'vi',
|
||||
openEditor: (filePath) => {
|
||||
writeFileSync(filePath, '', 'utf-8');
|
||||
},
|
||||
});
|
||||
|
||||
await cmd.parseAsync(['server', 'test'], { from: 'user' });
|
||||
|
||||
expect(client.put).not.toHaveBeenCalled();
|
||||
expect(output.join('\n')).toContain('cancelled');
|
||||
});
|
||||
|
||||
it('strips read-only fields from editor content', async () => {
|
||||
vi.mocked(client.get).mockImplementation(async (path: string) => {
|
||||
if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }];
|
||||
return {
|
||||
id: 'srv-1', name: 'test', description: '', transport: 'STDIO',
|
||||
createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1,
|
||||
};
|
||||
});
|
||||
|
||||
let editorContent = '';
|
||||
const cmd = createEditCommand({
|
||||
client,
|
||||
log,
|
||||
getEditor: () => 'vi',
|
||||
openEditor: (filePath) => {
|
||||
editorContent = readFileSync(filePath, 'utf-8');
|
||||
},
|
||||
});
|
||||
|
||||
await cmd.parseAsync(['server', 'test'], { from: 'user' });
|
||||
|
||||
// The editor content should NOT contain read-only fields
|
||||
expect(editorContent).not.toContain('id:');
|
||||
expect(editorContent).not.toContain('createdAt');
|
||||
expect(editorContent).not.toContain('updatedAt');
|
||||
expect(editorContent).not.toContain('version');
|
||||
// But should contain editable fields
|
||||
expect(editorContent).toContain('name:');
|
||||
});
|
||||
|
||||
it('rejects edit instance with error message', async () => {
|
||||
const cmd = createEditCommand({ client, log });
|
||||
|
||||
await cmd.parseAsync(['instance', 'inst-1'], { from: 'user' });
|
||||
|
||||
expect(client.get).not.toHaveBeenCalled();
|
||||
expect(client.put).not.toHaveBeenCalled();
|
||||
expect(output.join('\n')).toContain('immutable');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -41,39 +41,41 @@ describe('get command', () => {
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1');
|
||||
});
|
||||
|
||||
it('outputs JSON format', async () => {
|
||||
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]);
|
||||
it('outputs apply-compatible JSON format', async () => {
|
||||
const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1 }]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'json']);
|
||||
|
||||
const parsed = JSON.parse(deps.output[0] ?? '');
|
||||
expect(parsed).toEqual({ id: 'srv-1', name: 'slack' });
|
||||
// Wrapped in resource key, internal fields stripped
|
||||
expect(parsed).toHaveProperty('servers');
|
||||
expect(parsed.servers[0].name).toBe('slack');
|
||||
expect(parsed.servers[0]).not.toHaveProperty('id');
|
||||
expect(parsed.servers[0]).not.toHaveProperty('createdAt');
|
||||
expect(parsed.servers[0]).not.toHaveProperty('updatedAt');
|
||||
expect(parsed.servers[0]).not.toHaveProperty('version');
|
||||
});
|
||||
|
||||
it('outputs YAML format', async () => {
|
||||
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]);
|
||||
it('outputs apply-compatible YAML format', async () => {
|
||||
const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01' }]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'yaml']);
|
||||
expect(deps.output[0]).toContain('name: slack');
|
||||
});
|
||||
|
||||
it('lists profiles with correct columns', async () => {
|
||||
const deps = makeDeps([
|
||||
{ id: 'p1', name: 'default', serverId: 'srv-1' },
|
||||
]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'profiles']);
|
||||
expect(deps.output[0]).toContain('NAME');
|
||||
expect(deps.output[0]).toContain('SERVER ID');
|
||||
const text = deps.output[0];
|
||||
expect(text).toContain('servers:');
|
||||
expect(text).toContain('name: slack');
|
||||
expect(text).not.toContain('id:');
|
||||
expect(text).not.toContain('createdAt:');
|
||||
});
|
||||
|
||||
it('lists instances with correct columns', async () => {
|
||||
const deps = makeDeps([
|
||||
{ id: 'inst-1', serverId: 'srv-1', status: 'RUNNING', containerId: 'abc123def456', port: 3000 },
|
||||
{ id: 'inst-1', serverId: 'srv-1', server: { name: 'my-grafana' }, status: 'RUNNING', containerId: 'abc123def456', port: 3000 },
|
||||
]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'instances']);
|
||||
expect(deps.output[0]).toContain('NAME');
|
||||
expect(deps.output[0]).toContain('STATUS');
|
||||
expect(deps.output.join('\n')).toContain('my-grafana');
|
||||
expect(deps.output.join('\n')).toContain('RUNNING');
|
||||
});
|
||||
|
||||
@@ -81,6 +83,172 @@ describe('get command', () => {
|
||||
const deps = makeDeps([]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'servers']);
|
||||
expect(deps.output[0]).toContain('No results');
|
||||
expect(deps.output[0]).toContain('No servers found');
|
||||
});
|
||||
|
||||
it('lists users with correct columns (no ROLE column)', async () => {
|
||||
const deps = makeDeps([
|
||||
{ id: 'usr-1', email: 'alice@test.com', name: 'Alice', provider: null },
|
||||
{ id: 'usr-2', email: 'bob@test.com', name: null, provider: 'oidc' },
|
||||
]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'users']);
|
||||
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined);
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('EMAIL');
|
||||
expect(text).toContain('NAME');
|
||||
expect(text).not.toContain('ROLE');
|
||||
expect(text).toContain('PROVIDER');
|
||||
expect(text).toContain('alice@test.com');
|
||||
expect(text).toContain('Alice');
|
||||
expect(text).toContain('bob@test.com');
|
||||
expect(text).toContain('oidc');
|
||||
});
|
||||
|
||||
it('resolves user alias', async () => {
|
||||
const deps = makeDeps([]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'user']);
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined);
|
||||
});
|
||||
|
||||
it('lists groups with correct columns', async () => {
|
||||
const deps = makeDeps([
|
||||
{
|
||||
id: 'grp-1',
|
||||
name: 'dev-team',
|
||||
description: 'Developers',
|
||||
members: [{ user: { email: 'alice@test.com' } }, { user: { email: 'bob@test.com' } }],
|
||||
},
|
||||
{ id: 'grp-2', name: 'ops-team', description: 'Operations', members: [] },
|
||||
]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'groups']);
|
||||
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined);
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('NAME');
|
||||
expect(text).toContain('MEMBERS');
|
||||
expect(text).toContain('DESCRIPTION');
|
||||
expect(text).toContain('dev-team');
|
||||
expect(text).toContain('2');
|
||||
expect(text).toContain('ops-team');
|
||||
expect(text).toContain('0');
|
||||
});
|
||||
|
||||
it('resolves group alias', async () => {
|
||||
const deps = makeDeps([]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'group']);
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined);
|
||||
});
|
||||
|
||||
it('lists rbac definitions with correct columns', async () => {
|
||||
const deps = makeDeps([
|
||||
{
|
||||
id: 'rbac-1',
|
||||
name: 'admins',
|
||||
subjects: [{ kind: 'User', name: 'admin@test.com' }],
|
||||
roleBindings: [{ role: 'edit', resource: '*' }],
|
||||
},
|
||||
]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'rbac']);
|
||||
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined);
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('NAME');
|
||||
expect(text).toContain('SUBJECTS');
|
||||
expect(text).toContain('BINDINGS');
|
||||
expect(text).toContain('admins');
|
||||
expect(text).toContain('User:admin@test.com');
|
||||
expect(text).toContain('edit:*');
|
||||
});
|
||||
|
||||
it('resolves rbac-definition alias', async () => {
|
||||
const deps = makeDeps([]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'rbac-definition']);
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined);
|
||||
});
|
||||
|
||||
it('lists projects with new columns', async () => {
|
||||
const deps = makeDeps([{
|
||||
id: 'proj-1',
|
||||
name: 'smart-home',
|
||||
description: 'Home automation',
|
||||
proxyMode: 'filtered',
|
||||
ownerId: 'usr-1',
|
||||
servers: [{ server: { name: 'grafana' } }],
|
||||
}]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'projects']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('MODE');
|
||||
expect(text).toContain('SERVERS');
|
||||
expect(text).toContain('smart-home');
|
||||
expect(text).toContain('filtered');
|
||||
expect(text).toContain('1');
|
||||
});
|
||||
|
||||
it('displays mixed resource and operation bindings', async () => {
|
||||
const deps = makeDeps([
|
||||
{
|
||||
id: 'rbac-1',
|
||||
name: 'admin-access',
|
||||
subjects: [{ kind: 'Group', name: 'admin' }],
|
||||
roleBindings: [
|
||||
{ role: 'edit', resource: '*' },
|
||||
{ role: 'run', action: 'logs' },
|
||||
{ role: 'run', action: 'backup' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'rbac']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('edit:*');
|
||||
expect(text).toContain('run>logs');
|
||||
expect(text).toContain('run>backup');
|
||||
});
|
||||
|
||||
it('displays name-scoped resource bindings', async () => {
|
||||
const deps = makeDeps([
|
||||
{
|
||||
id: 'rbac-1',
|
||||
name: 'ha-viewer',
|
||||
subjects: [{ kind: 'User', name: 'alice@test.com' }],
|
||||
roleBindings: [{ role: 'view', resource: 'servers', name: 'my-ha' }],
|
||||
},
|
||||
]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'rbac']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('view:servers:my-ha');
|
||||
});
|
||||
|
||||
it('shows no results message for empty users list', async () => {
|
||||
const deps = makeDeps([]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'users']);
|
||||
expect(deps.output[0]).toContain('No users found');
|
||||
});
|
||||
|
||||
it('shows no results message for empty groups list', async () => {
|
||||
const deps = makeDeps([]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'groups']);
|
||||
expect(deps.output[0]).toContain('No groups found');
|
||||
});
|
||||
|
||||
it('shows no results message for empty rbac list', async () => {
|
||||
const deps = makeDeps([]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'rbac']);
|
||||
expect(deps.output[0]).toContain('No rbac found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createInstanceCommands } from '../../src/commands/instances.js';
|
||||
import { createDeleteCommand } from '../../src/commands/delete.js';
|
||||
import { createLogsCommand } from '../../src/commands/logs.js';
|
||||
import type { ApiClient } from '../../src/api-client.js';
|
||||
|
||||
function mockClient(): ApiClient {
|
||||
@@ -11,7 +12,7 @@ function mockClient(): ApiClient {
|
||||
} as unknown as ApiClient;
|
||||
}
|
||||
|
||||
describe('instance commands', () => {
|
||||
describe('delete command', () => {
|
||||
let client: ReturnType<typeof mockClient>;
|
||||
let output: string[];
|
||||
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
|
||||
@@ -21,107 +22,127 @@ describe('instance commands', () => {
|
||||
output = [];
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('shows no instances message when empty', async () => {
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['list'], { from: 'user' });
|
||||
expect(output.join('\n')).toContain('No instances found');
|
||||
});
|
||||
|
||||
it('shows instance table', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([
|
||||
{ id: 'inst-1', serverId: 'srv-1', status: 'RUNNING', containerId: 'ctr-abc123def', port: 3000, createdAt: '2025-01-01' },
|
||||
]);
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['list'], { from: 'user' });
|
||||
expect(output.join('\n')).toContain('inst-1');
|
||||
expect(output.join('\n')).toContain('RUNNING');
|
||||
});
|
||||
|
||||
it('filters by server', async () => {
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['list', '-s', 'srv-1'], { from: 'user' });
|
||||
expect(client.get).toHaveBeenCalledWith(expect.stringContaining('serverId=srv-1'));
|
||||
});
|
||||
|
||||
it('outputs json', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([{ id: 'inst-1' }]);
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['list', '-o', 'json'], { from: 'user' });
|
||||
expect(output[0]).toContain('"id"');
|
||||
});
|
||||
it('deletes an instance by ID', async () => {
|
||||
const cmd = createDeleteCommand({ client, log });
|
||||
await cmd.parseAsync(['instance', 'inst-1'], { from: 'user' });
|
||||
expect(client.delete).toHaveBeenCalledWith('/api/v1/instances/inst-1');
|
||||
expect(output.join('\n')).toContain('deleted');
|
||||
});
|
||||
|
||||
describe('start', () => {
|
||||
it('starts an instance', async () => {
|
||||
vi.mocked(client.post).mockResolvedValue({ id: 'inst-new', status: 'RUNNING' });
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['start', 'srv-1'], { from: 'user' });
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/instances', { serverId: 'srv-1' });
|
||||
expect(output.join('\n')).toContain('started');
|
||||
});
|
||||
|
||||
it('passes host port', async () => {
|
||||
vi.mocked(client.post).mockResolvedValue({ id: 'inst-new', status: 'RUNNING' });
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['start', 'srv-1', '-p', '8080'], { from: 'user' });
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/instances', { serverId: 'srv-1', hostPort: 8080 });
|
||||
});
|
||||
it('deletes a server by ID', async () => {
|
||||
const cmd = createDeleteCommand({ client, log });
|
||||
await cmd.parseAsync(['server', 'srv-1'], { from: 'user' });
|
||||
expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-1');
|
||||
expect(output.join('\n')).toContain('deleted');
|
||||
});
|
||||
|
||||
describe('stop', () => {
|
||||
it('stops an instance', async () => {
|
||||
vi.mocked(client.post).mockResolvedValue({ id: 'inst-1', status: 'STOPPED' });
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['stop', 'inst-1'], { from: 'user' });
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/instances/inst-1/stop');
|
||||
expect(output.join('\n')).toContain('stopped');
|
||||
});
|
||||
it('resolves server name to ID', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([
|
||||
{ id: 'srv-abc', name: 'ha-mcp' },
|
||||
]);
|
||||
const cmd = createDeleteCommand({ client, log });
|
||||
await cmd.parseAsync(['server', 'ha-mcp'], { from: 'user' });
|
||||
expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-abc');
|
||||
});
|
||||
|
||||
describe('restart', () => {
|
||||
it('restarts an instance', async () => {
|
||||
vi.mocked(client.post).mockResolvedValue({ id: 'inst-2', status: 'RUNNING' });
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['restart', 'inst-1'], { from: 'user' });
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/instances/inst-1/restart');
|
||||
expect(output.join('\n')).toContain('restarted');
|
||||
});
|
||||
it('deletes a project', async () => {
|
||||
const cmd = createDeleteCommand({ client, log });
|
||||
await cmd.parseAsync(['project', 'proj-1'], { from: 'user' });
|
||||
expect(client.delete).toHaveBeenCalledWith('/api/v1/projects/proj-1');
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('removes an instance', async () => {
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['remove', 'inst-1'], { from: 'user' });
|
||||
expect(client.delete).toHaveBeenCalledWith('/api/v1/instances/inst-1');
|
||||
expect(output.join('\n')).toContain('removed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logs', () => {
|
||||
it('shows logs', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ stdout: 'hello world\n', stderr: '' });
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['logs', 'inst-1'], { from: 'user' });
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
|
||||
expect(output.join('\n')).toContain('hello world');
|
||||
});
|
||||
|
||||
it('passes tail option', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ stdout: '', stderr: '' });
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['logs', 'inst-1', '-t', '50'], { from: 'user' });
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs?tail=50');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inspect', () => {
|
||||
it('shows container info as json', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue({ containerId: 'ctr-abc', state: 'running' });
|
||||
const cmd = createInstanceCommands({ client, log });
|
||||
await cmd.parseAsync(['inspect', 'inst-1'], { from: 'user' });
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/inspect');
|
||||
expect(output[0]).toContain('ctr-abc');
|
||||
});
|
||||
it('accepts resource aliases', async () => {
|
||||
const cmd = createDeleteCommand({ client, log });
|
||||
await cmd.parseAsync(['srv', 'srv-1'], { from: 'user' });
|
||||
expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logs command', () => {
|
||||
let client: ReturnType<typeof mockClient>;
|
||||
let output: string[];
|
||||
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
|
||||
|
||||
beforeEach(() => {
|
||||
client = mockClient();
|
||||
output = [];
|
||||
});
|
||||
|
||||
it('shows logs by instance ID', async () => {
|
||||
vi.mocked(client.get)
|
||||
.mockResolvedValueOnce({ id: 'inst-1', status: 'RUNNING' } as never) // instance lookup
|
||||
.mockResolvedValueOnce({ stdout: 'hello world\n', stderr: '' } as never); // logs
|
||||
const cmd = createLogsCommand({ client, log });
|
||||
await cmd.parseAsync(['inst-1'], { from: 'user' });
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1');
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
|
||||
expect(output.join('\n')).toContain('hello world');
|
||||
});
|
||||
|
||||
it('resolves server name to instance ID', async () => {
|
||||
vi.mocked(client.get)
|
||||
.mockRejectedValueOnce(new Error('not found')) // instance lookup fails
|
||||
.mockResolvedValueOnce([{ id: 'srv-1', name: 'my-grafana' }] as never) // servers list
|
||||
.mockResolvedValueOnce([{ id: 'inst-1', status: 'RUNNING', containerId: 'abc' }] as never) // instances for server
|
||||
.mockResolvedValueOnce({ stdout: 'grafana logs\n', stderr: '' } as never); // logs
|
||||
const cmd = createLogsCommand({ client, log });
|
||||
await cmd.parseAsync(['my-grafana'], { from: 'user' });
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
|
||||
expect(output.join('\n')).toContain('grafana logs');
|
||||
});
|
||||
|
||||
it('picks RUNNING instance over others', async () => {
|
||||
vi.mocked(client.get)
|
||||
.mockRejectedValueOnce(new Error('not found'))
|
||||
.mockResolvedValueOnce([{ id: 'srv-1', name: 'ha-mcp' }] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'inst-err', status: 'ERROR', containerId: null },
|
||||
{ id: 'inst-ok', status: 'RUNNING', containerId: 'abc' },
|
||||
] as never)
|
||||
.mockResolvedValueOnce({ stdout: 'running instance\n', stderr: '' } as never);
|
||||
const cmd = createLogsCommand({ client, log });
|
||||
await cmd.parseAsync(['ha-mcp'], { from: 'user' });
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-ok/logs');
|
||||
});
|
||||
|
||||
it('selects specific replica with --instance', async () => {
|
||||
vi.mocked(client.get)
|
||||
.mockRejectedValueOnce(new Error('not found'))
|
||||
.mockResolvedValueOnce([{ id: 'srv-1', name: 'ha-mcp' }] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 'inst-0', status: 'RUNNING', containerId: 'a' },
|
||||
{ id: 'inst-1', status: 'RUNNING', containerId: 'b' },
|
||||
] as never)
|
||||
.mockResolvedValueOnce({ stdout: 'replica 1\n', stderr: '' } as never);
|
||||
const cmd = createLogsCommand({ client, log });
|
||||
await cmd.parseAsync(['ha-mcp', '-i', '1'], { from: 'user' });
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
|
||||
});
|
||||
|
||||
it('throws on out-of-range --instance index', async () => {
|
||||
vi.mocked(client.get)
|
||||
.mockRejectedValueOnce(new Error('not found'))
|
||||
.mockResolvedValueOnce([{ id: 'srv-1', name: 'ha-mcp' }] as never)
|
||||
.mockResolvedValueOnce([{ id: 'inst-0', status: 'RUNNING' }] as never);
|
||||
const cmd = createLogsCommand({ client, log });
|
||||
await expect(cmd.parseAsync(['ha-mcp', '-i', '5'], { from: 'user' })).rejects.toThrow('out of range');
|
||||
});
|
||||
|
||||
it('throws when server has no instances', async () => {
|
||||
vi.mocked(client.get)
|
||||
.mockRejectedValueOnce(new Error('not found'))
|
||||
.mockResolvedValueOnce([{ id: 'srv-1', name: 'empty-srv' }] as never)
|
||||
.mockResolvedValueOnce([] as never);
|
||||
const cmd = createLogsCommand({ client, log });
|
||||
await expect(cmd.parseAsync(['empty-srv'], { from: 'user' })).rejects.toThrow('No instances found');
|
||||
});
|
||||
|
||||
it('passes tail option', async () => {
|
||||
vi.mocked(client.get)
|
||||
.mockResolvedValueOnce({ id: 'inst-1' } as never)
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as never);
|
||||
const cmd = createLogsCommand({ client, log });
|
||||
await cmd.parseAsync(['inst-1', '-t', '50'], { from: 'user' });
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs?tail=50');
|
||||
});
|
||||
});
|
||||
|
||||
481
src/cli/tests/commands/mcp.test.ts
Normal file
481
src/cli/tests/commands/mcp.test.ts
Normal file
@@ -0,0 +1,481 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import http from 'node:http';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import { runMcpBridge, createMcpCommand } 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' } });
|
||||
}
|
||||
|
||||
// Respond in SSE format for /projects/sse-project/mcp
|
||||
if (req.url?.includes('sse-project')) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
||||
res.end(`event: message\ndata: ${responseBody}\n\n`);
|
||||
} else {
|
||||
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('handles SSE (text/event-stream) responses', 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: 'sse-project', // triggers SSE response from mock server
|
||||
mcplocalUrl: `http://localhost:${mockPort}`,
|
||||
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
|
||||
});
|
||||
|
||||
// Should extract JSON from SSE data: lines
|
||||
const output = stdoutChunks.join('').trim();
|
||||
const parsed = JSON.parse(output);
|
||||
expect(parsed.result.serverInfo.name).toBe('test-server');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMcpCommand', () => {
|
||||
it('accepts --project option directly', () => {
|
||||
const cmd = createMcpCommand({
|
||||
getProject: () => undefined,
|
||||
configLoader: () => ({ mcplocalUrl: 'http://localhost:3200' }),
|
||||
credentialsLoader: () => null,
|
||||
});
|
||||
const opt = cmd.options.find((o) => o.long === '--project');
|
||||
expect(opt).toBeDefined();
|
||||
expect(opt!.short).toBe('-p');
|
||||
});
|
||||
|
||||
it('parses --project from command args', async () => {
|
||||
let capturedProject: string | undefined;
|
||||
const cmd = createMcpCommand({
|
||||
getProject: () => undefined,
|
||||
configLoader: () => ({ mcplocalUrl: `http://localhost:${mockPort}` }),
|
||||
credentialsLoader: () => null,
|
||||
});
|
||||
// Override the action to capture what project was parsed
|
||||
// We test by checking the option parsing works, not by running the full bridge
|
||||
const parsed = cmd.parse(['--project', 'test-proj'], { from: 'user' });
|
||||
capturedProject = parsed.opts().project;
|
||||
expect(capturedProject).toBe('test-proj');
|
||||
});
|
||||
|
||||
it('parses -p shorthand from command args', () => {
|
||||
const cmd = createMcpCommand({
|
||||
getProject: () => undefined,
|
||||
configLoader: () => ({ mcplocalUrl: `http://localhost:${mockPort}` }),
|
||||
credentialsLoader: () => null,
|
||||
});
|
||||
const parsed = cmd.parse(['-p', 'my-project'], { from: 'user' });
|
||||
expect(parsed.opts().project).toBe('my-project');
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,19 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createProjectCommand } from '../../src/commands/project.js';
|
||||
import type { ApiClient } from '../../src/api-client.js';
|
||||
import { createCreateCommand } from '../../src/commands/create.js';
|
||||
import { createGetCommand } from '../../src/commands/get.js';
|
||||
import { createDescribeCommand } from '../../src/commands/describe.js';
|
||||
import { type ApiClient, ApiError } from '../../src/api-client.js';
|
||||
|
||||
function mockClient(): ApiClient {
|
||||
return {
|
||||
get: vi.fn(async () => []),
|
||||
post: vi.fn(async () => ({ id: 'proj-1', name: 'my-project' })),
|
||||
post: vi.fn(async () => ({ id: 'new-id', name: 'test' })),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ApiClient;
|
||||
}
|
||||
|
||||
describe('project command', () => {
|
||||
describe('project with new fields', () => {
|
||||
let client: ReturnType<typeof mockClient>;
|
||||
let output: string[];
|
||||
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
|
||||
@@ -21,91 +23,94 @@ describe('project command', () => {
|
||||
output = [];
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('shows no projects message when empty', async () => {
|
||||
const cmd = createProjectCommand({ client, log });
|
||||
await cmd.parseAsync(['list'], { from: 'user' });
|
||||
expect(output.join('\n')).toContain('No projects found');
|
||||
describe('create project with enhanced options', () => {
|
||||
it('creates project with proxy mode and servers', async () => {
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync([
|
||||
'project', 'smart-home',
|
||||
'-d', 'Smart home project',
|
||||
'--proxy-mode', 'filtered',
|
||||
'--proxy-mode-llm-provider', 'gemini-cli',
|
||||
'--proxy-mode-llm-model', 'gemini-2.0-flash',
|
||||
'--server', 'my-grafana',
|
||||
'--server', 'my-ha',
|
||||
], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
|
||||
name: 'smart-home',
|
||||
description: 'Smart home project',
|
||||
proxyMode: 'filtered',
|
||||
llmProvider: 'gemini-cli',
|
||||
llmModel: 'gemini-2.0-flash',
|
||||
servers: ['my-grafana', 'my-ha'],
|
||||
}));
|
||||
});
|
||||
|
||||
it('shows project table', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([
|
||||
{ id: 'proj-1', name: 'dev', description: 'Dev project', ownerId: 'user-1', createdAt: '2025-01-01' },
|
||||
]);
|
||||
const cmd = createProjectCommand({ client, log });
|
||||
await cmd.parseAsync(['list'], { from: 'user' });
|
||||
expect(output.join('\n')).toContain('proj-1');
|
||||
expect(output.join('\n')).toContain('dev');
|
||||
});
|
||||
it('defaults proxy mode to direct', async () => {
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync(['project', 'basic'], { from: 'user' });
|
||||
|
||||
it('outputs json', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([{ id: 'proj-1', name: 'dev' }]);
|
||||
const cmd = createProjectCommand({ client, log });
|
||||
await cmd.parseAsync(['list', '-o', 'json'], { from: 'user' });
|
||||
expect(output[0]).toContain('"id"');
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
|
||||
proxyMode: 'direct',
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates a project', async () => {
|
||||
const cmd = createProjectCommand({ client, log });
|
||||
await cmd.parseAsync(['create', 'my-project', '-d', 'A test project'], { from: 'user' });
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
|
||||
name: 'my-project',
|
||||
description: 'A test project',
|
||||
});
|
||||
expect(output.join('\n')).toContain("Project 'my-project' created");
|
||||
describe('get projects shows new columns', () => {
|
||||
it('shows MODE and SERVERS columns', async () => {
|
||||
const deps = {
|
||||
output: [] as string[],
|
||||
fetchResource: vi.fn(async () => [{
|
||||
id: 'proj-1',
|
||||
name: 'smart-home',
|
||||
description: 'Test',
|
||||
proxyMode: 'filtered',
|
||||
ownerId: 'user-1',
|
||||
servers: [{ server: { name: 'grafana' } }, { server: { name: 'ha' } }],
|
||||
}]),
|
||||
log: (...args: string[]) => deps.output.push(args.join(' ')),
|
||||
};
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'projects']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('MODE');
|
||||
expect(text).toContain('SERVERS');
|
||||
expect(text).toContain('smart-home');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes a project', async () => {
|
||||
const cmd = createProjectCommand({ client, log });
|
||||
await cmd.parseAsync(['delete', 'proj-1'], { from: 'user' });
|
||||
expect(client.delete).toHaveBeenCalledWith('/api/v1/projects/proj-1');
|
||||
expect(output.join('\n')).toContain('deleted');
|
||||
});
|
||||
});
|
||||
describe('describe project shows full detail', () => {
|
||||
it('shows servers and proxy config', async () => {
|
||||
const deps = {
|
||||
output: [] as string[],
|
||||
client: mockClient(),
|
||||
fetchResource: vi.fn(async () => ({
|
||||
id: 'proj-1',
|
||||
name: 'smart-home',
|
||||
description: 'Smart home',
|
||||
proxyMode: 'filtered',
|
||||
llmProvider: 'gemini-cli',
|
||||
llmModel: 'gemini-2.0-flash',
|
||||
ownerId: 'user-1',
|
||||
servers: [
|
||||
{ server: { name: 'my-grafana' } },
|
||||
{ server: { name: 'my-ha' } },
|
||||
],
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
})),
|
||||
log: (...args: string[]) => deps.output.push(args.join(' ')),
|
||||
};
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'project', 'proj-1']);
|
||||
|
||||
describe('show', () => {
|
||||
it('shows project details', async () => {
|
||||
vi.mocked(client.get).mockImplementation(async (url: string) => {
|
||||
if (url.endsWith('/profiles')) return [];
|
||||
return { id: 'proj-1', name: 'dev', description: 'Dev project', ownerId: 'user-1', createdAt: '2025-01-01' };
|
||||
});
|
||||
const cmd = createProjectCommand({ client, log });
|
||||
await cmd.parseAsync(['show', 'proj-1'], { from: 'user' });
|
||||
expect(output.join('\n')).toContain('Name: dev');
|
||||
expect(output.join('\n')).toContain('ID: proj-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('profiles', () => {
|
||||
it('lists profiles for a project', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([
|
||||
{ id: 'prof-1', name: 'default', serverId: 'srv-1' },
|
||||
]);
|
||||
const cmd = createProjectCommand({ client, log });
|
||||
await cmd.parseAsync(['profiles', 'proj-1'], { from: 'user' });
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/proj-1/profiles');
|
||||
expect(output.join('\n')).toContain('default');
|
||||
});
|
||||
|
||||
it('shows empty message when no profiles', async () => {
|
||||
const cmd = createProjectCommand({ client, log });
|
||||
await cmd.parseAsync(['profiles', 'proj-1'], { from: 'user' });
|
||||
expect(output.join('\n')).toContain('No profiles assigned');
|
||||
});
|
||||
});
|
||||
|
||||
describe('set-profiles', () => {
|
||||
it('sets profiles for a project', async () => {
|
||||
const cmd = createProjectCommand({ client, log });
|
||||
await cmd.parseAsync(['set-profiles', 'proj-1', 'prof-1', 'prof-2'], { from: 'user' });
|
||||
expect(client.put).toHaveBeenCalledWith('/api/v1/projects/proj-1/profiles', {
|
||||
profileIds: ['prof-1', 'prof-2'],
|
||||
});
|
||||
expect(output.join('\n')).toContain('2 profile(s)');
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== Project: smart-home ===');
|
||||
expect(text).toContain('filtered');
|
||||
expect(text).toContain('gemini-cli');
|
||||
expect(text).toContain('my-grafana');
|
||||
expect(text).toContain('my-ha');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createSetupCommand } from '../../src/commands/setup.js';
|
||||
import type { ApiClient } from '../../src/api-client.js';
|
||||
import type { SetupPromptDeps } from '../../src/commands/setup.js';
|
||||
|
||||
function mockClient(): ApiClient {
|
||||
return {
|
||||
get: vi.fn(async () => []),
|
||||
post: vi.fn(async () => ({ id: 'new-id', name: 'test' })),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ApiClient;
|
||||
}
|
||||
|
||||
function mockPrompt(answers: Record<string, string | boolean>): SetupPromptDeps {
|
||||
const answersQueue = { ...answers };
|
||||
return {
|
||||
input: vi.fn(async (message: string) => {
|
||||
for (const [key, val] of Object.entries(answersQueue)) {
|
||||
if (message.toLowerCase().includes(key.toLowerCase()) && typeof val === 'string') {
|
||||
delete answersQueue[key];
|
||||
return val;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}),
|
||||
password: vi.fn(async () => 'secret-value'),
|
||||
select: vi.fn(async () => 'STDIO') as SetupPromptDeps['select'],
|
||||
confirm: vi.fn(async (message: string) => {
|
||||
if (message.includes('profile')) return true;
|
||||
if (message.includes('secret')) return false;
|
||||
if (message.includes('another')) return false;
|
||||
return false;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('setup command', () => {
|
||||
let client: ReturnType<typeof mockClient>;
|
||||
let output: string[];
|
||||
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
|
||||
|
||||
beforeEach(() => {
|
||||
client = mockClient();
|
||||
output = [];
|
||||
});
|
||||
|
||||
it('creates server with prompted values', async () => {
|
||||
const prompt = mockPrompt({
|
||||
'transport': 'STDIO',
|
||||
'npm package': '@anthropic/slack-mcp',
|
||||
'docker image': '',
|
||||
'description': 'Slack server',
|
||||
'profile name': 'default',
|
||||
'environment variable name': '',
|
||||
});
|
||||
|
||||
const cmd = createSetupCommand({ client, prompt, log });
|
||||
await cmd.parseAsync(['slack'], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({
|
||||
name: 'slack',
|
||||
transport: 'STDIO',
|
||||
}));
|
||||
expect(output.join('\n')).toContain("Server 'test' created");
|
||||
});
|
||||
|
||||
it('creates profile with env vars', async () => {
|
||||
vi.mocked(client.post)
|
||||
.mockResolvedValueOnce({ id: 'srv-1', name: 'slack' }) // server create
|
||||
.mockResolvedValueOnce({ id: 'prof-1', name: 'default' }); // profile create
|
||||
|
||||
const prompt = mockPrompt({
|
||||
'transport': 'STDIO',
|
||||
'npm package': '',
|
||||
'docker image': '',
|
||||
'description': '',
|
||||
'profile name': 'default',
|
||||
});
|
||||
// Override confirm to create profile and add one env var
|
||||
let confirmCallCount = 0;
|
||||
vi.mocked(prompt.confirm).mockImplementation(async (msg: string) => {
|
||||
confirmCallCount++;
|
||||
if (msg.includes('profile')) return true;
|
||||
if (msg.includes('secret')) return true;
|
||||
if (msg.includes('another')) return false;
|
||||
return false;
|
||||
});
|
||||
// Override input to provide env var name then empty to stop
|
||||
let inputCallCount = 0;
|
||||
vi.mocked(prompt.input).mockImplementation(async (msg: string) => {
|
||||
inputCallCount++;
|
||||
if (msg.includes('Profile name')) return 'default';
|
||||
if (msg.includes('variable name') && inputCallCount <= 8) return 'API_KEY';
|
||||
if (msg.includes('variable name')) return '';
|
||||
return '';
|
||||
});
|
||||
|
||||
const cmd = createSetupCommand({ client, prompt, log });
|
||||
await cmd.parseAsync(['slack'], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledTimes(2);
|
||||
const profileCall = vi.mocked(client.post).mock.calls[1];
|
||||
expect(profileCall?.[0]).toBe('/api/v1/profiles');
|
||||
expect(profileCall?.[1]).toEqual(expect.objectContaining({
|
||||
name: 'default',
|
||||
serverId: 'srv-1',
|
||||
}));
|
||||
});
|
||||
|
||||
it('exits if server creation fails', async () => {
|
||||
vi.mocked(client.post).mockRejectedValue(new Error('conflict'));
|
||||
|
||||
const prompt = mockPrompt({
|
||||
'npm package': '',
|
||||
'docker image': '',
|
||||
'description': '',
|
||||
});
|
||||
|
||||
const cmd = createSetupCommand({ client, prompt, log });
|
||||
await cmd.parseAsync(['slack'], { from: 'user' });
|
||||
|
||||
expect(output.join('\n')).toContain('Failed to create server');
|
||||
expect(client.post).toHaveBeenCalledTimes(1); // Only server create, no profile
|
||||
});
|
||||
|
||||
it('skips profile creation when declined', async () => {
|
||||
const prompt = mockPrompt({
|
||||
'npm package': '',
|
||||
'docker image': '',
|
||||
'description': '',
|
||||
});
|
||||
vi.mocked(prompt.confirm).mockResolvedValue(false);
|
||||
|
||||
const cmd = createSetupCommand({ client, prompt, log });
|
||||
await cmd.parseAsync(['test-server'], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledTimes(1); // Only server create
|
||||
expect(output.join('\n')).toContain('Setup complete');
|
||||
});
|
||||
});
|
||||
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'/);
|
||||
});
|
||||
});
|
||||
@@ -16,52 +16,49 @@ describe('CLI command registration (e2e)', () => {
|
||||
expect(commandNames).toContain('logout');
|
||||
expect(commandNames).toContain('get');
|
||||
expect(commandNames).toContain('describe');
|
||||
expect(commandNames).toContain('instance');
|
||||
expect(commandNames).toContain('delete');
|
||||
expect(commandNames).toContain('logs');
|
||||
expect(commandNames).toContain('apply');
|
||||
expect(commandNames).toContain('setup');
|
||||
expect(commandNames).toContain('claude');
|
||||
expect(commandNames).toContain('project');
|
||||
expect(commandNames).toContain('create');
|
||||
expect(commandNames).toContain('edit');
|
||||
expect(commandNames).toContain('backup');
|
||||
expect(commandNames).toContain('restore');
|
||||
});
|
||||
|
||||
it('instance command has lifecycle subcommands', () => {
|
||||
it('old project and claude top-level commands are removed', () => {
|
||||
const program = createProgram();
|
||||
const instance = program.commands.find((c) => c.name() === 'instance');
|
||||
expect(instance).toBeDefined();
|
||||
|
||||
const subcommands = instance!.commands.map((c) => c.name());
|
||||
expect(subcommands).toContain('list');
|
||||
expect(subcommands).toContain('start');
|
||||
expect(subcommands).toContain('stop');
|
||||
expect(subcommands).toContain('restart');
|
||||
expect(subcommands).toContain('remove');
|
||||
expect(subcommands).toContain('logs');
|
||||
expect(subcommands).toContain('inspect');
|
||||
const commandNames = program.commands.map((c) => c.name());
|
||||
expect(commandNames).not.toContain('claude');
|
||||
expect(commandNames).not.toContain('project');
|
||||
expect(commandNames).not.toContain('instance');
|
||||
});
|
||||
|
||||
it('claude command has config management subcommands', () => {
|
||||
it('config command has claude-generate and impersonate subcommands', () => {
|
||||
const program = createProgram();
|
||||
const claude = program.commands.find((c) => c.name() === 'claude');
|
||||
expect(claude).toBeDefined();
|
||||
const config = program.commands.find((c) => c.name() === 'config');
|
||||
expect(config).toBeDefined();
|
||||
|
||||
const subcommands = claude!.commands.map((c) => c.name());
|
||||
expect(subcommands).toContain('generate');
|
||||
expect(subcommands).toContain('show');
|
||||
expect(subcommands).toContain('add');
|
||||
expect(subcommands).toContain('remove');
|
||||
const subcommands = config!.commands.map((c) => c.name());
|
||||
expect(subcommands).toContain('claude-generate');
|
||||
expect(subcommands).toContain('impersonate');
|
||||
expect(subcommands).toContain('view');
|
||||
expect(subcommands).toContain('set');
|
||||
expect(subcommands).toContain('path');
|
||||
expect(subcommands).toContain('reset');
|
||||
});
|
||||
|
||||
it('project command has CRUD subcommands', () => {
|
||||
it('create command has user, group, rbac subcommands', () => {
|
||||
const program = createProgram();
|
||||
const project = program.commands.find((c) => c.name() === 'project');
|
||||
expect(project).toBeDefined();
|
||||
const create = program.commands.find((c) => c.name() === 'create');
|
||||
expect(create).toBeDefined();
|
||||
|
||||
const subcommands = project!.commands.map((c) => c.name());
|
||||
expect(subcommands).toContain('list');
|
||||
expect(subcommands).toContain('create');
|
||||
expect(subcommands).toContain('delete');
|
||||
expect(subcommands).toContain('show');
|
||||
expect(subcommands).toContain('profiles');
|
||||
expect(subcommands).toContain('set-profiles');
|
||||
const subcommands = create!.commands.map((c) => c.name());
|
||||
expect(subcommands).toContain('server');
|
||||
expect(subcommands).toContain('secret');
|
||||
expect(subcommands).toContain('project');
|
||||
expect(subcommands).toContain('user');
|
||||
expect(subcommands).toContain('group');
|
||||
expect(subcommands).toContain('rbac');
|
||||
});
|
||||
|
||||
it('displays version', () => {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Transport" AS ENUM ('STDIO', 'SSE', 'STREAMABLE_HTTP');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "InstanceStatus" AS ENUM ('STARTING', 'RUNNING', 'STOPPING', 'STOPPED', 'ERROR');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"role" "Role" NOT NULL DEFAULT 'USER',
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "McpServer" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"packageName" TEXT,
|
||||
"dockerImage" TEXT,
|
||||
"transport" "Transport" NOT NULL DEFAULT 'STDIO',
|
||||
"repositoryUrl" TEXT,
|
||||
"externalUrl" TEXT,
|
||||
"command" JSONB,
|
||||
"containerPort" INTEGER,
|
||||
"envTemplate" JSONB NOT NULL DEFAULT '[]',
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "McpServer_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "McpProfile" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"permissions" JSONB NOT NULL DEFAULT '[]',
|
||||
"envOverrides" JSONB NOT NULL DEFAULT '{}',
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "McpProfile_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Project" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"ownerId" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProjectMcpProfile" (
|
||||
"id" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"profileId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "ProjectMcpProfile_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "McpInstance" (
|
||||
"id" TEXT NOT NULL,
|
||||
"serverId" TEXT NOT NULL,
|
||||
"containerId" TEXT,
|
||||
"status" "InstanceStatus" NOT NULL DEFAULT 'STOPPED',
|
||||
"port" INTEGER,
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
"version" INTEGER NOT NULL DEFAULT 1,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "McpInstance_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"resource" TEXT NOT NULL,
|
||||
"resourceId" TEXT,
|
||||
"details" JSONB NOT NULL DEFAULT '{}',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "User_email_idx" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Session_token_idx" ON "Session"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "McpServer_name_key" ON "McpServer"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "McpServer_name_idx" ON "McpServer"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "McpProfile_serverId_idx" ON "McpProfile"("serverId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "McpProfile_name_serverId_key" ON "McpProfile"("name", "serverId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Project_name_key" ON "Project"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Project_name_idx" ON "Project"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Project_ownerId_idx" ON "Project"("ownerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProjectMcpProfile_projectId_idx" ON "ProjectMcpProfile"("projectId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProjectMcpProfile_profileId_idx" ON "ProjectMcpProfile"("profileId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ProjectMcpProfile_projectId_profileId_key" ON "ProjectMcpProfile"("projectId", "profileId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "McpInstance_serverId_idx" ON "McpInstance"("serverId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "McpInstance_status_idx" ON "McpInstance"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_userId_idx" ON "AuditLog"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_action_idx" ON "AuditLog"("action");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_resource_idx" ON "AuditLog"("resource");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "McpProfile" ADD CONSTRAINT "McpProfile_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProjectMcpProfile" ADD CONSTRAINT "ProjectMcpProfile_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ProjectMcpProfile" ADD CONSTRAINT "ProjectMcpProfile_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "McpProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "McpInstance" ADD CONSTRAINT "McpInstance_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ProjectMember" DROP CONSTRAINT IF EXISTS "ProjectMember_projectId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ProjectMember" DROP CONSTRAINT IF EXISTS "ProjectMember_userId_fkey";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE IF EXISTS "ProjectMember";
|
||||
3
src/db/prisma/migrations/migration_lock.toml
Normal file
3
src/db/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -15,13 +15,16 @@ model User {
|
||||
name String?
|
||||
passwordHash String
|
||||
role Role @default(USER)
|
||||
provider String?
|
||||
externalId String?
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
sessions Session[]
|
||||
auditLogs AuditLog[]
|
||||
projects Project[]
|
||||
sessions Session[]
|
||||
auditLogs AuditLog[]
|
||||
ownedProjects Project[]
|
||||
groupMemberships GroupMember[]
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
@@ -57,13 +60,21 @@ model McpServer {
|
||||
dockerImage String?
|
||||
transport Transport @default(STDIO)
|
||||
repositoryUrl String?
|
||||
envTemplate Json @default("[]")
|
||||
externalUrl String?
|
||||
command Json?
|
||||
containerPort Int?
|
||||
replicas Int @default(1)
|
||||
env Json @default("[]")
|
||||
healthCheck Json?
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
profiles McpProfile[]
|
||||
templateName String?
|
||||
templateVersion String?
|
||||
|
||||
instances McpInstance[]
|
||||
projects ProjectServer[]
|
||||
|
||||
@@index([name])
|
||||
}
|
||||
@@ -74,23 +85,83 @@ enum Transport {
|
||||
STREAMABLE_HTTP
|
||||
}
|
||||
|
||||
// ── MCP Profiles ──
|
||||
// ── MCP Templates ──
|
||||
|
||||
model McpProfile {
|
||||
model McpTemplate {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
version String @default("1.0.0")
|
||||
description String @default("")
|
||||
packageName String?
|
||||
dockerImage String?
|
||||
transport Transport @default(STDIO)
|
||||
repositoryUrl String?
|
||||
externalUrl String?
|
||||
command Json?
|
||||
containerPort Int?
|
||||
replicas Int @default(1)
|
||||
env Json @default("[]")
|
||||
healthCheck Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([name])
|
||||
}
|
||||
|
||||
// ── Secrets ──
|
||||
|
||||
model Secret {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
data Json @default("{}")
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([name])
|
||||
}
|
||||
|
||||
// ── Groups ──
|
||||
|
||||
model Group {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
serverId String
|
||||
permissions Json @default("[]")
|
||||
envOverrides Json @default("{}")
|
||||
name String @unique
|
||||
description String @default("")
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
projects ProjectMcpProfile[]
|
||||
members GroupMember[]
|
||||
|
||||
@@unique([name, serverId])
|
||||
@@index([serverId])
|
||||
@@index([name])
|
||||
}
|
||||
|
||||
model GroupMember {
|
||||
id String @id @default(cuid())
|
||||
groupId String
|
||||
userId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([groupId, userId])
|
||||
@@index([groupId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// ── RBAC Definitions ──
|
||||
|
||||
model RbacDefinition {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
subjects Json @default("[]")
|
||||
roleBindings Json @default("[]")
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([name])
|
||||
}
|
||||
|
||||
// ── Projects ──
|
||||
@@ -99,31 +170,34 @@ model Project {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
description String @default("")
|
||||
prompt String @default("")
|
||||
proxyMode String @default("direct")
|
||||
llmProvider String?
|
||||
llmModel String?
|
||||
ownerId String
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
profiles ProjectMcpProfile[]
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
servers ProjectServer[]
|
||||
prompts Prompt[]
|
||||
promptRequests PromptRequest[]
|
||||
|
||||
@@index([name])
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
// ── Project <-> Profile join table ──
|
||||
|
||||
model ProjectMcpProfile {
|
||||
id String @id @default(cuid())
|
||||
model ProjectServer {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
profileId String
|
||||
serverId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
profile McpProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([projectId, profileId])
|
||||
@@index([projectId])
|
||||
@@index([profileId])
|
||||
@@unique([projectId, serverId])
|
||||
}
|
||||
|
||||
// ── MCP Instances (running containers) ──
|
||||
@@ -134,10 +208,13 @@ model McpInstance {
|
||||
containerId String?
|
||||
status InstanceStatus @default(STOPPED)
|
||||
port Int?
|
||||
metadata Json @default("{}")
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
metadata Json @default("{}")
|
||||
healthStatus String?
|
||||
lastHealthCheck DateTime?
|
||||
events Json @default("[]")
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -153,6 +230,41 @@ enum InstanceStatus {
|
||||
ERROR
|
||||
}
|
||||
|
||||
// ── Prompts (approved content resources) ──
|
||||
|
||||
model Prompt {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
content String @db.Text
|
||||
projectId String?
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([name, projectId])
|
||||
@@index([projectId])
|
||||
}
|
||||
|
||||
// ── Prompt Requests (pending proposals from LLM sessions) ──
|
||||
|
||||
model PromptRequest {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
content String @db.Text
|
||||
projectId String?
|
||||
createdBySession String?
|
||||
createdByUserId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([name, projectId])
|
||||
@@index([projectId])
|
||||
@@index([createdBySession])
|
||||
}
|
||||
|
||||
// ── Audit Logs ──
|
||||
|
||||
model AuditLog {
|
||||
|
||||
@@ -4,9 +4,9 @@ export type {
|
||||
User,
|
||||
Session,
|
||||
McpServer,
|
||||
McpProfile,
|
||||
McpTemplate,
|
||||
Secret,
|
||||
Project,
|
||||
ProjectMcpProfile,
|
||||
McpInstance,
|
||||
AuditLog,
|
||||
Role,
|
||||
@@ -14,5 +14,5 @@ export type {
|
||||
InstanceStatus,
|
||||
} from '@prisma/client';
|
||||
|
||||
export { seedMcpServers, defaultServers } from './seed/index.js';
|
||||
export type { SeedServer } from './seed/index.js';
|
||||
export { seedTemplates } from './seed/index.js';
|
||||
export type { SeedTemplate, TemplateEnvEntry, HealthCheckSpec } from './seed/index.js';
|
||||
|
||||
@@ -1,131 +1,77 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaClient, Prisma } from '@prisma/client';
|
||||
|
||||
export interface SeedServer {
|
||||
export interface TemplateEnvEntry {
|
||||
name: string;
|
||||
description: string;
|
||||
packageName: string;
|
||||
transport: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP';
|
||||
repositoryUrl: string;
|
||||
envTemplate: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
isSecret: boolean;
|
||||
setupUrl?: string;
|
||||
}>;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export const defaultServers: SeedServer[] = [
|
||||
{
|
||||
name: 'slack',
|
||||
description: 'Slack MCP server for reading channels, messages, and user info',
|
||||
packageName: '@anthropic/slack-mcp',
|
||||
transport: 'STDIO',
|
||||
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/slack',
|
||||
envTemplate: [
|
||||
{
|
||||
name: 'SLACK_BOT_TOKEN',
|
||||
description: 'Slack Bot User OAuth Token (xoxb-...)',
|
||||
isSecret: true,
|
||||
setupUrl: 'https://api.slack.com/apps',
|
||||
},
|
||||
{
|
||||
name: 'SLACK_TEAM_ID',
|
||||
description: 'Slack Workspace Team ID',
|
||||
isSecret: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'jira',
|
||||
description: 'Jira MCP server for issues, projects, and boards',
|
||||
packageName: '@anthropic/jira-mcp',
|
||||
transport: 'STDIO',
|
||||
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/jira',
|
||||
envTemplate: [
|
||||
{
|
||||
name: 'JIRA_URL',
|
||||
description: 'Jira instance URL (e.g., https://company.atlassian.net)',
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
name: 'JIRA_EMAIL',
|
||||
description: 'Jira account email',
|
||||
isSecret: false,
|
||||
},
|
||||
{
|
||||
name: 'JIRA_API_TOKEN',
|
||||
description: 'Jira API token',
|
||||
isSecret: true,
|
||||
setupUrl: 'https://id.atlassian.com/manage-profile/security/api-tokens',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'github',
|
||||
description: 'GitHub MCP server for repos, issues, PRs, and code search',
|
||||
packageName: '@anthropic/github-mcp',
|
||||
transport: 'STDIO',
|
||||
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/github',
|
||||
envTemplate: [
|
||||
{
|
||||
name: 'GITHUB_TOKEN',
|
||||
description: 'GitHub Personal Access Token',
|
||||
isSecret: true,
|
||||
setupUrl: 'https://github.com/settings/tokens',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'terraform',
|
||||
description: 'Terraform MCP server for infrastructure documentation and state',
|
||||
packageName: '@anthropic/terraform-mcp',
|
||||
transport: 'STDIO',
|
||||
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/terraform',
|
||||
envTemplate: [],
|
||||
},
|
||||
];
|
||||
export interface HealthCheckSpec {
|
||||
tool: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
intervalSeconds?: number;
|
||||
timeoutSeconds?: number;
|
||||
failureThreshold?: number;
|
||||
}
|
||||
|
||||
export async function seedMcpServers(
|
||||
export interface SeedTemplate {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
packageName?: string;
|
||||
dockerImage?: string;
|
||||
transport: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP';
|
||||
repositoryUrl?: string;
|
||||
externalUrl?: string;
|
||||
command?: string[];
|
||||
containerPort?: number;
|
||||
replicas?: number;
|
||||
env?: TemplateEnvEntry[];
|
||||
healthCheck?: HealthCheckSpec;
|
||||
}
|
||||
|
||||
export async function seedTemplates(
|
||||
prisma: PrismaClient,
|
||||
servers: SeedServer[] = defaultServers,
|
||||
templates: SeedTemplate[],
|
||||
): Promise<number> {
|
||||
let created = 0;
|
||||
let upserted = 0;
|
||||
|
||||
for (const server of servers) {
|
||||
await prisma.mcpServer.upsert({
|
||||
where: { name: server.name },
|
||||
for (const tpl of templates) {
|
||||
await prisma.mcpTemplate.upsert({
|
||||
where: { name: tpl.name },
|
||||
update: {
|
||||
description: server.description,
|
||||
packageName: server.packageName,
|
||||
transport: server.transport,
|
||||
repositoryUrl: server.repositoryUrl,
|
||||
envTemplate: server.envTemplate,
|
||||
version: tpl.version,
|
||||
description: tpl.description,
|
||||
packageName: tpl.packageName ?? null,
|
||||
dockerImage: tpl.dockerImage ?? null,
|
||||
transport: tpl.transport,
|
||||
repositoryUrl: tpl.repositoryUrl ?? null,
|
||||
externalUrl: tpl.externalUrl ?? null,
|
||||
command: (tpl.command ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||
containerPort: tpl.containerPort ?? null,
|
||||
replicas: tpl.replicas ?? 1,
|
||||
env: (tpl.env ?? []) as unknown as Prisma.InputJsonValue,
|
||||
healthCheck: (tpl.healthCheck ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
create: {
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
packageName: server.packageName,
|
||||
transport: server.transport,
|
||||
repositoryUrl: server.repositoryUrl,
|
||||
envTemplate: server.envTemplate,
|
||||
name: tpl.name,
|
||||
version: tpl.version,
|
||||
description: tpl.description,
|
||||
packageName: tpl.packageName ?? null,
|
||||
dockerImage: tpl.dockerImage ?? null,
|
||||
transport: tpl.transport,
|
||||
repositoryUrl: tpl.repositoryUrl ?? null,
|
||||
externalUrl: tpl.externalUrl ?? null,
|
||||
command: (tpl.command ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||
containerPort: tpl.containerPort ?? null,
|
||||
replicas: tpl.replicas ?? 1,
|
||||
env: (tpl.env ?? []) as unknown as Prisma.InputJsonValue,
|
||||
healthCheck: (tpl.healthCheck ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
created++;
|
||||
upserted++;
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const prisma = new PrismaClient();
|
||||
seedMcpServers(prisma)
|
||||
.then((count) => {
|
||||
console.log(`Seeded ${count} MCP servers`);
|
||||
return prisma.$disconnect();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
return prisma.$disconnect().then(() => process.exit(1));
|
||||
});
|
||||
return upserted;
|
||||
}
|
||||
|
||||
@@ -48,11 +48,16 @@ export async function cleanupTestDb(): Promise<void> {
|
||||
export async function clearAllTables(client: PrismaClient): Promise<void> {
|
||||
// Delete in order respecting foreign keys
|
||||
await client.auditLog.deleteMany();
|
||||
await client.projectMcpProfile.deleteMany();
|
||||
await client.mcpInstance.deleteMany();
|
||||
await client.mcpProfile.deleteMany();
|
||||
await client.projectServer.deleteMany();
|
||||
await client.projectMember.deleteMany();
|
||||
await client.secret.deleteMany();
|
||||
await client.session.deleteMany();
|
||||
await client.project.deleteMany();
|
||||
await client.mcpServer.deleteMany();
|
||||
await client.mcpTemplate.deleteMany();
|
||||
await client.groupMember.deleteMany();
|
||||
await client.group.deleteMany();
|
||||
await client.rbacDefinition.deleteMany();
|
||||
await client.user.deleteMany();
|
||||
}
|
||||
|
||||
@@ -23,11 +23,35 @@ async function createUser(overrides: { email?: string; name?: string; role?: 'US
|
||||
data: {
|
||||
email: overrides.email ?? `test-${Date.now()}@example.com`,
|
||||
name: overrides.name ?? 'Test User',
|
||||
passwordHash: '$2b$10$test-hash-placeholder',
|
||||
role: overrides.role ?? 'USER',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createGroup(overrides: { name?: string; description?: string } = {}) {
|
||||
return prisma.group.create({
|
||||
data: {
|
||||
name: overrides.name ?? `group-${Date.now()}`,
|
||||
description: overrides.description ?? 'Test group',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createProject(overrides: { name?: string; ownerId?: string } = {}) {
|
||||
let ownerId = overrides.ownerId;
|
||||
if (!ownerId) {
|
||||
const user = await createUser();
|
||||
ownerId = user.id;
|
||||
}
|
||||
return prisma.project.create({
|
||||
data: {
|
||||
name: overrides.name ?? `project-${Date.now()}`,
|
||||
ownerId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createServer(overrides: { name?: string; transport?: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP' } = {}) {
|
||||
return prisma.mcpServer.create({
|
||||
data: {
|
||||
@@ -123,7 +147,7 @@ describe('McpServer', () => {
|
||||
const server = await createServer();
|
||||
expect(server.transport).toBe('STDIO');
|
||||
expect(server.version).toBe(1);
|
||||
expect(server.envTemplate).toEqual([]);
|
||||
expect(server.env).toEqual([]);
|
||||
});
|
||||
|
||||
it('enforces unique name', async () => {
|
||||
@@ -131,18 +155,18 @@ describe('McpServer', () => {
|
||||
await expect(createServer({ name: 'slack' })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('stores envTemplate as JSON', async () => {
|
||||
it('stores env as JSON', async () => {
|
||||
const server = await prisma.mcpServer.create({
|
||||
data: {
|
||||
name: 'with-env',
|
||||
envTemplate: [
|
||||
{ name: 'API_KEY', description: 'Key', isSecret: true },
|
||||
env: [
|
||||
{ name: 'API_KEY', value: 'test-key' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const envTemplate = server.envTemplate as Array<{ name: string }>;
|
||||
expect(envTemplate).toHaveLength(1);
|
||||
expect(envTemplate[0].name).toBe('API_KEY');
|
||||
const env = server.env as Array<{ name: string }>;
|
||||
expect(env).toHaveLength(1);
|
||||
expect(env[0].name).toBe('API_KEY');
|
||||
});
|
||||
|
||||
it('supports SSE transport', async () => {
|
||||
@@ -151,43 +175,46 @@ describe('McpServer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── McpProfile model ──
|
||||
// ── Secret model ──
|
||||
|
||||
describe('McpProfile', () => {
|
||||
it('creates a profile linked to server', async () => {
|
||||
const server = await createServer();
|
||||
const profile = await prisma.mcpProfile.create({
|
||||
describe('Secret', () => {
|
||||
it('creates a secret with defaults', async () => {
|
||||
const secret = await prisma.secret.create({
|
||||
data: { name: 'my-secret' },
|
||||
});
|
||||
expect(secret.name).toBe('my-secret');
|
||||
expect(secret.data).toEqual({});
|
||||
expect(secret.version).toBe(1);
|
||||
});
|
||||
|
||||
it('stores key-value data as JSON', async () => {
|
||||
const secret = await prisma.secret.create({
|
||||
data: {
|
||||
name: 'readonly',
|
||||
serverId: server.id,
|
||||
permissions: ['read'],
|
||||
name: 'api-keys',
|
||||
data: { API_KEY: 'test-key', API_SECRET: 'test-secret' },
|
||||
},
|
||||
});
|
||||
expect(profile.name).toBe('readonly');
|
||||
expect(profile.serverId).toBe(server.id);
|
||||
const data = secret.data as Record<string, string>;
|
||||
expect(data['API_KEY']).toBe('test-key');
|
||||
expect(data['API_SECRET']).toBe('test-secret');
|
||||
});
|
||||
|
||||
it('enforces unique name per server', async () => {
|
||||
const server = await createServer();
|
||||
const data = { name: 'default', serverId: server.id };
|
||||
await prisma.mcpProfile.create({ data });
|
||||
await expect(prisma.mcpProfile.create({ data })).rejects.toThrow();
|
||||
it('enforces unique name', async () => {
|
||||
await prisma.secret.create({ data: { name: 'dup-secret' } });
|
||||
await expect(prisma.secret.create({ data: { name: 'dup-secret' } })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('allows same profile name on different servers', async () => {
|
||||
const server1 = await createServer({ name: 'server-1' });
|
||||
const server2 = await createServer({ name: 'server-2' });
|
||||
await prisma.mcpProfile.create({ data: { name: 'default', serverId: server1.id } });
|
||||
const profile2 = await prisma.mcpProfile.create({ data: { name: 'default', serverId: server2.id } });
|
||||
expect(profile2.name).toBe('default');
|
||||
});
|
||||
|
||||
it('cascades delete when server is deleted', async () => {
|
||||
const server = await createServer();
|
||||
await prisma.mcpProfile.create({ data: { name: 'test', serverId: server.id } });
|
||||
await prisma.mcpServer.delete({ where: { id: server.id } });
|
||||
const profiles = await prisma.mcpProfile.findMany({ where: { serverId: server.id } });
|
||||
expect(profiles).toHaveLength(0);
|
||||
it('updates data', async () => {
|
||||
const secret = await prisma.secret.create({
|
||||
data: { name: 'updatable', data: { KEY: 'old' } },
|
||||
});
|
||||
const updated = await prisma.secret.update({
|
||||
where: { id: secret.id },
|
||||
data: { data: { KEY: 'new', EXTRA: 'added' } },
|
||||
});
|
||||
const data = updated.data as Record<string, string>;
|
||||
expect(data['KEY']).toBe('new');
|
||||
expect(data['EXTRA']).toBe('added');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -220,62 +247,6 @@ describe('Project', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── ProjectMcpProfile (join table) ──
|
||||
|
||||
describe('ProjectMcpProfile', () => {
|
||||
it('links project to profile', async () => {
|
||||
const user = await createUser();
|
||||
const server = await createServer();
|
||||
const profile = await prisma.mcpProfile.create({
|
||||
data: { name: 'default', serverId: server.id },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: { name: 'test-project', ownerId: user.id },
|
||||
});
|
||||
|
||||
const link = await prisma.projectMcpProfile.create({
|
||||
data: { projectId: project.id, profileId: profile.id },
|
||||
});
|
||||
expect(link.projectId).toBe(project.id);
|
||||
expect(link.profileId).toBe(profile.id);
|
||||
});
|
||||
|
||||
it('enforces unique project+profile combination', async () => {
|
||||
const user = await createUser();
|
||||
const server = await createServer();
|
||||
const profile = await prisma.mcpProfile.create({
|
||||
data: { name: 'default', serverId: server.id },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: { name: 'test-project', ownerId: user.id },
|
||||
});
|
||||
|
||||
const data = { projectId: project.id, profileId: profile.id };
|
||||
await prisma.projectMcpProfile.create({ data });
|
||||
await expect(prisma.projectMcpProfile.create({ data })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('loads profiles through project include', async () => {
|
||||
const user = await createUser();
|
||||
const server = await createServer();
|
||||
const profile = await prisma.mcpProfile.create({
|
||||
data: { name: 'slack-ro', serverId: server.id },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: { name: 'reports', ownerId: user.id },
|
||||
});
|
||||
await prisma.projectMcpProfile.create({
|
||||
data: { projectId: project.id, profileId: profile.id },
|
||||
});
|
||||
|
||||
const loaded = await prisma.project.findUnique({
|
||||
where: { id: project.id },
|
||||
include: { profiles: { include: { profile: true } } },
|
||||
});
|
||||
expect(loaded!.profiles).toHaveLength(1);
|
||||
expect(loaded!.profiles[0].profile.name).toBe('slack-ro');
|
||||
});
|
||||
});
|
||||
|
||||
// ── McpInstance model ──
|
||||
|
||||
@@ -362,3 +333,236 @@ describe('AuditLog', () => {
|
||||
expect(logs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── User SSO fields ──
|
||||
|
||||
describe('User SSO fields', () => {
|
||||
it('stores provider and externalId', async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: 'sso@example.com',
|
||||
passwordHash: 'hash',
|
||||
provider: 'oidc',
|
||||
externalId: 'ext-123',
|
||||
},
|
||||
});
|
||||
expect(user.provider).toBe('oidc');
|
||||
expect(user.externalId).toBe('ext-123');
|
||||
});
|
||||
|
||||
it('defaults provider and externalId to null', async () => {
|
||||
const user = await createUser();
|
||||
expect(user.provider).toBeNull();
|
||||
expect(user.externalId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Group model ──
|
||||
|
||||
describe('Group', () => {
|
||||
it('creates a group with defaults', async () => {
|
||||
const group = await createGroup();
|
||||
expect(group.id).toBeDefined();
|
||||
expect(group.version).toBe(1);
|
||||
});
|
||||
|
||||
it('enforces unique name', async () => {
|
||||
await createGroup({ name: 'devs' });
|
||||
await expect(createGroup({ name: 'devs' })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('creates group members', async () => {
|
||||
const group = await createGroup();
|
||||
const user = await createUser();
|
||||
const member = await prisma.groupMember.create({
|
||||
data: { groupId: group.id, userId: user.id },
|
||||
});
|
||||
expect(member.groupId).toBe(group.id);
|
||||
expect(member.userId).toBe(user.id);
|
||||
});
|
||||
|
||||
it('enforces unique group-user pair', async () => {
|
||||
const group = await createGroup();
|
||||
const user = await createUser();
|
||||
await prisma.groupMember.create({ data: { groupId: group.id, userId: user.id } });
|
||||
await expect(
|
||||
prisma.groupMember.create({ data: { groupId: group.id, userId: user.id } }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('cascades delete when group is deleted', async () => {
|
||||
const group = await createGroup();
|
||||
const user = await createUser();
|
||||
await prisma.groupMember.create({ data: { groupId: group.id, userId: user.id } });
|
||||
await prisma.group.delete({ where: { id: group.id } });
|
||||
const members = await prisma.groupMember.findMany({ where: { groupId: group.id } });
|
||||
expect(members).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── RbacDefinition model ──
|
||||
|
||||
describe('RbacDefinition', () => {
|
||||
it('creates with defaults', async () => {
|
||||
const rbac = await prisma.rbacDefinition.create({
|
||||
data: { name: 'test-rbac' },
|
||||
});
|
||||
expect(rbac.subjects).toEqual([]);
|
||||
expect(rbac.roleBindings).toEqual([]);
|
||||
expect(rbac.version).toBe(1);
|
||||
});
|
||||
|
||||
it('enforces unique name', async () => {
|
||||
await prisma.rbacDefinition.create({ data: { name: 'dup-rbac' } });
|
||||
await expect(prisma.rbacDefinition.create({ data: { name: 'dup-rbac' } })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('stores subjects as JSON', async () => {
|
||||
const rbac = await prisma.rbacDefinition.create({
|
||||
data: {
|
||||
name: 'with-subjects',
|
||||
subjects: [{ kind: 'User', name: 'alice@test.com' }, { kind: 'Group', name: 'devs' }],
|
||||
},
|
||||
});
|
||||
const subjects = rbac.subjects as Array<{ kind: string; name: string }>;
|
||||
expect(subjects).toHaveLength(2);
|
||||
expect(subjects[0].kind).toBe('User');
|
||||
});
|
||||
|
||||
it('stores roleBindings as JSON', async () => {
|
||||
const rbac = await prisma.rbacDefinition.create({
|
||||
data: {
|
||||
name: 'with-bindings',
|
||||
roleBindings: [{ role: 'editor', resource: 'servers' }],
|
||||
},
|
||||
});
|
||||
const bindings = rbac.roleBindings as Array<{ role: string; resource: string }>;
|
||||
expect(bindings).toHaveLength(1);
|
||||
expect(bindings[0].role).toBe('editor');
|
||||
});
|
||||
|
||||
it('updates subjects and roleBindings', async () => {
|
||||
const rbac = await prisma.rbacDefinition.create({ data: { name: 'updatable-rbac' } });
|
||||
const updated = await prisma.rbacDefinition.update({
|
||||
where: { id: rbac.id },
|
||||
data: {
|
||||
subjects: [{ kind: 'User', name: 'bob@test.com' }],
|
||||
roleBindings: [{ role: 'admin', resource: '*' }],
|
||||
},
|
||||
});
|
||||
expect((updated.subjects as unknown[]).length).toBe(1);
|
||||
expect((updated.roleBindings as unknown[]).length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── ProjectServer model ──
|
||||
|
||||
describe('ProjectServer', () => {
|
||||
it('links project to server', async () => {
|
||||
const project = await createProject();
|
||||
const server = await createServer();
|
||||
const ps = await prisma.projectServer.create({
|
||||
data: { projectId: project.id, serverId: server.id },
|
||||
});
|
||||
expect(ps.projectId).toBe(project.id);
|
||||
expect(ps.serverId).toBe(server.id);
|
||||
});
|
||||
|
||||
it('enforces unique project-server pair', async () => {
|
||||
const project = await createProject();
|
||||
const server = await createServer();
|
||||
await prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } });
|
||||
await expect(
|
||||
prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('cascades delete when project is deleted', async () => {
|
||||
const project = await createProject();
|
||||
const server = await createServer();
|
||||
await prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } });
|
||||
await prisma.project.delete({ where: { id: project.id } });
|
||||
const links = await prisma.projectServer.findMany({ where: { projectId: project.id } });
|
||||
expect(links).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('cascades delete when server is deleted', async () => {
|
||||
const project = await createProject();
|
||||
const server = await createServer();
|
||||
await prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } });
|
||||
await prisma.mcpServer.delete({ where: { id: server.id } });
|
||||
const links = await prisma.projectServer.findMany({ where: { serverId: server.id } });
|
||||
expect(links).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── ProjectMember model ──
|
||||
|
||||
describe('ProjectMember', () => {
|
||||
it('links project to user with role', async () => {
|
||||
const user = await createUser();
|
||||
const project = await createProject({ ownerId: user.id });
|
||||
const pm = await prisma.projectMember.create({
|
||||
data: { projectId: project.id, userId: user.id, role: 'admin' },
|
||||
});
|
||||
expect(pm.role).toBe('admin');
|
||||
});
|
||||
|
||||
it('defaults role to member', async () => {
|
||||
const user = await createUser();
|
||||
const project = await createProject({ ownerId: user.id });
|
||||
const pm = await prisma.projectMember.create({
|
||||
data: { projectId: project.id, userId: user.id },
|
||||
});
|
||||
expect(pm.role).toBe('member');
|
||||
});
|
||||
|
||||
it('enforces unique project-user pair', async () => {
|
||||
const user = await createUser();
|
||||
const project = await createProject({ ownerId: user.id });
|
||||
await prisma.projectMember.create({ data: { projectId: project.id, userId: user.id } });
|
||||
await expect(
|
||||
prisma.projectMember.create({ data: { projectId: project.id, userId: user.id } }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('cascades delete when project is deleted', async () => {
|
||||
const user = await createUser();
|
||||
const project = await createProject({ ownerId: user.id });
|
||||
await prisma.projectMember.create({ data: { projectId: project.id, userId: user.id } });
|
||||
await prisma.project.delete({ where: { id: project.id } });
|
||||
const members = await prisma.projectMember.findMany({ where: { projectId: project.id } });
|
||||
expect(members).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Project new fields ──
|
||||
|
||||
describe('Project new fields', () => {
|
||||
it('defaults proxyMode to direct', async () => {
|
||||
const project = await createProject();
|
||||
expect(project.proxyMode).toBe('direct');
|
||||
});
|
||||
|
||||
it('stores proxyMode, llmProvider, llmModel', async () => {
|
||||
const user = await createUser();
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name: 'filtered-project',
|
||||
ownerId: user.id,
|
||||
proxyMode: 'filtered',
|
||||
llmProvider: 'gemini-cli',
|
||||
llmModel: 'gemini-2.0-flash',
|
||||
},
|
||||
});
|
||||
expect(project.proxyMode).toBe('filtered');
|
||||
expect(project.llmProvider).toBe('gemini-cli');
|
||||
expect(project.llmModel).toBe('gemini-2.0-flash');
|
||||
});
|
||||
|
||||
it('defaults llmProvider and llmModel to null', async () => {
|
||||
const project = await createProject();
|
||||
expect(project.llmProvider).toBeNull();
|
||||
expect(project.llmModel).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js';
|
||||
import { seedMcpServers, defaultServers } from '../src/seed/index.js';
|
||||
import { seedTemplates } from '../src/seed/index.js';
|
||||
import type { SeedTemplate } from '../src/seed/index.js';
|
||||
|
||||
let prisma: PrismaClient;
|
||||
|
||||
@@ -17,55 +18,69 @@ beforeEach(async () => {
|
||||
await clearAllTables(prisma);
|
||||
});
|
||||
|
||||
describe('seedMcpServers', () => {
|
||||
it('seeds all default servers', async () => {
|
||||
const count = await seedMcpServers(prisma);
|
||||
expect(count).toBe(defaultServers.length);
|
||||
const testTemplates: SeedTemplate[] = [
|
||||
{
|
||||
name: 'github',
|
||||
version: '1.0.0',
|
||||
description: 'GitHub MCP server',
|
||||
packageName: '@anthropic/github-mcp',
|
||||
transport: 'STDIO',
|
||||
env: [{ name: 'GITHUB_TOKEN', description: 'Personal access token', required: true }],
|
||||
},
|
||||
{
|
||||
name: 'slack',
|
||||
version: '1.0.0',
|
||||
description: 'Slack MCP server',
|
||||
packageName: '@anthropic/slack-mcp',
|
||||
transport: 'STDIO',
|
||||
env: [],
|
||||
},
|
||||
];
|
||||
|
||||
const servers = await prisma.mcpServer.findMany({ orderBy: { name: 'asc' } });
|
||||
expect(servers).toHaveLength(defaultServers.length);
|
||||
describe('seedTemplates', () => {
|
||||
it('seeds templates', async () => {
|
||||
const count = await seedTemplates(prisma, testTemplates);
|
||||
expect(count).toBe(2);
|
||||
|
||||
const names = servers.map((s) => s.name);
|
||||
expect(names).toContain('slack');
|
||||
expect(names).toContain('github');
|
||||
expect(names).toContain('jira');
|
||||
expect(names).toContain('terraform');
|
||||
const templates = await prisma.mcpTemplate.findMany({ orderBy: { name: 'asc' } });
|
||||
expect(templates).toHaveLength(2);
|
||||
expect(templates.map((t) => t.name)).toEqual(['github', 'slack']);
|
||||
});
|
||||
|
||||
it('is idempotent (upsert)', async () => {
|
||||
await seedMcpServers(prisma);
|
||||
const count = await seedMcpServers(prisma);
|
||||
expect(count).toBe(defaultServers.length);
|
||||
await seedTemplates(prisma, testTemplates);
|
||||
const count = await seedTemplates(prisma, testTemplates);
|
||||
expect(count).toBe(2);
|
||||
|
||||
const servers = await prisma.mcpServer.findMany();
|
||||
expect(servers).toHaveLength(defaultServers.length);
|
||||
const templates = await prisma.mcpTemplate.findMany();
|
||||
expect(templates).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('seeds envTemplate correctly', async () => {
|
||||
await seedMcpServers(prisma);
|
||||
const slack = await prisma.mcpServer.findUnique({ where: { name: 'slack' } });
|
||||
const envTemplate = slack!.envTemplate as Array<{ name: string; isSecret: boolean }>;
|
||||
expect(envTemplate).toHaveLength(2);
|
||||
expect(envTemplate[0].name).toBe('SLACK_BOT_TOKEN');
|
||||
expect(envTemplate[0].isSecret).toBe(true);
|
||||
it('seeds env correctly', async () => {
|
||||
await seedTemplates(prisma, testTemplates);
|
||||
const github = await prisma.mcpTemplate.findUnique({ where: { name: 'github' } });
|
||||
const env = github!.env as Array<{ name: string; description?: string; required?: boolean }>;
|
||||
expect(env).toHaveLength(1);
|
||||
expect(env[0].name).toBe('GITHUB_TOKEN');
|
||||
expect(env[0].required).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts custom server list', async () => {
|
||||
const custom = [
|
||||
it('accepts custom template list', async () => {
|
||||
const custom: SeedTemplate[] = [
|
||||
{
|
||||
name: 'custom-server',
|
||||
description: 'Custom test server',
|
||||
name: 'custom-template',
|
||||
version: '2.0.0',
|
||||
description: 'Custom test template',
|
||||
packageName: '@test/custom',
|
||||
transport: 'STDIO' as const,
|
||||
repositoryUrl: 'https://example.com',
|
||||
envTemplate: [],
|
||||
transport: 'STDIO',
|
||||
env: [],
|
||||
},
|
||||
];
|
||||
const count = await seedMcpServers(prisma, custom);
|
||||
const count = await seedTemplates(prisma, custom);
|
||||
expect(count).toBe(1);
|
||||
|
||||
const servers = await prisma.mcpServer.findMany();
|
||||
expect(servers).toHaveLength(1);
|
||||
expect(servers[0].name).toBe('custom-server');
|
||||
const templates = await prisma.mcpTemplate.findMany();
|
||||
expect(templates).toHaveLength(1);
|
||||
expect(templates[0].name).toBe('custom-template');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,11 +23,13 @@
|
||||
"bcrypt": "^5.1.1",
|
||||
"dockerode": "^4.0.9",
|
||||
"fastify": "^5.0.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/dockerode": "^4.0.1",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^25.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { seedMcpServers } from '@mcpctl/db';
|
||||
import yaml from 'js-yaml';
|
||||
import { seedTemplates } from '@mcpctl/db';
|
||||
import type { SeedTemplate } from '@mcpctl/db';
|
||||
import { loadConfigFromEnv } from './config/index.js';
|
||||
import { createServer } from './server.js';
|
||||
import { setupGracefulShutdown } from './utils/index.js';
|
||||
import {
|
||||
McpServerRepository,
|
||||
McpProfileRepository,
|
||||
SecretRepository,
|
||||
McpInstanceRepository,
|
||||
ProjectRepository,
|
||||
AuditLogRepository,
|
||||
TemplateRepository,
|
||||
RbacDefinitionRepository,
|
||||
UserRepository,
|
||||
GroupRepository,
|
||||
} from './repositories/index.js';
|
||||
import { PromptRepository } from './repositories/prompt.repository.js';
|
||||
import { PromptRequestRepository } from './repositories/prompt-request.repository.js';
|
||||
import {
|
||||
McpServerService,
|
||||
McpProfileService,
|
||||
SecretService,
|
||||
InstanceService,
|
||||
ProjectService,
|
||||
AuditLogService,
|
||||
@@ -23,10 +33,19 @@ import {
|
||||
RestoreService,
|
||||
AuthService,
|
||||
McpProxyService,
|
||||
TemplateService,
|
||||
HealthProbeRunner,
|
||||
RbacDefinitionService,
|
||||
RbacService,
|
||||
UserService,
|
||||
GroupService,
|
||||
} from './services/index.js';
|
||||
import type { RbacAction } from './services/index.js';
|
||||
import type { UpdateRbacDefinitionInput } from './validation/rbac-definition.schema.js';
|
||||
import { createAuthMiddleware } from './middleware/auth.js';
|
||||
import {
|
||||
registerMcpServerRoutes,
|
||||
registerMcpProfileRoutes,
|
||||
registerSecretRoutes,
|
||||
registerInstanceRoutes,
|
||||
registerProjectRoutes,
|
||||
registerAuditLogRoutes,
|
||||
@@ -34,7 +53,156 @@ import {
|
||||
registerBackupRoutes,
|
||||
registerAuthRoutes,
|
||||
registerMcpProxyRoutes,
|
||||
registerTemplateRoutes,
|
||||
registerRbacRoutes,
|
||||
registerUserRoutes,
|
||||
registerGroupRoutes,
|
||||
} from './routes/index.js';
|
||||
import { registerPromptRoutes } from './routes/prompts.js';
|
||||
import { PromptService } from './services/prompt.service.js';
|
||||
|
||||
type PermissionCheck =
|
||||
| { kind: 'resource'; resource: string; action: RbacAction; resourceName?: string }
|
||||
| { kind: 'operation'; operation: string }
|
||||
| { kind: 'skip' };
|
||||
|
||||
/**
|
||||
* Map an HTTP method + URL to a permission check.
|
||||
* Returns 'skip' for URLs that should not be RBAC-checked.
|
||||
*/
|
||||
function mapUrlToPermission(method: string, url: string): PermissionCheck {
|
||||
const match = url.match(/^\/api\/v1\/([a-z-]+)/);
|
||||
if (!match) return { kind: 'skip' };
|
||||
|
||||
const segment = match[1] as string;
|
||||
|
||||
// Operations (non-resource endpoints)
|
||||
if (segment === 'backup') return { kind: 'operation', operation: 'backup' };
|
||||
if (segment === 'restore') return { kind: 'operation', operation: 'restore' };
|
||||
if (segment === 'audit-logs' && method === 'DELETE') return { kind: 'operation', operation: 'audit-purge' };
|
||||
|
||||
const resourceMap: Record<string, string | undefined> = {
|
||||
'servers': 'servers',
|
||||
'instances': 'instances',
|
||||
'secrets': 'secrets',
|
||||
'projects': 'projects',
|
||||
'templates': 'templates',
|
||||
'users': 'users',
|
||||
'groups': 'groups',
|
||||
'rbac': 'rbac',
|
||||
'audit-logs': 'rbac',
|
||||
'mcp': 'servers',
|
||||
'prompts': 'prompts',
|
||||
'promptrequests': 'promptrequests',
|
||||
};
|
||||
|
||||
const resource = resourceMap[segment];
|
||||
if (resource === undefined) return { kind: 'skip' };
|
||||
|
||||
// Special case: /api/v1/promptrequests/:id/approve → needs both delete+promptrequests and create+prompts
|
||||
// We check delete on promptrequests (the harder permission); create on prompts is checked in the service layer
|
||||
const approveMatch = url.match(/^\/api\/v1\/promptrequests\/([^/?]+)\/approve/);
|
||||
if (approveMatch?.[1]) {
|
||||
return { kind: 'resource', resource: 'promptrequests', action: 'delete', resourceName: approveMatch[1] };
|
||||
}
|
||||
|
||||
// Special case: /api/v1/projects/:name/prompts/visible → view prompts
|
||||
const visiblePromptsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/prompts\/visible/);
|
||||
if (visiblePromptsMatch?.[1]) {
|
||||
return { kind: 'resource', resource: 'prompts', action: 'view' };
|
||||
}
|
||||
|
||||
// Special case: /api/v1/projects/:name/promptrequests → create promptrequests
|
||||
const projectPromptrequestsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/promptrequests/);
|
||||
if (projectPromptrequestsMatch?.[1] && method === 'POST') {
|
||||
return { kind: 'resource', resource: 'promptrequests', action: 'create' };
|
||||
}
|
||||
|
||||
// Special case: /api/v1/projects/:id/instructions → view projects
|
||||
const instructionsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/instructions/);
|
||||
if (instructionsMatch?.[1]) {
|
||||
return { kind: 'resource', resource: 'projects', action: 'view', resourceName: instructionsMatch[1] };
|
||||
}
|
||||
|
||||
// Special case: /api/v1/projects/:id/mcp-config → requires 'expose' permission
|
||||
const mcpConfigMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/mcp-config/);
|
||||
if (mcpConfigMatch?.[1]) {
|
||||
return { kind: 'resource', resource: 'projects', action: 'expose', resourceName: mcpConfigMatch[1] };
|
||||
}
|
||||
|
||||
// Special case: /api/v1/projects/:id/servers — attach/detach requires 'edit'
|
||||
const projectServersMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/servers/);
|
||||
if (projectServersMatch?.[1] && method !== 'GET') {
|
||||
return { kind: 'resource', resource: 'projects', action: 'edit', resourceName: projectServersMatch[1] };
|
||||
}
|
||||
|
||||
// Map HTTP method to action
|
||||
let action: RbacAction;
|
||||
switch (method) {
|
||||
case 'GET':
|
||||
case 'HEAD':
|
||||
action = 'view';
|
||||
break;
|
||||
case 'POST':
|
||||
action = 'create';
|
||||
break;
|
||||
case 'DELETE':
|
||||
action = 'delete';
|
||||
break;
|
||||
default: // PUT, PATCH
|
||||
action = 'edit';
|
||||
break;
|
||||
}
|
||||
|
||||
// Extract resource name/ID from URL (3rd segment: /api/v1/servers/:nameOrId)
|
||||
const nameMatch = url.match(/^\/api\/v1\/[a-z-]+\/([^/?]+)/);
|
||||
const resourceName = nameMatch?.[1];
|
||||
|
||||
const check: PermissionCheck = { kind: 'resource', resource, action };
|
||||
if (resourceName !== undefined) (check as { resourceName: string }).resourceName = resourceName;
|
||||
return check;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate legacy 'admin' role bindings → granular roles.
|
||||
* Old format: { role: 'admin', resource: '*' }
|
||||
* New format: { role: 'edit', resource: '*' }, { role: 'run', resource: '*' },
|
||||
* plus operation bindings for impersonate, logs, backup, restore, audit-purge
|
||||
*/
|
||||
async function migrateAdminRole(rbacRepo: InstanceType<typeof RbacDefinitionRepository>): Promise<void> {
|
||||
const definitions = await rbacRepo.findAll();
|
||||
for (const def of definitions) {
|
||||
const bindings = def.roleBindings as Array<Record<string, unknown>>;
|
||||
const hasAdminRole = bindings.some((b) => b['role'] === 'admin');
|
||||
if (!hasAdminRole) continue;
|
||||
|
||||
// Replace admin bindings with granular equivalents
|
||||
const newBindings: Array<Record<string, string>> = [];
|
||||
for (const b of bindings) {
|
||||
if (b['role'] === 'admin') {
|
||||
const resource = b['resource'] as string;
|
||||
newBindings.push({ role: 'edit', resource });
|
||||
newBindings.push({ role: 'run', resource });
|
||||
} else {
|
||||
newBindings.push(b as Record<string, string>);
|
||||
}
|
||||
}
|
||||
// Add operation bindings (idempotent — only for wildcard admin)
|
||||
const hasWildcard = bindings.some((b) => b['role'] === 'admin' && b['resource'] === '*');
|
||||
if (hasWildcard) {
|
||||
const ops = ['impersonate', 'logs', 'backup', 'restore', 'audit-purge'];
|
||||
for (const op of ops) {
|
||||
if (!newBindings.some((b) => b['action'] === op)) {
|
||||
newBindings.push({ role: 'run', action: op });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await rbacRepo.update(def.id, { roleBindings: newBindings as UpdateRbacDefinitionInput['roleBindings'] });
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`mcpd: migrated RBAC '${def.name}' from admin → granular roles`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = loadConfigFromEnv();
|
||||
@@ -45,31 +213,80 @@ async function main(): Promise<void> {
|
||||
});
|
||||
await prisma.$connect();
|
||||
|
||||
// Seed default servers (upsert, safe to repeat)
|
||||
await seedMcpServers(prisma);
|
||||
// Seed templates from YAML files
|
||||
const templatesDir = process.env.TEMPLATES_DIR ?? 'templates';
|
||||
const templateFiles = (() => {
|
||||
try {
|
||||
return readdirSync(templatesDir).filter((f) => f.endsWith('.yaml') || f.endsWith('.yml'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
const templates: SeedTemplate[] = templateFiles.map((f) => {
|
||||
const content = readFileSync(join(templatesDir, f), 'utf-8');
|
||||
const parsed = yaml.load(content) as SeedTemplate;
|
||||
return {
|
||||
...parsed,
|
||||
transport: parsed.transport ?? 'STDIO',
|
||||
version: parsed.version ?? '1.0.0',
|
||||
description: parsed.description ?? '',
|
||||
...(parsed.healthCheck ? { healthCheck: parsed.healthCheck } : {}),
|
||||
};
|
||||
});
|
||||
await seedTemplates(prisma, templates);
|
||||
|
||||
// Repositories
|
||||
const serverRepo = new McpServerRepository(prisma);
|
||||
const profileRepo = new McpProfileRepository(prisma);
|
||||
const secretRepo = new SecretRepository(prisma);
|
||||
const instanceRepo = new McpInstanceRepository(prisma);
|
||||
const projectRepo = new ProjectRepository(prisma);
|
||||
const auditLogRepo = new AuditLogRepository(prisma);
|
||||
const templateRepo = new TemplateRepository(prisma);
|
||||
const rbacDefinitionRepo = new RbacDefinitionRepository(prisma);
|
||||
const userRepo = new UserRepository(prisma);
|
||||
const groupRepo = new GroupRepository(prisma);
|
||||
|
||||
// CUID detection for RBAC name resolution
|
||||
const CUID_RE = /^c[^\s-]{8,}$/i;
|
||||
const nameResolvers: Record<string, { findById(id: string): Promise<{ name: string } | null> }> = {
|
||||
servers: serverRepo,
|
||||
secrets: secretRepo,
|
||||
projects: projectRepo,
|
||||
groups: groupRepo,
|
||||
};
|
||||
|
||||
// Migrate legacy 'admin' role → granular roles
|
||||
await migrateAdminRole(rbacDefinitionRepo);
|
||||
|
||||
// Orchestrator
|
||||
const orchestrator = new DockerContainerManager();
|
||||
|
||||
// Services
|
||||
const serverService = new McpServerService(serverRepo);
|
||||
const profileService = new McpProfileService(profileRepo, serverRepo);
|
||||
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator);
|
||||
const projectService = new ProjectService(projectRepo, profileRepo, serverRepo);
|
||||
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretRepo);
|
||||
serverService.setInstanceService(instanceService);
|
||||
const secretService = new SecretService(secretRepo);
|
||||
const projectService = new ProjectService(projectRepo, serverRepo, secretRepo);
|
||||
const auditLogService = new AuditLogService(auditLogRepo);
|
||||
const metricsCollector = new MetricsCollector();
|
||||
const healthAggregator = new HealthAggregator(metricsCollector, orchestrator);
|
||||
const backupService = new BackupService(serverRepo, profileRepo, projectRepo);
|
||||
const restoreService = new RestoreService(serverRepo, profileRepo, projectRepo);
|
||||
const backupService = new BackupService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo);
|
||||
const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo);
|
||||
const authService = new AuthService(prisma);
|
||||
const mcpProxyService = new McpProxyService(instanceRepo);
|
||||
const templateService = new TemplateService(templateRepo);
|
||||
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo, orchestrator);
|
||||
const rbacDefinitionService = new RbacDefinitionService(rbacDefinitionRepo);
|
||||
const rbacService = new RbacService(rbacDefinitionRepo, prisma);
|
||||
const userService = new UserService(userRepo);
|
||||
const groupService = new GroupService(groupRepo, userRepo);
|
||||
const promptRepo = new PromptRepository(prisma);
|
||||
const promptRequestRepo = new PromptRequestRepository(prisma);
|
||||
const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo);
|
||||
|
||||
// Auth middleware for global hooks
|
||||
const authMiddleware = createAuthMiddleware({
|
||||
findSession: (token) => authService.findSession(token),
|
||||
});
|
||||
|
||||
// Server
|
||||
const app = await createServer(config, {
|
||||
@@ -85,28 +302,120 @@ async function main(): Promise<void> {
|
||||
},
|
||||
});
|
||||
|
||||
// ── Global auth hook ──
|
||||
// Runs on all /api/v1/* routes EXCEPT auth endpoints and health checks.
|
||||
// Tests that use createServer() directly are NOT affected — this hook
|
||||
// is only registered here in main.ts.
|
||||
app.addHook('preHandler', async (request, reply) => {
|
||||
const url = request.url;
|
||||
// Skip auth for health, auth, and root
|
||||
if (url.startsWith('/api/v1/auth/') || url === '/healthz' || url === '/health') return;
|
||||
if (!url.startsWith('/api/v1/')) return;
|
||||
|
||||
// Run auth middleware
|
||||
await authMiddleware(request, reply);
|
||||
});
|
||||
|
||||
// ── Global RBAC hook ──
|
||||
// Runs after the auth hook. Maps URL to resource+action and checks permissions.
|
||||
app.addHook('preHandler', async (request, reply) => {
|
||||
if (reply.sent) return; // Auth hook already rejected
|
||||
const url = request.url;
|
||||
if (url.startsWith('/api/v1/auth/') || url === '/healthz' || url === '/health') return;
|
||||
if (!url.startsWith('/api/v1/')) return;
|
||||
if (request.userId === undefined) return; // Auth hook will handle 401
|
||||
|
||||
const check = mapUrlToPermission(request.method, url);
|
||||
if (check.kind === 'skip') return;
|
||||
|
||||
// Extract service account identity from header (sent by mcplocal)
|
||||
const saHeader = request.headers['x-service-account'];
|
||||
const serviceAccountName = typeof saHeader === 'string' ? saHeader : undefined;
|
||||
|
||||
let allowed: boolean;
|
||||
if (check.kind === 'operation') {
|
||||
allowed = await rbacService.canRunOperation(request.userId, check.operation, serviceAccountName);
|
||||
} else {
|
||||
// Resolve CUID → human name for name-scoped RBAC bindings
|
||||
if (check.resourceName !== undefined && CUID_RE.test(check.resourceName)) {
|
||||
const resolver = nameResolvers[check.resource];
|
||||
if (resolver) {
|
||||
const entity = await resolver.findById(check.resourceName);
|
||||
if (entity) check.resourceName = entity.name;
|
||||
}
|
||||
}
|
||||
allowed = await rbacService.canAccess(request.userId, check.action, check.resource, check.resourceName, serviceAccountName);
|
||||
// Compute scope for list filtering (used by preSerialization hook)
|
||||
if (allowed && check.resourceName === undefined) {
|
||||
request.rbacScope = await rbacService.getAllowedScope(request.userId, check.action, check.resource, serviceAccountName);
|
||||
}
|
||||
}
|
||||
if (!allowed) {
|
||||
reply.code(403).send({ error: 'Forbidden' });
|
||||
}
|
||||
});
|
||||
|
||||
// Routes
|
||||
registerMcpServerRoutes(app, serverService);
|
||||
registerMcpProfileRoutes(app, profileService);
|
||||
registerMcpServerRoutes(app, serverService, instanceService);
|
||||
registerTemplateRoutes(app, templateService);
|
||||
registerSecretRoutes(app, secretService);
|
||||
registerInstanceRoutes(app, instanceService);
|
||||
registerProjectRoutes(app, projectService);
|
||||
registerAuditLogRoutes(app, auditLogService);
|
||||
registerHealthMonitoringRoutes(app, { healthAggregator, metricsCollector });
|
||||
registerBackupRoutes(app, { backupService, restoreService });
|
||||
registerAuthRoutes(app, { authService });
|
||||
registerAuthRoutes(app, { authService, userService, groupService, rbacDefinitionService, rbacService });
|
||||
registerMcpProxyRoutes(app, {
|
||||
mcpProxyService,
|
||||
auditLogService,
|
||||
authDeps: { findSession: (token) => authService.findSession(token) },
|
||||
});
|
||||
registerRbacRoutes(app, rbacDefinitionService);
|
||||
registerUserRoutes(app, userService);
|
||||
registerGroupRoutes(app, groupService);
|
||||
registerPromptRoutes(app, promptService, projectRepo);
|
||||
|
||||
// ── RBAC list filtering hook ──
|
||||
// Filters array responses to only include resources the user is allowed to see.
|
||||
app.addHook('preSerialization', async (request, _reply, payload) => {
|
||||
if (!request.rbacScope || request.rbacScope.wildcard) return payload;
|
||||
if (!Array.isArray(payload)) return payload;
|
||||
return (payload as Array<Record<string, unknown>>).filter((item) => {
|
||||
const name = item['name'];
|
||||
return typeof name === 'string' && request.rbacScope!.names.has(name);
|
||||
});
|
||||
});
|
||||
|
||||
// Start
|
||||
await app.listen({ port: config.port, host: config.host });
|
||||
app.log.info(`mcpd listening on ${config.host}:${config.port}`);
|
||||
|
||||
// Periodic container liveness sync — detect crashed containers
|
||||
const SYNC_INTERVAL_MS = 30_000; // 30s
|
||||
const syncTimer = setInterval(async () => {
|
||||
try {
|
||||
await instanceService.syncStatus();
|
||||
} catch (err) {
|
||||
app.log.error({ err }, 'Container status sync failed');
|
||||
}
|
||||
}, SYNC_INTERVAL_MS);
|
||||
|
||||
// Health probe runner — periodic MCP tool-call probes (like k8s livenessProbe)
|
||||
const healthProbeRunner = new HealthProbeRunner(
|
||||
instanceRepo,
|
||||
serverRepo,
|
||||
orchestrator,
|
||||
{ info: (msg) => app.log.info(msg), error: (obj, msg) => app.log.error(obj, msg) },
|
||||
);
|
||||
healthProbeRunner.start(15_000);
|
||||
|
||||
// Graceful shutdown
|
||||
setupGracefulShutdown(app, {
|
||||
disconnectDb: () => prisma.$disconnect(),
|
||||
disconnectDb: async () => {
|
||||
clearInterval(syncTimer);
|
||||
healthProbeRunner.stop();
|
||||
await prisma.$disconnect();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface AuthDeps {
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
userId?: string;
|
||||
rbacScope?: { wildcard: boolean; names: Set<string> };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
36
src/mcpd/src/middleware/rbac.ts
Normal file
36
src/mcpd/src/middleware/rbac.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import type { RbacService, RbacAction } from '../services/rbac.service.js';
|
||||
|
||||
export function createRbacMiddleware(rbacService: RbacService) {
|
||||
function requirePermission(resource: string, action: RbacAction, resourceName?: string) {
|
||||
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
||||
if (request.userId === undefined) {
|
||||
reply.code(401).send({ error: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = await rbacService.canAccess(request.userId, action, resource, resourceName);
|
||||
if (!allowed) {
|
||||
reply.code(403).send({ error: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function requireOperation(operation: string) {
|
||||
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
||||
if (request.userId === undefined) {
|
||||
reply.code(401).send({ error: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = await rbacService.canRunOperation(request.userId, operation);
|
||||
if (!allowed) {
|
||||
reply.code(403).send({ error: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return { requirePermission, requireOperation };
|
||||
}
|
||||
93
src/mcpd/src/repositories/group.repository.ts
Normal file
93
src/mcpd/src/repositories/group.repository.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { PrismaClient, Group } from '@prisma/client';
|
||||
|
||||
export interface GroupWithMembers extends Group {
|
||||
members: Array<{ id: string; user: { id: string; email: string; name: string | null } }>;
|
||||
}
|
||||
|
||||
export interface IGroupRepository {
|
||||
findAll(): Promise<GroupWithMembers[]>;
|
||||
findById(id: string): Promise<GroupWithMembers | null>;
|
||||
findByName(name: string): Promise<GroupWithMembers | null>;
|
||||
create(data: { name: string; description?: string }): Promise<Group>;
|
||||
update(id: string, data: { description?: string }): Promise<Group>;
|
||||
delete(id: string): Promise<void>;
|
||||
setMembers(groupId: string, userIds: string[]): Promise<void>;
|
||||
findGroupsForUser(userId: string): Promise<Array<{ id: string; name: string }>>;
|
||||
}
|
||||
|
||||
const MEMBERS_INCLUDE = {
|
||||
members: {
|
||||
select: {
|
||||
id: true,
|
||||
user: {
|
||||
select: { id: true, email: true, name: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export class GroupRepository implements IGroupRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async findAll(): Promise<GroupWithMembers[]> {
|
||||
return this.prisma.group.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: MEMBERS_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<GroupWithMembers | null> {
|
||||
return this.prisma.group.findUnique({
|
||||
where: { id },
|
||||
include: MEMBERS_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<GroupWithMembers | null> {
|
||||
return this.prisma.group.findUnique({
|
||||
where: { name },
|
||||
include: MEMBERS_INCLUDE,
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: { name: string; description?: string }): Promise<Group> {
|
||||
const createData: Record<string, unknown> = { name: data.name };
|
||||
if (data.description !== undefined) createData['description'] = data.description;
|
||||
return this.prisma.group.create({
|
||||
data: createData as Parameters<PrismaClient['group']['create']>[0]['data'],
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: { description?: string }): Promise<Group> {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.description !== undefined) updateData['description'] = data.description;
|
||||
return this.prisma.group.update({ where: { id }, data: updateData });
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.group.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async setMembers(groupId: string, userIds: string[]): Promise<void> {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.groupMember.deleteMany({ where: { groupId } });
|
||||
if (userIds.length > 0) {
|
||||
await tx.groupMember.createMany({
|
||||
data: userIds.map((userId) => ({ groupId, userId })),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async findGroupsForUser(userId: string): Promise<Array<{ id: string; name: string }>> {
|
||||
const memberships = await this.prisma.groupMember.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
group: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return memberships.map((m) => m.group);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
export type { IMcpServerRepository, IMcpProfileRepository, IMcpInstanceRepository, IAuditLogRepository, AuditLogFilter } from './interfaces.js';
|
||||
export type { IMcpServerRepository, IMcpInstanceRepository, ISecretRepository, IAuditLogRepository, AuditLogFilter } from './interfaces.js';
|
||||
export { McpServerRepository } from './mcp-server.repository.js';
|
||||
export { McpProfileRepository } from './mcp-profile.repository.js';
|
||||
export type { IProjectRepository } from './project.repository.js';
|
||||
export { SecretRepository } from './secret.repository.js';
|
||||
export type { IProjectRepository, ProjectWithRelations } from './project.repository.js';
|
||||
export { ProjectRepository } from './project.repository.js';
|
||||
export { McpInstanceRepository } from './mcp-instance.repository.js';
|
||||
export { AuditLogRepository } from './audit-log.repository.js';
|
||||
export type { ITemplateRepository } from './template.repository.js';
|
||||
export { TemplateRepository } from './template.repository.js';
|
||||
export type { IRbacDefinitionRepository } from './rbac-definition.repository.js';
|
||||
export { RbacDefinitionRepository } from './rbac-definition.repository.js';
|
||||
export type { IUserRepository, SafeUser } from './user.repository.js';
|
||||
export { UserRepository } from './user.repository.js';
|
||||
export type { IGroupRepository, GroupWithMembers } from './group.repository.js';
|
||||
export { GroupRepository } from './group.repository.js';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { McpServer, McpProfile, McpInstance, AuditLog, InstanceStatus } from '@prisma/client';
|
||||
import type { McpServer, McpInstance, AuditLog, Secret, InstanceStatus } from '@prisma/client';
|
||||
import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
|
||||
import type { CreateMcpProfileInput, UpdateMcpProfileInput } from '../validation/mcp-profile.schema.js';
|
||||
import type { CreateSecretInput, UpdateSecretInput } from '../validation/secret.schema.js';
|
||||
|
||||
export interface IMcpServerRepository {
|
||||
findAll(): Promise<McpServer[]>;
|
||||
@@ -16,16 +16,16 @@ export interface IMcpInstanceRepository {
|
||||
findById(id: string): Promise<McpInstance | null>;
|
||||
findByContainerId(containerId: string): Promise<McpInstance | null>;
|
||||
create(data: { serverId: string; containerId?: string; status?: InstanceStatus; port?: number; metadata?: Record<string, unknown> }): Promise<McpInstance>;
|
||||
updateStatus(id: string, status: InstanceStatus, fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown> }): Promise<McpInstance>;
|
||||
updateStatus(id: string, status: InstanceStatus, fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown>; healthStatus?: string; lastHealthCheck?: Date; events?: unknown[] }): Promise<McpInstance>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface IMcpProfileRepository {
|
||||
findAll(serverId?: string): Promise<McpProfile[]>;
|
||||
findById(id: string): Promise<McpProfile | null>;
|
||||
findByServerAndName(serverId: string, name: string): Promise<McpProfile | null>;
|
||||
create(data: CreateMcpProfileInput): Promise<McpProfile>;
|
||||
update(id: string, data: UpdateMcpProfileInput): Promise<McpProfile>;
|
||||
export interface ISecretRepository {
|
||||
findAll(): Promise<Secret[]>;
|
||||
findById(id: string): Promise<Secret | null>;
|
||||
findByName(name: string): Promise<Secret | null>;
|
||||
create(data: CreateSecretInput): Promise<Secret>;
|
||||
update(id: string, data: UpdateSecretInput): Promise<Secret>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,12 +11,16 @@ export class McpInstanceRepository implements IMcpInstanceRepository {
|
||||
}
|
||||
return this.prisma.mcpInstance.findMany({
|
||||
where,
|
||||
include: { server: { select: { name: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<McpInstance | null> {
|
||||
return this.prisma.mcpInstance.findUnique({ where: { id } });
|
||||
return this.prisma.mcpInstance.findUnique({
|
||||
where: { id },
|
||||
include: { server: { select: { name: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async findByContainerId(containerId: string): Promise<McpInstance | null> {
|
||||
@@ -44,7 +48,7 @@ export class McpInstanceRepository implements IMcpInstanceRepository {
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: InstanceStatus,
|
||||
fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown> },
|
||||
fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown>; healthStatus?: string; lastHealthCheck?: Date; events?: unknown[] },
|
||||
): Promise<McpInstance> {
|
||||
const updateData: Prisma.McpInstanceUpdateInput = {
|
||||
status,
|
||||
@@ -59,6 +63,15 @@ export class McpInstanceRepository implements IMcpInstanceRepository {
|
||||
if (fields?.metadata !== undefined) {
|
||||
updateData.metadata = fields.metadata as Prisma.InputJsonValue;
|
||||
}
|
||||
if (fields?.healthStatus !== undefined) {
|
||||
updateData.healthStatus = fields.healthStatus;
|
||||
}
|
||||
if (fields?.lastHealthCheck !== undefined) {
|
||||
updateData.lastHealthCheck = fields.lastHealthCheck;
|
||||
}
|
||||
if (fields?.events !== undefined) {
|
||||
updateData.events = fields.events as unknown as Prisma.InputJsonValue;
|
||||
}
|
||||
return this.prisma.mcpInstance.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { PrismaClient, McpProfile } from '@prisma/client';
|
||||
import type { IMcpProfileRepository } from './interfaces.js';
|
||||
import type { CreateMcpProfileInput, UpdateMcpProfileInput } from '../validation/mcp-profile.schema.js';
|
||||
|
||||
export class McpProfileRepository implements IMcpProfileRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async findAll(serverId?: string): Promise<McpProfile[]> {
|
||||
const where = serverId !== undefined ? { serverId } : {};
|
||||
return this.prisma.mcpProfile.findMany({ where, orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<McpProfile | null> {
|
||||
return this.prisma.mcpProfile.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async findByServerAndName(serverId: string, name: string): Promise<McpProfile | null> {
|
||||
return this.prisma.mcpProfile.findUnique({
|
||||
where: { name_serverId: { name, serverId } },
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: CreateMcpProfileInput): Promise<McpProfile> {
|
||||
return this.prisma.mcpProfile.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
serverId: data.serverId,
|
||||
permissions: data.permissions,
|
||||
envOverrides: data.envOverrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateMcpProfileInput): Promise<McpProfile> {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.name !== undefined) updateData['name'] = data.name;
|
||||
if (data.permissions !== undefined) updateData['permissions'] = data.permissions;
|
||||
if (data.envOverrides !== undefined) updateData['envOverrides'] = data.envOverrides;
|
||||
|
||||
return this.prisma.mcpProfile.update({ where: { id }, data: updateData });
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.mcpProfile.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PrismaClient, McpServer } from '@prisma/client';
|
||||
import { type PrismaClient, type McpServer, Prisma } from '@prisma/client';
|
||||
import type { IMcpServerRepository } from './interfaces.js';
|
||||
import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
|
||||
|
||||
@@ -26,7 +26,12 @@ export class McpServerRepository implements IMcpServerRepository {
|
||||
dockerImage: data.dockerImage ?? null,
|
||||
transport: data.transport,
|
||||
repositoryUrl: data.repositoryUrl ?? null,
|
||||
envTemplate: data.envTemplate,
|
||||
externalUrl: data.externalUrl ?? null,
|
||||
command: data.command ?? Prisma.DbNull,
|
||||
containerPort: data.containerPort ?? null,
|
||||
replicas: data.replicas,
|
||||
env: data.env,
|
||||
healthCheck: (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -38,7 +43,12 @@ export class McpServerRepository implements IMcpServerRepository {
|
||||
if (data.dockerImage !== undefined) updateData['dockerImage'] = data.dockerImage;
|
||||
if (data.transport !== undefined) updateData['transport'] = data.transport;
|
||||
if (data.repositoryUrl !== undefined) updateData['repositoryUrl'] = data.repositoryUrl;
|
||||
if (data.envTemplate !== undefined) updateData['envTemplate'] = data.envTemplate;
|
||||
if (data.externalUrl !== undefined) updateData['externalUrl'] = data.externalUrl;
|
||||
if (data.command !== undefined) updateData['command'] = data.command;
|
||||
if (data.containerPort !== undefined) updateData['containerPort'] = data.containerPort;
|
||||
if (data.replicas !== undefined) updateData['replicas'] = data.replicas;
|
||||
if (data.env !== undefined) updateData['env'] = data.env;
|
||||
if (data.healthCheck !== undefined) updateData['healthCheck'] = (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue;
|
||||
|
||||
return this.prisma.mcpServer.update({ where: { id }, data: updateData });
|
||||
}
|
||||
|
||||
@@ -1,69 +1,92 @@
|
||||
import type { PrismaClient, Project } from '@prisma/client';
|
||||
import type { CreateProjectInput, UpdateProjectInput } from '../validation/project.schema.js';
|
||||
|
||||
export interface ProjectWithRelations extends Project {
|
||||
servers: Array<{ id: string; projectId: string; serverId: string; server: Record<string, unknown> & { id: string; name: string } }>;
|
||||
}
|
||||
|
||||
const PROJECT_INCLUDE = {
|
||||
servers: { include: { server: true } },
|
||||
} as const;
|
||||
|
||||
export interface IProjectRepository {
|
||||
findAll(ownerId?: string): Promise<Project[]>;
|
||||
findById(id: string): Promise<Project | null>;
|
||||
findByName(name: string): Promise<Project | null>;
|
||||
create(data: CreateProjectInput & { ownerId: string }): Promise<Project>;
|
||||
update(id: string, data: UpdateProjectInput): Promise<Project>;
|
||||
findAll(ownerId?: string): Promise<ProjectWithRelations[]>;
|
||||
findById(id: string): Promise<ProjectWithRelations | null>;
|
||||
findByName(name: string): Promise<ProjectWithRelations | null>;
|
||||
create(data: { name: string; description: string; prompt?: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string }): Promise<ProjectWithRelations>;
|
||||
update(id: string, data: Record<string, unknown>): Promise<ProjectWithRelations>;
|
||||
delete(id: string): Promise<void>;
|
||||
setProfiles(projectId: string, profileIds: string[]): Promise<void>;
|
||||
getProfileIds(projectId: string): Promise<string[]>;
|
||||
setServers(projectId: string, serverIds: string[]): Promise<void>;
|
||||
addServer(projectId: string, serverId: string): Promise<void>;
|
||||
removeServer(projectId: string, serverId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class ProjectRepository implements IProjectRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async findAll(ownerId?: string): Promise<Project[]> {
|
||||
async findAll(ownerId?: string): Promise<ProjectWithRelations[]> {
|
||||
const where = ownerId !== undefined ? { ownerId } : {};
|
||||
return this.prisma.project.findMany({ where, orderBy: { name: 'asc' } });
|
||||
return this.prisma.project.findMany({ where, orderBy: { name: 'asc' }, include: PROJECT_INCLUDE }) as unknown as Promise<ProjectWithRelations[]>;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Project | null> {
|
||||
return this.prisma.project.findUnique({ where: { id } });
|
||||
async findById(id: string): Promise<ProjectWithRelations | null> {
|
||||
return this.prisma.project.findUnique({ where: { id }, include: PROJECT_INCLUDE }) as unknown as Promise<ProjectWithRelations | null>;
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<Project | null> {
|
||||
return this.prisma.project.findUnique({ where: { name } });
|
||||
async findByName(name: string): Promise<ProjectWithRelations | null> {
|
||||
return this.prisma.project.findUnique({ where: { name }, include: PROJECT_INCLUDE }) as unknown as Promise<ProjectWithRelations | null>;
|
||||
}
|
||||
|
||||
async create(data: CreateProjectInput & { ownerId: string }): Promise<Project> {
|
||||
async create(data: { name: string; description: string; prompt?: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string }): Promise<ProjectWithRelations> {
|
||||
const createData: Record<string, unknown> = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
ownerId: data.ownerId,
|
||||
proxyMode: data.proxyMode,
|
||||
};
|
||||
if (data.prompt !== undefined) createData['prompt'] = data.prompt;
|
||||
if (data.llmProvider !== undefined) createData['llmProvider'] = data.llmProvider;
|
||||
if (data.llmModel !== undefined) createData['llmModel'] = data.llmModel;
|
||||
|
||||
return this.prisma.project.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
ownerId: data.ownerId,
|
||||
},
|
||||
});
|
||||
data: createData as Parameters<PrismaClient['project']['create']>[0]['data'],
|
||||
include: PROJECT_INCLUDE,
|
||||
}) as unknown as Promise<ProjectWithRelations>;
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateProjectInput): Promise<Project> {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.description !== undefined) updateData['description'] = data.description;
|
||||
return this.prisma.project.update({ where: { id }, data: updateData });
|
||||
async update(id: string, data: Record<string, unknown>): Promise<ProjectWithRelations> {
|
||||
return this.prisma.project.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: PROJECT_INCLUDE,
|
||||
}) as unknown as Promise<ProjectWithRelations>;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.project.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async setProfiles(projectId: string, profileIds: string[]): Promise<void> {
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.projectMcpProfile.deleteMany({ where: { projectId } }),
|
||||
...profileIds.map((profileId) =>
|
||||
this.prisma.projectMcpProfile.create({
|
||||
data: { projectId, profileId },
|
||||
}),
|
||||
),
|
||||
]);
|
||||
async setServers(projectId: string, serverIds: string[]): Promise<void> {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.projectServer.deleteMany({ where: { projectId } });
|
||||
if (serverIds.length > 0) {
|
||||
await tx.projectServer.createMany({
|
||||
data: serverIds.map((serverId) => ({ projectId, serverId })),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getProfileIds(projectId: string): Promise<string[]> {
|
||||
const links = await this.prisma.projectMcpProfile.findMany({
|
||||
where: { projectId },
|
||||
select: { profileId: true },
|
||||
async addServer(projectId: string, serverId: string): Promise<void> {
|
||||
await this.prisma.projectServer.upsert({
|
||||
where: { projectId_serverId: { projectId, serverId } },
|
||||
create: { projectId, serverId },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
async removeServer(projectId: string, serverId: string): Promise<void> {
|
||||
await this.prisma.projectServer.deleteMany({
|
||||
where: { projectId, serverId },
|
||||
});
|
||||
return links.map((l) => l.profileId);
|
||||
}
|
||||
}
|
||||
|
||||
53
src/mcpd/src/repositories/prompt-request.repository.ts
Normal file
53
src/mcpd/src/repositories/prompt-request.repository.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { PrismaClient, PromptRequest } from '@prisma/client';
|
||||
|
||||
export interface IPromptRequestRepository {
|
||||
findAll(projectId?: string): Promise<PromptRequest[]>;
|
||||
findById(id: string): Promise<PromptRequest | null>;
|
||||
findByNameAndProject(name: string, projectId: string | null): Promise<PromptRequest | null>;
|
||||
findBySession(sessionId: string, projectId?: string): Promise<PromptRequest[]>;
|
||||
create(data: { name: string; content: string; projectId?: string; createdBySession?: string; createdByUserId?: string }): Promise<PromptRequest>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class PromptRequestRepository implements IPromptRequestRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async findAll(projectId?: string): Promise<PromptRequest[]> {
|
||||
if (projectId !== undefined) {
|
||||
return this.prisma.promptRequest.findMany({
|
||||
where: { OR: [{ projectId }, { projectId: null }] },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
return this.prisma.promptRequest.findMany({ orderBy: { createdAt: 'desc' } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<PromptRequest | null> {
|
||||
return this.prisma.promptRequest.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async findByNameAndProject(name: string, projectId: string | null): Promise<PromptRequest | null> {
|
||||
return this.prisma.promptRequest.findUnique({
|
||||
where: { name_projectId: { name, projectId: projectId ?? '' } },
|
||||
});
|
||||
}
|
||||
|
||||
async findBySession(sessionId: string, projectId?: string): Promise<PromptRequest[]> {
|
||||
const where: Record<string, unknown> = { createdBySession: sessionId };
|
||||
if (projectId !== undefined) {
|
||||
where['OR'] = [{ projectId }, { projectId: null }];
|
||||
}
|
||||
return this.prisma.promptRequest.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: { name: string; content: string; projectId?: string; createdBySession?: string; createdByUserId?: string }): Promise<PromptRequest> {
|
||||
return this.prisma.promptRequest.create({ data });
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.promptRequest.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
47
src/mcpd/src/repositories/prompt.repository.ts
Normal file
47
src/mcpd/src/repositories/prompt.repository.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { PrismaClient, Prompt } from '@prisma/client';
|
||||
|
||||
export interface IPromptRepository {
|
||||
findAll(projectId?: string): Promise<Prompt[]>;
|
||||
findById(id: string): Promise<Prompt | null>;
|
||||
findByNameAndProject(name: string, projectId: string | null): Promise<Prompt | null>;
|
||||
create(data: { name: string; content: string; projectId?: string }): Promise<Prompt>;
|
||||
update(id: string, data: { content?: string }): Promise<Prompt>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class PromptRepository implements IPromptRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async findAll(projectId?: string): Promise<Prompt[]> {
|
||||
if (projectId !== undefined) {
|
||||
// Project-scoped + global prompts
|
||||
return this.prisma.prompt.findMany({
|
||||
where: { OR: [{ projectId }, { projectId: null }] },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
return this.prisma.prompt.findMany({ orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Prompt | null> {
|
||||
return this.prisma.prompt.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async findByNameAndProject(name: string, projectId: string | null): Promise<Prompt | null> {
|
||||
return this.prisma.prompt.findUnique({
|
||||
where: { name_projectId: { name, projectId: projectId ?? '' } },
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: { name: string; content: string; projectId?: string }): Promise<Prompt> {
|
||||
return this.prisma.prompt.create({ data });
|
||||
}
|
||||
|
||||
async update(id: string, data: { content?: string }): Promise<Prompt> {
|
||||
return this.prisma.prompt.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.prompt.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
48
src/mcpd/src/repositories/rbac-definition.repository.ts
Normal file
48
src/mcpd/src/repositories/rbac-definition.repository.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { PrismaClient, RbacDefinition } from '@prisma/client';
|
||||
import type { CreateRbacDefinitionInput, UpdateRbacDefinitionInput } from '../validation/rbac-definition.schema.js';
|
||||
|
||||
export interface IRbacDefinitionRepository {
|
||||
findAll(): Promise<RbacDefinition[]>;
|
||||
findById(id: string): Promise<RbacDefinition | null>;
|
||||
findByName(name: string): Promise<RbacDefinition | null>;
|
||||
create(data: CreateRbacDefinitionInput): Promise<RbacDefinition>;
|
||||
update(id: string, data: UpdateRbacDefinitionInput): Promise<RbacDefinition>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class RbacDefinitionRepository implements IRbacDefinitionRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async findAll(): Promise<RbacDefinition[]> {
|
||||
return this.prisma.rbacDefinition.findMany({ orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<RbacDefinition | null> {
|
||||
return this.prisma.rbacDefinition.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<RbacDefinition | null> {
|
||||
return this.prisma.rbacDefinition.findUnique({ where: { name } });
|
||||
}
|
||||
|
||||
async create(data: CreateRbacDefinitionInput): Promise<RbacDefinition> {
|
||||
return this.prisma.rbacDefinition.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
subjects: data.subjects,
|
||||
roleBindings: data.roleBindings,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateRbacDefinitionInput): Promise<RbacDefinition> {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.subjects !== undefined) updateData['subjects'] = data.subjects;
|
||||
if (data.roleBindings !== undefined) updateData['roleBindings'] = data.roleBindings;
|
||||
return this.prisma.rbacDefinition.update({ where: { id }, data: updateData });
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.rbacDefinition.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
39
src/mcpd/src/repositories/secret.repository.ts
Normal file
39
src/mcpd/src/repositories/secret.repository.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { type PrismaClient, type Secret } from '@prisma/client';
|
||||
import type { ISecretRepository } from './interfaces.js';
|
||||
import type { CreateSecretInput, UpdateSecretInput } from '../validation/secret.schema.js';
|
||||
|
||||
export class SecretRepository implements ISecretRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async findAll(): Promise<Secret[]> {
|
||||
return this.prisma.secret.findMany({ orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Secret | null> {
|
||||
return this.prisma.secret.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<Secret | null> {
|
||||
return this.prisma.secret.findUnique({ where: { name } });
|
||||
}
|
||||
|
||||
async create(data: CreateSecretInput): Promise<Secret> {
|
||||
return this.prisma.secret.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
data: data.data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateSecretInput): Promise<Secret> {
|
||||
return this.prisma.secret.update({
|
||||
where: { id },
|
||||
data: { data: data.data },
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.secret.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
82
src/mcpd/src/repositories/template.repository.ts
Normal file
82
src/mcpd/src/repositories/template.repository.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { type PrismaClient, type McpTemplate, Prisma } from '@prisma/client';
|
||||
import type { CreateTemplateInput, UpdateTemplateInput } from '../validation/template.schema.js';
|
||||
|
||||
export interface ITemplateRepository {
|
||||
findAll(): Promise<McpTemplate[]>;
|
||||
findById(id: string): Promise<McpTemplate | null>;
|
||||
findByName(name: string): Promise<McpTemplate | null>;
|
||||
search(pattern: string): Promise<McpTemplate[]>;
|
||||
create(data: CreateTemplateInput): Promise<McpTemplate>;
|
||||
update(id: string, data: UpdateTemplateInput): Promise<McpTemplate>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class TemplateRepository implements ITemplateRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async findAll(): Promise<McpTemplate[]> {
|
||||
return this.prisma.mcpTemplate.findMany({ orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<McpTemplate | null> {
|
||||
return this.prisma.mcpTemplate.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<McpTemplate | null> {
|
||||
return this.prisma.mcpTemplate.findUnique({ where: { name } });
|
||||
}
|
||||
|
||||
async search(pattern: string): Promise<McpTemplate[]> {
|
||||
// Convert glob * to SQL %
|
||||
const sqlPattern = pattern.replace(/\*/g, '%');
|
||||
return this.prisma.mcpTemplate.findMany({
|
||||
where: { name: { contains: sqlPattern.replace(/%/g, ''), mode: 'insensitive' } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: CreateTemplateInput): Promise<McpTemplate> {
|
||||
return this.prisma.mcpTemplate.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
version: data.version,
|
||||
description: data.description,
|
||||
packageName: data.packageName ?? null,
|
||||
dockerImage: data.dockerImage ?? null,
|
||||
transport: data.transport,
|
||||
repositoryUrl: data.repositoryUrl ?? null,
|
||||
externalUrl: data.externalUrl ?? null,
|
||||
command: (data.command ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||
containerPort: data.containerPort ?? null,
|
||||
replicas: data.replicas,
|
||||
env: (data.env ?? []) as unknown as Prisma.InputJsonValue,
|
||||
healthCheck: (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateTemplateInput): Promise<McpTemplate> {
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (data.version !== undefined) updateData.version = data.version;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
if (data.packageName !== undefined) updateData.packageName = data.packageName;
|
||||
if (data.dockerImage !== undefined) updateData.dockerImage = data.dockerImage;
|
||||
if (data.transport !== undefined) updateData.transport = data.transport;
|
||||
if (data.repositoryUrl !== undefined) updateData.repositoryUrl = data.repositoryUrl;
|
||||
if (data.externalUrl !== undefined) updateData.externalUrl = data.externalUrl;
|
||||
if (data.command !== undefined) updateData.command = (data.command ?? Prisma.JsonNull) as Prisma.InputJsonValue;
|
||||
if (data.containerPort !== undefined) updateData.containerPort = data.containerPort;
|
||||
if (data.replicas !== undefined) updateData.replicas = data.replicas;
|
||||
if (data.env !== undefined) updateData.env = (data.env ?? []) as Prisma.InputJsonValue;
|
||||
if (data.healthCheck !== undefined) updateData.healthCheck = (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue;
|
||||
|
||||
return this.prisma.mcpTemplate.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.mcpTemplate.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
76
src/mcpd/src/repositories/user.repository.ts
Normal file
76
src/mcpd/src/repositories/user.repository.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { PrismaClient, User } from '@prisma/client';
|
||||
|
||||
/** User without the passwordHash field — safe for API responses. */
|
||||
export type SafeUser = Omit<User, 'passwordHash'>;
|
||||
|
||||
export interface IUserRepository {
|
||||
findAll(): Promise<SafeUser[]>;
|
||||
findById(id: string): Promise<SafeUser | null>;
|
||||
findByEmail(email: string, includeHash?: boolean): Promise<SafeUser | null> | Promise<User | null>;
|
||||
create(data: { email: string; passwordHash: string; name?: string; role?: string }): Promise<SafeUser>;
|
||||
delete(id: string): Promise<void>;
|
||||
count(): Promise<number>;
|
||||
}
|
||||
|
||||
/** Fields to select when passwordHash must be excluded. */
|
||||
const safeSelect = {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
provider: true,
|
||||
externalId: true,
|
||||
version: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} as const;
|
||||
|
||||
export class UserRepository implements IUserRepository {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
|
||||
async findAll(): Promise<SafeUser[]> {
|
||||
return this.prisma.user.findMany({
|
||||
select: safeSelect,
|
||||
orderBy: { email: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SafeUser | null> {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: safeSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async findByEmail(email: string, includeHash?: boolean): Promise<User | SafeUser | null> {
|
||||
if (includeHash === true) {
|
||||
return this.prisma.user.findUnique({ where: { email } });
|
||||
}
|
||||
return this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
select: safeSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: { email: string; passwordHash: string; name?: string; role?: string }): Promise<SafeUser> {
|
||||
const createData: Record<string, unknown> = {
|
||||
email: data.email,
|
||||
passwordHash: data.passwordHash,
|
||||
};
|
||||
if (data.name !== undefined) createData['name'] = data.name;
|
||||
if (data.role !== undefined) createData['role'] = data.role;
|
||||
|
||||
return this.prisma.user.create({
|
||||
data: createData as Parameters<PrismaClient['user']['create']>[0]['data'],
|
||||
select: safeSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.user.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return this.prisma.user.count();
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,76 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { AuthService } from '../services/auth.service.js';
|
||||
import type { UserService } from '../services/user.service.js';
|
||||
import type { GroupService } from '../services/group.service.js';
|
||||
import type { RbacDefinitionService } from '../services/rbac-definition.service.js';
|
||||
import type { RbacService } from '../services/rbac.service.js';
|
||||
import { createAuthMiddleware } from '../middleware/auth.js';
|
||||
import { createRbacMiddleware } from '../middleware/rbac.js';
|
||||
|
||||
export interface AuthRouteDeps {
|
||||
authService: AuthService;
|
||||
userService: UserService;
|
||||
groupService: GroupService;
|
||||
rbacDefinitionService: RbacDefinitionService;
|
||||
rbacService: RbacService;
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDeps): void {
|
||||
const authMiddleware = createAuthMiddleware({
|
||||
findSession: (token) => deps.authService.findSession(token),
|
||||
});
|
||||
const { requireOperation } = createRbacMiddleware(deps.rbacService);
|
||||
|
||||
// GET /api/v1/auth/status — unauthenticated, returns whether any users exist
|
||||
app.get('/api/v1/auth/status', async () => {
|
||||
const count = await deps.userService.count();
|
||||
return { hasUsers: count > 0 };
|
||||
});
|
||||
|
||||
// POST /api/v1/auth/bootstrap — only works when no users exist (first-run setup)
|
||||
app.post('/api/v1/auth/bootstrap', async (request, reply) => {
|
||||
const count = await deps.userService.count();
|
||||
if (count > 0) {
|
||||
reply.code(409).send({ error: 'Users already exist. Use login instead.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { email, password, name } = request.body as { email: string; password: string; name?: string };
|
||||
|
||||
// Create the first admin user
|
||||
await deps.userService.create({
|
||||
email,
|
||||
password,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
});
|
||||
|
||||
// Create "admin" group and add the first user to it
|
||||
await deps.groupService.create({
|
||||
name: 'admin',
|
||||
description: 'Bootstrap admin group',
|
||||
members: [email],
|
||||
});
|
||||
|
||||
// Create bootstrap RBAC: full resource access + all operations
|
||||
await deps.rbacDefinitionService.create({
|
||||
name: 'bootstrap-admin',
|
||||
subjects: [{ kind: 'Group', name: 'admin' }],
|
||||
roleBindings: [
|
||||
{ role: 'edit', resource: '*' },
|
||||
{ role: 'run', resource: '*' },
|
||||
{ role: 'run', action: 'impersonate' },
|
||||
{ role: 'run', action: 'logs' },
|
||||
{ role: 'run', action: 'backup' },
|
||||
{ role: 'run', action: 'restore' },
|
||||
{ role: 'run', action: 'audit-purge' },
|
||||
],
|
||||
});
|
||||
|
||||
// Auto-login so the caller gets a token immediately
|
||||
const session = await deps.authService.login(email, password);
|
||||
reply.code(201);
|
||||
return session;
|
||||
});
|
||||
|
||||
// POST /api/v1/auth/login — no auth required
|
||||
app.post<{
|
||||
@@ -28,4 +89,15 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDeps): v
|
||||
await deps.authService.logout(token);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// POST /api/v1/auth/impersonate — requires auth + run:impersonate operation
|
||||
app.post(
|
||||
'/api/v1/auth/impersonate',
|
||||
{ preHandler: [authMiddleware, requireOperation('impersonate')] },
|
||||
async (request) => {
|
||||
const { email } = request.body as { email: string };
|
||||
const result = await deps.authService.impersonate(email);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function registerBackupRoutes(app: FastifyInstance, deps: BackupDeps): vo
|
||||
app.post<{
|
||||
Body: {
|
||||
password?: string;
|
||||
resources?: Array<'servers' | 'profiles' | 'projects'>;
|
||||
resources?: Array<'servers' | 'secrets' | 'projects' | 'users' | 'groups' | 'rbac'>;
|
||||
};
|
||||
}>('/api/v1/backup', async (request) => {
|
||||
const opts: BackupOptions = {};
|
||||
@@ -51,7 +51,7 @@ export function registerBackupRoutes(app: FastifyInstance, deps: BackupDeps): vo
|
||||
|
||||
const result = await deps.restoreService.restore(bundle, restoreOpts);
|
||||
|
||||
if (result.errors.length > 0 && result.serversCreated === 0 && result.profilesCreated === 0 && result.projectsCreated === 0) {
|
||||
if (result.errors.length > 0 && result.serversCreated === 0 && result.secretsCreated === 0 && result.projectsCreated === 0 && result.usersCreated === 0 && result.groupsCreated === 0 && result.rbacCreated === 0) {
|
||||
reply.code(422);
|
||||
}
|
||||
|
||||
|
||||
35
src/mcpd/src/routes/groups.ts
Normal file
35
src/mcpd/src/routes/groups.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { GroupService } from '../services/group.service.js';
|
||||
|
||||
export function registerGroupRoutes(
|
||||
app: FastifyInstance,
|
||||
service: GroupService,
|
||||
): void {
|
||||
app.get('/api/v1/groups', async () => {
|
||||
return service.list();
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/v1/groups/:id', async (request) => {
|
||||
// Try by ID first, fall back to name lookup
|
||||
try {
|
||||
return await service.getById(request.params.id);
|
||||
} catch {
|
||||
return service.getByName(request.params.id);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/v1/groups', async (request, reply) => {
|
||||
const group = await service.create(request.body);
|
||||
reply.code(201);
|
||||
return group;
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/v1/groups/:id', async (request) => {
|
||||
return service.update(request.params.id, request.body);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/groups/:id', async (request, reply) => {
|
||||
await service.delete(request.params.id);
|
||||
reply.code(204);
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
export { registerHealthRoutes } from './health.js';
|
||||
export type { HealthDeps } from './health.js';
|
||||
export { registerMcpServerRoutes } from './mcp-servers.js';
|
||||
export { registerMcpProfileRoutes } from './mcp-profiles.js';
|
||||
export { registerSecretRoutes } from './secrets.js';
|
||||
export { registerProjectRoutes } from './projects.js';
|
||||
export { registerInstanceRoutes } from './instances.js';
|
||||
export { registerAuditLogRoutes } from './audit-logs.js';
|
||||
@@ -13,3 +13,7 @@ export { registerAuthRoutes } from './auth.js';
|
||||
export type { AuthRouteDeps } from './auth.js';
|
||||
export { registerMcpProxyRoutes } from './mcp-proxy.js';
|
||||
export type { McpProxyRouteDeps } from './mcp-proxy.js';
|
||||
export { registerTemplateRoutes } from './templates.js';
|
||||
export { registerRbacRoutes } from './rbac-definitions.js';
|
||||
export { registerUserRoutes } from './users.js';
|
||||
export { registerGroupRoutes } from './groups.js';
|
||||
|
||||
@@ -10,40 +10,17 @@ export function registerInstanceRoutes(app: FastifyInstance, service: InstanceSe
|
||||
return service.getById(request.params.id);
|
||||
});
|
||||
|
||||
app.post<{ Body: { serverId: string; env?: Record<string, string>; hostPort?: number } }>(
|
||||
'/api/v1/instances',
|
||||
async (request, reply) => {
|
||||
const { serverId } = request.body;
|
||||
const opts: { env?: Record<string, string>; hostPort?: number } = {};
|
||||
if (request.body.env) {
|
||||
opts.env = request.body.env;
|
||||
}
|
||||
if (request.body.hostPort !== undefined) {
|
||||
opts.hostPort = request.body.hostPort;
|
||||
}
|
||||
const instance = await service.start(serverId, opts);
|
||||
reply.code(201);
|
||||
return instance;
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string } }>('/api/v1/instances/:id/stop', async (request) => {
|
||||
return service.stop(request.params.id);
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string } }>('/api/v1/instances/:id/restart', async (request) => {
|
||||
return service.restart(request.params.id);
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/instances/:id', async (request, reply) => {
|
||||
const { serverId } = await service.remove(request.params.id);
|
||||
// Reconcile: server will auto-create a replacement if replicas > 0
|
||||
await service.reconcile(serverId);
|
||||
reply.code(204);
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/v1/instances/:id/inspect', async (request) => {
|
||||
return service.inspect(request.params.id);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/instances/:id', async (request, reply) => {
|
||||
await service.remove(request.params.id);
|
||||
reply.code(204);
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string }; Querystring: { tail?: string } }>(
|
||||
'/api/v1/instances/:id/logs',
|
||||
async (request) => {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { McpProfileService } from '../services/mcp-profile.service.js';
|
||||
|
||||
export function registerMcpProfileRoutes(app: FastifyInstance, service: McpProfileService): void {
|
||||
app.get<{ Querystring: { serverId?: string } }>('/api/v1/profiles', async (request) => {
|
||||
return service.list(request.query.serverId);
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/v1/profiles/:id', async (request) => {
|
||||
return service.getById(request.params.id);
|
||||
});
|
||||
|
||||
app.post('/api/v1/profiles', async (request, reply) => {
|
||||
const profile = await service.create(request.body);
|
||||
reply.code(201);
|
||||
return profile;
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/v1/profiles/:id', async (request) => {
|
||||
return service.update(request.params.id, request.body);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/profiles/:id', async (request, reply) => {
|
||||
await service.delete(request.params.id);
|
||||
reply.code(204);
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { McpServerService } from '../services/mcp-server.service.js';
|
||||
import type { InstanceService } from '../services/instance.service.js';
|
||||
|
||||
export function registerMcpServerRoutes(app: FastifyInstance, service: McpServerService): void {
|
||||
export function registerMcpServerRoutes(
|
||||
app: FastifyInstance,
|
||||
service: McpServerService,
|
||||
instanceService: InstanceService,
|
||||
): void {
|
||||
app.get('/api/v1/servers', async () => {
|
||||
return service.list();
|
||||
});
|
||||
@@ -12,12 +17,17 @@ export function registerMcpServerRoutes(app: FastifyInstance, service: McpServer
|
||||
|
||||
app.post('/api/v1/servers', async (request, reply) => {
|
||||
const server = await service.create(request.body);
|
||||
// Auto-reconcile: create instances to match replicas
|
||||
await instanceService.reconcile(server.id);
|
||||
reply.code(201);
|
||||
return server;
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/v1/servers/:id', async (request) => {
|
||||
return service.update(request.params.id, request.body);
|
||||
const server = await service.update(request.params.id, request.body);
|
||||
// Re-reconcile after update (replicas may have changed)
|
||||
await instanceService.reconcile(server.id);
|
||||
return server;
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/servers/:id', async (request, reply) => {
|
||||
|
||||
@@ -2,13 +2,13 @@ import type { FastifyInstance } from 'fastify';
|
||||
import type { ProjectService } from '../services/project.service.js';
|
||||
|
||||
export function registerProjectRoutes(app: FastifyInstance, service: ProjectService): void {
|
||||
app.get('/api/v1/projects', async (request) => {
|
||||
// If authenticated, filter by owner; otherwise list all
|
||||
return service.list(request.userId);
|
||||
app.get('/api/v1/projects', async () => {
|
||||
// RBAC preSerialization hook handles access filtering
|
||||
return service.list();
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => {
|
||||
return service.getById(request.params.id);
|
||||
return service.resolveAndGet(request.params.id);
|
||||
});
|
||||
|
||||
app.post('/api/v1/projects', async (request, reply) => {
|
||||
@@ -19,25 +19,51 @@ export function registerProjectRoutes(app: FastifyInstance, service: ProjectServ
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => {
|
||||
return service.update(request.params.id, request.body);
|
||||
const project = await service.resolveAndGet(request.params.id);
|
||||
return service.update(project.id, request.body);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/projects/:id', async (request, reply) => {
|
||||
await service.delete(request.params.id);
|
||||
const project = await service.resolveAndGet(request.params.id);
|
||||
await service.delete(project.id);
|
||||
reply.code(204);
|
||||
});
|
||||
|
||||
// Profile associations
|
||||
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/profiles', async (request) => {
|
||||
return service.getProfiles(request.params.id);
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/v1/projects/:id/profiles', async (request) => {
|
||||
return service.setProfiles(request.params.id, request.body);
|
||||
});
|
||||
|
||||
// MCP config generation
|
||||
// Generate .mcp.json for a project
|
||||
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/mcp-config', async (request) => {
|
||||
return service.getMcpConfig(request.params.id);
|
||||
return service.generateMcpConfig(request.params.id);
|
||||
});
|
||||
|
||||
// Attach a server to a project
|
||||
app.post<{ Params: { id: string }; Body: { server: string } }>('/api/v1/projects/:id/servers', async (request) => {
|
||||
const body = request.body as { server?: string };
|
||||
if (!body.server) {
|
||||
throw Object.assign(new Error('Missing "server" in request body'), { statusCode: 400 });
|
||||
}
|
||||
return service.addServer(request.params.id, body.server);
|
||||
});
|
||||
|
||||
// Detach a server from a project
|
||||
app.delete<{ Params: { id: string; serverName: string } }>('/api/v1/projects/:id/servers/:serverName', async (request, reply) => {
|
||||
await service.removeServer(request.params.id, request.params.serverName);
|
||||
reply.code(204);
|
||||
});
|
||||
|
||||
// List servers in a project (for mcplocal discovery)
|
||||
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/servers', async (request) => {
|
||||
const project = await service.resolveAndGet(request.params.id);
|
||||
return project.servers.map((ps) => ps.server);
|
||||
});
|
||||
|
||||
// Get project instructions for LLM (prompt + server list)
|
||||
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/instructions', async (request) => {
|
||||
const project = await service.resolveAndGet(request.params.id);
|
||||
return {
|
||||
prompt: project.prompt,
|
||||
servers: project.servers.map((ps) => ({
|
||||
name: (ps.server as Record<string, unknown>).name as string,
|
||||
description: (ps.server as Record<string, unknown>).description as string,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
86
src/mcpd/src/routes/prompts.ts
Normal file
86
src/mcpd/src/routes/prompts.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { PromptService } from '../services/prompt.service.js';
|
||||
import type { IProjectRepository } from '../repositories/project.repository.js';
|
||||
|
||||
export function registerPromptRoutes(
|
||||
app: FastifyInstance,
|
||||
service: PromptService,
|
||||
projectRepo: IProjectRepository,
|
||||
): void {
|
||||
// ── Prompts (approved) ──
|
||||
|
||||
app.get('/api/v1/prompts', async () => {
|
||||
return service.listPrompts();
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/v1/prompts/:id', async (request) => {
|
||||
return service.getPrompt(request.params.id);
|
||||
});
|
||||
|
||||
app.post('/api/v1/prompts', async (request, reply) => {
|
||||
const prompt = await service.createPrompt(request.body);
|
||||
reply.code(201);
|
||||
return prompt;
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/v1/prompts/:id', async (request) => {
|
||||
return service.updatePrompt(request.params.id, request.body);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/prompts/:id', async (request, reply) => {
|
||||
await service.deletePrompt(request.params.id);
|
||||
reply.code(204);
|
||||
});
|
||||
|
||||
// ── Prompt Requests (pending proposals) ──
|
||||
|
||||
app.get('/api/v1/promptrequests', async () => {
|
||||
return service.listPromptRequests();
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/v1/promptrequests/:id', async (request) => {
|
||||
return service.getPromptRequest(request.params.id);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/promptrequests/:id', async (request, reply) => {
|
||||
await service.deletePromptRequest(request.params.id);
|
||||
reply.code(204);
|
||||
});
|
||||
|
||||
// Approve: atomic delete request → create prompt
|
||||
app.post<{ Params: { id: string } }>('/api/v1/promptrequests/:id/approve', async (request) => {
|
||||
return service.approve(request.params.id);
|
||||
});
|
||||
|
||||
// ── Project-scoped endpoints (for mcplocal) ──
|
||||
|
||||
// Visible prompts: approved + session's pending requests
|
||||
app.get<{ Params: { name: string }; Querystring: { session?: string } }>(
|
||||
'/api/v1/projects/:name/prompts/visible',
|
||||
async (request) => {
|
||||
const project = await projectRepo.findByName(request.params.name);
|
||||
if (!project) {
|
||||
throw Object.assign(new Error(`Project not found: ${request.params.name}`), { statusCode: 404 });
|
||||
}
|
||||
return service.getVisiblePrompts(project.id, request.query.session);
|
||||
},
|
||||
);
|
||||
|
||||
// LLM propose: create a PromptRequest for a project
|
||||
app.post<{ Params: { name: string } }>(
|
||||
'/api/v1/projects/:name/promptrequests',
|
||||
async (request, reply) => {
|
||||
const project = await projectRepo.findByName(request.params.name);
|
||||
if (!project) {
|
||||
throw Object.assign(new Error(`Project not found: ${request.params.name}`), { statusCode: 404 });
|
||||
}
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const req = await service.propose({
|
||||
...body,
|
||||
projectId: project.id,
|
||||
});
|
||||
reply.code(201);
|
||||
return req;
|
||||
},
|
||||
);
|
||||
}
|
||||
30
src/mcpd/src/routes/rbac-definitions.ts
Normal file
30
src/mcpd/src/routes/rbac-definitions.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { RbacDefinitionService } from '../services/rbac-definition.service.js';
|
||||
|
||||
export function registerRbacRoutes(
|
||||
app: FastifyInstance,
|
||||
service: RbacDefinitionService,
|
||||
): void {
|
||||
app.get('/api/v1/rbac', async () => {
|
||||
return service.list();
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/v1/rbac/:id', async (request) => {
|
||||
return service.getById(request.params.id);
|
||||
});
|
||||
|
||||
app.post('/api/v1/rbac', async (request, reply) => {
|
||||
const def = await service.create(request.body);
|
||||
reply.code(201);
|
||||
return def;
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/v1/rbac/:id', async (request) => {
|
||||
return service.update(request.params.id, request.body);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/rbac/:id', async (request, reply) => {
|
||||
await service.delete(request.params.id);
|
||||
reply.code(204);
|
||||
});
|
||||
}
|
||||
30
src/mcpd/src/routes/secrets.ts
Normal file
30
src/mcpd/src/routes/secrets.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { SecretService } from '../services/secret.service.js';
|
||||
|
||||
export function registerSecretRoutes(
|
||||
app: FastifyInstance,
|
||||
service: SecretService,
|
||||
): void {
|
||||
app.get('/api/v1/secrets', async () => {
|
||||
return service.list();
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/v1/secrets/:id', async (request) => {
|
||||
return service.getById(request.params.id);
|
||||
});
|
||||
|
||||
app.post('/api/v1/secrets', async (request, reply) => {
|
||||
const secret = await service.create(request.body);
|
||||
reply.code(201);
|
||||
return secret;
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/v1/secrets/:id', async (request) => {
|
||||
return service.update(request.params.id, request.body);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/secrets/:id', async (request, reply) => {
|
||||
await service.delete(request.params.id);
|
||||
reply.code(204);
|
||||
});
|
||||
}
|
||||
31
src/mcpd/src/routes/templates.ts
Normal file
31
src/mcpd/src/routes/templates.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { TemplateService } from '../services/template.service.js';
|
||||
|
||||
export function registerTemplateRoutes(
|
||||
app: FastifyInstance,
|
||||
service: TemplateService,
|
||||
): void {
|
||||
app.get<{ Querystring: { name?: string } }>('/api/v1/templates', async (request) => {
|
||||
const namePattern = request.query.name;
|
||||
return service.list(namePattern);
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/v1/templates/:id', async (request) => {
|
||||
return service.getById(request.params.id);
|
||||
});
|
||||
|
||||
app.post('/api/v1/templates', async (request, reply) => {
|
||||
const template = await service.create(request.body);
|
||||
reply.code(201);
|
||||
return template;
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/v1/templates/:id', async (request) => {
|
||||
return service.update(request.params.id, request.body);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/templates/:id', async (request, reply) => {
|
||||
await service.delete(request.params.id);
|
||||
reply.code(204);
|
||||
});
|
||||
}
|
||||
31
src/mcpd/src/routes/users.ts
Normal file
31
src/mcpd/src/routes/users.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { UserService } from '../services/user.service.js';
|
||||
|
||||
export function registerUserRoutes(
|
||||
app: FastifyInstance,
|
||||
service: UserService,
|
||||
): void {
|
||||
app.get('/api/v1/users', async () => {
|
||||
return service.list();
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/v1/users/:id', async (request) => {
|
||||
// Support lookup by email (contains @) or by id
|
||||
const idOrEmail = request.params.id;
|
||||
if (idOrEmail.includes('@')) {
|
||||
return service.getByEmail(idOrEmail);
|
||||
}
|
||||
return service.getById(idOrEmail);
|
||||
});
|
||||
|
||||
app.post('/api/v1/users', async (request, reply) => {
|
||||
const user = await service.create(request.body);
|
||||
reply.code(201);
|
||||
return user;
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/v1/users/:id', async (_request, reply) => {
|
||||
await service.delete(_request.params.id);
|
||||
reply.code(204);
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,45 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { seedMcpServers } from '@mcpctl/db';
|
||||
import yaml from 'js-yaml';
|
||||
import { seedTemplates } from '@mcpctl/db';
|
||||
import type { SeedTemplate } from '@mcpctl/db';
|
||||
|
||||
function loadTemplatesFromDir(dir: string): SeedTemplate[] {
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(dir).filter((f) => f.endsWith('.yaml') || f.endsWith('.yml'));
|
||||
} catch {
|
||||
console.warn(`Templates directory not found: ${dir}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const templates: SeedTemplate[] = [];
|
||||
for (const file of files) {
|
||||
const content = readFileSync(join(dir, file), 'utf-8');
|
||||
const parsed = yaml.load(content) as SeedTemplate;
|
||||
if (parsed?.name) {
|
||||
templates.push({
|
||||
...parsed,
|
||||
transport: parsed.transport ?? 'STDIO',
|
||||
version: parsed.version ?? '1.0.0',
|
||||
description: parsed.description ?? '',
|
||||
...(parsed.healthCheck ? { healthCheck: parsed.healthCheck } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return templates;
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
const count = await seedMcpServers(prisma);
|
||||
console.log(`Seeded ${count} MCP servers`);
|
||||
// Look for templates in common locations
|
||||
const templatesDir = process.env.TEMPLATES_DIR ?? 'templates';
|
||||
const templates = loadTemplatesFromDir(templatesDir);
|
||||
const count = await seedTemplates(prisma, templates);
|
||||
console.log(`Seeded ${count} templates from ${templatesDir}`);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
@@ -63,4 +63,32 @@ export class AuthService {
|
||||
}
|
||||
return { userId: session.userId, expiresAt: session.expiresAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session for a user by email without requiring their password.
|
||||
* Used for admin impersonation.
|
||||
*/
|
||||
async impersonate(email: string): Promise<LoginResult> {
|
||||
const user = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (user === null) {
|
||||
throw new AuthenticationError('User not found');
|
||||
}
|
||||
|
||||
const token = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
|
||||
|
||||
await this.prisma.session.create({
|
||||
data: {
|
||||
token,
|
||||
userId: user.id,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
expiresAt,
|
||||
user: { id: user.id, email: user.email, role: user.role },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { IMcpServerRepository, IMcpProfileRepository } from '../../repositories/interfaces.js';
|
||||
import type { IMcpServerRepository, ISecretRepository } from '../../repositories/interfaces.js';
|
||||
import type { IProjectRepository } from '../../repositories/project.repository.js';
|
||||
import type { IUserRepository } from '../../repositories/user.repository.js';
|
||||
import type { IGroupRepository } from '../../repositories/group.repository.js';
|
||||
import type { IRbacDefinitionRepository } from '../../repositories/rbac-definition.repository.js';
|
||||
import { encrypt, isSensitiveKey } from './crypto.js';
|
||||
import type { EncryptedPayload } from './crypto.js';
|
||||
import { APP_VERSION } from '@mcpctl/shared';
|
||||
@@ -10,8 +13,11 @@ export interface BackupBundle {
|
||||
createdAt: string;
|
||||
encrypted: boolean;
|
||||
servers: BackupServer[];
|
||||
profiles: BackupProfile[];
|
||||
secrets: BackupSecret[];
|
||||
projects: BackupProject[];
|
||||
users?: BackupUser[];
|
||||
groups?: BackupGroup[];
|
||||
rbacBindings?: BackupRbacBinding[];
|
||||
encryptedSecrets?: EncryptedPayload;
|
||||
}
|
||||
|
||||
@@ -22,40 +28,66 @@ export interface BackupServer {
|
||||
dockerImage: string | null;
|
||||
transport: string;
|
||||
repositoryUrl: string | null;
|
||||
envTemplate: unknown;
|
||||
env: unknown;
|
||||
}
|
||||
|
||||
export interface BackupProfile {
|
||||
export interface BackupSecret {
|
||||
name: string;
|
||||
serverName: string;
|
||||
permissions: unknown;
|
||||
envOverrides: unknown;
|
||||
data: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface BackupProject {
|
||||
name: string;
|
||||
description: string;
|
||||
profileNames: string[];
|
||||
proxyMode?: string;
|
||||
llmProvider?: string | null;
|
||||
llmModel?: string | null;
|
||||
serverNames?: string[];
|
||||
}
|
||||
|
||||
export interface BackupUser {
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
provider: string | null;
|
||||
}
|
||||
|
||||
export interface BackupGroup {
|
||||
name: string;
|
||||
description: string;
|
||||
memberEmails: string[];
|
||||
}
|
||||
|
||||
export interface BackupRbacBinding {
|
||||
name: string;
|
||||
subjects: unknown;
|
||||
roleBindings: unknown;
|
||||
}
|
||||
|
||||
export interface BackupOptions {
|
||||
password?: string;
|
||||
resources?: Array<'servers' | 'profiles' | 'projects'>;
|
||||
resources?: Array<'servers' | 'secrets' | 'projects' | 'users' | 'groups' | 'rbac'>;
|
||||
}
|
||||
|
||||
export class BackupService {
|
||||
constructor(
|
||||
private serverRepo: IMcpServerRepository,
|
||||
private profileRepo: IMcpProfileRepository,
|
||||
private projectRepo: IProjectRepository,
|
||||
private secretRepo: ISecretRepository,
|
||||
private userRepo?: IUserRepository,
|
||||
private groupRepo?: IGroupRepository,
|
||||
private rbacRepo?: IRbacDefinitionRepository,
|
||||
) {}
|
||||
|
||||
async createBackup(options?: BackupOptions): Promise<BackupBundle> {
|
||||
const resources = options?.resources ?? ['servers', 'profiles', 'projects'];
|
||||
const resources = options?.resources ?? ['servers', 'secrets', 'projects', 'users', 'groups', 'rbac'];
|
||||
|
||||
let servers: BackupServer[] = [];
|
||||
let profiles: BackupProfile[] = [];
|
||||
let secrets: BackupSecret[] = [];
|
||||
let projects: BackupProject[] = [];
|
||||
let users: BackupUser[] = [];
|
||||
let groups: BackupGroup[] = [];
|
||||
let rbacBindings: BackupRbacBinding[] = [];
|
||||
|
||||
if (resources.includes('servers')) {
|
||||
const allServers = await this.serverRepo.findAll();
|
||||
@@ -66,44 +98,56 @@ export class BackupService {
|
||||
dockerImage: s.dockerImage,
|
||||
transport: s.transport,
|
||||
repositoryUrl: s.repositoryUrl,
|
||||
envTemplate: s.envTemplate,
|
||||
env: s.env,
|
||||
}));
|
||||
}
|
||||
|
||||
if (resources.includes('profiles')) {
|
||||
const allProfiles = await this.profileRepo.findAll();
|
||||
const serverMap = new Map<string, string>();
|
||||
const allServers = await this.serverRepo.findAll();
|
||||
for (const s of allServers) {
|
||||
serverMap.set(s.id, s.name);
|
||||
}
|
||||
|
||||
profiles = allProfiles.map((p) => ({
|
||||
name: p.name,
|
||||
serverName: serverMap.get(p.serverId) ?? p.serverId,
|
||||
permissions: p.permissions,
|
||||
envOverrides: p.envOverrides,
|
||||
if (resources.includes('secrets')) {
|
||||
const allSecrets = await this.secretRepo.findAll();
|
||||
secrets = allSecrets.map((s) => ({
|
||||
name: s.name,
|
||||
data: s.data as Record<string, string>,
|
||||
}));
|
||||
}
|
||||
|
||||
if (resources.includes('projects')) {
|
||||
const allProjects = await this.projectRepo.findAll();
|
||||
const allProfiles = await this.profileRepo.findAll();
|
||||
const profileMap = new Map<string, string>();
|
||||
for (const p of allProfiles) {
|
||||
profileMap.set(p.id, p.name);
|
||||
}
|
||||
projects = allProjects.map((proj) => ({
|
||||
name: proj.name,
|
||||
description: proj.description,
|
||||
proxyMode: proj.proxyMode,
|
||||
llmProvider: proj.llmProvider,
|
||||
llmModel: proj.llmModel,
|
||||
serverNames: proj.servers.map((ps) => ps.server.name),
|
||||
}));
|
||||
}
|
||||
|
||||
projects = await Promise.all(
|
||||
allProjects.map(async (proj) => {
|
||||
const profileIds = await this.projectRepo.getProfileIds(proj.id);
|
||||
return {
|
||||
name: proj.name,
|
||||
description: proj.description,
|
||||
profileNames: profileIds.map((id) => profileMap.get(id) ?? id),
|
||||
};
|
||||
}),
|
||||
);
|
||||
if (resources.includes('users') && this.userRepo) {
|
||||
const allUsers = await this.userRepo.findAll();
|
||||
users = allUsers.map((u) => ({
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
role: u.role,
|
||||
provider: u.provider,
|
||||
}));
|
||||
}
|
||||
|
||||
if (resources.includes('groups') && this.groupRepo) {
|
||||
const allGroups = await this.groupRepo.findAll();
|
||||
groups = allGroups.map((g) => ({
|
||||
name: g.name,
|
||||
description: g.description,
|
||||
memberEmails: g.members.map((m) => m.user.email),
|
||||
}));
|
||||
}
|
||||
|
||||
if (resources.includes('rbac') && this.rbacRepo) {
|
||||
const allRbac = await this.rbacRepo.findAll();
|
||||
rbacBindings = allRbac.map((r) => ({
|
||||
name: r.name,
|
||||
subjects: r.subjects,
|
||||
roleBindings: r.roleBindings,
|
||||
}));
|
||||
}
|
||||
|
||||
const bundle: BackupBundle = {
|
||||
@@ -112,29 +156,29 @@ export class BackupService {
|
||||
createdAt: new Date().toISOString(),
|
||||
encrypted: false,
|
||||
servers,
|
||||
profiles,
|
||||
secrets,
|
||||
projects,
|
||||
users,
|
||||
groups,
|
||||
rbacBindings,
|
||||
};
|
||||
|
||||
if (options?.password) {
|
||||
// Collect sensitive values and encrypt them
|
||||
const secrets: Record<string, string> = {};
|
||||
for (const profile of profiles) {
|
||||
const overrides = profile.envOverrides as Record<string, string> | null;
|
||||
if (overrides) {
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (isSensitiveKey(key)) {
|
||||
const secretKey = `profile:${profile.name}:${key}`;
|
||||
secrets[secretKey] = value;
|
||||
(overrides as Record<string, string>)[key] = `__ENCRYPTED:${secretKey}__`;
|
||||
}
|
||||
if (options?.password && secrets.length > 0) {
|
||||
// Collect sensitive values from secrets and encrypt them
|
||||
const sensitiveData: Record<string, string> = {};
|
||||
for (const secret of secrets) {
|
||||
for (const [key, value] of Object.entries(secret.data)) {
|
||||
if (isSensitiveKey(key)) {
|
||||
const secretKey = `secret:${secret.name}:${key}`;
|
||||
sensitiveData[secretKey] = value;
|
||||
secret.data[key] = `__ENCRYPTED:${secretKey}__`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(secrets).length > 0) {
|
||||
if (Object.keys(sensitiveData).length > 0) {
|
||||
bundle.encrypted = true;
|
||||
bundle.encryptedSecrets = encrypt(JSON.stringify(secrets), options.password);
|
||||
bundle.encryptedSecrets = encrypt(JSON.stringify(sensitiveData), options.password);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { BackupService } from './backup-service.js';
|
||||
export type { BackupBundle, BackupServer, BackupProfile, BackupProject, BackupOptions } from './backup-service.js';
|
||||
export type { BackupBundle, BackupServer, BackupSecret, BackupProject, BackupOptions } from './backup-service.js';
|
||||
export { RestoreService } from './restore-service.js';
|
||||
export type { RestoreOptions, RestoreResult, ConflictStrategy } from './restore-service.js';
|
||||
export { encrypt, decrypt, isSensitiveKey } from './crypto.js';
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { IMcpServerRepository, IMcpProfileRepository } from '../../repositories/interfaces.js';
|
||||
import type { IMcpServerRepository, ISecretRepository } from '../../repositories/interfaces.js';
|
||||
import type { IProjectRepository } from '../../repositories/project.repository.js';
|
||||
import type { IUserRepository } from '../../repositories/user.repository.js';
|
||||
import type { IGroupRepository } from '../../repositories/group.repository.js';
|
||||
import type { IRbacDefinitionRepository } from '../../repositories/rbac-definition.repository.js';
|
||||
import type { RbacRoleBinding } from '../../validation/rbac-definition.schema.js';
|
||||
import { decrypt } from './crypto.js';
|
||||
import type { BackupBundle } from './backup-service.js';
|
||||
|
||||
@@ -13,18 +17,27 @@ export interface RestoreOptions {
|
||||
export interface RestoreResult {
|
||||
serversCreated: number;
|
||||
serversSkipped: number;
|
||||
profilesCreated: number;
|
||||
profilesSkipped: number;
|
||||
secretsCreated: number;
|
||||
secretsSkipped: number;
|
||||
projectsCreated: number;
|
||||
projectsSkipped: number;
|
||||
usersCreated: number;
|
||||
usersSkipped: number;
|
||||
groupsCreated: number;
|
||||
groupsSkipped: number;
|
||||
rbacCreated: number;
|
||||
rbacSkipped: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export class RestoreService {
|
||||
constructor(
|
||||
private serverRepo: IMcpServerRepository,
|
||||
private profileRepo: IMcpProfileRepository,
|
||||
private projectRepo: IProjectRepository,
|
||||
private secretRepo: ISecretRepository,
|
||||
private userRepo?: IUserRepository,
|
||||
private groupRepo?: IGroupRepository,
|
||||
private rbacRepo?: IRbacDefinitionRepository,
|
||||
) {}
|
||||
|
||||
validateBundle(bundle: unknown): bundle is BackupBundle {
|
||||
@@ -33,9 +46,10 @@ export class RestoreService {
|
||||
return (
|
||||
typeof b['version'] === 'string' &&
|
||||
Array.isArray(b['servers']) &&
|
||||
Array.isArray(b['profiles']) &&
|
||||
Array.isArray(b['secrets']) &&
|
||||
Array.isArray(b['projects'])
|
||||
);
|
||||
// users, groups, rbacBindings are optional for backwards compatibility
|
||||
}
|
||||
|
||||
async restore(bundle: BackupBundle, options?: RestoreOptions): Promise<RestoreResult> {
|
||||
@@ -43,46 +57,79 @@ export class RestoreService {
|
||||
const result: RestoreResult = {
|
||||
serversCreated: 0,
|
||||
serversSkipped: 0,
|
||||
profilesCreated: 0,
|
||||
profilesSkipped: 0,
|
||||
secretsCreated: 0,
|
||||
secretsSkipped: 0,
|
||||
projectsCreated: 0,
|
||||
projectsSkipped: 0,
|
||||
usersCreated: 0,
|
||||
usersSkipped: 0,
|
||||
groupsCreated: 0,
|
||||
groupsSkipped: 0,
|
||||
rbacCreated: 0,
|
||||
rbacSkipped: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
// Decrypt secrets if encrypted
|
||||
let secrets: Record<string, string> = {};
|
||||
let decryptedSecrets: Record<string, string> = {};
|
||||
if (bundle.encrypted && bundle.encryptedSecrets) {
|
||||
if (!options?.password) {
|
||||
result.errors.push('Backup is encrypted but no password provided');
|
||||
return result;
|
||||
}
|
||||
try {
|
||||
secrets = JSON.parse(decrypt(bundle.encryptedSecrets, options.password)) as Record<string, string>;
|
||||
decryptedSecrets = JSON.parse(decrypt(bundle.encryptedSecrets, options.password)) as Record<string, string>;
|
||||
} catch {
|
||||
result.errors.push('Failed to decrypt backup - incorrect password or corrupted data');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore secrets into profile envOverrides
|
||||
for (const profile of bundle.profiles) {
|
||||
const overrides = profile.envOverrides as Record<string, string> | null;
|
||||
if (overrides) {
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (typeof value === 'string' && value.startsWith('__ENCRYPTED:') && value.endsWith('__')) {
|
||||
const secretKey = value.slice(12, -2);
|
||||
const decrypted = secrets[secretKey];
|
||||
if (decrypted !== undefined) {
|
||||
overrides[key] = decrypted;
|
||||
}
|
||||
// Restore encrypted values into secret data
|
||||
for (const secret of bundle.secrets) {
|
||||
for (const [key, value] of Object.entries(secret.data)) {
|
||||
if (typeof value === 'string' && value.startsWith('__ENCRYPTED:') && value.endsWith('__')) {
|
||||
const secretKey = value.slice(12, -2);
|
||||
const decrypted = decryptedSecrets[secretKey];
|
||||
if (decrypted !== undefined) {
|
||||
secret.data[key] = decrypted;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore order: secrets → servers → users → groups → projects → rbacBindings
|
||||
|
||||
// Restore secrets
|
||||
for (const secret of bundle.secrets) {
|
||||
try {
|
||||
const existing = await this.secretRepo.findByName(secret.name);
|
||||
if (existing) {
|
||||
if (strategy === 'fail') {
|
||||
result.errors.push(`Secret "${secret.name}" already exists`);
|
||||
return result;
|
||||
}
|
||||
if (strategy === 'skip') {
|
||||
result.secretsSkipped++;
|
||||
continue;
|
||||
}
|
||||
// overwrite
|
||||
await this.secretRepo.update(existing.id, { data: secret.data });
|
||||
result.secretsCreated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.secretRepo.create({
|
||||
name: secret.name,
|
||||
data: secret.data,
|
||||
});
|
||||
result.secretsCreated++;
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to restore secret "${secret.name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore servers
|
||||
const serverNameToId = new Map<string, string>();
|
||||
for (const server of bundle.servers) {
|
||||
try {
|
||||
const existing = await this.serverRepo.findByName(server.name);
|
||||
@@ -93,7 +140,6 @@ export class RestoreService {
|
||||
}
|
||||
if (strategy === 'skip') {
|
||||
result.serversSkipped++;
|
||||
serverNameToId.set(server.name, existing.id);
|
||||
continue;
|
||||
}
|
||||
// overwrite
|
||||
@@ -105,7 +151,6 @@ export class RestoreService {
|
||||
if (server.dockerImage) updateData.dockerImage = server.dockerImage;
|
||||
if (server.repositoryUrl) updateData.repositoryUrl = server.repositoryUrl;
|
||||
await this.serverRepo.update(existing.id, updateData);
|
||||
serverNameToId.set(server.name, existing.id);
|
||||
result.serversCreated++;
|
||||
continue;
|
||||
}
|
||||
@@ -114,70 +159,88 @@ export class RestoreService {
|
||||
name: server.name,
|
||||
description: server.description,
|
||||
transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP',
|
||||
envTemplate: (server.envTemplate ?? []) as Array<{ name: string; description: string; isSecret: boolean }>,
|
||||
replicas: (server as { replicas?: number }).replicas ?? 1,
|
||||
env: (server.env ?? []) as Array<{ name: string; value?: string; valueFrom?: { secretRef: { name: string; key: string } } }>,
|
||||
};
|
||||
if (server.packageName) createData.packageName = server.packageName;
|
||||
if (server.dockerImage) createData.dockerImage = server.dockerImage;
|
||||
if (server.repositoryUrl) createData.repositoryUrl = server.repositoryUrl;
|
||||
const created = await this.serverRepo.create(createData);
|
||||
serverNameToId.set(server.name, created.id);
|
||||
await this.serverRepo.create(createData);
|
||||
result.serversCreated++;
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to restore server "${server.name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore profiles
|
||||
const profileNameToId = new Map<string, string>();
|
||||
for (const profile of bundle.profiles) {
|
||||
try {
|
||||
const serverId = serverNameToId.get(profile.serverName);
|
||||
if (!serverId) {
|
||||
// Try to find server by name in DB
|
||||
const server = await this.serverRepo.findByName(profile.serverName);
|
||||
if (!server) {
|
||||
result.errors.push(`Profile "${profile.name}" references unknown server "${profile.serverName}"`);
|
||||
// Restore users
|
||||
if (bundle.users && this.userRepo) {
|
||||
for (const user of bundle.users) {
|
||||
try {
|
||||
const existing = await this.userRepo.findByEmail(user.email);
|
||||
if (existing) {
|
||||
if (strategy === 'fail') {
|
||||
result.errors.push(`User "${user.email}" already exists`);
|
||||
return result;
|
||||
}
|
||||
result.usersSkipped++;
|
||||
continue;
|
||||
}
|
||||
serverNameToId.set(profile.serverName, server.id);
|
||||
}
|
||||
|
||||
const sid = serverNameToId.get(profile.serverName)!;
|
||||
const existing = await this.profileRepo.findByServerAndName(sid, profile.name);
|
||||
if (existing) {
|
||||
if (strategy === 'fail') {
|
||||
result.errors.push(`Profile "${profile.name}" already exists for server "${profile.serverName}"`);
|
||||
return result;
|
||||
}
|
||||
if (strategy === 'skip') {
|
||||
result.profilesSkipped++;
|
||||
profileNameToId.set(profile.name, existing.id);
|
||||
continue;
|
||||
}
|
||||
// overwrite
|
||||
await this.profileRepo.update(existing.id, {
|
||||
permissions: profile.permissions as string[],
|
||||
envOverrides: profile.envOverrides as Record<string, string>,
|
||||
});
|
||||
profileNameToId.set(profile.name, existing.id);
|
||||
result.profilesCreated++;
|
||||
continue;
|
||||
// Create with placeholder passwordHash — user must reset password
|
||||
const createData: { email: string; passwordHash: string; name?: string; role?: string } = {
|
||||
email: user.email,
|
||||
passwordHash: '__RESTORED_MUST_RESET__',
|
||||
role: user.role,
|
||||
};
|
||||
if (user.name !== null) createData.name = user.name;
|
||||
await this.userRepo.create(createData);
|
||||
result.usersCreated++;
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to restore user "${user.email}": ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
const created = await this.profileRepo.create({
|
||||
name: profile.name,
|
||||
serverId: sid,
|
||||
permissions: profile.permissions as string[],
|
||||
envOverrides: profile.envOverrides as Record<string, string>,
|
||||
});
|
||||
profileNameToId.set(profile.name, created.id);
|
||||
result.profilesCreated++;
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to restore profile "${profile.name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore projects
|
||||
// Restore groups
|
||||
if (bundle.groups && this.groupRepo && this.userRepo) {
|
||||
for (const group of bundle.groups) {
|
||||
try {
|
||||
const existing = await this.groupRepo.findByName(group.name);
|
||||
if (existing) {
|
||||
if (strategy === 'fail') {
|
||||
result.errors.push(`Group "${group.name}" already exists`);
|
||||
return result;
|
||||
}
|
||||
if (strategy === 'skip') {
|
||||
result.groupsSkipped++;
|
||||
continue;
|
||||
}
|
||||
// overwrite: update description and re-set members
|
||||
await this.groupRepo.update(existing.id, { description: group.description });
|
||||
if (group.memberEmails.length > 0) {
|
||||
const memberIds = await this.resolveUserEmails(group.memberEmails);
|
||||
await this.groupRepo.setMembers(existing.id, memberIds);
|
||||
}
|
||||
result.groupsCreated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await this.groupRepo.create({
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
});
|
||||
if (group.memberEmails.length > 0) {
|
||||
const memberIds = await this.resolveUserEmails(group.memberEmails);
|
||||
await this.groupRepo.setMembers(created.id, memberIds);
|
||||
}
|
||||
result.groupsCreated++;
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to restore group "${group.name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore projects (enriched)
|
||||
for (const project of bundle.projects) {
|
||||
try {
|
||||
const existing = await this.projectRepo.findByName(project.name);
|
||||
@@ -190,35 +253,101 @@ export class RestoreService {
|
||||
result.projectsSkipped++;
|
||||
continue;
|
||||
}
|
||||
// overwrite - update and set profiles
|
||||
await this.projectRepo.update(existing.id, { description: project.description });
|
||||
const profileIds = project.profileNames
|
||||
.map((name) => profileNameToId.get(name))
|
||||
.filter((id): id is string => id !== undefined);
|
||||
if (profileIds.length > 0) {
|
||||
await this.projectRepo.setProfiles(existing.id, profileIds);
|
||||
// overwrite
|
||||
const updateData: Record<string, unknown> = { description: project.description };
|
||||
if (project.proxyMode) updateData['proxyMode'] = project.proxyMode;
|
||||
if (project.llmProvider !== undefined) updateData['llmProvider'] = project.llmProvider;
|
||||
if (project.llmModel !== undefined) updateData['llmModel'] = project.llmModel;
|
||||
await this.projectRepo.update(existing.id, updateData);
|
||||
|
||||
// Re-link servers
|
||||
if (project.serverNames && project.serverNames.length > 0) {
|
||||
const serverIds = await this.resolveServerNames(project.serverNames);
|
||||
await this.projectRepo.setServers(existing.id, serverIds);
|
||||
}
|
||||
|
||||
result.projectsCreated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const created = await this.projectRepo.create({
|
||||
const projectCreateData: { name: string; description: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string } = {
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
ownerId: 'system',
|
||||
});
|
||||
const profileIds = project.profileNames
|
||||
.map((name) => profileNameToId.get(name))
|
||||
.filter((id): id is string => id !== undefined);
|
||||
if (profileIds.length > 0) {
|
||||
await this.projectRepo.setProfiles(created.id, profileIds);
|
||||
proxyMode: project.proxyMode ?? 'direct',
|
||||
};
|
||||
if (project.llmProvider != null) projectCreateData.llmProvider = project.llmProvider;
|
||||
if (project.llmModel != null) projectCreateData.llmModel = project.llmModel;
|
||||
const created = await this.projectRepo.create(projectCreateData);
|
||||
|
||||
// Link servers
|
||||
if (project.serverNames && project.serverNames.length > 0) {
|
||||
const serverIds = await this.resolveServerNames(project.serverNames);
|
||||
await this.projectRepo.setServers(created.id, serverIds);
|
||||
}
|
||||
|
||||
result.projectsCreated++;
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to restore project "${project.name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore RBAC bindings
|
||||
if (bundle.rbacBindings && this.rbacRepo) {
|
||||
for (const rbac of bundle.rbacBindings) {
|
||||
try {
|
||||
const existing = await this.rbacRepo.findByName(rbac.name);
|
||||
if (existing) {
|
||||
if (strategy === 'fail') {
|
||||
result.errors.push(`RBAC binding "${rbac.name}" already exists`);
|
||||
return result;
|
||||
}
|
||||
if (strategy === 'skip') {
|
||||
result.rbacSkipped++;
|
||||
continue;
|
||||
}
|
||||
// overwrite
|
||||
await this.rbacRepo.update(existing.id, {
|
||||
subjects: rbac.subjects as Array<{ kind: 'User' | 'Group'; name: string }>,
|
||||
roleBindings: rbac.roleBindings as RbacRoleBinding[],
|
||||
});
|
||||
result.rbacCreated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.rbacRepo.create({
|
||||
name: rbac.name,
|
||||
subjects: rbac.subjects as Array<{ kind: 'User' | 'Group'; name: string }>,
|
||||
roleBindings: rbac.roleBindings as RbacRoleBinding[],
|
||||
});
|
||||
result.rbacCreated++;
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to restore RBAC binding "${rbac.name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Resolve email addresses to user IDs via the user repository. */
|
||||
private async resolveUserEmails(emails: string[]): Promise<string[]> {
|
||||
const ids: string[] = [];
|
||||
for (const email of emails) {
|
||||
const user = await this.userRepo!.findByEmail(email);
|
||||
if (user) ids.push(user.id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Resolve server names to server IDs via the server repository. */
|
||||
private async resolveServerNames(names: string[]): Promise<string[]> {
|
||||
const ids: string[] = [];
|
||||
for (const name of names) {
|
||||
const server = await this.serverRepo.findByName(name);
|
||||
if (server) ids.push(server.id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import Docker from 'dockerode';
|
||||
import { PassThrough } from 'node:stream';
|
||||
import type {
|
||||
McpOrchestrator,
|
||||
ContainerSpec,
|
||||
ContainerInfo,
|
||||
ContainerLogs,
|
||||
ExecResult,
|
||||
} from '../orchestrator.js';
|
||||
import { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from '../orchestrator.js';
|
||||
import { DEFAULT_MEMORY_LIMIT } from '../orchestrator.js';
|
||||
|
||||
const MCPCTL_LABEL = 'mcpctl.managed';
|
||||
|
||||
@@ -54,7 +56,7 @@ export class DockerContainerManager implements McpOrchestrator {
|
||||
|
||||
async createContainer(spec: ContainerSpec): Promise<ContainerInfo> {
|
||||
const memoryLimit = spec.memoryLimit ?? DEFAULT_MEMORY_LIMIT;
|
||||
const nanoCpus = spec.nanoCpus ?? DEFAULT_NANO_CPUS;
|
||||
const nanoCpus = spec.nanoCpus;
|
||||
|
||||
const portBindings: Record<string, Array<{ HostPort: string }>> = {};
|
||||
const exposedPorts: Record<string, Record<string, never>> = {};
|
||||
@@ -74,19 +76,27 @@ export class DockerContainerManager implements McpOrchestrator {
|
||||
? Object.entries(spec.env).map(([k, v]) => `${k}=${v}`)
|
||||
: undefined;
|
||||
|
||||
const container = await this.docker.createContainer({
|
||||
const createOpts: Docker.ContainerCreateOptions = {
|
||||
Image: spec.image,
|
||||
name: spec.name,
|
||||
Env: envArr,
|
||||
ExposedPorts: exposedPorts,
|
||||
Labels: labels,
|
||||
// Keep stdin open for STDIO MCP servers (they read from stdin)
|
||||
OpenStdin: true,
|
||||
StdinOnce: false,
|
||||
HostConfig: {
|
||||
PortBindings: portBindings,
|
||||
Memory: memoryLimit,
|
||||
NanoCpus: nanoCpus,
|
||||
...(nanoCpus ? { NanoCpus: nanoCpus } : {}),
|
||||
NetworkMode: spec.network ?? 'bridge',
|
||||
},
|
||||
});
|
||||
};
|
||||
if (spec.command) {
|
||||
createOpts.Cmd = spec.command;
|
||||
}
|
||||
|
||||
const container = await this.docker.createContainer(createOpts);
|
||||
|
||||
await container.start();
|
||||
|
||||
@@ -128,6 +138,19 @@ export class DockerContainerManager implements McpOrchestrator {
|
||||
if (port !== undefined) {
|
||||
result.port = port;
|
||||
}
|
||||
|
||||
// Extract container IP from first non-default network
|
||||
const networks = info.NetworkSettings?.Networks;
|
||||
if (networks) {
|
||||
for (const [, net] of Object.entries(networks)) {
|
||||
const netInfo = net as { IPAddress?: string };
|
||||
if (netInfo.IPAddress) {
|
||||
result.ip = netInfo.IPAddress;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -153,4 +176,67 @@ export class DockerContainerManager implements McpOrchestrator {
|
||||
// For simplicity we return everything as stdout.
|
||||
return { stdout: raw, stderr: '' };
|
||||
}
|
||||
|
||||
async execInContainer(
|
||||
containerId: string,
|
||||
cmd: string[],
|
||||
opts?: { stdin?: string; timeoutMs?: number },
|
||||
): Promise<ExecResult> {
|
||||
const container = this.docker.getContainer(containerId);
|
||||
const hasStdin = opts?.stdin !== undefined;
|
||||
|
||||
const exec = await container.exec({
|
||||
Cmd: cmd,
|
||||
AttachStdin: hasStdin,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
});
|
||||
|
||||
const stream = await exec.start({ hijack: hasStdin, stdin: hasStdin });
|
||||
const timeoutMs = opts?.timeoutMs ?? 30_000;
|
||||
|
||||
return new Promise<ExecResult>((resolve, reject) => {
|
||||
const stdout = new PassThrough();
|
||||
const stderr = new PassThrough();
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
|
||||
stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
|
||||
stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
|
||||
|
||||
this.docker.modem.demuxStream(stream, stdout, stderr);
|
||||
|
||||
if (hasStdin) {
|
||||
stream.write(opts!.stdin);
|
||||
stream.end();
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
stream.destroy();
|
||||
reject(new Error(`Exec timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
stream.on('end', () => {
|
||||
clearTimeout(timer);
|
||||
exec.inspect().then((info) => {
|
||||
resolve({
|
||||
exitCode: (info as { ExitCode: number }).ExitCode,
|
||||
stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
|
||||
stderr: Buffer.concat(stderrChunks).toString('utf-8'),
|
||||
});
|
||||
}).catch((err) => {
|
||||
resolve({
|
||||
exitCode: -1,
|
||||
stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
|
||||
stderr: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
stream.on('error', (err: Error) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
44
src/mcpd/src/services/env-resolver.ts
Normal file
44
src/mcpd/src/services/env-resolver.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { McpServer } from '@prisma/client';
|
||||
import type { ISecretRepository } from '../repositories/interfaces.js';
|
||||
import type { ServerEnvEntry } from '../validation/mcp-server.schema.js';
|
||||
|
||||
/**
|
||||
* Resolve a server's env entries into a flat key-value map.
|
||||
* - Inline `value` entries are used directly.
|
||||
* - `valueFrom.secretRef` entries are looked up from the secret repository.
|
||||
* Throws if a referenced secret or key is missing.
|
||||
*/
|
||||
export async function resolveServerEnv(
|
||||
server: McpServer,
|
||||
secretRepo: ISecretRepository,
|
||||
): Promise<Record<string, string>> {
|
||||
const entries = server.env as ServerEnvEntry[];
|
||||
if (!entries || entries.length === 0) return {};
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
const secretCache = new Map<string, Record<string, string>>();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.value !== undefined) {
|
||||
result[entry.name] = entry.value;
|
||||
} else if (entry.valueFrom?.secretRef) {
|
||||
const { name: secretName, key } = entry.valueFrom.secretRef;
|
||||
|
||||
if (!secretCache.has(secretName)) {
|
||||
const secret = await secretRepo.findByName(secretName);
|
||||
if (!secret) {
|
||||
throw new Error(`Secret '${secretName}' not found (referenced by server '${server.name}' env '${entry.name}')`);
|
||||
}
|
||||
secretCache.set(secretName, secret.data as Record<string, string>);
|
||||
}
|
||||
|
||||
const data = secretCache.get(secretName)!;
|
||||
if (!(key in data)) {
|
||||
throw new Error(`Key '${key}' not found in secret '${secretName}' (referenced by server '${server.name}' env '${entry.name}')`);
|
||||
}
|
||||
result[entry.name] = data[key]!;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
89
src/mcpd/src/services/group.service.ts
Normal file
89
src/mcpd/src/services/group.service.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { GroupWithMembers, IGroupRepository } from '../repositories/group.repository.js';
|
||||
import type { IUserRepository } from '../repositories/user.repository.js';
|
||||
import { CreateGroupSchema, UpdateGroupSchema } from '../validation/group.schema.js';
|
||||
import { NotFoundError, ConflictError } from './mcp-server.service.js';
|
||||
|
||||
export class GroupService {
|
||||
constructor(
|
||||
private readonly groupRepo: IGroupRepository,
|
||||
private readonly userRepo: IUserRepository,
|
||||
) {}
|
||||
|
||||
async list(): Promise<GroupWithMembers[]> {
|
||||
return this.groupRepo.findAll();
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<GroupWithMembers> {
|
||||
const group = await this.groupRepo.findById(id);
|
||||
if (group === null) {
|
||||
throw new NotFoundError(`Group not found: ${id}`);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
async getByName(name: string): Promise<GroupWithMembers> {
|
||||
const group = await this.groupRepo.findByName(name);
|
||||
if (group === null) {
|
||||
throw new NotFoundError(`Group not found: ${name}`);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
async create(input: unknown): Promise<GroupWithMembers> {
|
||||
const data = CreateGroupSchema.parse(input);
|
||||
|
||||
const existing = await this.groupRepo.findByName(data.name);
|
||||
if (existing !== null) {
|
||||
throw new ConflictError(`Group already exists: ${data.name}`);
|
||||
}
|
||||
|
||||
const group = await this.groupRepo.create({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
});
|
||||
|
||||
if (data.members.length > 0) {
|
||||
const userIds = await this.resolveEmails(data.members);
|
||||
await this.groupRepo.setMembers(group.id, userIds);
|
||||
}
|
||||
|
||||
const result = await this.groupRepo.findById(group.id);
|
||||
// Should always exist since we just created it
|
||||
return result!;
|
||||
}
|
||||
|
||||
async update(id: string, input: unknown): Promise<GroupWithMembers> {
|
||||
const data = UpdateGroupSchema.parse(input);
|
||||
|
||||
// Verify exists
|
||||
await this.getById(id);
|
||||
|
||||
if (data.description !== undefined) {
|
||||
await this.groupRepo.update(id, { description: data.description });
|
||||
}
|
||||
|
||||
if (data.members !== undefined) {
|
||||
const userIds = await this.resolveEmails(data.members);
|
||||
await this.groupRepo.setMembers(id, userIds);
|
||||
}
|
||||
|
||||
return this.getById(id);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.getById(id);
|
||||
await this.groupRepo.delete(id);
|
||||
}
|
||||
|
||||
private async resolveEmails(emails: string[]): Promise<string[]> {
|
||||
const userIds: string[] = [];
|
||||
for (const email of emails) {
|
||||
const user = await this.userRepo.findByEmail(email);
|
||||
if (user === null) {
|
||||
throw new NotFoundError(`User not found: ${email}`);
|
||||
}
|
||||
userIds.push(user.id);
|
||||
}
|
||||
return userIds;
|
||||
}
|
||||
}
|
||||
520
src/mcpd/src/services/health-probe.service.ts
Normal file
520
src/mcpd/src/services/health-probe.service.ts
Normal file
@@ -0,0 +1,520 @@
|
||||
import type { McpServer, McpInstance } from '@prisma/client';
|
||||
import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js';
|
||||
import type { McpOrchestrator } from './orchestrator.js';
|
||||
|
||||
export interface HealthCheckSpec {
|
||||
tool: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
intervalSeconds?: number;
|
||||
timeoutSeconds?: number;
|
||||
failureThreshold?: number;
|
||||
}
|
||||
|
||||
export interface ProbeResult {
|
||||
healthy: boolean;
|
||||
latencyMs: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ProbeState {
|
||||
consecutiveFailures: number;
|
||||
lastProbeAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodic health probe runner — calls MCP tools on running instances to verify
|
||||
* they are alive and responsive. Mirrors Kubernetes liveness probe semantics.
|
||||
*
|
||||
* For STDIO servers: runs `docker exec` with a disposable MCP client script
|
||||
* that sends initialize + tool/call via the package binary.
|
||||
*
|
||||
* For SSE/HTTP servers: sends HTTP JSON-RPC directly to the container port.
|
||||
*/
|
||||
export class HealthProbeRunner {
|
||||
private probeStates = new Map<string, ProbeState>();
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(
|
||||
private instanceRepo: IMcpInstanceRepository,
|
||||
private serverRepo: IMcpServerRepository,
|
||||
private orchestrator: McpOrchestrator,
|
||||
private logger?: { info: (msg: string) => void; error: (obj: unknown, msg: string) => void },
|
||||
) {}
|
||||
|
||||
/** Start the periodic probe loop. Runs every `tickIntervalMs` (default 15s). */
|
||||
start(tickIntervalMs = 15_000): void {
|
||||
if (this.timer) return;
|
||||
this.timer = setInterval(() => {
|
||||
this.tick().catch((err) => {
|
||||
this.logger?.error({ err }, 'Health probe tick failed');
|
||||
});
|
||||
}, tickIntervalMs);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Single tick: probe all RUNNING instances that have healthCheck configs and are due. */
|
||||
async tick(): Promise<void> {
|
||||
const instances = await this.instanceRepo.findAll();
|
||||
const running = instances.filter((i) => i.status === 'RUNNING' && i.containerId);
|
||||
|
||||
// Cache servers by ID to avoid repeated lookups
|
||||
const serverCache = new Map<string, McpServer>();
|
||||
|
||||
for (const inst of running) {
|
||||
let server = serverCache.get(inst.serverId);
|
||||
if (!server) {
|
||||
const s = await this.serverRepo.findById(inst.serverId);
|
||||
if (!s) continue;
|
||||
serverCache.set(inst.serverId, s);
|
||||
server = s;
|
||||
}
|
||||
|
||||
const healthCheck = server.healthCheck as HealthCheckSpec | null;
|
||||
if (!healthCheck) continue;
|
||||
|
||||
const intervalMs = (healthCheck.intervalSeconds ?? 60) * 1000;
|
||||
const state = this.probeStates.get(inst.id);
|
||||
const now = Date.now();
|
||||
|
||||
// Skip if not due yet
|
||||
if (state && (now - state.lastProbeAt) < intervalMs) continue;
|
||||
|
||||
await this.probeInstance(inst, server, healthCheck);
|
||||
}
|
||||
|
||||
// Clean up states for instances that no longer exist
|
||||
const activeIds = new Set(running.map((i) => i.id));
|
||||
for (const key of this.probeStates.keys()) {
|
||||
if (!activeIds.has(key)) {
|
||||
this.probeStates.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Probe a single instance and update its health status. */
|
||||
async probeInstance(
|
||||
instance: McpInstance,
|
||||
server: McpServer,
|
||||
healthCheck: HealthCheckSpec,
|
||||
): Promise<ProbeResult> {
|
||||
const timeoutMs = (healthCheck.timeoutSeconds ?? 10) * 1000;
|
||||
const failureThreshold = healthCheck.failureThreshold ?? 3;
|
||||
const now = new Date();
|
||||
const start = Date.now();
|
||||
|
||||
let result: ProbeResult;
|
||||
|
||||
try {
|
||||
if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') {
|
||||
result = await this.probeHttp(instance, server, healthCheck, timeoutMs);
|
||||
} else {
|
||||
result = await this.probeStdio(instance, server, healthCheck, timeoutMs);
|
||||
}
|
||||
} catch (err) {
|
||||
result = {
|
||||
healthy: false,
|
||||
latencyMs: Date.now() - start,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
|
||||
// Update probe state
|
||||
const state = this.probeStates.get(instance.id) ?? { consecutiveFailures: 0, lastProbeAt: 0 };
|
||||
state.lastProbeAt = Date.now();
|
||||
|
||||
if (result.healthy) {
|
||||
state.consecutiveFailures = 0;
|
||||
} else {
|
||||
state.consecutiveFailures++;
|
||||
}
|
||||
this.probeStates.set(instance.id, state);
|
||||
|
||||
// Determine health status
|
||||
const healthStatus = result.healthy
|
||||
? 'healthy'
|
||||
: state.consecutiveFailures >= failureThreshold
|
||||
? 'unhealthy'
|
||||
: 'degraded';
|
||||
|
||||
// Build event
|
||||
const eventType = result.healthy ? 'Normal' : 'Warning';
|
||||
const eventMessage = result.healthy
|
||||
? `Health check passed (${result.latencyMs}ms)`
|
||||
: `Health check failed: ${result.message}`;
|
||||
|
||||
const existingEvents = (instance.events as Array<{ timestamp: string; type: string; message: string }>) ?? [];
|
||||
// Keep last 50 events
|
||||
const events = [
|
||||
...existingEvents.slice(-49),
|
||||
{ timestamp: now.toISOString(), type: eventType, message: eventMessage },
|
||||
];
|
||||
|
||||
// Update instance
|
||||
await this.instanceRepo.updateStatus(instance.id, instance.status as 'RUNNING', {
|
||||
healthStatus,
|
||||
lastHealthCheck: now,
|
||||
events,
|
||||
});
|
||||
|
||||
this.logger?.info(
|
||||
`[health] ${(instance as unknown as { server?: { name: string } }).server?.name ?? instance.serverId}: ${healthStatus} (${result.latencyMs}ms) - ${eventMessage}`,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Probe an HTTP/SSE MCP server by sending a JSON-RPC tool call. */
|
||||
private async probeHttp(
|
||||
instance: McpInstance,
|
||||
server: McpServer,
|
||||
healthCheck: HealthCheckSpec,
|
||||
timeoutMs: number,
|
||||
): Promise<ProbeResult> {
|
||||
if (!instance.containerId) {
|
||||
return { healthy: false, latencyMs: 0, message: 'No container ID' };
|
||||
}
|
||||
|
||||
// Get container IP for internal network communication
|
||||
// (mcpd and MCP containers share the mcp-servers network)
|
||||
const containerInfo = await this.orchestrator.inspectContainer(instance.containerId);
|
||||
const containerPort = (server.containerPort as number | null) ?? 3000;
|
||||
|
||||
let baseUrl: string;
|
||||
if (containerInfo.ip) {
|
||||
baseUrl = `http://${containerInfo.ip}:${containerPort}`;
|
||||
} else if (instance.port) {
|
||||
baseUrl = `http://localhost:${instance.port}`;
|
||||
} else {
|
||||
return { healthy: false, latencyMs: 0, message: 'No container IP or port' };
|
||||
}
|
||||
|
||||
if (server.transport === 'SSE') {
|
||||
return this.probeSse(baseUrl, healthCheck, timeoutMs);
|
||||
}
|
||||
return this.probeStreamableHttp(baseUrl, healthCheck, timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a streamable-http MCP server (POST to root endpoint).
|
||||
*/
|
||||
private async probeStreamableHttp(
|
||||
baseUrl: string,
|
||||
healthCheck: HealthCheckSpec,
|
||||
timeoutMs: number,
|
||||
): Promise<ProbeResult> {
|
||||
const start = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const initResp = await fetch(baseUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'mcpctl-health', version: '0.1.0' } },
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!initResp.ok) {
|
||||
return { healthy: false, latencyMs: Date.now() - start, message: `Initialize HTTP ${initResp.status}` };
|
||||
}
|
||||
|
||||
const sessionId = initResp.headers.get('mcp-session-id');
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream' };
|
||||
if (sessionId) headers['Mcp-Session-Id'] = sessionId;
|
||||
|
||||
await fetch(baseUrl, {
|
||||
method: 'POST', headers,
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const toolResp = await fetch(baseUrl, {
|
||||
method: 'POST', headers,
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0', id: 2, method: 'tools/call',
|
||||
params: { name: healthCheck.tool, arguments: healthCheck.arguments ?? {} },
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const latencyMs = Date.now() - start;
|
||||
|
||||
if (!toolResp.ok) {
|
||||
return { healthy: false, latencyMs, message: `Tool call HTTP ${toolResp.status}` };
|
||||
}
|
||||
|
||||
const body = await toolResp.text();
|
||||
try {
|
||||
const parsed = JSON.parse(body.includes('data: ') ? body.split('data: ')[1]!.split('\n')[0]! : body);
|
||||
if (parsed.error) {
|
||||
return { healthy: false, latencyMs, message: parsed.error.message ?? 'Tool call error' };
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails but HTTP was ok, consider it healthy
|
||||
}
|
||||
|
||||
return { healthy: true, latencyMs, message: 'ok' };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe an SSE-transport MCP server.
|
||||
* SSE protocol: GET /sse → endpoint event → POST /messages?session_id=...
|
||||
*/
|
||||
private async probeSse(
|
||||
baseUrl: string,
|
||||
healthCheck: HealthCheckSpec,
|
||||
timeoutMs: number,
|
||||
): Promise<ProbeResult> {
|
||||
const start = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
// 1. Connect to SSE endpoint to get the message URL
|
||||
const sseResp = await fetch(`${baseUrl}/sse`, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'text/event-stream' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!sseResp.ok) {
|
||||
return { healthy: false, latencyMs: Date.now() - start, message: `SSE connect HTTP ${sseResp.status}` };
|
||||
}
|
||||
|
||||
// 2. Read the SSE stream to find the endpoint event
|
||||
const reader = sseResp.body?.getReader();
|
||||
if (!reader) {
|
||||
return { healthy: false, latencyMs: Date.now() - start, message: 'No SSE stream body' };
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let messagesUrl = '';
|
||||
|
||||
// Read until we get the endpoint event
|
||||
while (!messagesUrl) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
for (const line of buffer.split('\n')) {
|
||||
if (line.startsWith('data: ') && buffer.includes('event: endpoint')) {
|
||||
const endpoint = line.slice(6).trim();
|
||||
// Endpoint may be relative (e.g., /messages?session_id=...) or absolute
|
||||
messagesUrl = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`;
|
||||
}
|
||||
}
|
||||
// Keep only the last incomplete line
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines[lines.length - 1] ?? '';
|
||||
}
|
||||
|
||||
if (!messagesUrl) {
|
||||
reader.cancel();
|
||||
return { healthy: false, latencyMs: Date.now() - start, message: 'No endpoint event from SSE' };
|
||||
}
|
||||
|
||||
// 3. Initialize via the messages endpoint
|
||||
const postHeaders = { 'Content-Type': 'application/json' };
|
||||
|
||||
const initResp = await fetch(messagesUrl, {
|
||||
method: 'POST', headers: postHeaders,
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'mcpctl-health', version: '0.1.0' } },
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!initResp.ok) {
|
||||
reader.cancel();
|
||||
return { healthy: false, latencyMs: Date.now() - start, message: `Initialize HTTP ${initResp.status}` };
|
||||
}
|
||||
|
||||
// 4. Send initialized notification
|
||||
await fetch(messagesUrl, {
|
||||
method: 'POST', headers: postHeaders,
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
// 5. Call health check tool
|
||||
const toolResp = await fetch(messagesUrl, {
|
||||
method: 'POST', headers: postHeaders,
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0', id: 2, method: 'tools/call',
|
||||
params: { name: healthCheck.tool, arguments: healthCheck.arguments ?? {} },
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const latencyMs = Date.now() - start;
|
||||
|
||||
// 6. Read tool response from SSE stream
|
||||
// The response comes back on the SSE stream, not the POST response
|
||||
let responseBuffer = '';
|
||||
const readTimeout = setTimeout(() => reader.cancel(), 5000);
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
responseBuffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Look for data lines containing our response (id: 2)
|
||||
for (const line of responseBuffer.split('\n')) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const parsed = JSON.parse(line.slice(6));
|
||||
if (parsed.id === 2) {
|
||||
clearTimeout(readTimeout);
|
||||
reader.cancel();
|
||||
if (parsed.error) {
|
||||
return { healthy: false, latencyMs, message: parsed.error.message ?? 'Tool call error' };
|
||||
}
|
||||
return { healthy: true, latencyMs, message: 'ok' };
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
const respLines = responseBuffer.split('\n');
|
||||
responseBuffer = respLines[respLines.length - 1] ?? '';
|
||||
}
|
||||
|
||||
clearTimeout(readTimeout);
|
||||
reader.cancel();
|
||||
|
||||
// If POST response itself was ok (202 for SSE), consider it healthy
|
||||
if (toolResp.ok) {
|
||||
return { healthy: true, latencyMs, message: 'ok' };
|
||||
}
|
||||
|
||||
return { healthy: false, latencyMs, message: `Tool call HTTP ${toolResp.status}` };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a STDIO MCP server by running `docker exec` with a disposable Node.js
|
||||
* script that pipes JSON-RPC messages into the package binary.
|
||||
*/
|
||||
private async probeStdio(
|
||||
instance: McpInstance,
|
||||
server: McpServer,
|
||||
healthCheck: HealthCheckSpec,
|
||||
timeoutMs: number,
|
||||
): Promise<ProbeResult> {
|
||||
if (!instance.containerId) {
|
||||
return { healthy: false, latencyMs: 0, message: 'No container ID' };
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const packageName = server.packageName as string | null;
|
||||
|
||||
if (!packageName) {
|
||||
return { healthy: false, latencyMs: 0, message: 'No package name for STDIO server' };
|
||||
}
|
||||
|
||||
// Build JSON-RPC messages for the health probe
|
||||
const initMsg = JSON.stringify({
|
||||
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'mcpctl-health', version: '0.1.0' },
|
||||
},
|
||||
});
|
||||
const initializedMsg = JSON.stringify({
|
||||
jsonrpc: '2.0', method: 'notifications/initialized',
|
||||
});
|
||||
const toolCallMsg = JSON.stringify({
|
||||
jsonrpc: '2.0', id: 2, method: 'tools/call',
|
||||
params: { name: healthCheck.tool, arguments: healthCheck.arguments ?? {} },
|
||||
});
|
||||
|
||||
// Use a Node.js inline script that:
|
||||
// 1. Spawns the MCP server binary via npx
|
||||
// 2. Sends initialize + initialized + tool call via stdin
|
||||
// 3. Reads responses from stdout
|
||||
// 4. Exits with 0 if tool call succeeds, 1 if it fails
|
||||
const probeScript = `
|
||||
const { spawn } = require('child_process');
|
||||
const proc = spawn('npx', ['--prefer-offline', '-y', ${JSON.stringify(packageName)}], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
let output = '';
|
||||
let responded = false;
|
||||
proc.stdout.on('data', d => {
|
||||
output += d;
|
||||
const lines = output.split('\\n');
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.id === 2) {
|
||||
responded = true;
|
||||
if (msg.error) {
|
||||
process.stdout.write('ERROR:' + (msg.error.message || 'unknown'));
|
||||
proc.kill();
|
||||
process.exit(1);
|
||||
} else {
|
||||
process.stdout.write('OK');
|
||||
proc.kill();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
output = lines[lines.length - 1] || '';
|
||||
});
|
||||
proc.stderr.on('data', () => {});
|
||||
proc.on('error', e => { process.stdout.write('ERROR:' + e.message); process.exit(1); });
|
||||
proc.on('exit', (code) => { if (!responded) { process.stdout.write('ERROR:process exited ' + code); process.exit(1); } });
|
||||
setTimeout(() => { if (!responded) { process.stdout.write('ERROR:timeout'); proc.kill(); process.exit(1); } }, ${timeoutMs - 2000});
|
||||
proc.stdin.write(${JSON.stringify(initMsg)} + '\\n');
|
||||
setTimeout(() => {
|
||||
proc.stdin.write(${JSON.stringify(initializedMsg)} + '\\n');
|
||||
setTimeout(() => {
|
||||
proc.stdin.write(${JSON.stringify(toolCallMsg)} + '\\n');
|
||||
}, 500);
|
||||
}, 500);
|
||||
`.trim();
|
||||
|
||||
try {
|
||||
const result = await this.orchestrator.execInContainer(
|
||||
instance.containerId,
|
||||
['node', '-e', probeScript],
|
||||
{ timeoutMs },
|
||||
);
|
||||
|
||||
const latencyMs = Date.now() - start;
|
||||
|
||||
if (result.exitCode === 0 && result.stdout.includes('OK')) {
|
||||
return { healthy: true, latencyMs, message: 'ok' };
|
||||
}
|
||||
|
||||
// Extract error message
|
||||
const errorMatch = result.stdout.match(/ERROR:(.*)/);
|
||||
const errorMsg = errorMatch?.[1] ?? (result.stderr.trim() || `exit code ${result.exitCode}`);
|
||||
return { healthy: false, latencyMs, message: errorMsg };
|
||||
} catch (err) {
|
||||
return {
|
||||
healthy: false,
|
||||
latencyMs: Date.now() - start,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user