Compare commits

...

20 Commits

Author SHA1 Message Date
Michal
079c7b3dfa feat: add prompt resources, fix MCP proxy transport, enrich tool descriptions
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Fix MCP proxy to support SSE and STDIO transports (not just HTTP POST)
- Enrich tool descriptions with server context for LLM clarity
- Add Prompt and PromptRequest resources with two-resource RBAC model
- Add propose_prompt MCP tool for LLM to create pending prompt requests
- Add prompt resources visible in MCP resources/list (approved + session's pending)
- Add project-level prompt/instructions in MCP initialize response
- Add ServiceAccount subject type for RBAC (SA identity from X-Service-Account header)
- Add CLI commands: create prompt, get prompts/promptrequests, approve promptrequest
- Add prompts to apply config schema
- 956 tests passing across all packages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:53:00 +00:00
Michal
7829f4fb92 fix: handle SSE responses in MCP bridge and add Commander-level tests
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
The bridge now parses SSE text/event-stream responses (extracting data:
lines) in addition to plain JSON. Also sends correct Accept header
per MCP streamable HTTP spec. Added tests for SSE handling and
command option parsing (-p/--project).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:17:45 +00:00
Michal
fa6240107f fix: mcp command accepts --project directly for Claude spawned processes
The mcp subcommand now has its own -p/--project option with
passThroughOptions(), so `mcpctl mcp --project NAME` works when Claude
spawns the process. Updated config claude to generate
args: ['mcp', '--project', project] and added Commander-level tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:14:16 +00:00
b34ea63d3d Merge pull request 'feat: add mcpctl mcp STDIO bridge, rework config claude' (#35) from feat/mcp-stdio-bridge into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-24 00:52:21 +00:00
Michal
e17a2282e8 feat: add mcpctl mcp STDIO bridge, rework config claude
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- New `mcpctl mcp -p PROJECT` command: STDIO-to-StreamableHTTP bridge
  that reads JSON-RPC from stdin and forwards to mcplocal project endpoint
- Rework `config claude` to write mcpctl mcp entry instead of fetching
  server configs from API (no secrets in .mcp.json)
- Keep `config claude-generate` as backward-compat alias
- Fix discovery.ts auth token not being forwarded to mcpd (RBAC bypass)
- Update fish/bash completions for new commands
- 10 new MCP bridge tests, updated claude tests, fixed project-discovery test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 00:52:05 +00:00
01d3c4e02d Merge pull request 'fix: don't send Content-Type on bodyless DELETE, include full server data in project queries' (#34) from fix/delete-content-type-and-project-servers into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 19:55:35 +00:00
Michal
e4affe5962 fix: don't send Content-Type on bodyless DELETE, include full server data in project queries
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Only set Content-Type: application/json when request body is present (fixes
  Fastify rejecting empty DELETE with "Body cannot be empty" 400 error)
- Changed PROJECT_INCLUDE to return full server objects instead of just {id, name}
  so project server listings show transport, package, image columns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:54:34 +00:00
c75e7cdf4d Merge pull request 'fix: prevent attach/detach-server from repeating server arg on tab' (#33) from fix/completion-no-repeat-server-arg into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 19:36:53 +00:00
Michal
65c340a03c fix: prevent attach/detach-server from repeating server arg on tab
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
Added __mcpctl_needs_server_arg guard in fish and position check in
bash so completions stop after one server name is selected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:36:45 +00:00
677d34b868 Merge pull request 'fix: instance completions use server.name, smart attach/detach' (#32) from fix/completion-instances-attach-detach into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 19:32:34 +00:00
Michal
c5b8cb60b7 fix: instance completions use server.name, smart attach/detach
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Instances have no name field — use server.name for completions
- attach-server: show only servers NOT in the project
- detach-server: show only servers IN the project
- Add helper functions for project-aware server completion
- 5 new tests covering all three fixes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:32:18 +00:00
9a5deffb8f Merge pull request 'fix: use .[][].name in jq for wrapped JSON response' (#31) from fix/completion-jq-wrapped-json into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 19:27:02 +00:00
Michal
ec7ada5383 fix: use .[][].name in jq for wrapped JSON response
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
API returns { "resources": [...] } not bare arrays, so .[].name
produced no output. Use .[][].name to unwrap the outer object first.
Also auto-load .env in pr.sh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:26:47 +00:00
b81d3be2d5 Merge pull request 'fix: use jq for completion name extraction to avoid nested matches' (#30) from fix/completion-nested-names into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 19:23:48 +00:00
Michal
e2c54bfc5c fix: use jq for completion name extraction to avoid nested matches
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
The regex "name":\s*"..." on JSON matched nested server names inside
project objects, mixing resource types in completions. Switch to
jq -r '.[].name' for proper top-level extraction. Add jq as RPM
dependency. Add pr.sh for PR creation via Gitea API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:23:21 +00:00
7b7854b007 Merge pull request 'feat: erase stale fish completions and add completion tests' (#29) from feat/completions-stale-erase-and-tests into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 19:17:00 +00:00
Michal
f23dd99662 feat: erase stale fish completions and add completion tests
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
Fish completions are additive — sourcing a new file doesn't remove old
rules. Add `complete -c mcpctl -e` at the top to clear stale entries.
Also add 12 structural tests to prevent completion regressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:16:36 +00:00
43af85cb58 Merge pull request 'feat: context-aware completions with dynamic resource names' (#28) from feat/completions-project-scope-dynamic into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 19:08:45 +00:00
Michal
6d2e3c2eb3 feat: context-aware completions with dynamic resource names
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Hide attach-server/detach-server from --help (only relevant with --project)
- --project shows only project-scoped commands in tab completion
- Tab after resource type fetches live resource names from API
- --project value auto-completes from existing project names
- Stop offering resource types after one is already selected

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:08:29 +00:00
ce21db3853 Merge pull request 'feat: --project scopes get servers/instances' (#27) from feat/project-scoped-get into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 19:03:23 +00:00
44 changed files with 3052 additions and 175 deletions

View File

@@ -2,14 +2,83 @@ _mcpctl() {
local cur prev words cword local cur prev words cword
_init_completion || return _init_completion || return
local commands="status login logout config get describe delete logs create edit apply backup restore help" local commands="status login logout config get describe delete logs create edit apply backup restore mcp help"
local global_opts="-v --version --daemon-url --direct -h --help" local project_commands="attach-server detach-server get describe delete logs create edit help"
local global_opts="-v --version --daemon-url --direct --project -h --help"
local resources="servers instances secrets templates projects users groups rbac" local resources="servers instances secrets templates projects users groups rbac"
case "${words[1]}" in # Check if --project was given
local has_project=false
local i
for ((i=1; i < cword; i++)); do
if [[ "${words[i]}" == "--project" ]]; then
has_project=true
break
fi
done
# Find the first subcommand (skip --project and its argument, skip flags)
local subcmd=""
local subcmd_pos=0
for ((i=1; i < cword; i++)); do
if [[ "${words[i]}" == "--project" || "${words[i]}" == "--daemon-url" ]]; then
((i++)) # skip the argument
continue
fi
if [[ "${words[i]}" != -* ]]; then
subcmd="${words[i]}"
subcmd_pos=$i
break
fi
done
# Find the resource type after get/describe/delete/edit
local resource_type=""
if [[ -n "$subcmd_pos" ]] && [[ $subcmd_pos -gt 0 ]]; then
for ((i=subcmd_pos+1; i < cword; i++)); do
if [[ "${words[i]}" != -* ]] && [[ " $resources " == *" ${words[i]} "* ]]; then
resource_type="${words[i]}"
break
fi
done
fi
# If completing the --project value
if [[ "$prev" == "--project" ]]; then
local names
names=$(mcpctl get projects -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null)
COMPREPLY=($(compgen -W "$names" -- "$cur"))
return
fi
# Fetch resource names dynamically (jq extracts only top-level names)
_mcpctl_resource_names() {
local rt="$1"
if [[ -n "$rt" ]]; then
# Instances don't have a name field — use server.name instead
if [[ "$rt" == "instances" ]]; then
mcpctl get instances -o json 2>/dev/null | jq -r '.[][].server.name' 2>/dev/null
else
mcpctl get "$rt" -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null
fi
fi
}
# Get the --project value from the command line
_mcpctl_get_project_value() {
local i
for ((i=1; i < cword; i++)); do
if [[ "${words[i]}" == "--project" ]] && (( i+1 < cword )); then
echo "${words[i+1]}"
return
fi
done
}
case "$subcmd" in
config) config)
if [[ $cword -eq 2 ]]; then if [[ $((cword - subcmd_pos)) -eq 1 ]]; then
COMPREPLY=($(compgen -W "view set path reset claude-generate impersonate help" -- "$cur")) COMPREPLY=($(compgen -W "view set path reset claude impersonate help" -- "$cur"))
fi fi
return ;; return ;;
status) status)
@@ -20,35 +89,31 @@ _mcpctl() {
return ;; return ;;
logout) logout)
return ;; return ;;
get) mcp)
if [[ $cword -eq 2 ]]; then return ;;
get|describe|delete)
if [[ -z "$resource_type" ]]; then
COMPREPLY=($(compgen -W "$resources" -- "$cur")) COMPREPLY=($(compgen -W "$resources" -- "$cur"))
else else
COMPREPLY=($(compgen -W "-o --output -h --help" -- "$cur")) local names
fi names=$(_mcpctl_resource_names "$resource_type")
return ;; COMPREPLY=($(compgen -W "$names -o --output -h --help" -- "$cur"))
describe)
if [[ $cword -eq 2 ]]; then
COMPREPLY=($(compgen -W "$resources" -- "$cur"))
else
COMPREPLY=($(compgen -W "-o --output --show-values -h --help" -- "$cur"))
fi
return ;;
delete)
if [[ $cword -eq 2 ]]; then
COMPREPLY=($(compgen -W "$resources" -- "$cur"))
fi fi
return ;; return ;;
edit) edit)
if [[ $cword -eq 2 ]]; then if [[ -z "$resource_type" ]]; then
COMPREPLY=($(compgen -W "servers projects" -- "$cur")) COMPREPLY=($(compgen -W "servers projects" -- "$cur"))
else
local names
names=$(_mcpctl_resource_names "$resource_type")
COMPREPLY=($(compgen -W "$names -h --help" -- "$cur"))
fi fi
return ;; return ;;
logs) logs)
COMPREPLY=($(compgen -W "--tail --since -f --follow -h --help" -- "$cur")) COMPREPLY=($(compgen -W "--tail --since -f --follow -h --help" -- "$cur"))
return ;; return ;;
create) create)
if [[ $cword -eq 2 ]]; then if [[ $((cword - subcmd_pos)) -eq 1 ]]; then
COMPREPLY=($(compgen -W "server secret project user group rbac help" -- "$cur")) COMPREPLY=($(compgen -W "server secret project user group rbac help" -- "$cur"))
fi fi
return ;; return ;;
@@ -61,14 +126,43 @@ _mcpctl() {
restore) restore)
COMPREPLY=($(compgen -W "-i --input -p --password -c --conflict -h --help" -- "$cur")) COMPREPLY=($(compgen -W "-i --input -p --password -c --conflict -h --help" -- "$cur"))
return ;; return ;;
attach-server)
# Only complete if no server arg given yet (first arg after subcmd)
if [[ $((cword - subcmd_pos)) -ne 1 ]]; then return; fi
local proj names all_servers proj_servers
proj=$(_mcpctl_get_project_value)
if [[ -n "$proj" ]]; then
all_servers=$(mcpctl get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null)
proj_servers=$(mcpctl --project "$proj" get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null)
names=$(comm -23 <(echo "$all_servers" | sort) <(echo "$proj_servers" | sort))
else
names=$(_mcpctl_resource_names "servers")
fi
COMPREPLY=($(compgen -W "$names" -- "$cur"))
return ;;
detach-server)
# Only complete if no server arg given yet (first arg after subcmd)
if [[ $((cword - subcmd_pos)) -ne 1 ]]; then return; fi
local proj names
proj=$(_mcpctl_get_project_value)
if [[ -n "$proj" ]]; then
names=$(mcpctl --project "$proj" get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null)
fi
COMPREPLY=($(compgen -W "$names" -- "$cur"))
return ;;
help) help)
COMPREPLY=($(compgen -W "$commands" -- "$cur")) COMPREPLY=($(compgen -W "$commands" -- "$cur"))
return ;; return ;;
esac esac
if [[ $cword -eq 1 ]]; then # No subcommand yet — offer commands based on context
if [[ -z "$subcmd" ]]; then
if $has_project; then
COMPREPLY=($(compgen -W "$project_commands $global_opts" -- "$cur"))
else
COMPREPLY=($(compgen -W "$commands $global_opts" -- "$cur")) COMPREPLY=($(compgen -W "$commands $global_opts" -- "$cur"))
fi fi
fi
} }
complete -F _mcpctl mcpctl complete -F _mcpctl mcpctl

View File

