Compare commits

...

26 Commits

Author SHA1 Message Date
Michal
4c127a7dc3 fix: show server name in instances table, allow logs by server name
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
- Instance list now shows server NAME instead of cryptic server ID
- Include server relation in findAll query (Prisma include)
- Logs command accepts server name, server ID, or instance ID
  (resolves server name → first RUNNING instance)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:07:42 +00:00
c1e3e4aed6 Merge pull request 'feat: auto-pull images + registry path for node-runner' (#12) from feat/node-runner-registry-pull 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 00:03:19 +00:00
Michal
e45c6079c1 feat: pull images before container creation, use registry path for node-runner
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
- Default node-runner image now uses mysources.co.uk registry path
- Add pullImage() call before createContainer() to auto-pull missing images
- Update stack/docker-compose.yml with MCPD_NODE_RUNNER_IMAGE and
  MCPD_MCP_NETWORK env vars, fix mcp-servers network naming

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:03:01 +00:00
e4aef3acf1 Merge pull request 'feat: add node-runner base image for npm-based MCP servers' (#11) from feat/node-runner-base-image 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-22 23:41:36 +00:00
Michal
a2cda38850 feat: add node-runner base image for npm-based MCP servers
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
STDIO servers with packageName (e.g. @leval/mcp-grafana) need a Node.js
container that runs `npx -y <package>`. Previously, packageName was used
as a Docker image reference causing "invalid reference format" errors.

- Add Dockerfile.node-runner: minimal node:20-alpine with npx entrypoint
- Update instance.service.ts: detect npm-based servers and use node-runner
  image with npx command instead of treating packageName as image name
- Fix NanoCPUs: only set when explicitly provided (kernel CFS not available
  on all hosts)
- Add mcp-servers network with explicit name for container isolation
- Configure MCPD_NODE_RUNNER_IMAGE and MCPD_MCP_NETWORK env vars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:41:16 +00:00
081e90de0f Merge pull request 'fix: error handling and --force flag for create commands' (#10) from fix/create-error-handling 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-22 23:06:52 +00:00
Michal
4e3d896ef6 fix: proper error handling and --force flag for create commands
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
- Add global error handler: clean messages instead of stack traces
- Add --force flag to create server/secret/project: updates on 409 conflict
- Strip null values and template-only fields from --from-template payload
- Add tests: 409 handling, --force update, null-stripping from templates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:06:33 +00:00
0823e965bf Merge pull request 'feat: MCP healthcheck probes + new templates' (#9) from feat/healthcheck-probes 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-22 22:50:10 +00:00
Michal
c97219f85e feat: add MCP healthcheck probes and new templates (grafana, home-assistant, node-red)
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
- Add healthCheck spec to templates and servers (tool, arguments, interval, timeout, failureThreshold)
- Add healthStatus, lastHealthCheck, events fields to instances
- Create grafana, home-assistant, node-red templates with healthcheck probes
- Add healthcheck probes to existing templates (github, slack, postgres, jira)
- Show HEALTH column in `get instances` and Events section in `describe instance`
- Display healthCheck details in `describe server` and `describe template`
- Schema + storage + display only; actual probe runner is future work

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:48:59 +00:00
93adcd4be7 Merge pull request 'feat: add MCP server templates and deployment infrastructure' (#8) from feat/mcp-templates 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-22 22:25:02 +00:00
Michal
d58e6e153f feat: add MCP server templates and deployment infrastructure
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
Introduce a Helm-chart-like template system for MCP servers. Templates are
YAML files in templates/ that get seeded into the DB on startup. Users can
browse them with `mcpctl get templates`, inspect with `mcpctl describe
template`, and instantiate with `mcpctl create server --from-template=`.

Also adds Portainer deployment scripts, mcplocal systemd service,
Streamable HTTP MCP endpoint, and RPM packaging for mcpctl-local.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:24:35 +00:00
Michal
1e8847bb63 fix: remove unused variables from profile cleanup
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
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:43:32 +00:00
Michal
2a0deaa225 fix: unused deps parameter in project command
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
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:42:16 +00:00
4eef6e38a2 Merge pull request 'feat: replace profiles with kubernetes-style secrets' (#7) from feat/replace-profiles-with-secrets 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-22 18:41:44 +00:00
Michal
ca02340a4c feat: replace profiles with kubernetes-style secrets
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
Replace the confused Profile abstraction with a dedicated Secret resource
following Kubernetes conventions. Servers now have env entries with inline
values or secretRef references. Env vars are resolved and passed to
containers at startup (fixes existing gap).

- Add Secret CRUD (model, repo, service, routes, CLI commands)
- Server env: {name, value} or {name, valueFrom: {secretRef: {name, key}}}
- Add env-resolver utility shared by instance startup and config generation
- Remove all profile-related code (models, services, routes, CLI, tests)
- Update backup/restore for secrets instead of profiles
- describe secret masks values by default, --show-values to reveal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:40:58 +00:00
Michal
02254f2aac fix: enable positional options so -o works on subcommands
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
Remove global -o/--output from parent program and enable
enablePositionalOptions() so -o yaml/json is parsed by subcommands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:43:35 +00:00
Michal
540dd6fd63 fix: remove unused Project interface from project.ts
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
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:41:14 +00:00
a05a4c4816 Merge pull request 'feat: create/edit commands, apply-compatible output, better describe' (#6) from feat/create-edit-commands 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-22 16:40:36 +00:00
Michal
97ade470df fix: resolve resource names in get/describe (not just IDs)
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
fetchResource and fetchSingleResource now use resolveNameOrId so
`mcpctl get server ha-mcp` works by name, not just by ID.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:39:21 +00:00
Michal
b25ff98374 feat: add create/edit commands, apply-compatible output, better describe
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
- `create server/profile/project` with all CLI flags (kubectl parity)
- `edit server/profile/project` opens $EDITOR for in-flight editing
- `get -o yaml/json` now outputs apply-compatible format (strips internal fields, wraps in resource key)
- `describe` shows visually clean sectioned output with aligned columns
- Extract shared utilities (resolveResource, resolveNameOrId, stripInternalFields)
- Instances are immutable (no create/edit, like pods)
- Full test coverage for create, edit, and updated describe/get

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:33:25 +00:00
Michal
22fe9c3435 fix: add replicas to restore-service server creation
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
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:47:03 +00:00
72643fceda Merge pull request 'feat: kubectl-style CLI + Deployment/Pod model' (#5) from feat/kubectl-deployment-model 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
Reviewed-on: #5
2026-02-22 13:39:02 +00:00
Michal
467357c2c6 feat: kubectl-style CLI + Deployment/Pod model for servers/instances
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
Server = Deployment (defines what to run + desired replicas)
Instance = Pod (ephemeral, auto-created by reconciliation)

Backend:
- Add replicas field to McpServer schema
- Add reconcile() to InstanceService (scales instances to match replicas)
- Remove manual start/stop/restart - instances are auto-managed
- Cascade: deleting server stops all containers then cascades DB
- Server create/update auto-triggers reconciliation

CLI:
- Add top-level delete command (servers, instances, profiles, projects)
- Add top-level logs command
- Remove instance compound command (use get/delete/logs instead)
- Clean up project command (list/show/delete → top-level get/describe/delete)
- Enhance describe for instances with container inspect info
- Add replicas to apply command's ServerSpec

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:30:46 +00:00
d6a80fc03d Merge pull request 'feat: external MCP server support + HA MCP PoC' (#4) from feat/external-mcp-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
Reviewed-on: #4
2026-02-22 12:39:19 +00:00
Michal
c07da826a0 test: add integration test for full MCP server flow
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
Tests the complete lifecycle through Fastify routes with in-memory
repositories and a fake streamable-http MCP server:
- External server: register → start virtual instance → proxy tools/list
- Managed server: register with dockerImage → start container → verify spec
- Full lifecycle: register → start → list → stop → remove → delete
- Proxy auth enforcement
- Server update flow
- Error handling (Docker failure → ERROR status)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:34:55 +00:00
Michal
0482944056 feat: add external MCP server support with streamable-http proxy
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
Support non-containerized MCP servers via externalUrl field and add
streamable-http session management for HA MCP proof of concept.

- Add externalUrl, command, containerPort fields to McpServer schema
- Skip Docker orchestration for external servers (virtual instances)
- Implement streamable-http proxy with Mcp-Session-Id session management
- Parse SSE-framed responses from streamable-http endpoints
- Add command passthrough to Docker container creation
- Create HA MCP example manifest (examples/ha-mcp.yaml)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:21:25 +00:00
126 changed files with 5120 additions and 2884 deletions

2
.gitignore vendored
View File

@@ -9,6 +9,8 @@ dist/
.env
.env.local
.env.*.local
stack/.env
.portainer_password
# Logs
logs/

398
deploy.sh Executable file
View File

@@ -0,0 +1,398 @@
#!/bin/bash
# Deploy mcpctl stack to Portainer
# Usage: ./deploy.sh [--dry-run]
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$SCRIPT_DIR/stack"
COMPOSE_FILE="$STACK_DIR/docker-compose.yml"
ENV_FILE="$STACK_DIR/.env"
# Portainer configuration
PORTAINER_URL="${PORTAINER_URL:-http://10.0.0.194:9000}"
PORTAINER_USER="${PORTAINER_USER:-michal}"
STACK_NAME="mcpctl"
ENDPOINT_ID="2"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1" >&2; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1" >&2; }
log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
check_files() {
if [[ ! -f "$COMPOSE_FILE" ]]; then
log_error "Compose file not found: $COMPOSE_FILE"
exit 1
fi
if [[ ! -f "$ENV_FILE" ]]; then
log_error "Environment file not found: $ENV_FILE"
exit 1
fi
log_info "Found compose file: $COMPOSE_FILE"
log_info "Found env file: $ENV_FILE"
}
get_password() {
if [[ -n "$PORTAINER_PASSWORD" ]]; then
echo "$PORTAINER_PASSWORD"
return
fi
if [[ -f "$SCRIPT_DIR/.portainer_password" ]]; then
cat "$SCRIPT_DIR/.portainer_password"
return
fi
if [[ -f "$HOME/.portainer_password" ]]; then
cat "$HOME/.portainer_password"
return
fi
read -s -p "Enter Portainer password for $PORTAINER_USER: " password
echo >&2
echo "$password"
}
get_jwt_token() {
local password="$1"
log_info "Authenticating to Portainer..."
local escaped_password
escaped_password=$(printf '%s' "$password" | jq -Rs .)
local response
response=$(curl -s -X POST "$PORTAINER_URL/api/auth" \
-H "Content-Type: application/json" \
-d "{\"Username\":\"$PORTAINER_USER\",\"Password\":$escaped_password}")
local token
token=$(echo "$response" | jq -r '.jwt // empty')
if [[ -z "$token" ]]; then
log_error "Authentication failed: $(echo "$response" | jq -r '.message // "Unknown error"')"
exit 1
fi
echo "$token"
}
parse_env_to_json() {
local env_file="$1"
local json_array="["
local first=true
while IFS= read -r line || [[ -n "$line" ]]; do
[[ "$line" =~ ^#.*$ ]] && continue
[[ -z "$line" ]] && continue
local name="${line%%=*}"
local value="${line#*=}"
[[ "$name" == "$line" ]] && continue
if [[ "$first" == "true" ]]; then
first=false
else
json_array+=","
fi
value=$(echo "$value" | sed 's/\\/\\\\/g; s/"/\\"/g')
json_array+="{\"name\":\"$name\",\"value\":\"$value\"}"
done < "$env_file"
json_array+="]"
echo "$json_array"
}
# Find existing stack by name
find_stack_id() {
local token="$1"
local response
response=$(curl -s -X GET "$PORTAINER_URL/api/stacks" \
-H "Authorization: Bearer $token")
echo "$response" | jq -r --arg name "$STACK_NAME" \
'.[] | select(.Name == $name) | .Id // empty'
}
get_stack_info() {
local token="$1"
local stack_id="$2"
curl -s -X GET "$PORTAINER_URL/api/stacks/$stack_id" \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json"
}
get_stack_file() {
local token="$1"
local stack_id="$2"
local response
response=$(curl -s -X GET "$PORTAINER_URL/api/stacks/$stack_id/file" \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json")
if echo "$response" | jq -e '.StackFileContent' > /dev/null 2>&1; then
echo "$response" | jq -r '.StackFileContent'
else
echo "# Could not retrieve current compose file"
fi
}
show_diff() {
local token="$1"
local stack_id="$2"
local env_json="$3"
log_info "Fetching current state from Portainer..."
local current_compose
current_compose=$(get_stack_file "$token" "$stack_id")
local current_env
local stack_info
stack_info=$(get_stack_info "$token" "$stack_id")
current_env=$(echo "$stack_info" | jq -r 'if .Env then .Env[] | "\(.name)=\(.value)" else empty end' 2>/dev/null | sort)
local new_env
new_env=$(echo "$env_json" | jq -r '.[] | "\(.name)=\(.value)"' | sort)
local tmp_dir
tmp_dir=$(mktemp -d)
echo "$current_compose" > "$tmp_dir/current_compose.yml"
cat "$COMPOSE_FILE" > "$tmp_dir/new_compose.yml"
echo "$current_env" > "$tmp_dir/current_env.txt"
echo "$new_env" > "$tmp_dir/new_env.txt"
echo ""
echo "=== ENVIRONMENT VARIABLES DIFF ==="
echo ""
if diff -u "$tmp_dir/current_env.txt" "$tmp_dir/new_env.txt" > "$tmp_dir/env_diff.txt" 2>&1; then
echo -e "${GREEN}No changes in environment variables${NC}"
else
while IFS= read -r line; do
if [[ "$line" == ---* ]] || [[ "$line" == +++* ]] || [[ "$line" == @@* ]]; then
echo -e "${YELLOW}$line${NC}"
elif [[ "$line" == -* ]]; then
echo -e "${RED}$line${NC}"
elif [[ "$line" == +* ]]; then
echo -e "${GREEN}$line${NC}"
else
echo "$line"
fi
done < "$tmp_dir/env_diff.txt"
fi
echo ""
echo "=== COMPOSE FILE DIFF ==="
echo ""
if diff -u "$tmp_dir/current_compose.yml" "$tmp_dir/new_compose.yml" > "$tmp_dir/compose_diff.txt" 2>&1; then
echo -e "${GREEN}No changes in compose file${NC}"
else
while IFS= read -r line; do
if [[ "$line" == ---* ]] || [[ "$line" == +++* ]] || [[ "$line" == @@* ]]; then
echo -e "${YELLOW}$line${NC}"
elif [[ "$line" == -* ]]; then
echo -e "${RED}$line${NC}"
elif [[ "$line" == +* ]]; then
echo -e "${GREEN}$line${NC}"
else
echo "$line"
fi
done < "$tmp_dir/compose_diff.txt"
fi
rm -rf "$tmp_dir"
}
create_stack() {
local token="$1"
local env_json="$2"
local compose_content
compose_content=$(cat "$COMPOSE_FILE")
local compose_escaped
compose_escaped=$(echo "$compose_content" | jq -Rs .)
log_info "Creating new stack '$STACK_NAME'..."
local payload
payload=$(jq -n \
--arg name "$STACK_NAME" \
--argjson env "$env_json" \
--argjson stackFileContent "$compose_escaped" \
'{
"name": $name,
"env": $env,
"stackFileContent": $stackFileContent
}')
local response
response=$(curl -s -X POST "$PORTAINER_URL/api/stacks?type=2&method=string&endpointId=$ENDPOINT_ID" \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
-d "$payload")
local error_msg
error_msg=$(echo "$response" | jq -r '.message // empty')
if [[ -n "$error_msg" ]]; then
log_error "Stack creation failed: $error_msg"
echo "$response" | jq .
exit 1
fi
local new_id
new_id=$(echo "$response" | jq -r '.Id')
log_info "Stack created successfully! (ID: $new_id)"
echo "$response" | jq '{Id, Name, Status, CreationDate}'
}
update_stack() {
local token="$1"
local stack_id="$2"
local dry_run="$3"
local compose_content
compose_content=$(cat "$COMPOSE_FILE")
local env_json
env_json=$(parse_env_to_json "$ENV_FILE")
if [[ "$dry_run" == "true" ]]; then
log_warn "DRY RUN - Not actually deploying"
show_diff "$token" "$stack_id" "$env_json"
echo ""
log_warn "DRY RUN complete - no changes made"
log_info "Run without --dry-run to apply these changes"
return 0
fi
local env_count
env_count=$(echo "$env_json" | jq 'length')
log_info "Deploying $env_count environment variables"
log_info "Updating stack '$STACK_NAME' (ID: $stack_id)..."
local compose_escaped
compose_escaped=$(echo "$compose_content" | jq -Rs .)
local payload
payload=$(jq -n \
--argjson env "$env_json" \
--argjson stackFileContent "$compose_escaped" \
'{
"env": $env,
"stackFileContent": $stackFileContent,
"prune": true,
"pullImage": true
}')
local response
response=$(curl -s -X PUT "$PORTAINER_URL/api/stacks/$stack_id?endpointId=$ENDPOINT_ID" \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
-d "$payload")
local error_msg
error_msg=$(echo "$response" | jq -r '.message // empty')
if [[ -n "$error_msg" ]]; then
log_error "Deployment failed: $error_msg"
echo "$response" | jq .
exit 1
fi
log_info "Stack updated successfully!"
echo "$response" | jq '{Id, Name, Status, CreationDate, UpdateDate}'
}
main() {
local dry_run=false
while [[ $# -gt 0 ]]; do
case $1 in
--dry-run)
dry_run=true
shift
;;
--help|-h)
echo "Usage: $0 [--dry-run]"
echo ""
echo "Deploy mcpctl stack to Portainer"
echo ""
echo "Options:"
echo " --dry-run Show what would be deployed without actually deploying"
echo " --help Show this help message"
echo ""
echo "Environment variables:"
echo " PORTAINER_URL Portainer URL (default: http://10.0.0.194:9000)"
echo " PORTAINER_USER Portainer username (default: michal)"
echo " PORTAINER_PASSWORD Portainer password (or store in ~/.portainer_password)"
exit 0
;;
*)
log_error "Unknown option: $1"
exit 1
;;
esac
done
echo "========================================"
echo " mcpctl Stack Deployment"
echo "========================================"
echo ""
check_files
local password
password=$(get_password)
local token
token=$(get_jwt_token "$password")
log_info "Authentication successful"
# Find or create stack
local stack_id
stack_id=$(find_stack_id "$token")
if [[ -z "$stack_id" ]]; then
if [[ "$dry_run" == "true" ]]; then
log_warn "Stack '$STACK_NAME' does not exist yet"
log_info "A real deploy would create it"
return 0
fi
log_info "Stack '$STACK_NAME' not found, creating..."
local env_json
env_json=$(parse_env_to_json "$ENV_FILE")
create_stack "$token" "$env_json"
else
local stack_info
stack_info=$(get_stack_info "$token" "$stack_id")
local status_code
status_code=$(echo "$stack_info" | jq -r '.Status // 0')
local status_text="Unknown"
case "$status_code" in
1) status_text="Active" ;;
2) status_text="Inactive" ;;
esac
log_info "Current stack status: $status_text (ID: $stack_id, Env vars: $(echo "$stack_info" | jq '.Env | length'))"
echo ""
update_stack "$token" "$stack_id" "$dry_run"
fi
echo ""
log_info "Done!"
if [[ "$dry_run" == "false" ]]; then
log_info "Check Portainer UI to verify containers are running"
log_info "URL: $PORTAINER_URL/#!/$ENDPOINT_ID/docker/stacks/$STACK_NAME"
fi
}
main "$@"

View File

@@ -49,6 +49,9 @@ COPY --from=builder /app/src/shared/dist/ src/shared/dist/
COPY --from=builder /app/src/db/dist/ src/db/dist/
COPY --from=builder /app/src/mcpd/dist/ src/mcpd/dist/
# Copy templates for seeding
COPY templates/ templates/
# Copy entrypoint
COPY deploy/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

View File

@@ -0,0 +1,12 @@
# Base container for npm-based MCP servers (STDIO transport).
# mcpd uses this image to run `npx -y <packageName>` when a server
# has packageName but no dockerImage.
FROM node:20-alpine
WORKDIR /mcp
# Pre-warm npx cache directory
RUN mkdir -p /root/.npm
# Default entrypoint — overridden by mcpd via container command
ENTRYPOINT ["npx", "-y"]

View File

@@ -30,6 +30,8 @@ services:
MCPD_PORT: "3100"
MCPD_HOST: "0.0.0.0"
MCPD_LOG_LEVEL: info
MCPD_NODE_RUNNER_IMAGE: mcpctl-node-runner:latest
MCPD_MCP_NETWORK: mcp-servers
depends_on:
postgres:
condition: service_healthy
@@ -48,6 +50,16 @@ services:
retries: 3
start_period: 10s
# Base image for npm-based MCP servers (built once, used by mcpd)
node-runner:
build:
context: ..
dockerfile: deploy/Dockerfile.node-runner
image: mcpctl-node-runner:latest
profiles:
- build
entrypoint: ["echo", "Image built successfully"]
postgres-test:
image: postgres:16-alpine
container_name: mcpctl-postgres-test
@@ -71,8 +83,11 @@ networks:
mcpctl:
driver: bridge
mcp-servers:
name: mcp-servers
driver: bridge
internal: true
# Not internal — MCP servers need outbound access to reach external APIs
# (e.g., Grafana, Home Assistant). Isolation is enforced by not binding
# host ports on MCP server containers; only mcpd can reach them.
volumes:
mcpctl-pgdata:

View File

@@ -4,8 +4,8 @@ set -e
echo "mcpd: pushing database schema..."
pnpm -F @mcpctl/db exec prisma db push --schema=prisma/schema.prisma --accept-data-loss 2>&1
echo "mcpd: seeding default data..."
node src/mcpd/dist/seed-runner.js
echo "mcpd: seeding templates..."
TEMPLATES_DIR=templates node src/mcpd/dist/seed-runner.js
echo "mcpd: starting server..."
exec node src/mcpd/dist/main.js

15
deploy/mcplocal.service Normal file
View File

@@ -0,0 +1,15 @@
[Unit]
Description=mcpctl local MCP proxy
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/mcpctl-local
Restart=on-failure
RestartSec=5
Environment=MCPLOCAL_MCPD_URL=http://10.0.0.194:3100
Environment=MCPLOCAL_HTTP_PORT=3200
Environment=MCPLOCAL_HTTP_HOST=127.0.0.1
[Install]
WantedBy=default.target

View File

@@ -96,10 +96,12 @@ servers:
description: Slack MCP server
transport: STDIO
packageName: "@anthropic/slack-mcp"
envTemplate:
env:
- name: SLACK_TOKEN
description: Slack bot token
isSecret: true
valueFrom:
secretRef:
name: slack-secrets
key: token
- name: github
description: GitHub MCP server

28
examples/ha-mcp.yaml Normal file
View File

@@ -0,0 +1,28 @@
servers:
- name: ha-mcp
description: "Home Assistant MCP - smart home control via MCP"
dockerImage: "ghcr.io/homeassistant-ai/ha-mcp:2.4"
transport: STREAMABLE_HTTP
containerPort: 3000
# For mcpd-managed containers:
command:
- python
- "-c"
- "from ha_mcp.server import HomeAssistantSmartMCPServer; s = HomeAssistantSmartMCPServer(); s.mcp.run(transport='sse', host='0.0.0.0', port=3000)"
# For connecting to an already-running instance (host.containers.internal for container-to-host):
externalUrl: "http://host.containers.internal:8086/mcp"
env:
- name: HOMEASSISTANT_URL
value: ""
- name: HOMEASSISTANT_TOKEN
valueFrom:
secretRef:
name: ha-secrets
key: token
profiles:
- name: production
server: ha-mcp
envOverrides:
HOMEASSISTANT_URL: "https://ha.itaz.eu"
HOMEASSISTANT_TOKEN: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIyNjFlZTRhOWI2MGM0YTllOGJkNTIxN2Q3YmVmZDkzNSIsImlhdCI6MTc3MDA3NjYzOCwiZXhwIjoyMDg1NDM2NjM4fQ.17mAQxIrCBrQx3ogqAUetwEt-cngRmJiH-e7sLt-3FY"

26
installlocal.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/bin/bash
# Build (if needed) and install mcpctl RPM locally
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
RPM_FILE=$(ls dist/mcpctl-*.rpm 2>/dev/null | head -1)
# Build if no RPM exists or if source is newer than the RPM
if [[ -z "$RPM_FILE" ]] || [[ $(find src/ -name '*.ts' -newer "$RPM_FILE" 2>/dev/null | head -1) ]]; then
echo "==> Building RPM..."
bash scripts/build-rpm.sh
RPM_FILE=$(ls dist/mcpctl-*.rpm 2>/dev/null | head -1)
else
echo "==> RPM is up to date: $RPM_FILE"
fi
echo "==> Installing $RPM_FILE..."
sudo rpm -Uvh --force "$RPM_FILE"
echo "==> Reloading systemd user units..."
systemctl --user daemon-reload
echo "==> Done!"
echo " Enable mcplocal: systemctl --user enable --now mcplocal"

View File

@@ -10,6 +10,14 @@ contents:
dst: /usr/bin/mcpctl
file_info:
mode: 0755
- src: ./dist/mcpctl-local
dst: /usr/bin/mcpctl-local
file_info:
mode: 0755
- src: ./deploy/mcplocal.service
dst: /usr/lib/systemd/user/mcplocal.service
file_info:
mode: 0644
- src: ./completions/mcpctl.bash
dst: /usr/share/bash-completion/completions/mcpctl
file_info:

View File

@@ -18,7 +18,11 @@
"typecheck": "tsc --build",
"rpm:build": "bash scripts/build-rpm.sh",
"rpm:publish": "bash scripts/publish-rpm.sh",
"release": "bash scripts/release.sh"
"release": "bash scripts/release.sh",
"mcpd:build": "bash scripts/build-mcpd.sh",
"mcpd:deploy": "bash deploy.sh",
"mcpd:deploy-dry": "bash deploy.sh --dry-run",
"mcpd:logs": "bash logs.sh"
},
"engines": {
"node": ">=20.0.0",

6
pnpm-lock.yaml generated
View File

@@ -112,6 +112,9 @@ importers:
fastify:
specifier: ^5.0.0
version: 5.7.4
js-yaml:
specifier: ^4.1.0
version: 4.1.1
zod:
specifier: ^3.24.0
version: 3.25.76
@@ -122,6 +125,9 @@ importers:
'@types/dockerode':
specifier: ^4.0.1
version: 4.0.1
'@types/js-yaml':
specifier: ^4.0.9
version: 4.0.9
'@types/node':
specifier: ^25.3.0
version: 25.3.0

32
scripts/build-mcpd.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/bin/bash
# Build mcpd Docker image and push to Gitea container registry
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_ROOT"
# Load .env for GITEA_TOKEN
if [ -f .env ]; then
set -a; source .env; set +a
fi
# Push directly to internal address (external proxy has body size limit)
REGISTRY="10.0.0.194:3012"
IMAGE="mcpd"
TAG="${1:-latest}"
echo "==> Building mcpd image..."
podman build -t "$IMAGE:$TAG" -f deploy/Dockerfile.mcpd .
echo "==> Tagging as $REGISTRY/michal/$IMAGE:$TAG..."
podman tag "$IMAGE:$TAG" "$REGISTRY/michal/$IMAGE:$TAG"
echo "==> Logging in to $REGISTRY..."
podman login --tls-verify=false -u michal -p "$GITEA_TOKEN" "$REGISTRY"
echo "==> Pushing to $REGISTRY/michal/$IMAGE:$TAG..."
podman push --tls-verify=false "$REGISTRY/michal/$IMAGE:$TAG"
echo "==> Done!"
echo " Image: $REGISTRY/michal/$IMAGE:$TAG"

View File

@@ -16,10 +16,11 @@ export PATH="$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH"
echo "==> Building TypeScript..."
pnpm build
echo "==> Bundling standalone binary..."
echo "==> Bundling standalone binaries..."
mkdir -p dist
rm -f dist/mcpctl dist/mcpctl-*.rpm
rm -f dist/mcpctl dist/mcpctl-local dist/mcpctl-*.rpm
bun build src/cli/src/index.ts --compile --outfile dist/mcpctl
bun build src/mcplocal/src/main.ts --compile --outfile dist/mcpctl-local
echo "==> Packaging RPM..."
nfpm pkg --packager rpm --target dist/

View File

@@ -4,6 +4,22 @@ import yaml from 'js-yaml';
import { z } from 'zod';
import type { ApiClient } from '../api-client.js';
const HealthCheckSchema = z.object({
tool: z.string().min(1),
arguments: z.record(z.unknown()).default({}),
intervalSeconds: z.number().int().min(5).max(3600).default(60),
timeoutSeconds: z.number().int().min(1).max(120).default(10),
failureThreshold: z.number().int().min(1).max(20).default(3),
});
const ServerEnvEntrySchema = z.object({
name: z.string().min(1),
value: z.string().optional(),
valueFrom: z.object({
secretRef: z.object({ name: z.string(), key: z.string() }),
}).optional(),
});
const ServerSpecSchema = z.object({
name: z.string().min(1),
description: z.string().default(''),
@@ -11,30 +27,52 @@ const ServerSpecSchema = z.object({
dockerImage: z.string().optional(),
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
repositoryUrl: z.string().url().optional(),
envTemplate: z.array(z.object({
name: z.string(),
description: z.string().default(''),
isSecret: z.boolean().default(false),
})).default([]),
externalUrl: z.string().url().optional(),
command: z.array(z.string()).optional(),
containerPort: z.number().int().min(1).max(65535).optional(),
replicas: z.number().int().min(0).max(10).default(1),
env: z.array(ServerEnvEntrySchema).default([]),
healthCheck: HealthCheckSchema.optional(),
});
const ProfileSpecSchema = z.object({
const SecretSpecSchema = z.object({
name: z.string().min(1),
server: z.string().min(1),
permissions: z.array(z.string()).default([]),
envOverrides: z.record(z.string()).default({}),
data: z.record(z.string()).default({}),
});
const TemplateEnvEntrySchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
required: z.boolean().optional(),
defaultValue: z.string().optional(),
});
const TemplateSpecSchema = z.object({
name: z.string().min(1),
version: z.string().default('1.0.0'),
description: z.string().default(''),
packageName: z.string().optional(),
dockerImage: z.string().optional(),
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
repositoryUrl: z.string().optional(),
externalUrl: z.string().optional(),
command: z.array(z.string()).optional(),
containerPort: z.number().int().min(1).max(65535).optional(),
replicas: z.number().int().min(0).max(10).default(1),
env: z.array(TemplateEnvEntrySchema).default([]),
healthCheck: HealthCheckSchema.optional(),
});
const ProjectSpecSchema = z.object({
name: z.string().min(1),
description: z.string().default(''),
profiles: z.array(z.string()).default([]),
});
const ApplyConfigSchema = z.object({
servers: z.array(ServerSpecSchema).default([]),
profiles: z.array(ProfileSpecSchema).default([]),
secrets: z.array(SecretSpecSchema).default([]),
projects: z.array(ProjectSpecSchema).default([]),
templates: z.array(TemplateSpecSchema).default([]),
});
export type ApplyConfig = z.infer<typeof ApplyConfigSchema>;
@@ -57,8 +95,9 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command {
if (opts.dryRun) {
log('Dry run - would apply:');
if (config.servers.length > 0) log(` ${config.servers.length} server(s)`);
if (config.profiles.length > 0) log(` ${config.profiles.length} profile(s)`);
if (config.secrets.length > 0) log(` ${config.secrets.length} secret(s)`);
if (config.projects.length > 0) log(` ${config.projects.length} project(s)`);
if (config.templates.length > 0) log(` ${config.templates.length} template(s)`);
return;
}
@@ -80,7 +119,7 @@ function loadConfigFile(path: string): ApplyConfig {
}
async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args: unknown[]) => void): Promise<void> {
// Apply servers first (profiles depend on servers)
// Apply servers first
for (const server of config.servers) {
try {
const existing = await findByName(client, 'servers', server.name);
@@ -96,34 +135,19 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
}
}
// Apply profiles (need server IDs)
for (const profile of config.profiles) {
// Apply secrets
for (const secret of config.secrets) {
try {
const server = await findByName(client, 'servers', profile.server);
if (!server) {
log(`Skipping profile '${profile.name}': server '${profile.server}' not found`);
continue;
}
const serverId = (server as { id: string }).id;
const existing = await findProfile(client, serverId, profile.name);
const existing = await findByName(client, 'secrets', secret.name);
if (existing) {
await client.put(`/api/v1/profiles/${(existing as { id: string }).id}`, {
permissions: profile.permissions,
envOverrides: profile.envOverrides,
});
log(`Updated profile: ${profile.name} (server: ${profile.server})`);
await client.put(`/api/v1/secrets/${(existing as { id: string }).id}`, { data: secret.data });
log(`Updated secret: ${secret.name}`);
} else {
await client.post('/api/v1/profiles', {
name: profile.name,
serverId,
permissions: profile.permissions,
envOverrides: profile.envOverrides,
});
log(`Created profile: ${profile.name} (server: ${profile.server})`);
await client.post('/api/v1/secrets', secret);
log(`Created secret: ${secret.name}`);
}
} catch (err) {
log(`Error applying profile '${profile.name}': ${err instanceof Error ? err.message : err}`);
log(`Error applying secret '${secret.name}': ${err instanceof Error ? err.message : err}`);
}
}
@@ -147,6 +171,22 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
log(`Error applying project '${project.name}': ${err instanceof Error ? err.message : err}`);
}
}
// Apply templates
for (const template of config.templates) {
try {
const existing = await findByName(client, 'templates', template.name);
if (existing) {
await client.put(`/api/v1/templates/${(existing as { id: string }).id}`, template);
log(`Updated template: ${template.name}`);
} else {
await client.post('/api/v1/templates', template);
log(`Created template: ${template.name}`);
}
} catch (err) {
log(`Error applying template '${template.name}': ${err instanceof Error ? err.message : err}`);
}
}
}
async function findByName(client: ApiClient, resource: string, name: string): Promise<unknown | null> {
@@ -158,16 +198,5 @@ async function findByName(client: ApiClient, resource: string, name: string): Pr
}
}
async function findProfile(client: ApiClient, serverId: string, name: string): Promise<unknown | null> {
try {
const profiles = await client.get<Array<{ name: string; serverId: string }>>(
`/api/v1/profiles?serverId=${serverId}`,
);
return profiles.find((p) => p.name === name) ?? null;
} catch {
return null;
}
}
// Export for testing
export { loadConfigFile, applyConfig };

View File

@@ -0,0 +1,219 @@
import { Command } from 'commander';
import { type ApiClient, ApiError } from '../api-client.js';
export interface CreateCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
}
function collect(value: string, prev: string[]): string[] {
return [...prev, value];
}
interface ServerEnvEntry {
name: string;
value?: string;
valueFrom?: { secretRef: { name: string; key: string } };
}
function parseServerEnv(entries: string[]): ServerEnvEntry[] {
return entries.map((entry) => {
const eqIdx = entry.indexOf('=');
if (eqIdx === -1) {
throw new Error(`Invalid env format '${entry}'. Expected KEY=value or KEY=secretRef:SECRET:KEY`);
}
const envName = entry.slice(0, eqIdx);
const rhs = entry.slice(eqIdx + 1);
if (rhs.startsWith('secretRef:')) {
const parts = rhs.split(':');
if (parts.length !== 3) {
throw new Error(`Invalid secretRef format '${entry}'. Expected KEY=secretRef:SECRET_NAME:SECRET_KEY`);
}
return {
name: envName,
valueFrom: { secretRef: { name: parts[1]!, key: parts[2]! } },
};
}
return { name: envName, value: rhs };
});
}
function parseEnvEntries(entries: string[]): Record<string, string> {
const result: Record<string, string> = {};
for (const entry of entries) {
const eqIdx = entry.indexOf('=');
if (eqIdx === -1) {
throw new Error(`Invalid env format '${entry}'. Expected KEY=value`);
}
result[entry.slice(0, eqIdx)] = entry.slice(eqIdx + 1);
}
return result;
}
export function createCreateCommand(deps: CreateCommandDeps): Command {
const { client, log } = deps;
const cmd = new Command('create')
.description('Create a resource (server, project)');
// --- create server ---
cmd.command('server')
.description('Create an MCP server definition')
.argument('<name>', 'Server name (lowercase, hyphens allowed)')
.option('-d, --description <text>', 'Server description')
.option('--package-name <name>', 'NPM package name')
.option('--docker-image <image>', 'Docker image')
.option('--transport <type>', 'Transport type (STDIO, SSE, STREAMABLE_HTTP)')
.option('--repository-url <url>', 'Source repository URL')
.option('--external-url <url>', 'External endpoint URL')
.option('--command <arg>', 'Command argument (repeat for multiple)', collect, [])
.option('--container-port <port>', 'Container port number')
.option('--replicas <count>', 'Number of replicas')
.option('--env <entry>', 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, repeat for multiple)', collect, [])
.option('--from-template <name>', 'Create from template (name or name:version)')
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => {
let base: Record<string, unknown> = {};
// If --from-template, fetch template and use as base
if (opts.fromTemplate) {
const tplRef = opts.fromTemplate as string;
const [tplName, tplVersion] = tplRef.includes(':')
? [tplRef.slice(0, tplRef.indexOf(':')), tplRef.slice(tplRef.indexOf(':') + 1)]
: [tplRef, undefined];
const templates = await client.get<Array<Record<string, unknown>>>(`/api/v1/templates?name=${encodeURIComponent(tplName)}`);
let template: Record<string, unknown> | undefined;
if (tplVersion) {
template = templates.find((t) => t.name === tplName && t.version === tplVersion);
if (!template) throw new Error(`Template '${tplName}' version '${tplVersion}' not found`);
} else {
template = templates.find((t) => t.name === tplName);
if (!template) throw new Error(`Template '${tplName}' not found`);
}
// Copy template fields as base (strip template-only, internal, and null fields)
const { id: _id, createdAt: _c, updatedAt: _u, version: _v, name: _n, ...tplFields } = template;
base = {};
for (const [k, v] of Object.entries(tplFields)) {
if (v !== null && v !== undefined) base[k] = v;
}
// Convert template env (description/required) to server env (name/value/valueFrom)
const tplEnv = template.env as Array<{ name: string; description?: string; required?: boolean; defaultValue?: string }> | undefined;
if (tplEnv && tplEnv.length > 0) {
base.env = tplEnv.map((e) => ({ name: e.name, value: e.defaultValue ?? '' }));
}
// Track template origin
base.templateName = tplName;
base.templateVersion = (template.version as string) ?? '1.0.0';
}
// Build body: template base → CLI overrides (last wins)
const body: Record<string, unknown> = {
...base,
name,
};
if (opts.description !== undefined) body.description = opts.description;
if (opts.transport) body.transport = opts.transport;
if (opts.replicas) body.replicas = parseInt(opts.replicas, 10);
if (opts.packageName) body.packageName = opts.packageName;
if (opts.dockerImage) body.dockerImage = opts.dockerImage;
if (opts.repositoryUrl) body.repositoryUrl = opts.repositoryUrl;
if (opts.externalUrl) body.externalUrl = opts.externalUrl;
if (opts.command.length > 0) body.command = opts.command;
if (opts.containerPort) body.containerPort = parseInt(opts.containerPort, 10);
if (opts.env.length > 0) {
// Merge: CLI env entries override template env entries by name
const cliEnv = parseServerEnv(opts.env);
const existing = (body.env as ServerEnvEntry[] | undefined) ?? [];
const merged = [...existing];
for (const entry of cliEnv) {
const idx = merged.findIndex((e) => e.name === entry.name);
if (idx >= 0) {
merged[idx] = entry;
} else {
merged.push(entry);
}
}
body.env = merged;
}
// Defaults when no template
if (!opts.fromTemplate) {
if (body.description === undefined) body.description = '';
if (!body.transport) body.transport = 'STDIO';
if (!body.replicas) body.replicas = 1;
}
try {
const server = await client.post<{ id: string; name: string }>('/api/v1/servers', body);
log(`server '${server.name}' created (id: ${server.id})`);
} catch (err) {
if (err instanceof ApiError && err.status === 409 && opts.force) {
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/servers')).find((s) => s.name === name);
if (!existing) throw err;
const { name: _n, ...updateBody } = body;
await client.put(`/api/v1/servers/${existing.id}`, updateBody);
log(`server '${name}' updated (id: ${existing.id})`);
} else {
throw err;
}
}
});
// --- create secret ---
cmd.command('secret')
.description('Create a secret')
.argument('<name>', 'Secret name (lowercase, hyphens allowed)')
.option('--data <entry>', 'Secret data KEY=value (repeat for multiple)', collect, [])
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => {
const data = parseEnvEntries(opts.data);
try {
const secret = await client.post<{ id: string; name: string }>('/api/v1/secrets', {
name,
data,
});
log(`secret '${secret.name}' created (id: ${secret.id})`);
} catch (err) {
if (err instanceof ApiError && err.status === 409 && opts.force) {
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/secrets')).find((s) => s.name === name);
if (!existing) throw err;
await client.put(`/api/v1/secrets/${existing.id}`, { data });
log(`secret '${name}' updated (id: ${existing.id})`);
} else {
throw err;
}
}
});
// --- create project ---
cmd.command('project')
.description('Create a project')
.argument('<name>', 'Project name')
.option('-d, --description <text>', 'Project description', '')
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => {
try {
const project = await client.post<{ id: string; name: string }>('/api/v1/projects', {
name,
description: opts.description,
});
log(`project '${project.name}' created (id: ${project.id})`);
} catch (err) {
if (err instanceof ApiError && err.status === 409 && opts.force) {
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/projects')).find((p) => p.name === name);
if (!existing) throw err;
await client.put(`/api/v1/projects/${existing.id}`, { description: opts.description });
log(`project '${name}' updated (id: ${existing.id})`);
} else {
throw err;
}
}
});
return cmd;
}

View File

@@ -0,0 +1,33 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
import { resolveResource, resolveNameOrId } from './shared.js';
export interface DeleteCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
}
export function createDeleteCommand(deps: DeleteCommandDeps): Command {
const { client, log } = deps;
return new Command('delete')
.description('Delete a resource (server, instance, profile, project)')
.argument('<resource>', 'resource type')
.argument('<id>', 'resource ID or name')
.action(async (resourceArg: string, idOrName: string) => {
const resource = resolveResource(resourceArg);
// Resolve name → ID for any resource type
let id: string;
try {
id = await resolveNameOrId(client, resource, idOrName);
} catch {
id = idOrName; // Fall through with original
}
await client.delete(`/api/v1/${resource}/${id}`);
const singular = resource.replace(/s$/, '');
log(`${singular} '${idOrName}' deleted.`);
});
}

View File

@@ -1,74 +1,326 @@
import { Command } from 'commander';
import { formatJson, formatYaml } from '../formatters/output.js';
import { resolveResource, resolveNameOrId } from './shared.js';
import type { ApiClient } from '../api-client.js';
export interface DescribeCommandDeps {
client: ApiClient;
fetchResource: (resource: string, id: string) => Promise<unknown>;
fetchInspect?: (id: string) => Promise<unknown>;
log: (...args: string[]) => void;
}
const RESOURCE_ALIASES: Record<string, string> = {
server: 'servers',
srv: 'servers',
profile: 'profiles',
prof: 'profiles',
project: 'projects',
proj: 'projects',
instance: 'instances',
inst: 'instances',
};
function resolveResource(name: string): string {
const lower = name.toLowerCase();
return RESOURCE_ALIASES[lower] ?? lower;
function pad(label: string, width = 18): string {
return label.padEnd(width);
}
function formatDetail(obj: Record<string, unknown>, indent = 0): string {
const pad = ' '.repeat(indent);
function formatServerDetail(server: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`=== Server: ${server.name} ===`);
lines.push(`${pad('Name:')}${server.name}`);
lines.push(`${pad('Transport:')}${server.transport ?? '-'}`);
lines.push(`${pad('Replicas:')}${server.replicas ?? 1}`);
if (server.dockerImage) lines.push(`${pad('Docker Image:')}${server.dockerImage}`);
if (server.packageName) lines.push(`${pad('Package:')}${server.packageName}`);
if (server.externalUrl) lines.push(`${pad('External URL:')}${server.externalUrl}`);
if (server.repositoryUrl) lines.push(`${pad('Repository:')}${server.repositoryUrl}`);
if (server.containerPort) lines.push(`${pad('Container Port:')}${server.containerPort}`);
if (server.description) lines.push(`${pad('Description:')}${server.description}`);
for (const [key, value] of Object.entries(obj)) {
if (value === null || value === undefined) {
lines.push(`${pad}${key}: -`);
} else if (Array.isArray(value)) {
if (value.length === 0) {
lines.push(`${pad}${key}: []`);
} else if (typeof value[0] === 'object') {
lines.push(`${pad}${key}:`);
for (const item of value) {
lines.push(`${pad} - ${JSON.stringify(item)}`);
}
} else {
lines.push(`${pad}${key}: ${value.join(', ')}`);
const command = server.command as string[] | null;
if (command && command.length > 0) {
lines.push('');
lines.push('Command:');
lines.push(` ${command.join(' ')}`);
}
const env = server.env as Array<{ name: string; value?: string; valueFrom?: { secretRef: { name: string; key: string } } }> | undefined;
if (env && env.length > 0) {
lines.push('');
lines.push('Environment:');
const nameW = Math.max(6, ...env.map((e) => e.name.length)) + 2;
lines.push(` ${'NAME'.padEnd(nameW)}SOURCE`);
for (const e of env) {
if (e.value !== undefined) {
lines.push(` ${e.name.padEnd(nameW)}${e.value}`);
} else if (e.valueFrom?.secretRef) {
const ref = e.valueFrom.secretRef;
lines.push(` ${e.name.padEnd(nameW)}secret:${ref.name}/${ref.key}`);
}
} else if (typeof value === 'object') {
lines.push(`${pad}${key}:`);
lines.push(formatDetail(value as Record<string, unknown>, indent + 1));
} else {
lines.push(`${pad}${key}: ${String(value)}`);
}
}
const hc = server.healthCheck as { tool: string; arguments?: Record<string, unknown>; intervalSeconds?: number; timeoutSeconds?: number; failureThreshold?: number } | null;
if (hc) {
lines.push('');
lines.push('Health Check:');
lines.push(` ${pad('Tool:', 22)}${hc.tool}`);
if (hc.arguments && Object.keys(hc.arguments).length > 0) {
lines.push(` ${pad('Arguments:', 22)}${JSON.stringify(hc.arguments)}`);
}
lines.push(` ${pad('Interval:', 22)}${hc.intervalSeconds ?? 60}s`);
lines.push(` ${pad('Timeout:', 22)}${hc.timeoutSeconds ?? 10}s`);
lines.push(` ${pad('Failure Threshold:', 22)}${hc.failureThreshold ?? 3}`);
}
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${server.id}`);
if (server.createdAt) lines.push(` ${pad('Created:', 12)}${server.createdAt}`);
if (server.updatedAt) lines.push(` ${pad('Updated:', 12)}${server.updatedAt}`);
return lines.join('\n');
}
function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`=== Instance: ${instance.id} ===`);
lines.push(`${pad('Status:')}${instance.status}`);
lines.push(`${pad('Server ID:')}${instance.serverId}`);
lines.push(`${pad('Container ID:')}${instance.containerId ?? '-'}`);
lines.push(`${pad('Port:')}${instance.port ?? '-'}`);
// Health section
const healthStatus = instance.healthStatus as string | null;
const lastHealthCheck = instance.lastHealthCheck as string | null;
if (healthStatus || lastHealthCheck) {
lines.push('');
lines.push('Health:');
lines.push(` ${pad('Status:', 16)}${healthStatus ?? 'unknown'}`);
if (lastHealthCheck) lines.push(` ${pad('Last Check:', 16)}${lastHealthCheck}`);
}
const metadata = instance.metadata as Record<string, unknown> | undefined;
if (metadata && Object.keys(metadata).length > 0) {
lines.push('');
lines.push('Metadata:');
for (const [key, value] of Object.entries(metadata)) {
lines.push(` ${pad(key + ':', 16)}${String(value)}`);
}
}
if (inspect) {
lines.push('');
lines.push('Container:');
for (const [key, value] of Object.entries(inspect)) {
if (typeof value === 'object' && value !== null) {
lines.push(` ${key}: ${JSON.stringify(value)}`);
} else {
lines.push(` ${pad(key + ':', 16)}${String(value)}`);
}
}
}
// Events section (k8s-style)
const events = instance.events as Array<{ timestamp: string; type: string; message: string }> | undefined;
if (events && events.length > 0) {
lines.push('');
lines.push('Events:');
const tsW = 26;
const typeW = 10;
lines.push(` ${'TIMESTAMP'.padEnd(tsW)}${'TYPE'.padEnd(typeW)}MESSAGE`);
for (const ev of events) {
lines.push(` ${(ev.timestamp ?? '').padEnd(tsW)}${(ev.type ?? '').padEnd(typeW)}${ev.message ?? ''}`);
}
}
lines.push('');
lines.push(` ${pad('ID:', 12)}${instance.id}`);
if (instance.createdAt) lines.push(` ${pad('Created:', 12)}${instance.createdAt}`);
if (instance.updatedAt) lines.push(` ${pad('Updated:', 12)}${instance.updatedAt}`);
return lines.join('\n');
}
function formatProjectDetail(project: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`=== Project: ${project.name} ===`);
lines.push(`${pad('Name:')}${project.name}`);
if (project.description) lines.push(`${pad('Description:')}${project.description}`);
if (project.ownerId) lines.push(`${pad('Owner:')}${project.ownerId}`);
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${project.id}`);
if (project.createdAt) lines.push(` ${pad('Created:', 12)}${project.createdAt}`);
if (project.updatedAt) lines.push(` ${pad('Updated:', 12)}${project.updatedAt}`);
return lines.join('\n');
}
function formatSecretDetail(secret: Record<string, unknown>, showValues: boolean): string {
const lines: string[] = [];
lines.push(`=== Secret: ${secret.name} ===`);
lines.push(`${pad('Name:')}${secret.name}`);
const data = secret.data as Record<string, string> | undefined;
if (data && Object.keys(data).length > 0) {
lines.push('');
lines.push('Data:');
const keyW = Math.max(4, ...Object.keys(data).map((k) => k.length)) + 2;
for (const [key, value] of Object.entries(data)) {
const display = showValues ? value : '***';
lines.push(` ${key.padEnd(keyW)}${display}`);
}
if (!showValues) {
lines.push('');
lines.push(' (use --show-values to reveal)');
}
} else {
lines.push(`${pad('Data:')}(empty)`);
}
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${secret.id}`);
if (secret.createdAt) lines.push(` ${pad('Created:', 12)}${secret.createdAt}`);
if (secret.updatedAt) lines.push(` ${pad('Updated:', 12)}${secret.updatedAt}`);
return lines.join('\n');
}
function formatTemplateDetail(template: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`=== Template: ${template.name} ===`);
lines.push(`${pad('Name:')}${template.name}`);
lines.push(`${pad('Version:')}${template.version ?? '1.0.0'}`);
lines.push(`${pad('Transport:')}${template.transport ?? 'STDIO'}`);
lines.push(`${pad('Replicas:')}${template.replicas ?? 1}`);
if (template.dockerImage) lines.push(`${pad('Docker Image:')}${template.dockerImage}`);
if (template.packageName) lines.push(`${pad('Package:')}${template.packageName}`);
if (template.externalUrl) lines.push(`${pad('External URL:')}${template.externalUrl}`);
if (template.repositoryUrl) lines.push(`${pad('Repository:')}${template.repositoryUrl}`);
if (template.containerPort) lines.push(`${pad('Container Port:')}${template.containerPort}`);
if (template.description) lines.push(`${pad('Description:')}${template.description}`);
const command = template.command as string[] | null;
if (command && command.length > 0) {
lines.push('');
lines.push('Command:');
lines.push(` ${command.join(' ')}`);
}
const env = template.env as Array<{ name: string; description?: string; required?: boolean; defaultValue?: string }> | undefined;
if (env && env.length > 0) {
lines.push('');
lines.push('Environment Variables:');
const nameW = Math.max(6, ...env.map((e) => e.name.length)) + 2;
lines.push(` ${'NAME'.padEnd(nameW)}${'REQUIRED'.padEnd(10)}DESCRIPTION`);
for (const e of env) {
const req = e.required ? 'yes' : 'no';
const desc = e.description ?? '';
lines.push(` ${e.name.padEnd(nameW)}${req.padEnd(10)}${desc}`);
}
}
const hc = template.healthCheck as { tool: string; arguments?: Record<string, unknown>; intervalSeconds?: number; timeoutSeconds?: number; failureThreshold?: number } | null;
if (hc) {
lines.push('');
lines.push('Health Check:');
lines.push(` ${pad('Tool:', 22)}${hc.tool}`);
if (hc.arguments && Object.keys(hc.arguments).length > 0) {
lines.push(` ${pad('Arguments:', 22)}${JSON.stringify(hc.arguments)}`);
}
lines.push(` ${pad('Interval:', 22)}${hc.intervalSeconds ?? 60}s`);
lines.push(` ${pad('Timeout:', 22)}${hc.timeoutSeconds ?? 10}s`);
lines.push(` ${pad('Failure Threshold:', 22)}${hc.failureThreshold ?? 3}`);
}
lines.push('');
lines.push('Usage:');
lines.push(` mcpctl create server my-${template.name} --from-template=${template.name}`);
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${template.id}`);
if (template.createdAt) lines.push(` ${pad('Created:', 12)}${template.createdAt}`);
if (template.updatedAt) lines.push(` ${pad('Updated:', 12)}${template.updatedAt}`);
return lines.join('\n');
}
function formatGenericDetail(obj: Record<string, unknown>): string {
const lines: string[] = [];
for (const [key, value] of Object.entries(obj)) {
if (value === null || value === undefined) {
lines.push(`${pad(key + ':')} -`);
} else if (Array.isArray(value)) {
if (value.length === 0) {
lines.push(`${pad(key + ':')} []`);
} else {
lines.push(`${key}:`);
for (const item of value) {
lines.push(` - ${typeof item === 'object' ? JSON.stringify(item) : String(item)}`);
}
}
} else if (typeof value === 'object') {
lines.push(`${key}:`);
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
lines.push(` ${pad(k + ':')}${String(v)}`);
}
} else {
lines.push(`${pad(key + ':')}${String(value)}`);
}
}
return lines.join('\n');
}
export function createDescribeCommand(deps: DescribeCommandDeps): Command {
return new Command('describe')
.description('Show detailed information about a resource')
.argument('<resource>', 'resource type (server, profile, project, instance)')
.argument('<id>', 'resource ID')
.argument('<resource>', 'resource type (server, project, instance)')
.argument('<id>', 'resource ID or name')
.option('-o, --output <format>', 'output format (detail, json, yaml)', 'detail')
.action(async (resourceArg: string, id: string, opts: { output: string }) => {
.option('--show-values', 'Show secret values (default: masked)')
.action(async (resourceArg: string, idOrName: string, opts: { output: string; showValues?: boolean }) => {
const resource = resolveResource(resourceArg);
const item = await deps.fetchResource(resource, id);
// Resolve name → ID
let id: string;
try {
id = await resolveNameOrId(deps.client, resource, idOrName);
} catch {
id = idOrName;
}
const item = await deps.fetchResource(resource, id) as Record<string, unknown>;
// Enrich instances with container inspect data
let inspect: Record<string, unknown> | undefined;
if (resource === 'instances' && deps.fetchInspect && item.containerId) {
try {
inspect = await deps.fetchInspect(id) as Record<string, unknown>;
item.containerInspect = inspect;
} catch {
// Container may not be available
}
}
if (opts.output === 'json') {
deps.log(formatJson(item));
} else if (opts.output === 'yaml') {
deps.log(formatYaml(item));
} else {
const typeName = resource.replace(/s$/, '').charAt(0).toUpperCase() + resource.replace(/s$/, '').slice(1);
deps.log(`--- ${typeName} ---`);
deps.log(formatDetail(item as Record<string, unknown>));
// Visually clean sectioned output
switch (resource) {
case 'servers':
deps.log(formatServerDetail(item));
break;
case 'instances':
deps.log(formatInstanceDetail(item, inspect));
break;
case 'secrets':
deps.log(formatSecretDetail(item, opts.showValues === true));
break;
case 'templates':
deps.log(formatTemplateDetail(item));
break;
case 'projects':
deps.log(formatProjectDetail(item));
break;
default:
deps.log(formatGenericDetail(item));
}
}
});
}

View File

@@ -0,0 +1,114 @@
import { Command } from 'commander';
import { writeFileSync, readFileSync, unlinkSync, mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execSync } from 'node:child_process';
import yaml from 'js-yaml';
import type { ApiClient } from '../api-client.js';
import { resolveResource, resolveNameOrId, stripInternalFields } from './shared.js';
export interface EditCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
/** Override for testing — return editor binary name. */
getEditor?: () => string;
/** Override for testing — simulate opening the editor. */
openEditor?: (filePath: string, editor: string) => void;
}
function getEditor(deps: EditCommandDeps): string {
if (deps.getEditor) return deps.getEditor();
return process.env.VISUAL ?? process.env.EDITOR ?? 'vi';
}
function openEditor(filePath: string, editor: string, deps: EditCommandDeps): void {
if (deps.openEditor) {
deps.openEditor(filePath, editor);
return;
}
execSync(`${editor} "${filePath}"`, { stdio: 'inherit' });
}
export function createEditCommand(deps: EditCommandDeps): Command {
const { client, log } = deps;
return new Command('edit')
.description('Edit a resource in your default editor (server, project)')
.argument('<resource>', 'Resource type (server, project)')
.argument('<name-or-id>', 'Resource name or ID')
.action(async (resourceArg: string, nameOrId: string) => {
const resource = resolveResource(resourceArg);
// Instances are immutable
if (resource === 'instances') {
log('Error: instances are immutable and cannot be edited.');
log('To change an instance, update the server definition and let reconciliation handle it.');
process.exitCode = 1;
return;
}
const validResources = ['servers', 'secrets', 'projects'];
if (!validResources.includes(resource)) {
log(`Error: unknown resource type '${resourceArg}'`);
process.exitCode = 1;
return;
}
// Resolve name → ID
const id = await resolveNameOrId(client, resource, nameOrId);
// Fetch current state
const current = await client.get<Record<string, unknown>>(`/api/v1/${resource}/${id}`);
// Strip read-only fields for editor
const editable = stripInternalFields(current);
// Serialize to YAML
const singular = resource.replace(/s$/, '');
const header = `# Editing ${singular}: ${nameOrId}\n# Save and close to apply changes. Clear the file to cancel.\n`;
const originalYaml = yaml.dump(editable, { lineWidth: 120, noRefs: true });
const content = header + originalYaml;
// Write to temp file
const tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-edit-'));
const tmpFile = join(tmpDir, `${singular}-${nameOrId}.yaml`);
writeFileSync(tmpFile, content, 'utf-8');
try {
// Open editor
const editor = getEditor(deps);
openEditor(tmpFile, editor, deps);
// Read back
const modified = readFileSync(tmpFile, 'utf-8');
// Strip comments for comparison
const modifiedClean = modified
.split('\n')
.filter((line) => !line.startsWith('#'))
.join('\n')
.trim();
if (!modifiedClean) {
log('Edit cancelled (empty file).');
return;
}
if (modifiedClean === originalYaml.trim()) {
log(`${singular} '${nameOrId}' unchanged.`);
return;
}
// Parse and apply
const updates = yaml.load(modifiedClean) as Record<string, unknown>;
await client.put(`/api/v1/${resource}/${id}`, updates);
log(`${singular} '${nameOrId}' updated.`);
} finally {
try {
unlinkSync(tmpFile);
} catch {
// Ignore cleanup errors
}
}
});
}

View File

@@ -2,6 +2,7 @@ import { Command } from 'commander';
import { formatTable } from '../formatters/table.js';
import { formatJson, formatYaml } from '../formatters/output.js';
import type { Column } from '../formatters/table.js';
import { resolveResource, stripInternalFields } from './shared.js';
export interface GetCommandDeps {
fetchResource: (resource: string, id?: string) => Promise<unknown[]>;
@@ -16,12 +17,6 @@ interface ServerRow {
dockerImage: string | null;
}
interface ProfileRow {
id: string;
name: string;
serverId: string;
}
interface ProjectRow {
id: string;
name: string;
@@ -29,28 +24,29 @@ interface ProjectRow {
ownerId: string;
}
interface SecretRow {
id: string;
name: string;
data: Record<string, string>;
}
interface TemplateRow {
id: string;
name: string;
version: string;
transport: string;
packageName: string | null;
description: string;
}
interface InstanceRow {
id: string;
serverId: string;
server?: { name: string };
status: string;
containerId: string | null;
port: number | null;
}
const RESOURCE_ALIASES: Record<string, string> = {
server: 'servers',
srv: 'servers',
profile: 'profiles',
prof: 'profiles',
project: 'projects',
proj: 'projects',
instance: 'instances',
inst: 'instances',
};
function resolveResource(name: string): string {
const lower = name.toLowerCase();
return RESOURCE_ALIASES[lower] ?? lower;
healthStatus: string | null;
}
const serverColumns: Column<ServerRow>[] = [
@@ -61,12 +57,6 @@ const serverColumns: Column<ServerRow>[] = [
{ header: 'ID', key: 'id' },
];
const profileColumns: Column<ProfileRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'SERVER ID', key: 'serverId' },
{ header: 'ID', key: 'id' },
];
const projectColumns: Column<ProjectRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'DESCRIPTION', key: 'description', width: 40 },
@@ -74,9 +64,24 @@ const projectColumns: Column<ProjectRow>[] = [
{ header: 'ID', key: 'id' },
];
const secretColumns: Column<SecretRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'KEYS', key: (r) => Object.keys(r.data).join(', ') || '-', width: 40 },
{ header: 'ID', key: 'id' },
];
const templateColumns: Column<TemplateRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'VERSION', key: 'version', width: 10 },
{ header: 'TRANSPORT', key: 'transport', width: 16 },
{ header: 'PACKAGE', key: (r) => r.packageName ?? '-' },
{ header: 'DESCRIPTION', key: 'description', width: 50 },
];
const instanceColumns: Column<InstanceRow>[] = [
{ header: 'NAME', key: (r) => r.server?.name ?? '-', width: 20 },
{ header: 'STATUS', key: 'status', width: 10 },
{ header: 'SERVER ID', key: 'serverId' },
{ header: 'HEALTH', key: (r) => r.healthStatus ?? '-', width: 10 },
{ header: 'PORT', key: (r) => r.port != null ? String(r.port) : '-', width: 6 },
{ header: 'CONTAINER', key: (r) => r.containerId ? r.containerId.slice(0, 12) : '-', width: 14 },
{ header: 'ID', key: 'id' },
@@ -86,10 +91,12 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
switch (resource) {
case 'servers':
return serverColumns as unknown as Column<Record<string, unknown>>[];
case 'profiles':
return profileColumns as unknown as Column<Record<string, unknown>>[];
case 'projects':
return projectColumns as unknown as Column<Record<string, unknown>>[];
case 'secrets':
return secretColumns as unknown as Column<Record<string, unknown>>[];
case 'templates':
return templateColumns as unknown as Column<Record<string, unknown>>[];
case 'instances':
return instanceColumns as unknown as Column<Record<string, unknown>>[];
default:
@@ -100,21 +107,38 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
}
}
/**
* Transform API response items into apply-compatible format.
* Strips internal fields and wraps in the resource key.
*/
function toApplyFormat(resource: string, items: unknown[]): Record<string, unknown[]> {
const cleaned = items.map((item) => {
return stripInternalFields(item as Record<string, unknown>);
});
return { [resource]: cleaned };
}
export function createGetCommand(deps: GetCommandDeps): Command {
return new Command('get')
.description('List resources (servers, profiles, projects, instances)')
.argument('<resource>', 'resource type (servers, profiles, projects, instances)')
.argument('[id]', 'specific resource ID')
.description('List resources (servers, projects, instances)')
.argument('<resource>', 'resource type (servers, projects, instances)')
.argument('[id]', 'specific resource ID or name')
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
.action(async (resourceArg: string, id: string | undefined, opts: { output: string }) => {
const resource = resolveResource(resourceArg);
const items = await deps.fetchResource(resource, id);
if (opts.output === 'json') {
deps.log(formatJson(items.length === 1 ? items[0] : items));
// Apply-compatible JSON wrapped in resource key
deps.log(formatJson(toApplyFormat(resource, items)));
} else if (opts.output === 'yaml') {
deps.log(formatYaml(items.length === 1 ? items[0] : items));
// Apply-compatible YAML wrapped in resource key
deps.log(formatYaml(toApplyFormat(resource, items)));
} else {
if (items.length === 0) {
deps.log(`No ${resource} found.`);
return;
}
const columns = getColumnsForResource(resource);
deps.log(formatTable(items as Record<string, unknown>[], columns));
}

View File

@@ -1,123 +0,0 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
interface Instance {
id: string;
serverId: string;
status: string;
containerId: string | null;
port: number | null;
createdAt: string;
}
export interface InstanceCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
}
export function createInstanceCommands(deps: InstanceCommandDeps): Command {
const { client, log } = deps;
const cmd = new Command('instance')
.alias('instances')
.alias('inst')
.description('Manage MCP server instances');
cmd
.command('list')
.alias('ls')
.description('List running instances')
.option('-s, --server <id>', 'Filter by server ID')
.option('-o, --output <format>', 'Output format (table, json)', 'table')
.action(async (opts: { server?: string; output: string }) => {
let url = '/api/v1/instances';
if (opts.server) {
url += `?serverId=${encodeURIComponent(opts.server)}`;
}
const instances = await client.get<Instance[]>(url);
if (opts.output === 'json') {
log(JSON.stringify(instances, null, 2));
return;
}
if (instances.length === 0) {
log('No instances found.');
return;
}
log('ID\tSERVER\tSTATUS\tPORT\tCONTAINER');
for (const inst of instances) {
const cid = inst.containerId ? inst.containerId.slice(0, 12) : '-';
const port = inst.port ?? '-';
log(`${inst.id}\t${inst.serverId}\t${inst.status}\t${port}\t${cid}`);
}
});
cmd
.command('start <serverId>')
.description('Start a new MCP server instance')
.option('-p, --port <port>', 'Host port to bind')
.option('-o, --output <format>', 'Output format (table, json)', 'table')
.action(async (serverId: string, opts: { port?: string; output: string }) => {
const body: Record<string, unknown> = { serverId };
if (opts.port !== undefined) {
body.hostPort = parseInt(opts.port, 10);
}
const instance = await client.post<Instance>('/api/v1/instances', body);
if (opts.output === 'json') {
log(JSON.stringify(instance, null, 2));
return;
}
log(`Instance ${instance.id} started (status: ${instance.status})`);
});
cmd
.command('stop <id>')
.description('Stop a running instance')
.action(async (id: string) => {
const instance = await client.post<Instance>(`/api/v1/instances/${id}/stop`);
log(`Instance ${id} stopped (status: ${instance.status})`);
});
cmd
.command('restart <id>')
.description('Restart an instance (stop, remove, start fresh)')
.action(async (id: string) => {
const instance = await client.post<Instance>(`/api/v1/instances/${id}/restart`);
log(`Instance restarted as ${instance.id} (status: ${instance.status})`);
});
cmd
.command('remove <id>')
.alias('rm')
.description('Remove an instance and its container')
.action(async (id: string) => {
await client.delete(`/api/v1/instances/${id}`);
log(`Instance ${id} removed.`);
});
cmd
.command('logs <id>')
.description('Get logs from an instance')
.option('-t, --tail <lines>', 'Number of lines to show')
.action(async (id: string, opts: { tail?: string }) => {
let url = `/api/v1/instances/${id}/logs`;
if (opts.tail) {
url += `?tail=${opts.tail}`;
}
const logs = await client.get<{ stdout: string; stderr: string }>(url);
if (logs.stdout) {
log(logs.stdout);
}
if (logs.stderr) {
process.stderr.write(logs.stderr);
}
});
cmd
.command('inspect <id>')
.description('Get detailed container info for an instance')
.action(async (id: string) => {
const info = await client.get(`/api/v1/instances/${id}/inspect`);
log(JSON.stringify(info, null, 2));
});
return cmd;
}

View File

@@ -0,0 +1,57 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
export interface LogsCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
}
/**
* Resolve a name/ID to an instance ID.
* Accepts: instance ID, server name, or server ID.
* For servers, picks the first RUNNING instance.
*/
async function resolveInstanceId(client: ApiClient, nameOrId: string): Promise<string> {
// Try as instance ID first
try {
await client.get(`/api/v1/instances/${nameOrId}`);
return nameOrId;
} catch {
// Not a valid instance ID
}
// Try as server name → find its instances
const servers = await client.get<Array<{ id: string; name: string }>>('/api/v1/servers');
const server = servers.find((s) => s.name === nameOrId || s.id === nameOrId);
if (server) {
const instances = await client.get<Array<{ id: string; status: string }>>(`/api/v1/instances?serverId=${server.id}`);
const running = instances.find((i) => i.status === 'RUNNING') ?? instances[0];
if (running) return running.id;
throw new Error(`No instances found for server '${nameOrId}'`);
}
throw new Error(`Instance or server '${nameOrId}' not found`);
}
export function createLogsCommand(deps: LogsCommandDeps): Command {
const { client, log } = deps;
return new Command('logs')
.description('Get logs from an MCP server instance')
.argument('<name>', 'Server name, server ID, or instance ID')
.option('-t, --tail <lines>', 'Number of lines to show')
.action(async (nameOrId: string, opts: { tail?: string }) => {
const instanceId = await resolveInstanceId(client, nameOrId);
let url = `/api/v1/instances/${instanceId}/logs`;
if (opts.tail) {
url += `?tail=${opts.tail}`;
}
const logs = await client.get<{ stdout: string; stderr: string }>(url);
if (logs.stdout) {
log(logs.stdout);
}
if (logs.stderr) {
process.stderr.write(logs.stderr);
}
});
}

View File

@@ -1,129 +1,15 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
interface Project {
id: string;
name: string;
description: string;
ownerId: string;
createdAt: string;
}
interface Profile {
id: string;
name: string;
serverId: string;
}
export interface ProjectCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
}
export function createProjectCommand(deps: ProjectCommandDeps): Command {
const { client, log } = deps;
export function createProjectCommand(_deps: ProjectCommandDeps): Command {
const cmd = new Command('project')
.alias('projects')
.alias('proj')
.description('Manage mcpctl projects');
cmd
.command('list')
.alias('ls')
.description('List all projects')
.option('-o, --output <format>', 'Output format (table, json)', 'table')
.action(async (opts: { output: string }) => {
const projects = await client.get<Project[]>('/api/v1/projects');
if (opts.output === 'json') {
log(JSON.stringify(projects, null, 2));
return;
}
if (projects.length === 0) {
log('No projects found.');
return;
}
log('ID\tNAME\tDESCRIPTION');
for (const p of projects) {
log(`${p.id}\t${p.name}\t${p.description || '-'}`);
}
});
cmd
.command('create <name>')
.description('Create a new project')
.option('-d, --description <text>', 'Project description', '')
.action(async (name: string, opts: { description: string }) => {
const project = await client.post<Project>('/api/v1/projects', {
name,
description: opts.description,
});
log(`Project '${project.name}' created (id: ${project.id})`);
});
cmd
.command('delete <id>')
.alias('rm')
.description('Delete a project')
.action(async (id: string) => {
await client.delete(`/api/v1/projects/${id}`);
log(`Project '${id}' deleted.`);
});
cmd
.command('show <id>')
.description('Show project details')
.action(async (id: string) => {
const project = await client.get<Project>(`/api/v1/projects/${id}`);
log(`Name: ${project.name}`);
log(`ID: ${project.id}`);
log(`Description: ${project.description || '-'}`);
log(`Owner: ${project.ownerId}`);
log(`Created: ${project.createdAt}`);
try {
const profiles = await client.get<Profile[]>(`/api/v1/projects/${id}/profiles`);
if (profiles.length > 0) {
log('\nProfiles:');
for (const p of profiles) {
log(` - ${p.name} (id: ${p.id})`);
}
} else {
log('\nNo profiles assigned.');
}
} catch {
// Profiles endpoint may not be available
}
});
cmd
.command('profiles <id>')
.description('List profiles assigned to a project')
.option('-o, --output <format>', 'Output format (table, json)', 'table')
.action(async (id: string, opts: { output: string }) => {
const profiles = await client.get<Profile[]>(`/api/v1/projects/${id}/profiles`);
if (opts.output === 'json') {
log(JSON.stringify(profiles, null, 2));
return;
}
if (profiles.length === 0) {
log('No profiles assigned.');
return;
}
log('ID\tNAME\tSERVER');
for (const p of profiles) {
log(`${p.id}\t${p.name}\t${p.serverId}`);
}
});
cmd
.command('set-profiles <id>')
.description('Set the profiles assigned to a project')
.argument('<profileIds...>', 'Profile IDs to assign')
.action(async (id: string, profileIds: string[]) => {
await client.put(`/api/v1/projects/${id}/profiles`, { profileIds });
log(`Set ${profileIds.length} profile(s) for project '${id}'.`);
});
.description('Project-specific actions (create with "create project", list with "get projects")');
return cmd;
}

View File

@@ -1,103 +0,0 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
export interface SetupPromptDeps {
input: (message: string) => Promise<string>;
password: (message: string) => Promise<string>;
select: <T extends string>(message: string, choices: Array<{ name: string; value: T }>) => Promise<T>;
confirm: (message: string) => Promise<boolean>;
}
export interface SetupCommandDeps {
client: ApiClient;
prompt: SetupPromptDeps;
log: (...args: unknown[]) => void;
}
export function createSetupCommand(deps: SetupCommandDeps): Command {
const { client, prompt, log } = deps;
return new Command('setup')
.description('Interactive wizard for configuring an MCP server')
.argument('[server-name]', 'Server name to set up (will prompt if not given)')
.action(async (serverName?: string) => {
log('MCP Server Setup Wizard\n');
// Step 1: Server name
const name = serverName ?? await prompt.input('Server name (lowercase, hyphens allowed):');
if (!name) {
log('Setup cancelled.');
return;
}
// Step 2: Transport
const transport = await prompt.select('Transport type:', [
{ name: 'STDIO (command-line process)', value: 'STDIO' as const },
{ name: 'SSE (Server-Sent Events over HTTP)', value: 'SSE' as const },
{ name: 'Streamable HTTP', value: 'STREAMABLE_HTTP' as const },
]);
// Step 3: Package or image
const packageName = await prompt.input('NPM package name (or leave empty):');
const dockerImage = await prompt.input('Docker image (or leave empty):');
// Step 4: Description
const description = await prompt.input('Description:');
// Step 5: Create the server
const serverData: Record<string, unknown> = {
name,
transport,
description,
};
if (packageName) serverData.packageName = packageName;
if (dockerImage) serverData.dockerImage = dockerImage;
let server: { id: string; name: string };
try {
server = await client.post<{ id: string; name: string }>('/api/v1/servers', serverData);
log(`\nServer '${server.name}' created.`);
} catch (err) {
log(`\nFailed to create server: ${err instanceof Error ? err.message : err}`);
return;
}
// Step 6: Create a profile with env vars
const createProfile = await prompt.confirm('Create a profile with environment variables?');
if (!createProfile) {
log('\nSetup complete!');
return;
}
const profileName = await prompt.input('Profile name:') || 'default';
// Collect env vars
const envOverrides: Record<string, string> = {};
let addMore = true;
while (addMore) {
const envName = await prompt.input('Environment variable name (empty to finish):');
if (!envName) break;
const isSecret = await prompt.confirm(`Is '${envName}' a secret (e.g., API key)?`);
const envValue = isSecret
? await prompt.password(`Value for ${envName}:`)
: await prompt.input(`Value for ${envName}:`);
envOverrides[envName] = envValue;
addMore = await prompt.confirm('Add another environment variable?');
}
try {
await client.post('/api/v1/profiles', {
name: profileName,
serverId: server.id,
envOverrides,
});
log(`Profile '${profileName}' created for server '${name}'.`);
} catch (err) {
log(`Failed to create profile: ${err instanceof Error ? err.message : err}`);
}
log('\nSetup complete!');
});
}

View File

@@ -0,0 +1,44 @@
import type { ApiClient } from '../api-client.js';
export const RESOURCE_ALIASES: Record<string, string> = {
server: 'servers',
srv: 'servers',
project: 'projects',
proj: 'projects',
instance: 'instances',
inst: 'instances',
secret: 'secrets',
sec: 'secrets',
template: 'templates',
tpl: 'templates',
};
export function resolveResource(name: string): string {
const lower = name.toLowerCase();
return RESOURCE_ALIASES[lower] ?? lower;
}
/** Resolve a name-or-ID to an ID. CUIDs pass through; names are looked up. */
export async function resolveNameOrId(
client: ApiClient,
resource: string,
nameOrId: string,
): Promise<string> {
// CUIDs start with 'c' followed by 24+ alphanumeric chars
if (/^c[a-z0-9]{24}/.test(nameOrId)) {
return nameOrId;
}
const items = await client.get<Array<{ id: string; name: string }>>(`/api/v1/${resource}`);
const match = items.find((item) => item.name === nameOrId);
if (match) return match.id;
throw new Error(`${resource.replace(/s$/, '')} '${nameOrId}' not found`);
}
/** Strip internal/read-only fields from an API response to make it apply-compatible. */
export function stripInternalFields(obj: Record<string, unknown>): Record<string, unknown> {
const result = { ...obj };
for (const key of ['id', 'createdAt', 'updatedAt', 'version', 'ownerId']) {
delete result[key];
}
return result;
}

View File

@@ -5,23 +5,26 @@ import { createConfigCommand } from './commands/config.js';
import { createStatusCommand } from './commands/status.js';
import { createGetCommand } from './commands/get.js';
import { createDescribeCommand } from './commands/describe.js';
import { createInstanceCommands } from './commands/instances.js';
import { createDeleteCommand } from './commands/delete.js';
import { createLogsCommand } from './commands/logs.js';
import { createApplyCommand } from './commands/apply.js';
import { createSetupCommand } from './commands/setup.js';
import { createCreateCommand } from './commands/create.js';
import { createEditCommand } from './commands/edit.js';
import { createClaudeCommand } from './commands/claude.js';
import { createProjectCommand } from './commands/project.js';
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
import { ApiClient } from './api-client.js';
import { ApiClient, ApiError } from './api-client.js';
import { loadConfig } from './config/index.js';
import { loadCredentials } from './auth/index.js';
import { resolveNameOrId } from './commands/shared.js';
export function createProgram(): Command {
const program = new Command()
.name(APP_NAME)
.description('Manage MCP servers like kubectl manages containers')
.version(APP_VERSION, '-v, --version')
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
.enablePositionalOptions()
.option('--daemon-url <url>', 'mcplocal daemon URL')
.option('--direct', 'bypass mcplocal and connect directly to mcpd');
@@ -45,15 +48,31 @@ export function createProgram(): Command {
const client = new ApiClient({ baseUrl, token: creds?.token ?? undefined });
const fetchResource = async (resource: string, id?: string): Promise<unknown[]> => {
if (id) {
const fetchResource = async (resource: string, nameOrId?: string): Promise<unknown[]> => {
if (nameOrId) {
// Glob pattern — use query param filtering
if (nameOrId.includes('*')) {
return client.get<unknown[]>(`/api/v1/${resource}?name=${encodeURIComponent(nameOrId)}`);
}
let id: string;
try {
id = await resolveNameOrId(client, resource, nameOrId);
} catch {
id = nameOrId;
}
const item = await client.get(`/api/v1/${resource}/${id}`);
return [item];
}
return client.get<unknown[]>(`/api/v1/${resource}`);
};
const fetchSingleResource = async (resource: string, id: string): Promise<unknown> => {
const fetchSingleResource = async (resource: string, nameOrId: string): Promise<unknown> => {
let id: string;
try {
id = await resolveNameOrId(client, resource, nameOrId);
} catch {
id = nameOrId;
}
return client.get(`/api/v1/${resource}/${id}`);
};
@@ -63,11 +82,28 @@ export function createProgram(): Command {
}));
program.addCommand(createDescribeCommand({
client,
fetchResource: fetchSingleResource,
fetchInspect: async (id: string) => client.get(`/api/v1/instances/${id}/inspect`),
log: (...args) => console.log(...args),
}));
program.addCommand(createInstanceCommands({
program.addCommand(createDeleteCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createLogsCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createCreateCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createEditCommand({
client,
log: (...args) => console.log(...args),
}));
@@ -77,33 +113,6 @@ export function createProgram(): Command {
log: (...args) => console.log(...args),
}));
program.addCommand(createSetupCommand({
client,
prompt: {
async input(message) {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{ type: 'input', name: 'answer', message }]);
return answer as string;
},
async password(message) {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{ type: 'password', name: 'answer', message }]);
return answer as string;
},
async select(message, choices) {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{ type: 'list', name: 'answer', message, choices }]);
return answer;
},
async confirm(message) {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{ type: 'confirm', name: 'answer', message }]);
return answer as boolean;
},
},
log: (...args) => console.log(...args),
}));
program.addCommand(createClaudeCommand({
client,
log: (...args) => console.log(...args),
@@ -134,5 +143,21 @@ const isDirectRun =
import.meta.url === `file://${process.argv[1]}`;
if (isDirectRun) {
createProgram().parseAsync(process.argv);
createProgram().parseAsync(process.argv).catch((err: unknown) => {
if (err instanceof ApiError) {
let msg: string;
try {
const parsed = JSON.parse(err.body) as { error?: string; message?: string };
msg = parsed.error ?? parsed.message ?? err.body;
} catch {
msg = err.body;
}
console.error(`Error: ${msg}`);
} else if (err instanceof Error) {
console.error(`Error: ${err.message}`);
} else {
console.error(`Error: ${String(err)}`);
}
process.exit(1);
});
}

View File

@@ -24,9 +24,10 @@ describe('createProgram', () => {
expect(status).toBeDefined();
});
it('has output option', () => {
it('subcommands have output option', () => {
const program = createProgram();
const opt = program.options.find((o) => o.long === '--output');
const get = program.commands.find((c) => c.name() === 'get');
const opt = get?.options.find((o) => o.long === '--output');
expect(opt).toBeDefined();
});

View File

@@ -86,9 +86,6 @@ servers:
servers:
- name: test
transport: STDIO
profiles:
- name: default
server: test
`);
const cmd = createApplyCommand({ client, log });
@@ -97,52 +94,51 @@ profiles:
expect(client.post).not.toHaveBeenCalled();
expect(output.join('\n')).toContain('Dry run');
expect(output.join('\n')).toContain('1 server(s)');
expect(output.join('\n')).toContain('1 profile(s)');
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies profiles with server lookup', async () => {
it('applies secrets', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
secrets:
- name: ha-creds
data:
TOKEN: abc123
URL: https://ha.local
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', expect.objectContaining({
name: 'ha-creds',
data: { TOKEN: 'abc123', URL: 'https://ha.local' },
}));
expect(output.join('\n')).toContain('Created secret: ha-creds');
rmSync(tmpDir, { recursive: true, force: true });
});
it('updates existing secrets', async () => {
vi.mocked(client.get).mockImplementation(async (url: string) => {
if (url === '/api/v1/servers') return [{ id: 'srv-1', name: 'slack' }];
if (url === '/api/v1/secrets') return [{ id: 'sec-1', name: 'ha-creds' }];
return [];
});
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
profiles:
- name: default
server: slack
envOverrides:
SLACK_TOKEN: "xoxb-test"
secrets:
- name: ha-creds
data:
TOKEN: new-token
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({
name: 'default',
serverId: 'srv-1',
envOverrides: { SLACK_TOKEN: 'xoxb-test' },
}));
expect(output.join('\n')).toContain('Created profile: default');
rmSync(tmpDir, { recursive: true, force: true });
});
it('skips profiles when server not found', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
profiles:
- name: default
server: nonexistent
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).not.toHaveBeenCalled();
expect(output.join('\n')).toContain("Skipping profile 'default'");
expect(client.put).toHaveBeenCalledWith('/api/v1/secrets/sec-1', { data: { TOKEN: 'new-token' } });
expect(output.join('\n')).toContain('Updated secret: ha-creds');
rmSync(tmpDir, { recursive: true, force: true });
});

View File

@@ -0,0 +1,200 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createCreateCommand } from '../../src/commands/create.js';
import { type ApiClient, ApiError } from '../../src/api-client.js';
function mockClient(): ApiClient {
return {
get: vi.fn(async () => []),
post: vi.fn(async () => ({ id: 'new-id', name: 'test' })),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
describe('create command', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
beforeEach(() => {
client = mockClient();
output = [];
});
describe('create server', () => {
it('creates a server with minimal flags', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['server', 'my-server'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({
name: 'my-server',
transport: 'STDIO',
replicas: 1,
}));
expect(output.join('\n')).toContain("server 'test' created");
});
it('creates a server with all flags', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'server', 'ha-mcp',
'-d', 'Home Assistant MCP',
'--docker-image', 'ghcr.io/ha-mcp:latest',
'--transport', 'STREAMABLE_HTTP',
'--external-url', 'http://localhost:8086/mcp',
'--container-port', '3000',
'--replicas', '2',
'--command', 'python',
'--command', '-c',
'--command', 'print("hello")',
'--env', 'API_KEY=secretRef:creds:API_KEY',
'--env', 'BASE_URL=http://localhost',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', {
name: 'ha-mcp',
description: 'Home Assistant MCP',
dockerImage: 'ghcr.io/ha-mcp:latest',
transport: 'STREAMABLE_HTTP',
externalUrl: 'http://localhost:8086/mcp',
containerPort: 3000,
replicas: 2,
command: ['python', '-c', 'print("hello")'],
env: [
{ name: 'API_KEY', valueFrom: { secretRef: { name: 'creds', key: 'API_KEY' } } },
{ name: 'BASE_URL', value: 'http://localhost' },
],
});
});
it('defaults transport to STDIO', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['server', 'test'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({
transport: 'STDIO',
}));
});
it('strips null values from template when using --from-template', async () => {
vi.mocked(client.get).mockResolvedValueOnce([{
id: 'tpl-1',
name: 'grafana',
version: '1.0.0',
description: 'Grafana MCP',
packageName: '@leval/mcp-grafana',
dockerImage: null,
transport: 'STDIO',
repositoryUrl: 'https://github.com/test',
externalUrl: null,
command: null,
containerPort: null,
replicas: 1,
env: [{ name: 'TOKEN', required: true, description: 'A token' }],
healthCheck: { tool: 'test', arguments: {} },
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
}] as never);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'server', 'my-grafana', '--from-template=grafana',
'--env', 'TOKEN=secretRef:creds:TOKEN',
], { from: 'user' });
const call = vi.mocked(client.post).mock.calls[0]![1] as Record<string, unknown>;
// null fields from template should NOT be in the body
expect(call).not.toHaveProperty('dockerImage');
expect(call).not.toHaveProperty('externalUrl');
expect(call).not.toHaveProperty('command');
expect(call).not.toHaveProperty('containerPort');
// non-null fields should be present
expect(call.packageName).toBe('@leval/mcp-grafana');
expect(call.healthCheck).toEqual({ tool: 'test', arguments: {} });
expect(call.templateName).toBe('grafana');
});
it('throws on 409 without --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Server already exists: my-server"}'));
const cmd = createCreateCommand({ client, log });
await expect(cmd.parseAsync(['server', 'my-server'], { from: 'user' })).rejects.toThrow('API error 409');
});
it('updates existing server on 409 with --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Server already exists"}'));
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'srv-1', name: 'my-server' }] as never);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['server', 'my-server', '--force'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/servers/srv-1', expect.objectContaining({
transport: 'STDIO',
}));
expect(output.join('\n')).toContain("server 'my-server' updated");
});
});
describe('create secret', () => {
it('creates a secret with --data flags', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'secret', 'ha-creds',
'--data', 'TOKEN=abc123',
'--data', 'URL=https://ha.local',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', {
name: 'ha-creds',
data: { TOKEN: 'abc123', URL: 'https://ha.local' },
});
expect(output.join('\n')).toContain("secret 'test' created");
});
it('creates a secret with empty data', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['secret', 'empty-secret'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', {
name: 'empty-secret',
data: {},
});
});
it('throws on 409 without --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Secret already exists: my-creds"}'));
const cmd = createCreateCommand({ client, log });
await expect(cmd.parseAsync(['secret', 'my-creds', '--data', 'KEY=val'], { from: 'user' })).rejects.toThrow('API error 409');
});
it('updates existing secret on 409 with --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Secret already exists"}'));
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'sec-1', name: 'my-creds' }] as never);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['secret', 'my-creds', '--data', 'KEY=val', '--force'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/secrets/sec-1', { data: { KEY: 'val' } });
expect(output.join('\n')).toContain("secret 'my-creds' updated");
});
});
describe('create project', () => {
it('creates a project', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'my-project', '-d', 'A test project'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
name: 'my-project',
description: 'A test project',
});
expect(output.join('\n')).toContain("project 'test' created");
});
it('creates a project with no description', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'minimal'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
name: 'minimal',
description: '',
});
});
it('updates existing project on 409 with --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Project already exists"}'));
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'proj-1', name: 'my-proj' }] as never);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'my-proj', '-d', 'updated', '--force'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/projects/proj-1', { description: 'updated' });
expect(output.join('\n')).toContain("project 'my-proj' updated");
});
});
});

View File

@@ -1,42 +1,59 @@
import { describe, it, expect, vi } from 'vitest';
import { createDescribeCommand } from '../../src/commands/describe.js';
import type { DescribeCommandDeps } from '../../src/commands/describe.js';
import type { ApiClient } from '../../src/api-client.js';
function mockClient(): ApiClient {
return {
get: vi.fn(async () => []),
post: vi.fn(async () => ({})),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
function makeDeps(item: unknown = {}): DescribeCommandDeps & { output: string[] } {
const output: string[] = [];
return {
output,
client: mockClient(),
fetchResource: vi.fn(async () => item),
log: (...args: string[]) => output.push(args.join(' ')),
};
}
describe('describe command', () => {
it('shows detailed server info', async () => {
it('shows detailed server info with sections', async () => {
const deps = makeDeps({
id: 'srv-1',
name: 'slack',
transport: 'STDIO',
packageName: '@slack/mcp',
dockerImage: null,
envTemplate: [],
env: [],
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1');
const text = deps.output.join('\n');
expect(text).toContain('--- Server ---');
expect(text).toContain('name: slack');
expect(text).toContain('transport: STDIO');
expect(text).toContain('dockerImage: -');
expect(text).toContain('=== Server: slack ===');
expect(text).toContain('Name:');
expect(text).toContain('slack');
expect(text).toContain('Transport:');
expect(text).toContain('STDIO');
expect(text).toContain('Package:');
expect(text).toContain('@slack/mcp');
expect(text).toContain('Metadata:');
expect(text).toContain('ID:');
});
it('resolves resource aliases', async () => {
const deps = makeDeps({ id: 'p1' });
const deps = makeDeps({ id: 's1' });
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'prof', 'p1']);
expect(deps.fetchResource).toHaveBeenCalledWith('profiles', 'p1');
await cmd.parseAsync(['node', 'test', 'sec', 's1']);
expect(deps.fetchResource).toHaveBeenCalledWith('secrets', 's1');
});
it('outputs JSON format', async () => {
@@ -55,31 +72,71 @@ describe('describe command', () => {
expect(deps.output[0]).toContain('name: slack');
});
it('formats nested objects', async () => {
it('shows project detail', async () => {
const deps = makeDeps({
id: 'srv-1',
name: 'slack',
metadata: { version: '1.0', nested: { deep: true } },
id: 'proj-1',
name: 'my-project',
description: 'A test project',
ownerId: 'user-1',
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
await cmd.parseAsync(['node', 'test', 'project', 'proj-1']);
const text = deps.output.join('\n');
expect(text).toContain('metadata:');
expect(text).toContain('version: 1.0');
expect(text).toContain('=== Project: my-project ===');
expect(text).toContain('A test project');
expect(text).toContain('user-1');
});
it('formats arrays correctly', async () => {
it('shows secret detail with masked values', async () => {
const deps = makeDeps({
id: 'srv-1',
permissions: ['read', 'write'],
envTemplate: [],
id: 'sec-1',
name: 'ha-creds',
data: { TOKEN: 'abc123', URL: 'https://ha.local' },
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
await cmd.parseAsync(['node', 'test', 'secret', 'sec-1']);
const text = deps.output.join('\n');
expect(text).toContain('permissions: read, write');
expect(text).toContain('envTemplate: []');
expect(text).toContain('=== Secret: ha-creds ===');
expect(text).toContain('TOKEN');
expect(text).toContain('***');
expect(text).not.toContain('abc123');
expect(text).toContain('use --show-values to reveal');
});
it('shows secret detail with revealed values when --show-values', async () => {
const deps = makeDeps({
id: 'sec-1',
name: 'ha-creds',
data: { TOKEN: 'abc123' },
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'secret', 'sec-1', '--show-values']);
const text = deps.output.join('\n');
expect(text).toContain('abc123');
expect(text).not.toContain('***');
});
it('shows instance detail with container info', async () => {
const deps = makeDeps({
id: 'inst-1',
serverId: 'srv-1',
status: 'RUNNING',
containerId: 'abc123',
port: 3000,
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'instance', 'inst-1']);
const text = deps.output.join('\n');
expect(text).toContain('=== Instance: inst-1 ===');
expect(text).toContain('RUNNING');
expect(text).toContain('abc123');
});
});

View File

@@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { readFileSync, writeFileSync } from 'node:fs';
import yaml from 'js-yaml';
import { createEditCommand } from '../../src/commands/edit.js';
import type { ApiClient } from '../../src/api-client.js';
function mockClient(): ApiClient {
return {
get: vi.fn(async () => ({})),
post: vi.fn(async () => ({})),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
describe('edit command', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
beforeEach(() => {
client = mockClient();
output = [];
});
it('fetches server, opens editor, applies changes on save', async () => {
// GET /api/v1/servers returns list for resolveNameOrId
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/servers') {
return [{ id: 'srv-1', name: 'ha-mcp' }];
}
// GET /api/v1/servers/srv-1 returns full server
return {
id: 'srv-1',
name: 'ha-mcp',
description: 'Old desc',
transport: 'STDIO',
replicas: 1,
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
version: 1,
};
});
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: (filePath) => {
// Simulate user editing the file
const content = readFileSync(filePath, 'utf-8');
const modified = content
.replace('Old desc', 'New desc')
.replace('replicas: 1', 'replicas: 3');
writeFileSync(filePath, modified, 'utf-8');
},
});
await cmd.parseAsync(['server', 'ha-mcp'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/servers/srv-1', expect.objectContaining({
description: 'New desc',
replicas: 3,
}));
expect(output.join('\n')).toContain("server 'ha-mcp' updated");
});
it('detects no changes and skips PUT', async () => {
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }];
return {
id: 'srv-1', name: 'test', description: '', transport: 'STDIO',
createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1,
};
});
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: () => {
// Don't modify the file
},
});
await cmd.parseAsync(['server', 'test'], { from: 'user' });
expect(client.put).not.toHaveBeenCalled();
expect(output.join('\n')).toContain("unchanged");
});
it('handles empty file as cancel', async () => {
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }];
return { id: 'srv-1', name: 'test', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1 };
});
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: (filePath) => {
writeFileSync(filePath, '', 'utf-8');
},
});
await cmd.parseAsync(['server', 'test'], { from: 'user' });
expect(client.put).not.toHaveBeenCalled();
expect(output.join('\n')).toContain('cancelled');
});
it('strips read-only fields from editor content', async () => {
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }];
return {
id: 'srv-1', name: 'test', description: '', transport: 'STDIO',
createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1,
};
});
let editorContent = '';
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: (filePath) => {
editorContent = readFileSync(filePath, 'utf-8');
},
});
await cmd.parseAsync(['server', 'test'], { from: 'user' });
// The editor content should NOT contain read-only fields
expect(editorContent).not.toContain('id:');
expect(editorContent).not.toContain('createdAt');
expect(editorContent).not.toContain('updatedAt');
expect(editorContent).not.toContain('version');
// But should contain editable fields
expect(editorContent).toContain('name:');
});
it('rejects edit instance with error message', async () => {
const cmd = createEditCommand({ client, log });
await cmd.parseAsync(['instance', 'inst-1'], { from: 'user' });
expect(client.get).not.toHaveBeenCalled();
expect(client.put).not.toHaveBeenCalled();
expect(output.join('\n')).toContain('immutable');
});
});

View File

@@ -41,39 +41,41 @@ describe('get command', () => {
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1');
});
it('outputs JSON format', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]);
it('outputs apply-compatible JSON format', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1 }]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'json']);
const parsed = JSON.parse(deps.output[0] ?? '');
expect(parsed).toEqual({ id: 'srv-1', name: 'slack' });
// Wrapped in resource key, internal fields stripped
expect(parsed).toHaveProperty('servers');
expect(parsed.servers[0].name).toBe('slack');
expect(parsed.servers[0]).not.toHaveProperty('id');
expect(parsed.servers[0]).not.toHaveProperty('createdAt');
expect(parsed.servers[0]).not.toHaveProperty('updatedAt');
expect(parsed.servers[0]).not.toHaveProperty('version');
});
it('outputs YAML format', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]);
it('outputs apply-compatible YAML format', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01' }]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'yaml']);
expect(deps.output[0]).toContain('name: slack');
});
it('lists profiles with correct columns', async () => {
const deps = makeDeps([
{ id: 'p1', name: 'default', serverId: 'srv-1' },
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'profiles']);
expect(deps.output[0]).toContain('NAME');
expect(deps.output[0]).toContain('SERVER ID');
const text = deps.output[0];
expect(text).toContain('servers:');
expect(text).toContain('name: slack');
expect(text).not.toContain('id:');
expect(text).not.toContain('createdAt:');
});
it('lists instances with correct columns', async () => {
const deps = makeDeps([
{ id: 'inst-1', serverId: 'srv-1', status: 'RUNNING', containerId: 'abc123def456', port: 3000 },
{ id: 'inst-1', serverId: 'srv-1', server: { name: 'my-grafana' }, status: 'RUNNING', containerId: 'abc123def456', port: 3000 },
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'instances']);
expect(deps.output[0]).toContain('NAME');
expect(deps.output[0]).toContain('STATUS');
expect(deps.output.join('\n')).toContain('my-grafana');
expect(deps.output.join('\n')).toContain('RUNNING');
});
@@ -81,6 +83,6 @@ describe('get command', () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers']);
expect(deps.output[0]).toContain('No results');
expect(deps.output[0]).toContain('No servers found');
});
});

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createInstanceCommands } from '../../src/commands/instances.js';
import { createDeleteCommand } from '../../src/commands/delete.js';
import { createLogsCommand } from '../../src/commands/logs.js';
import type { ApiClient } from '../../src/api-client.js';
function mockClient(): ApiClient {
@@ -11,7 +12,7 @@ function mockClient(): ApiClient {
} as unknown as ApiClient;
}
describe('instance commands', () => {
describe('delete command', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
@@ -21,107 +22,64 @@ describe('instance commands', () => {
output = [];
});
describe('list', () => {
it('shows no instances message when empty', async () => {
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['list'], { from: 'user' });
expect(output.join('\n')).toContain('No instances found');
});
it('shows instance table', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'inst-1', serverId: 'srv-1', status: 'RUNNING', containerId: 'ctr-abc123def', port: 3000, createdAt: '2025-01-01' },
]);
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['list'], { from: 'user' });
expect(output.join('\n')).toContain('inst-1');
expect(output.join('\n')).toContain('RUNNING');
});
it('filters by server', async () => {
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['list', '-s', 'srv-1'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith(expect.stringContaining('serverId=srv-1'));
});
it('outputs json', async () => {
vi.mocked(client.get).mockResolvedValue([{ id: 'inst-1' }]);
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['list', '-o', 'json'], { from: 'user' });
expect(output[0]).toContain('"id"');
});
it('deletes an instance by ID', async () => {
const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['instance', 'inst-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/instances/inst-1');
expect(output.join('\n')).toContain('deleted');
});
describe('start', () => {
it('starts an instance', async () => {
vi.mocked(client.post).mockResolvedValue({ id: 'inst-new', status: 'RUNNING' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['start', 'srv-1'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/instances', { serverId: 'srv-1' });
expect(output.join('\n')).toContain('started');
});
it('passes host port', async () => {
vi.mocked(client.post).mockResolvedValue({ id: 'inst-new', status: 'RUNNING' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['start', 'srv-1', '-p', '8080'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/instances', { serverId: 'srv-1', hostPort: 8080 });
});
it('deletes a server by ID', async () => {
const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['server', 'srv-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-1');
expect(output.join('\n')).toContain('deleted');
});
describe('stop', () => {
it('stops an instance', async () => {
vi.mocked(client.post).mockResolvedValue({ id: 'inst-1', status: 'STOPPED' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['stop', 'inst-1'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/instances/inst-1/stop');
expect(output.join('\n')).toContain('stopped');
});
it('resolves server name to ID', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'srv-abc', name: 'ha-mcp' },
]);
const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['server', 'ha-mcp'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-abc');
});
describe('restart', () => {
it('restarts an instance', async () => {
vi.mocked(client.post).mockResolvedValue({ id: 'inst-2', status: 'RUNNING' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['restart', 'inst-1'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/instances/inst-1/restart');
expect(output.join('\n')).toContain('restarted');
});
it('deletes a project', async () => {
const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['project', 'proj-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/projects/proj-1');
});
describe('remove', () => {
it('removes an instance', async () => {
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['remove', 'inst-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/instances/inst-1');
expect(output.join('\n')).toContain('removed');
});
});
describe('logs', () => {
it('shows logs', async () => {
vi.mocked(client.get).mockResolvedValue({ stdout: 'hello world\n', stderr: '' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['logs', 'inst-1'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
expect(output.join('\n')).toContain('hello world');
});
it('passes tail option', async () => {
vi.mocked(client.get).mockResolvedValue({ stdout: '', stderr: '' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['logs', 'inst-1', '-t', '50'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs?tail=50');
});
});
describe('inspect', () => {
it('shows container info as json', async () => {
vi.mocked(client.get).mockResolvedValue({ containerId: 'ctr-abc', state: 'running' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['inspect', 'inst-1'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/inspect');
expect(output[0]).toContain('ctr-abc');
});
it('accepts resource aliases', async () => {
const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['srv', 'srv-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-1');
});
});
describe('logs command', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
beforeEach(() => {
client = mockClient();
output = [];
});
it('shows logs', async () => {
vi.mocked(client.get).mockResolvedValue({ stdout: 'hello world\n', stderr: '' });
const cmd = createLogsCommand({ client, log });
await cmd.parseAsync(['inst-1'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
expect(output.join('\n')).toContain('hello world');
});
it('passes tail option', async () => {
vi.mocked(client.get).mockResolvedValue({ stdout: '', stderr: '' });
const cmd = createLogsCommand({ client, log });
await cmd.parseAsync(['inst-1', '-t', '50'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs?tail=50');
});
});

View File

@@ -21,91 +21,9 @@ describe('project command', () => {
output = [];
});
describe('list', () => {
it('shows no projects message when empty', async () => {
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['list'], { from: 'user' });
expect(output.join('\n')).toContain('No projects found');
});
it('shows project table', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'proj-1', name: 'dev', description: 'Dev project', ownerId: 'user-1', createdAt: '2025-01-01' },
]);
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['list'], { from: 'user' });
expect(output.join('\n')).toContain('proj-1');
expect(output.join('\n')).toContain('dev');
});
it('outputs json', async () => {
vi.mocked(client.get).mockResolvedValue([{ id: 'proj-1', name: 'dev' }]);
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['list', '-o', 'json'], { from: 'user' });
expect(output[0]).toContain('"id"');
});
});
describe('create', () => {
it('creates a project', async () => {
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['create', 'my-project', '-d', 'A test project'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
name: 'my-project',
description: 'A test project',
});
expect(output.join('\n')).toContain("Project 'my-project' created");
});
});
describe('delete', () => {
it('deletes a project', async () => {
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['delete', 'proj-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/projects/proj-1');
expect(output.join('\n')).toContain('deleted');
});
});
describe('show', () => {
it('shows project details', async () => {
vi.mocked(client.get).mockImplementation(async (url: string) => {
if (url.endsWith('/profiles')) return [];
return { id: 'proj-1', name: 'dev', description: 'Dev project', ownerId: 'user-1', createdAt: '2025-01-01' };
});
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['show', 'proj-1'], { from: 'user' });
expect(output.join('\n')).toContain('Name: dev');
expect(output.join('\n')).toContain('ID: proj-1');
});
});
describe('profiles', () => {
it('lists profiles for a project', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'prof-1', name: 'default', serverId: 'srv-1' },
]);
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['profiles', 'proj-1'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/proj-1/profiles');
expect(output.join('\n')).toContain('default');
});
it('shows empty message when no profiles', async () => {
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['profiles', 'proj-1'], { from: 'user' });
expect(output.join('\n')).toContain('No profiles assigned');
});
});
describe('set-profiles', () => {
it('sets profiles for a project', async () => {
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['set-profiles', 'proj-1', 'prof-1', 'prof-2'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/projects/proj-1/profiles', {
profileIds: ['prof-1', 'prof-2'],
});
expect(output.join('\n')).toContain('2 profile(s)');
});
it('creates command with alias', () => {
const cmd = createProjectCommand({ client, log });
expect(cmd.name()).toBe('project');
expect(cmd.alias()).toBe('proj');
});
});

View File

@@ -1,141 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createSetupCommand } from '../../src/commands/setup.js';
import type { ApiClient } from '../../src/api-client.js';
import type { SetupPromptDeps } from '../../src/commands/setup.js';
function mockClient(): ApiClient {
return {
get: vi.fn(async () => []),
post: vi.fn(async () => ({ id: 'new-id', name: 'test' })),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
function mockPrompt(answers: Record<string, string | boolean>): SetupPromptDeps {
const answersQueue = { ...answers };
return {
input: vi.fn(async (message: string) => {
for (const [key, val] of Object.entries(answersQueue)) {
if (message.toLowerCase().includes(key.toLowerCase()) && typeof val === 'string') {
delete answersQueue[key];
return val;
}
}
return '';
}),
password: vi.fn(async () => 'secret-value'),
select: vi.fn(async () => 'STDIO') as SetupPromptDeps['select'],
confirm: vi.fn(async (message: string) => {
if (message.includes('profile')) return true;
if (message.includes('secret')) return false;
if (message.includes('another')) return false;
return false;
}),
};
}
describe('setup command', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
beforeEach(() => {
client = mockClient();
output = [];
});
it('creates server with prompted values', async () => {
const prompt = mockPrompt({
'transport': 'STDIO',
'npm package': '@anthropic/slack-mcp',
'docker image': '',
'description': 'Slack server',
'profile name': 'default',
'environment variable name': '',
});
const cmd = createSetupCommand({ client, prompt, log });
await cmd.parseAsync(['slack'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({
name: 'slack',
transport: 'STDIO',
}));
expect(output.join('\n')).toContain("Server 'test' created");
});
it('creates profile with env vars', async () => {
vi.mocked(client.post)
.mockResolvedValueOnce({ id: 'srv-1', name: 'slack' }) // server create
.mockResolvedValueOnce({ id: 'prof-1', name: 'default' }); // profile create
const prompt = mockPrompt({
'transport': 'STDIO',
'npm package': '',
'docker image': '',
'description': '',
'profile name': 'default',
});
// Override confirm to create profile and add one env var
let confirmCallCount = 0;
vi.mocked(prompt.confirm).mockImplementation(async (msg: string) => {
confirmCallCount++;
if (msg.includes('profile')) return true;
if (msg.includes('secret')) return true;
if (msg.includes('another')) return false;
return false;
});
// Override input to provide env var name then empty to stop
let inputCallCount = 0;
vi.mocked(prompt.input).mockImplementation(async (msg: string) => {
inputCallCount++;
if (msg.includes('Profile name')) return 'default';
if (msg.includes('variable name') && inputCallCount <= 8) return 'API_KEY';
if (msg.includes('variable name')) return '';
return '';
});
const cmd = createSetupCommand({ client, prompt, log });
await cmd.parseAsync(['slack'], { from: 'user' });
expect(client.post).toHaveBeenCalledTimes(2);
const profileCall = vi.mocked(client.post).mock.calls[1];
expect(profileCall?.[0]).toBe('/api/v1/profiles');
expect(profileCall?.[1]).toEqual(expect.objectContaining({
name: 'default',
serverId: 'srv-1',
}));
});
it('exits if server creation fails', async () => {
vi.mocked(client.post).mockRejectedValue(new Error('conflict'));
const prompt = mockPrompt({
'npm package': '',
'docker image': '',
'description': '',
});
const cmd = createSetupCommand({ client, prompt, log });
await cmd.parseAsync(['slack'], { from: 'user' });
expect(output.join('\n')).toContain('Failed to create server');
expect(client.post).toHaveBeenCalledTimes(1); // Only server create, no profile
});
it('skips profile creation when declined', async () => {
const prompt = mockPrompt({
'npm package': '',
'docker image': '',
'description': '',
});
vi.mocked(prompt.confirm).mockResolvedValue(false);
const cmd = createSetupCommand({ client, prompt, log });
await cmd.parseAsync(['test-server'], { from: 'user' });
expect(client.post).toHaveBeenCalledTimes(1); // Only server create
expect(output.join('\n')).toContain('Setup complete');
});
});

View File

@@ -16,26 +16,21 @@ describe('CLI command registration (e2e)', () => {
expect(commandNames).toContain('logout');
expect(commandNames).toContain('get');
expect(commandNames).toContain('describe');
expect(commandNames).toContain('instance');
expect(commandNames).toContain('delete');
expect(commandNames).toContain('logs');
expect(commandNames).toContain('apply');
expect(commandNames).toContain('setup');
expect(commandNames).toContain('create');
expect(commandNames).toContain('edit');
expect(commandNames).toContain('claude');
expect(commandNames).toContain('project');
expect(commandNames).toContain('backup');
expect(commandNames).toContain('restore');
});
it('instance command has lifecycle subcommands', () => {
it('instance command is removed (use get/delete/logs instead)', () => {
const program = createProgram();
const instance = program.commands.find((c) => c.name() === 'instance');
expect(instance).toBeDefined();
const subcommands = instance!.commands.map((c) => c.name());
expect(subcommands).toContain('list');
expect(subcommands).toContain('start');
expect(subcommands).toContain('stop');
expect(subcommands).toContain('restart');
expect(subcommands).toContain('remove');
expect(subcommands).toContain('logs');
expect(subcommands).toContain('inspect');
const commandNames = program.commands.map((c) => c.name());
expect(commandNames).not.toContain('instance');
});
it('claude command has config management subcommands', () => {
@@ -50,18 +45,11 @@ describe('CLI command registration (e2e)', () => {
expect(subcommands).toContain('remove');
});
it('project command has CRUD subcommands', () => {
it('project command exists with alias', () => {
const program = createProgram();
const project = program.commands.find((c) => c.name() === 'project');
expect(project).toBeDefined();
const subcommands = project!.commands.map((c) => c.name());
expect(subcommands).toContain('list');
expect(subcommands).toContain('create');
expect(subcommands).toContain('delete');
expect(subcommands).toContain('show');
expect(subcommands).toContain('profiles');
expect(subcommands).toContain('set-profiles');
expect(project!.alias()).toBe('proj');
});
it('displays version', () => {

View File

@@ -0,0 +1,204 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN');
-- CreateEnum
CREATE TYPE "Transport" AS ENUM ('STDIO', 'SSE', 'STREAMABLE_HTTP');
-- CreateEnum
CREATE TYPE "InstanceStatus" AS ENUM ('STARTING', 'RUNNING', 'STOPPING', 'STOPPED', 'ERROR');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"passwordHash" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'USER',
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "McpServer" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"packageName" TEXT,
"dockerImage" TEXT,
"transport" "Transport" NOT NULL DEFAULT 'STDIO',
"repositoryUrl" TEXT,
"externalUrl" TEXT,
"command" JSONB,
"containerPort" INTEGER,
"envTemplate" JSONB NOT NULL DEFAULT '[]',
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "McpServer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "McpProfile" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"permissions" JSONB NOT NULL DEFAULT '[]',
"envOverrides" JSONB NOT NULL DEFAULT '{}',
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "McpProfile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"ownerId" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectMcpProfile" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"profileId" TEXT NOT NULL,
CONSTRAINT "ProjectMcpProfile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "McpInstance" (
"id" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"containerId" TEXT,
"status" "InstanceStatus" NOT NULL DEFAULT 'STOPPED',
"port" INTEGER,
"metadata" JSONB NOT NULL DEFAULT '{}',
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "McpInstance_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"resource" TEXT NOT NULL,
"resourceId" TEXT,
"details" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_email_idx" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
-- CreateIndex
CREATE INDEX "Session_token_idx" ON "Session"("token");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "McpServer_name_key" ON "McpServer"("name");
-- CreateIndex
CREATE INDEX "McpServer_name_idx" ON "McpServer"("name");
-- CreateIndex
CREATE INDEX "McpProfile_serverId_idx" ON "McpProfile"("serverId");
-- CreateIndex
CREATE UNIQUE INDEX "McpProfile_name_serverId_key" ON "McpProfile"("name", "serverId");
-- CreateIndex
CREATE UNIQUE INDEX "Project_name_key" ON "Project"("name");
-- CreateIndex
CREATE INDEX "Project_name_idx" ON "Project"("name");
-- CreateIndex
CREATE INDEX "Project_ownerId_idx" ON "Project"("ownerId");
-- CreateIndex
CREATE INDEX "ProjectMcpProfile_projectId_idx" ON "ProjectMcpProfile"("projectId");
-- CreateIndex
CREATE INDEX "ProjectMcpProfile_profileId_idx" ON "ProjectMcpProfile"("profileId");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectMcpProfile_projectId_profileId_key" ON "ProjectMcpProfile"("projectId", "profileId");
-- CreateIndex
CREATE INDEX "McpInstance_serverId_idx" ON "McpInstance"("serverId");
-- CreateIndex
CREATE INDEX "McpInstance_status_idx" ON "McpInstance"("status");
-- CreateIndex
CREATE INDEX "AuditLog_userId_idx" ON "AuditLog"("userId");
-- CreateIndex
CREATE INDEX "AuditLog_action_idx" ON "AuditLog"("action");
-- CreateIndex
CREATE INDEX "AuditLog_resource_idx" ON "AuditLog"("resource");
-- CreateIndex
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "McpProfile" ADD CONSTRAINT "McpProfile_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectMcpProfile" ADD CONSTRAINT "ProjectMcpProfile_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectMcpProfile" ADD CONSTRAINT "ProjectMcpProfile_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "McpProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "McpInstance" ADD CONSTRAINT "McpInstance_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -57,12 +57,19 @@ model McpServer {
dockerImage String?
transport Transport @default(STDIO)
repositoryUrl String?
envTemplate Json @default("[]")
externalUrl String?
command Json?
containerPort Int?
replicas Int @default(1)
env Json @default("[]")
healthCheck Json?
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
profiles McpProfile[]
templateName String?
templateVersion String?
instances McpInstance[]
@@index([name])
@@ -74,23 +81,40 @@ enum Transport {
STREAMABLE_HTTP
}
// ── MCP Profiles ──
// ── MCP Templates ──
model McpProfile {
id String @id @default(cuid())
name String
serverId String
permissions Json @default("[]")
envOverrides Json @default("{}")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
model McpTemplate {
id String @id @default(cuid())
name String @unique
version String @default("1.0.0")
description String @default("")
packageName String?
dockerImage String?
transport Transport @default(STDIO)
repositoryUrl String?
externalUrl String?
command Json?
containerPort Int?
replicas Int @default(1)
env Json @default("[]")
healthCheck Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
projects ProjectMcpProfile[]
@@index([name])
}
@@unique([name, serverId])
@@index([serverId])
// ── Secrets ──
model Secret {
id String @id @default(cuid())
name String @unique
data Json @default("{}")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([name])
}
// ── Projects ──
@@ -105,27 +129,11 @@ model Project {
updatedAt DateTime @updatedAt
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
profiles ProjectMcpProfile[]
@@index([name])
@@index([ownerId])
}
// ── Project <-> Profile join table ──
model ProjectMcpProfile {
id String @id @default(cuid())
projectId String
profileId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
profile McpProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@unique([projectId, profileId])
@@index([projectId])
@@index([profileId])
}
// ── MCP Instances (running containers) ──
model McpInstance {
@@ -134,10 +142,13 @@ model McpInstance {
containerId String?
status InstanceStatus @default(STOPPED)
port Int?
metadata Json @default("{}")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
metadata Json @default("{}")
healthStatus String?
lastHealthCheck DateTime?
events Json @default("[]")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)

View File

@@ -4,9 +4,9 @@ export type {
User,
Session,
McpServer,
McpProfile,
McpTemplate,
Secret,
Project,
ProjectMcpProfile,
McpInstance,
AuditLog,
Role,
@@ -14,5 +14,5 @@ export type {
InstanceStatus,
} from '@prisma/client';
export { seedMcpServers, defaultServers } from './seed/index.js';
export type { SeedServer } from './seed/index.js';
export { seedTemplates } from './seed/index.js';
export type { SeedTemplate, TemplateEnvEntry, HealthCheckSpec } from './seed/index.js';

View File

@@ -1,131 +1,77 @@
import { PrismaClient } from '@prisma/client';
import { PrismaClient, Prisma } from '@prisma/client';
export interface SeedServer {
export interface TemplateEnvEntry {
name: string;
description: string;
packageName: string;
transport: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP';
repositoryUrl: string;
envTemplate: Array<{
name: string;
description: string;
isSecret: boolean;
setupUrl?: string;
}>;
description?: string;
required?: boolean;
defaultValue?: string;
}
export const defaultServers: SeedServer[] = [
{
name: 'slack',
description: 'Slack MCP server for reading channels, messages, and user info',
packageName: '@anthropic/slack-mcp',
transport: 'STDIO',
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/slack',
envTemplate: [
{
name: 'SLACK_BOT_TOKEN',
description: 'Slack Bot User OAuth Token (xoxb-...)',
isSecret: true,
setupUrl: 'https://api.slack.com/apps',
},
{
name: 'SLACK_TEAM_ID',
description: 'Slack Workspace Team ID',
isSecret: false,
},
],
},
{
name: 'jira',
description: 'Jira MCP server for issues, projects, and boards',
packageName: '@anthropic/jira-mcp',
transport: 'STDIO',
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/jira',
envTemplate: [
{
name: 'JIRA_URL',
description: 'Jira instance URL (e.g., https://company.atlassian.net)',
isSecret: false,
},
{
name: 'JIRA_EMAIL',
description: 'Jira account email',
isSecret: false,
},
{
name: 'JIRA_API_TOKEN',
description: 'Jira API token',
isSecret: true,
setupUrl: 'https://id.atlassian.com/manage-profile/security/api-tokens',
},
],
},
{
name: 'github',
description: 'GitHub MCP server for repos, issues, PRs, and code search',
packageName: '@anthropic/github-mcp',
transport: 'STDIO',
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/github',
envTemplate: [
{
name: 'GITHUB_TOKEN',
description: 'GitHub Personal Access Token',
isSecret: true,
setupUrl: 'https://github.com/settings/tokens',
},
],
},
{
name: 'terraform',
description: 'Terraform MCP server for infrastructure documentation and state',
packageName: '@anthropic/terraform-mcp',
transport: 'STDIO',
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/terraform',
envTemplate: [],
},
];
export interface HealthCheckSpec {
tool: string;
arguments?: Record<string, unknown>;
intervalSeconds?: number;
timeoutSeconds?: number;
failureThreshold?: number;
}
export async function seedMcpServers(
export interface SeedTemplate {
name: string;
version: string;
description: string;
packageName?: string;
dockerImage?: string;
transport: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP';
repositoryUrl?: string;
externalUrl?: string;
command?: string[];
containerPort?: number;
replicas?: number;
env?: TemplateEnvEntry[];
healthCheck?: HealthCheckSpec;
}
export async function seedTemplates(
prisma: PrismaClient,
servers: SeedServer[] = defaultServers,
templates: SeedTemplate[],
): Promise<number> {
let created = 0;
let upserted = 0;
for (const server of servers) {
await prisma.mcpServer.upsert({
where: { name: server.name },
for (const tpl of templates) {
await prisma.mcpTemplate.upsert({
where: { name: tpl.name },
update: {
description: server.description,
packageName: server.packageName,
transport: server.transport,
repositoryUrl: server.repositoryUrl,
envTemplate: server.envTemplate,
version: tpl.version,
description: tpl.description,
packageName: tpl.packageName ?? null,
dockerImage: tpl.dockerImage ?? null,
transport: tpl.transport,
repositoryUrl: tpl.repositoryUrl ?? null,
externalUrl: tpl.externalUrl ?? null,
command: (tpl.command ?? Prisma.JsonNull) as Prisma.InputJsonValue,
containerPort: tpl.containerPort ?? null,
replicas: tpl.replicas ?? 1,
env: (tpl.env ?? []) as unknown as Prisma.InputJsonValue,
healthCheck: (tpl.healthCheck ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue,
},
create: {
name: server.name,
description: server.description,
packageName: server.packageName,
transport: server.transport,
repositoryUrl: server.repositoryUrl,
envTemplate: server.envTemplate,
name: tpl.name,
version: tpl.version,
description: tpl.description,
packageName: tpl.packageName ?? null,
dockerImage: tpl.dockerImage ?? null,
transport: tpl.transport,
repositoryUrl: tpl.repositoryUrl ?? null,
externalUrl: tpl.externalUrl ?? null,
command: (tpl.command ?? Prisma.JsonNull) as Prisma.InputJsonValue,
containerPort: tpl.containerPort ?? null,
replicas: tpl.replicas ?? 1,
env: (tpl.env ?? []) as unknown as Prisma.InputJsonValue,
healthCheck: (tpl.healthCheck ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue,
},
});
created++;
upserted++;
}
return created;
}
// CLI entry point
if (import.meta.url === `file://${process.argv[1]}`) {
const prisma = new PrismaClient();
seedMcpServers(prisma)
.then((count) => {
console.log(`Seeded ${count} MCP servers`);
return prisma.$disconnect();
})
.catch((e) => {
console.error(e);
return prisma.$disconnect().then(() => process.exit(1));
});
return upserted;
}

View File

@@ -48,11 +48,11 @@ export async function cleanupTestDb(): Promise<void> {
export async function clearAllTables(client: PrismaClient): Promise<void> {
// Delete in order respecting foreign keys
await client.auditLog.deleteMany();
await client.projectMcpProfile.deleteMany();
await client.mcpInstance.deleteMany();
await client.mcpProfile.deleteMany();
await client.secret.deleteMany();
await client.session.deleteMany();
await client.project.deleteMany();
await client.mcpServer.deleteMany();
await client.mcpTemplate.deleteMany();
await client.user.deleteMany();
}

View File

@@ -123,7 +123,7 @@ describe('McpServer', () => {
const server = await createServer();
expect(server.transport).toBe('STDIO');
expect(server.version).toBe(1);
expect(server.envTemplate).toEqual([]);
expect(server.env).toEqual([]);
});
it('enforces unique name', async () => {
@@ -131,18 +131,18 @@ describe('McpServer', () => {
await expect(createServer({ name: 'slack' })).rejects.toThrow();
});
it('stores envTemplate as JSON', async () => {
it('stores env as JSON', async () => {
const server = await prisma.mcpServer.create({
data: {
name: 'with-env',
envTemplate: [
{ name: 'API_KEY', description: 'Key', isSecret: true },
env: [
{ name: 'API_KEY', value: 'test-key' },
],
},
});
const envTemplate = server.envTemplate as Array<{ name: string }>;
expect(envTemplate).toHaveLength(1);
expect(envTemplate[0].name).toBe('API_KEY');
const env = server.env as Array<{ name: string }>;
expect(env).toHaveLength(1);
expect(env[0].name).toBe('API_KEY');
});
it('supports SSE transport', async () => {
@@ -151,43 +151,46 @@ describe('McpServer', () => {
});
});
// ── McpProfile model ──
// ── Secret model ──
describe('McpProfile', () => {
it('creates a profile linked to server', async () => {
const server = await createServer();
const profile = await prisma.mcpProfile.create({
describe('Secret', () => {
it('creates a secret with defaults', async () => {
const secret = await prisma.secret.create({
data: { name: 'my-secret' },
});
expect(secret.name).toBe('my-secret');
expect(secret.data).toEqual({});
expect(secret.version).toBe(1);
});
it('stores key-value data as JSON', async () => {
const secret = await prisma.secret.create({
data: {
name: 'readonly',
serverId: server.id,
permissions: ['read'],
name: 'api-keys',
data: { API_KEY: 'test-key', API_SECRET: 'test-secret' },
},
});
expect(profile.name).toBe('readonly');
expect(profile.serverId).toBe(server.id);
const data = secret.data as Record<string, string>;
expect(data['API_KEY']).toBe('test-key');
expect(data['API_SECRET']).toBe('test-secret');
});
it('enforces unique name per server', async () => {
const server = await createServer();
const data = { name: 'default', serverId: server.id };
await prisma.mcpProfile.create({ data });
await expect(prisma.mcpProfile.create({ data })).rejects.toThrow();
it('enforces unique name', async () => {
await prisma.secret.create({ data: { name: 'dup-secret' } });
await expect(prisma.secret.create({ data: { name: 'dup-secret' } })).rejects.toThrow();
});
it('allows same profile name on different servers', async () => {
const server1 = await createServer({ name: 'server-1' });
const server2 = await createServer({ name: 'server-2' });
await prisma.mcpProfile.create({ data: { name: 'default', serverId: server1.id } });
const profile2 = await prisma.mcpProfile.create({ data: { name: 'default', serverId: server2.id } });
expect(profile2.name).toBe('default');
});
it('cascades delete when server is deleted', async () => {
const server = await createServer();
await prisma.mcpProfile.create({ data: { name: 'test', serverId: server.id } });
await prisma.mcpServer.delete({ where: { id: server.id } });
const profiles = await prisma.mcpProfile.findMany({ where: { serverId: server.id } });
expect(profiles).toHaveLength(0);
it('updates data', async () => {
const secret = await prisma.secret.create({
data: { name: 'updatable', data: { KEY: 'old' } },
});
const updated = await prisma.secret.update({
where: { id: secret.id },
data: { data: { KEY: 'new', EXTRA: 'added' } },
});
const data = updated.data as Record<string, string>;
expect(data['KEY']).toBe('new');
expect(data['EXTRA']).toBe('added');
});
});
@@ -220,62 +223,6 @@ describe('Project', () => {
});
});
// ── ProjectMcpProfile (join table) ──
describe('ProjectMcpProfile', () => {
it('links project to profile', async () => {
const user = await createUser();
const server = await createServer();
const profile = await prisma.mcpProfile.create({
data: { name: 'default', serverId: server.id },
});
const project = await prisma.project.create({
data: { name: 'test-project', ownerId: user.id },
});
const link = await prisma.projectMcpProfile.create({
data: { projectId: project.id, profileId: profile.id },
});
expect(link.projectId).toBe(project.id);
expect(link.profileId).toBe(profile.id);
});
it('enforces unique project+profile combination', async () => {
const user = await createUser();
const server = await createServer();
const profile = await prisma.mcpProfile.create({
data: { name: 'default', serverId: server.id },
});
const project = await prisma.project.create({
data: { name: 'test-project', ownerId: user.id },
});
const data = { projectId: project.id, profileId: profile.id };
await prisma.projectMcpProfile.create({ data });
await expect(prisma.projectMcpProfile.create({ data })).rejects.toThrow();
});
it('loads profiles through project include', async () => {
const user = await createUser();
const server = await createServer();
const profile = await prisma.mcpProfile.create({
data: { name: 'slack-ro', serverId: server.id },
});
const project = await prisma.project.create({
data: { name: 'reports', ownerId: user.id },
});
await prisma.projectMcpProfile.create({
data: { projectId: project.id, profileId: profile.id },
});
const loaded = await prisma.project.findUnique({
where: { id: project.id },
include: { profiles: { include: { profile: true } } },
});
expect(loaded!.profiles).toHaveLength(1);
expect(loaded!.profiles[0].profile.name).toBe('slack-ro');
});
});
// ── McpInstance model ──

View File

@@ -1,7 +1,8 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js';
import { seedMcpServers, defaultServers } from '../src/seed/index.js';
import { seedTemplates } from '../src/seed/index.js';
import type { SeedTemplate } from '../src/seed/index.js';
let prisma: PrismaClient;
@@ -17,55 +18,69 @@ beforeEach(async () => {
await clearAllTables(prisma);
});
describe('seedMcpServers', () => {
it('seeds all default servers', async () => {
const count = await seedMcpServers(prisma);
expect(count).toBe(defaultServers.length);
const testTemplates: SeedTemplate[] = [
{
name: 'github',
version: '1.0.0',
description: 'GitHub MCP server',
packageName: '@anthropic/github-mcp',
transport: 'STDIO',
env: [{ name: 'GITHUB_TOKEN', description: 'Personal access token', required: true }],
},
{
name: 'slack',
version: '1.0.0',
description: 'Slack MCP server',
packageName: '@anthropic/slack-mcp',
transport: 'STDIO',
env: [],
},
];
const servers = await prisma.mcpServer.findMany({ orderBy: { name: 'asc' } });
expect(servers).toHaveLength(defaultServers.length);
describe('seedTemplates', () => {
it('seeds templates', async () => {
const count = await seedTemplates(prisma, testTemplates);
expect(count).toBe(2);
const names = servers.map((s) => s.name);
expect(names).toContain('slack');
expect(names).toContain('github');
expect(names).toContain('jira');
expect(names).toContain('terraform');
const templates = await prisma.mcpTemplate.findMany({ orderBy: { name: 'asc' } });
expect(templates).toHaveLength(2);
expect(templates.map((t) => t.name)).toEqual(['github', 'slack']);
});
it('is idempotent (upsert)', async () => {
await seedMcpServers(prisma);
const count = await seedMcpServers(prisma);
expect(count).toBe(defaultServers.length);
await seedTemplates(prisma, testTemplates);
const count = await seedTemplates(prisma, testTemplates);
expect(count).toBe(2);
const servers = await prisma.mcpServer.findMany();
expect(servers).toHaveLength(defaultServers.length);
const templates = await prisma.mcpTemplate.findMany();
expect(templates).toHaveLength(2);
});
it('seeds envTemplate correctly', async () => {
await seedMcpServers(prisma);
const slack = await prisma.mcpServer.findUnique({ where: { name: 'slack' } });
const envTemplate = slack!.envTemplate as Array<{ name: string; isSecret: boolean }>;
expect(envTemplate).toHaveLength(2);
expect(envTemplate[0].name).toBe('SLACK_BOT_TOKEN');
expect(envTemplate[0].isSecret).toBe(true);
it('seeds env correctly', async () => {
await seedTemplates(prisma, testTemplates);
const github = await prisma.mcpTemplate.findUnique({ where: { name: 'github' } });
const env = github!.env as Array<{ name: string; description?: string; required?: boolean }>;
expect(env).toHaveLength(1);
expect(env[0].name).toBe('GITHUB_TOKEN');
expect(env[0].required).toBe(true);
});
it('accepts custom server list', async () => {
const custom = [
it('accepts custom template list', async () => {
const custom: SeedTemplate[] = [
{
name: 'custom-server',
description: 'Custom test server',
name: 'custom-template',
version: '2.0.0',
description: 'Custom test template',
packageName: '@test/custom',
transport: 'STDIO' as const,
repositoryUrl: 'https://example.com',
envTemplate: [],
transport: 'STDIO',
env: [],
},
];
const count = await seedMcpServers(prisma, custom);
const count = await seedTemplates(prisma, custom);
expect(count).toBe(1);
const servers = await prisma.mcpServer.findMany();
expect(servers).toHaveLength(1);
expect(servers[0].name).toBe('custom-server');
const templates = await prisma.mcpTemplate.findMany();
expect(templates).toHaveLength(1);
expect(templates[0].name).toBe('custom-template');
});
});

View File

@@ -23,11 +23,13 @@
"bcrypt": "^5.1.1",
"dockerode": "^4.0.9",
"fastify": "^5.0.0",
"js-yaml": "^4.1.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/dockerode": "^4.0.1",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.3.0"
}
}

View File

@@ -1,18 +1,23 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { PrismaClient } from '@prisma/client';
import { seedMcpServers } from '@mcpctl/db';
import yaml from 'js-yaml';
import { seedTemplates } from '@mcpctl/db';
import type { SeedTemplate } from '@mcpctl/db';
import { loadConfigFromEnv } from './config/index.js';
import { createServer } from './server.js';
import { setupGracefulShutdown } from './utils/index.js';
import {
McpServerRepository,
McpProfileRepository,
SecretRepository,
McpInstanceRepository,
ProjectRepository,
AuditLogRepository,
TemplateRepository,
} from './repositories/index.js';
import {
McpServerService,
McpProfileService,
SecretService,
InstanceService,
ProjectService,
AuditLogService,
@@ -23,10 +28,11 @@ import {
RestoreService,
AuthService,
McpProxyService,
TemplateService,
} from './services/index.js';
import {
registerMcpServerRoutes,
registerMcpProfileRoutes,
registerSecretRoutes,
registerInstanceRoutes,
registerProjectRoutes,
registerAuditLogRoutes,
@@ -34,6 +40,7 @@ import {
registerBackupRoutes,
registerAuthRoutes,
registerMcpProxyRoutes,
registerTemplateRoutes,
} from './routes/index.js';
async function main(): Promise<void> {
@@ -45,31 +52,53 @@ async function main(): Promise<void> {
});
await prisma.$connect();
// Seed default servers (upsert, safe to repeat)
await seedMcpServers(prisma);
// Seed templates from YAML files
const templatesDir = process.env.TEMPLATES_DIR ?? 'templates';
const templateFiles = (() => {
try {
return readdirSync(templatesDir).filter((f) => f.endsWith('.yaml') || f.endsWith('.yml'));
} catch {
return [];
}
})();
const templates: SeedTemplate[] = templateFiles.map((f) => {
const content = readFileSync(join(templatesDir, f), 'utf-8');
const parsed = yaml.load(content) as SeedTemplate;
return {
...parsed,
transport: parsed.transport ?? 'STDIO',
version: parsed.version ?? '1.0.0',
description: parsed.description ?? '',
...(parsed.healthCheck ? { healthCheck: parsed.healthCheck } : {}),
};
});
await seedTemplates(prisma, templates);
// Repositories
const serverRepo = new McpServerRepository(prisma);
const profileRepo = new McpProfileRepository(prisma);
const secretRepo = new SecretRepository(prisma);
const instanceRepo = new McpInstanceRepository(prisma);
const projectRepo = new ProjectRepository(prisma);
const auditLogRepo = new AuditLogRepository(prisma);
const templateRepo = new TemplateRepository(prisma);
// Orchestrator
const orchestrator = new DockerContainerManager();
// Services
const serverService = new McpServerService(serverRepo);
const profileService = new McpProfileService(profileRepo, serverRepo);
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator);
const projectService = new ProjectService(projectRepo, profileRepo, serverRepo);
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretRepo);
serverService.setInstanceService(instanceService);
const secretService = new SecretService(secretRepo);
const projectService = new ProjectService(projectRepo);
const auditLogService = new AuditLogService(auditLogRepo);
const metricsCollector = new MetricsCollector();
const healthAggregator = new HealthAggregator(metricsCollector, orchestrator);
const backupService = new BackupService(serverRepo, profileRepo, projectRepo);
const restoreService = new RestoreService(serverRepo, profileRepo, projectRepo);
const backupService = new BackupService(serverRepo, projectRepo, secretRepo);
const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo);
const authService = new AuthService(prisma);
const mcpProxyService = new McpProxyService(instanceRepo);
const templateService = new TemplateService(templateRepo);
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo);
// Server
const app = await createServer(config, {
@@ -86,8 +115,9 @@ async function main(): Promise<void> {
});
// Routes
registerMcpServerRoutes(app, serverService);
registerMcpProfileRoutes(app, profileService);
registerMcpServerRoutes(app, serverService, instanceService);
registerTemplateRoutes(app, templateService);
registerSecretRoutes(app, secretService);
registerInstanceRoutes(app, instanceService);
registerProjectRoutes(app, projectService);
registerAuditLogRoutes(app, auditLogService);

View File

@@ -1,7 +1,9 @@
export type { IMcpServerRepository, IMcpProfileRepository, IMcpInstanceRepository, IAuditLogRepository, AuditLogFilter } from './interfaces.js';
export type { IMcpServerRepository, IMcpInstanceRepository, ISecretRepository, IAuditLogRepository, AuditLogFilter } from './interfaces.js';
export { McpServerRepository } from './mcp-server.repository.js';
export { McpProfileRepository } from './mcp-profile.repository.js';
export { SecretRepository } from './secret.repository.js';
export type { IProjectRepository } from './project.repository.js';
export { ProjectRepository } from './project.repository.js';
export { McpInstanceRepository } from './mcp-instance.repository.js';
export { AuditLogRepository } from './audit-log.repository.js';
export type { ITemplateRepository } from './template.repository.js';
export { TemplateRepository } from './template.repository.js';

View File

@@ -1,6 +1,6 @@
import type { McpServer, McpProfile, McpInstance, AuditLog, InstanceStatus } from '@prisma/client';
import type { McpServer, McpInstance, AuditLog, Secret, InstanceStatus } from '@prisma/client';
import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
import type { CreateMcpProfileInput, UpdateMcpProfileInput } from '../validation/mcp-profile.schema.js';
import type { CreateSecretInput, UpdateSecretInput } from '../validation/secret.schema.js';
export interface IMcpServerRepository {
findAll(): Promise<McpServer[]>;
@@ -16,16 +16,16 @@ export interface IMcpInstanceRepository {
findById(id: string): Promise<McpInstance | null>;
findByContainerId(containerId: string): Promise<McpInstance | null>;
create(data: { serverId: string; containerId?: string; status?: InstanceStatus; port?: number; metadata?: Record<string, unknown> }): Promise<McpInstance>;
updateStatus(id: string, status: InstanceStatus, fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown> }): Promise<McpInstance>;
updateStatus(id: string, status: InstanceStatus, fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown>; healthStatus?: string; lastHealthCheck?: Date; events?: unknown[] }): Promise<McpInstance>;
delete(id: string): Promise<void>;
}
export interface IMcpProfileRepository {
findAll(serverId?: string): Promise<McpProfile[]>;
findById(id: string): Promise<McpProfile | null>;
findByServerAndName(serverId: string, name: string): Promise<McpProfile | null>;
create(data: CreateMcpProfileInput): Promise<McpProfile>;
update(id: string, data: UpdateMcpProfileInput): Promise<McpProfile>;
export interface ISecretRepository {
findAll(): Promise<Secret[]>;
findById(id: string): Promise<Secret | null>;
findByName(name: string): Promise<Secret | null>;
create(data: CreateSecretInput): Promise<Secret>;
update(id: string, data: UpdateSecretInput): Promise<Secret>;
delete(id: string): Promise<void>;
}

View File

@@ -11,6 +11,7 @@ export class McpInstanceRepository implements IMcpInstanceRepository {
}
return this.prisma.mcpInstance.findMany({
where,
include: { server: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
});
}
@@ -44,7 +45,7 @@ export class McpInstanceRepository implements IMcpInstanceRepository {
async updateStatus(
id: string,
status: InstanceStatus,
fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown> },
fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown>; healthStatus?: string; lastHealthCheck?: Date; events?: unknown[] },
): Promise<McpInstance> {
const updateData: Prisma.McpInstanceUpdateInput = {
status,
@@ -59,6 +60,15 @@ export class McpInstanceRepository implements IMcpInstanceRepository {
if (fields?.metadata !== undefined) {
updateData.metadata = fields.metadata as Prisma.InputJsonValue;
}
if (fields?.healthStatus !== undefined) {
updateData.healthStatus = fields.healthStatus;
}
if (fields?.lastHealthCheck !== undefined) {
updateData.lastHealthCheck = fields.lastHealthCheck;
}
if (fields?.events !== undefined) {
updateData.events = fields.events as unknown as Prisma.InputJsonValue;
}
return this.prisma.mcpInstance.update({
where: { id },
data: updateData,

View File

@@ -1,46 +0,0 @@
import type { PrismaClient, McpProfile } from '@prisma/client';
import type { IMcpProfileRepository } from './interfaces.js';
import type { CreateMcpProfileInput, UpdateMcpProfileInput } from '../validation/mcp-profile.schema.js';
export class McpProfileRepository implements IMcpProfileRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(serverId?: string): Promise<McpProfile[]> {
const where = serverId !== undefined ? { serverId } : {};
return this.prisma.mcpProfile.findMany({ where, orderBy: { name: 'asc' } });
}
async findById(id: string): Promise<McpProfile | null> {
return this.prisma.mcpProfile.findUnique({ where: { id } });
}
async findByServerAndName(serverId: string, name: string): Promise<McpProfile | null> {
return this.prisma.mcpProfile.findUnique({
where: { name_serverId: { name, serverId } },
});
}
async create(data: CreateMcpProfileInput): Promise<McpProfile> {
return this.prisma.mcpProfile.create({
data: {
name: data.name,
serverId: data.serverId,
permissions: data.permissions,
envOverrides: data.envOverrides,
},
});
}
async update(id: string, data: UpdateMcpProfileInput): Promise<McpProfile> {
const updateData: Record<string, unknown> = {};
if (data.name !== undefined) updateData['name'] = data.name;
if (data.permissions !== undefined) updateData['permissions'] = data.permissions;
if (data.envOverrides !== undefined) updateData['envOverrides'] = data.envOverrides;
return this.prisma.mcpProfile.update({ where: { id }, data: updateData });
}
async delete(id: string): Promise<void> {
await this.prisma.mcpProfile.delete({ where: { id } });
}
}

View File

@@ -1,4 +1,4 @@
import type { PrismaClient, McpServer } from '@prisma/client';
import { type PrismaClient, type McpServer, Prisma } from '@prisma/client';
import type { IMcpServerRepository } from './interfaces.js';
import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
@@ -26,7 +26,12 @@ export class McpServerRepository implements IMcpServerRepository {
dockerImage: data.dockerImage ?? null,
transport: data.transport,
repositoryUrl: data.repositoryUrl ?? null,
envTemplate: data.envTemplate,
externalUrl: data.externalUrl ?? null,
command: data.command ?? Prisma.DbNull,
containerPort: data.containerPort ?? null,
replicas: data.replicas,
env: data.env,
healthCheck: (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue,
},
});
}
@@ -38,7 +43,12 @@ export class McpServerRepository implements IMcpServerRepository {
if (data.dockerImage !== undefined) updateData['dockerImage'] = data.dockerImage;
if (data.transport !== undefined) updateData['transport'] = data.transport;
if (data.repositoryUrl !== undefined) updateData['repositoryUrl'] = data.repositoryUrl;
if (data.envTemplate !== undefined) updateData['envTemplate'] = data.envTemplate;
if (data.externalUrl !== undefined) updateData['externalUrl'] = data.externalUrl;
if (data.command !== undefined) updateData['command'] = data.command;
if (data.containerPort !== undefined) updateData['containerPort'] = data.containerPort;
if (data.replicas !== undefined) updateData['replicas'] = data.replicas;
if (data.env !== undefined) updateData['env'] = data.env;
if (data.healthCheck !== undefined) updateData['healthCheck'] = (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue;
return this.prisma.mcpServer.update({ where: { id }, data: updateData });
}

View File

@@ -8,8 +8,6 @@ export interface IProjectRepository {
create(data: CreateProjectInput & { ownerId: string }): Promise<Project>;
update(id: string, data: UpdateProjectInput): Promise<Project>;
delete(id: string): Promise<void>;
setProfiles(projectId: string, profileIds: string[]): Promise<void>;
getProfileIds(projectId: string): Promise<string[]>;
}
export class ProjectRepository implements IProjectRepository {
@@ -48,22 +46,4 @@ export class ProjectRepository implements IProjectRepository {
await this.prisma.project.delete({ where: { id } });
}
async setProfiles(projectId: string, profileIds: string[]): Promise<void> {
await this.prisma.$transaction([
this.prisma.projectMcpProfile.deleteMany({ where: { projectId } }),
...profileIds.map((profileId) =>
this.prisma.projectMcpProfile.create({
data: { projectId, profileId },
}),
),
]);
}
async getProfileIds(projectId: string): Promise<string[]> {
const links = await this.prisma.projectMcpProfile.findMany({
where: { projectId },
select: { profileId: true },
});
return links.map((l) => l.profileId);
}
}

View File

@@ -0,0 +1,39 @@
import { type PrismaClient, type Secret } from '@prisma/client';
import type { ISecretRepository } from './interfaces.js';
import type { CreateSecretInput, UpdateSecretInput } from '../validation/secret.schema.js';
export class SecretRepository implements ISecretRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(): Promise<Secret[]> {
return this.prisma.secret.findMany({ orderBy: { name: 'asc' } });
}
async findById(id: string): Promise<Secret | null> {
return this.prisma.secret.findUnique({ where: { id } });
}
async findByName(name: string): Promise<Secret | null> {
return this.prisma.secret.findUnique({ where: { name } });
}
async create(data: CreateSecretInput): Promise<Secret> {
return this.prisma.secret.create({
data: {
name: data.name,
data: data.data,
},
});
}
async update(id: string, data: UpdateSecretInput): Promise<Secret> {
return this.prisma.secret.update({
where: { id },
data: { data: data.data },
});
}
async delete(id: string): Promise<void> {
await this.prisma.secret.delete({ where: { id } });
}
}

View File

@@ -0,0 +1,82 @@
import { type PrismaClient, type McpTemplate, Prisma } from '@prisma/client';
import type { CreateTemplateInput, UpdateTemplateInput } from '../validation/template.schema.js';
export interface ITemplateRepository {
findAll(): Promise<McpTemplate[]>;
findById(id: string): Promise<McpTemplate | null>;
findByName(name: string): Promise<McpTemplate | null>;
search(pattern: string): Promise<McpTemplate[]>;
create(data: CreateTemplateInput): Promise<McpTemplate>;
update(id: string, data: UpdateTemplateInput): Promise<McpTemplate>;
delete(id: string): Promise<void>;
}
export class TemplateRepository implements ITemplateRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(): Promise<McpTemplate[]> {
return this.prisma.mcpTemplate.findMany({ orderBy: { name: 'asc' } });
}
async findById(id: string): Promise<McpTemplate | null> {
return this.prisma.mcpTemplate.findUnique({ where: { id } });
}
async findByName(name: string): Promise<McpTemplate | null> {
return this.prisma.mcpTemplate.findUnique({ where: { name } });
}
async search(pattern: string): Promise<McpTemplate[]> {
// Convert glob * to SQL %
const sqlPattern = pattern.replace(/\*/g, '%');
return this.prisma.mcpTemplate.findMany({
where: { name: { contains: sqlPattern.replace(/%/g, ''), mode: 'insensitive' } },
orderBy: { name: 'asc' },
});
}
async create(data: CreateTemplateInput): Promise<McpTemplate> {
return this.prisma.mcpTemplate.create({
data: {
name: data.name,
version: data.version,
description: data.description,
packageName: data.packageName ?? null,
dockerImage: data.dockerImage ?? null,
transport: data.transport,
repositoryUrl: data.repositoryUrl ?? null,
externalUrl: data.externalUrl ?? null,
command: (data.command ?? Prisma.JsonNull) as Prisma.InputJsonValue,
containerPort: data.containerPort ?? null,
replicas: data.replicas,
env: (data.env ?? []) as unknown as Prisma.InputJsonValue,
healthCheck: (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue,
},
});
}
async update(id: string, data: UpdateTemplateInput): Promise<McpTemplate> {
const updateData: Record<string, unknown> = {};
if (data.version !== undefined) updateData.version = data.version;
if (data.description !== undefined) updateData.description = data.description;
if (data.packageName !== undefined) updateData.packageName = data.packageName;
if (data.dockerImage !== undefined) updateData.dockerImage = data.dockerImage;
if (data.transport !== undefined) updateData.transport = data.transport;
if (data.repositoryUrl !== undefined) updateData.repositoryUrl = data.repositoryUrl;
if (data.externalUrl !== undefined) updateData.externalUrl = data.externalUrl;
if (data.command !== undefined) updateData.command = (data.command ?? Prisma.JsonNull) as Prisma.InputJsonValue;
if (data.containerPort !== undefined) updateData.containerPort = data.containerPort;
if (data.replicas !== undefined) updateData.replicas = data.replicas;
if (data.env !== undefined) updateData.env = (data.env ?? []) as Prisma.InputJsonValue;
if (data.healthCheck !== undefined) updateData.healthCheck = (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue;
return this.prisma.mcpTemplate.update({
where: { id },
data: updateData,
});
}
async delete(id: string): Promise<void> {
await this.prisma.mcpTemplate.delete({ where: { id } });
}
}

View File

@@ -13,7 +13,7 @@ export function registerBackupRoutes(app: FastifyInstance, deps: BackupDeps): vo
app.post<{
Body: {
password?: string;
resources?: Array<'servers' | 'profiles' | 'projects'>;
resources?: Array<'servers' | 'secrets' | 'projects'>;
};
}>('/api/v1/backup', async (request) => {
const opts: BackupOptions = {};
@@ -51,7 +51,7 @@ export function registerBackupRoutes(app: FastifyInstance, deps: BackupDeps): vo
const result = await deps.restoreService.restore(bundle, restoreOpts);
if (result.errors.length > 0 && result.serversCreated === 0 && result.profilesCreated === 0 && result.projectsCreated === 0) {
if (result.errors.length > 0 && result.serversCreated === 0 && result.secretsCreated === 0 && result.projectsCreated === 0) {
reply.code(422);
}

View File

@@ -1,7 +1,7 @@
export { registerHealthRoutes } from './health.js';
export type { HealthDeps } from './health.js';
export { registerMcpServerRoutes } from './mcp-servers.js';
export { registerMcpProfileRoutes } from './mcp-profiles.js';
export { registerSecretRoutes } from './secrets.js';
export { registerProjectRoutes } from './projects.js';
export { registerInstanceRoutes } from './instances.js';
export { registerAuditLogRoutes } from './audit-logs.js';
@@ -13,3 +13,4 @@ export { registerAuthRoutes } from './auth.js';
export type { AuthRouteDeps } from './auth.js';
export { registerMcpProxyRoutes } from './mcp-proxy.js';
export type { McpProxyRouteDeps } from './mcp-proxy.js';
export { registerTemplateRoutes } from './templates.js';

View File

@@ -10,40 +10,17 @@ export function registerInstanceRoutes(app: FastifyInstance, service: InstanceSe
return service.getById(request.params.id);
});
app.post<{ Body: { serverId: string; env?: Record<string, string>; hostPort?: number } }>(
'/api/v1/instances',
async (request, reply) => {
const { serverId } = request.body;
const opts: { env?: Record<string, string>; hostPort?: number } = {};
if (request.body.env) {
opts.env = request.body.env;
}
if (request.body.hostPort !== undefined) {
opts.hostPort = request.body.hostPort;
}
const instance = await service.start(serverId, opts);
reply.code(201);
return instance;
},
);
app.post<{ Params: { id: string } }>('/api/v1/instances/:id/stop', async (request) => {
return service.stop(request.params.id);
});
app.post<{ Params: { id: string } }>('/api/v1/instances/:id/restart', async (request) => {
return service.restart(request.params.id);
app.delete<{ Params: { id: string } }>('/api/v1/instances/:id', async (request, reply) => {
const { serverId } = await service.remove(request.params.id);
// Reconcile: server will auto-create a replacement if replicas > 0
await service.reconcile(serverId);
reply.code(204);
});
app.get<{ Params: { id: string } }>('/api/v1/instances/:id/inspect', async (request) => {
return service.inspect(request.params.id);
});
app.delete<{ Params: { id: string } }>('/api/v1/instances/:id', async (request, reply) => {
await service.remove(request.params.id);
reply.code(204);
});
app.get<{ Params: { id: string }; Querystring: { tail?: string } }>(
'/api/v1/instances/:id/logs',
async (request) => {

View File

@@ -1,27 +0,0 @@
import type { FastifyInstance } from 'fastify';
import type { McpProfileService } from '../services/mcp-profile.service.js';
export function registerMcpProfileRoutes(app: FastifyInstance, service: McpProfileService): void {
app.get<{ Querystring: { serverId?: string } }>('/api/v1/profiles', async (request) => {
return service.list(request.query.serverId);
});
app.get<{ Params: { id: string } }>('/api/v1/profiles/:id', async (request) => {
return service.getById(request.params.id);
});
app.post('/api/v1/profiles', async (request, reply) => {
const profile = await service.create(request.body);
reply.code(201);
return profile;
});
app.put<{ Params: { id: string } }>('/api/v1/profiles/:id', async (request) => {
return service.update(request.params.id, request.body);
});
app.delete<{ Params: { id: string } }>('/api/v1/profiles/:id', async (request, reply) => {
await service.delete(request.params.id);
reply.code(204);
});
}

View File

@@ -1,7 +1,12 @@
import type { FastifyInstance } from 'fastify';
import type { McpServerService } from '../services/mcp-server.service.js';
import type { InstanceService } from '../services/instance.service.js';
export function registerMcpServerRoutes(app: FastifyInstance, service: McpServerService): void {
export function registerMcpServerRoutes(
app: FastifyInstance,
service: McpServerService,
instanceService: InstanceService,
): void {
app.get('/api/v1/servers', async () => {
return service.list();
});
@@ -12,12 +17,17 @@ export function registerMcpServerRoutes(app: FastifyInstance, service: McpServer
app.post('/api/v1/servers', async (request, reply) => {
const server = await service.create(request.body);
// Auto-reconcile: create instances to match replicas
await instanceService.reconcile(server.id);
reply.code(201);
return server;
});
app.put<{ Params: { id: string } }>('/api/v1/servers/:id', async (request) => {
return service.update(request.params.id, request.body);
const server = await service.update(request.params.id, request.body);
// Re-reconcile after update (replicas may have changed)
await instanceService.reconcile(server.id);
return server;
});
app.delete<{ Params: { id: string } }>('/api/v1/servers/:id', async (request, reply) => {

View File

@@ -26,18 +26,4 @@ export function registerProjectRoutes(app: FastifyInstance, service: ProjectServ
await service.delete(request.params.id);
reply.code(204);
});
// Profile associations
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/profiles', async (request) => {
return service.getProfiles(request.params.id);
});
app.put<{ Params: { id: string } }>('/api/v1/projects/:id/profiles', async (request) => {
return service.setProfiles(request.params.id, request.body);
});
// MCP config generation
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/mcp-config', async (request) => {
return service.getMcpConfig(request.params.id);
});
}

View File

@@ -0,0 +1,30 @@
import type { FastifyInstance } from 'fastify';
import type { SecretService } from '../services/secret.service.js';
export function registerSecretRoutes(
app: FastifyInstance,
service: SecretService,
): void {
app.get('/api/v1/secrets', async () => {
return service.list();
});
app.get<{ Params: { id: string } }>('/api/v1/secrets/:id', async (request) => {
return service.getById(request.params.id);
});
app.post('/api/v1/secrets', async (request, reply) => {
const secret = await service.create(request.body);
reply.code(201);
return secret;
});
app.put<{ Params: { id: string } }>('/api/v1/secrets/:id', async (request) => {
return service.update(request.params.id, request.body);
});
app.delete<{ Params: { id: string } }>('/api/v1/secrets/:id', async (request, reply) => {
await service.delete(request.params.id);
reply.code(204);
});
}

View File

@@ -0,0 +1,31 @@
import type { FastifyInstance } from 'fastify';
import type { TemplateService } from '../services/template.service.js';
export function registerTemplateRoutes(
app: FastifyInstance,
service: TemplateService,
): void {
app.get<{ Querystring: { name?: string } }>('/api/v1/templates', async (request) => {
const namePattern = request.query.name;
return service.list(namePattern);
});
app.get<{ Params: { id: string } }>('/api/v1/templates/:id', async (request) => {
return service.getById(request.params.id);
});
app.post('/api/v1/templates', async (request, reply) => {
const template = await service.create(request.body);
reply.code(201);
return template;
});
app.put<{ Params: { id: string } }>('/api/v1/templates/:id', async (request) => {
return service.update(request.params.id, request.body);
});
app.delete<{ Params: { id: string } }>('/api/v1/templates/:id', async (request, reply) => {
await service.delete(request.params.id);
reply.code(204);
});
}

View File

@@ -1,11 +1,45 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { PrismaClient } from '@prisma/client';
import { seedMcpServers } from '@mcpctl/db';
import yaml from 'js-yaml';
import { seedTemplates } from '@mcpctl/db';
import type { SeedTemplate } from '@mcpctl/db';
function loadTemplatesFromDir(dir: string): SeedTemplate[] {
let files: string[];
try {
files = readdirSync(dir).filter((f) => f.endsWith('.yaml') || f.endsWith('.yml'));
} catch {
console.warn(`Templates directory not found: ${dir}`);
return [];
}
const templates: SeedTemplate[] = [];
for (const file of files) {
const content = readFileSync(join(dir, file), 'utf-8');
const parsed = yaml.load(content) as SeedTemplate;
if (parsed?.name) {
templates.push({
...parsed,
transport: parsed.transport ?? 'STDIO',
version: parsed.version ?? '1.0.0',
description: parsed.description ?? '',
...(parsed.healthCheck ? { healthCheck: parsed.healthCheck } : {}),
});
}
}
return templates;
}
async function run(): Promise<void> {
const prisma = new PrismaClient();
try {
const count = await seedMcpServers(prisma);
console.log(`Seeded ${count} MCP servers`);
// Look for templates in common locations
const templatesDir = process.env.TEMPLATES_DIR ?? 'templates';
const templates = loadTemplatesFromDir(templatesDir);
const count = await seedTemplates(prisma, templates);
console.log(`Seeded ${count} templates from ${templatesDir}`);
} finally {
await prisma.$disconnect();
}

View File

@@ -1,4 +1,4 @@
import type { IMcpServerRepository, IMcpProfileRepository } from '../../repositories/interfaces.js';
import type { IMcpServerRepository, ISecretRepository } from '../../repositories/interfaces.js';
import type { IProjectRepository } from '../../repositories/project.repository.js';
import { encrypt, isSensitiveKey } from './crypto.js';
import type { EncryptedPayload } from './crypto.js';
@@ -10,7 +10,7 @@ export interface BackupBundle {
createdAt: string;
encrypted: boolean;
servers: BackupServer[];
profiles: BackupProfile[];
secrets: BackupSecret[];
projects: BackupProject[];
encryptedSecrets?: EncryptedPayload;
}
@@ -22,39 +22,36 @@ export interface BackupServer {
dockerImage: string | null;
transport: string;
repositoryUrl: string | null;
envTemplate: unknown;
env: unknown;
}
export interface BackupProfile {
export interface BackupSecret {
name: string;
serverName: string;
permissions: unknown;
envOverrides: unknown;
data: Record<string, string>;
}
export interface BackupProject {
name: string;
description: string;
profileNames: string[];
}
export interface BackupOptions {
password?: string;
resources?: Array<'servers' | 'profiles' | 'projects'>;
resources?: Array<'servers' | 'secrets' | 'projects'>;
}
export class BackupService {
constructor(
private serverRepo: IMcpServerRepository,
private profileRepo: IMcpProfileRepository,
private projectRepo: IProjectRepository,
private secretRepo: ISecretRepository,
) {}
async createBackup(options?: BackupOptions): Promise<BackupBundle> {
const resources = options?.resources ?? ['servers', 'profiles', 'projects'];
const resources = options?.resources ?? ['servers', 'secrets', 'projects'];
let servers: BackupServer[] = [];
let profiles: BackupProfile[] = [];
let secrets: BackupSecret[] = [];
let projects: BackupProject[] = [];
if (resources.includes('servers')) {
@@ -66,44 +63,24 @@ export class BackupService {
dockerImage: s.dockerImage,
transport: s.transport,
repositoryUrl: s.repositoryUrl,
envTemplate: s.envTemplate,
env: s.env,
}));
}
if (resources.includes('profiles')) {
const allProfiles = await this.profileRepo.findAll();
const serverMap = new Map<string, string>();
const allServers = await this.serverRepo.findAll();
for (const s of allServers) {
serverMap.set(s.id, s.name);
}
profiles = allProfiles.map((p) => ({
name: p.name,
serverName: serverMap.get(p.serverId) ?? p.serverId,
permissions: p.permissions,
envOverrides: p.envOverrides,
if (resources.includes('secrets')) {
const allSecrets = await this.secretRepo.findAll();
secrets = allSecrets.map((s) => ({
name: s.name,
data: s.data as Record<string, string>,
}));
}
if (resources.includes('projects')) {
const allProjects = await this.projectRepo.findAll();
const allProfiles = await this.profileRepo.findAll();
const profileMap = new Map<string, string>();
for (const p of allProfiles) {
profileMap.set(p.id, p.name);
}
projects = await Promise.all(
allProjects.map(async (proj) => {
const profileIds = await this.projectRepo.getProfileIds(proj.id);
return {
name: proj.name,
description: proj.description,
profileNames: profileIds.map((id) => profileMap.get(id) ?? id),
};
}),
);
projects = allProjects.map((proj) => ({
name: proj.name,
description: proj.description,
}));
}
const bundle: BackupBundle = {
@@ -112,29 +89,26 @@ export class BackupService {
createdAt: new Date().toISOString(),
encrypted: false,
servers,
profiles,
secrets,
projects,
};
if (options?.password) {
// Collect sensitive values and encrypt them
const secrets: Record<string, string> = {};
for (const profile of profiles) {
const overrides = profile.envOverrides as Record<string, string> | null;
if (overrides) {
for (const [key, value] of Object.entries(overrides)) {
if (isSensitiveKey(key)) {
const secretKey = `profile:${profile.name}:${key}`;
secrets[secretKey] = value;
(overrides as Record<string, string>)[key] = `__ENCRYPTED:${secretKey}__`;
}
if (options?.password && secrets.length > 0) {
// Collect sensitive values from secrets and encrypt them
const sensitiveData: Record<string, string> = {};
for (const secret of secrets) {
for (const [key, value] of Object.entries(secret.data)) {
if (isSensitiveKey(key)) {
const secretKey = `secret:${secret.name}:${key}`;
sensitiveData[secretKey] = value;
secret.data[key] = `__ENCRYPTED:${secretKey}__`;
}
}
}
if (Object.keys(secrets).length > 0) {
if (Object.keys(sensitiveData).length > 0) {
bundle.encrypted = true;
bundle.encryptedSecrets = encrypt(JSON.stringify(secrets), options.password);
bundle.encryptedSecrets = encrypt(JSON.stringify(sensitiveData), options.password);
}
}

View File

@@ -1,5 +1,5 @@
export { BackupService } from './backup-service.js';
export type { BackupBundle, BackupServer, BackupProfile, BackupProject, BackupOptions } from './backup-service.js';
export type { BackupBundle, BackupServer, BackupSecret, BackupProject, BackupOptions } from './backup-service.js';
export { RestoreService } from './restore-service.js';
export type { RestoreOptions, RestoreResult, ConflictStrategy } from './restore-service.js';
export { encrypt, decrypt, isSensitiveKey } from './crypto.js';

View File

@@ -1,4 +1,4 @@
import type { IMcpServerRepository, IMcpProfileRepository } from '../../repositories/interfaces.js';
import type { IMcpServerRepository, ISecretRepository } from '../../repositories/interfaces.js';
import type { IProjectRepository } from '../../repositories/project.repository.js';
import { decrypt } from './crypto.js';
import type { BackupBundle } from './backup-service.js';
@@ -13,8 +13,8 @@ export interface RestoreOptions {
export interface RestoreResult {
serversCreated: number;
serversSkipped: number;
profilesCreated: number;
profilesSkipped: number;
secretsCreated: number;
secretsSkipped: number;
projectsCreated: number;
projectsSkipped: number;
errors: string[];
@@ -23,8 +23,8 @@ export interface RestoreResult {
export class RestoreService {
constructor(
private serverRepo: IMcpServerRepository,
private profileRepo: IMcpProfileRepository,
private projectRepo: IProjectRepository,
private secretRepo: ISecretRepository,
) {}
validateBundle(bundle: unknown): bundle is BackupBundle {
@@ -33,7 +33,7 @@ export class RestoreService {
return (
typeof b['version'] === 'string' &&
Array.isArray(b['servers']) &&
Array.isArray(b['profiles']) &&
Array.isArray(b['secrets']) &&
Array.isArray(b['projects'])
);
}
@@ -43,46 +43,42 @@ export class RestoreService {
const result: RestoreResult = {
serversCreated: 0,
serversSkipped: 0,
profilesCreated: 0,
profilesSkipped: 0,
secretsCreated: 0,
secretsSkipped: 0,
projectsCreated: 0,
projectsSkipped: 0,
errors: [],
};
// Decrypt secrets if encrypted
let secrets: Record<string, string> = {};
let decryptedSecrets: Record<string, string> = {};
if (bundle.encrypted && bundle.encryptedSecrets) {
if (!options?.password) {
result.errors.push('Backup is encrypted but no password provided');
return result;
}
try {
secrets = JSON.parse(decrypt(bundle.encryptedSecrets, options.password)) as Record<string, string>;
decryptedSecrets = JSON.parse(decrypt(bundle.encryptedSecrets, options.password)) as Record<string, string>;
} catch {
result.errors.push('Failed to decrypt backup - incorrect password or corrupted data');
return result;
}
}
// Restore secrets into profile envOverrides
for (const profile of bundle.profiles) {
const overrides = profile.envOverrides as Record<string, string> | null;
if (overrides) {
for (const [key, value] of Object.entries(overrides)) {
if (typeof value === 'string' && value.startsWith('__ENCRYPTED:') && value.endsWith('__')) {
const secretKey = value.slice(12, -2);
const decrypted = secrets[secretKey];
if (decrypted !== undefined) {
overrides[key] = decrypted;
}
// Restore encrypted values into secret data
for (const secret of bundle.secrets) {
for (const [key, value] of Object.entries(secret.data)) {
if (typeof value === 'string' && value.startsWith('__ENCRYPTED:') && value.endsWith('__')) {
const secretKey = value.slice(12, -2);
const decrypted = decryptedSecrets[secretKey];
if (decrypted !== undefined) {
secret.data[key] = decrypted;
}
}
}
}
// Restore servers
const serverNameToId = new Map<string, string>();
for (const server of bundle.servers) {
try {
const existing = await this.serverRepo.findByName(server.name);
@@ -93,7 +89,6 @@ export class RestoreService {
}
if (strategy === 'skip') {
result.serversSkipped++;
serverNameToId.set(server.name, existing.id);
continue;
}
// overwrite
@@ -105,7 +100,6 @@ export class RestoreService {
if (server.dockerImage) updateData.dockerImage = server.dockerImage;
if (server.repositoryUrl) updateData.repositoryUrl = server.repositoryUrl;
await this.serverRepo.update(existing.id, updateData);
serverNameToId.set(server.name, existing.id);
result.serversCreated++;
continue;
}
@@ -114,66 +108,45 @@ export class RestoreService {
name: server.name,
description: server.description,
transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP',
envTemplate: (server.envTemplate ?? []) as Array<{ name: string; description: string; isSecret: boolean }>,
replicas: (server as { replicas?: number }).replicas ?? 1,
env: (server.env ?? []) as Array<{ name: string; value?: string; valueFrom?: { secretRef: { name: string; key: string } } }>,
};
if (server.packageName) createData.packageName = server.packageName;
if (server.dockerImage) createData.dockerImage = server.dockerImage;
if (server.repositoryUrl) createData.repositoryUrl = server.repositoryUrl;
const created = await this.serverRepo.create(createData);
serverNameToId.set(server.name, created.id);
await this.serverRepo.create(createData);
result.serversCreated++;
} catch (err) {
result.errors.push(`Failed to restore server "${server.name}": ${err instanceof Error ? err.message : String(err)}`);
}
}
// Restore profiles
const profileNameToId = new Map<string, string>();
for (const profile of bundle.profiles) {
// Restore secrets
for (const secret of bundle.secrets) {
try {
const serverId = serverNameToId.get(profile.serverName);
if (!serverId) {
// Try to find server by name in DB
const server = await this.serverRepo.findByName(profile.serverName);
if (!server) {
result.errors.push(`Profile "${profile.name}" references unknown server "${profile.serverName}"`);
continue;
}
serverNameToId.set(profile.serverName, server.id);
}
const sid = serverNameToId.get(profile.serverName)!;
const existing = await this.profileRepo.findByServerAndName(sid, profile.name);
const existing = await this.secretRepo.findByName(secret.name);
if (existing) {
if (strategy === 'fail') {
result.errors.push(`Profile "${profile.name}" already exists for server "${profile.serverName}"`);
result.errors.push(`Secret "${secret.name}" already exists`);
return result;
}
if (strategy === 'skip') {
result.profilesSkipped++;
profileNameToId.set(profile.name, existing.id);
result.secretsSkipped++;
continue;
}
// overwrite
await this.profileRepo.update(existing.id, {
permissions: profile.permissions as string[],
envOverrides: profile.envOverrides as Record<string, string>,
});
profileNameToId.set(profile.name, existing.id);
result.profilesCreated++;
await this.secretRepo.update(existing.id, { data: secret.data });
result.secretsCreated++;
continue;
}
const created = await this.profileRepo.create({
name: profile.name,
serverId: sid,
permissions: profile.permissions as string[],
envOverrides: profile.envOverrides as Record<string, string>,
await this.secretRepo.create({
name: secret.name,
data: secret.data,
});
profileNameToId.set(profile.name, created.id);
result.profilesCreated++;
result.secretsCreated++;
} catch (err) {
result.errors.push(`Failed to restore profile "${profile.name}": ${err instanceof Error ? err.message : String(err)}`);
result.errors.push(`Failed to restore secret "${secret.name}": ${err instanceof Error ? err.message : String(err)}`);
}
}
@@ -190,29 +163,17 @@ export class RestoreService {
result.projectsSkipped++;
continue;
}
// overwrite - update and set profiles
// overwrite
await this.projectRepo.update(existing.id, { description: project.description });
const profileIds = project.profileNames
.map((name) => profileNameToId.get(name))
.filter((id): id is string => id !== undefined);
if (profileIds.length > 0) {
await this.projectRepo.setProfiles(existing.id, profileIds);
}
result.projectsCreated++;
continue;
}
const created = await this.projectRepo.create({
await this.projectRepo.create({
name: project.name,
description: project.description,
ownerId: 'system',
});
const profileIds = project.profileNames
.map((name) => profileNameToId.get(name))
.filter((id): id is string => id !== undefined);
if (profileIds.length > 0) {
await this.projectRepo.setProfiles(created.id, profileIds);
}
result.projectsCreated++;
} catch (err) {
result.errors.push(`Failed to restore project "${project.name}": ${err instanceof Error ? err.message : String(err)}`);

View File

@@ -5,7 +5,7 @@ import type {
ContainerInfo,
ContainerLogs,
} from '../orchestrator.js';
import { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from '../orchestrator.js';
import { DEFAULT_MEMORY_LIMIT } from '../orchestrator.js';
const MCPCTL_LABEL = 'mcpctl.managed';
@@ -54,7 +54,7 @@ export class DockerContainerManager implements McpOrchestrator {
async createContainer(spec: ContainerSpec): Promise<ContainerInfo> {
const memoryLimit = spec.memoryLimit ?? DEFAULT_MEMORY_LIMIT;
const nanoCpus = spec.nanoCpus ?? DEFAULT_NANO_CPUS;
const nanoCpus = spec.nanoCpus;
const portBindings: Record<string, Array<{ HostPort: string }>> = {};
const exposedPorts: Record<string, Record<string, never>> = {};
@@ -74,7 +74,7 @@ export class DockerContainerManager implements McpOrchestrator {
? Object.entries(spec.env).map(([k, v]) => `${k}=${v}`)
: undefined;
const container = await this.docker.createContainer({
const createOpts: Docker.ContainerCreateOptions = {
Image: spec.image,
name: spec.name,
Env: envArr,
@@ -83,10 +83,15 @@ export class DockerContainerManager implements McpOrchestrator {
HostConfig: {
PortBindings: portBindings,
Memory: memoryLimit,
NanoCpus: nanoCpus,
...(nanoCpus ? { NanoCpus: nanoCpus } : {}),
NetworkMode: spec.network ?? 'bridge',
},
});
};
if (spec.command) {
createOpts.Cmd = spec.command;
}
const container = await this.docker.createContainer(createOpts);
await container.start();

View File

@@ -0,0 +1,44 @@
import type { McpServer } from '@prisma/client';
import type { ISecretRepository } from '../repositories/interfaces.js';
import type { ServerEnvEntry } from '../validation/mcp-server.schema.js';
/**
* Resolve a server's env entries into a flat key-value map.
* - Inline `value` entries are used directly.
* - `valueFrom.secretRef` entries are looked up from the secret repository.
* Throws if a referenced secret or key is missing.
*/
export async function resolveServerEnv(
server: McpServer,
secretRepo: ISecretRepository,
): Promise<Record<string, string>> {
const entries = server.env as ServerEnvEntry[];
if (!entries || entries.length === 0) return {};
const result: Record<string, string> = {};
const secretCache = new Map<string, Record<string, string>>();
for (const entry of entries) {
if (entry.value !== undefined) {
result[entry.name] = entry.value;
} else if (entry.valueFrom?.secretRef) {
const { name: secretName, key } = entry.valueFrom.secretRef;
if (!secretCache.has(secretName)) {
const secret = await secretRepo.findByName(secretName);
if (!secret) {
throw new Error(`Secret '${secretName}' not found (referenced by server '${server.name}' env '${entry.name}')`);
}
secretCache.set(secretName, secret.data as Record<string, string>);
}
const data = secretCache.get(secretName)!;
if (!(key in data)) {
throw new Error(`Key '${key}' not found in secret '${secretName}' (referenced by server '${server.name}' env '${entry.name}')`);
}
result[entry.name] = data[key]!;
}
}
return result;
}

View File

@@ -1,9 +1,10 @@
export { McpServerService, NotFoundError, ConflictError } from './mcp-server.service.js';
export { McpProfileService } from './mcp-profile.service.js';
export { SecretService } from './secret.service.js';
export { resolveServerEnv } from './env-resolver.js';
export { ProjectService } from './project.service.js';
export { InstanceService, InvalidStateError } from './instance.service.js';
export { generateMcpConfig } from './mcp-config-generator.js';
export type { McpConfig, McpConfigServer, ProfileWithServer } from './mcp-config-generator.js';
export type { McpConfig, McpConfigServer } from './mcp-config-generator.js';
export type { McpOrchestrator, ContainerSpec, ContainerInfo, ContainerLogs } from './orchestrator.js';
export { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from './orchestrator.js';
export { DockerContainerManager } from './docker/container-manager.js';
@@ -23,3 +24,4 @@ export { AuthService, AuthenticationError } from './auth.service.js';
export type { LoginResult } from './auth.service.js';
export { McpProxyService } from './mcp-proxy-service.js';
export type { McpProxyRequest, McpProxyResponse } from './mcp-proxy-service.js';
export { TemplateService } from './template.service.js';

View File

@@ -1,7 +1,14 @@
import type { McpInstance } from '@prisma/client';
import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js';
import type { IMcpInstanceRepository, IMcpServerRepository, ISecretRepository } from '../repositories/interfaces.js';
import type { McpOrchestrator, ContainerSpec, ContainerInfo } from './orchestrator.js';
import { NotFoundError } from './mcp-server.service.js';
import { resolveServerEnv } from './env-resolver.js';
/** Default image for npm-based MCP servers (STDIO with packageName, no dockerImage). */
const DEFAULT_NODE_RUNNER_IMAGE = process.env['MCPD_NODE_RUNNER_IMAGE'] ?? 'mysources.co.uk/michal/mcpctl-node-runner:latest';
/** Network for MCP server containers (matches docker-compose mcp-servers network). */
const MCP_SERVERS_NETWORK = process.env['MCPD_MCP_NETWORK'] ?? 'mcp-servers';
export class InvalidStateError extends Error {
readonly statusCode = 409;
@@ -16,6 +23,7 @@ export class InstanceService {
private instanceRepo: IMcpInstanceRepository,
private serverRepo: IMcpServerRepository,
private orchestrator: McpOrchestrator,
private secretRepo?: ISecretRepository,
) {}
async list(serverId?: string): Promise<McpInstance[]> {
@@ -28,81 +36,46 @@ export class InstanceService {
return instance;
}
async start(serverId: string, opts?: { env?: Record<string, string>; hostPort?: number }): Promise<McpInstance> {
/**
* Reconcile instances for a server to match desired replica count.
* - If fewer running instances than replicas: start new ones
* - If more running instances than replicas: remove excess (oldest first)
*/
async reconcile(serverId: string): Promise<McpInstance[]> {
const server = await this.serverRepo.findById(serverId);
if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`);
const image = server.dockerImage ?? server.packageName ?? server.name;
const instances = await this.instanceRepo.findAll(serverId);
const active = instances.filter((i) => i.status === 'RUNNING' || i.status === 'STARTING');
const desired = server.replicas;
// Create DB record first in STARTING state
let instance = await this.instanceRepo.create({
serverId,
status: 'STARTING',
});
try {
const spec: ContainerSpec = {
image,
name: `mcpctl-${server.name}-${instance.id}`,
hostPort: opts?.hostPort ?? null,
labels: {
'mcpctl.server-id': serverId,
'mcpctl.instance-id': instance.id,
},
};
if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') {
spec.containerPort = 3000;
if (active.length < desired) {
// Scale up
const toStart = desired - active.length;
for (let i = 0; i < toStart; i++) {
await this.startOne(serverId);
}
if (opts?.env) {
spec.env = opts.env;
} else if (active.length > desired) {
// Scale down — remove oldest first
const excess = active
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.slice(0, active.length - desired);
for (const inst of excess) {
await this.removeOne(inst);
}
const containerInfo = await this.orchestrator.createContainer(spec);
const updateFields: { containerId: string; port?: number } = {
containerId: containerInfo.containerId,
};
if (containerInfo.port !== undefined) {
updateFields.port = containerInfo.port;
}
instance = await this.instanceRepo.updateStatus(instance.id, 'RUNNING', updateFields);
} catch (err) {
// Mark as ERROR if container creation fails
instance = await this.instanceRepo.updateStatus(instance.id, 'ERROR', {
metadata: { error: err instanceof Error ? err.message : String(err) },
});
}
return instance;
return this.instanceRepo.findAll(serverId);
}
async stop(id: string): Promise<McpInstance> {
const instance = await this.getById(id);
if (instance.status === 'STOPPED') {
throw new InvalidStateError(`Instance '${id}' is already stopped`);
}
if (!instance.containerId) {
return this.instanceRepo.updateStatus(id, 'STOPPED');
}
await this.instanceRepo.updateStatus(id, 'STOPPING');
try {
await this.orchestrator.stopContainer(instance.containerId);
return await this.instanceRepo.updateStatus(id, 'STOPPED');
} catch (err) {
return await this.instanceRepo.updateStatus(id, 'ERROR', {
metadata: { error: err instanceof Error ? err.message : String(err) },
});
}
}
async restart(id: string): Promise<McpInstance> {
/**
* Remove an instance (stop container + delete DB record).
* Does NOT reconcile — caller should reconcile after if needed.
*/
async remove(id: string): Promise<{ serverId: string }> {
const instance = await this.getById(id);
// Stop if running
if (instance.containerId && (instance.status === 'RUNNING' || instance.status === 'STARTING')) {
if (instance.containerId) {
try {
await this.orchestrator.stopContainer(instance.containerId);
} catch {
@@ -116,9 +89,29 @@ export class InstanceService {
}
await this.instanceRepo.delete(id);
return { serverId: instance.serverId };
}
// Start a fresh instance for the same server
return this.start(instance.serverId);
/**
* Remove all instances for a server (used before server deletion).
* Stops all containers so Prisma cascade only cleans up DB records.
*/
async removeAllForServer(serverId: string): Promise<void> {
const instances = await this.instanceRepo.findAll(serverId);
for (const inst of instances) {
if (inst.containerId) {
try {
await this.orchestrator.stopContainer(inst.containerId);
} catch {
// best-effort
}
try {
await this.orchestrator.removeContainer(inst.containerId, true);
} catch {
// best-effort
}
}
}
}
async inspect(id: string): Promise<ContainerInfo> {
@@ -129,20 +122,6 @@ export class InstanceService {
return this.orchestrator.inspectContainer(instance.containerId);
}
async remove(id: string): Promise<void> {
const instance = await this.getById(id);
if (instance.containerId) {
try {
await this.orchestrator.removeContainer(instance.containerId, true);
} catch {
// Container may already be gone, proceed with DB cleanup
}
}
await this.instanceRepo.delete(id);
}
async getLogs(id: string, opts?: { tail?: number }): Promise<{ stdout: string; stderr: string }> {
const instance = await this.getById(id);
if (!instance.containerId) {
@@ -151,4 +130,118 @@ export class InstanceService {
return this.orchestrator.getContainerLogs(instance.containerId, opts);
}
/** Start a single instance for a server. */
private async startOne(serverId: string): Promise<McpInstance> {
const server = await this.serverRepo.findById(serverId);
if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`);
// External servers don't need container management
if (server.externalUrl) {
return this.instanceRepo.create({
serverId,
status: 'RUNNING',
metadata: { external: true, url: server.externalUrl },
});
}
// Determine image + command based on server config:
// 1. Explicit dockerImage → use as-is
// 2. packageName (npm) → use node-runner image + npx command
// 3. Fallback → server name (legacy)
let image: string;
let npmCommand: string[] | undefined;
if (server.dockerImage) {
image = server.dockerImage;
} else if (server.packageName) {
image = DEFAULT_NODE_RUNNER_IMAGE;
// Build npx command: entrypoint is ["npx", "-y"], so CMD = [packageName, ...args]
const serverCommand = server.command as string[] | null;
npmCommand = [server.packageName, ...(serverCommand ?? [])];
} else {
image = server.name;
}
let instance = await this.instanceRepo.create({
serverId,
status: 'STARTING',
});
try {
const spec: ContainerSpec = {
image,
name: `mcpctl-${server.name}-${instance.id}`,
hostPort: null,
network: MCP_SERVERS_NETWORK,
labels: {
'mcpctl.server-id': serverId,
'mcpctl.instance-id': instance.id,
},
};
if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') {
spec.containerPort = server.containerPort ?? 3000;
}
// npm-based servers: command = [packageName, ...args] (entrypoint handles npx -y)
// Docker-image servers: use explicit command if provided
if (npmCommand) {
spec.command = npmCommand;
} else {
const command = server.command as string[] | null;
if (command) {
spec.command = command;
}
}
// Resolve env vars from inline values and secret refs
if (this.secretRepo) {
try {
const resolvedEnv = await resolveServerEnv(server, this.secretRepo);
if (Object.keys(resolvedEnv).length > 0) {
spec.env = resolvedEnv;
}
} catch (envErr) {
// Log but don't prevent startup — env resolution failures are non-fatal
// The container may still work if env vars are optional
}
}
// Pull image if not available locally
try {
await this.orchestrator.pullImage(image);
} catch {
// Image may already be available locally
}
const containerInfo = await this.orchestrator.createContainer(spec);
const updateFields: { containerId: string; port?: number } = {
containerId: containerInfo.containerId,
};
if (containerInfo.port !== undefined) {
updateFields.port = containerInfo.port;
}
instance = await this.instanceRepo.updateStatus(instance.id, 'RUNNING', updateFields);
} catch (err) {
instance = await this.instanceRepo.updateStatus(instance.id, 'ERROR', {
metadata: { error: err instanceof Error ? err.message : String(err) },
});
}
return instance;
}
/** Stop and remove a single instance. */
private async removeOne(instance: McpInstance): Promise<void> {
if (instance.containerId) {
try {
await this.orchestrator.stopContainer(instance.containerId);
} catch { /* best-effort */ }
try {
await this.orchestrator.removeContainer(instance.containerId, true);
} catch { /* best-effort */ }
}
await this.instanceRepo.delete(instance.id);
}
}

View File

@@ -1,4 +1,4 @@
import type { McpServer, McpProfile } from '@prisma/client';
import type { McpServer } from '@prisma/client';
export interface McpConfigServer {
command: string;
@@ -10,49 +10,25 @@ export interface McpConfig {
mcpServers: Record<string, McpConfigServer>;
}
export interface ProfileWithServer {
profile: McpProfile;
server: McpServer;
}
/**
* Generate .mcp.json config from a project's profiles.
* Secret env vars are excluded from the output — they must be injected at runtime.
* Generate .mcp.json config from servers with their resolved env vars.
*/
export function generateMcpConfig(profiles: ProfileWithServer[]): McpConfig {
export function generateMcpConfig(
servers: Array<{ server: McpServer; resolvedEnv: Record<string, string> }>,
): McpConfig {
const mcpServers: Record<string, McpConfigServer> = {};
for (const { profile, server } of profiles) {
const key = `${server.name}--${profile.name}`;
const envTemplate = server.envTemplate as Array<{
name: string;
isSecret: boolean;
defaultValue?: string;
}>;
const envOverrides = profile.envOverrides as Record<string, string>;
// Build env: only include non-secret env vars
const env: Record<string, string> = {};
for (const entry of envTemplate) {
if (entry.isSecret) continue; // Never include secrets in config output
const override = envOverrides[entry.name];
if (override !== undefined) {
env[entry.name] = override;
} else if (entry.defaultValue !== undefined) {
env[entry.name] = entry.defaultValue;
}
}
for (const { server, resolvedEnv } of servers) {
const config: McpConfigServer = {
command: 'npx',
args: ['-y', server.packageName ?? server.name],
};
if (Object.keys(env).length > 0) {
config.env = env;
if (Object.keys(resolvedEnv).length > 0) {
config.env = resolvedEnv;
}
mcpServers[key] = config;
mcpServers[server.name] = config;
}
return { mcpServers };

View File

@@ -1,62 +0,0 @@
import type { McpProfile } from '@prisma/client';
import type { IMcpProfileRepository, IMcpServerRepository } from '../repositories/interfaces.js';
import { CreateMcpProfileSchema, UpdateMcpProfileSchema } from '../validation/mcp-profile.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';
export class McpProfileService {
constructor(
private readonly profileRepo: IMcpProfileRepository,
private readonly serverRepo: IMcpServerRepository,
) {}
async list(serverId?: string): Promise<McpProfile[]> {
return this.profileRepo.findAll(serverId);
}
async getById(id: string): Promise<McpProfile> {
const profile = await this.profileRepo.findById(id);
if (profile === null) {
throw new NotFoundError(`Profile not found: ${id}`);
}
return profile;
}
async create(input: unknown): Promise<McpProfile> {
const data = CreateMcpProfileSchema.parse(input);
// Verify server exists
const server = await this.serverRepo.findById(data.serverId);
if (server === null) {
throw new NotFoundError(`Server not found: ${data.serverId}`);
}
// Check unique name per server
const existing = await this.profileRepo.findByServerAndName(data.serverId, data.name);
if (existing !== null) {
throw new ConflictError(`Profile "${data.name}" already exists for server "${server.name}"`);
}
return this.profileRepo.create(data);
}
async update(id: string, input: unknown): Promise<McpProfile> {
const data = UpdateMcpProfileSchema.parse(input);
const profile = await this.getById(id);
// If renaming, check uniqueness
if (data.name !== undefined && data.name !== profile.name) {
const existing = await this.profileRepo.findByServerAndName(profile.serverId, data.name);
if (existing !== null) {
throw new ConflictError(`Profile "${data.name}" already exists for this server`);
}
}
return this.profileRepo.update(id, data);
}
async delete(id: string): Promise<void> {
await this.getById(id);
await this.profileRepo.delete(id);
}
}

View File

@@ -1,5 +1,5 @@
import type { McpInstance } from '@prisma/client';
import type { IMcpInstanceRepository } from '../repositories/interfaces.js';
import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js';
import { NotFoundError } from './mcp-server.service.js';
import { InvalidStateError } from './instance.service.js';
@@ -16,11 +16,39 @@ export interface McpProxyResponse {
error?: { code: number; message: string; data?: unknown };
}
/**
* Parses a streamable-http SSE response body to extract the JSON-RPC payload.
* Streamable-http returns `event: message\ndata: {...}\n\n` format.
*/
function parseStreamableResponse(body: string): McpProxyResponse {
for (const line of body.split('\n')) {
const trimmed = line.trim();
if (trimmed.startsWith('data: ')) {
return JSON.parse(trimmed.slice(6)) as McpProxyResponse;
}
}
// If body is plain JSON (no SSE framing), parse directly
return JSON.parse(body) as McpProxyResponse;
}
export class McpProxyService {
constructor(private readonly instanceRepo: IMcpInstanceRepository) {}
/** Session IDs per server for streamable-http protocol */
private sessions = new Map<string, string>();
constructor(
private readonly instanceRepo: IMcpInstanceRepository,
private readonly serverRepo: IMcpServerRepository,
) {}
async execute(request: McpProxyRequest): Promise<McpProxyResponse> {
// Find a running instance for this server
const server = await this.serverRepo.findById(request.serverId);
// External server: proxy directly to externalUrl
if (server?.externalUrl) {
return this.sendToExternal(server.id, server.externalUrl, request.method, request.params);
}
// Managed server: find running instance
const instances = await this.instanceRepo.findAll(request.serverId);
const running = instances.find((i) => i.status === 'RUNNING');
@@ -37,6 +65,116 @@ export class McpProxyService {
return this.sendJsonRpc(running, request.method, request.params);
}
/**
* Send a JSON-RPC request to an external MCP server.
* Handles streamable-http protocol (session management + SSE response parsing).
*/
private async sendToExternal(
serverId: string,
url: string,
method: string,
params?: Record<string, unknown>,
): Promise<McpProxyResponse> {
// Ensure we have a session (initialize on first call)
if (!this.sessions.has(serverId)) {
await this.initSession(serverId, url);
}
const sessionId = this.sessions.get(serverId);
const body: Record<string, unknown> = {
jsonrpc: '2.0',
id: 1,
method,
};
if (params !== undefined) {
body.params = params;
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
};
if (sessionId) {
headers['Mcp-Session-Id'] = sessionId;
}
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
if (!response.ok) {
// Session expired? Clear and retry once
if (response.status === 400 || response.status === 404) {
this.sessions.delete(serverId);
return this.sendToExternal(serverId, url, method, params);
}
return {
jsonrpc: '2.0',
id: 1,
error: {
code: -32000,
message: `External MCP server returned HTTP ${response.status}: ${response.statusText}`,
},
};
}
const text = await response.text();
return parseStreamableResponse(text);
}
/**
* Initialize a streamable-http session with an external server.
* Sends `initialize` and `notifications/initialized`, caches the session ID.
*/
private async initSession(serverId: string, url: string): Promise<void> {
const initBody = {
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'mcpctl', version: '0.1.0' },
},
};
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
},
body: JSON.stringify(initBody),
});
if (!response.ok) {
throw new Error(`Failed to initialize session: HTTP ${response.status}`);
}
const sessionId = response.headers.get('mcp-session-id');
if (sessionId) {
this.sessions.set(serverId, sessionId);
}
// Send notifications/initialized
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
};
if (sessionId) {
headers['Mcp-Session-Id'] = sessionId;
}
await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
});
}
private async sendJsonRpc(
instance: McpInstance,
method: string,

View File

@@ -1,10 +1,18 @@
import type { McpServer } from '@prisma/client';
import type { IMcpServerRepository } from '../repositories/interfaces.js';
import type { InstanceService } from './instance.service.js';
import { CreateMcpServerSchema, UpdateMcpServerSchema } from '../validation/mcp-server.schema.js';
export class McpServerService {
private instanceService: InstanceService | null = null;
constructor(private readonly repo: IMcpServerRepository) {}
/** Set after construction to avoid circular dependency. */
setInstanceService(instanceService: InstanceService): void {
this.instanceService = instanceService;
}
async list(): Promise<McpServer[]> {
return this.repo.findAll();
}
@@ -48,6 +56,10 @@ export class McpServerService {
async delete(id: string): Promise<void> {
// Verify exists
await this.getById(id);
// Stop all containers before DB cascade
if (this.instanceService) {
await this.instanceService.removeAllForServer(id);
}
await this.repo.delete(id);
}
}

View File

@@ -7,6 +7,8 @@ export interface ContainerSpec {
image: string;
/** Human-readable name (used as container name prefix) */
name: string;
/** Custom command to run (overrides image CMD) */
command?: string[];
/** Environment variables */
env?: Record<string, string>;
/** Host port to bind (null = auto-assign) */

View File

@@ -1,16 +1,11 @@
import type { Project } from '@prisma/client';
import type { IProjectRepository } from '../repositories/project.repository.js';
import type { IMcpProfileRepository, IMcpServerRepository } from '../repositories/interfaces.js';
import { CreateProjectSchema, UpdateProjectSchema, UpdateProjectProfilesSchema } from '../validation/project.schema.js';
import { CreateProjectSchema, UpdateProjectSchema } from '../validation/project.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';
import { generateMcpConfig } from './mcp-config-generator.js';
import type { McpConfig, ProfileWithServer } from './mcp-config-generator.js';
export class ProjectService {
constructor(
private readonly projectRepo: IProjectRepository,
private readonly profileRepo: IMcpProfileRepository,
private readonly serverRepo: IMcpServerRepository,
) {}
async list(ownerId?: string): Promise<Project[]> {
@@ -46,41 +41,4 @@ export class ProjectService {
await this.getById(id);
await this.projectRepo.delete(id);
}
async setProfiles(projectId: string, input: unknown): Promise<string[]> {
const { profileIds } = UpdateProjectProfilesSchema.parse(input);
await this.getById(projectId);
// Verify all profiles exist
for (const profileId of profileIds) {
const profile = await this.profileRepo.findById(profileId);
if (profile === null) {
throw new NotFoundError(`Profile not found: ${profileId}`);
}
}
await this.projectRepo.setProfiles(projectId, profileIds);
return profileIds;
}
async getProfiles(projectId: string): Promise<string[]> {
await this.getById(projectId);
return this.projectRepo.getProfileIds(projectId);
}
async getMcpConfig(projectId: string): Promise<McpConfig> {
await this.getById(projectId);
const profileIds = await this.projectRepo.getProfileIds(projectId);
const profilesWithServers: ProfileWithServer[] = [];
for (const profileId of profileIds) {
const profile = await this.profileRepo.findById(profileId);
if (profile === null) continue;
const server = await this.serverRepo.findById(profile.serverId);
if (server === null) continue;
profilesWithServers.push({ profile, server });
}
return generateMcpConfig(profilesWithServers);
}
}

View File

@@ -0,0 +1,54 @@
import type { Secret } from '@prisma/client';
import type { ISecretRepository } from '../repositories/interfaces.js';
import { CreateSecretSchema, UpdateSecretSchema } from '../validation/secret.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';
export class SecretService {
constructor(private readonly repo: ISecretRepository) {}
async list(): Promise<Secret[]> {
return this.repo.findAll();
}
async getById(id: string): Promise<Secret> {
const secret = await this.repo.findById(id);
if (secret === null) {
throw new NotFoundError(`Secret not found: ${id}`);
}
return secret;
}
async getByName(name: string): Promise<Secret> {
const secret = await this.repo.findByName(name);
if (secret === null) {
throw new NotFoundError(`Secret not found: ${name}`);
}
return secret;
}
async create(input: unknown): Promise<Secret> {
const data = CreateSecretSchema.parse(input);
const existing = await this.repo.findByName(data.name);
if (existing !== null) {
throw new ConflictError(`Secret already exists: ${data.name}`);
}
return this.repo.create(data);
}
async update(id: string, input: unknown): Promise<Secret> {
const data = UpdateSecretSchema.parse(input);
// Verify exists
await this.getById(id);
return this.repo.update(id, data);
}
async delete(id: string): Promise<void> {
// Verify exists
await this.getById(id);
await this.repo.delete(id);
}
}

View File

@@ -0,0 +1,53 @@
import type { McpTemplate } from '@prisma/client';
import type { ITemplateRepository } from '../repositories/template.repository.js';
import { CreateTemplateSchema, UpdateTemplateSchema } from '../validation/template.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';
export class TemplateService {
constructor(private readonly repo: ITemplateRepository) {}
async list(namePattern?: string): Promise<McpTemplate[]> {
if (namePattern) {
return this.repo.search(namePattern);
}
return this.repo.findAll();
}
async getById(id: string): Promise<McpTemplate> {
const template = await this.repo.findById(id);
if (template === null) {
throw new NotFoundError(`Template not found: ${id}`);
}
return template;
}
async getByName(name: string): Promise<McpTemplate> {
const template = await this.repo.findByName(name);
if (template === null) {
throw new NotFoundError(`Template not found: ${name}`);
}
return template;
}
async create(input: unknown): Promise<McpTemplate> {
const data = CreateTemplateSchema.parse(input);
const existing = await this.repo.findByName(data.name);
if (existing !== null) {
throw new ConflictError(`Template already exists: ${data.name}`);
}
return this.repo.create(data);
}
async update(id: string, input: unknown): Promise<McpTemplate> {
const data = UpdateTemplateSchema.parse(input);
await this.getById(id);
return this.repo.update(id, data);
}
async delete(id: string): Promise<void> {
await this.getById(id);
await this.repo.delete(id);
}
}

View File

@@ -1,6 +1,4 @@
export { CreateMcpServerSchema, UpdateMcpServerSchema } from './mcp-server.schema.js';
export type { CreateMcpServerInput, UpdateMcpServerInput } from './mcp-server.schema.js';
export { CreateMcpProfileSchema, UpdateMcpProfileSchema } from './mcp-profile.schema.js';
export type { CreateMcpProfileInput, UpdateMcpProfileInput } from './mcp-profile.schema.js';
export { CreateProjectSchema, UpdateProjectSchema, UpdateProjectProfilesSchema } from './project.schema.js';
export type { CreateProjectInput, UpdateProjectInput, UpdateProjectProfilesInput } from './project.schema.js';
export { CreateProjectSchema, UpdateProjectSchema } from './project.schema.js';
export type { CreateProjectInput, UpdateProjectInput } from './project.schema.js';

View File

@@ -1,17 +0,0 @@
import { z } from 'zod';
export const CreateMcpProfileSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
serverId: z.string().min(1),
permissions: z.array(z.string()).default([]),
envOverrides: z.record(z.string()).default({}),
});
export const UpdateMcpProfileSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(),
permissions: z.array(z.string()).optional(),
envOverrides: z.record(z.string()).optional(),
});
export type CreateMcpProfileInput = z.infer<typeof CreateMcpProfileSchema>;
export type UpdateMcpProfileInput = z.infer<typeof UpdateMcpProfileSchema>;

View File

@@ -1,12 +1,24 @@
import { z } from 'zod';
import { HealthCheckSchema } from './template.schema.js';
const EnvTemplateEntrySchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).default(''),
isSecret: z.boolean().default(false),
setupUrl: z.string().url().optional(),
const SecretRefSchema = z.object({
name: z.string().min(1),
key: z.string().min(1),
});
export const ServerEnvEntrySchema = z.object({
name: z.string().min(1).max(100),
value: z.string().optional(),
valueFrom: z.object({
secretRef: SecretRefSchema,
}).optional(),
}).refine(
(e) => (e.value !== undefined) !== (e.valueFrom !== undefined),
{ message: 'Exactly one of value or valueFrom must be set' },
);
export type ServerEnvEntry = z.infer<typeof ServerEnvEntrySchema>;
export const CreateMcpServerSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
description: z.string().max(1000).default(''),
@@ -14,7 +26,12 @@ export const CreateMcpServerSchema = z.object({
dockerImage: z.string().max(200).optional(),
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
repositoryUrl: z.string().url().optional(),
envTemplate: z.array(EnvTemplateEntrySchema).default([]),
externalUrl: z.string().url().optional(),
command: z.array(z.string()).optional(),
containerPort: z.number().int().min(1).max(65535).optional(),
replicas: z.number().int().min(0).max(10).default(1),
env: z.array(ServerEnvEntrySchema).default([]),
healthCheck: HealthCheckSchema.optional(),
});
export const UpdateMcpServerSchema = z.object({
@@ -23,7 +40,12 @@ export const UpdateMcpServerSchema = z.object({
dockerImage: z.string().max(200).nullable().optional(),
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).optional(),
repositoryUrl: z.string().url().nullable().optional(),
envTemplate: z.array(EnvTemplateEntrySchema).optional(),
externalUrl: z.string().url().nullable().optional(),
command: z.array(z.string()).nullable().optional(),
containerPort: z.number().int().min(1).max(65535).nullable().optional(),
replicas: z.number().int().min(0).max(10).optional(),
env: z.array(ServerEnvEntrySchema).optional(),
healthCheck: HealthCheckSchema.nullable().optional(),
});
export type CreateMcpServerInput = z.infer<typeof CreateMcpServerSchema>;

View File

@@ -9,10 +9,5 @@ export const UpdateProjectSchema = z.object({
description: z.string().max(1000).optional(),
});
export const UpdateProjectProfilesSchema = z.object({
profileIds: z.array(z.string().min(1)).min(0),
});
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>;
export type UpdateProjectProfilesInput = z.infer<typeof UpdateProjectProfilesSchema>;

View File

@@ -0,0 +1,13 @@
import { z } from 'zod';
export const CreateSecretSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
data: z.record(z.string()).default({}),
});
export const UpdateSecretSchema = z.object({
data: z.record(z.string()),
});
export type CreateSecretInput = z.infer<typeof CreateSecretSchema>;
export type UpdateSecretInput = z.infer<typeof UpdateSecretSchema>;

View File

@@ -0,0 +1,39 @@
import { z } from 'zod';
const TemplateEnvEntrySchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
required: z.boolean().optional(),
defaultValue: z.string().optional(),
});
export const HealthCheckSchema = z.object({
tool: z.string().min(1),
arguments: z.record(z.unknown()).default({}),
intervalSeconds: z.number().int().min(5).max(3600).default(60),
timeoutSeconds: z.number().int().min(1).max(120).default(10),
failureThreshold: z.number().int().min(1).max(20).default(3),
});
export type HealthCheckInput = z.infer<typeof HealthCheckSchema>;
export const CreateTemplateSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
version: z.string().default('1.0.0'),
description: z.string().default(''),
packageName: z.string().optional(),
dockerImage: z.string().optional(),
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
repositoryUrl: z.string().optional(),
externalUrl: z.string().optional(),
command: z.array(z.string()).optional(),
containerPort: z.number().int().min(1).max(65535).optional(),
replicas: z.number().int().min(0).max(10).default(1),
env: z.array(TemplateEnvEntrySchema).default([]),
healthCheck: HealthCheckSchema.optional(),
});
export const UpdateTemplateSchema = CreateTemplateSchema.partial().omit({ name: true });
export type CreateTemplateInput = z.infer<typeof CreateTemplateSchema>;
export type UpdateTemplateInput = z.infer<typeof UpdateTemplateSchema>;

View File

@@ -4,7 +4,7 @@ import { BackupService } from '../src/services/backup/backup-service.js';
import { RestoreService } from '../src/services/backup/restore-service.js';
import { encrypt, decrypt, isSensitiveKey } from '../src/services/backup/crypto.js';
import { registerBackupRoutes } from '../src/routes/backup.js';
import type { IMcpServerRepository, IMcpProfileRepository } from '../src/repositories/interfaces.js';
import type { IMcpServerRepository, ISecretRepository } from '../src/repositories/interfaces.js';
import type { IProjectRepository } from '../src/repositories/project.repository.js';
// Mock data
@@ -12,19 +12,19 @@ const mockServers = [
{
id: 's1', name: 'github', description: 'GitHub MCP', packageName: '@mcp/github',
dockerImage: null, transport: 'STDIO' as const, repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
env: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
},
{
id: 's2', name: 'slack', description: 'Slack MCP', packageName: null,
dockerImage: 'mcp/slack:latest', transport: 'SSE' as const, repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
env: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
},
];
const mockProfiles = [
const mockSecrets = [
{
id: 'p1', name: 'default', serverId: 's1', permissions: ['read'],
envOverrides: { GITHUB_TOKEN: 'ghp_secret123' },
id: 'sec1', name: 'github-secrets',
data: { GITHUB_TOKEN: 'ghp_secret123' },
version: 1, createdAt: new Date(), updatedAt: new Date(),
},
];
@@ -41,19 +41,19 @@ function mockServerRepo(): IMcpServerRepository {
findAll: vi.fn(async () => [...mockServers]),
findById: vi.fn(async (id: string) => mockServers.find((s) => s.id === id) ?? null),
findByName: vi.fn(async (name: string) => mockServers.find((s) => s.name === name) ?? null),
create: vi.fn(async (data) => ({ id: 'new-s', ...data, envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockServers[0])),
create: vi.fn(async (data) => ({ id: 'new-s', ...data, env: [], version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockServers[0])),
update: vi.fn(async (id, data) => ({ ...mockServers.find((s) => s.id === id)!, ...data })),
delete: vi.fn(async () => {}),
};
}
function mockProfileRepo(): IMcpProfileRepository {
function mockSecretRepo(): ISecretRepository {
return {
findAll: vi.fn(async () => [...mockProfiles]),
findById: vi.fn(async (id: string) => mockProfiles.find((p) => p.id === id) ?? null),
findByServerAndName: vi.fn(async () => null),
create: vi.fn(async (data) => ({ id: 'new-p', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProfiles[0])),
update: vi.fn(async (id, data) => ({ ...mockProfiles.find((p) => p.id === id)!, ...data })),
findAll: vi.fn(async () => [...mockSecrets]),
findById: vi.fn(async (id: string) => mockSecrets.find((s) => s.id === id) ?? null),
findByName: vi.fn(async (name: string) => mockSecrets.find((s) => s.name === name) ?? null),
create: vi.fn(async (data) => ({ id: 'new-sec', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockSecrets[0])),
update: vi.fn(async (id, data) => ({ ...mockSecrets.find((s) => s.id === id)!, ...data })),
delete: vi.fn(async () => {}),
};
}
@@ -66,8 +66,6 @@ function mockProjectRepo(): IProjectRepository {
create: vi.fn(async (data) => ({ id: 'new-proj', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProjects[0])),
update: vi.fn(async (id, data) => ({ ...mockProjects.find((p) => p.id === id)!, ...data })),
delete: vi.fn(async () => {}),
setProfiles: vi.fn(async () => {}),
getProfileIds: vi.fn(async () => ['p1']),
};
}
@@ -112,7 +110,7 @@ describe('BackupService', () => {
let backupService: BackupService;
beforeEach(() => {
backupService = new BackupService(mockServerRepo(), mockProfileRepo(), mockProjectRepo());
backupService = new BackupService(mockServerRepo(), mockProjectRepo(), mockSecretRepo());
});
it('creates backup with all resources', async () => {
@@ -121,43 +119,43 @@ describe('BackupService', () => {
expect(bundle.version).toBe('1');
expect(bundle.encrypted).toBe(false);
expect(bundle.servers).toHaveLength(2);
expect(bundle.profiles).toHaveLength(1);
expect(bundle.secrets).toHaveLength(1);
expect(bundle.projects).toHaveLength(1);
expect(bundle.servers[0]!.name).toBe('github');
expect(bundle.profiles[0]!.serverName).toBe('github');
expect(bundle.secrets[0]!.name).toBe('github-secrets');
expect(bundle.projects[0]!.name).toBe('my-project');
});
it('filters resources', async () => {
const bundle = await backupService.createBackup({ resources: ['servers'] });
expect(bundle.servers).toHaveLength(2);
expect(bundle.profiles).toHaveLength(0);
expect(bundle.secrets).toHaveLength(0);
expect(bundle.projects).toHaveLength(0);
});
it('encrypts sensitive env values when password provided', async () => {
it('encrypts sensitive secret values when password provided', async () => {
const bundle = await backupService.createBackup({ password: 'test-pass' });
expect(bundle.encrypted).toBe(true);
expect(bundle.encryptedSecrets).toBeDefined();
// The GITHUB_TOKEN should be replaced with placeholder
const overrides = bundle.profiles[0]!.envOverrides as Record<string, string>;
expect(overrides['GITHUB_TOKEN']).toContain('__ENCRYPTED:');
const data = bundle.secrets[0]!.data;
expect(data['GITHUB_TOKEN']).toContain('__ENCRYPTED:');
});
it('handles empty repositories', async () => {
const emptyServerRepo = mockServerRepo();
(emptyServerRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const emptyProfileRepo = mockProfileRepo();
(emptyProfileRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const emptySecretRepo = mockSecretRepo();
(emptySecretRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const emptyProjectRepo = mockProjectRepo();
(emptyProjectRepo.findAll as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const service = new BackupService(emptyServerRepo, emptyProfileRepo, emptyProjectRepo);
const service = new BackupService(emptyServerRepo, emptyProjectRepo, emptySecretRepo);
const bundle = await service.createBackup();
expect(bundle.servers).toHaveLength(0);
expect(bundle.profiles).toHaveLength(0);
expect(bundle.secrets).toHaveLength(0);
expect(bundle.projects).toHaveLength(0);
});
});
@@ -165,18 +163,18 @@ describe('BackupService', () => {
describe('RestoreService', () => {
let restoreService: RestoreService;
let serverRepo: IMcpServerRepository;
let profileRepo: IMcpProfileRepository;
let secretRepo: ISecretRepository;
let projectRepo: IProjectRepository;
beforeEach(() => {
serverRepo = mockServerRepo();
profileRepo = mockProfileRepo();
secretRepo = mockSecretRepo();
projectRepo = mockProjectRepo();
// Default: nothing exists yet
(serverRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
(profileRepo.findByServerAndName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
(secretRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
(projectRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
restoreService = new RestoreService(serverRepo, profileRepo, projectRepo);
restoreService = new RestoreService(serverRepo, projectRepo, secretRepo);
});
const validBundle = {
@@ -184,9 +182,9 @@ describe('RestoreService', () => {
mcpctlVersion: '0.1.0',
createdAt: new Date().toISOString(),
encrypted: false,
servers: [{ name: 'github', description: 'GitHub', packageName: null, dockerImage: null, transport: 'STDIO', repositoryUrl: null, envTemplate: [] }],
profiles: [{ name: 'default', serverName: 'github', permissions: ['read'], envOverrides: {} }],
projects: [{ name: 'test-proj', description: 'Test', profileNames: ['default'] }],
servers: [{ name: 'github', description: 'GitHub', packageName: null, dockerImage: null, transport: 'STDIO', repositoryUrl: null, env: [] }],
secrets: [{ name: 'github-secrets', data: { GITHUB_TOKEN: 'ghp_123' } }],
projects: [{ name: 'test-proj', description: 'Test' }],
};
it('validates valid bundle', () => {
@@ -203,11 +201,11 @@ describe('RestoreService', () => {
const result = await restoreService.restore(validBundle);
expect(result.serversCreated).toBe(1);
expect(result.profilesCreated).toBe(1);
expect(result.secretsCreated).toBe(1);
expect(result.projectsCreated).toBe(1);
expect(result.errors).toHaveLength(0);
expect(serverRepo.create).toHaveBeenCalled();
expect(profileRepo.create).toHaveBeenCalled();
expect(secretRepo.create).toHaveBeenCalled();
expect(projectRepo.create).toHaveBeenCalled();
});
@@ -242,17 +240,17 @@ describe('RestoreService', () => {
});
it('restores encrypted bundle with correct password', async () => {
const secrets = { 'profile:default:API_KEY': 'secret-val' };
const encryptedData = { 'secret:github-secrets:GITHUB_TOKEN': 'ghp_decrypted' };
const encBundle = {
...validBundle,
encrypted: true,
encryptedSecrets: encrypt(JSON.stringify(secrets), 'test-pw'),
profiles: [{ ...validBundle.profiles[0]!, envOverrides: { API_KEY: '__ENCRYPTED:profile:default:API_KEY__' } }],
encryptedSecrets: encrypt(JSON.stringify(encryptedData), 'test-pw'),
secrets: [{ name: 'github-secrets', data: { GITHUB_TOKEN: '__ENCRYPTED:secret:github-secrets:GITHUB_TOKEN__' } }],
};
const result = await restoreService.restore(encBundle, { password: 'test-pw' });
expect(result.errors).toHaveLength(0);
expect(result.profilesCreated).toBe(1);
expect(result.secretsCreated).toBe(1);
});
it('fails with wrong decryption password', async () => {
@@ -272,17 +270,17 @@ describe('Backup Routes', () => {
beforeEach(() => {
const sRepo = mockServerRepo();
const pRepo = mockProfileRepo();
const secRepo = mockSecretRepo();
const prRepo = mockProjectRepo();
backupService = new BackupService(sRepo, pRepo, prRepo);
backupService = new BackupService(sRepo, prRepo, secRepo);
const rSRepo = mockServerRepo();
(rSRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const rPRepo = mockProfileRepo();
(rPRepo.findByServerAndName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const rSecRepo = mockSecretRepo();
(rSecRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const rPrRepo = mockProjectRepo();
(rPrRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
restoreService = new RestoreService(rSRepo, rPRepo, rPrRepo);
restoreService = new RestoreService(rSRepo, rPrRepo, rSecRepo);
});
async function buildApp() {
@@ -303,7 +301,7 @@ describe('Backup Routes', () => {
const body = res.json();
expect(body.version).toBe('1');
expect(body.servers).toBeDefined();
expect(body.profiles).toBeDefined();
expect(body.secrets).toBeDefined();
expect(body.projects).toBeDefined();
});

View File

@@ -0,0 +1,112 @@
import { describe, it, expect, vi } from 'vitest';
import { resolveServerEnv } from '../src/services/env-resolver.js';
import type { ISecretRepository } from '../src/repositories/interfaces.js';
import type { McpServer } from '@prisma/client';
function makeServer(env: unknown[]): McpServer {
return {
id: 'srv-1',
name: 'test-server',
description: '',
packageName: null,
dockerImage: 'test:latest',
transport: 'STDIO',
repositoryUrl: null,
externalUrl: null,
command: null,
containerPort: null,
replicas: 1,
env,
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
} as McpServer;
}
function mockSecretRepo(secrets: Record<string, Record<string, string>>): ISecretRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async (name: string) => {
const data = secrets[name];
if (!data) return null;
return { id: `sec-${name}`, name, data, version: 1, createdAt: new Date(), updatedAt: new Date() };
}),
create: vi.fn(async () => ({} as never)),
update: vi.fn(async () => ({} as never)),
delete: vi.fn(async () => {}),
};
}
describe('resolveServerEnv', () => {
it('resolves inline values', async () => {
const server = makeServer([
{ name: 'FOO', value: 'bar' },
{ name: 'BAZ', value: 'qux' },
]);
const repo = mockSecretRepo({});
const result = await resolveServerEnv(server, repo);
expect(result).toEqual({ FOO: 'bar', BAZ: 'qux' });
});
it('resolves secret references', async () => {
const server = makeServer([
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'ha-creds', key: 'HOMEASSISTANT_TOKEN' } } },
]);
const repo = mockSecretRepo({
'ha-creds': { HOMEASSISTANT_TOKEN: 'secret-token-123' },
});
const result = await resolveServerEnv(server, repo);
expect(result).toEqual({ TOKEN: 'secret-token-123' });
});
it('handles mixed inline and secret refs', async () => {
const server = makeServer([
{ name: 'URL', value: 'https://ha.local' },
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'creds', key: 'TOKEN' } } },
]);
const repo = mockSecretRepo({
creds: { TOKEN: 'my-token' },
});
const result = await resolveServerEnv(server, repo);
expect(result).toEqual({ URL: 'https://ha.local', TOKEN: 'my-token' });
});
it('caches secret lookups', async () => {
const server = makeServer([
{ name: 'A', valueFrom: { secretRef: { name: 'shared', key: 'KEY_A' } } },
{ name: 'B', valueFrom: { secretRef: { name: 'shared', key: 'KEY_B' } } },
]);
const repo = mockSecretRepo({
shared: { KEY_A: 'val-a', KEY_B: 'val-b' },
});
const result = await resolveServerEnv(server, repo);
expect(result).toEqual({ A: 'val-a', B: 'val-b' });
expect(repo.findByName).toHaveBeenCalledTimes(1);
});
it('throws when secret not found', async () => {
const server = makeServer([
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'missing', key: 'TOKEN' } } },
]);
const repo = mockSecretRepo({});
await expect(resolveServerEnv(server, repo)).rejects.toThrow("Secret 'missing' not found");
});
it('throws when secret key not found', async () => {
const server = makeServer([
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'creds', key: 'NONEXISTENT' } } },
]);
const repo = mockSecretRepo({
creds: { OTHER_KEY: 'val' },
});
await expect(resolveServerEnv(server, repo)).rejects.toThrow("Key 'NONEXISTENT' not found in secret 'creds'");
});
it('returns empty map for empty env', async () => {
const server = makeServer([]);
const repo = mockSecretRepo({});
const result = await resolveServerEnv(server, repo);
expect(result).toEqual({});
});
});

View File

@@ -3,6 +3,7 @@ import { InstanceService, InvalidStateError } from '../src/services/instance.ser
import { NotFoundError } from '../src/services/mcp-server.service.js';
import type { IMcpInstanceRepository, IMcpServerRepository } from '../src/repositories/interfaces.js';
import type { McpOrchestrator } from '../src/services/orchestrator.js';
import type { McpInstance } from '@prisma/client';
function mockInstanceRepo(): IMcpInstanceRepository {
return {
@@ -69,6 +70,41 @@ function mockOrchestrator(): McpOrchestrator {
};
}
function makeServer(overrides: Partial<{ id: string; name: string; replicas: number; dockerImage: string | null; externalUrl: string | null; transport: string; command: unknown; containerPort: number | null }> = {}) {
return {
id: overrides.id ?? 'srv-1',
name: overrides.name ?? 'slack',
dockerImage: overrides.dockerImage ?? 'ghcr.io/slack-mcp:latest',
packageName: null,
transport: overrides.transport ?? 'STDIO',
description: '',
repositoryUrl: null,
externalUrl: overrides.externalUrl ?? null,
command: overrides.command ?? null,
containerPort: overrides.containerPort ?? null,
replicas: overrides.replicas ?? 1,
env: [],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
};
}
function makeInstance(overrides: Partial<McpInstance> = {}): McpInstance {
return {
id: 'inst-1',
serverId: 'srv-1',
containerId: overrides.containerId ?? 'ctr-abc',
status: overrides.status ?? 'RUNNING',
port: overrides.port ?? 3000,
metadata: overrides.metadata ?? {},
version: 1,
createdAt: overrides.createdAt ?? new Date(),
updatedAt: new Date(),
...overrides,
} as McpInstance;
}
describe('InstanceService', () => {
let instanceRepo: ReturnType<typeof mockInstanceRepo>;
let serverRepo: ReturnType<typeof mockServerRepo>;
@@ -101,199 +137,98 @@ describe('InstanceService', () => {
});
it('returns instance when found', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({ id: 'inst-1' } as never);
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ id: 'inst-1' }));
const result = await service.getById('inst-1');
expect(result.id).toBe('inst-1');
});
});
describe('start', () => {
describe('reconcile', () => {
it('starts instances when below desired replicas', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 2 }));
vi.mocked(instanceRepo.findAll).mockResolvedValue([]);
await service.reconcile('srv-1');
// Should create 2 instances
expect(instanceRepo.create).toHaveBeenCalledTimes(2);
});
it('does nothing when at desired replicas', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 1 }));
vi.mocked(instanceRepo.findAll).mockResolvedValue([makeInstance({ status: 'RUNNING' })]);
await service.reconcile('srv-1');
expect(instanceRepo.create).not.toHaveBeenCalled();
expect(instanceRepo.delete).not.toHaveBeenCalled();
});
it('removes excess instances when above desired replicas', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 1 }));
vi.mocked(instanceRepo.findAll).mockResolvedValue([
makeInstance({ id: 'inst-old', createdAt: new Date('2025-01-01') }),
makeInstance({ id: 'inst-new', createdAt: new Date('2025-06-01') }),
]);
await service.reconcile('srv-1');
// Should remove the oldest one
expect(orchestrator.stopContainer).toHaveBeenCalledTimes(1);
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-old');
});
it('creates external instances without Docker', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue(
makeServer({ replicas: 1, externalUrl: 'http://localhost:8086/mcp', dockerImage: null }),
);
vi.mocked(instanceRepo.findAll).mockResolvedValue([]);
await service.reconcile('srv-1');
expect(instanceRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ status: 'RUNNING', metadata: expect.objectContaining({ external: true }) }),
);
expect(orchestrator.createContainer).not.toHaveBeenCalled();
});
it('handles replicas: 0 by removing all instances', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue(makeServer({ replicas: 0 }));
vi.mocked(instanceRepo.findAll).mockResolvedValue([makeInstance()]);
await service.reconcile('srv-1');
expect(instanceRepo.delete).toHaveBeenCalledTimes(1);
});
it('throws NotFoundError for unknown server', async () => {
await expect(service.start('missing')).rejects.toThrow(NotFoundError);
});
it('creates instance and starts container', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue({
id: 'srv-1', name: 'slack', dockerImage: 'ghcr.io/slack-mcp:latest',
packageName: null, transport: 'STDIO', description: '', repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
});
const result = await service.start('srv-1');
expect(instanceRepo.create).toHaveBeenCalledWith({
serverId: 'srv-1',
status: 'STARTING',
});
expect(orchestrator.createContainer).toHaveBeenCalled();
expect(instanceRepo.updateStatus).toHaveBeenCalledWith(
'inst-1', 'RUNNING',
expect.objectContaining({ containerId: 'ctr-abc123' }),
);
expect(result.status).toBe('RUNNING');
});
it('marks instance as ERROR on container failure', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue({
id: 'srv-1', name: 'slack', dockerImage: 'ghcr.io/slack-mcp:latest',
packageName: null, transport: 'STDIO', description: '', repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
});
vi.mocked(orchestrator.createContainer).mockRejectedValue(new Error('Docker unavailable'));
const result = await service.start('srv-1');
expect(instanceRepo.updateStatus).toHaveBeenCalledWith(
'inst-1', 'ERROR',
expect.objectContaining({ metadata: { error: 'Docker unavailable' } }),
);
expect(result.status).toBe('ERROR');
});
it('uses dockerImage for container spec', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue({
id: 'srv-1', name: 'slack', dockerImage: 'myregistry.com/slack:v1',
packageName: '@slack/mcp', transport: 'SSE', description: '', repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
});
await service.start('srv-1');
const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]?.[0];
expect(spec?.image).toBe('myregistry.com/slack:v1');
expect(spec?.containerPort).toBe(3000); // SSE transport
});
});
describe('stop', () => {
it('throws NotFoundError for missing instance', async () => {
await expect(service.stop('missing')).rejects.toThrow(NotFoundError);
});
it('stops a running container', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING',
serverId: 'srv-1', port: 3000, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
await service.stop('inst-1');
expect(orchestrator.stopContainer).toHaveBeenCalledWith('ctr-abc');
expect(instanceRepo.updateStatus).toHaveBeenCalledWith('inst-1', 'STOPPED');
});
it('handles stop without containerId', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: null, status: 'ERROR',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
await service.stop('inst-1');
expect(orchestrator.stopContainer).not.toHaveBeenCalled();
expect(instanceRepo.updateStatus).toHaveBeenCalledWith('inst-1', 'STOPPED');
});
it('throws InvalidStateError when already stopped', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
await expect(service.stop('inst-1')).rejects.toThrow(InvalidStateError);
});
});
describe('restart', () => {
it('stops, removes, and starts a new instance', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING',
serverId: 'srv-1', port: 3000, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
vi.mocked(serverRepo.findById).mockResolvedValue({
id: 'srv-1', name: 'slack', dockerImage: 'slack:latest',
packageName: null, transport: 'STDIO', description: '', repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
});
const result = await service.restart('inst-1');
expect(orchestrator.stopContainer).toHaveBeenCalledWith('ctr-abc');
expect(orchestrator.removeContainer).toHaveBeenCalledWith('ctr-abc', true);
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
expect(instanceRepo.create).toHaveBeenCalled();
expect(result.status).toBe('RUNNING');
});
it('handles restart when container already stopped', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
vi.mocked(serverRepo.findById).mockResolvedValue({
id: 'srv-1', name: 'slack', dockerImage: 'slack:latest',
packageName: null, transport: 'STDIO', description: '', repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
});
const result = await service.restart('inst-1');
// Should not try to stop an already-stopped container
expect(orchestrator.stopContainer).not.toHaveBeenCalled();
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
expect(result.status).toBe('RUNNING');
});
});
describe('inspect', () => {
it('returns container info', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING',
serverId: 'srv-1', port: 3000, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
const result = await service.inspect('inst-1');
expect(orchestrator.inspectContainer).toHaveBeenCalledWith('ctr-abc');
expect(result.containerId).toBe('ctr-abc123');
});
it('throws InvalidStateError when no container', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: null, status: 'ERROR',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
await expect(service.inspect('inst-1')).rejects.toThrow(InvalidStateError);
await expect(service.reconcile('missing')).rejects.toThrow(NotFoundError);
});
});
describe('remove', () => {
it('removes container and DB record', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
it('stops container and deletes DB record', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' }));
const result = await service.remove('inst-1');
expect(orchestrator.stopContainer).toHaveBeenCalledWith('ctr-abc');
expect(orchestrator.removeContainer).toHaveBeenCalledWith('ctr-abc', true);
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
expect(result.serverId).toBe('srv-1');
});
it('deletes DB record for external instance (no container)', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: null }));
await service.remove('inst-1');
expect(orchestrator.removeContainer).toHaveBeenCalledWith('ctr-abc', true);
expect(orchestrator.stopContainer).not.toHaveBeenCalled();
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
});
it('removes DB record even if container is already gone', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
it('deletes DB record even if container is already gone', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' }));
vi.mocked(orchestrator.removeContainer).mockRejectedValue(new Error('No such container'));
await service.remove('inst-1');
@@ -302,24 +237,56 @@ describe('InstanceService', () => {
});
});
describe('removeAllForServer', () => {
it('stops all containers for a server', async () => {
vi.mocked(instanceRepo.findAll).mockResolvedValue([
makeInstance({ id: 'inst-1', containerId: 'ctr-1' }),
makeInstance({ id: 'inst-2', containerId: 'ctr-2' }),
]);
await service.removeAllForServer('srv-1');
expect(orchestrator.stopContainer).toHaveBeenCalledTimes(2);
expect(orchestrator.removeContainer).toHaveBeenCalledTimes(2);
});
it('skips external instances with no container', async () => {
vi.mocked(instanceRepo.findAll).mockResolvedValue([
makeInstance({ id: 'inst-1', containerId: null }),
]);
await service.removeAllForServer('srv-1');
expect(orchestrator.stopContainer).not.toHaveBeenCalled();
});
});
describe('inspect', () => {
it('returns container info', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' }));
const result = await service.inspect('inst-1');
expect(orchestrator.inspectContainer).toHaveBeenCalledWith('ctr-abc');
expect(result.containerId).toBe('ctr-abc123');
});
it('throws InvalidStateError when no container', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: null }));
await expect(service.inspect('inst-1')).rejects.toThrow(InvalidStateError);
});
});
describe('getLogs', () => {
it('returns empty logs for instance without container', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: null, status: 'ERROR',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: null }));
const result = await service.getLogs('inst-1');
expect(result).toEqual({ stdout: '', stderr: '' });
});
it('returns container logs', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING',
serverId: 'srv-1', port: 3000, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
vi.mocked(instanceRepo.findById).mockResolvedValue(makeInstance({ containerId: 'ctr-abc' }));
const result = await service.getLogs('inst-1', { tail: 50 });

View File

@@ -1,22 +1,8 @@
import { describe, it, expect } from 'vitest';
import { generateMcpConfig } from '../src/services/mcp-config-generator.js';
import type { ProfileWithServer } from '../src/services/mcp-config-generator.js';
import type { McpServer } from '@prisma/client';
function makeProfile(overrides: Partial<ProfileWithServer['profile']> = {}): ProfileWithServer['profile'] {
return {
id: 'p1',
name: 'default',
serverId: 's1',
permissions: [],
envOverrides: {},
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function makeServer(overrides: Partial<ProfileWithServer['server']> = {}): ProfileWithServer['server'] {
function makeServer(overrides: Partial<McpServer> = {}): McpServer {
return {
id: 's1',
name: 'slack',
@@ -25,7 +11,7 @@ function makeServer(overrides: Partial<ProfileWithServer['server']> = {}): Profi
dockerImage: null,
transport: 'STDIO',
repositoryUrl: null,
envTemplate: [],
env: [],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
@@ -34,76 +20,51 @@ function makeServer(overrides: Partial<ProfileWithServer['server']> = {}): Profi
}
describe('generateMcpConfig', () => {
it('returns empty mcpServers for empty profiles', () => {
it('returns empty mcpServers for empty input', () => {
const result = generateMcpConfig([]);
expect(result).toEqual({ mcpServers: {} });
});
it('generates config for a single profile', () => {
it('generates config for a single server', () => {
const result = generateMcpConfig([
{ profile: makeProfile(), server: makeServer() },
{ server: makeServer(), resolvedEnv: {} },
]);
expect(result.mcpServers['slack--default']).toBeDefined();
expect(result.mcpServers['slack--default']?.command).toBe('npx');
expect(result.mcpServers['slack--default']?.args).toEqual(['-y', '@anthropic/slack-mcp']);
expect(result.mcpServers['slack']).toBeDefined();
expect(result.mcpServers['slack']?.command).toBe('npx');
expect(result.mcpServers['slack']?.args).toEqual(['-y', '@anthropic/slack-mcp']);
});
it('excludes secret env vars from output', () => {
const server = makeServer({
envTemplate: [
{ name: 'SLACK_BOT_TOKEN', description: 'Token', isSecret: true },
{ name: 'SLACK_TEAM_ID', description: 'Team', isSecret: false, defaultValue: 'T123' },
] as never,
});
it('includes resolved env when present', () => {
const result = generateMcpConfig([
{ profile: makeProfile(), server },
{ server: makeServer(), resolvedEnv: { SLACK_TEAM_ID: 'T123' } },
]);
const config = result.mcpServers['slack--default'];
const config = result.mcpServers['slack'];
expect(config?.env).toBeDefined();
expect(config?.env?.['SLACK_TEAM_ID']).toBe('T123');
expect(config?.env?.['SLACK_BOT_TOKEN']).toBeUndefined();
});
it('applies env overrides from profile (non-secret only)', () => {
const server = makeServer({
envTemplate: [
{ name: 'API_URL', description: 'URL', isSecret: false },
] as never,
});
const profile = makeProfile({
envOverrides: { API_URL: 'https://staging.example.com' } as never,
});
const result = generateMcpConfig([{ profile, server }]);
expect(result.mcpServers['slack--default']?.env?.['API_URL']).toBe('https://staging.example.com');
it('omits env when resolvedEnv is empty', () => {
const result = generateMcpConfig([
{ server: makeServer(), resolvedEnv: {} },
]);
expect(result.mcpServers['slack']?.env).toBeUndefined();
});
it('generates multiple server configs', () => {
const result = generateMcpConfig([
{ profile: makeProfile({ name: 'readonly' }), server: makeServer({ name: 'slack' }) },
{ profile: makeProfile({ name: 'default', id: 'p2' }), server: makeServer({ name: 'github', id: 's2', packageName: '@anthropic/github-mcp' }) },
{ server: makeServer({ name: 'slack' }), resolvedEnv: {} },
{ server: makeServer({ name: 'github', id: 's2', packageName: '@anthropic/github-mcp' }), resolvedEnv: {} },
]);
expect(Object.keys(result.mcpServers)).toHaveLength(2);
expect(result.mcpServers['slack--readonly']).toBeDefined();
expect(result.mcpServers['github--default']).toBeDefined();
});
it('omits env when no non-secret vars have values', () => {
const server = makeServer({
envTemplate: [
{ name: 'TOKEN', description: 'Secret', isSecret: true },
] as never,
});
const result = generateMcpConfig([
{ profile: makeProfile(), server },
]);
expect(result.mcpServers['slack--default']?.env).toBeUndefined();
expect(result.mcpServers['slack']).toBeDefined();
expect(result.mcpServers['github']).toBeDefined();
});
it('uses server name as fallback when packageName is null', () => {
const server = makeServer({ packageName: null });
const result = generateMcpConfig([
{ profile: makeProfile(), server },
{ server, resolvedEnv: {} },
]);
expect(result.mcpServers['slack--default']?.args).toEqual(['-y', 'slack']);
expect(result.mcpServers['slack']?.args).toEqual(['-y', 'slack']);
});
});

View File

@@ -1,128 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { McpProfileService } from '../src/services/mcp-profile.service.js';
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import type { IMcpProfileRepository, IMcpServerRepository } from '../src/repositories/interfaces.js';
function mockProfileRepo(): IMcpProfileRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByServerAndName: vi.fn(async () => null),
create: vi.fn(async (data) => ({
id: 'new-id',
name: data.name,
serverId: data.serverId,
permissions: data.permissions ?? [],
envOverrides: data.envOverrides ?? {},
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
})),
update: vi.fn(async (id, data) => ({
id,
name: data.name ?? 'test',
serverId: 'srv-1',
permissions: data.permissions ?? [],
envOverrides: data.envOverrides ?? {},
version: 2,
createdAt: new Date(),
updatedAt: new Date(),
})),
delete: vi.fn(async () => {}),
};
}
function mockServerRepo(): IMcpServerRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async () => ({} as never)),
update: vi.fn(async () => ({} as never)),
delete: vi.fn(async () => {}),
};
}
describe('McpProfileService', () => {
let profileRepo: ReturnType<typeof mockProfileRepo>;
let serverRepo: ReturnType<typeof mockServerRepo>;
let service: McpProfileService;
beforeEach(() => {
profileRepo = mockProfileRepo();
serverRepo = mockServerRepo();
service = new McpProfileService(profileRepo, serverRepo);
});
describe('list', () => {
it('returns all profiles', async () => {
await service.list();
expect(profileRepo.findAll).toHaveBeenCalledWith(undefined);
});
it('filters by serverId', async () => {
await service.list('srv-1');
expect(profileRepo.findAll).toHaveBeenCalledWith('srv-1');
});
});
describe('getById', () => {
it('returns profile when found', async () => {
vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1', name: 'test' } as never);
const result = await service.getById('1');
expect(result.id).toBe('1');
});
it('throws NotFoundError when not found', async () => {
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
it('creates a profile when server exists', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue({ id: 'srv-1', name: 'test' } as never);
const result = await service.create({ name: 'readonly', serverId: 'srv-1' });
expect(result.name).toBe('readonly');
});
it('throws NotFoundError when server does not exist', async () => {
await expect(service.create({ name: 'test', serverId: 'missing' })).rejects.toThrow(NotFoundError);
});
it('throws ConflictError when profile name exists for server', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue({ id: 'srv-1', name: 'test' } as never);
vi.mocked(profileRepo.findByServerAndName).mockResolvedValue({ id: '1' } as never);
await expect(service.create({ name: 'dup', serverId: 'srv-1' })).rejects.toThrow(ConflictError);
});
});
describe('update', () => {
it('updates an existing profile', async () => {
vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1', name: 'old', serverId: 'srv-1' } as never);
await service.update('1', { permissions: ['read'] });
expect(profileRepo.update).toHaveBeenCalled();
});
it('checks uniqueness when renaming', async () => {
vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1', name: 'old', serverId: 'srv-1' } as never);
vi.mocked(profileRepo.findByServerAndName).mockResolvedValue({ id: '2' } as never);
await expect(service.update('1', { name: 'taken' })).rejects.toThrow(ConflictError);
});
it('throws NotFoundError when profile does not exist', async () => {
await expect(service.update('missing', {})).rejects.toThrow(NotFoundError);
});
});
describe('delete', () => {
it('deletes an existing profile', async () => {
vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1' } as never);
await service.delete('1');
expect(profileRepo.delete).toHaveBeenCalledWith('1');
});
it('throws NotFoundError when profile does not exist', async () => {
await expect(service.delete('missing')).rejects.toThrow(NotFoundError);
});
});
});

View File

@@ -0,0 +1,727 @@
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import http from 'node:http';
import { McpServerService } from '../src/services/mcp-server.service.js';
import { InstanceService } from '../src/services/instance.service.js';
import { McpProxyService } from '../src/services/mcp-proxy-service.js';
import { AuditLogService } from '../src/services/audit-log.service.js';
import { errorHandler } from '../src/middleware/error-handler.js';
import { registerMcpServerRoutes } from '../src/routes/mcp-servers.js';
import { registerInstanceRoutes } from '../src/routes/instances.js';
import { registerMcpProxyRoutes } from '../src/routes/mcp-proxy.js';
import type {
IMcpServerRepository,
IMcpInstanceRepository,
IAuditLogRepository,
} from '../src/repositories/interfaces.js';
import type { McpOrchestrator } from '../src/services/orchestrator.js';
import type { McpServer, McpInstance, InstanceStatus } from '@prisma/client';
// ---------------------------------------------------------------------------
// In-memory repository implementations (stateful mocks)
// ---------------------------------------------------------------------------
function createInMemoryServerRepo(): IMcpServerRepository {
const servers = new Map<string, McpServer>();
let nextId = 1;
return {
findAll: vi.fn(async () => [...servers.values()]),
findById: vi.fn(async (id: string) => servers.get(id) ?? null),
findByName: vi.fn(async (name: string) => [...servers.values()].find((s) => s.name === name) ?? null),
create: vi.fn(async (data) => {
const id = `srv-${nextId++}`;
const server = {
id,
name: data.name,
description: data.description ?? '',
packageName: data.packageName ?? null,
dockerImage: data.dockerImage ?? null,
transport: data.transport ?? 'STDIO',
repositoryUrl: data.repositoryUrl ?? null,
externalUrl: data.externalUrl ?? null,
command: data.command ?? null,
containerPort: data.containerPort ?? null,
replicas: data.replicas ?? 1,
env: data.env ?? [],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
} as McpServer;
servers.set(id, server);
return server;
}),
update: vi.fn(async (id: string, data) => {
const existing = servers.get(id);
if (!existing) throw new Error(`Server ${id} not found`);
const updated = { ...existing, ...data, updatedAt: new Date() } as McpServer;
servers.set(id, updated);
return updated;
}),
delete: vi.fn(async (id: string) => {
servers.delete(id);
}),
};
}
function createInMemoryInstanceRepo(): IMcpInstanceRepository {
const instances = new Map<string, McpInstance>();
let nextId = 1;
return {
findAll: vi.fn(async (serverId?: string) => {
const all = [...instances.values()];
return serverId ? all.filter((i) => i.serverId === serverId) : all;
}),
findById: vi.fn(async (id: string) => instances.get(id) ?? null),
findByContainerId: vi.fn(async (containerId: string) =>
[...instances.values()].find((i) => i.containerId === containerId) ?? null,
),
create: vi.fn(async (data) => {
const id = `inst-${nextId++}`;
const instance = {
id,
serverId: data.serverId,
containerId: data.containerId ?? null,
status: (data.status ?? 'STOPPED') as InstanceStatus,
port: data.port ?? null,
metadata: data.metadata ?? {},
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
} as McpInstance;
instances.set(id, instance);
return instance;
}),
updateStatus: vi.fn(async (id: string, status: InstanceStatus, fields?) => {
const existing = instances.get(id);
if (!existing) throw new Error(`Instance ${id} not found`);
const updated = {
...existing,
status,
...(fields?.containerId !== undefined ? { containerId: fields.containerId } : {}),
...(fields?.port !== undefined ? { port: fields.port } : {}),
...(fields?.metadata !== undefined ? { metadata: fields.metadata } : {}),
version: existing.version + 1,
updatedAt: new Date(),
} as McpInstance;
instances.set(id, updated);
return updated;
}),
delete: vi.fn(async (id: string) => {
instances.delete(id);
}),
};
}
function createInMemoryAuditLogRepo(): IAuditLogRepository {
const logs: Array<{ id: string; userId: string; action: string; resource: string; resourceId: string | null; details: Record<string, unknown>; createdAt: Date }> = [];
let nextId = 1;
return {
findAll: vi.fn(async () => logs as never[]),
findById: vi.fn(async (id: string) => (logs.find((l) => l.id === id) as never) ?? null),
create: vi.fn(async (data) => {
const log = {
id: `log-${nextId++}`,
userId: data.userId,
action: data.action,
resource: data.resource,
resourceId: data.resourceId ?? null,
details: data.details ?? {},
createdAt: new Date(),
};
logs.push(log);
return log as never;
}),
count: vi.fn(async () => logs.length),
deleteOlderThan: vi.fn(async () => 0),
};
}
function createMockOrchestrator(): McpOrchestrator {
let containerPort = 40000;
return {
ping: vi.fn(async () => true),
pullImage: vi.fn(async () => {}),
createContainer: vi.fn(async (spec) => ({
containerId: `ctr-${spec.name}`,
name: spec.name,
state: 'running' as const,
port: spec.containerPort ?? ++containerPort,
createdAt: new Date(),
})),
stopContainer: vi.fn(async () => {}),
removeContainer: vi.fn(async () => {}),
inspectContainer: vi.fn(async (id) => ({
containerId: id,
name: 'test',
state: 'running' as const,
createdAt: new Date(),
})),
getContainerLogs: vi.fn(async () => ({ stdout: '', stderr: '' })),
};
}
// ---------------------------------------------------------------------------
// Fake MCP server (streamable-http)
// ---------------------------------------------------------------------------
function createFakeMcpServer(): { server: http.Server; getPort: () => number; requests: Array<{ method: string; body: unknown }> } {
const requests: Array<{ method: string; body: unknown }> = [];
let sessionCounter = 0;
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', () => {
let parsed: { method?: string; id?: number; params?: unknown } = {};
try {
parsed = JSON.parse(body);
} catch {
// notifications may not have id
}
requests.push({ method: parsed.method ?? 'unknown', body: parsed });
if (parsed.method === 'initialize') {
const sessionId = `session-${++sessionCounter}`;
const response = {
jsonrpc: '2.0',
id: parsed.id,
result: {
protocolVersion: '2025-03-26',
capabilities: { tools: {} },
serverInfo: { name: 'fake-mcp', version: '1.0.0' },
},
};
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Mcp-Session-Id': sessionId,
});
res.end(`event: message\ndata: ${JSON.stringify(response)}\n\n`);
return;
}
if (parsed.method === 'notifications/initialized') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end('');
return;
}
if (parsed.method === 'tools/list') {
const response = {
jsonrpc: '2.0',
id: parsed.id,
result: {
tools: [
{ name: 'ha_get_overview', description: 'Get Home Assistant overview', inputSchema: { type: 'object', properties: {} } },
{ name: 'ha_search_entities', description: 'Search HA entities', inputSchema: { type: 'object', properties: { query: { type: 'string' } } } },
],
},
};
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
res.end(`event: message\ndata: ${JSON.stringify(response)}\n\n`);
return;
}
if (parsed.method === 'tools/call') {
const toolName = (parsed.params as { name?: string })?.name;
const response = {
jsonrpc: '2.0',
id: parsed.id,
result: {
content: [{ type: 'text', text: `Result from ${toolName}` }],
},
};
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
res.end(`event: message\ndata: ${JSON.stringify(response)}\n\n`);
return;
}
// Default: echo back
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ jsonrpc: '2.0', id: parsed.id, result: {} }));
});
});
let port = 0;
return {
server,
getPort: () => port,
requests,
...{
listen: () =>
new Promise<void>((resolve) => {
server.listen(0, () => {
const addr = server.address();
if (addr && typeof addr === 'object') port = addr.port;
resolve();
});
}),
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
},
} as ReturnType<typeof createFakeMcpServer> & { listen: () => Promise<void>; close: () => Promise<void> };
}
// ---------------------------------------------------------------------------
// Test app builder
// ---------------------------------------------------------------------------
async function buildTestApp(deps: {
serverRepo: IMcpServerRepository;
instanceRepo: IMcpInstanceRepository;
auditLogRepo: IAuditLogRepository;
orchestrator: McpOrchestrator;
}): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
const serverService = new McpServerService(deps.serverRepo);
const instanceService = new InstanceService(deps.instanceRepo, deps.serverRepo, deps.orchestrator);
serverService.setInstanceService(instanceService);
const proxyService = new McpProxyService(deps.instanceRepo, deps.serverRepo);
const auditLogService = new AuditLogService(deps.auditLogRepo);
registerMcpServerRoutes(app, serverService, instanceService);
registerInstanceRoutes(app, instanceService);
registerMcpProxyRoutes(app, {
mcpProxyService: proxyService,
auditLogService,
authDeps: {
findSession: async () => ({ userId: 'test-user', expiresAt: new Date(Date.now() + 3600_000) }),
},
});
await app.ready();
return app;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('MCP server full flow', () => {
let fakeMcp: ReturnType<typeof createFakeMcpServer> & { listen: () => Promise<void>; close: () => Promise<void> };
let fakeMcpPort: number;
beforeAll(async () => {
fakeMcp = createFakeMcpServer() as typeof fakeMcp;
await fakeMcp.listen();
fakeMcpPort = fakeMcp.getPort();
});
afterAll(async () => {
await fakeMcp.close();
});
describe('external server flow (externalUrl)', () => {
let app: FastifyInstance;
let serverRepo: IMcpServerRepository;
let instanceRepo: IMcpInstanceRepository;
beforeEach(async () => {
serverRepo = createInMemoryServerRepo();
instanceRepo = createInMemoryInstanceRepo();
app = await buildTestApp({
serverRepo,
instanceRepo,
auditLogRepo: createInMemoryAuditLogRepo(),
orchestrator: createMockOrchestrator(),
});
});
afterAll(async () => {
if (app) await app.close();
});
it('registers server (auto-creates instance via reconcile), and proxies tools/list', async () => {
// 1. Register external MCP server (replicas defaults to 1 → auto-creates instance)
const createRes = await app.inject({
method: 'POST',
url: '/api/v1/servers',
payload: {
name: 'ha-mcp',
description: 'Home Assistant MCP',
transport: 'STREAMABLE_HTTP',
externalUrl: `http://localhost:${fakeMcpPort}`,
containerPort: 3000,
env: [
{ name: 'HOMEASSISTANT_TOKEN', value: 'placeholder' },
],
},
});
expect(createRes.statusCode).toBe(201);
const server = createRes.json<{ id: string; name: string; externalUrl: string }>();
expect(server.name).toBe('ha-mcp');
expect(server.externalUrl).toBe(`http://localhost:${fakeMcpPort}`);
// 2. Verify server is listed
const listRes = await app.inject({ method: 'GET', url: '/api/v1/servers' });
expect(listRes.statusCode).toBe(200);
const servers = listRes.json<Array<{ name: string }>>();
expect(servers).toHaveLength(1);
expect(servers[0]!.name).toBe('ha-mcp');
// 3. Verify instance was auto-created (no Docker for external servers)
const instancesRes = await app.inject({
method: 'GET',
url: `/api/v1/instances?serverId=${server.id}`,
});
expect(instancesRes.statusCode).toBe(200);
const instances = instancesRes.json<Array<{ id: string; status: string; containerId: string | null }>>();
expect(instances).toHaveLength(1);
expect(instances[0]!.status).toBe('RUNNING');
expect(instances[0]!.containerId).toBeNull();
// 4. Proxy tools/list to the fake MCP server
const proxyRes = await app.inject({
method: 'POST',
url: '/api/v1/mcp/proxy',
headers: { authorization: 'Bearer test-token' },
payload: {
serverId: server.id,
method: 'tools/list',
},
});
expect(proxyRes.statusCode).toBe(200);
const proxyBody = proxyRes.json<{ jsonrpc: string; result: { tools: Array<{ name: string }> } }>();
expect(proxyBody.jsonrpc).toBe('2.0');
expect(proxyBody.result.tools).toHaveLength(2);
expect(proxyBody.result.tools.map((t) => t.name)).toContain('ha_get_overview');
expect(proxyBody.result.tools.map((t) => t.name)).toContain('ha_search_entities');
// 5. Verify the fake server received the protocol handshake + tools/list
const methods = fakeMcp.requests.map((r) => r.method);
expect(methods).toContain('initialize');
expect(methods).toContain('notifications/initialized');
expect(methods).toContain('tools/list');
});
it('proxies tools/call with parameters', async () => {
// Register (auto-creates instance via reconcile)
const createRes = await app.inject({
method: 'POST',
url: '/api/v1/servers',
payload: {
name: 'ha-mcp-call',
description: 'HA MCP for call test',
transport: 'STREAMABLE_HTTP',
externalUrl: `http://localhost:${fakeMcpPort}`,
},
});
const server = createRes.json<{ id: string }>();
// Proxy tools/call (instance was auto-created)
const proxyRes = await app.inject({
method: 'POST',
url: '/api/v1/mcp/proxy',
headers: { authorization: 'Bearer test-token' },
payload: {
serverId: server.id,
method: 'tools/call',
params: { name: 'ha_get_overview' },
},
});
expect(proxyRes.statusCode).toBe(200);
const body = proxyRes.json<{ result: { content: Array<{ text: string }> } }>();
expect(body.result.content[0]!.text).toBe('Result from ha_get_overview');
});
});
describe('managed server flow (Docker)', () => {
let app: FastifyInstance;
let orchestrator: ReturnType<typeof createMockOrchestrator>;
beforeEach(async () => {
orchestrator = createMockOrchestrator();
app = await buildTestApp({
serverRepo: createInMemoryServerRepo(),
instanceRepo: createInMemoryInstanceRepo(),
auditLogRepo: createInMemoryAuditLogRepo(),
orchestrator,
});
});
afterAll(async () => {
if (app) await app.close();
});
it('registers server with dockerImage, auto-creates container instance via reconcile', async () => {
// 1. Register managed server (replicas: 1 → auto-creates container)
const createRes = await app.inject({
method: 'POST',
url: '/api/v1/servers',
payload: {
name: 'ha-mcp-docker',
description: 'HA MCP managed by Docker',
dockerImage: 'ghcr.io/homeassistant-ai/ha-mcp:2.4',
transport: 'STREAMABLE_HTTP',
containerPort: 3000,
command: ['python', '-c', 'print("hello")'],
env: [
{ name: 'HOMEASSISTANT_URL', value: 'http://localhost:8123' },
{ name: 'HOMEASSISTANT_TOKEN', valueFrom: { secretRef: { name: 'ha-secrets', key: 'token' } } },
],
},
});
expect(createRes.statusCode).toBe(201);
const server = createRes.json<{ id: string; name: string; dockerImage: string; command: string[] }>();
expect(server.name).toBe('ha-mcp-docker');
expect(server.dockerImage).toBe('ghcr.io/homeassistant-ai/ha-mcp:2.4');
expect(server.command).toEqual(['python', '-c', 'print("hello")']);
// 2. Verify instance was auto-created with container
const instancesRes = await app.inject({
method: 'GET',
url: `/api/v1/instances?serverId=${server.id}`,
});
expect(instancesRes.statusCode).toBe(200);
const instances = instancesRes.json<Array<{ id: string; status: string; containerId: string }>>();
expect(instances).toHaveLength(1);
expect(instances[0]!.status).toBe('RUNNING');
expect(instances[0]!.containerId).toBeTruthy();
// 3. Verify orchestrator was called with correct spec
expect(orchestrator.createContainer).toHaveBeenCalledTimes(1);
const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]![0];
expect(spec.image).toBe('ghcr.io/homeassistant-ai/ha-mcp:2.4');
expect(spec.containerPort).toBe(3000);
expect(spec.command).toEqual(['python', '-c', 'print("hello")']);
});
it('marks instance as ERROR when Docker fails', async () => {
vi.mocked(orchestrator.createContainer).mockRejectedValueOnce(new Error('Docker socket unavailable'));
// Creating server triggers reconcile which tries to create container → fails
const createRes = await app.inject({
method: 'POST',
url: '/api/v1/servers',
payload: {
name: 'failing-server',
description: 'Will fail to start',
dockerImage: 'some-image:latest',
transport: 'STDIO',
},
});
expect(createRes.statusCode).toBe(201);
const server = createRes.json<{ id: string }>();
const instancesRes = await app.inject({
method: 'GET',
url: `/api/v1/instances?serverId=${server.id}`,
});
const instances = instancesRes.json<Array<{ id: string; status: string }>>();
expect(instances).toHaveLength(1);
expect(instances[0]!.status).toBe('ERROR');
});
});
describe('full lifecycle', () => {
let app: FastifyInstance;
let orchestrator: ReturnType<typeof createMockOrchestrator>;
beforeEach(async () => {
orchestrator = createMockOrchestrator();
app = await buildTestApp({
serverRepo: createInMemoryServerRepo(),
instanceRepo: createInMemoryInstanceRepo(),
auditLogRepo: createInMemoryAuditLogRepo(),
orchestrator,
});
});
afterAll(async () => {
if (app) await app.close();
});
it('register → auto-create → list → delete instance (reconcile) → delete server (cascade)', async () => {
// Register (auto-creates instance via reconcile)
const createRes = await app.inject({
method: 'POST',
url: '/api/v1/servers',
payload: {
name: 'lifecycle-test',
description: 'Full lifecycle',
dockerImage: 'test:latest',
transport: 'SSE',
containerPort: 8080,
},
});
expect(createRes.statusCode).toBe(201);
const server = createRes.json<{ id: string }>();
// List instances (auto-created)
const listRes = await app.inject({
method: 'GET',
url: `/api/v1/instances?serverId=${server.id}`,
});
expect(listRes.statusCode).toBe(200);
const instances = listRes.json<Array<{ id: string; status: string }>>();
expect(instances).toHaveLength(1);
expect(instances[0]!.status).toBe('RUNNING');
const instanceId = instances[0]!.id;
// Delete instance → triggers reconcile → new instance auto-created
const removeRes = await app.inject({
method: 'DELETE',
url: `/api/v1/instances/${instanceId}`,
});
expect(removeRes.statusCode).toBe(204);
// Verify a replacement instance was created (reconcile)
const listAfter = await app.inject({
method: 'GET',
url: `/api/v1/instances?serverId=${server.id}`,
});
const afterInstances = listAfter.json<Array<{ id: string }>>();
expect(afterInstances).toHaveLength(1);
expect(afterInstances[0]!.id).not.toBe(instanceId); // New instance, not the old one
// Delete server (cascade removes all instances)
const deleteRes = await app.inject({
method: 'DELETE',
url: `/api/v1/servers/${server.id}`,
});
expect(deleteRes.statusCode).toBe(204);
// Verify server is gone
const serversAfter = await app.inject({ method: 'GET', url: '/api/v1/servers' });
expect(serversAfter.json<unknown[]>()).toHaveLength(0);
});
it('external server lifecycle: register → auto-create → proxy → delete server (cascade)', async () => {
// Register external (auto-creates virtual instance)
const createRes = await app.inject({
method: 'POST',
url: '/api/v1/servers',
payload: {
name: 'external-lifecycle',
transport: 'STREAMABLE_HTTP',
externalUrl: `http://localhost:${fakeMcpPort}`,
},
});
const server = createRes.json<{ id: string }>();
// Verify auto-created instance
const instancesRes = await app.inject({
method: 'GET',
url: `/api/v1/instances?serverId=${server.id}`,
});
const instances = instancesRes.json<Array<{ id: string; status: string; containerId: string | null }>>();
expect(instances).toHaveLength(1);
expect(instances[0]!.status).toBe('RUNNING');
expect(instances[0]!.containerId).toBeNull();
// Proxy tools/list
const proxyRes = await app.inject({
method: 'POST',
url: '/api/v1/mcp/proxy',
headers: { authorization: 'Bearer test-token' },
payload: { serverId: server.id, method: 'tools/list' },
});
expect(proxyRes.statusCode).toBe(200);
expect(proxyRes.json<{ result: { tools: unknown[] } }>().result.tools.length).toBeGreaterThan(0);
// Docker orchestrator should NOT have been called (external server)
expect(orchestrator.createContainer).not.toHaveBeenCalled();
expect(orchestrator.stopContainer).not.toHaveBeenCalled();
// Delete server (cascade)
const deleteRes = await app.inject({
method: 'DELETE',
url: `/api/v1/servers/${server.id}`,
});
expect(deleteRes.statusCode).toBe(204);
});
});
describe('proxy authentication', () => {
let app: FastifyInstance;
beforeEach(async () => {
app = await buildTestApp({
serverRepo: createInMemoryServerRepo(),
instanceRepo: createInMemoryInstanceRepo(),
auditLogRepo: createInMemoryAuditLogRepo(),
orchestrator: createMockOrchestrator(),
});
});
afterAll(async () => {
if (app) await app.close();
});
it('rejects proxy calls without auth header', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/v1/mcp/proxy',
payload: { serverId: 'srv-1', method: 'tools/list' },
});
// Auth middleware rejects with 401 (no Bearer token)
expect(res.statusCode).toBe(401);
});
});
describe('server update flow', () => {
let app: FastifyInstance;
beforeEach(async () => {
app = await buildTestApp({
serverRepo: createInMemoryServerRepo(),
instanceRepo: createInMemoryInstanceRepo(),
auditLogRepo: createInMemoryAuditLogRepo(),
orchestrator: createMockOrchestrator(),
});
});
afterAll(async () => {
if (app) await app.close();
});
it('creates and updates server fields', async () => {
// Create (with replicas: 0 to avoid creating instances in this test)
const createRes = await app.inject({
method: 'POST',
url: '/api/v1/servers',
payload: {
name: 'updatable',
description: 'Original desc',
transport: 'STDIO',
replicas: 0,
},
});
expect(createRes.statusCode).toBe(201);
const server = createRes.json<{ id: string; description: string }>();
expect(server.description).toBe('Original desc');
// Update
const updateRes = await app.inject({
method: 'PUT',
url: `/api/v1/servers/${server.id}`,
payload: {
description: 'Updated desc',
externalUrl: `http://localhost:${fakeMcpPort}`,
transport: 'STREAMABLE_HTTP',
},
});
expect(updateRes.statusCode).toBe(200);
const updated = updateRes.json<{ description: string; externalUrl: string; transport: string }>();
expect(updated.description).toBe('Updated desc');
expect(updated.externalUrl).toBe(`http://localhost:${fakeMcpPort}`);
expect(updated.transport).toBe('STREAMABLE_HTTP');
// Fetch to verify persistence
const getRes = await app.inject({
method: 'GET',
url: `/api/v1/servers/${server.id}`,
});
expect(getRes.json<{ description: string }>().description).toBe('Updated desc');
});
});
});

View File

@@ -3,44 +3,66 @@ import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { registerMcpServerRoutes } from '../src/routes/mcp-servers.js';
import { McpServerService, NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import { InstanceService } from '../src/services/instance.service.js';
import { errorHandler } from '../src/middleware/error-handler.js';
import type { IMcpServerRepository } from '../src/repositories/interfaces.js';
import type { IMcpServerRepository, IMcpInstanceRepository } from '../src/repositories/interfaces.js';
import type { McpOrchestrator } from '../src/services/orchestrator.js';
let app: FastifyInstance;
function mockRepo(): IMcpServerRepository {
let lastCreated: Record<string, unknown> | null = null;
return {
findAll: vi.fn(async () => [
{ id: '1', name: 'slack', description: 'Slack server', transport: 'STDIO' },
{ id: '1', name: 'slack', description: 'Slack server', transport: 'STDIO', replicas: 1 },
]),
findById: vi.fn(async () => null),
findById: vi.fn(async (id: string) => {
if (lastCreated && (lastCreated as { id: string }).id === id) return lastCreated as never;
return null;
}),
findByName: vi.fn(async () => null),
create: vi.fn(async (data) => ({
id: 'new-id',
name: data.name,
description: data.description ?? '',
packageName: data.packageName ?? null,
dockerImage: null,
transport: data.transport ?? 'STDIO',
repositoryUrl: null,
envTemplate: [],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
})),
update: vi.fn(async (id, data) => ({
id,
name: 'slack',
description: (data.description as string) ?? 'Slack server',
packageName: null,
dockerImage: null,
transport: 'STDIO',
repositoryUrl: null,
envTemplate: [],
version: 2,
createdAt: new Date(),
updatedAt: new Date(),
})),
create: vi.fn(async (data) => {
const server = {
id: 'new-id',
name: data.name,
description: data.description ?? '',
packageName: data.packageName ?? null,
dockerImage: null,
transport: data.transport ?? 'STDIO',
repositoryUrl: null,
externalUrl: null,
command: null,
containerPort: null,
replicas: data.replicas ?? 1,
env: [],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
};
lastCreated = server;
return server;
}),
update: vi.fn(async (id, data) => {
const server = {
id,
name: 'slack',
description: (data.description as string) ?? 'Slack server',
packageName: null,
dockerImage: null,
transport: 'STDIO',
repositoryUrl: null,
externalUrl: null,
command: null,
containerPort: null,
replicas: 1,
env: [],
version: 2,
createdAt: new Date(),
updatedAt: new Date(),
};
lastCreated = server;
return server;
}),
delete: vi.fn(async () => {}),
};
}
@@ -49,11 +71,56 @@ afterEach(async () => {
if (app) await app.close();
});
function stubInstanceRepo(): IMcpInstanceRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByContainerId: vi.fn(async () => null),
create: vi.fn(async (data) => ({
id: 'inst-stub',
serverId: data.serverId,
containerId: null,
status: data.status ?? 'STOPPED',
port: null,
metadata: {},
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
})),
updateStatus: vi.fn(async (id, status) => ({
id,
serverId: 'srv-1',
containerId: null,
status,
port: null,
metadata: {},
version: 2,
createdAt: new Date(),
updatedAt: new Date(),
})),
delete: vi.fn(async () => {}),
};
}
function stubOrchestrator(): McpOrchestrator {
return {
ping: vi.fn(async () => true),
pullImage: vi.fn(async () => {}),
createContainer: vi.fn(async () => ({ containerId: 'ctr-stub', name: 'stub', state: 'running' as const, port: 3000, createdAt: new Date() })),
stopContainer: vi.fn(async () => {}),
removeContainer: vi.fn(async () => {}),
inspectContainer: vi.fn(async () => ({ containerId: 'ctr-stub', name: 'stub', state: 'running' as const, createdAt: new Date() })),
getContainerLogs: vi.fn(async () => ({ stdout: '', stderr: '' })),
};
}
function createApp(repo: IMcpServerRepository) {
app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
const service = new McpServerService(repo);
registerMcpServerRoutes(app, service);
const instanceService = new InstanceService(stubInstanceRepo(), repo, stubOrchestrator());
service.setInstanceService(instanceService);
registerMcpServerRoutes(app, service, instanceService);
return app.ready();
}

View File

@@ -15,7 +15,7 @@ function mockRepo(): IMcpServerRepository {
dockerImage: null,
transport: data.transport ?? 'STDIO',
repositoryUrl: data.repositoryUrl ?? null,
envTemplate: data.envTemplate ?? [],
env: data.env ?? [],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
@@ -28,7 +28,7 @@ function mockRepo(): IMcpServerRepository {
dockerImage: null,
transport: 'STDIO' as const,
repositoryUrl: null,
envTemplate: [],
env: [],
version: 2,
createdAt: new Date(),
updatedAt: new Date(),

View File

@@ -2,8 +2,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ProjectService } from '../src/services/project.service.js';
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import type { IProjectRepository } from '../src/repositories/project.repository.js';
import type { IMcpProfileRepository, IMcpServerRepository } from '../src/repositories/interfaces.js';
function mockProjectRepo(): IProjectRepository {
return {
findAll: vi.fn(async () => []),
@@ -23,44 +21,16 @@ function mockProjectRepo(): IProjectRepository {
createdAt: new Date(), updatedAt: new Date(),
})),
delete: vi.fn(async () => {}),
setProfiles: vi.fn(async () => {}),
getProfileIds: vi.fn(async () => []),
};
}
function mockProfileRepo(): IMcpProfileRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByServerAndName: vi.fn(async () => null),
create: vi.fn(async () => ({} as never)),
update: vi.fn(async () => ({} as never)),
delete: vi.fn(async () => {}),
};
}
function mockServerRepo(): IMcpServerRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async () => ({} as never)),
update: vi.fn(async () => ({} as never)),
delete: vi.fn(async () => {}),
};
}
describe('ProjectService', () => {
let projectRepo: ReturnType<typeof mockProjectRepo>;
let profileRepo: ReturnType<typeof mockProfileRepo>;
let serverRepo: ReturnType<typeof mockServerRepo>;
let service: ProjectService;
beforeEach(() => {
projectRepo = mockProjectRepo();
profileRepo = mockProfileRepo();
serverRepo = mockServerRepo();
service = new ProjectService(projectRepo, profileRepo, serverRepo);
service = new ProjectService(projectRepo);
});
describe('create', () => {
@@ -86,55 +56,6 @@ describe('ProjectService', () => {
});
});
describe('setProfiles', () => {
it('sets profile associations', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
vi.mocked(profileRepo.findById).mockResolvedValue({ id: 'prof-1' } as never);
const result = await service.setProfiles('p1', { profileIds: ['prof-1'] });
expect(result).toEqual(['prof-1']);
expect(projectRepo.setProfiles).toHaveBeenCalledWith('p1', ['prof-1']);
});
it('throws NotFoundError for missing profile', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
await expect(service.setProfiles('p1', { profileIds: ['missing'] })).rejects.toThrow(NotFoundError);
});
it('throws NotFoundError for missing project', async () => {
await expect(service.setProfiles('missing', { profileIds: [] })).rejects.toThrow(NotFoundError);
});
});
describe('getMcpConfig', () => {
it('returns empty config for project with no profiles', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
const result = await service.getMcpConfig('p1');
expect(result).toEqual({ mcpServers: {} });
});
it('generates config from profiles', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
vi.mocked(projectRepo.getProfileIds).mockResolvedValue(['prof-1']);
vi.mocked(profileRepo.findById).mockResolvedValue({
id: 'prof-1', name: 'default', serverId: 's1',
permissions: [], envOverrides: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
vi.mocked(serverRepo.findById).mockResolvedValue({
id: 's1', name: 'slack', description: '', packageName: '@anthropic/slack-mcp',
dockerImage: null, transport: 'STDIO', repositoryUrl: null, envTemplate: [],
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
const result = await service.getMcpConfig('p1');
expect(result.mcpServers['slack--default']).toBeDefined();
});
it('throws NotFoundError for missing project', async () => {
await expect(service.getMcpConfig('missing')).rejects.toThrow(NotFoundError);
});
});
describe('delete', () => {
it('deletes project', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);

View File

@@ -0,0 +1,170 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { registerSecretRoutes } from '../src/routes/secrets.js';
import { SecretService } from '../src/services/secret.service.js';
import { errorHandler } from '../src/middleware/error-handler.js';
import type { ISecretRepository } from '../src/repositories/interfaces.js';
let app: FastifyInstance;
function mockRepo(): ISecretRepository {
let lastCreated: Record<string, unknown> | null = null;
return {
findAll: vi.fn(async () => [
{ id: '1', name: 'ha-creds', data: { TOKEN: 'abc' }, version: 1, createdAt: new Date(), updatedAt: new Date() },
]),
findById: vi.fn(async (id: string) => {
if (lastCreated && (lastCreated as { id: string }).id === id) return lastCreated as never;
return null;
}),
findByName: vi.fn(async () => null),
create: vi.fn(async (data) => {
const secret = {
id: 'new-id',
name: data.name,
data: data.data ?? {},
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
};
lastCreated = secret;
return secret;
}),
update: vi.fn(async (id, data) => {
const secret = {
id,
name: 'ha-creds',
data: data.data,
version: 2,
createdAt: new Date(),
updatedAt: new Date(),
};
lastCreated = secret;
return secret;
}),
delete: vi.fn(async () => {}),
};
}
afterEach(async () => {
if (app) await app.close();
});
function createApp(repo: ISecretRepository) {
app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
const service = new SecretService(repo);
registerSecretRoutes(app, service);
return app.ready();
}
describe('Secret Routes', () => {
describe('GET /api/v1/secrets', () => {
it('returns secret list', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({ method: 'GET', url: '/api/v1/secrets' });
expect(res.statusCode).toBe(200);
const body = res.json<Array<{ name: string }>>();
expect(body).toHaveLength(1);
expect(body[0]?.name).toBe('ha-creds');
});
});
describe('GET /api/v1/secrets/:id', () => {
it('returns 404 when not found', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({ method: 'GET', url: '/api/v1/secrets/missing' });
expect(res.statusCode).toBe(404);
});
it('returns secret when found', async () => {
const repo = mockRepo();
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'ha-creds', data: { TOKEN: 'abc' } } as never);
await createApp(repo);
const res = await app.inject({ method: 'GET', url: '/api/v1/secrets/1' });
expect(res.statusCode).toBe(200);
});
});
describe('POST /api/v1/secrets', () => {
it('creates a secret and returns 201', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({
method: 'POST',
url: '/api/v1/secrets',
payload: { name: 'new-secret', data: { KEY: 'val' } },
});
expect(res.statusCode).toBe(201);
expect(res.json<{ name: string }>().name).toBe('new-secret');
});
it('returns 400 for invalid input', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({
method: 'POST',
url: '/api/v1/secrets',
payload: { name: '' },
});
expect(res.statusCode).toBe(400);
});
it('returns 409 when name already exists', async () => {
const repo = mockRepo();
vi.mocked(repo.findByName).mockResolvedValue({ id: '1' } as never);
await createApp(repo);
const res = await app.inject({
method: 'POST',
url: '/api/v1/secrets',
payload: { name: 'existing' },
});
expect(res.statusCode).toBe(409);
});
});
describe('PUT /api/v1/secrets/:id', () => {
it('updates a secret', async () => {
const repo = mockRepo();
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'ha-creds' } as never);
await createApp(repo);
const res = await app.inject({
method: 'PUT',
url: '/api/v1/secrets/1',
payload: { data: { TOKEN: 'new-val' } },
});
expect(res.statusCode).toBe(200);
});
it('returns 404 when not found', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({
method: 'PUT',
url: '/api/v1/secrets/missing',
payload: { data: { X: 'y' } },
});
expect(res.statusCode).toBe(404);
});
});
describe('DELETE /api/v1/secrets/:id', () => {
it('deletes a secret and returns 204', async () => {
const repo = mockRepo();
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'ha-creds' } as never);
await createApp(repo);
const res = await app.inject({ method: 'DELETE', url: '/api/v1/secrets/1' });
expect(res.statusCode).toBe(204);
});
it('returns 404 when not found', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({ method: 'DELETE', url: '/api/v1/secrets/missing' });
expect(res.statusCode).toBe(404);
});
});
});

View File

@@ -2,8 +2,6 @@ import { describe, it, expect } from 'vitest';
import {
CreateMcpServerSchema,
UpdateMcpServerSchema,
CreateMcpProfileSchema,
UpdateMcpProfileSchema,
} from '../src/validation/index.js';
describe('CreateMcpServerSchema', () => {
@@ -14,7 +12,7 @@ describe('CreateMcpServerSchema', () => {
transport: 'STDIO',
});
expect(result.name).toBe('my-server');
expect(result.envTemplate).toEqual([]);
expect(result.env).toEqual([]);
});
it('rejects empty name', () => {
@@ -39,15 +37,40 @@ describe('CreateMcpServerSchema', () => {
expect(result.transport).toBe('STDIO');
});
it('validates envTemplate entries', () => {
it('validates env entries with inline value', () => {
const result = CreateMcpServerSchema.parse({
name: 'test',
envTemplate: [
{ name: 'API_KEY', description: 'The key', isSecret: true },
env: [
{ name: 'API_URL', value: 'https://example.com' },
],
});
expect(result.envTemplate).toHaveLength(1);
expect(result.envTemplate[0]?.isSecret).toBe(true);
expect(result.env).toHaveLength(1);
expect(result.env[0]?.value).toBe('https://example.com');
});
it('validates env entries with secretRef', () => {
const result = CreateMcpServerSchema.parse({
name: 'test',
env: [
{ name: 'API_KEY', valueFrom: { secretRef: { name: 'my-secret', key: 'api-key' } } },
],
});
expect(result.env).toHaveLength(1);
expect(result.env[0]?.valueFrom?.secretRef.name).toBe('my-secret');
});
it('rejects env entry with neither value nor valueFrom', () => {
expect(() => CreateMcpServerSchema.parse({
name: 'test',
env: [{ name: 'FOO' }],
})).toThrow();
});
it('rejects env entry with both value and valueFrom', () => {
expect(() => CreateMcpServerSchema.parse({
name: 'test',
env: [{ name: 'FOO', value: 'bar', valueFrom: { secretRef: { name: 'x', key: 'y' } } }],
})).toThrow();
});
it('rejects invalid transport', () => {
@@ -78,47 +101,3 @@ describe('UpdateMcpServerSchema', () => {
});
});
describe('CreateMcpProfileSchema', () => {
it('validates valid input', () => {
const result = CreateMcpProfileSchema.parse({
name: 'readonly',
serverId: 'server-123',
});
expect(result.name).toBe('readonly');
expect(result.permissions).toEqual([]);
expect(result.envOverrides).toEqual({});
});
it('rejects empty name', () => {
expect(() => CreateMcpProfileSchema.parse({ name: '', serverId: 'x' })).toThrow();
});
it('accepts permissions array', () => {
const result = CreateMcpProfileSchema.parse({
name: 'admin',
serverId: 'x',
permissions: ['read', 'write', 'delete'],
});
expect(result.permissions).toHaveLength(3);
});
it('accepts envOverrides', () => {
const result = CreateMcpProfileSchema.parse({
name: 'staging',
serverId: 'x',
envOverrides: { API_URL: 'https://staging.example.com' },
});
expect(result.envOverrides['API_URL']).toBe('https://staging.example.com');
});
});
describe('UpdateMcpProfileSchema', () => {
it('allows partial updates', () => {
const result = UpdateMcpProfileSchema.parse({ permissions: ['read'] });
expect(result.permissions).toEqual(['read']);
});
it('allows empty object', () => {
expect(UpdateMcpProfileSchema.parse({})).toBeDefined();
});
});

View File

@@ -3,7 +3,7 @@
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node"]
"types": ["node", "js-yaml"]
},
"include": ["src/**/*.ts"],
"references": [

View File

@@ -4,3 +4,4 @@ export { loadHttpConfig } from './config.js';
export type { HttpConfig } from './config.js';
export { McpdClient, AuthenticationError, ConnectionError } from './mcpd-client.js';
export { registerProxyRoutes } from './routes/proxy.js';
export { registerMcpEndpoint } from './mcp-endpoint.js';

View File

@@ -0,0 +1,100 @@
/**
* Streamable HTTP MCP protocol endpoint.
*
* Exposes the McpRouter over HTTP at /mcp so Claude Code can connect
* via `{ "type": "http", "url": "http://localhost:3200/mcp" }` in .mcp.json.
*
* Each client session gets its own StreamableHTTPServerTransport, but all
* share the same McpRouter (and therefore the same upstream connections).
*/
import { randomUUID } from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
import type { McpRouter } from '../router.js';
import type { JsonRpcRequest } from '../types.js';
interface SessionEntry {
transport: StreamableHTTPServerTransport;
}
export function registerMcpEndpoint(app: FastifyInstance, router: McpRouter): void {
const sessions = new Map<string, SessionEntry>();
// POST /mcp — JSON-RPC requests (initialize, tools/call, etc.)
app.post('/mcp', async (request, reply) => {
const sessionId = request.headers['mcp-session-id'] as string | undefined;
if (sessionId && sessions.has(sessionId)) {
// Existing session
const session = sessions.get(sessionId)!;
await session.transport.handleRequest(request.raw, reply.raw, request.body);
// Fastify must not send its own response — the transport already did
reply.hijack();
return;
}
if (sessionId && !sessions.has(sessionId)) {
// Unknown session
reply.code(404).send({ error: 'Session not found' });
return;
}
// New session — no session ID header
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id) => {
sessions.set(id, { transport });
},
});
// Wire transport messages to the router
transport.onmessage = async (message: JSONRPCMessage) => {
// The transport sends us JSON-RPC messages; route them through McpRouter
if ('method' in message && 'id' in message) {
const response = await router.route(message as unknown as JsonRpcRequest);
await transport.send(response as unknown as JSONRPCMessage);
}
// Notifications (no id) are ignored — router doesn't handle inbound notifications
};
transport.onclose = () => {
const id = transport.sessionId;
if (id) {
sessions.delete(id);
}
};
await transport.handleRequest(request.raw, reply.raw, request.body);
reply.hijack();
});
// GET /mcp — SSE stream for server-initiated notifications
app.get('/mcp', async (request, reply) => {
const sessionId = request.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !sessions.has(sessionId)) {
reply.code(400).send({ error: 'Invalid or missing session ID' });
return;
}
const session = sessions.get(sessionId)!;
await session.transport.handleRequest(request.raw, reply.raw);
reply.hijack();
});
// DELETE /mcp — Session cleanup
app.delete('/mcp', async (request, reply) => {
const sessionId = request.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !sessions.has(sessionId)) {
reply.code(400).send({ error: 'Invalid or missing session ID' });
return;
}
const session = sessions.get(sessionId)!;
await session.transport.handleRequest(request.raw, reply.raw);
sessions.delete(sessionId);
reply.hijack();
});
}

View File

@@ -5,6 +5,7 @@ import { APP_VERSION } from '@mcpctl/shared';
import type { HttpConfig } from './config.js';
import { McpdClient } from './mcpd-client.js';
import { registerProxyRoutes } from './routes/proxy.js';
import { registerMcpEndpoint } from './mcp-endpoint.js';
import type { McpRouter } from '../router.js';
import type { HealthMonitor } from '../health.js';
import type { TieredHealthMonitor } from '../health/tiered.js';
@@ -81,5 +82,8 @@ export async function createHttpServer(
const mcpdClient = new McpdClient(config.mcpdUrl, config.mcpdToken);
registerProxyRoutes(app, mcpdClient);
// Streamable HTTP MCP protocol endpoint at /mcp
registerMcpEndpoint(app, deps.router);
return app;
}

Some files were not shown because too many files have changed in this diff Show More