@@ -1,6 +1,10 @@
# mcpctl fish completions # mcpctl fish completions
set -l commands status login logout config get describe delete logs create edit apply backup restore help # Erase any stale completions from previous versions
complete -c mcpctl -e
set -l commands status login logout config get describe delete logs create edit apply backup restore mcp help
set -l project_commands attach-server detach-server get describe delete logs create edit help
# Disable file completions by default # Disable file completions by default
complete -c mcpctl -f complete -c mcpctl -f
@@ -9,30 +13,179 @@ complete -c mcpctl -f
complete -c mcpctl -s v -l version -d 'Show version' complete -c mcpctl -s v -l version -d 'Show version'
complete -c mcpctl -l daemon-url -d 'mcplocal daemon URL' -x complete -c mcpctl -l daemon-url -d 'mcplocal daemon URL' -x
complete -c mcpctl -l direct -d 'Bypass mcplocal, connect directly to mcpd' complete -c mcpctl -l direct -d 'Bypass mcplocal, connect directly to mcpd'
complete -c mcpctl -l project -d 'Target project context' -x
complete -c mcpctl -s h -l help -d 'Show help' complete -c mcpctl -s h -l help -d 'Show help'
# Top-level commands # Helper: check if --project was given
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a status -d 'Show status and connectivity' function __mcpctl_has_project
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a login -d 'Authenticate with mcpd' set -l tokens (commandline -opc)
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a logout -d 'Log out' for i in (seq (count $tokens))
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a config -d 'Manage configuration' if test "$tokens[$i]" = "--project"
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a get -d 'List resources' return 0
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a describe -d 'Show resource details' end
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a delete -d 'Delete a resource' end
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a logs -d 'Get instance logs' return 1
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource' end
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a edit -d 'Edit a resource'
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a apply -d 'Apply configuration from file'
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a backup -d 'Backup configuration'
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a restore -d 'Restore from backup'
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a help -d 'Show help'
# Resource types for get/describe/delete/edit # Helper: check if a resource type has been selected after get/describe/delete/edit
set -l resources servers instances secrets templates projects users groups rbac set -l resources servers instances secrets templates projects users groups rbac
complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete" -a "$resources" -d 'Resource type'
complete -c mcpctl -n "__fish_seen_subcommand_from edit" -a 'servers projects' -d 'Resource type'
# get/describe/delete options function __mcpctl_needs_resource_type
set -l tokens (commandline -opc)
set -l found_cmd false
for tok in $tokens
if $found_cmd
# Check if next token after get/describe/delete/edit is a resource type
if contains -- $tok servers instances secrets templates projects users groups rbac
return 1 # resource type already present
end
end
if contains -- $tok get describe delete edit
set found_cmd true
end
end
if $found_cmd
return 0 # command found but no resource type yet
end
return 1
end
function __mcpctl_get_resource_type
set -l tokens (commandline -opc)
set -l found_cmd false
for tok in $tokens
if $found_cmd
if contains -- $tok servers instances secrets templates projects users groups rbac
echo $tok
return
end
end
if contains -- $tok get describe delete edit
set found_cmd true
end
end
end
# Fetch resource names dynamically from the API (jq extracts only top-level names)
function __mcpctl_resource_names
set -l resource (__mcpctl_get_resource_type)
if test -z "$resource"
return
end
# Instances don't have a name field — use server.name instead
if test "$resource" = "instances"
mcpctl get instances -o json 2>/dev/null | jq -r '.[][].server.name' 2>/dev/null
else
mcpctl get $resource -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null
end
end
# Fetch project names for --project value
function __mcpctl_project_names
mcpctl get projects -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null
end
# Helper: get the --project value from the command line
function __mcpctl_get_project_value
set -l tokens (commandline -opc)
for i in (seq (count $tokens))
if test "$tokens[$i]" = "--project"; and test $i -lt (count $tokens)
echo $tokens[(math $i + 1)]
return
end
end
end
# Servers currently attached to the project (for detach-server)
function __mcpctl_project_servers
set -l proj (__mcpctl_get_project_value)
if test -z "$proj"
return
end
mcpctl --project $proj get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null
end
# Servers NOT attached to the project (for attach-server)
function __mcpctl_available_servers
set -l proj (__mcpctl_get_project_value)
if test -z "$proj"
# No project — show all servers
mcpctl get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null
return
end
set -l all (mcpctl get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null)
set -l attached (mcpctl --project $proj get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null)
for s in $all
if not contains -- $s $attached
echo $s
end
end
end
# --project value completion
complete -c mcpctl -l project -xa '(__mcpctl_project_names)'
# Top-level commands (without --project)
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a status -d 'Show status and connectivity'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a login -d 'Authenticate with mcpd'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logout -d 'Log out'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a config -d 'Manage configuration'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a get -d 'List resources'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a describe -d 'Show resource details'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a delete -d 'Delete a resource'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logs -d 'Get instance logs'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a edit -d 'Edit a resource'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a apply -d 'Apply configuration from file'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a backup -d 'Backup configuration'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a restore -d 'Restore from backup'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a help -d 'Show help'
# Project-scoped commands (with --project)
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a attach-server -d 'Attach a server to the project'
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a detach-server -d 'Detach a server from the project'
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a get -d 'List resources (scoped to project)'
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a describe -d 'Show resource details'
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a delete -d 'Delete a resource'
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a logs -d 'Get instance logs'
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a create -d 'Create a resource'
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a edit -d 'Edit a resource'
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a help -d 'Show help'
# Resource types — only when resource type not yet selected
complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete; and __mcpctl_needs_resource_type" -a "$resources" -d 'Resource type'
complete -c mcpctl -n "__fish_seen_subcommand_from edit; and __mcpctl_needs_resource_type" -a 'servers projects' -d 'Resource type'
# Resource names — after resource type is selected
complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete edit; and not __mcpctl_needs_resource_type" -a '(__mcpctl_resource_names)' -d 'Resource name'
# Helper: check if attach-server/detach-server already has a server argument
function __mcpctl_needs_server_arg
set -l tokens (commandline -opc)
set -l found_cmd false
for tok in $tokens
if $found_cmd
if not string match -q -- '-*' $tok
return 1 # server arg already present
end
end
if contains -- $tok attach-server detach-server
set found_cmd true
end
end
if $found_cmd
return 0 # command found but no server arg yet
end
return 1
end
# attach-server: show servers NOT in the project (only if no server arg yet)
complete -c mcpctl -n "__fish_seen_subcommand_from attach-server; and __mcpctl_needs_server_arg" -a '(__mcpctl_available_servers)' -d 'Server'
# detach-server: show servers IN the project (only if no server arg yet)
complete -c mcpctl -n "__fish_seen_subcommand_from detach-server; and __mcpctl_needs_server_arg" -a '(__mcpctl_project_servers)' -d 'Server'
# get/describe options
complete -c mcpctl -n "__fish_seen_subcommand_from get" -s o -l output -d 'Output format' -xa 'table json yaml' complete -c mcpctl -n "__fish_seen_subcommand_from get" -s o -l output -d 'Output format' -xa 'table json yaml'
complete -c mcpctl -n "__fish_seen_subcommand_from describe" -s o -l output -d 'Output format' -xa 'detail json yaml' complete -c mcpctl -n "__fish_seen_subcommand_from describe" -s o -l output -d 'Output format' -xa 'detail json yaml'
complete -c mcpctl -n "__fish_seen_subcommand_from describe" -l show-values -d 'Show secret values' complete -c mcpctl -n "__fish_seen_subcommand_from describe" -l show-values -d 'Show secret values'
@@ -43,12 +196,12 @@ complete -c mcpctl -n "__fish_seen_subcommand_from login" -l email -d 'Email add
complete -c mcpctl -n "__fish_seen_subcommand_from login" -l password -d 'Password' -x complete -c mcpctl -n "__fish_seen_subcommand_from login" -l password -d 'Password' -x
# config subcommands # config subcommands
set -l config_cmds view set path reset claude-generate impersonate set -l config_cmds view set path reset claude claude-generate impersonate
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a view -d 'Show configuration' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a view -d 'Show configuration'
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a set -d 'Set a config value' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a set -d 'Set a config value'
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a path -d 'Show config file path' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a path -d 'Show config file path'
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a reset -d 'Reset to defaults' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a reset -d 'Reset to defaults'
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude-generate -d 'Generate .mcp.json' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude -d 'Generate .mcp.json for project'
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a impersonate -d 'Impersonate a user' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a impersonate -d 'Impersonate a user'
# create subcommands # create subcommands

View File

@@ -5,6 +5,8 @@ release: "1"
maintainer: michal maintainer: michal
description: kubectl-like CLI for managing MCP servers description: kubectl-like CLI for managing MCP servers
license: MIT license: MIT
depends:
- jq
contents: contents:
- src: ./dist/mcpctl - src: ./dist/mcpctl
dst: /usr/bin/mcpctl dst: /usr/bin/mcpctl

55
pr.sh Executable file
View 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"

View File

@@ -24,7 +24,10 @@ export class ApiError extends Error {
function request<T>(method: string, url: string, timeout: number, body?: unknown, token?: string): Promise<ApiResponse<T>> { function request<T>(method: string, url: string, timeout: number, body?: unknown, token?: string): Promise<ApiResponse<T>> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const parsed = new URL(url); const parsed = new URL(url);
const headers: Record<string, string> = { 'Content-Type': 'application/json' }; const headers: Record<string, string> = {};
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
}
if (token) { if (token) {
headers['Authorization'] = `Bearer ${token}`; headers['Authorization'] = `Bearer ${token}`;
} }

View File

@@ -76,13 +76,14 @@ const GroupSpecSchema = z.object({
}); });
const RbacSubjectSchema = z.object({ const RbacSubjectSchema = z.object({
kind: z.enum(['User', 'Group']), kind: z.enum(['User', 'Group', 'ServiceAccount']),
name: z.string().min(1), name: z.string().min(1),
}); });
const RESOURCE_ALIASES: Record<string, string> = { const RESOURCE_ALIASES: Record<string, string> = {
server: 'servers', instance: 'instances', secret: 'secrets', server: 'servers', instance: 'instances', secret: 'secrets',
project: 'projects', template: 'templates', user: 'users', group: 'groups', project: 'projects', template: 'templates', user: 'users', group: 'groups',
prompt: 'prompts', promptrequest: 'promptrequests',
}; };
const RbacRoleBindingSchema = z.union([ const RbacRoleBindingSchema = z.union([
@@ -103,9 +104,16 @@ const RbacBindingSpecSchema = z.object({
roleBindings: z.array(RbacRoleBindingSchema).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({ const ProjectSpecSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
description: z.string().default(''), description: z.string().default(''),
prompt: z.string().max(10000).default(''),
proxyMode: z.enum(['direct', 'filtered']).default('direct'), proxyMode: z.enum(['direct', 'filtered']).default('direct'),
llmProvider: z.string().optional(), llmProvider: z.string().optional(),
llmModel: z.string().optional(), llmModel: z.string().optional(),
@@ -121,6 +129,7 @@ const ApplyConfigSchema = z.object({
templates: z.array(TemplateSpecSchema).default([]), templates: z.array(TemplateSpecSchema).default([]),
rbacBindings: z.array(RbacBindingSpecSchema).default([]), rbacBindings: z.array(RbacBindingSpecSchema).default([]),
rbac: z.array(RbacBindingSpecSchema).default([]), rbac: z.array(RbacBindingSpecSchema).default([]),
prompts: z.array(PromptSpecSchema).default([]),
}).transform((data) => ({ }).transform((data) => ({
...data, ...data,
// Merge rbac into rbacBindings so both keys work // Merge rbac into rbacBindings so both keys work
@@ -158,6 +167,7 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command {
if (config.projects.length > 0) log(` ${config.projects.length} project(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.templates.length > 0) log(` ${config.templates.length} template(s)`);
if (config.rbacBindings.length > 0) log(` ${config.rbacBindings.length} rbacBinding(s)`); if (config.rbacBindings.length > 0) log(` ${config.rbacBindings.length} rbacBinding(s)`);
if (config.prompts.length > 0) log(` ${config.prompts.length} prompt(s)`);
return; return;
} }
@@ -292,6 +302,22 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
log(`Error applying rbacBinding '${rbacBinding.name}': ${err instanceof Error ? err.message : 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> { async function findByName(client: ApiClient, resource: string, name: string): Promise<unknown | null> {

View File

@@ -10,7 +10,7 @@ import type { CredentialsDeps, StoredCredentials } from '../auth/index.js';
import type { ApiClient } from '../api-client.js'; import type { ApiClient } from '../api-client.js';
interface McpConfig { interface McpConfig {
mcpServers: Record<string, { command: string; args: string[]; env?: Record<string, string> }>; mcpServers: Record<string, { command?: string; args?: string[]; url?: string; env?: Record<string, string> }>;
} }
export interface ConfigCommandDeps { export interface ConfigCommandDeps {
@@ -84,21 +84,27 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
log('Configuration reset to defaults'); log('Configuration reset to defaults');
}); });
if (apiDeps) { // claude/claude-generate: generate .mcp.json pointing at mcpctl mcp bridge
const { client, credentialsDeps, log: apiLog } = apiDeps; function registerClaudeCommand(name: string, hidden: boolean): void {
const cmd = config
config .command(name)
.command('claude-generate') .description(hidden ? '' : 'Generate .mcp.json that connects a project via mcpctl mcp bridge')
.description('Generate .mcp.json from a project configuration')
.requiredOption('--project <name>', 'Project name') .requiredOption('--project <name>', 'Project name')
.option('-o, --output <path>', 'Output file path', '.mcp.json') .option('-o, --output <path>', 'Output file path', '.mcp.json')
.option('--merge', 'Merge with existing .mcp.json instead of overwriting') .option('--merge', 'Merge with existing .mcp.json instead of overwriting')
.option('--stdout', 'Print to stdout instead of writing a file') .option('--stdout', 'Print to stdout instead of writing a file')
.action(async (opts: { project: string; output: string; merge?: boolean; stdout?: boolean }) => { .action((opts: { project: string; output: string; merge?: boolean; stdout?: boolean }) => {
const mcpConfig = await client.get<McpConfig>(`/api/v1/projects/${opts.project}/mcp-config`); const mcpConfig: McpConfig = {
mcpServers: {
[opts.project]: {
command: 'mcpctl',
args: ['mcp', '--project', opts.project],
},
},
};
if (opts.stdout) { if (opts.stdout) {
apiLog(JSON.stringify(mcpConfig, null, 2)); log(JSON.stringify(mcpConfig, null, 2));
return; return;
} }
@@ -121,8 +127,19 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n'); writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n');
const serverCount = Object.keys(finalConfig.mcpServers).length; const serverCount = Object.keys(finalConfig.mcpServers).length;
apiLog(`Wrote ${outputPath} (${serverCount} server(s))`); log(`Wrote ${outputPath} (${serverCount} server(s))`);
}); });
if (hidden) {
// Commander shows empty-description commands but they won't clutter help output
void cmd; // suppress unused lint
}
}
registerClaudeCommand('claude', false);
registerClaudeCommand('claude-generate', true); // backward compat
if (apiDeps) {
const { client, credentialsDeps, log: apiLog } = apiDeps;
config config
.command('impersonate') .command('impersonate')

View File

@@ -198,6 +198,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.option('--proxy-mode <mode>', 'Proxy mode (direct, filtered)') .option('--proxy-mode <mode>', 'Proxy mode (direct, filtered)')
.option('--proxy-mode-llm-provider <name>', 'LLM provider name (for filtered proxy mode)') .option('--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('--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('--server <name>', 'Server name (repeat for multiple)', collect, [])
.option('--force', 'Update if already exists') .option('--force', 'Update if already exists')
.action(async (name: string, opts) => { .action(async (name: string, opts) => {
@@ -206,6 +207,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
description: opts.description, description: opts.description,
proxyMode: opts.proxyMode ?? 'direct', proxyMode: opts.proxyMode ?? 'direct',
}; };
if (opts.prompt) body.prompt = opts.prompt;
if (opts.proxyModeLlmProvider) body.llmProvider = opts.proxyModeLlmProvider; if (opts.proxyModeLlmProvider) body.llmProvider = opts.proxyModeLlmProvider;
if (opts.proxyModeLlmModel) body.llmModel = opts.proxyModeLlmModel; if (opts.proxyModeLlmModel) body.llmModel = opts.proxyModeLlmModel;
if (opts.server.length > 0) body.servers = opts.server; if (opts.server.length > 0) body.servers = opts.server;
@@ -347,5 +349,35 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
} }
}); });
// --- 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; return cmd;
} }

View File

@@ -130,6 +130,36 @@ const templateColumns: Column<TemplateRow>[] = [
{ header: 'DESCRIPTION', key: 'description', width: 50 }, { 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>[] = [ const instanceColumns: Column<InstanceRow>[] = [
{ header: 'NAME', key: (r) => r.server?.name ?? '-', width: 20 }, { header: 'NAME', key: (r) => r.server?.name ?? '-', width: 20 },
{ header: 'STATUS', key: 'status', width: 10 }, { header: 'STATUS', key: 'status', width: 10 },
@@ -157,6 +187,10 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
return groupColumns as unknown as Column<Record<string, unknown>>[]; return groupColumns as unknown as Column<Record<string, unknown>>[];
case 'rbac': case 'rbac':
return rbacColumns as unknown as Column<Record<string, unknown>>[]; 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: default:
return [ return [
{ header: 'ID', key: 'id' as keyof Record<string, unknown> }, { header: 'ID', key: 'id' as keyof Record<string, unknown> },

224
src/cli/src/commands/mcp.ts Normal file
View 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;
}

View File

@@ -1,6 +1,6 @@
import { Command } from 'commander'; import { Command } from 'commander';
import type { ApiClient } from '../api-client.js'; import type { ApiClient } from '../api-client.js';
import { resolveNameOrId } from './shared.js'; import { resolveNameOrId, resolveResource } from './shared.js';
export interface ProjectOpsDeps { export interface ProjectOpsDeps {
client: ApiClient; client: ApiClient;
@@ -45,3 +45,22 @@ export function createDetachServerCommand(deps: ProjectOpsDeps): Command {
log(`server '${serverName}' detached from project '${projectName}'`); 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})`);
});
}

View File

@@ -16,6 +16,11 @@ export const RESOURCE_ALIASES: Record<string, string> = {
rbac: 'rbac', rbac: 'rbac',
'rbac-definition': 'rbac', 'rbac-definition': 'rbac',
'rbac-binding': 'rbac', 'rbac-binding': 'rbac',
prompt: 'prompts',
prompts: 'prompts',
promptrequest: 'promptrequests',
promptrequests: 'promptrequests',
pr: 'promptrequests',
}; };
export function resolveResource(name: string): string { export function resolveResource(name: string): string {

View File

@@ -12,7 +12,8 @@ import { createCreateCommand } from './commands/create.js';
import { createEditCommand } from './commands/edit.js'; import { createEditCommand } from './commands/edit.js';
import { createBackupCommand, createRestoreCommand } from './commands/backup.js'; import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
import { createLoginCommand, createLogoutCommand } from './commands/auth.js'; import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
import { createAttachServerCommand, createDetachServerCommand } from './commands/project-ops.js'; import { createAttachServerCommand, createDetachServerCommand, createApproveCommand } from './commands/project-ops.js';
import { createMcpCommand } from './commands/mcp.js';
import { ApiClient, ApiError } from './api-client.js'; import { ApiClient, ApiError } from './api-client.js';
import { loadConfig } from './config/index.js'; import { loadConfig } from './config/index.js';
import { loadCredentials } from './auth/index.js'; import { loadCredentials } from './auth/index.js';
@@ -148,8 +149,12 @@ export function createProgram(): Command {
log: (...args: string[]) => console.log(...args), log: (...args: string[]) => console.log(...args),
getProject: () => program.opts().project as string | undefined, getProject: () => program.opts().project as string | undefined,
}; };
program.addCommand(createAttachServerCommand(projectOpsDeps)); program.addCommand(createAttachServerCommand(projectOpsDeps), { hidden: true });
program.addCommand(createDetachServerCommand(projectOpsDeps)); program.addCommand(createDetachServerCommand(projectOpsDeps), { hidden: true });
program.addCommand(createApproveCommand(projectOpsDeps));
program.addCommand(createMcpCommand({
getProject: () => program.opts().project as string | undefined,
}), { hidden: true });
return program; return program;
} }

View File

@@ -21,6 +21,16 @@ beforeAll(async () => {
res.writeHead(201, { 'Content-Type': 'application/json' }); res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 'srv-new', ...body })); res.end(JSON.stringify({ id: 'srv-new', ...body }));
}); });
} else if (req.url === '/api/v1/servers/srv-1' && req.method === 'DELETE') {
// Fastify rejects empty body with Content-Type: application/json
const ct = req.headers['content-type'] ?? '';
if (ct.includes('application/json')) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: "Body cannot be empty when content-type is set to 'application/json'" }));
} else {
res.writeHead(204);
res.end();
}
} else if (req.url === '/api/v1/missing' && req.method === 'GET') { } else if (req.url === '/api/v1/missing' && req.method === 'GET') {
res.writeHead(404, { 'Content-Type': 'application/json' }); res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' })); res.end(JSON.stringify({ error: 'Not found' }));
@@ -75,6 +85,12 @@ describe('ApiClient', () => {
await expect(client.get('/anything')).rejects.toThrow(); await expect(client.get('/anything')).rejects.toThrow();
}); });
it('performs DELETE without Content-Type header', async () => {
const client = new ApiClient({ baseUrl: `http://localhost:${port}` });
// Should succeed (204) because no Content-Type is sent on bodyless DELETE
await expect(client.delete('/api/v1/servers/srv-1')).resolves.toBeUndefined();
});
it('sends Authorization header when token provided', async () => { it('sends Authorization header when token provided', async () => {
// We need a separate server to check the header // We need a separate server to check the header
let receivedAuth = ''; let receivedAuth = '';

View File

@@ -8,19 +8,14 @@ import { saveCredentials, loadCredentials } from '../../src/auth/index.js';
function mockClient(): ApiClient { function mockClient(): ApiClient {
return { return {
get: vi.fn(async () => ({ get: vi.fn(async () => ({})),
mcpServers: {
'slack--default': { command: 'npx', args: ['-y', '@anthropic/slack-mcp'], env: { WORKSPACE: 'test' } },
'github--default': { command: 'npx', args: ['-y', '@anthropic/github-mcp'] },
},
})),
post: vi.fn(async () => ({ token: 'impersonated-tok', user: { email: 'other@test.com' } })), post: vi.fn(async () => ({ token: 'impersonated-tok', user: { email: 'other@test.com' } })),
put: vi.fn(async () => ({})), put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}), delete: vi.fn(async () => {}),
} as unknown as ApiClient; } as unknown as ApiClient;
} }
describe('config claude-generate', () => { describe('config claude', () => {
let client: ReturnType<typeof mockClient>; let client: ReturnType<typeof mockClient>;
let output: string[]; let output: string[];
let tmpDir: string; let tmpDir: string;
@@ -36,18 +31,23 @@ describe('config claude-generate', () => {
rmSync(tmpDir, { recursive: true, force: true }); rmSync(tmpDir, { recursive: true, force: true });
}); });
it('generates .mcp.json from project config', async () => { it('generates .mcp.json with mcpctl mcp bridge entry', async () => {
const outPath = join(tmpDir, '.mcp.json'); const outPath = join(tmpDir, '.mcp.json');
const cmd = createConfigCommand( const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log }, { configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log },
); );
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath], { from: 'user' }); await cmd.parseAsync(['claude', '--project', 'homeautomation', '-o', outPath], { from: 'user' });
// No API call should be made
expect(client.get).not.toHaveBeenCalled();
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/proj-1/mcp-config');
const written = JSON.parse(readFileSync(outPath, 'utf-8')); const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['slack--default']).toBeDefined(); expect(written.mcpServers['homeautomation']).toEqual({
expect(output.join('\n')).toContain('2 server(s)'); command: 'mcpctl',
args: ['mcp', '--project', 'homeautomation'],
});
expect(output.join('\n')).toContain('1 server(s)');
}); });
it('prints to stdout with --stdout', async () => { it('prints to stdout with --stdout', async () => {
@@ -55,9 +55,13 @@ describe('config claude-generate', () => {
{ configDeps: { configDir: tmpDir }, log }, { configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log },
); );
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '--stdout'], { from: 'user' }); await cmd.parseAsync(['claude', '--project', 'myproj', '--stdout'], { from: 'user' });
expect(output[0]).toContain('mcpServers'); const parsed = JSON.parse(output[0]);
expect(parsed.mcpServers['myproj']).toEqual({
command: 'mcpctl',
args: ['mcp', '--project', 'myproj'],
});
}); });
it('merges with existing .mcp.json', async () => { it('merges with existing .mcp.json', async () => {
@@ -70,12 +74,41 @@ describe('config claude-generate', () => {
{ configDeps: { configDir: tmpDir }, log }, { configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log },
); );
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath, '--merge'], { from: 'user' }); await cmd.parseAsync(['claude', '--project', 'proj-1', '-o', outPath, '--merge'], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8')); const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['existing--server']).toBeDefined(); expect(written.mcpServers['existing--server']).toBeDefined();
expect(written.mcpServers['slack--default']).toBeDefined(); expect(written.mcpServers['proj-1']).toEqual({
expect(output.join('\n')).toContain('3 server(s)'); command: 'mcpctl',
args: ['mcp', '--project', 'proj-1'],
});
expect(output.join('\n')).toContain('2 server(s)');
});
it('backward compat: claude-generate still works', async () => {
const outPath = join(tmpDir, '.mcp.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['proj-1']).toEqual({
command: 'mcpctl',
args: ['mcp', '--project', 'proj-1'],
});
});
it('uses project name as the server key', async () => {
const outPath = join(tmpDir, '.mcp.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['claude', '--project', 'my-fancy-project', '-o', outPath], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(Object.keys(written.mcpServers)).toEqual(['my-fancy-project']);
}); });
}); });

View 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');
});
});

View 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'/);
});
});

View File

@@ -170,6 +170,7 @@ model Project {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique name String @unique
description String @default("") description String @default("")
prompt String @default("")
proxyMode String @default("direct") proxyMode String @default("direct")
llmProvider String? llmProvider String?
llmModel String? llmModel String?
@@ -180,6 +181,8 @@ model Project {
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
servers ProjectServer[] servers ProjectServer[]
prompts Prompt[]
promptRequests PromptRequest[]
@@index([name]) @@index([name])
@@index([ownerId]) @@index([ownerId])
@@ -227,6 +230,41 @@ enum InstanceStatus {
ERROR 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 ── // ── Audit Logs ──
model AuditLog { model AuditLog {

View File

@@ -18,6 +18,8 @@ import {
UserRepository, UserRepository,
GroupRepository, GroupRepository,
} from './repositories/index.js'; } from './repositories/index.js';
import { PromptRepository } from './repositories/prompt.repository.js';
import { PromptRequestRepository } from './repositories/prompt-request.repository.js';
import { import {
McpServerService, McpServerService,
SecretService, SecretService,
@@ -56,6 +58,8 @@ import {
registerUserRoutes, registerUserRoutes,
registerGroupRoutes, registerGroupRoutes,
} from './routes/index.js'; } from './routes/index.js';
import { registerPromptRoutes } from './routes/prompts.js';
import { PromptService } from './services/prompt.service.js';
type PermissionCheck = type PermissionCheck =
| { kind: 'resource'; resource: string; action: RbacAction; resourceName?: string } | { kind: 'resource'; resource: string; action: RbacAction; resourceName?: string }
@@ -88,11 +92,38 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
'rbac': 'rbac', 'rbac': 'rbac',
'audit-logs': 'rbac', 'audit-logs': 'rbac',
'mcp': 'servers', 'mcp': 'servers',
'prompts': 'prompts',
'promptrequests': 'promptrequests',
}; };
const resource = resourceMap[segment]; const resource = resourceMap[segment];
if (resource === undefined) return { kind: 'skip' }; 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 // Special case: /api/v1/projects/:id/mcp-config → requires 'expose' permission
const mcpConfigMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/mcp-config/); const mcpConfigMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/mcp-config/);
if (mcpConfigMatch?.[1]) { if (mcpConfigMatch?.[1]) {
@@ -243,11 +274,14 @@ async function main(): Promise<void> {
const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo); const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo);
const authService = new AuthService(prisma); const authService = new AuthService(prisma);
const templateService = new TemplateService(templateRepo); const templateService = new TemplateService(templateRepo);
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo); const mcpProxyService = new McpProxyService(instanceRepo, serverRepo, orchestrator);
const rbacDefinitionService = new RbacDefinitionService(rbacDefinitionRepo); const rbacDefinitionService = new RbacDefinitionService(rbacDefinitionRepo);
const rbacService = new RbacService(rbacDefinitionRepo, prisma); const rbacService = new RbacService(rbacDefinitionRepo, prisma);
const userService = new UserService(userRepo); const userService = new UserService(userRepo);
const groupService = new GroupService(groupRepo, 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 // Auth middleware for global hooks
const authMiddleware = createAuthMiddleware({ const authMiddleware = createAuthMiddleware({
@@ -294,9 +328,13 @@ async function main(): Promise<void> {
const check = mapUrlToPermission(request.method, url); const check = mapUrlToPermission(request.method, url);
if (check.kind === 'skip') return; 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; let allowed: boolean;
if (check.kind === 'operation') { if (check.kind === 'operation') {
allowed = await rbacService.canRunOperation(request.userId, check.operation); allowed = await rbacService.canRunOperation(request.userId, check.operation, serviceAccountName);
} else { } else {
// Resolve CUID → human name for name-scoped RBAC bindings // Resolve CUID → human name for name-scoped RBAC bindings
if (check.resourceName !== undefined && CUID_RE.test(check.resourceName)) { if (check.resourceName !== undefined && CUID_RE.test(check.resourceName)) {
@@ -306,10 +344,10 @@ async function main(): Promise<void> {
if (entity) check.resourceName = entity.name; if (entity) check.resourceName = entity.name;
} }
} }
allowed = await rbacService.canAccess(request.userId, check.action, check.resource, check.resourceName); allowed = await rbacService.canAccess(request.userId, check.action, check.resource, check.resourceName, serviceAccountName);
// Compute scope for list filtering (used by preSerialization hook) // Compute scope for list filtering (used by preSerialization hook)
if (allowed && check.resourceName === undefined) { if (allowed && check.resourceName === undefined) {
request.rbacScope = await rbacService.getAllowedScope(request.userId, check.action, check.resource); request.rbacScope = await rbacService.getAllowedScope(request.userId, check.action, check.resource, serviceAccountName);
} }
} }
if (!allowed) { if (!allowed) {
@@ -335,6 +373,7 @@ async function main(): Promise<void> {
registerRbacRoutes(app, rbacDefinitionService); registerRbacRoutes(app, rbacDefinitionService);
registerUserRoutes(app, userService); registerUserRoutes(app, userService);
registerGroupRoutes(app, groupService); registerGroupRoutes(app, groupService);
registerPromptRoutes(app, promptService, projectRepo);
// ── RBAC list filtering hook ── // ── RBAC list filtering hook ──
// Filters array responses to only include resources the user is allowed to see. // Filters array responses to only include resources the user is allowed to see.

View File

@@ -1,18 +1,18 @@
import type { PrismaClient, Project } from '@prisma/client'; import type { PrismaClient, Project } from '@prisma/client';
export interface ProjectWithRelations extends Project { export interface ProjectWithRelations extends Project {
servers: Array<{ id: string; server: { id: string; name: string } }>; servers: Array<{ id: string; projectId: string; serverId: string; server: Record<string, unknown> & { id: string; name: string } }>;
} }
const PROJECT_INCLUDE = { const PROJECT_INCLUDE = {
servers: { include: { server: { select: { id: true, name: true } } } }, servers: { include: { server: true } },
} as const; } as const;
export interface IProjectRepository { export interface IProjectRepository {
findAll(ownerId?: string): Promise<ProjectWithRelations[]>; findAll(ownerId?: string): Promise<ProjectWithRelations[]>;
findById(id: string): Promise<ProjectWithRelations | null>; findById(id: string): Promise<ProjectWithRelations | null>;
findByName(name: string): Promise<ProjectWithRelations | null>; findByName(name: string): Promise<ProjectWithRelations | null>;
create(data: { name: string; description: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string }): Promise<ProjectWithRelations>; 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>; update(id: string, data: Record<string, unknown>): Promise<ProjectWithRelations>;
delete(id: string): Promise<void>; delete(id: string): Promise<void>;
setServers(projectId: string, serverIds: string[]): Promise<void>; setServers(projectId: string, serverIds: string[]): Promise<void>;
@@ -36,13 +36,14 @@ export class ProjectRepository implements IProjectRepository {
return this.prisma.project.findUnique({ where: { name }, include: PROJECT_INCLUDE }) as unknown as Promise<ProjectWithRelations | null>; return this.prisma.project.findUnique({ where: { name }, include: PROJECT_INCLUDE }) as unknown as Promise<ProjectWithRelations | null>;
} }
async create(data: { name: string; description: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string }): Promise<ProjectWithRelations> { async create(data: { name: string; description: string; prompt?: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string }): Promise<ProjectWithRelations> {
const createData: Record<string, unknown> = { const createData: Record<string, unknown> = {
name: data.name, name: data.name,
description: data.description, description: data.description,
ownerId: data.ownerId, ownerId: data.ownerId,
proxyMode: data.proxyMode, proxyMode: data.proxyMode,
}; };
if (data.prompt !== undefined) createData['prompt'] = data.prompt;
if (data.llmProvider !== undefined) createData['llmProvider'] = data.llmProvider; if (data.llmProvider !== undefined) createData['llmProvider'] = data.llmProvider;
if (data.llmModel !== undefined) createData['llmModel'] = data.llmModel; if (data.llmModel !== undefined) createData['llmModel'] = data.llmModel;

View 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 } });
}
}

View 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 } });
}
}

View File

@@ -54,4 +54,16 @@ export function registerProjectRoutes(app: FastifyInstance, service: ProjectServ
const project = await service.resolveAndGet(request.params.id); const project = await service.resolveAndGet(request.params.id);
return project.servers.map((ps) => ps.server); 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,
})),
};
});
} }

View 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;
},
);
}

View File

@@ -1,7 +1,10 @@
import type { McpInstance } from '@prisma/client'; import type { McpInstance, McpServer } from '@prisma/client';
import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js'; import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js';
import type { McpOrchestrator } from './orchestrator.js';
import { NotFoundError } from './mcp-server.service.js'; import { NotFoundError } from './mcp-server.service.js';
import { InvalidStateError } from './instance.service.js'; import { InvalidStateError } from './instance.service.js';
import { sendViaSse } from './transport/sse-client.js';
import { sendViaStdio } from './transport/stdio-client.js';
export interface McpProxyRequest { export interface McpProxyRequest {
serverId: string; serverId: string;
@@ -38,17 +41,21 @@ export class McpProxyService {
constructor( constructor(
private readonly instanceRepo: IMcpInstanceRepository, private readonly instanceRepo: IMcpInstanceRepository,
private readonly serverRepo: IMcpServerRepository, private readonly serverRepo: IMcpServerRepository,
private readonly orchestrator?: McpOrchestrator,
) {} ) {}
async execute(request: McpProxyRequest): Promise<McpProxyResponse> { async execute(request: McpProxyRequest): Promise<McpProxyResponse> {
const server = await this.serverRepo.findById(request.serverId); const server = await this.serverRepo.findById(request.serverId);
if (!server) {
// External server: proxy directly to externalUrl throw new NotFoundError(`Server '${request.serverId}' not found`);
if (server?.externalUrl) {
return this.sendToExternal(server.id, server.externalUrl, request.method, request.params);
} }
// Managed server: find running instance // External server: proxy directly to externalUrl
if (server.externalUrl) {
return this.sendToExternal(server, request.method, request.params);
}
// Managed server: find running instance and dispatch by transport
const instances = await this.instanceRepo.findAll(request.serverId); const instances = await this.instanceRepo.findAll(request.serverId);
const running = instances.find((i) => i.status === 'RUNNING'); const running = instances.find((i) => i.status === 'RUNNING');
@@ -56,20 +63,95 @@ export class McpProxyService {
throw new NotFoundError(`No running instance found for server '${request.serverId}'`); throw new NotFoundError(`No running instance found for server '${request.serverId}'`);
} }
if (running.port === null || running.port === undefined) { return this.sendToManaged(server, running, request.method, request.params);
throw new InvalidStateError(
`Running instance '${running.id}' for server '${request.serverId}' has no port assigned`,
);
}
return this.sendJsonRpc(running, request.method, request.params);
} }
/** /**
* Send a JSON-RPC request to an external MCP server. * Send to an external MCP server. Dispatches based on transport type.
* Handles streamable-http protocol (session management + SSE response parsing).
*/ */
private async sendToExternal( private async sendToExternal(
server: McpServer,
method: string,
params?: Record<string, unknown>,
): Promise<McpProxyResponse> {
const url = server.externalUrl as string;
if (server.transport === 'SSE') {
return sendViaSse(url, method, params);
}
// STREAMABLE_HTTP (default for external)
return this.sendStreamableHttp(server.id, url, method, params);
}
/**
* Send to a managed (containerized) MCP server. Dispatches based on transport type.
*/
private async sendToManaged(
server: McpServer,
instance: McpInstance,
method: string,
params?: Record<string, unknown>,
): Promise<McpProxyResponse> {
const transport = server.transport as string;
// STDIO: use docker exec
if (transport === 'STDIO') {
if (!this.orchestrator) {
throw new InvalidStateError('Orchestrator required for STDIO transport');
}
if (!instance.containerId) {
throw new InvalidStateError(`Instance '${instance.id}' has no container ID`);
}
const packageName = server.packageName as string | null;
if (!packageName) {
throw new InvalidStateError(`Server '${server.id}' has no package name for STDIO transport`);
}
return sendViaStdio(this.orchestrator, instance.containerId, packageName, method, params);
}
// SSE or STREAMABLE_HTTP: need a base URL
const baseUrl = await this.resolveBaseUrl(instance, server);
if (transport === 'SSE') {
return sendViaSse(baseUrl, method, params);
}
// STREAMABLE_HTTP (default)
return this.sendStreamableHttp(server.id, baseUrl, method, params);
}
/**
* Resolve the base URL for an HTTP-based managed server.
* Prefers container internal IP on Docker network, falls back to localhost:port.
*/
private async resolveBaseUrl(instance: McpInstance, server: McpServer): Promise<string> {
const containerPort = (server.containerPort as number | null) ?? 3000;
if (this.orchestrator && instance.containerId) {
try {
const containerInfo = await this.orchestrator.inspectContainer(instance.containerId);
if (containerInfo.ip) {
return `http://${containerInfo.ip}:${containerPort}`;
}
} catch {
// Fall through to localhost
}
}
if (instance.port !== null && instance.port !== undefined) {
return `http://localhost:${instance.port}`;
}
throw new InvalidStateError(
`Cannot resolve URL for instance '${instance.id}': no container IP or host port`,
);
}
/**
* Send via streamable-http protocol with session management.
*/
private async sendStreamableHttp(
serverId: string, serverId: string,
url: string, url: string,
method: string, method: string,
@@ -109,14 +191,14 @@ export class McpProxyService {
// Session expired? Clear and retry once // Session expired? Clear and retry once
if (response.status === 400 || response.status === 404) { if (response.status === 400 || response.status === 404) {
this.sessions.delete(serverId); this.sessions.delete(serverId);
return this.sendToExternal(serverId, url, method, params); return this.sendStreamableHttp(serverId, url, method, params);
} }
return { return {
jsonrpc: '2.0', jsonrpc: '2.0',
id: 1, id: 1,
error: { error: {
code: -32000, code: -32000,
message: `External MCP server returned HTTP ${response.status}: ${response.statusText}`, message: `MCP server returned HTTP ${response.status}: ${response.statusText}`,
}, },
}; };
} }
@@ -126,8 +208,7 @@ export class McpProxyService {
} }
/** /**
* Initialize a streamable-http session with an external server. * Initialize a streamable-http session with a server.
* Sends `initialize` and `notifications/initialized`, caches the session ID.
*/ */
private async initSession(serverId: string, url: string): Promise<void> { private async initSession(serverId: string, url: string): Promise<void> {
const initBody = { const initBody = {
@@ -174,41 +255,4 @@ export class McpProxyService {
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }), body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
}); });
} }
private async sendJsonRpc(
instance: McpInstance,
method: string,
params?: Record<string, unknown>,
): Promise<McpProxyResponse> {
const url = `http://localhost:${instance.port}`;
const body: Record<string, unknown> = {
jsonrpc: '2.0',
id: 1,
method,
};
if (params !== undefined) {
body.params = params;
}
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
return {
jsonrpc: '2.0',
id: 1,
error: {
code: -32000,
message: `MCP server returned HTTP ${response.status}: ${response.statusText}`,
},
};
}
const result = (await response.json()) as McpProxyResponse;
return result;
}
} }

View File

@@ -53,6 +53,7 @@ export class ProjectService {
const project = await this.projectRepo.create({ const project = await this.projectRepo.create({
name: data.name, name: data.name,
description: data.description, description: data.description,
prompt: data.prompt,
ownerId, ownerId,
proxyMode: data.proxyMode, proxyMode: data.proxyMode,
...(data.llmProvider !== undefined ? { llmProvider: data.llmProvider } : {}), ...(data.llmProvider !== undefined ? { llmProvider: data.llmProvider } : {}),
@@ -75,6 +76,7 @@ export class ProjectService {
// Build update data for scalar fields // Build update data for scalar fields
const updateData: Record<string, unknown> = {}; const updateData: Record<string, unknown> = {};
if (data.description !== undefined) updateData['description'] = data.description; if (data.description !== undefined) updateData['description'] = data.description;
if (data.prompt !== undefined) updateData['prompt'] = data.prompt;
if (data.proxyMode !== undefined) updateData['proxyMode'] = data.proxyMode; if (data.proxyMode !== undefined) updateData['proxyMode'] = data.proxyMode;
if (data.llmProvider !== undefined) updateData['llmProvider'] = data.llmProvider; if (data.llmProvider !== undefined) updateData['llmProvider'] = data.llmProvider;
if (data.llmModel !== undefined) updateData['llmModel'] = data.llmModel; if (data.llmModel !== undefined) updateData['llmModel'] = data.llmModel;

View File

@@ -0,0 +1,137 @@
import type { Prompt, PromptRequest } from '@prisma/client';
import type { IPromptRepository } from '../repositories/prompt.repository.js';
import type { IPromptRequestRepository } from '../repositories/prompt-request.repository.js';
import type { IProjectRepository } from '../repositories/project.repository.js';
import { CreatePromptSchema, UpdatePromptSchema, CreatePromptRequestSchema } from '../validation/prompt.schema.js';
import { NotFoundError } from './mcp-server.service.js';
export class PromptService {
constructor(
private readonly promptRepo: IPromptRepository,
private readonly promptRequestRepo: IPromptRequestRepository,
private readonly projectRepo: IProjectRepository,
) {}
// ── Prompt CRUD ──
async listPrompts(projectId?: string): Promise<Prompt[]> {
return this.promptRepo.findAll(projectId);
}
async getPrompt(id: string): Promise<Prompt> {
const prompt = await this.promptRepo.findById(id);
if (prompt === null) throw new NotFoundError(`Prompt not found: ${id}`);
return prompt;
}
async createPrompt(input: unknown): Promise<Prompt> {
const data = CreatePromptSchema.parse(input);
if (data.projectId) {
const project = await this.projectRepo.findById(data.projectId);
if (project === null) throw new NotFoundError(`Project not found: ${data.projectId}`);
}
const createData: { name: string; content: string; projectId?: string } = {
name: data.name,
content: data.content,
};
if (data.projectId !== undefined) createData.projectId = data.projectId;
return this.promptRepo.create(createData);
}
async updatePrompt(id: string, input: unknown): Promise<Prompt> {
const data = UpdatePromptSchema.parse(input);
await this.getPrompt(id);
const updateData: { content?: string } = {};
if (data.content !== undefined) updateData.content = data.content;
return this.promptRepo.update(id, updateData);
}
async deletePrompt(id: string): Promise<void> {
await this.getPrompt(id);
await this.promptRepo.delete(id);
}
// ── PromptRequest CRUD ──
async listPromptRequests(projectId?: string): Promise<PromptRequest[]> {
return this.promptRequestRepo.findAll(projectId);
}
async getPromptRequest(id: string): Promise<PromptRequest> {
const req = await this.promptRequestRepo.findById(id);
if (req === null) throw new NotFoundError(`PromptRequest not found: ${id}`);
return req;
}
async deletePromptRequest(id: string): Promise<void> {
await this.getPromptRequest(id);
await this.promptRequestRepo.delete(id);
}
// ── Propose (LLM creates a PromptRequest) ──
async propose(input: unknown): Promise<PromptRequest> {
const data = CreatePromptRequestSchema.parse(input);
if (data.projectId) {
const project = await this.projectRepo.findById(data.projectId);
if (project === null) throw new NotFoundError(`Project not found: ${data.projectId}`);
}
const createData: { name: string; content: string; projectId?: string; createdBySession?: string; createdByUserId?: string } = {
name: data.name,
content: data.content,
};
if (data.projectId !== undefined) createData.projectId = data.projectId;
if (data.createdBySession !== undefined) createData.createdBySession = data.createdBySession;
if (data.createdByUserId !== undefined) createData.createdByUserId = data.createdByUserId;
return this.promptRequestRepo.create(createData);
}
// ── Approve (delete PromptRequest → create Prompt) ──
async approve(requestId: string): Promise<Prompt> {
const req = await this.getPromptRequest(requestId);
// Create the approved prompt
const createData: { name: string; content: string; projectId?: string } = {
name: req.name,
content: req.content,
};
if (req.projectId !== null) createData.projectId = req.projectId;
const prompt = await this.promptRepo.create(createData);
// Delete the request
await this.promptRequestRepo.delete(requestId);
return prompt;
}
// ── Visibility for MCP (approved prompts + session's pending requests) ──
async getVisiblePrompts(
projectId?: string,
sessionId?: string,
): Promise<Array<{ name: string; content: string; type: 'prompt' | 'promptrequest' }>> {
const results: Array<{ name: string; content: string; type: 'prompt' | 'promptrequest' }> = [];
// Approved prompts (project-scoped + global)
const prompts = await this.promptRepo.findAll(projectId);
for (const p of prompts) {
results.push({ name: p.name, content: p.content, type: 'prompt' });
}
// Session's own pending requests
if (sessionId) {
const requests = await this.promptRequestRepo.findBySession(sessionId, projectId);
for (const r of requests) {
results.push({ name: r.name, content: r.content, type: 'promptrequest' });
}
}
return results;
}
}

View File

@@ -50,8 +50,8 @@ export class RbacService {
* If provided, name-scoped bindings only match when their name equals this. * If provided, name-scoped bindings only match when their name equals this.
* If omitted (listing), name-scoped bindings still grant access. * If omitted (listing), name-scoped bindings still grant access.
*/ */
async canAccess(userId: string, action: RbacAction, resource: string, resourceName?: string): Promise<boolean> { async canAccess(userId: string, action: RbacAction, resource: string, resourceName?: string, serviceAccountName?: string): Promise<boolean> {
const permissions = await this.getPermissions(userId); const permissions = await this.getPermissions(userId, serviceAccountName);
const normalized = normalizeResource(resource); const normalized = normalizeResource(resource);
for (const perm of permissions) { for (const perm of permissions) {
@@ -73,8 +73,8 @@ export class RbacService {
* Check whether a user is allowed to perform a named operation. * Check whether a user is allowed to perform a named operation.
* Operations require an explicit 'run' role binding with a matching action. * Operations require an explicit 'run' role binding with a matching action.
*/ */
async canRunOperation(userId: string, operation: string): Promise<boolean> { async canRunOperation(userId: string, operation: string, serviceAccountName?: string): Promise<boolean> {
const permissions = await this.getPermissions(userId); const permissions = await this.getPermissions(userId, serviceAccountName);
for (const perm of permissions) { for (const perm of permissions) {
if ('action' in perm && perm.role === 'run' && perm.action === operation) { if ('action' in perm && perm.role === 'run' && perm.action === operation) {
@@ -90,8 +90,8 @@ export class RbacService {
* Returns wildcard:true if any matching binding is unscoped (no name constraint). * Returns wildcard:true if any matching binding is unscoped (no name constraint).
* Returns wildcard:false with a set of allowed names if all bindings are name-scoped. * Returns wildcard:false with a set of allowed names if all bindings are name-scoped.
*/ */
async getAllowedScope(userId: string, action: RbacAction, resource: string): Promise<AllowedScope> { async getAllowedScope(userId: string, action: RbacAction, resource: string, serviceAccountName?: string): Promise<AllowedScope> {
const permissions = await this.getPermissions(userId); const permissions = await this.getPermissions(userId, serviceAccountName);
const normalized = normalizeResource(resource); const normalized = normalizeResource(resource);
const names = new Set<string>(); const names = new Set<string>();
@@ -113,31 +113,35 @@ export class RbacService {
/** /**
* Collect all permissions for a user across all matching RbacDefinitions. * Collect all permissions for a user across all matching RbacDefinitions.
*/ */
async getPermissions(userId: string): Promise<Permission[]> { async getPermissions(userId: string, serviceAccountName?: string): Promise<Permission[]> {
// 1. Resolve user email // 1. Resolve user email
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { email: true }, select: { email: true },
}); });
if (user === null) return []; if (user === null && serviceAccountName === undefined) return [];
// 2. Resolve group names the user belongs to // 2. Resolve group names the user belongs to
let groupNames: string[] = [];
if (user !== null) {
const memberships = await this.prisma.groupMember.findMany({ const memberships = await this.prisma.groupMember.findMany({
where: { userId }, where: { userId },
select: { group: { select: { name: true } } }, select: { group: { select: { name: true } } },
}); });
const groupNames = memberships.map((m) => m.group.name); groupNames = memberships.map((m) => m.group.name);
}
// 3. Load all RbacDefinitions // 3. Load all RbacDefinitions
const definitions = await this.rbacRepo.findAll(); const definitions = await this.rbacRepo.findAll();
// 4. Find definitions where user is a subject // 4. Find definitions where user or service account is a subject
const permissions: Permission[] = []; const permissions: Permission[] = [];
for (const def of definitions) { for (const def of definitions) {
const subjects = def.subjects as RbacSubject[]; const subjects = def.subjects as RbacSubject[];
const matched = subjects.some((s) => { const matched = subjects.some((s) => {
if (s.kind === 'User') return s.name === user.email; if (s.kind === 'User') return user !== null && s.name === user.email;
if (s.kind === 'Group') return groupNames.includes(s.name); if (s.kind === 'Group') return groupNames.includes(s.name);
if (s.kind === 'ServiceAccount') return serviceAccountName !== undefined && s.name === serviceAccountName;
return false; return false;
}); });

View File

@@ -0,0 +1,2 @@
export { sendViaSse } from './sse-client.js';
export { sendViaStdio } from './stdio-client.js';

View File

@@ -0,0 +1,150 @@
import type { McpProxyResponse } from '../mcp-proxy-service.js';
/**
* SSE transport client for MCP servers using the legacy SSE protocol.
*
* Protocol: GET /sse → endpoint event with messages URL → POST to messages URL.
* Responses come back on the SSE stream, matched by JSON-RPC request ID.
*
* Each call opens a fresh SSE connection, initializes, sends the request,
* reads the response, and closes. Session caching may be added later.
*/
export async function sendViaSse(
baseUrl: string,
method: string,
params?: Record<string, unknown>,
timeoutMs = 30_000,
): Promise<McpProxyResponse> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
// 1. GET /sse → SSE stream
const sseResp = await fetch(`${baseUrl}/sse`, {
method: 'GET',
headers: { 'Accept': 'text/event-stream' },
signal: controller.signal,
});
if (!sseResp.ok) {
return errorResponse(`SSE connect failed: HTTP ${sseResp.status}`);
}
const reader = sseResp.body?.getReader();
if (!reader) {
return errorResponse('No SSE stream body');
}
// 2. Read until we get the endpoint event with messages URL
const decoder = new TextDecoder();
let buffer = '';
let messagesUrl = '';
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();
messagesUrl = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`;
}
}
const lines = buffer.split('\n');
buffer = lines[lines.length - 1] ?? '';
}
if (!messagesUrl) {
reader.cancel();
return errorResponse('No endpoint event from SSE stream');
}
const postHeaders = { 'Content-Type': 'application/json' };
// 3. Initialize
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-proxy', version: '0.1.0' },
},
}),
signal: controller.signal,
});
if (!initResp.ok) {
reader.cancel();
return errorResponse(`SSE initialize failed: HTTP ${initResp.status}`);
}
// 4. Send notifications/initialized
await fetch(messagesUrl, {
method: 'POST',
headers: postHeaders,
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
signal: controller.signal,
});
// 5. Send the actual request
const requestId = 2;
await fetch(messagesUrl, {
method: 'POST',
headers: postHeaders,
body: JSON.stringify({
jsonrpc: '2.0',
id: requestId,
method,
...(params !== undefined ? { params } : {}),
}),
signal: controller.signal,
});
// 6. Read response from SSE stream (matched by request ID)
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 });
for (const line of responseBuffer.split('\n')) {
if (line.startsWith('data: ')) {
try {
const parsed = JSON.parse(line.slice(6)) as McpProxyResponse;
if (parsed.id === requestId) {
clearTimeout(readTimeout);
reader.cancel();
return parsed;
}
} catch {
// Not valid JSON, skip
}
}
}
const respLines = responseBuffer.split('\n');
responseBuffer = respLines[respLines.length - 1] ?? '';
}
clearTimeout(readTimeout);
reader.cancel();
return errorResponse('No response received from SSE stream');
} finally {
clearTimeout(timer);
}
}
function errorResponse(message: string): McpProxyResponse {
return {
jsonrpc: '2.0',
id: 1,
error: { code: -32000, message },
};
}

View File

@@ -0,0 +1,118 @@
import type { McpOrchestrator } from '../orchestrator.js';
import type { McpProxyResponse } from '../mcp-proxy-service.js';
/**
* STDIO transport client for MCP servers running as Docker containers.
*
* Runs `docker exec` with an inline Node.js script that spawns the MCP server
* binary, pipes JSON-RPC messages via stdin/stdout, and returns the response.
*
* Each call is self-contained: initialize → notifications/initialized → request → response.
*/
export async function sendViaStdio(
orchestrator: McpOrchestrator,
containerId: string,
packageName: string,
method: string,
params?: Record<string, unknown>,
timeoutMs = 30_000,
): Promise<McpProxyResponse> {
const initMsg = JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'mcpctl-proxy', version: '0.1.0' },
},
});
const initializedMsg = JSON.stringify({
jsonrpc: '2.0',
method: 'notifications/initialized',
});
const requestBody: Record<string, unknown> = {
jsonrpc: '2.0',
id: 2,
method,
};
if (params !== undefined) {
requestBody.params = params;
}
const requestMsg = JSON.stringify(requestBody);
// Inline Node.js script that:
// 1. Spawns the MCP server binary via npx
// 2. Sends initialize → initialized → actual request via stdin
// 3. Reads stdout for JSON-RPC response with id: 2
// 4. Outputs the full JSON-RPC response to stdout
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;
process.stdout.write(JSON.stringify(msg));
proc.kill();
process.exit(0);
}
} catch {}
}
output = lines[lines.length - 1] || '';
});
proc.stderr.on('data', () => {});
proc.on('error', e => { process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:2,error:{code:-32000,message:e.message}})); process.exit(1); });
proc.on('exit', (code) => { if (!responded) { process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:2,error:{code:-32000,message:'process exited '+code}})); process.exit(1); } });
setTimeout(() => { if (!responded) { process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:2,error:{code:-32000,message:'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(requestMsg)} + '\\n');
}, 500);
}, 500);
`.trim();
try {
const result = await orchestrator.execInContainer(
containerId,
['node', '-e', probeScript],
{ timeoutMs },
);
if (result.exitCode === 0 && result.stdout.trim()) {
try {
return JSON.parse(result.stdout.trim()) as McpProxyResponse;
} catch {
return errorResponse(`Failed to parse STDIO response: ${result.stdout.slice(0, 200)}`);
}
}
// Try to parse error response from stdout
try {
return JSON.parse(result.stdout.trim()) as McpProxyResponse;
} catch {
const errorMsg = result.stderr.trim() || `docker exec exit code ${result.exitCode}`;
return errorResponse(errorMsg);
}
} catch (err) {
return errorResponse(err instanceof Error ? err.message : String(err));
}
}
function errorResponse(message: string): McpProxyResponse {
return {
jsonrpc: '2.0',
id: 2,
error: { code: -32000, message },
};
}

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
export const CreateProjectSchema = z.object({ export const CreateProjectSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
description: z.string().max(1000).default(''), description: z.string().max(1000).default(''),
prompt: z.string().max(10000).default(''),
proxyMode: z.enum(['direct', 'filtered']).default('direct'), proxyMode: z.enum(['direct', 'filtered']).default('direct'),
llmProvider: z.string().max(100).optional(), llmProvider: z.string().max(100).optional(),
llmModel: z.string().max(100).optional(), llmModel: z.string().max(100).optional(),
@@ -14,6 +15,7 @@ export const CreateProjectSchema = z.object({
export const UpdateProjectSchema = z.object({ export const UpdateProjectSchema = z.object({
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
prompt: z.string().max(10000).optional(),
proxyMode: z.enum(['direct', 'filtered']).optional(), proxyMode: z.enum(['direct', 'filtered']).optional(),
llmProvider: z.string().max(100).nullable().optional(), llmProvider: z.string().max(100).nullable().optional(),
llmModel: z.string().max(100).nullable().optional(), llmModel: z.string().max(100).nullable().optional(),

View File

@@ -0,0 +1,23 @@
import { z } from 'zod';
export const CreatePromptSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
content: z.string().min(1).max(50000),
projectId: z.string().optional(),
});
export const UpdatePromptSchema = z.object({
content: z.string().min(1).max(50000).optional(),
});
export const CreatePromptRequestSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
content: z.string().min(1).max(50000),
projectId: z.string().optional(),
createdBySession: z.string().optional(),
createdByUserId: z.string().optional(),
});
export type CreatePromptInput = z.infer<typeof CreatePromptSchema>;
export type UpdatePromptInput = z.infer<typeof UpdatePromptSchema>;
export type CreatePromptRequestInput = z.infer<typeof CreatePromptRequestSchema>;

View File

@@ -1,7 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const; export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const;
export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'projects', 'templates', 'users', 'groups', 'rbac'] as const; export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests'] as const;
/** Singular→plural map for resource names. */ /** Singular→plural map for resource names. */
const RESOURCE_ALIASES: Record<string, string> = { const RESOURCE_ALIASES: Record<string, string> = {
@@ -12,6 +12,8 @@ const RESOURCE_ALIASES: Record<string, string> = {
template: 'templates', template: 'templates',
user: 'users', user: 'users',
group: 'groups', group: 'groups',
prompt: 'prompts',
promptrequest: 'promptrequests',
}; };
/** Normalize a resource name to its canonical plural form. */ /** Normalize a resource name to its canonical plural form. */
@@ -20,7 +22,7 @@ export function normalizeResource(resource: string): string {
} }
export const RbacSubjectSchema = z.object({ export const RbacSubjectSchema = z.object({
kind: z.enum(['User', 'Group']), kind: z.enum(['User', 'Group', 'ServiceAccount']),
name: z.string().min(1), name: z.string().min(1),
}); });

View File

@@ -0,0 +1,302 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PromptService } from '../../src/services/prompt.service.js';
import type { IPromptRepository } from '../../src/repositories/prompt.repository.js';
import type { IPromptRequestRepository } from '../../src/repositories/prompt-request.repository.js';
import type { IProjectRepository } from '../../src/repositories/project.repository.js';
import type { Prompt, PromptRequest, Project } from '@prisma/client';
function makePrompt(overrides: Partial<Prompt> = {}): Prompt {
return {
id: 'prompt-1',
name: 'test-prompt',
content: 'Hello world',
projectId: null,
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function makePromptRequest(overrides: Partial<PromptRequest> = {}): PromptRequest {
return {
id: 'req-1',
name: 'test-request',
content: 'Proposed content',
projectId: null,
createdBySession: 'session-abc',
createdByUserId: null,
createdAt: new Date(),
...overrides,
};
}
function makeProject(overrides: Partial<Project> = {}): Project {
return {
id: 'proj-1',
name: 'test-project',
description: '',
prompt: '',
proxyMode: 'direct',
llmProvider: null,
llmModel: null,
ownerId: 'user-1',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
} as Project;
}
function mockPromptRepo(): IPromptRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByNameAndProject: vi.fn(async () => null),
create: vi.fn(async (data) => makePrompt(data)),
update: vi.fn(async (id, data) => makePrompt({ id, ...data })),
delete: vi.fn(async () => {}),
};
}
function mockPromptRequestRepo(): IPromptRequestRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByNameAndProject: vi.fn(async () => null),
findBySession: vi.fn(async () => []),
create: vi.fn(async (data) => makePromptRequest(data)),
delete: vi.fn(async () => {}),
};
}
function mockProjectRepo(): IProjectRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async (data) => makeProject(data)),
update: vi.fn(async (id, data) => makeProject({ id, ...data })),
delete: vi.fn(async () => {}),
};
}
describe('PromptService', () => {
let promptRepo: IPromptRepository;
let promptRequestRepo: IPromptRequestRepository;
let projectRepo: IProjectRepository;
let service: PromptService;
beforeEach(() => {
promptRepo = mockPromptRepo();
promptRequestRepo = mockPromptRequestRepo();
projectRepo = mockProjectRepo();
service = new PromptService(promptRepo, promptRequestRepo, projectRepo);
});
// ── Prompt CRUD ──
describe('listPrompts', () => {
it('should return all prompts', async () => {
const prompts = [makePrompt(), makePrompt({ id: 'prompt-2', name: 'other' })];
vi.mocked(promptRepo.findAll).mockResolvedValue(prompts);
const result = await service.listPrompts();
expect(result).toEqual(prompts);
expect(promptRepo.findAll).toHaveBeenCalledWith(undefined);
});
it('should filter by projectId', async () => {
await service.listPrompts('proj-1');
expect(promptRepo.findAll).toHaveBeenCalledWith('proj-1');
});
});
describe('getPrompt', () => {
it('should return a prompt by id', async () => {
const prompt = makePrompt();
vi.mocked(promptRepo.findById).mockResolvedValue(prompt);
const result = await service.getPrompt('prompt-1');
expect(result).toEqual(prompt);
});
it('should throw NotFoundError for missing prompt', async () => {
await expect(service.getPrompt('nope')).rejects.toThrow('Prompt not found: nope');
});
});
describe('createPrompt', () => {
it('should create a prompt', async () => {
const result = await service.createPrompt({ name: 'new-prompt', content: 'stuff' });
expect(promptRepo.create).toHaveBeenCalledWith({ name: 'new-prompt', content: 'stuff' });
expect(result.name).toBe('new-prompt');
});
it('should validate project exists when projectId given', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject());
await service.createPrompt({ name: 'scoped', content: 'x', projectId: 'proj-1' });
expect(projectRepo.findById).toHaveBeenCalledWith('proj-1');
});
it('should throw when project not found', async () => {
await expect(
service.createPrompt({ name: 'bad', content: 'x', projectId: 'nope' }),
).rejects.toThrow('Project not found: nope');
});
it('should reject invalid name format', async () => {
await expect(
service.createPrompt({ name: 'INVALID_NAME', content: 'x' }),
).rejects.toThrow();
});
});
describe('updatePrompt', () => {
it('should update prompt content', async () => {
vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt());
await service.updatePrompt('prompt-1', { content: 'updated' });
expect(promptRepo.update).toHaveBeenCalledWith('prompt-1', { content: 'updated' });
});
it('should throw for missing prompt', async () => {
await expect(service.updatePrompt('nope', { content: 'x' })).rejects.toThrow('Prompt not found');
});
});
describe('deletePrompt', () => {
it('should delete an existing prompt', async () => {
vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt());
await service.deletePrompt('prompt-1');
expect(promptRepo.delete).toHaveBeenCalledWith('prompt-1');
});
it('should throw for missing prompt', async () => {
await expect(service.deletePrompt('nope')).rejects.toThrow('Prompt not found');
});
});
// ── PromptRequest CRUD ──
describe('listPromptRequests', () => {
it('should return all prompt requests', async () => {
const reqs = [makePromptRequest()];
vi.mocked(promptRequestRepo.findAll).mockResolvedValue(reqs);
const result = await service.listPromptRequests();
expect(result).toEqual(reqs);
});
});
describe('getPromptRequest', () => {
it('should return a prompt request by id', async () => {
const req = makePromptRequest();
vi.mocked(promptRequestRepo.findById).mockResolvedValue(req);
const result = await service.getPromptRequest('req-1');
expect(result).toEqual(req);
});
it('should throw for missing request', async () => {
await expect(service.getPromptRequest('nope')).rejects.toThrow('PromptRequest not found');
});
});
describe('deletePromptRequest', () => {
it('should delete an existing request', async () => {
vi.mocked(promptRequestRepo.findById).mockResolvedValue(makePromptRequest());
await service.deletePromptRequest('req-1');
expect(promptRequestRepo.delete).toHaveBeenCalledWith('req-1');
});
});
// ── Propose ──
describe('propose', () => {
it('should create a prompt request', async () => {
const result = await service.propose({
name: 'my-prompt',
content: 'proposal',
createdBySession: 'sess-1',
});
expect(promptRequestRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ name: 'my-prompt', content: 'proposal', createdBySession: 'sess-1' }),
);
expect(result.name).toBe('my-prompt');
});
it('should validate project exists when projectId given', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject());
await service.propose({
name: 'scoped',
content: 'x',
projectId: 'proj-1',
});
expect(projectRepo.findById).toHaveBeenCalledWith('proj-1');
});
});
// ── Approve ──
describe('approve', () => {
it('should delete request and create prompt (atomic)', async () => {
const req = makePromptRequest({ id: 'req-1', name: 'approved', content: 'good stuff', projectId: 'proj-1' });
vi.mocked(promptRequestRepo.findById).mockResolvedValue(req);
const result = await service.approve('req-1');
expect(promptRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ name: 'approved', content: 'good stuff', projectId: 'proj-1' }),
);
expect(promptRequestRepo.delete).toHaveBeenCalledWith('req-1');
expect(result.name).toBe('approved');
});
it('should throw for missing request', async () => {
await expect(service.approve('nope')).rejects.toThrow('PromptRequest not found');
});
it('should handle global prompt (no projectId)', async () => {
const req = makePromptRequest({ id: 'req-2', name: 'global', content: 'stuff', projectId: null });
vi.mocked(promptRequestRepo.findById).mockResolvedValue(req);
await service.approve('req-2');
// Should NOT include projectId in the create call
const createArg = vi.mocked(promptRepo.create).mock.calls[0]![0];
expect(createArg).not.toHaveProperty('projectId');
});
});
// ── Visibility ──
describe('getVisiblePrompts', () => {
it('should return approved prompts and session requests', async () => {
vi.mocked(promptRepo.findAll).mockResolvedValue([
makePrompt({ name: 'approved-1', content: 'A' }),
]);
vi.mocked(promptRequestRepo.findBySession).mockResolvedValue([
makePromptRequest({ name: 'pending-1', content: 'B' }),
]);
const result = await service.getVisiblePrompts('proj-1', 'sess-1');
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ name: 'approved-1', content: 'A', type: 'prompt' });
expect(result[1]).toEqual({ name: 'pending-1', content: 'B', type: 'promptrequest' });
});
it('should not include pending requests without sessionId', async () => {
vi.mocked(promptRepo.findAll).mockResolvedValue([makePrompt()]);
const result = await service.getVisiblePrompts('proj-1');
expect(result).toHaveLength(1);
expect(promptRequestRepo.findBySession).not.toHaveBeenCalled();
});
it('should return empty when no prompts or requests', async () => {
const result = await service.getVisiblePrompts();
expect(result).toEqual([]);
});
});
});

View File

@@ -5,6 +5,7 @@ import { McpdUpstream } from './upstream/mcpd.js';
interface McpdServer { interface McpdServer {
id: string; id: string;
name: string; name: string;
description?: string;
transport: string; transport: string;
status?: string; status?: string;
} }
@@ -35,7 +36,7 @@ export async function refreshProjectUpstreams(
let servers: McpdServer[]; let servers: McpdServer[];
if (authToken) { if (authToken) {
// Forward the client's auth token to mcpd so RBAC applies // Forward the client's auth token to mcpd so RBAC applies
const result = await mcpdClient.forward('GET', path, '', undefined); const result = await mcpdClient.forward('GET', path, '', undefined, authToken);
if (result.status >= 400) { if (result.status >= 400) {
throw new Error(`Failed to fetch project servers: ${result.status}`); throw new Error(`Failed to fetch project servers: ${result.status}`);
} }
@@ -63,7 +64,7 @@ function syncUpstreams(router: McpRouter, mcpdClient: McpdClient, servers: McpdS
// Add/update upstreams for each server // Add/update upstreams for each server
for (const server of servers) { for (const server of servers) {
if (!currentNames.has(server.name)) { if (!currentNames.has(server.name)) {
const upstream = new McpdUpstream(server.id, server.name, mcpdClient); const upstream = new McpdUpstream(server.id, server.name, mcpdClient, server.description);
router.addUpstream(upstream); router.addUpstream(upstream);
} }
registered.push(server.name); registered.push(server.name);

View File

@@ -23,11 +23,21 @@ export class ConnectionError extends Error {
export class McpdClient { export class McpdClient {
private readonly baseUrl: string; private readonly baseUrl: string;
private readonly token: string; private readonly token: string;
private readonly extraHeaders: Record<string, string>;
constructor(baseUrl: string, token: string) { constructor(baseUrl: string, token: string, extraHeaders?: Record<string, string>) {
// Strip trailing slash for consistent URL joining // Strip trailing slash for consistent URL joining
this.baseUrl = baseUrl.replace(/\/+$/, ''); this.baseUrl = baseUrl.replace(/\/+$/, '');
this.token = token; this.token = token;
this.extraHeaders = extraHeaders ?? {};
}
/**
* Create a new client with additional default headers.
* Inherits base URL and token from the current client.
*/
withHeaders(headers: Record<string, string>): McpdClient {
return new McpdClient(this.baseUrl, this.token, { ...this.extraHeaders, ...headers });
} }
async get<T>(path: string): Promise<T> { async get<T>(path: string): Promise<T> {
@@ -62,6 +72,7 @@ export class McpdClient {
): Promise<{ status: number; body: unknown }> { ): Promise<{ status: number; body: unknown }> {
const url = `${this.baseUrl}${path}${query ? `?${query}` : ''}`; const url = `${this.baseUrl}${path}${query ? `?${query}` : ''}`;
const headers: Record<string, string> = { const headers: Record<string, string> = {
...this.extraHeaders,
'Authorization': `Bearer ${authOverride ?? this.token}`, 'Authorization': `Bearer ${authOverride ?? this.token}`,
'Accept': 'application/json', 'Accept': 'application/json',
}; };

View File

@@ -44,6 +44,32 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
const router = existing?.router ?? new McpRouter(); const router = existing?.router ?? new McpRouter();
await refreshProjectUpstreams(router, mcpdClient, projectName, authToken); await refreshProjectUpstreams(router, mcpdClient, projectName, authToken);
// Configure prompt resources with SA-scoped client for RBAC
const saClient = mcpdClient.withHeaders({ 'X-Service-Account': `project:${projectName}` });
router.setPromptConfig(saClient, projectName);
// Fetch project instructions and set on router
try {
const instructions = await mcpdClient.get<{ prompt: string; servers: Array<{ name: string; description: string }> }>(
`/api/v1/projects/${encodeURIComponent(projectName)}/instructions`,
);
const parts: string[] = [];
if (instructions.prompt) {
parts.push(instructions.prompt);
}
if (instructions.servers.length > 0) {
parts.push('Available MCP servers:');
for (const s of instructions.servers) {
parts.push(`- ${s.name}${s.description ? `: ${s.description}` : ''}`);
}
}
if (parts.length > 0) {
router.setInstructions(parts.join('\n'));
}
} catch {
// Instructions are optional — don't fail if endpoint is unavailable
}
projectCache.set(projectName, { router, lastRefresh: now }); projectCache.set(projectName, { router, lastRefresh: now });
return router; return router;
} }
@@ -84,7 +110,8 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp
transport.onmessage = async (message: JSONRPCMessage) => { transport.onmessage = async (message: JSONRPCMessage) => {
if ('method' in message && 'id' in message) { if ('method' in message && 'id' in message) {
const response = await router.route(message as unknown as JsonRpcRequest); const ctx = transport.sessionId ? { sessionId: transport.sessionId } : undefined;
const response = await router.route(message as unknown as JsonRpcRequest, ctx);
await transport.send(response as unknown as JSONRPCMessage); await transport.send(response as unknown as JSONRPCMessage);
} }
}; };

View File

@@ -1,5 +1,10 @@
import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from './types.js'; import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from './types.js';
import type { LlmProcessor } from './llm/processor.js'; import type { LlmProcessor } from './llm/processor.js';
import type { McpdClient } from './http/mcpd-client.js';
export interface RouteContext {
sessionId?: string;
}
/** /**
* Routes MCP requests to the appropriate upstream server. * Routes MCP requests to the appropriate upstream server.
@@ -17,11 +22,24 @@ export class McpRouter {
private promptToServer = new Map<string, string>(); private promptToServer = new Map<string, string>();
private notificationHandler: ((notification: JsonRpcNotification) => void) | null = null; private notificationHandler: ((notification: JsonRpcNotification) => void) | null = null;
private llmProcessor: LlmProcessor | null = null; private llmProcessor: LlmProcessor | null = null;
private instructions: string | null = null;
private mcpdClient: McpdClient | null = null;
private projectName: string | null = null;
private mcpctlResourceContents = new Map<string, string>();
setLlmProcessor(processor: LlmProcessor): void { setLlmProcessor(processor: LlmProcessor): void {
this.llmProcessor = processor; this.llmProcessor = processor;
} }
setInstructions(instructions: string): void {
this.instructions = instructions;
}
setPromptConfig(mcpdClient: McpdClient, projectName: string): void {
this.mcpdClient = mcpdClient;
this.projectName = projectName;
}
addUpstream(connection: UpstreamConnection): void { addUpstream(connection: UpstreamConnection): void {
this.upstreams.set(connection.name, connection); this.upstreams.set(connection.name, connection);
if (this.notificationHandler && connection.onNotification) { if (this.notificationHandler && connection.onNotification) {
@@ -87,10 +105,18 @@ export class McpRouter {
for (const tool of tools) { for (const tool of tools) {
const namespacedName = `${serverName}/${tool.name}`; const namespacedName = `${serverName}/${tool.name}`;
this.toolToServer.set(namespacedName, serverName); this.toolToServer.set(namespacedName, serverName);
allTools.push({ // Enrich description with server context if available
const entry: { name: string; description?: string; inputSchema?: unknown } = {
...tool, ...tool,
name: namespacedName, name: namespacedName,
}); };
if (upstream.description && tool.description) {
entry.description = `[${upstream.description}] ${tool.description}`;
} else if (upstream.description) {
entry.description = `[${upstream.description}]`;
}
// If neither upstream.description nor tool.description, keep tool.description (may be undefined — that's fine, just don't set it)
allTools.push(entry);
} }
} }
} catch { } catch {
@@ -223,7 +249,7 @@ export class McpRouter {
* Route a generic request. Handles protocol-level methods locally, * Route a generic request. Handles protocol-level methods locally,
* delegates tool/resource/prompt calls to upstreams. * delegates tool/resource/prompt calls to upstreams.
*/ */
async route(request: JsonRpcRequest): Promise<JsonRpcResponse> { async route(request: JsonRpcRequest, context?: RouteContext): Promise<JsonRpcResponse> {
switch (request.method) { switch (request.method) {
case 'initialize': case 'initialize':
return { return {
@@ -240,11 +266,27 @@ export class McpRouter {
resources: {}, resources: {},
prompts: {}, prompts: {},
}, },
...(this.instructions ? { instructions: this.instructions } : {}),
}, },
}; };
case 'tools/list': { case 'tools/list': {
const tools = await this.discoverTools(); const tools = await this.discoverTools();
// Append propose_prompt tool if prompt config is set
if (this.mcpdClient && this.projectName) {
tools.push({
name: 'propose_prompt',
description: 'Propose a new prompt for this project. Creates a pending request that must be approved by a user before becoming active.',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Prompt name (lowercase alphanumeric with hyphens, e.g. "debug-guide")' },
content: { type: 'string', description: 'Prompt content text' },
},
required: ['name', 'content'],
},
});
}
return { return {
jsonrpc: '2.0', jsonrpc: '2.0',
id: request.id, id: request.id,
@@ -253,10 +295,32 @@ export class McpRouter {
} }
case 'tools/call': case 'tools/call':
return this.routeToolCall(request); return this.routeToolCall(request, context);
case 'resources/list': { case 'resources/list': {
const resources = await this.discoverResources(); const resources = await this.discoverResources();
// Append mcpctl prompt resources
if (this.mcpdClient && this.projectName) {
try {
const sessionParam = context?.sessionId ? `?session=${encodeURIComponent(context.sessionId)}` : '';
const visible = await this.mcpdClient.get<Array<{ name: string; content: string; type: string }>>(
`/api/v1/projects/${encodeURIComponent(this.projectName)}/prompts/visible${sessionParam}`,
);
this.mcpctlResourceContents.clear();
for (const p of visible) {
const uri = `mcpctl://prompts/${p.name}`;
resources.push({
uri,
name: p.name,
description: p.type === 'promptrequest' ? `[Pending proposal] ${p.name}` : `[Approved prompt] ${p.name}`,
mimeType: 'text/plain',
});
this.mcpctlResourceContents.set(uri, p.content);
}
} catch {
// Prompt resources are optional — don't fail discovery
}
}
return { return {
jsonrpc: '2.0', jsonrpc: '2.0',
id: request.id, id: request.id,
@@ -264,8 +328,28 @@ export class McpRouter {
}; };
} }
case 'resources/read': case 'resources/read': {
const params = request.params as Record<string, unknown> | undefined;
const uri = params?.['uri'] as string | undefined;
if (uri?.startsWith('mcpctl://')) {
const content = this.mcpctlResourceContents.get(uri);
if (content !== undefined) {
return {
jsonrpc: '2.0',
id: request.id,
result: {
contents: [{ uri, mimeType: 'text/plain', text: content }],
},
};
}
return {
jsonrpc: '2.0',
id: request.id,
error: { code: -32602, message: `Resource not found: ${uri}` },
};
}
return this.routeNamespacedCall(request, 'uri', this.resourceToServer); return this.routeNamespacedCall(request, 'uri', this.resourceToServer);
}
case 'resources/subscribe': case 'resources/subscribe':
case 'resources/unsubscribe': case 'resources/unsubscribe':
@@ -295,10 +379,15 @@ export class McpRouter {
/** /**
* Route a tools/call request, optionally applying LLM pre/post-processing. * Route a tools/call request, optionally applying LLM pre/post-processing.
*/ */
private async routeToolCall(request: JsonRpcRequest): Promise<JsonRpcResponse> { private async routeToolCall(request: JsonRpcRequest, context?: RouteContext): Promise<JsonRpcResponse> {
const params = request.params as Record<string, unknown> | undefined; const params = request.params as Record<string, unknown> | undefined;
const toolName = params?.['name'] as string | undefined; const toolName = params?.['name'] as string | undefined;
// Handle built-in propose_prompt tool
if (toolName === 'propose_prompt') {
return this.handleProposePrompt(request, context);
}
// If no processor or tool shouldn't be processed, route directly // If no processor or tool shouldn't be processed, route directly
if (!this.llmProcessor || !toolName || !this.llmProcessor.shouldProcess('tools/call', toolName)) { if (!this.llmProcessor || !toolName || !this.llmProcessor.shouldProcess('tools/call', toolName)) {
return this.routeNamespacedCall(request, 'name', this.toolToServer); return this.routeNamespacedCall(request, 'name', this.toolToServer);
@@ -323,6 +412,61 @@ export class McpRouter {
return response; return response;
} }
private async handleProposePrompt(request: JsonRpcRequest, context?: RouteContext): Promise<JsonRpcResponse> {
if (!this.mcpdClient || !this.projectName) {
return {
jsonrpc: '2.0',
id: request.id,
error: { code: -32603, message: 'Prompt config not set — propose_prompt unavailable' },
};
}
const params = request.params as Record<string, unknown> | undefined;
const args = (params?.['arguments'] ?? {}) as Record<string, unknown>;
const name = args['name'] as string | undefined;
const content = args['content'] as string | undefined;
if (!name || !content) {
return {
jsonrpc: '2.0',
id: request.id,
error: { code: -32602, message: 'Missing required arguments: name and content' },
};
}
try {
const body: Record<string, unknown> = { name, content };
if (context?.sessionId) {
body['createdBySession'] = context.sessionId;
}
await this.mcpdClient.post(
`/api/v1/projects/${encodeURIComponent(this.projectName)}/promptrequests`,
body,
);
return {
jsonrpc: '2.0',
id: request.id,
result: {
content: [
{
type: 'text',
text: `Prompt request "${name}" created successfully. It will be visible to you as a resource at mcpctl://prompts/${name}. A user must approve it before it becomes permanent.`,
},
],
},
};
} catch (err) {
return {
jsonrpc: '2.0',
id: request.id,
error: {
code: -32603,
message: `Failed to propose prompt: ${err instanceof Error ? err.message : String(err)}`,
},
};
}
}
getUpstreamNames(): string[] { getUpstreamNames(): string[] {
return [...this.upstreams.keys()]; return [...this.upstreams.keys()];
} }

View File

@@ -63,6 +63,8 @@ export interface ProxyConfig {
export interface UpstreamConnection { export interface UpstreamConnection {
/** Server name */ /** Server name */
name: string; name: string;
/** Human-readable description of the server's purpose */
description?: string;
/** Send a JSON-RPC request and get a response */ /** Send a JSON-RPC request and get a response */
send(request: JsonRpcRequest): Promise<JsonRpcResponse>; send(request: JsonRpcRequest): Promise<JsonRpcResponse>;
/** Disconnect from the upstream */ /** Disconnect from the upstream */

View File

@@ -18,14 +18,17 @@ interface McpdProxyResponse {
*/ */
export class McpdUpstream implements UpstreamConnection { export class McpdUpstream implements UpstreamConnection {
readonly name: string; readonly name: string;
readonly description?: string;
private alive = true; private alive = true;
constructor( constructor(
private serverId: string, private serverId: string,
serverName: string, serverName: string,
private mcpdClient: McpdClient, private mcpdClient: McpdClient,
serverDescription?: string,
) { ) {
this.name = serverName; this.name = serverName;
if (serverDescription !== undefined) this.description = serverDescription;
} }
async send(request: JsonRpcRequest): Promise<JsonRpcResponse> { async send(request: JsonRpcRequest): Promise<JsonRpcResponse> {

View File

@@ -54,7 +54,7 @@ describe('refreshProjectUpstreams', () => {
const client = mockMcpdClient(servers); const client = mockMcpdClient(servers);
await refreshProjectUpstreams(router, client as any, 'smart-home', 'user-token-123'); await refreshProjectUpstreams(router, client as any, 'smart-home', 'user-token-123');
expect(client.forward).toHaveBeenCalledWith('GET', '/api/v1/projects/smart-home/servers', '', undefined); expect(client.forward).toHaveBeenCalledWith('GET', '/api/v1/projects/smart-home/servers', '', undefined, 'user-token-123');
expect(router.getUpstreamNames()).toContain('grafana'); expect(router.getUpstreamNames()).toContain('grafana');
}); });

View File

@@ -11,7 +11,7 @@ vi.mock('../src/discovery.js', () => ({
import { refreshProjectUpstreams } from '../src/discovery.js'; import { refreshProjectUpstreams } from '../src/discovery.js';
function mockMcpdClient() { function mockMcpdClient() {
return { const client: Record<string, unknown> = {
baseUrl: 'http://test:3100', baseUrl: 'http://test:3100',
token: 'test-token', token: 'test-token',
get: vi.fn(async () => []), get: vi.fn(async () => []),
@@ -19,7 +19,11 @@ function mockMcpdClient() {
put: vi.fn(), put: vi.fn(),
delete: vi.fn(), delete: vi.fn(),
forward: vi.fn(async () => ({ status: 200, body: [] })), forward: vi.fn(async () => ({ status: 200, body: [] })),
withHeaders: vi.fn(),
}; };
// withHeaders returns a new client-like object (returns self for simplicity)
(client.withHeaders as ReturnType<typeof vi.fn>).mockReturnValue(client);
return client;
} }
describe('registerProjectMcpEndpoint', () => { describe('registerProjectMcpEndpoint', () => {

View File

@@ -0,0 +1,248 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { McpRouter } from '../src/router.js';
import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from '../src/types.js';
import type { McpdClient } from '../src/http/mcpd-client.js';
function mockUpstream(name: string, opts?: {
tools?: Array<{ name: string; description?: string; inputSchema?: unknown }>;
}): UpstreamConnection {
return {
name,
isAlive: vi.fn(() => true),
close: vi.fn(async () => {}),
onNotification: vi.fn(),
send: vi.fn(async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
if (req.method === 'tools/list') {
return { jsonrpc: '2.0', id: req.id, result: { tools: opts?.tools ?? [] } };
}
if (req.method === 'resources/list') {
return { jsonrpc: '2.0', id: req.id, result: { resources: [] } };
}
return { jsonrpc: '2.0', id: req.id, result: {} };
}),
};
}
function mockMcpdClient(): McpdClient {
return {
get: vi.fn(async () => []),
post: vi.fn(async () => ({})),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
forward: vi.fn(async () => ({ status: 200, body: {} })),
withHeaders: vi.fn(function (this: McpdClient) { return this; }),
} as unknown as McpdClient;
}
describe('McpRouter - Prompt Integration', () => {
let router: McpRouter;
let mcpdClient: McpdClient;
beforeEach(() => {
router = new McpRouter();
mcpdClient = mockMcpdClient();
});
describe('propose_prompt tool', () => {
it('should include propose_prompt in tools/list when prompt config is set', async () => {
router.setPromptConfig(mcpdClient, 'test-project');
router.addUpstream(mockUpstream('server1'));
const response = await router.route({
jsonrpc: '2.0',
id: 1,
method: 'tools/list',
});
const tools = (response.result as { tools: Array<{ name: string }> }).tools;
expect(tools.some((t) => t.name === 'propose_prompt')).toBe(true);
});
it('should NOT include propose_prompt when no prompt config', async () => {
router.addUpstream(mockUpstream('server1'));
const response = await router.route({
jsonrpc: '2.0',
id: 1,
method: 'tools/list',
});
const tools = (response.result as { tools: Array<{ name: string }> }).tools;
expect(tools.some((t) => t.name === 'propose_prompt')).toBe(false);
});
it('should call mcpd to create a prompt request', async () => {
router.setPromptConfig(mcpdClient, 'my-project');
const response = await router.route(
{
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'propose_prompt',
arguments: { name: 'my-prompt', content: 'Hello world' },
},
},
{ sessionId: 'sess-123' },
);
expect(response.error).toBeUndefined();
expect(mcpdClient.post).toHaveBeenCalledWith(
'/api/v1/projects/my-project/promptrequests',
{ name: 'my-prompt', content: 'Hello world', createdBySession: 'sess-123' },
);
});
it('should return error when name or content missing', async () => {
router.setPromptConfig(mcpdClient, 'proj');
const response = await router.route({
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
params: {
name: 'propose_prompt',
arguments: { name: 'only-name' },
},
});
expect(response.error?.code).toBe(-32602);
expect(response.error?.message).toContain('Missing required arguments');
});
it('should return error when mcpd call fails', async () => {
router.setPromptConfig(mcpdClient, 'proj');
vi.mocked(mcpdClient.post).mockRejectedValue(new Error('mcpd returned 409'));
const response = await router.route({
jsonrpc: '2.0',
id: 4,
method: 'tools/call',
params: {
name: 'propose_prompt',
arguments: { name: 'dup', content: 'x' },
},
});
expect(response.error?.code).toBe(-32603);
expect(response.error?.message).toContain('mcpd returned 409');
});
});
describe('prompt resources', () => {
it('should include prompt resources in resources/list', async () => {
router.setPromptConfig(mcpdClient, 'test-project');
vi.mocked(mcpdClient.get).mockResolvedValue([
{ name: 'approved-prompt', content: 'Content A', type: 'prompt' },
{ name: 'pending-req', content: 'Content B', type: 'promptrequest' },
]);
const response = await router.route(
{ jsonrpc: '2.0', id: 1, method: 'resources/list' },
{ sessionId: 'sess-1' },
);
const resources = (response.result as { resources: Array<{ uri: string; description?: string }> }).resources;
expect(resources).toHaveLength(2);
expect(resources[0]!.uri).toBe('mcpctl://prompts/approved-prompt');
expect(resources[0]!.description).toContain('Approved');
expect(resources[1]!.uri).toBe('mcpctl://prompts/pending-req');
expect(resources[1]!.description).toContain('Pending');
});
it('should pass session ID when fetching visible prompts', async () => {
router.setPromptConfig(mcpdClient, 'proj');
vi.mocked(mcpdClient.get).mockResolvedValue([]);
await router.route(
{ jsonrpc: '2.0', id: 1, method: 'resources/list' },
{ sessionId: 'my-session' },
);
expect(mcpdClient.get).toHaveBeenCalledWith(
'/api/v1/projects/proj/prompts/visible?session=my-session',
);
});
it('should read mcpctl resource content', async () => {
router.setPromptConfig(mcpdClient, 'proj');
vi.mocked(mcpdClient.get).mockResolvedValue([
{ name: 'my-prompt', content: 'The content here', type: 'prompt' },
]);
// First list to populate cache
await router.route({ jsonrpc: '2.0', id: 1, method: 'resources/list' });
// Then read
const response = await router.route({
jsonrpc: '2.0',
id: 2,
method: 'resources/read',
params: { uri: 'mcpctl://prompts/my-prompt' },
});
expect(response.error).toBeUndefined();
const contents = (response.result as { contents: Array<{ text: string }> }).contents;
expect(contents[0]!.text).toBe('The content here');
});
it('should return error for unknown mcpctl resource', async () => {
router.setPromptConfig(mcpdClient, 'proj');
const response = await router.route({
jsonrpc: '2.0',
id: 3,
method: 'resources/read',
params: { uri: 'mcpctl://prompts/nonexistent' },
});
expect(response.error?.code).toBe(-32602);
expect(response.error?.message).toContain('Resource not found');
});
it('should not fail when mcpd is unavailable', async () => {
router.setPromptConfig(mcpdClient, 'proj');
vi.mocked(mcpdClient.get).mockRejectedValue(new Error('Connection refused'));
const response = await router.route({ jsonrpc: '2.0', id: 1, method: 'resources/list' });
// Should succeed with empty resources (upstream errors are swallowed)
expect(response.error).toBeUndefined();
const resources = (response.result as { resources: unknown[] }).resources;
expect(resources).toEqual([]);
});
});
describe('session isolation', () => {
it('should not include session parameter when no sessionId in context', async () => {
router.setPromptConfig(mcpdClient, 'proj');
vi.mocked(mcpdClient.get).mockResolvedValue([]);
await router.route({ jsonrpc: '2.0', id: 1, method: 'resources/list' });
expect(mcpdClient.get).toHaveBeenCalledWith(
'/api/v1/projects/proj/prompts/visible',
);
});
it('should not include session in propose when no context', async () => {
router.setPromptConfig(mcpdClient, 'proj');
await router.route({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'propose_prompt',
arguments: { name: 'test', content: 'stuff' },
},
});
expect(mcpdClient.post).toHaveBeenCalledWith(
'/api/v1/projects/proj/promptrequests',
{ name: 'test', content: 'stuff' },
);
});
});
});