Compare commits

...

37 Commits

Author SHA1 Message Date
Michal
ec1dfe7438 fix: migrate legacy admin role to granular roles at startup
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 migrateAdminRole() that runs on mcpd boot
- Converts { role: 'admin', resource: X } → edit + run bindings
- Adds operation bindings for wildcard admin (impersonate, logs, etc.)
- Add tests verifying unknown/legacy roles are denied by canAccess

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:31:15 +00:00
50b4112398 Merge pull request 'fix: resolve tsc --build type errors' (#20) from fix/build-type-errors 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 11:08:08 +00:00
Michal
bb17a892d6 fix: resolve tsc --build type errors (exactOptionalPropertyTypes)
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Fix resourceName assignment in mapUrlToPermission for strictness
- Use RbacRoleBinding type in restore-service instead of loose cast
- Remove stale ProjectMemberInput export from validation index

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:07:46 +00:00
a8117091a1 Merge pull request 'feat: granular RBAC with resource/operation bindings, users, groups' (#19) from feat/projects-rbac-users-groups 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 11:05:51 +00:00
Michal
dcda93d179 feat: granular RBAC with resource/operation bindings, users, groups
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 admin role with granular roles: view, create, delete, edit, run
- Two binding types: resource bindings (role+resource+optional name) and
  operation bindings (role:run + action like backup, logs, impersonate)
- Name-scoped resource bindings for per-instance access control
- Remove role from project members (all permissions via RBAC)
- Add users, groups, RBAC CRUD endpoints and CLI commands
- describe user/group shows all RBAC access (direct + inherited)
- create rbac supports --subject, --binding, --operation flags
- Backup/restore handles users, groups, RBAC definitions
- mcplocal project-based MCP endpoint discovery
- Full test coverage for all new functionality

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:05:19 +00:00
a6b5e24a8d Merge pull request 'fix: add missing passwordHash to DB test user factory' (#18) from fix/db-tests-passwordhash 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 01:03:11 +00:00
Michal
3a6e58274c fix: add missing passwordHash to DB test user factory
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
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 01:02:41 +00:00
Michal
c819b65175 fix: SSE health probe uses proper SSE protocol (GET /sse + POST /messages)
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
SSE-transport MCP servers (like ha-mcp) use a different protocol flow:
GET /sse to establish event stream, read endpoint event, then POST
JSON-RPC messages to /messages?session_id=... URL. Previously was
POSTing to root which returned 404.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:55:25 +00:00
Michal
c3ef5a664f chore: remove accidentally committed logs.sh
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-23 00:52:31 +00:00
Michal
4c2927a16e fix: HTTP health probes use container IP for internal network communication
mcpd and MCP containers share the mcp-servers Docker network. HTTP probes
must use the container's internal IP + containerPort instead of localhost
+ host-mapped port. Also extracts container IP from Docker inspect.

Updated home-assistant template to use ghcr.io/homeassistant-ai/ha-mcp
Docker image (SSE transport) instead of broken npm package.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:52:17 +00:00
79dd6e723d Merge pull request 'feat: MCP health probe runner with tool-call probes' (#17) from feat/health-probe-runner 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:39:09 +00:00
Michal
cde1c59fd6 feat: MCP health probe runner — periodic tool-call probes for 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
Implements Kubernetes-style liveness probes that call MCP tools defined
in server healthCheck configs. For STDIO servers, uses docker exec to
spawn a disposable MCP client that sends initialize + tool call. For
HTTP/SSE servers, sends JSON-RPC directly.

- HealthProbeRunner service with configurable interval/threshold/timeout
- execInContainer added to orchestrator interface + Docker implementation
- Instance findById now includes server relation (fixes describe showing IDs)
- Events appended to instance (last 50), healthStatus tracked as
  healthy/degraded/unhealthy
- 12 unit tests covering probing, thresholds, intervals, cleanup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:38:48 +00:00
daa5860ed2 Merge pull request 'fix: stdin open for STDIO servers + describe instance resolution' (#16) from fix/stdin-describe-instance 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:26:49 +00:00
Michal
ecbf48dd49 fix: keep stdin open for STDIO servers + describe instance resolves server names
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
STDIO MCP servers read from stdin and exit on EOF. Docker containers close
stdin by default, causing all STDIO servers to crash immediately. Added
OpenStdin: true to container creation.

Describe instance now resolves server names (like logs command), preferring
RUNNING instances. Added 7 new describe tests covering server name resolution,
healthcheck display, events section, and template detail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:26:28 +00:00
d38b5aac60 Merge pull request 'feat: container liveness sync + node-runner slim base' (#15) from feat/container-liveness-sync 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:18:41 +00:00
Michal
d07d4d11dd feat: container liveness sync + node-runner slim base
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 syncStatus() to InstanceService: detects crashed/stopped containers,
  marks them ERROR with last log line as context
- Reconcile now syncs container status first (detect dead before counting)
- Add 30s periodic sync loop in main.ts
- Switch node-runner from alpine to slim (Debian) for npm compatibility
  (fixes home-assistant-mcp-server binary not found on Alpine)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:18:28 +00:00
fa58c1b5ed Merge pull request 'fix: logs resolves server names + replica handling + tests' (#14) from fix/logs-resolve-and-tests into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 00:12:50 +00:00
Michal
dd1dfc629d fix: logs command resolves server names, proper replica handling
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
- `mcpctl logs <server-name>` resolves to first RUNNING instance
- `mcpctl logs <server-name> -i <N>` selects specific replica
- Shows "instance N/M" hint when server has multiple replicas
- Added 5 proper tests: server name resolution, RUNNING preference,
  replica selection, out-of-range error, no instances error

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:12:39 +00:00
7b3dab142e Merge pull request 'fix: show server name in instances, logs by server name' (#13) from fix/instance-ux 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:07:57 +00:00
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
153 changed files with 11287 additions and 2685 deletions

3
.gitignore vendored
View File

@@ -9,6 +9,8 @@ dist/
.env .env
.env.local .env.local
.env.*.local .env.*.local
stack/.env
.portainer_password
# Logs # Logs
logs/ logs/
@@ -35,3 +37,4 @@ pgdata/
# Prisma # Prisma
src/db/prisma/migrations/*.sql.backup src/db/prisma/migrations/*.sql.backup
logs.sh

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/db/dist/ src/db/dist/
COPY --from=builder /app/src/mcpd/dist/ src/mcpd/dist/ COPY --from=builder /app/src/mcpd/dist/ src/mcpd/dist/
# Copy templates for seeding
COPY templates/ templates/
# Copy entrypoint # Copy entrypoint
COPY deploy/entrypoint.sh /entrypoint.sh COPY deploy/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh

View File

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

View File

@@ -30,6 +30,8 @@ services:
MCPD_PORT: "3100" MCPD_PORT: "3100"
MCPD_HOST: "0.0.0.0" MCPD_HOST: "0.0.0.0"
MCPD_LOG_LEVEL: info MCPD_LOG_LEVEL: info
MCPD_NODE_RUNNER_IMAGE: mcpctl-node-runner:latest
MCPD_MCP_NETWORK: mcp-servers
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -48,6 +50,16 @@ services:
retries: 3 retries: 3
start_period: 10s 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: postgres-test:
image: postgres:16-alpine image: postgres:16-alpine
container_name: mcpctl-postgres-test container_name: mcpctl-postgres-test
@@ -71,8 +83,11 @@ networks:
mcpctl: mcpctl:
driver: bridge driver: bridge
mcp-servers: mcp-servers:
name: mcp-servers
driver: bridge 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: volumes:
mcpctl-pgdata: mcpctl-pgdata:

View File

@@ -4,8 +4,8 @@ set -e
echo "mcpd: pushing database schema..." echo "mcpd: pushing database schema..."
pnpm -F @mcpctl/db exec prisma db push --schema=prisma/schema.prisma --accept-data-loss 2>&1 pnpm -F @mcpctl/db exec prisma db push --schema=prisma/schema.prisma --accept-data-loss 2>&1
echo "mcpd: seeding default data..." echo "mcpd: seeding templates..."
node src/mcpd/dist/seed-runner.js TEMPLATES_DIR=templates node src/mcpd/dist/seed-runner.js
echo "mcpd: starting server..." echo "mcpd: starting server..."
exec node src/mcpd/dist/main.js 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 description: Slack MCP server
transport: STDIO transport: STDIO
packageName: "@anthropic/slack-mcp" packageName: "@anthropic/slack-mcp"
envTemplate: env:
- name: SLACK_TOKEN - name: SLACK_TOKEN
description: Slack bot token valueFrom:
isSecret: true secretRef:
name: slack-secrets
key: token
- name: github - name: github
description: GitHub MCP server description: GitHub MCP server

View File

@@ -11,12 +11,14 @@ servers:
- "from ha_mcp.server import HomeAssistantSmartMCPServer; s = HomeAssistantSmartMCPServer(); s.mcp.run(transport='sse', host='0.0.0.0', port=3000)" - "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): # For connecting to an already-running instance (host.containers.internal for container-to-host):
externalUrl: "http://host.containers.internal:8086/mcp" externalUrl: "http://host.containers.internal:8086/mcp"
envTemplate: env:
- name: HOMEASSISTANT_URL - name: HOMEASSISTANT_URL
description: "Home Assistant instance URL (e.g. https://ha.example.com)" value: ""
- name: HOMEASSISTANT_TOKEN - name: HOMEASSISTANT_TOKEN
description: "Home Assistant long-lived access token" valueFrom:
isSecret: true secretRef:
name: ha-secrets
key: token
profiles: profiles:
- name: production - name: production

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 dst: /usr/bin/mcpctl
file_info: file_info:
mode: 0755 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 - src: ./completions/mcpctl.bash
dst: /usr/share/bash-completion/completions/mcpctl dst: /usr/share/bash-completion/completions/mcpctl
file_info: file_info:

View File

@@ -18,7 +18,11 @@
"typecheck": "tsc --build", "typecheck": "tsc --build",
"rpm:build": "bash scripts/build-rpm.sh", "rpm:build": "bash scripts/build-rpm.sh",
"rpm:publish": "bash scripts/publish-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": { "engines": {
"node": ">=20.0.0", "node": ">=20.0.0",

6
pnpm-lock.yaml generated
View File

@@ -112,6 +112,9 @@ importers:
fastify: fastify:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.7.4 version: 5.7.4
js-yaml:
specifier: ^4.1.0
version: 4.1.1
zod: zod:
specifier: ^3.24.0 specifier: ^3.24.0
version: 3.25.76 version: 3.25.76
@@ -122,6 +125,9 @@ importers:
'@types/dockerode': '@types/dockerode':
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
'@types/js-yaml':
specifier: ^4.0.9
version: 4.0.9
'@types/node': '@types/node':
specifier: ^25.3.0 specifier: ^25.3.0
version: 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..." echo "==> Building TypeScript..."
pnpm build pnpm build
echo "==> Bundling standalone binary..." echo "==> Bundling standalone binaries..."
mkdir -p dist 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/cli/src/index.ts --compile --outfile dist/mcpctl
bun build src/mcplocal/src/main.ts --compile --outfile dist/mcpctl-local
echo "==> Packaging RPM..." echo "==> Packaging RPM..."
nfpm pkg --packager rpm --target dist/ nfpm pkg --packager rpm --target dist/

View File

@@ -4,6 +4,22 @@ import yaml from 'js-yaml';
import { z } from 'zod'; import { z } from 'zod';
import type { ApiClient } from '../api-client.js'; 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({ const ServerSpecSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
description: z.string().default(''), description: z.string().default(''),
@@ -15,31 +31,102 @@ const ServerSpecSchema = z.object({
command: z.array(z.string()).optional(), command: z.array(z.string()).optional(),
containerPort: z.number().int().min(1).max(65535).optional(), containerPort: z.number().int().min(1).max(65535).optional(),
replicas: z.number().int().min(0).max(10).default(1), replicas: z.number().int().min(0).max(10).default(1),
envTemplate: z.array(z.object({ env: z.array(ServerEnvEntrySchema).default([]),
name: z.string(), healthCheck: HealthCheckSchema.optional(),
description: z.string().default(''),
isSecret: z.boolean().default(false),
})).default([]),
}); });
const ProfileSpecSchema = z.object({ const SecretSpecSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
server: z.string().min(1), data: z.record(z.string()).default({}),
permissions: z.array(z.string()).default([]), });
envOverrides: z.record(z.string()).default({}),
const TemplateEnvEntrySchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
required: z.boolean().optional(),
defaultValue: z.string().optional(),
});
const TemplateSpecSchema = z.object({
name: z.string().min(1),
version: z.string().default('1.0.0'),
description: z.string().default(''),
packageName: z.string().optional(),
dockerImage: z.string().optional(),
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
repositoryUrl: z.string().optional(),
externalUrl: z.string().optional(),
command: z.array(z.string()).optional(),
containerPort: z.number().int().min(1).max(65535).optional(),
replicas: z.number().int().min(0).max(10).default(1),
env: z.array(TemplateEnvEntrySchema).default([]),
healthCheck: HealthCheckSchema.optional(),
});
const UserSpecSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().optional(),
});
const GroupSpecSchema = z.object({
name: z.string().min(1),
description: z.string().default(''),
members: z.array(z.string().email()).default([]),
});
const RbacSubjectSchema = z.object({
kind: z.enum(['User', 'Group']),
name: z.string().min(1),
});
const RESOURCE_ALIASES: Record<string, string> = {
server: 'servers', instance: 'instances', secret: 'secrets',
project: 'projects', template: 'templates', user: 'users', group: 'groups',
};
const RbacRoleBindingSchema = z.union([
z.object({
role: z.enum(['edit', 'view', 'create', 'delete', 'run']),
resource: z.string().min(1).transform((r) => RESOURCE_ALIASES[r] ?? r),
name: z.string().min(1).optional(),
}),
z.object({
role: z.literal('run'),
action: z.string().min(1),
}),
]);
const RbacBindingSpecSchema = z.object({
name: z.string().min(1),
subjects: z.array(RbacSubjectSchema).default([]),
roleBindings: z.array(RbacRoleBindingSchema).default([]),
}); });
const ProjectSpecSchema = z.object({ const ProjectSpecSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
description: z.string().default(''), description: z.string().default(''),
profiles: z.array(z.string()).default([]), proxyMode: z.enum(['direct', 'filtered']).default('direct'),
llmProvider: z.string().optional(),
llmModel: z.string().optional(),
servers: z.array(z.string()).default([]),
members: z.array(z.string().email()).default([]),
}); });
const ApplyConfigSchema = z.object({ const ApplyConfigSchema = z.object({
secrets: z.array(SecretSpecSchema).default([]),
servers: z.array(ServerSpecSchema).default([]), servers: z.array(ServerSpecSchema).default([]),
profiles: z.array(ProfileSpecSchema).default([]), users: z.array(UserSpecSchema).default([]),
groups: z.array(GroupSpecSchema).default([]),
projects: z.array(ProjectSpecSchema).default([]), projects: z.array(ProjectSpecSchema).default([]),
}); templates: z.array(TemplateSpecSchema).default([]),
rbacBindings: z.array(RbacBindingSpecSchema).default([]),
rbac: z.array(RbacBindingSpecSchema).default([]),
}).transform((data) => ({
...data,
// Merge rbac into rbacBindings so both keys work
rbacBindings: [...data.rbacBindings, ...data.rbac],
}));
export type ApplyConfig = z.infer<typeof ApplyConfigSchema>; export type ApplyConfig = z.infer<typeof ApplyConfigSchema>;
@@ -53,16 +140,25 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command {
return new Command('apply') return new Command('apply')
.description('Apply declarative configuration from a YAML or JSON file') .description('Apply declarative configuration from a YAML or JSON file')
.argument('<file>', 'Path to config file (.yaml, .yml, or .json)') .argument('[file]', 'Path to config file (.yaml, .yml, or .json)')
.option('-f, --file <file>', 'Path to config file (alternative to positional arg)')
.option('--dry-run', 'Validate and show changes without applying') .option('--dry-run', 'Validate and show changes without applying')
.action(async (file: string, opts: { dryRun?: boolean }) => { .action(async (fileArg: string | undefined, opts: { file?: string; dryRun?: boolean }) => {
const file = fileArg ?? opts.file;
if (!file) {
throw new Error('File path required. Usage: mcpctl apply <file> or mcpctl apply -f <file>');
}
const config = loadConfigFile(file); const config = loadConfigFile(file);
if (opts.dryRun) { if (opts.dryRun) {
log('Dry run - would apply:'); log('Dry run - would apply:');
if (config.secrets.length > 0) log(` ${config.secrets.length} secret(s)`);
if (config.servers.length > 0) log(` ${config.servers.length} server(s)`); if (config.servers.length > 0) log(` ${config.servers.length} server(s)`);
if (config.profiles.length > 0) log(` ${config.profiles.length} profile(s)`); if (config.users.length > 0) log(` ${config.users.length} user(s)`);
if (config.groups.length > 0) log(` ${config.groups.length} group(s)`);
if (config.projects.length > 0) log(` ${config.projects.length} project(s)`); if (config.projects.length > 0) log(` ${config.projects.length} project(s)`);
if (config.templates.length > 0) log(` ${config.templates.length} template(s)`);
if (config.rbacBindings.length > 0) log(` ${config.rbacBindings.length} rbacBinding(s)`);
return; return;
} }
@@ -84,7 +180,25 @@ function loadConfigFile(path: string): ApplyConfig {
} }
async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args: unknown[]) => void): Promise<void> { async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args: unknown[]) => void): Promise<void> {
// Apply servers first (profiles depend on servers) // Apply order: secrets, servers, users, groups, projects, templates, rbacBindings
// Apply secrets
for (const secret of config.secrets) {
try {
const existing = await findByName(client, 'secrets', secret.name);
if (existing) {
await client.put(`/api/v1/secrets/${(existing as { id: string }).id}`, { data: secret.data });
log(`Updated secret: ${secret.name}`);
} else {
await client.post('/api/v1/secrets', secret);
log(`Created secret: ${secret.name}`);
}
} catch (err) {
log(`Error applying secret '${secret.name}': ${err instanceof Error ? err.message : err}`);
}
}
// Apply servers
for (const server of config.servers) { for (const server of config.servers) {
try { try {
const existing = await findByName(client, 'servers', server.name); const existing = await findByName(client, 'servers', server.name);
@@ -100,57 +214,85 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
} }
} }
// Apply profiles (need server IDs) // Apply users (matched by email)
for (const profile of config.profiles) { for (const user of config.users) {
try { try {
const server = await findByName(client, 'servers', profile.server); const existing = await findByField(client, 'users', 'email', user.email);
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);
if (existing) { if (existing) {
await client.put(`/api/v1/profiles/${(existing as { id: string }).id}`, { await client.put(`/api/v1/users/${(existing as { id: string }).id}`, user);
permissions: profile.permissions, log(`Updated user: ${user.email}`);
envOverrides: profile.envOverrides,
});
log(`Updated profile: ${profile.name} (server: ${profile.server})`);
} else { } else {
await client.post('/api/v1/profiles', { await client.post('/api/v1/users', user);
name: profile.name, log(`Created user: ${user.email}`);
serverId,
permissions: profile.permissions,
envOverrides: profile.envOverrides,
});
log(`Created profile: ${profile.name} (server: ${profile.server})`);
} }
} catch (err) { } catch (err) {
log(`Error applying profile '${profile.name}': ${err instanceof Error ? err.message : err}`); log(`Error applying user '${user.email}': ${err instanceof Error ? err.message : err}`);
} }
} }
// Apply projects // Apply groups
for (const group of config.groups) {
try {
const existing = await findByName(client, 'groups', group.name);
if (existing) {
await client.put(`/api/v1/groups/${(existing as { id: string }).id}`, group);
log(`Updated group: ${group.name}`);
} else {
await client.post('/api/v1/groups', group);
log(`Created group: ${group.name}`);
}
} catch (err) {
log(`Error applying group '${group.name}': ${err instanceof Error ? err.message : err}`);
}
}
// Apply projects (send full spec including servers/members)
for (const project of config.projects) { for (const project of config.projects) {
try { try {
const existing = await findByName(client, 'projects', project.name); const existing = await findByName(client, 'projects', project.name);
if (existing) { if (existing) {
await client.put(`/api/v1/projects/${(existing as { id: string }).id}`, { await client.put(`/api/v1/projects/${(existing as { id: string }).id}`, project);
description: project.description,
});
log(`Updated project: ${project.name}`); log(`Updated project: ${project.name}`);
} else { } else {
await client.post('/api/v1/projects', { await client.post('/api/v1/projects', project);
name: project.name,
description: project.description,
});
log(`Created project: ${project.name}`); log(`Created project: ${project.name}`);
} }
} catch (err) { } catch (err) {
log(`Error applying project '${project.name}': ${err instanceof Error ? err.message : err}`); log(`Error applying project '${project.name}': ${err instanceof Error ? err.message : err}`);
} }
} }
// Apply templates
for (const template of config.templates) {
try {
const existing = await findByName(client, 'templates', template.name);
if (existing) {
await client.put(`/api/v1/templates/${(existing as { id: string }).id}`, template);
log(`Updated template: ${template.name}`);
} else {
await client.post('/api/v1/templates', template);
log(`Created template: ${template.name}`);
}
} catch (err) {
log(`Error applying template '${template.name}': ${err instanceof Error ? err.message : err}`);
}
}
// Apply RBAC bindings
for (const rbacBinding of config.rbacBindings) {
try {
const existing = await findByName(client, 'rbac', rbacBinding.name);
if (existing) {
await client.put(`/api/v1/rbac/${(existing as { id: string }).id}`, rbacBinding);
log(`Updated rbacBinding: ${rbacBinding.name}`);
} else {
await client.post('/api/v1/rbac', rbacBinding);
log(`Created rbacBinding: ${rbacBinding.name}`);
}
} catch (err) {
log(`Error applying rbacBinding '${rbacBinding.name}': ${err instanceof Error ? err.message : err}`);
}
}
} }
async function findByName(client: ApiClient, resource: string, name: string): Promise<unknown | null> { async function findByName(client: ApiClient, resource: string, name: string): Promise<unknown | null> {
@@ -162,12 +304,10 @@ async function findByName(client: ApiClient, resource: string, name: string): Pr
} }
} }
async function findProfile(client: ApiClient, serverId: string, name: string): Promise<unknown | null> { async function findByField<T extends string>(client: ApiClient, resource: string, field: T, value: string): Promise<unknown | null> {
try { try {
const profiles = await client.get<Array<{ name: string; serverId: string }>>( const items = await client.get<Array<Record<string, unknown>>>(`/api/v1/${resource}`);
`/api/v1/profiles?serverId=${serverId}`, return items.find((item) => item[field] === value) ?? null;
);
return profiles.find((p) => p.name === name) ?? null;
} catch { } catch {
return null; return null;
} }

View File

@@ -10,6 +10,10 @@ export interface PromptDeps {
password(message: string): Promise<string>; password(message: string): Promise<string>;
} }
export interface StatusResponse {
hasUsers: boolean;
}
export interface AuthCommandDeps { export interface AuthCommandDeps {
configDeps: Partial<ConfigLoaderDeps>; configDeps: Partial<ConfigLoaderDeps>;
credentialsDeps: Partial<CredentialsDeps>; credentialsDeps: Partial<CredentialsDeps>;
@@ -17,6 +21,8 @@ export interface AuthCommandDeps {
log: (...args: string[]) => void; log: (...args: string[]) => void;
loginRequest: (mcpdUrl: string, email: string, password: string) => Promise<LoginResponse>; loginRequest: (mcpdUrl: string, email: string, password: string) => Promise<LoginResponse>;
logoutRequest: (mcpdUrl: string, token: string) => Promise<void>; logoutRequest: (mcpdUrl: string, token: string) => Promise<void>;
statusRequest: (mcpdUrl: string) => Promise<StatusResponse>;
bootstrapRequest: (mcpdUrl: string, email: string, password: string, name?: string) => Promise<LoginResponse>;
} }
interface LoginResponse { interface LoginResponse {
@@ -80,6 +86,70 @@ function defaultLogoutRequest(mcpdUrl: string, token: string): Promise<void> {
}); });
} }
function defaultStatusRequest(mcpdUrl: string): Promise<StatusResponse> {
return new Promise((resolve, reject) => {
const url = new URL('/api/v1/auth/status', mcpdUrl);
const opts: http.RequestOptions = {
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'GET',
timeout: 10000,
headers: { 'Content-Type': 'application/json' },
};
const req = http.request(opts, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf-8');
if ((res.statusCode ?? 0) >= 400) {
reject(new Error(`Status check failed (${res.statusCode}): ${raw}`));
return;
}
resolve(JSON.parse(raw) as StatusResponse);
});
});
req.on('error', (err) => reject(new Error(`Cannot reach mcpd: ${err.message}`)));
req.on('timeout', () => { req.destroy(); reject(new Error('Status request timed out')); });
req.end();
});
}
function defaultBootstrapRequest(mcpdUrl: string, email: string, password: string, name?: string): Promise<LoginResponse> {
return new Promise((resolve, reject) => {
const url = new URL('/api/v1/auth/bootstrap', mcpdUrl);
const payload: Record<string, string> = { email, password };
if (name) {
payload['name'] = name;
}
const body = JSON.stringify(payload);
const opts: http.RequestOptions = {
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'POST',
timeout: 10000,
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
};
const req = http.request(opts, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf-8');
if ((res.statusCode ?? 0) >= 400) {
reject(new Error(`Bootstrap failed (${res.statusCode}): ${raw}`));
return;
}
resolve(JSON.parse(raw) as LoginResponse);
});
});
req.on('error', (err) => reject(new Error(`Cannot reach mcpd: ${err.message}`)));
req.on('timeout', () => { req.destroy(); reject(new Error('Bootstrap request timed out')); });
req.write(body);
req.end();
});
}
async function defaultInput(message: string): Promise<string> { async function defaultInput(message: string): Promise<string> {
const { default: inquirer } = await import('inquirer'); const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{ type: 'input', name: 'answer', message }]); const { answer } = await inquirer.prompt([{ type: 'input', name: 'answer', message }]);
@@ -99,10 +169,12 @@ const defaultDeps: AuthCommandDeps = {
log: (...args) => console.log(...args), log: (...args) => console.log(...args),
loginRequest: defaultLoginRequest, loginRequest: defaultLoginRequest,
logoutRequest: defaultLogoutRequest, logoutRequest: defaultLogoutRequest,
statusRequest: defaultStatusRequest,
bootstrapRequest: defaultBootstrapRequest,
}; };
export function createLoginCommand(deps?: Partial<AuthCommandDeps>): Command { export function createLoginCommand(deps?: Partial<AuthCommandDeps>): Command {
const { configDeps, credentialsDeps, prompt, log, loginRequest } = { ...defaultDeps, ...deps }; const { configDeps, credentialsDeps, prompt, log, loginRequest, statusRequest, bootstrapRequest } = { ...defaultDeps, ...deps };
return new Command('login') return new Command('login')
.description('Authenticate with mcpd') .description('Authenticate with mcpd')
@@ -111,10 +183,28 @@ export function createLoginCommand(deps?: Partial<AuthCommandDeps>): Command {
const config = loadConfig(configDeps); const config = loadConfig(configDeps);
const mcpdUrl = opts.mcpdUrl ?? config.mcpdUrl; const mcpdUrl = opts.mcpdUrl ?? config.mcpdUrl;
try {
const status = await statusRequest(mcpdUrl);
if (!status.hasUsers) {
log('No users configured. Creating first admin account.');
const email = await prompt.input('Email:');
const password = await prompt.password('Password:');
const name = await prompt.input('Name (optional):');
const result = name
? await bootstrapRequest(mcpdUrl, email, password, name)
: await bootstrapRequest(mcpdUrl, email, password);
saveCredentials({
token: result.token,
mcpdUrl,
user: result.user.email,
}, credentialsDeps);
log(`Logged in as ${result.user.email} (admin)`);
} else {
const email = await prompt.input('Email:'); const email = await prompt.input('Email:');
const password = await prompt.password('Password:'); const password = await prompt.password('Password:');
try {
const result = await loginRequest(mcpdUrl, email, password); const result = await loginRequest(mcpdUrl, email, password);
saveCredentials({ saveCredentials({
token: result.token, token: result.token,
@@ -122,6 +212,7 @@ export function createLoginCommand(deps?: Partial<AuthCommandDeps>): Command {
user: result.user.email, user: result.user.email,
}, credentialsDeps); }, credentialsDeps);
log(`Logged in as ${result.user.email}`); log(`Logged in as ${result.user.email}`);
}
} catch (err) { } catch (err) {
log(`Login failed: ${(err as Error).message}`); log(`Login failed: ${(err as Error).message}`);
process.exitCode = 1; process.exitCode = 1;

View File

@@ -1,155 +0,0 @@
import { Command } from 'commander';
import { writeFileSync, readFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import type { ApiClient } from '../api-client.js';
interface McpConfig {
mcpServers: Record<string, { command: string; args: string[]; env?: Record<string, string> }>;
}
export interface ClaudeCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
}
export function createClaudeCommand(deps: ClaudeCommandDeps): Command {
const { client, log } = deps;
const cmd = new Command('claude')
.description('Manage Claude MCP configuration (.mcp.json)');
cmd
.command('generate <projectId>')
.description('Generate .mcp.json from a project configuration')
.option('-o, --output <path>', 'Output file path', '.mcp.json')
.option('--merge', 'Merge with existing .mcp.json instead of overwriting')
.option('--stdout', 'Print to stdout instead of writing a file')
.action(async (projectId: string, opts: { output: string; merge?: boolean; stdout?: boolean }) => {
const config = await client.get<McpConfig>(`/api/v1/projects/${projectId}/mcp-config`);
if (opts.stdout) {
log(JSON.stringify(config, null, 2));
return;
}
const outputPath = resolve(opts.output);
let finalConfig = config;
if (opts.merge && existsSync(outputPath)) {
try {
const existing = JSON.parse(readFileSync(outputPath, 'utf-8')) as McpConfig;
finalConfig = {
mcpServers: {
...existing.mcpServers,
...config.mcpServers,
},
};
} catch {
// If existing file is invalid, just overwrite
}
}
writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n');
const serverCount = Object.keys(finalConfig.mcpServers).length;
log(`Wrote ${outputPath} (${serverCount} server(s))`);
});
cmd
.command('show')
.description('Show current .mcp.json configuration')
.option('-p, --path <path>', 'Path to .mcp.json', '.mcp.json')
.action((opts: { path: string }) => {
const filePath = resolve(opts.path);
if (!existsSync(filePath)) {
log(`No .mcp.json found at ${filePath}`);
return;
}
const content = readFileSync(filePath, 'utf-8');
try {
const config = JSON.parse(content) as McpConfig;
const servers = Object.entries(config.mcpServers ?? {});
if (servers.length === 0) {
log('No MCP servers configured.');
return;
}
log(`MCP servers in ${filePath}:\n`);
for (const [name, server] of servers) {
log(` ${name}`);
log(` command: ${server.command} ${server.args.join(' ')}`);
if (server.env) {
const envKeys = Object.keys(server.env);
log(` env: ${envKeys.join(', ')}`);
}
}
} catch {
log(`Invalid JSON in ${filePath}`);
}
});
cmd
.command('add <name>')
.description('Add an MCP server entry to .mcp.json')
.requiredOption('-c, --command <cmd>', 'Command to run')
.option('-a, --args <args...>', 'Command arguments')
.option('-e, --env <key=value...>', 'Environment variables')
.option('-p, --path <path>', 'Path to .mcp.json', '.mcp.json')
.action((name: string, opts: { command: string; args?: string[]; env?: string[]; path: string }) => {
const filePath = resolve(opts.path);
let config: McpConfig = { mcpServers: {} };
if (existsSync(filePath)) {
try {
config = JSON.parse(readFileSync(filePath, 'utf-8')) as McpConfig;
} catch {
// Start fresh
}
}
const entry: { command: string; args: string[]; env?: Record<string, string> } = {
command: opts.command,
args: opts.args ?? [],
};
if (opts.env && opts.env.length > 0) {
const env: Record<string, string> = {};
for (const pair of opts.env) {
const eqIdx = pair.indexOf('=');
if (eqIdx > 0) {
env[pair.slice(0, eqIdx)] = pair.slice(eqIdx + 1);
}
}
entry.env = env;
}
config.mcpServers[name] = entry;
writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
log(`Added '${name}' to ${filePath}`);
});
cmd
.command('remove <name>')
.description('Remove an MCP server entry from .mcp.json')
.option('-p, --path <path>', 'Path to .mcp.json', '.mcp.json')
.action((name: string, opts: { path: string }) => {
const filePath = resolve(opts.path);
if (!existsSync(filePath)) {
log(`No .mcp.json found at ${filePath}`);
return;
}
try {
const config = JSON.parse(readFileSync(filePath, 'utf-8')) as McpConfig;
if (!(name in config.mcpServers)) {
log(`Server '${name}' not found in ${filePath}`);
return;
}
delete config.mcpServers[name];
writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
log(`Removed '${name}' from ${filePath}`);
} catch {
log(`Invalid JSON in ${filePath}`);
}
});
return cmd;
}

View File

@@ -1,19 +1,35 @@
import { Command } from 'commander'; import { Command } from 'commander';
import { writeFileSync, readFileSync, existsSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { homedir } from 'node:os';
import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../config/index.js'; import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../config/index.js';
import type { McpctlConfig, ConfigLoaderDeps } from '../config/index.js'; import type { McpctlConfig, ConfigLoaderDeps } from '../config/index.js';
import { formatJson, formatYaml } from '../formatters/index.js'; import { formatJson, formatYaml } from '../formatters/index.js';
import { saveCredentials, loadCredentials } from '../auth/index.js';
import type { CredentialsDeps, StoredCredentials } from '../auth/index.js';
import type { ApiClient } from '../api-client.js';
interface McpConfig {
mcpServers: Record<string, { command: string; args: string[]; env?: Record<string, string> }>;
}
export interface ConfigCommandDeps { export interface ConfigCommandDeps {
configDeps: Partial<ConfigLoaderDeps>; configDeps: Partial<ConfigLoaderDeps>;
log: (...args: string[]) => void; log: (...args: string[]) => void;
} }
export interface ConfigApiDeps {
client: ApiClient;
credentialsDeps: Partial<CredentialsDeps>;
log: (...args: string[]) => void;
}
const defaultDeps: ConfigCommandDeps = { const defaultDeps: ConfigCommandDeps = {
configDeps: {}, configDeps: {},
log: (...args) => console.log(...args), log: (...args) => console.log(...args),
}; };
export function createConfigCommand(deps?: Partial<ConfigCommandDeps>): Command { export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?: ConfigApiDeps): Command {
const { configDeps, log } = { ...defaultDeps, ...deps }; const { configDeps, log } = { ...defaultDeps, ...deps };
const config = new Command('config').description('Manage mcpctl configuration'); const config = new Command('config').description('Manage mcpctl configuration');
@@ -68,5 +84,115 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>): Command
log('Configuration reset to defaults'); log('Configuration reset to defaults');
}); });
if (apiDeps) {
const { client, credentialsDeps, log: apiLog } = apiDeps;
config
.command('claude-generate')
.description('Generate .mcp.json from a project configuration')
.requiredOption('--project <name>', 'Project name')
.option('-o, --output <path>', 'Output file path', '.mcp.json')
.option('--merge', 'Merge with existing .mcp.json instead of overwriting')
.option('--stdout', 'Print to stdout instead of writing a file')
.action(async (opts: { project: string; output: string; merge?: boolean; stdout?: boolean }) => {
const mcpConfig = await client.get<McpConfig>(`/api/v1/projects/${opts.project}/mcp-config`);
if (opts.stdout) {
apiLog(JSON.stringify(mcpConfig, null, 2));
return;
}
const outputPath = resolve(opts.output);
let finalConfig = mcpConfig;
if (opts.merge && existsSync(outputPath)) {
try {
const existing = JSON.parse(readFileSync(outputPath, 'utf-8')) as McpConfig;
finalConfig = {
mcpServers: {
...existing.mcpServers,
...mcpConfig.mcpServers,
},
};
} catch {
// If existing file is invalid, just overwrite
}
}
writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n');
const serverCount = Object.keys(finalConfig.mcpServers).length;
apiLog(`Wrote ${outputPath} (${serverCount} server(s))`);
});
config
.command('impersonate')
.description('Impersonate another user or return to original identity')
.argument('[email]', 'Email of user to impersonate')
.option('--quit', 'Stop impersonating and return to original identity')
.action(async (email: string | undefined, opts: { quit?: boolean }) => {
const configDir = credentialsDeps?.configDir ?? join(homedir(), '.mcpctl');
const backupPath = join(configDir, 'credentials-backup');
if (opts.quit) {
if (!existsSync(backupPath)) {
apiLog('No impersonation session to quit');
process.exitCode = 1;
return;
}
const backupRaw = readFileSync(backupPath, 'utf-8');
const backup = JSON.parse(backupRaw) as StoredCredentials;
saveCredentials(backup, credentialsDeps);
// Remove backup file
const { unlinkSync } = await import('node:fs');
unlinkSync(backupPath);
apiLog(`Returned to ${backup.user}`);
return;
}
if (!email) {
apiLog('Email is required when not using --quit');
process.exitCode = 1;
return;
}
// Save current credentials as backup
const currentCreds = loadCredentials(credentialsDeps);
if (!currentCreds) {
apiLog('Not logged in. Run "mcpctl login" first.');
process.exitCode = 1;
return;
}
writeFileSync(backupPath, JSON.stringify(currentCreds, null, 2) + '\n', 'utf-8');
try {
const result = await client.post<{ token: string; user: { email: string } }>(
'/api/v1/auth/impersonate',
{ email },
);
saveCredentials({
token: result.token,
mcpdUrl: currentCreds.mcpdUrl,
user: result.user.email,
}, credentialsDeps);
apiLog(`Impersonating ${result.user.email}. Use 'mcpctl config impersonate --quit' to return.`);
} catch (err) {
// Restore backup on failure
const backup = JSON.parse(readFileSync(backupPath, 'utf-8')) as StoredCredentials;
saveCredentials(backup, credentialsDeps);
const { unlinkSync } = await import('node:fs');
unlinkSync(backupPath);
apiLog(`Impersonate failed: ${(err as Error).message}`);
process.exitCode = 1;
}
});
}
return config; return config;
} }

View File

@@ -1,7 +1,5 @@
import { Command } from 'commander'; import { Command } from 'commander';
import type { ApiClient } from '../api-client.js'; import { type ApiClient, ApiError } from '../api-client.js';
import { resolveNameOrId } from './shared.js';
export interface CreateCommandDeps { export interface CreateCommandDeps {
client: ApiClient; client: ApiClient;
log: (...args: unknown[]) => void; log: (...args: unknown[]) => void;
@@ -11,17 +9,33 @@ function collect(value: string, prev: string[]): string[] {
return [...prev, value]; return [...prev, value];
} }
function parseEnvTemplate(entries: string[]): Array<{ name: string; description: string; isSecret: boolean }> { interface ServerEnvEntry {
name: string;
value?: string;
valueFrom?: { secretRef: { name: string; key: string } };
}
function parseServerEnv(entries: string[]): ServerEnvEntry[] {
return entries.map((entry) => { return entries.map((entry) => {
const parts = entry.split(':'); const eqIdx = entry.indexOf('=');
if (parts.length < 2) { if (eqIdx === -1) {
throw new Error(`Invalid env-template format '${entry}'. Expected NAME:description[:isSecret]`); 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 { return {
name: parts[0]!, name: envName,
description: parts[1]!, valueFrom: { secretRef: { name: parts[1]!, key: parts[2]! } },
isSecret: parts[2] === 'true',
}; };
}
return { name: envName, value: rhs };
}); });
} }
@@ -41,60 +55,139 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
const { client, log } = deps; const { client, log } = deps;
const cmd = new Command('create') const cmd = new Command('create')
.description('Create a resource (server, profile, project)'); .description('Create a resource (server, secret, project, user, group, rbac)');
// --- create server --- // --- create server ---
cmd.command('server') cmd.command('server')
.description('Create an MCP server definition') .description('Create an MCP server definition')
.argument('<name>', 'Server name (lowercase, hyphens allowed)') .argument('<name>', 'Server name (lowercase, hyphens allowed)')
.option('-d, --description <text>', 'Server description', '') .option('-d, --description <text>', 'Server description')
.option('--package-name <name>', 'NPM package name') .option('--package-name <name>', 'NPM package name')
.option('--docker-image <image>', 'Docker image') .option('--docker-image <image>', 'Docker image')
.option('--transport <type>', 'Transport type (STDIO, SSE, STREAMABLE_HTTP)', 'STDIO') .option('--transport <type>', 'Transport type (STDIO, SSE, STREAMABLE_HTTP)')
.option('--repository-url <url>', 'Source repository URL') .option('--repository-url <url>', 'Source repository URL')
.option('--external-url <url>', 'External endpoint URL') .option('--external-url <url>', 'External endpoint URL')
.option('--command <arg>', 'Command argument (repeat for multiple)', collect, []) .option('--command <arg>', 'Command argument (repeat for multiple)', collect, [])
.option('--container-port <port>', 'Container port number') .option('--container-port <port>', 'Container port number')
.option('--replicas <count>', 'Number of replicas', '1') .option('--replicas <count>', 'Number of replicas')
.option('--env-template <entry>', 'Env template (NAME:description[:isSecret], repeat for multiple)', collect, []) .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) => { .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> = { const body: Record<string, unknown> = {
...base,
name, name,
description: opts.description,
transport: opts.transport,
replicas: parseInt(opts.replicas, 10),
}; };
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.packageName) body.packageName = opts.packageName;
if (opts.dockerImage) body.dockerImage = opts.dockerImage; if (opts.dockerImage) body.dockerImage = opts.dockerImage;
if (opts.repositoryUrl) body.repositoryUrl = opts.repositoryUrl; if (opts.repositoryUrl) body.repositoryUrl = opts.repositoryUrl;
if (opts.externalUrl) body.externalUrl = opts.externalUrl; if (opts.externalUrl) body.externalUrl = opts.externalUrl;
if (opts.command.length > 0) body.command = opts.command; if (opts.command.length > 0) body.command = opts.command;
if (opts.containerPort) body.containerPort = parseInt(opts.containerPort, 10); if (opts.containerPort) body.containerPort = parseInt(opts.containerPort, 10);
if (opts.envTemplate.length > 0) body.envTemplate = parseEnvTemplate(opts.envTemplate); 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); const server = await client.post<{ id: string; name: string }>('/api/v1/servers', body);
log(`server '${server.name}' created (id: ${server.id})`); 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 profile --- // --- create secret ---
cmd.command('profile') cmd.command('secret')
.description('Create a profile for an MCP server') .description('Create a secret')
.argument('<name>', 'Profile name') .argument('<name>', 'Secret name (lowercase, hyphens allowed)')
.requiredOption('--server <name-or-id>', 'Server name or ID') .option('--data <entry>', 'Secret data KEY=value (repeat for multiple)', collect, [])
.option('--permissions <perm>', 'Permission (repeat for multiple)', collect, []) .option('--force', 'Update if already exists')
.option('--env <entry>', 'Environment override KEY=value (repeat for multiple)', collect, [])
.action(async (name: string, opts) => { .action(async (name: string, opts) => {
const serverId = await resolveNameOrId(client, 'servers', opts.server); const data = parseEnvEntries(opts.data);
try {
const body: Record<string, unknown> = { const secret = await client.post<{ id: string; name: string }>('/api/v1/secrets', {
name, name,
serverId, data,
}; });
if (opts.permissions.length > 0) body.permissions = opts.permissions; log(`secret '${secret.name}' created (id: ${secret.id})`);
if (opts.env.length > 0) body.envOverrides = parseEnvEntries(opts.env); } catch (err) {
if (err instanceof ApiError && err.status === 409 && opts.force) {
const profile = await client.post<{ id: string; name: string }>('/api/v1/profiles', body); const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/secrets')).find((s) => s.name === name);
log(`profile '${profile.name}' created (id: ${profile.id})`); 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 --- // --- create project ---
@@ -102,12 +195,158 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.description('Create a project') .description('Create a project')
.argument('<name>', 'Project name') .argument('<name>', 'Project name')
.option('-d, --description <text>', 'Project description', '') .option('-d, --description <text>', 'Project description', '')
.option('--proxy-mode <mode>', 'Proxy mode (direct, filtered)')
.option('--llm-provider <name>', 'LLM provider name')
.option('--llm-model <name>', 'LLM model name')
.option('--server <name>', 'Server name (repeat for multiple)', collect, [])
.option('--member <email>', 'Member email (repeat for multiple)', collect, [])
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => { .action(async (name: string, opts) => {
const project = await client.post<{ id: string; name: string }>('/api/v1/projects', { const body: Record<string, unknown> = {
name, name,
description: opts.description, description: opts.description,
}); proxyMode: opts.proxyMode ?? 'direct',
};
if (opts.llmProvider) body.llmProvider = opts.llmProvider;
if (opts.llmModel) body.llmModel = opts.llmModel;
if (opts.server.length > 0) body.servers = opts.server;
if (opts.member.length > 0) body.members = opts.member;
try {
const project = await client.post<{ id: string; name: string }>('/api/v1/projects', body);
log(`project '${project.name}' created (id: ${project.id})`); log(`project '${project.name}' created (id: ${project.id})`);
} catch (err) {
if (err instanceof ApiError && err.status === 409 && opts.force) {
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/projects')).find((p) => p.name === name);
if (!existing) throw err;
const { name: _n, ...updateBody } = body;
await client.put(`/api/v1/projects/${existing.id}`, updateBody);
log(`project '${name}' updated (id: ${existing.id})`);
} else {
throw err;
}
}
});
// --- create user ---
cmd.command('user')
.description('Create a user')
.argument('<email>', 'User email address')
.option('--password <pass>', 'User password')
.option('--name <name>', 'User display name')
.option('--force', 'Update if already exists')
.action(async (email: string, opts) => {
if (!opts.password) {
throw new Error('--password is required');
}
const body: Record<string, unknown> = {
email,
password: opts.password,
};
if (opts.name) body.name = opts.name;
try {
const user = await client.post<{ id: string; email: string }>('/api/v1/users', body);
log(`user '${user.email}' created (id: ${user.id})`);
} catch (err) {
if (err instanceof ApiError && err.status === 409 && opts.force) {
const existing = (await client.get<Array<{ id: string; email: string }>>('/api/v1/users')).find((u) => u.email === email);
if (!existing) throw err;
const { email: _e, ...updateBody } = body;
await client.put(`/api/v1/users/${existing.id}`, updateBody);
log(`user '${email}' updated (id: ${existing.id})`);
} else {
throw err;
}
}
});
// --- create group ---
cmd.command('group')
.description('Create a group')
.argument('<name>', 'Group name')
.option('--description <text>', 'Group description')
.option('--member <email>', 'Member email (repeat for multiple)', collect, [])
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => {
const body: Record<string, unknown> = {
name,
members: opts.member,
};
if (opts.description) body.description = opts.description;
try {
const group = await client.post<{ id: string; name: string }>('/api/v1/groups', body);
log(`group '${group.name}' created (id: ${group.id})`);
} catch (err) {
if (err instanceof ApiError && err.status === 409 && opts.force) {
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/groups')).find((g) => g.name === name);
if (!existing) throw err;
const { name: _n, ...updateBody } = body;
await client.put(`/api/v1/groups/${existing.id}`, updateBody);
log(`group '${name}' updated (id: ${existing.id})`);
} else {
throw err;
}
}
});
// --- create rbac ---
cmd.command('rbac')
.description('Create an RBAC binding definition')
.argument('<name>', 'RBAC binding name')
.option('--subject <entry>', 'Subject as Kind:name (repeat for multiple)', collect, [])
.option('--binding <entry>', 'Role binding as role:resource (e.g. edit:servers, run:projects)', collect, [])
.option('--operation <action>', 'Operation binding (e.g. logs, backup)', collect, [])
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => {
const subjects = (opts.subject as string[]).map((entry: string) => {
const colonIdx = entry.indexOf(':');
if (colonIdx === -1) {
throw new Error(`Invalid subject format '${entry}'. Expected Kind:name (e.g. User:alice@example.com)`);
}
return { kind: entry.slice(0, colonIdx), name: entry.slice(colonIdx + 1) };
});
const roleBindings: Array<Record<string, string>> = [];
// Resource bindings from --binding flag (role:resource or role:resource:name)
for (const entry of opts.binding as string[]) {
const parts = entry.split(':');
if (parts.length === 2) {
roleBindings.push({ role: parts[0]!, resource: parts[1]! });
} else if (parts.length === 3) {
roleBindings.push({ role: parts[0]!, resource: parts[1]!, name: parts[2]! });
} else {
throw new Error(`Invalid binding format '${entry}'. Expected role:resource or role:resource:name (e.g. edit:servers, view:servers:my-ha)`);
}
}
// Operation bindings from --operation flag
for (const action of opts.operation as string[]) {
roleBindings.push({ role: 'run', action });
}
const body: Record<string, unknown> = {
name,
subjects,
roleBindings,
};
try {
const rbac = await client.post<{ id: string; name: string }>('/api/v1/rbac', body);
log(`rbac '${rbac.name}' created (id: ${rbac.id})`);
} catch (err) {
if (err instanceof ApiError && err.status === 409 && opts.force) {
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/rbac')).find((r) => r.name === name);
if (!existing) throw err;
const { name: _n, ...updateBody } = body;
await client.put(`/api/v1/rbac/${existing.id}`, updateBody);
log(`rbac '${name}' updated (id: ${existing.id})`);
} else {
throw err;
}
}
}); });
return cmd; return cmd;

View File

@@ -34,17 +34,34 @@ function formatServerDetail(server: Record<string, unknown>): string {
lines.push(` ${command.join(' ')}`); lines.push(` ${command.join(' ')}`);
} }
const envTemplate = server.envTemplate as Array<{ name: string; description: string; isSecret?: boolean }> | undefined; const env = server.env as Array<{ name: string; value?: string; valueFrom?: { secretRef: { name: string; key: string } } }> | undefined;
if (envTemplate && envTemplate.length > 0) { if (env && env.length > 0) {
lines.push(''); lines.push('');
lines.push('Environment Template:'); lines.push('Environment:');
const nameW = Math.max(6, ...envTemplate.map((e) => e.name.length)) + 2; const nameW = Math.max(6, ...env.map((e) => e.name.length)) + 2;
const descW = Math.max(12, ...envTemplate.map((e) => e.description.length)) + 2; lines.push(` ${'NAME'.padEnd(nameW)}SOURCE`);
lines.push(` ${'NAME'.padEnd(nameW)}${'DESCRIPTION'.padEnd(descW)}SECRET`); for (const e of env) {
for (const env of envTemplate) { if (e.value !== undefined) {
lines.push(` ${env.name.padEnd(nameW)}${env.description.padEnd(descW)}${env.isSecret ? 'yes' : 'no'}`); 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}`);
} }
} }
}
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('');
lines.push('Metadata:'); lines.push('Metadata:');
@@ -57,12 +74,23 @@ function formatServerDetail(server: Record<string, unknown>): string {
function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Record<string, unknown>): string { function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Record<string, unknown>): string {
const lines: string[] = []; const lines: string[] = [];
lines.push(`=== Instance: ${instance.id} ===`); const server = instance.server as { name: string } | undefined;
lines.push(`=== Instance: ${server?.name ?? instance.id} ===`);
lines.push(`${pad('Status:')}${instance.status}`); lines.push(`${pad('Status:')}${instance.status}`);
lines.push(`${pad('Server ID:')}${instance.serverId}`); lines.push(`${pad('Server:')}${server?.name ?? String(instance.serverId)}`);
lines.push(`${pad('Container ID:')}${instance.containerId ?? '-'}`); lines.push(`${pad('Container ID:')}${instance.containerId ?? '-'}`);
lines.push(`${pad('Port:')}${instance.port ?? '-'}`); 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; const metadata = instance.metadata as Record<string, unknown> | undefined;
if (metadata && Object.keys(metadata).length > 0) { if (metadata && Object.keys(metadata).length > 0) {
lines.push(''); lines.push('');
@@ -84,6 +112,19 @@ function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Recor
} }
} }
// 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('');
lines.push(` ${pad('ID:', 12)}${instance.id}`); lines.push(` ${pad('ID:', 12)}${instance.id}`);
if (instance.createdAt) lines.push(` ${pad('Created:', 12)}${instance.createdAt}`); if (instance.createdAt) lines.push(` ${pad('Created:', 12)}${instance.createdAt}`);
@@ -92,52 +133,372 @@ function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Recor
return lines.join('\n'); return lines.join('\n');
} }
function formatProfileDetail(profile: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`=== Profile: ${profile.name} ===`);
lines.push(`${pad('Name:')}${profile.name}`);
lines.push(`${pad('Server ID:')}${profile.serverId}`);
const permissions = profile.permissions as string[] | undefined;
if (permissions && permissions.length > 0) {
lines.push(`${pad('Permissions:')}${permissions.join(', ')}`);
}
const envOverrides = profile.envOverrides as Record<string, string> | undefined;
if (envOverrides && Object.keys(envOverrides).length > 0) {
lines.push('');
lines.push('Environment Overrides:');
const keyW = Math.max(4, ...Object.keys(envOverrides).map((k) => k.length)) + 2;
for (const [key, value] of Object.entries(envOverrides)) {
lines.push(` ${key.padEnd(keyW)}${value}`);
}
}
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${profile.id}`);
if (profile.createdAt) lines.push(` ${pad('Created:', 12)}${profile.createdAt}`);
if (profile.updatedAt) lines.push(` ${pad('Updated:', 12)}${profile.updatedAt}`);
return lines.join('\n');
}
function formatProjectDetail(project: Record<string, unknown>): string { function formatProjectDetail(project: Record<string, unknown>): string {
const lines: string[] = []; const lines: string[] = [];
lines.push(`=== Project: ${project.name} ===`); lines.push(`=== Project: ${project.name} ===`);
lines.push(`${pad('Name:')}${project.name}`); lines.push(`${pad('Name:')}${project.name}`);
if (project.description) lines.push(`${pad('Description:')}${project.description}`); if (project.description) lines.push(`${pad('Description:')}${project.description}`);
if (project.ownerId) lines.push(`${pad('Owner:')}${project.ownerId}`);
// Proxy config section
const proxyMode = project.proxyMode as string | undefined;
const llmProvider = project.llmProvider as string | undefined;
const llmModel = project.llmModel as string | undefined;
if (proxyMode || llmProvider || llmModel) {
lines.push('');
lines.push('Proxy Config:');
lines.push(` ${pad('Mode:', 18)}${proxyMode ?? 'direct'}`);
if (llmProvider) lines.push(` ${pad('LLM Provider:', 18)}${llmProvider}`);
if (llmModel) lines.push(` ${pad('LLM Model:', 18)}${llmModel}`);
}
// Servers section
const servers = project.servers as Array<{ server: { name: string } }> | undefined;
if (servers && servers.length > 0) {
lines.push('');
lines.push('Servers:');
lines.push(' NAME');
for (const s of servers) {
lines.push(` ${s.server.name}`);
}
}
// Members section (no role — all permissions are in RBAC)
const members = project.members as Array<{ user: { email: string } }> | undefined;
if (members && members.length > 0) {
lines.push('');
lines.push('Members:');
lines.push(' EMAIL');
for (const m of members) {
lines.push(` ${m.user.email}`);
}
}
lines.push(''); lines.push('');
lines.push('Metadata:'); lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${project.id}`); lines.push(` ${pad('ID:', 12)}${project.id}`);
if (project.ownerId) lines.push(` ${pad('Owner:', 12)}${project.ownerId}`);
if (project.createdAt) lines.push(` ${pad('Created:', 12)}${project.createdAt}`); if (project.createdAt) lines.push(` ${pad('Created:', 12)}${project.createdAt}`);
if (project.updatedAt) lines.push(` ${pad('Updated:', 12)}${project.updatedAt}`); if (project.updatedAt) lines.push(` ${pad('Updated:', 12)}${project.updatedAt}`);
return lines.join('\n'); return lines.join('\n');
} }
function formatSecretDetail(secret: Record<string, unknown>, showValues: boolean): string {
const lines: string[] = [];
lines.push(`=== Secret: ${secret.name} ===`);
lines.push(`${pad('Name:')}${secret.name}`);
const data = secret.data as Record<string, string> | undefined;
if (data && Object.keys(data).length > 0) {
lines.push('');
lines.push('Data:');
const keyW = Math.max(4, ...Object.keys(data).map((k) => k.length)) + 2;
for (const [key, value] of Object.entries(data)) {
const display = showValues ? value : '***';
lines.push(` ${key.padEnd(keyW)}${display}`);
}
if (!showValues) {
lines.push('');
lines.push(' (use --show-values to reveal)');
}
} else {
lines.push(`${pad('Data:')}(empty)`);
}
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${secret.id}`);
if (secret.createdAt) lines.push(` ${pad('Created:', 12)}${secret.createdAt}`);
if (secret.updatedAt) lines.push(` ${pad('Updated:', 12)}${secret.updatedAt}`);
return lines.join('\n');
}
function formatTemplateDetail(template: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`=== Template: ${template.name} ===`);
lines.push(`${pad('Name:')}${template.name}`);
lines.push(`${pad('Version:')}${template.version ?? '1.0.0'}`);
lines.push(`${pad('Transport:')}${template.transport ?? 'STDIO'}`);
lines.push(`${pad('Replicas:')}${template.replicas ?? 1}`);
if (template.dockerImage) lines.push(`${pad('Docker Image:')}${template.dockerImage}`);
if (template.packageName) lines.push(`${pad('Package:')}${template.packageName}`);
if (template.externalUrl) lines.push(`${pad('External URL:')}${template.externalUrl}`);
if (template.repositoryUrl) lines.push(`${pad('Repository:')}${template.repositoryUrl}`);
if (template.containerPort) lines.push(`${pad('Container Port:')}${template.containerPort}`);
if (template.description) lines.push(`${pad('Description:')}${template.description}`);
const command = template.command as string[] | null;
if (command && command.length > 0) {
lines.push('');
lines.push('Command:');
lines.push(` ${command.join(' ')}`);
}
const env = template.env as Array<{ name: string; description?: string; required?: boolean; defaultValue?: string }> | undefined;
if (env && env.length > 0) {
lines.push('');
lines.push('Environment Variables:');
const nameW = Math.max(6, ...env.map((e) => e.name.length)) + 2;
lines.push(` ${'NAME'.padEnd(nameW)}${'REQUIRED'.padEnd(10)}DESCRIPTION`);
for (const e of env) {
const req = e.required ? 'yes' : 'no';
const desc = e.description ?? '';
lines.push(` ${e.name.padEnd(nameW)}${req.padEnd(10)}${desc}`);
}
}
const hc = template.healthCheck as { tool: string; arguments?: Record<string, unknown>; intervalSeconds?: number; timeoutSeconds?: number; failureThreshold?: number } | null;
if (hc) {
lines.push('');
lines.push('Health Check:');
lines.push(` ${pad('Tool:', 22)}${hc.tool}`);
if (hc.arguments && Object.keys(hc.arguments).length > 0) {
lines.push(` ${pad('Arguments:', 22)}${JSON.stringify(hc.arguments)}`);
}
lines.push(` ${pad('Interval:', 22)}${hc.intervalSeconds ?? 60}s`);
lines.push(` ${pad('Timeout:', 22)}${hc.timeoutSeconds ?? 10}s`);
lines.push(` ${pad('Failure Threshold:', 22)}${hc.failureThreshold ?? 3}`);
}
lines.push('');
lines.push('Usage:');
lines.push(` mcpctl create server my-${template.name} --from-template=${template.name}`);
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${template.id}`);
if (template.createdAt) lines.push(` ${pad('Created:', 12)}${template.createdAt}`);
if (template.updatedAt) lines.push(` ${pad('Updated:', 12)}${template.updatedAt}`);
return lines.join('\n');
}
interface RbacBinding { role: string; resource?: string; action?: string; name?: string }
interface RbacDef { name: string; subjects: Array<{ kind: string; name: string }>; roleBindings: RbacBinding[] }
interface PermissionSet { source: string; bindings: RbacBinding[] }
function formatPermissionSections(sections: PermissionSet[]): string[] {
const lines: string[] = [];
for (const section of sections) {
const bindings = section.bindings;
if (bindings.length === 0) continue;
const resourceBindings = bindings.filter((b) => 'resource' in b && b.resource !== undefined);
const operationBindings = bindings.filter((b) => 'action' in b && b.action !== undefined);
if (resourceBindings.length > 0) {
lines.push('');
lines.push(`${section.source} — Resources:`);
const roleW = Math.max(6, ...resourceBindings.map((b) => b.role.length)) + 2;
const resW = Math.max(10, ...resourceBindings.map((b) => (b.resource ?? '').length)) + 2;
const hasName = resourceBindings.some((b) => b.name);
if (hasName) {
lines.push(` ${'ROLE'.padEnd(roleW)}${'RESOURCE'.padEnd(resW)}NAME`);
} else {
lines.push(` ${'ROLE'.padEnd(roleW)}RESOURCE`);
}
for (const b of resourceBindings) {
if (hasName) {
lines.push(` ${b.role.padEnd(roleW)}${(b.resource ?? '').padEnd(resW)}${b.name ?? '*'}`);
} else {
lines.push(` ${b.role.padEnd(roleW)}${b.resource}`);
}
}
}
if (operationBindings.length > 0) {
lines.push('');
lines.push(`${section.source} — Operations:`);
lines.push(` ${'ACTION'.padEnd(20)}ROLE`);
for (const b of operationBindings) {
lines.push(` ${(b.action ?? '').padEnd(20)}${b.role}`);
}
}
}
return lines;
}
function collectBindingsForSubject(
rbacDefs: RbacDef[],
kind: string,
name: string,
): { rbacName: string; bindings: RbacBinding[] }[] {
const results: { rbacName: string; bindings: RbacBinding[] }[] = [];
for (const def of rbacDefs) {
const matched = def.subjects.some((s) => s.kind === kind && s.name === name);
if (matched) {
results.push({ rbacName: def.name, bindings: def.roleBindings });
}
}
return results;
}
function formatUserDetail(
user: Record<string, unknown>,
rbacDefs?: RbacDef[],
userGroups?: string[],
): string {
const lines: string[] = [];
lines.push(`=== User: ${user.email} ===`);
lines.push(`${pad('Email:')}${user.email}`);
lines.push(`${pad('Name:')}${(user.name as string | null) ?? '-'}`);
lines.push(`${pad('Provider:')}${(user.provider as string | null) ?? 'local'}`);
if (userGroups && userGroups.length > 0) {
lines.push(`${pad('Groups:')}${userGroups.join(', ')}`);
}
if (rbacDefs) {
const email = user.email as string;
// Direct permissions (User:email subjects)
const directMatches = collectBindingsForSubject(rbacDefs, 'User', email);
const directBindings = directMatches.flatMap((m) => m.bindings);
const directSources = directMatches.map((m) => m.rbacName).join(', ');
// Inherited permissions (Group:name subjects)
const inheritedSections: PermissionSet[] = [];
if (userGroups) {
for (const groupName of userGroups) {
const groupMatches = collectBindingsForSubject(rbacDefs, 'Group', groupName);
const groupBindings = groupMatches.flatMap((m) => m.bindings);
if (groupBindings.length > 0) {
inheritedSections.push({ source: `Inherited (${groupName})`, bindings: groupBindings });
}
}
}
const sections: PermissionSet[] = [];
if (directBindings.length > 0) {
sections.push({ source: `Direct (${directSources})`, bindings: directBindings });
}
sections.push(...inheritedSections);
if (sections.length > 0) {
lines.push('');
lines.push('Access:');
lines.push(...formatPermissionSections(sections));
} else {
lines.push('');
lines.push('Access: (none)');
}
}
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${user.id}`);
if (user.createdAt) lines.push(` ${pad('Created:', 12)}${user.createdAt}`);
if (user.updatedAt) lines.push(` ${pad('Updated:', 12)}${user.updatedAt}`);
return lines.join('\n');
}
function formatGroupDetail(group: Record<string, unknown>, rbacDefs?: RbacDef[]): string {
const lines: string[] = [];
lines.push(`=== Group: ${group.name} ===`);
lines.push(`${pad('Name:')}${group.name}`);
if (group.description) lines.push(`${pad('Description:')}${group.description}`);
const members = group.members as Array<{ user: { email: string }; createdAt?: string }> | undefined;
if (members && members.length > 0) {
lines.push('');
lines.push('Members:');
const emailW = Math.max(6, ...members.map((m) => m.user.email.length)) + 2;
lines.push(` ${'EMAIL'.padEnd(emailW)}ADDED`);
for (const m of members) {
const added = (m.createdAt as string | undefined) ?? '-';
lines.push(` ${m.user.email.padEnd(emailW)}${added}`);
}
}
if (rbacDefs) {
const groupName = group.name as string;
const matches = collectBindingsForSubject(rbacDefs, 'Group', groupName);
const allBindings = matches.flatMap((m) => m.bindings);
const sources = matches.map((m) => m.rbacName).join(', ');
if (allBindings.length > 0) {
const sections: PermissionSet[] = [{ source: `Granted (${sources})`, bindings: allBindings }];
lines.push('');
lines.push('Access:');
lines.push(...formatPermissionSections(sections));
} else {
lines.push('');
lines.push('Access: (none)');
}
}
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${group.id}`);
if (group.createdAt) lines.push(` ${pad('Created:', 12)}${group.createdAt}`);
if (group.updatedAt) lines.push(` ${pad('Updated:', 12)}${group.updatedAt}`);
return lines.join('\n');
}
function formatRbacDetail(rbac: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`=== RBAC: ${rbac.name} ===`);
lines.push(`${pad('Name:')}${rbac.name}`);
const subjects = rbac.subjects as Array<{ kind: string; name: string }> | undefined;
if (subjects && subjects.length > 0) {
lines.push('');
lines.push('Subjects:');
const kindW = Math.max(6, ...subjects.map((s) => s.kind.length)) + 2;
lines.push(` ${'KIND'.padEnd(kindW)}NAME`);
for (const s of subjects) {
lines.push(` ${s.kind.padEnd(kindW)}${s.name}`);
}
}
const roleBindings = rbac.roleBindings as Array<{ role: string; resource?: string; action?: string; name?: string }> | undefined;
if (roleBindings && roleBindings.length > 0) {
// Separate resource bindings from operation bindings
const resourceBindings = roleBindings.filter((b) => 'resource' in b && b.resource !== undefined);
const operationBindings = roleBindings.filter((b) => 'action' in b && b.action !== undefined);
if (resourceBindings.length > 0) {
lines.push('');
lines.push('Resource Bindings:');
const roleW = Math.max(6, ...resourceBindings.map((b) => b.role.length)) + 2;
const resW = Math.max(10, ...resourceBindings.map((b) => (b.resource ?? '').length)) + 2;
const hasName = resourceBindings.some((b) => b.name);
if (hasName) {
lines.push(` ${'ROLE'.padEnd(roleW)}${'RESOURCE'.padEnd(resW)}NAME`);
} else {
lines.push(` ${'ROLE'.padEnd(roleW)}RESOURCE`);
}
for (const b of resourceBindings) {
if (hasName) {
lines.push(` ${b.role.padEnd(roleW)}${(b.resource ?? '').padEnd(resW)}${b.name ?? '*'}`);
} else {
lines.push(` ${b.role.padEnd(roleW)}${b.resource}`);
}
}
}
if (operationBindings.length > 0) {
lines.push('');
lines.push('Operations:');
lines.push(` ${'ACTION'.padEnd(20)}ROLE`);
for (const b of operationBindings) {
lines.push(` ${(b.action ?? '').padEnd(20)}${b.role}`);
}
}
}
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${rbac.id}`);
if (rbac.createdAt) lines.push(` ${pad('Created:', 12)}${rbac.createdAt}`);
if (rbac.updatedAt) lines.push(` ${pad('Updated:', 12)}${rbac.updatedAt}`);
return lines.join('\n');
}
function formatGenericDetail(obj: Record<string, unknown>): string { function formatGenericDetail(obj: Record<string, unknown>): string {
const lines: string[] = []; const lines: string[] = [];
for (const [key, value] of Object.entries(obj)) { for (const [key, value] of Object.entries(obj)) {
@@ -167,19 +528,42 @@ function formatGenericDetail(obj: Record<string, unknown>): string {
export function createDescribeCommand(deps: DescribeCommandDeps): Command { export function createDescribeCommand(deps: DescribeCommandDeps): Command {
return new Command('describe') return new Command('describe')
.description('Show detailed information about a resource') .description('Show detailed information about a resource')
.argument('<resource>', 'resource type (server, profile, project, instance)') .argument('<resource>', 'resource type (server, project, instance)')
.argument('<id>', 'resource ID or name') .argument('<id>', 'resource ID or name')
.option('-o, --output <format>', 'output format (detail, json, yaml)', 'detail') .option('-o, --output <format>', 'output format (detail, json, yaml)', 'detail')
.action(async (resourceArg: string, idOrName: 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 resource = resolveResource(resourceArg);
// Resolve name → ID // Resolve name → ID
let id: string; let id: string;
if (resource === 'instances') {
// Instances: accept instance ID or server name (resolve to first running instance)
try {
id = await resolveNameOrId(deps.client, resource, idOrName);
} catch {
// Not an instance ID — try as server name
const servers = await deps.client.get<Array<{ id: string; name: string }>>('/api/v1/servers');
const server = servers.find((s) => s.name === idOrName || s.id === idOrName);
if (server) {
const instances = await deps.client.get<Array<{ id: string; status: string }>>(`/api/v1/instances?serverId=${server.id}`);
const running = instances.find((i) => i.status === 'RUNNING') ?? instances[0];
if (running) {
id = running.id;
} else {
throw new Error(`No instances found for server '${idOrName}'`);
}
} else {
id = idOrName;
}
}
} else {
try { try {
id = await resolveNameOrId(deps.client, resource, idOrName); id = await resolveNameOrId(deps.client, resource, idOrName);
} catch { } catch {
id = idOrName; id = idOrName;
} }
}
const item = await deps.fetchResource(resource, id) as Record<string, unknown>; const item = await deps.fetchResource(resource, id) as Record<string, unknown>;
@@ -207,12 +591,36 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
case 'instances': case 'instances':
deps.log(formatInstanceDetail(item, inspect)); deps.log(formatInstanceDetail(item, inspect));
break; break;
case 'profiles': case 'secrets':
deps.log(formatProfileDetail(item)); deps.log(formatSecretDetail(item, opts.showValues === true));
break;
case 'templates':
deps.log(formatTemplateDetail(item));
break; break;
case 'projects': case 'projects':
deps.log(formatProjectDetail(item)); deps.log(formatProjectDetail(item));
break; break;
case 'users': {
// Fetch RBAC definitions and groups to show permissions
const [rbacDefsForUser, allGroupsForUser] = await Promise.all([
deps.client.get<RbacDef[]>('/api/v1/rbac').catch(() => [] as RbacDef[]),
deps.client.get<Array<{ name: string; members?: Array<{ user: { email: string } }> }>>('/api/v1/groups').catch(() => []),
]);
const userEmail = item.email as string;
const userGroupNames = allGroupsForUser
.filter((g) => g.members?.some((m) => m.user.email === userEmail))
.map((g) => g.name);
deps.log(formatUserDetail(item, rbacDefsForUser, userGroupNames));
break;
}
case 'groups': {
const rbacDefsForGroup = await deps.client.get<RbacDef[]>('/api/v1/rbac').catch(() => [] as RbacDef[]);
deps.log(formatGroupDetail(item, rbacDefsForGroup));
break;
}
case 'rbac':
deps.log(formatRbacDetail(item));
break;
default: default:
deps.log(formatGenericDetail(item)); deps.log(formatGenericDetail(item));
} }

View File

@@ -33,8 +33,8 @@ export function createEditCommand(deps: EditCommandDeps): Command {
const { client, log } = deps; const { client, log } = deps;
return new Command('edit') return new Command('edit')
.description('Edit a resource in your default editor (server, profile, project)') .description('Edit a resource in your default editor (server, project)')
.argument('<resource>', 'Resource type (server, profile, project)') .argument('<resource>', 'Resource type (server, project)')
.argument('<name-or-id>', 'Resource name or ID') .argument('<name-or-id>', 'Resource name or ID')
.action(async (resourceArg: string, nameOrId: string) => { .action(async (resourceArg: string, nameOrId: string) => {
const resource = resolveResource(resourceArg); const resource = resolveResource(resourceArg);
@@ -47,7 +47,7 @@ export function createEditCommand(deps: EditCommandDeps): Command {
return; return;
} }
const validResources = ['servers', 'profiles', 'projects']; const validResources = ['servers', 'secrets', 'projects', 'groups', 'rbac'];
if (!validResources.includes(resource)) { if (!validResources.includes(resource)) {
log(`Error: unknown resource type '${resourceArg}'`); log(`Error: unknown resource type '${resourceArg}'`);
process.exitCode = 1; process.exitCode = 1;

View File

@@ -17,25 +17,39 @@ interface ServerRow {
dockerImage: string | null; dockerImage: string | null;
} }
interface ProfileRow {
id: string;
name: string;
serverId: string;
}
interface ProjectRow { interface ProjectRow {
id: string; id: string;
name: string; name: string;
description: string; description: string;
proxyMode: string;
ownerId: string; ownerId: string;
servers?: Array<{ server: { name: string } }>;
members?: Array<{ user: { email: string }; role: 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 { interface InstanceRow {
id: string; id: string;
serverId: string; serverId: string;
server?: { name: string };
status: string; status: string;
containerId: string | null; containerId: string | null;
port: number | null; port: number | null;
healthStatus: string | null;
} }
const serverColumns: Column<ServerRow>[] = [ const serverColumns: Column<ServerRow>[] = [
@@ -46,22 +60,82 @@ const serverColumns: Column<ServerRow>[] = [
{ header: 'ID', key: 'id' }, { header: 'ID', key: 'id' },
]; ];
const profileColumns: Column<ProfileRow>[] = [ interface UserRow {
{ header: 'NAME', key: 'name' }, id: string;
{ header: 'SERVER ID', key: 'serverId' }, email: string;
{ header: 'ID', key: 'id' }, name: string | null;
]; provider: string | null;
}
interface GroupRow {
id: string;
name: string;
description: string;
members?: Array<{ user: { email: string } }>;
}
interface RbacRow {
id: string;
name: string;
subjects: Array<{ kind: string; name: string }>;
roleBindings: Array<{ role: string; resource?: string; action?: string; name?: string }>;
}
const projectColumns: Column<ProjectRow>[] = [ const projectColumns: Column<ProjectRow>[] = [
{ header: 'NAME', key: 'name' }, { header: 'NAME', key: 'name' },
{ header: 'DESCRIPTION', key: 'description', width: 40 }, { header: 'MODE', key: (r) => r.proxyMode ?? 'direct', width: 10 },
{ header: 'OWNER', key: 'ownerId' }, { header: 'SERVERS', key: (r) => r.servers ? String(r.servers.length) : '0', width: 8 },
{ header: 'MEMBERS', key: (r) => r.members ? String(r.members.length) : '0', width: 8 },
{ header: 'DESCRIPTION', key: 'description', width: 30 },
{ header: 'ID', key: 'id' }, { header: 'ID', key: 'id' },
]; ];
const userColumns: Column<UserRow>[] = [
{ header: 'EMAIL', key: 'email' },
{ header: 'NAME', key: (r) => r.name ?? '-' },
{ header: 'PROVIDER', key: (r) => r.provider ?? 'local', width: 10 },
{ header: 'ID', key: 'id' },
];
const groupColumns: Column<GroupRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'MEMBERS', key: (r) => r.members ? String(r.members.length) : '0', width: 8 },
{ header: 'DESCRIPTION', key: 'description', width: 40 },
{ header: 'ID', key: 'id' },
];
const rbacColumns: Column<RbacRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'SUBJECTS', key: (r) => r.subjects.map((s) => `${s.kind}:${s.name}`).join(', '), width: 30 },
{ header: 'BINDINGS', key: (r) => r.roleBindings.map((b) => {
if ('action' in b && b.action !== undefined) return `run>${b.action}`;
if ('resource' in b && b.resource !== undefined) {
const base = `${b.role}:${b.resource}`;
return b.name ? `${base}:${b.name}` : base;
}
return b.role;
}).join(', '), width: 40 },
{ header: 'ID', key: 'id' },
];
const secretColumns: Column<SecretRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'KEYS', key: (r) => Object.keys(r.data).join(', ') || '-', width: 40 },
{ header: 'ID', key: 'id' },
];
const templateColumns: Column<TemplateRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'VERSION', key: 'version', width: 10 },
{ header: 'TRANSPORT', key: 'transport', width: 16 },
{ header: 'PACKAGE', key: (r) => r.packageName ?? '-' },
{ header: 'DESCRIPTION', key: 'description', width: 50 },
];
const instanceColumns: Column<InstanceRow>[] = [ const instanceColumns: Column<InstanceRow>[] = [
{ header: 'NAME', key: (r) => r.server?.name ?? '-', width: 20 },
{ header: 'STATUS', key: 'status', width: 10 }, { 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: '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: 'CONTAINER', key: (r) => r.containerId ? r.containerId.slice(0, 12) : '-', width: 14 },
{ header: 'ID', key: 'id' }, { header: 'ID', key: 'id' },
@@ -71,12 +145,20 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
switch (resource) { switch (resource) {
case 'servers': case 'servers':
return serverColumns as unknown as Column<Record<string, unknown>>[]; return serverColumns as unknown as Column<Record<string, unknown>>[];
case 'profiles':
return profileColumns as unknown as Column<Record<string, unknown>>[];
case 'projects': case 'projects':
return projectColumns as unknown as Column<Record<string, unknown>>[]; 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': case 'instances':
return instanceColumns as unknown as Column<Record<string, unknown>>[]; return instanceColumns as unknown as Column<Record<string, unknown>>[];
case 'users':
return userColumns as unknown as Column<Record<string, unknown>>[];
case 'groups':
return groupColumns as unknown as Column<Record<string, unknown>>[];
case 'rbac':
return rbacColumns as unknown as Column<Record<string, unknown>>[];
default: default:
return [ return [
{ header: 'ID', key: 'id' as keyof Record<string, unknown> }, { header: 'ID', key: 'id' as keyof Record<string, unknown> },
@@ -91,21 +173,15 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
*/ */
function toApplyFormat(resource: string, items: unknown[]): Record<string, unknown[]> { function toApplyFormat(resource: string, items: unknown[]): Record<string, unknown[]> {
const cleaned = items.map((item) => { const cleaned = items.map((item) => {
const obj = stripInternalFields(item as Record<string, unknown>); return stripInternalFields(item as Record<string, unknown>);
// For profiles: convert serverId → server (name) for apply compat
// We can't resolve the name here without an API call, so keep serverId
// but also remove it's not in the apply schema. Actually profiles use
// "server" (name) in apply format but serverId from API. Keep serverId
// since it can still be used with apply (the apply command resolves names).
return obj;
}); });
return { [resource]: cleaned }; return { [resource]: cleaned };
} }
export function createGetCommand(deps: GetCommandDeps): Command { export function createGetCommand(deps: GetCommandDeps): Command {
return new Command('get') return new Command('get')
.description('List resources (servers, profiles, projects, instances)') .description('List resources (servers, projects, instances)')
.argument('<resource>', 'resource type (servers, profiles, projects, instances)') .argument('<resource>', 'resource type (servers, projects, instances)')
.argument('[id]', 'specific resource ID or name') .argument('[id]', 'specific resource ID or name')
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table') .option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
.action(async (resourceArg: string, id: string | undefined, opts: { output: string }) => { .action(async (resourceArg: string, id: string | undefined, opts: { output: string }) => {

View File

@@ -6,15 +6,84 @@ export interface LogsCommandDeps {
log: (...args: unknown[]) => void; log: (...args: unknown[]) => void;
} }
interface InstanceInfo {
id: string;
status: string;
containerId: string | null;
}
/**
* Resolve a name/ID to an instance ID.
* Accepts: instance ID, server name, or server ID.
* For servers with multiple replicas, picks by --instance index or first RUNNING.
*/
async function resolveInstance(
client: ApiClient,
nameOrId: string,
instanceIndex?: number,
): Promise<{ instanceId: string; serverName?: string; replicaInfo?: string }> {
// Try as instance ID first
try {
await client.get(`/api/v1/instances/${nameOrId}`);
return { instanceId: nameOrId };
} catch {
// Not a valid instance ID
}
// Try as server name/ID → find its instances
const servers = await client.get<Array<{ id: string; name: string }>>('/api/v1/servers');
const server = servers.find((s) => s.name === nameOrId || s.id === nameOrId);
if (!server) {
throw new Error(`Instance or server '${nameOrId}' not found`);
}
const instances = await client.get<InstanceInfo[]>(`/api/v1/instances?serverId=${server.id}`);
if (instances.length === 0) {
throw new Error(`No instances found for server '${server.name}'`);
}
// Select by index or pick first running
let selected: InstanceInfo | undefined;
if (instanceIndex !== undefined) {
if (instanceIndex < 0 || instanceIndex >= instances.length) {
throw new Error(`Instance index ${instanceIndex} out of range (server '${server.name}' has ${instances.length} instance${instances.length > 1 ? 's' : ''})`);
}
selected = instances[instanceIndex];
} else {
selected = instances.find((i) => i.status === 'RUNNING') ?? instances[0];
}
if (!selected) {
throw new Error(`No instances found for server '${server.name}'`);
}
const result: { instanceId: string; serverName?: string; replicaInfo?: string } = {
instanceId: selected.id,
serverName: server.name,
};
if (instances.length > 1) {
result.replicaInfo = `instance ${instances.indexOf(selected) + 1}/${instances.length}`;
}
return result;
}
export function createLogsCommand(deps: LogsCommandDeps): Command { export function createLogsCommand(deps: LogsCommandDeps): Command {
const { client, log } = deps; const { client, log } = deps;
return new Command('logs') return new Command('logs')
.description('Get logs from an MCP server instance') .description('Get logs from an MCP server instance')
.argument('<instance-id>', 'Instance ID') .argument('<name>', 'Server name, server ID, or instance ID')
.option('-t, --tail <lines>', 'Number of lines to show') .option('-t, --tail <lines>', 'Number of lines to show')
.action(async (id: string, opts: { tail?: string }) => { .option('-i, --instance <index>', 'Instance/replica index (0-based, for servers with multiple replicas)')
let url = `/api/v1/instances/${id}/logs`; .action(async (nameOrId: string, opts: { tail?: string; instance?: string }) => {
const instanceIndex = opts.instance !== undefined ? parseInt(opts.instance, 10) : undefined;
const { instanceId, serverName, replicaInfo } = await resolveInstance(client, nameOrId, instanceIndex);
if (replicaInfo) {
process.stderr.write(`Showing logs for ${serverName} (${replicaInfo})\n`);
}
let url = `/api/v1/instances/${instanceId}/logs`;
if (opts.tail) { if (opts.tail) {
url += `?tail=${opts.tail}`; url += `?tail=${opts.tail}`;
} }

View File

@@ -1,60 +0,0 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
interface Project {
id: string;
name: string;
description: string;
ownerId: string;
createdAt: string;
}
interface Profile {
id: string;
name: string;
serverId: string;
}
export interface ProjectCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
}
export function createProjectCommand(deps: ProjectCommandDeps): Command {
const { client, log } = deps;
const cmd = new Command('project')
.alias('proj')
.description('Project-specific actions (create with "create project", list with "get projects")');
cmd
.command('profiles <id>')
.description('List profiles assigned to a project')
.option('-o, --output <format>', 'Output format (table, json)', 'table')
.action(async (id: string, opts: { output: string }) => {
const profiles = await client.get<Profile[]>(`/api/v1/projects/${id}/profiles`);
if (opts.output === 'json') {
log(JSON.stringify(profiles, null, 2));
return;
}
if (profiles.length === 0) {
log('No profiles assigned.');
return;
}
log('ID\tNAME\tSERVER');
for (const p of profiles) {
log(`${p.id}\t${p.name}\t${p.serverId}`);
}
});
cmd
.command('set-profiles <id>')
.description('Set the profiles assigned to a project')
.argument('<profileIds...>', 'Profile IDs to assign')
.action(async (id: string, profileIds: string[]) => {
await client.put(`/api/v1/projects/${id}/profiles`, { profileIds });
log(`Set ${profileIds.length} profile(s) for project '${id}'.`);
});
return cmd;
}

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

@@ -3,12 +3,19 @@ import type { ApiClient } from '../api-client.js';
export const RESOURCE_ALIASES: Record<string, string> = { export const RESOURCE_ALIASES: Record<string, string> = {
server: 'servers', server: 'servers',
srv: 'servers', srv: 'servers',
profile: 'profiles',
prof: 'profiles',
project: 'projects', project: 'projects',
proj: 'projects', proj: 'projects',
instance: 'instances', instance: 'instances',
inst: 'instances', inst: 'instances',
secret: 'secrets',
sec: 'secrets',
template: 'templates',
tpl: 'templates',
user: 'users',
group: 'groups',
rbac: 'rbac',
'rbac-definition': 'rbac',
'rbac-binding': 'rbac',
}; };
export function resolveResource(name: string): string { export function resolveResource(name: string): string {
@@ -26,9 +33,23 @@ export async function resolveNameOrId(
if (/^c[a-z0-9]{24}/.test(nameOrId)) { if (/^c[a-z0-9]{24}/.test(nameOrId)) {
return nameOrId; return nameOrId;
} }
const items = await client.get<Array<{ id: string; name: string }>>(`/api/v1/${resource}`); // Users resolve by email, not name
const match = items.find((item) => item.name === nameOrId); if (resource === 'users') {
const items = await client.get<Array<{ id: string; email: string }>>(`/api/v1/${resource}`);
const match = items.find((item) => item.email === nameOrId);
if (match) return match.id; if (match) return match.id;
throw new Error(`user '${nameOrId}' not found`);
}
const items = await client.get<Array<Record<string, unknown>>>(`/api/v1/${resource}`);
const match = items.find((item) => {
// Instances use server.name, other resources use name directly
if (resource === 'instances') {
const server = item.server as { name?: string } | undefined;
return server?.name === nameOrId;
}
return item.name === nameOrId;
});
if (match) return match.id as string;
throw new Error(`${resource.replace(/s$/, '')} '${nameOrId}' not found`); throw new Error(`${resource.replace(/s$/, '')} '${nameOrId}' not found`);
} }

View File

@@ -10,12 +10,9 @@ import { createLogsCommand } from './commands/logs.js';
import { createApplyCommand } from './commands/apply.js'; import { createApplyCommand } from './commands/apply.js';
import { createCreateCommand } from './commands/create.js'; import { createCreateCommand } from './commands/create.js';
import { createEditCommand } from './commands/edit.js'; import { createEditCommand } from './commands/edit.js';
import { createSetupCommand } from './commands/setup.js';
import { createClaudeCommand } from './commands/claude.js';
import { createProjectCommand } from './commands/project.js';
import { createBackupCommand, createRestoreCommand } from './commands/backup.js'; import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
import { createLoginCommand, createLogoutCommand } from './commands/auth.js'; import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
import { ApiClient } from './api-client.js'; import { ApiClient, ApiError } from './api-client.js';
import { loadConfig } from './config/index.js'; import { loadConfig } from './config/index.js';
import { loadCredentials } from './auth/index.js'; import { loadCredentials } from './auth/index.js';
import { resolveNameOrId } from './commands/shared.js'; import { resolveNameOrId } from './commands/shared.js';
@@ -25,11 +22,10 @@ export function createProgram(): Command {
.name(APP_NAME) .name(APP_NAME)
.description('Manage MCP servers like kubectl manages containers') .description('Manage MCP servers like kubectl manages containers')
.version(APP_VERSION, '-v, --version') .version(APP_VERSION, '-v, --version')
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table') .enablePositionalOptions()
.option('--daemon-url <url>', 'mcplocal daemon URL') .option('--daemon-url <url>', 'mcplocal daemon URL')
.option('--direct', 'bypass mcplocal and connect directly to mcpd'); .option('--direct', 'bypass mcplocal and connect directly to mcpd');
program.addCommand(createConfigCommand());
program.addCommand(createStatusCommand()); program.addCommand(createStatusCommand());
program.addCommand(createLoginCommand()); program.addCommand(createLoginCommand());
program.addCommand(createLogoutCommand()); program.addCommand(createLogoutCommand());
@@ -49,8 +45,18 @@ export function createProgram(): Command {
const client = new ApiClient({ baseUrl, token: creds?.token ?? undefined }); const client = new ApiClient({ baseUrl, token: creds?.token ?? undefined });
program.addCommand(createConfigCommand(undefined, {
client,
credentialsDeps: {},
log: (...args) => console.log(...args),
}));
const fetchResource = async (resource: string, nameOrId?: string): Promise<unknown[]> => { const fetchResource = async (resource: string, nameOrId?: string): Promise<unknown[]> => {
if (nameOrId) { if (nameOrId) {
// Glob pattern — use query param filtering
if (nameOrId.includes('*')) {
return client.get<unknown[]>(`/api/v1/${resource}?name=${encodeURIComponent(nameOrId)}`);
}
let id: string; let id: string;
try { try {
id = await resolveNameOrId(client, resource, nameOrId); id = await resolveNameOrId(client, resource, nameOrId);
@@ -110,43 +116,6 @@ export function createProgram(): Command {
log: (...args) => console.log(...args), log: (...args) => console.log(...args),
})); }));
program.addCommand(createSetupCommand({
client,
prompt: {
async input(message) {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{ type: 'input', name: 'answer', message }]);
return answer as string;
},
async password(message) {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{ type: 'password', name: 'answer', message }]);
return answer as string;
},
async select(message, choices) {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{ type: 'list', name: 'answer', message, choices }]);
return answer;
},
async confirm(message) {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{ type: 'confirm', name: 'answer', message }]);
return answer as boolean;
},
},
log: (...args) => console.log(...args),
}));
program.addCommand(createClaudeCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createProjectCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createBackupCommand({ program.addCommand(createBackupCommand({
client, client,
log: (...args) => console.log(...args), log: (...args) => console.log(...args),
@@ -167,5 +136,35 @@ const isDirectRun =
import.meta.url === `file://${process.argv[1]}`; import.meta.url === `file://${process.argv[1]}`;
if (isDirectRun) { if (isDirectRun) {
createProgram().parseAsync(process.argv); createProgram().parseAsync(process.argv).catch((err: unknown) => {
if (err instanceof ApiError) {
if (err.status === 401) {
console.error("Error: you need to log in. Run 'mcpctl login' to authenticate.");
} else if (err.status === 403) {
console.error('Error: permission denied. You do not have access to this resource.');
} else {
let msg: string;
try {
const parsed = JSON.parse(err.body) as { error?: string; message?: string; details?: unknown };
msg = parsed.error ?? parsed.message ?? err.body;
if (parsed.details && Array.isArray(parsed.details)) {
const issues = parsed.details as Array<{ message?: string; path?: string[] }>;
const detail = issues.map((i) => {
const path = i.path?.join('.') ?? '';
return path ? `${path}: ${i.message}` : (i.message ?? '');
}).filter(Boolean).join('; ');
if (detail) msg += `: ${detail}`;
}
} catch {
msg = err.body;
}
console.error(`Error: ${msg}`);
}
} else if (err instanceof Error) {
console.error(`Error: ${err.message}`);
} else {
console.error(`Error: ${String(err)}`);
}
process.exit(1);
});
} }

View File

@@ -24,9 +24,10 @@ describe('createProgram', () => {
expect(status).toBeDefined(); expect(status).toBeDefined();
}); });
it('has output option', () => { it('subcommands have output option', () => {
const program = createProgram(); 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(); expect(opt).toBeDefined();
}); });

View File

@@ -86,9 +86,6 @@ servers:
servers: servers:
- name: test - name: test
transport: STDIO transport: STDIO
profiles:
- name: default
server: test
`); `);
const cmd = createApplyCommand({ client, log }); const cmd = createApplyCommand({ client, log });
@@ -97,52 +94,51 @@ profiles:
expect(client.post).not.toHaveBeenCalled(); expect(client.post).not.toHaveBeenCalled();
expect(output.join('\n')).toContain('Dry run'); expect(output.join('\n')).toContain('Dry run');
expect(output.join('\n')).toContain('1 server(s)'); expect(output.join('\n')).toContain('1 server(s)');
expect(output.join('\n')).toContain('1 profile(s)');
rmSync(tmpDir, { recursive: true, force: true }); 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) => { 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 []; return [];
}); });
const configPath = join(tmpDir, 'config.yaml'); const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, ` writeFileSync(configPath, `
profiles: secrets:
- name: default - name: ha-creds
server: slack data:
envOverrides: TOKEN: new-token
SLACK_TOKEN: "xoxb-test"
`); `);
const cmd = createApplyCommand({ client, log }); const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' }); await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({ expect(client.put).toHaveBeenCalledWith('/api/v1/secrets/sec-1', { data: { TOKEN: 'new-token' } });
name: 'default', expect(output.join('\n')).toContain('Updated secret: ha-creds');
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'");
rmSync(tmpDir, { recursive: true, force: true }); rmSync(tmpDir, { recursive: true, force: true });
}); });
@@ -163,4 +159,351 @@ projects:
rmSync(tmpDir, { recursive: true, force: true }); rmSync(tmpDir, { recursive: true, force: true });
}); });
it('applies users (no role field)', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
users:
- email: alice@test.com
password: password123
name: Alice
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
const callBody = vi.mocked(client.post).mock.calls[0]![1] as Record<string, unknown>;
expect(callBody).toEqual(expect.objectContaining({
email: 'alice@test.com',
password: 'password123',
name: 'Alice',
}));
expect(callBody).not.toHaveProperty('role');
expect(output.join('\n')).toContain('Created user: alice@test.com');
rmSync(tmpDir, { recursive: true, force: true });
});
it('updates existing users matched by email', async () => {
vi.mocked(client.get).mockImplementation(async (url: string) => {
if (url === '/api/v1/users') return [{ id: 'usr-1', email: 'alice@test.com' }];
return [];
});
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
users:
- email: alice@test.com
password: newpassword
name: Alice Updated
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/users/usr-1', expect.objectContaining({
email: 'alice@test.com',
name: 'Alice Updated',
}));
expect(output.join('\n')).toContain('Updated user: alice@test.com');
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies groups', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
groups:
- name: dev-team
description: Development team
members:
- alice@test.com
- bob@test.com
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/groups', expect.objectContaining({
name: 'dev-team',
description: 'Development team',
members: ['alice@test.com', 'bob@test.com'],
}));
expect(output.join('\n')).toContain('Created group: dev-team');
rmSync(tmpDir, { recursive: true, force: true });
});
it('updates existing groups', async () => {
vi.mocked(client.get).mockImplementation(async (url: string) => {
if (url === '/api/v1/groups') return [{ id: 'grp-1', name: 'dev-team' }];
return [];
});
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
groups:
- name: dev-team
description: Updated devs
members:
- new@test.com
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/groups/grp-1', expect.objectContaining({
name: 'dev-team',
description: 'Updated devs',
}));
expect(output.join('\n')).toContain('Updated group: dev-team');
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies rbacBindings', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
rbac:
- name: developers
subjects:
- kind: User
name: alice@test.com
- kind: Group
name: dev-team
roleBindings:
- role: edit
resource: servers
- role: view
resource: instances
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', expect.objectContaining({
name: 'developers',
subjects: [
{ kind: 'User', name: 'alice@test.com' },
{ kind: 'Group', name: 'dev-team' },
],
roleBindings: [
{ role: 'edit', resource: 'servers' },
{ role: 'view', resource: 'instances' },
],
}));
expect(output.join('\n')).toContain('Created rbacBinding: developers');
rmSync(tmpDir, { recursive: true, force: true });
});
it('updates existing rbacBindings', async () => {
vi.mocked(client.get).mockImplementation(async (url: string) => {
if (url === '/api/v1/rbac') return [{ id: 'rbac-1', name: 'developers' }];
return [];
});
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
rbacBindings:
- name: developers
subjects:
- kind: User
name: new@test.com
roleBindings:
- role: edit
resource: "*"
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/rbac/rbac-1', expect.objectContaining({
name: 'developers',
}));
expect(output.join('\n')).toContain('Updated rbacBinding: developers');
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies projects with servers and members', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
projects:
- name: smart-home
description: Home automation
proxyMode: filtered
llmProvider: gemini-cli
llmModel: gemini-2.0-flash
servers:
- my-grafana
- my-ha
members:
- alice@test.com
- bob@test.com
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
name: 'smart-home',
proxyMode: 'filtered',
llmProvider: 'gemini-cli',
llmModel: 'gemini-2.0-flash',
servers: ['my-grafana', 'my-ha'],
members: ['alice@test.com', 'bob@test.com'],
}));
expect(output.join('\n')).toContain('Created project: smart-home');
rmSync(tmpDir, { recursive: true, force: true });
});
it('dry-run shows all new resource types', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
secrets:
- name: creds
data:
TOKEN: abc
users:
- email: alice@test.com
password: password123
groups:
- name: dev-team
members: []
projects:
- name: my-proj
description: A project
rbacBindings:
- name: admins
subjects:
- kind: User
name: admin@test.com
roleBindings:
- role: edit
resource: "*"
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath, '--dry-run'], { from: 'user' });
expect(client.post).not.toHaveBeenCalled();
const text = output.join('\n');
expect(text).toContain('Dry run');
expect(text).toContain('1 secret(s)');
expect(text).toContain('1 user(s)');
expect(text).toContain('1 group(s)');
expect(text).toContain('1 project(s)');
expect(text).toContain('1 rbacBinding(s)');
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies resources in correct order', async () => {
const callOrder: string[] = [];
vi.mocked(client.post).mockImplementation(async (url: string) => {
callOrder.push(url);
return { id: 'new-id', name: 'test' };
});
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
rbacBindings:
- name: admins
subjects:
- kind: User
name: admin@test.com
roleBindings:
- role: edit
resource: "*"
users:
- email: admin@test.com
password: password123
secrets:
- name: creds
data:
KEY: val
groups:
- name: dev-team
servers:
- name: my-server
transport: STDIO
projects:
- name: my-proj
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
// Apply order: secrets → servers → users → groups → projects → templates → rbacBindings
expect(callOrder[0]).toBe('/api/v1/secrets');
expect(callOrder[1]).toBe('/api/v1/servers');
expect(callOrder[2]).toBe('/api/v1/users');
expect(callOrder[3]).toBe('/api/v1/groups');
expect(callOrder[4]).toBe('/api/v1/projects');
expect(callOrder[5]).toBe('/api/v1/rbac');
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies rbac with operation bindings', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
rbac:
- name: ops-team
subjects:
- kind: Group
name: ops
roleBindings:
- role: edit
resource: servers
- role: run
action: backup
- role: run
action: logs
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', expect.objectContaining({
name: 'ops-team',
roleBindings: [
{ role: 'edit', resource: 'servers' },
{ role: 'run', action: 'backup' },
{ role: 'run', action: 'logs' },
],
}));
expect(output.join('\n')).toContain('Created rbacBinding: ops-team');
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies rbac with name-scoped resource binding', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
rbac:
- name: ha-viewer
subjects:
- kind: User
name: alice@test.com
roleBindings:
- role: view
resource: servers
name: my-ha
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', expect.objectContaining({
name: 'ha-viewer',
roleBindings: [
{ role: 'view', resource: 'servers', name: 'my-ha' },
],
}));
rmSync(tmpDir, { recursive: true, force: true });
});
}); });

View File

@@ -37,6 +37,8 @@ describe('login command', () => {
user: { email }, user: { email },
}), }),
logoutRequest: async () => {}, logoutRequest: async () => {},
statusRequest: async () => ({ hasUsers: true }),
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
}); });
await cmd.parseAsync([], { from: 'user' }); await cmd.parseAsync([], { from: 'user' });
expect(output[0]).toContain('Logged in as alice@test.com'); expect(output[0]).toContain('Logged in as alice@test.com');
@@ -58,6 +60,8 @@ describe('login command', () => {
log, log,
loginRequest: async () => { throw new Error('Invalid credentials'); }, loginRequest: async () => { throw new Error('Invalid credentials'); },
logoutRequest: async () => {}, logoutRequest: async () => {},
statusRequest: async () => ({ hasUsers: true }),
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
}); });
await cmd.parseAsync([], { from: 'user' }); await cmd.parseAsync([], { from: 'user' });
expect(output[0]).toContain('Login failed'); expect(output[0]).toContain('Login failed');
@@ -83,6 +87,8 @@ describe('login command', () => {
return { token: 'tok', user: { email } }; return { token: 'tok', user: { email } };
}, },
logoutRequest: async () => {}, logoutRequest: async () => {},
statusRequest: async () => ({ hasUsers: true }),
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
}); });
await cmd.parseAsync([], { from: 'user' }); await cmd.parseAsync([], { from: 'user' });
expect(capturedUrl).toBe('http://custom:3100'); expect(capturedUrl).toBe('http://custom:3100');
@@ -103,12 +109,74 @@ describe('login command', () => {
return { token: 'tok', user: { email } }; return { token: 'tok', user: { email } };
}, },
logoutRequest: async () => {}, logoutRequest: async () => {},
statusRequest: async () => ({ hasUsers: true }),
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
}); });
await cmd.parseAsync(['--mcpd-url', 'http://override:3100'], { from: 'user' }); await cmd.parseAsync(['--mcpd-url', 'http://override:3100'], { from: 'user' });
expect(capturedUrl).toBe('http://override:3100'); expect(capturedUrl).toBe('http://override:3100');
}); });
}); });
describe('login bootstrap flow', () => {
it('bootstraps first admin when no users exist', async () => {
let bootstrapCalled = false;
const cmd = createLoginCommand({
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
prompt: {
input: async (msg) => {
if (msg.includes('Name')) return 'Admin User';
return 'admin@test.com';
},
password: async () => 'admin-pass',
},
log,
loginRequest: async () => ({ token: '', user: { email: '' } }),
logoutRequest: async () => {},
statusRequest: async () => ({ hasUsers: false }),
bootstrapRequest: async (_url, email, _password) => {
bootstrapCalled = true;
return { token: 'admin-token', user: { email } };
},
});
await cmd.parseAsync([], { from: 'user' });
expect(bootstrapCalled).toBe(true);
expect(output.join('\n')).toContain('No users configured');
expect(output.join('\n')).toContain('admin@test.com');
expect(output.join('\n')).toContain('admin');
const creds = loadCredentials({ configDir: tempDir });
expect(creds).not.toBeNull();
expect(creds!.token).toBe('admin-token');
expect(creds!.user).toBe('admin@test.com');
});
it('falls back to normal login when users exist', async () => {
let loginCalled = false;
const cmd = createLoginCommand({
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
prompt: {
input: async () => 'alice@test.com',
password: async () => 'secret',
},
log,
loginRequest: async (_url, email) => {
loginCalled = true;
return { token: 'session-tok', user: { email } };
},
logoutRequest: async () => {},
statusRequest: async () => ({ hasUsers: true }),
bootstrapRequest: async () => { throw new Error('Should not be called'); },
});
await cmd.parseAsync([], { from: 'user' });
expect(loginCalled).toBe(true);
expect(output.join('\n')).not.toContain('No users configured');
});
});
describe('logout command', () => { describe('logout command', () => {
it('removes credentials on logout', async () => { it('removes credentials on logout', async () => {
saveCredentials({ token: 'tok', mcpdUrl: 'http://x:3100', user: 'alice' }, { configDir: tempDir }); saveCredentials({ token: 'tok', mcpdUrl: 'http://x:3100', user: 'alice' }, { configDir: tempDir });
@@ -120,6 +188,8 @@ describe('logout command', () => {
log, log,
loginRequest: async () => ({ token: '', user: { email: '' } }), loginRequest: async () => ({ token: '', user: { email: '' } }),
logoutRequest: async () => { logoutCalled = true; }, logoutRequest: async () => { logoutCalled = true; },
statusRequest: async () => ({ hasUsers: true }),
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
}); });
await cmd.parseAsync([], { from: 'user' }); await cmd.parseAsync([], { from: 'user' });
expect(output[0]).toContain('Logged out successfully'); expect(output[0]).toContain('Logged out successfully');
@@ -137,6 +207,8 @@ describe('logout command', () => {
log, log,
loginRequest: async () => ({ token: '', user: { email: '' } }), loginRequest: async () => ({ token: '', user: { email: '' } }),
logoutRequest: async () => {}, logoutRequest: async () => {},
statusRequest: async () => ({ hasUsers: true }),
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
}); });
await cmd.parseAsync([], { from: 'user' }); await cmd.parseAsync([], { from: 'user' });
expect(output[0]).toContain('Not logged in'); expect(output[0]).toContain('Not logged in');

View File

@@ -1,9 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs'; import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { createClaudeCommand } from '../../src/commands/claude.js'; import { createConfigCommand } from '../../src/commands/config.js';
import type { ApiClient } from '../../src/api-client.js'; import type { ApiClient } from '../../src/api-client.js';
import { saveCredentials, loadCredentials } from '../../src/auth/index.js';
function mockClient(): ApiClient { function mockClient(): ApiClient {
return { return {
@@ -13,44 +14,50 @@ function mockClient(): ApiClient {
'github--default': { command: 'npx', args: ['-y', '@anthropic/github-mcp'] }, 'github--default': { command: 'npx', args: ['-y', '@anthropic/github-mcp'] },
}, },
})), })),
post: vi.fn(async () => ({})), post: vi.fn(async () => ({ token: 'impersonated-tok', user: { email: 'other@test.com' } })),
put: vi.fn(async () => ({})), put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}), delete: vi.fn(async () => {}),
} as unknown as ApiClient; } as unknown as ApiClient;
} }
describe('claude command', () => { describe('config claude-generate', () => {
let client: ReturnType<typeof mockClient>; let client: ReturnType<typeof mockClient>;
let output: string[]; let output: string[];
let tmpDir: string; let tmpDir: string;
const log = (...args: unknown[]) => output.push(args.map(String).join(' ')); const log = (...args: string[]) => output.push(args.join(' '));
beforeEach(() => { beforeEach(() => {
client = mockClient(); client = mockClient();
output = []; output = [];
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-claude-')); tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-claude-'));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
}); });
describe('generate', () => {
it('generates .mcp.json from project config', async () => { it('generates .mcp.json from project config', async () => {
const outPath = join(tmpDir, '.mcp.json'); const outPath = join(tmpDir, '.mcp.json');
const cmd = createClaudeCommand({ client, log }); const cmd = createConfigCommand(
await cmd.parseAsync(['generate', 'proj-1', '-o', outPath], { from: 'user' }); { configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/proj-1/mcp-config'); expect(client.get).toHaveBeenCalledWith('/api/v1/projects/proj-1/mcp-config');
const written = JSON.parse(readFileSync(outPath, 'utf-8')); const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['slack--default']).toBeDefined(); expect(written.mcpServers['slack--default']).toBeDefined();
expect(output.join('\n')).toContain('2 server(s)'); expect(output.join('\n')).toContain('2 server(s)');
rmSync(tmpDir, { recursive: true, force: true });
}); });
it('prints to stdout with --stdout', async () => { it('prints to stdout with --stdout', async () => {
const cmd = createClaudeCommand({ client, log }); const cmd = createConfigCommand(
await cmd.parseAsync(['generate', 'proj-1', '--stdout'], { from: 'user' }); { configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '--stdout'], { from: 'user' });
expect(output[0]).toContain('mcpServers'); expect(output[0]).toContain('mcpServers');
rmSync(tmpDir, { recursive: true, force: true });
}); });
it('merges with existing .mcp.json', async () => { it('merges with existing .mcp.json', async () => {
@@ -59,100 +66,94 @@ describe('claude command', () => {
mcpServers: { 'existing--server': { command: 'echo', args: [] } }, mcpServers: { 'existing--server': { command: 'echo', args: [] } },
})); }));
const cmd = createClaudeCommand({ client, log }); const cmd = createConfigCommand(
await cmd.parseAsync(['generate', 'proj-1', '-o', outPath, '--merge'], { from: 'user' }); { configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath, '--merge'], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8')); const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['existing--server']).toBeDefined(); expect(written.mcpServers['existing--server']).toBeDefined();
expect(written.mcpServers['slack--default']).toBeDefined(); expect(written.mcpServers['slack--default']).toBeDefined();
expect(output.join('\n')).toContain('3 server(s)'); expect(output.join('\n')).toContain('3 server(s)');
});
rmSync(tmpDir, { recursive: true, force: true }); });
});
}); describe('config impersonate', () => {
let client: ReturnType<typeof mockClient>;
describe('show', () => { let output: string[];
it('shows servers in .mcp.json', () => { let tmpDir: string;
const filePath = join(tmpDir, '.mcp.json'); const log = (...args: string[]) => output.push(args.join(' '));
writeFileSync(filePath, JSON.stringify({
mcpServers: { beforeEach(() => {
'slack': { command: 'npx', args: ['-y', '@anthropic/slack-mcp'], env: { TOKEN: 'x' } }, client = mockClient();
}, output = [];
})); tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-impersonate-'));
});
const cmd = createClaudeCommand({ client, log });
cmd.parseAsync(['show', '-p', filePath], { from: 'user' }); afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
expect(output.join('\n')).toContain('slack'); });
expect(output.join('\n')).toContain('npx -y @anthropic/slack-mcp');
expect(output.join('\n')).toContain('TOKEN'); it('impersonates a user and saves backup', async () => {
saveCredentials({ token: 'admin-tok', mcpdUrl: 'http://localhost:3100', user: 'admin@test.com' }, { configDir: tmpDir });
rmSync(tmpDir, { recursive: true, force: true });
}); const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
it('handles missing file', () => { { client, credentialsDeps: { configDir: tmpDir }, log },
const cmd = createClaudeCommand({ client, log }); );
cmd.parseAsync(['show', '-p', join(tmpDir, 'nonexistent.json')], { from: 'user' }); await cmd.parseAsync(['impersonate', 'other@test.com'], { from: 'user' });
expect(output.join('\n')).toContain('No .mcp.json found'); expect(client.post).toHaveBeenCalledWith('/api/v1/auth/impersonate', { email: 'other@test.com' });
rmSync(tmpDir, { recursive: true, force: true }); expect(output.join('\n')).toContain('Impersonating other@test.com');
});
}); const creds = loadCredentials({ configDir: tmpDir });
expect(creds!.user).toBe('other@test.com');
describe('add', () => { expect(creds!.token).toBe('impersonated-tok');
it('adds a server entry', () => {
const filePath = join(tmpDir, '.mcp.json'); // Backup exists
const cmd = createClaudeCommand({ client, log }); const backup = JSON.parse(readFileSync(join(tmpDir, 'credentials-backup'), 'utf-8'));
cmd.parseAsync(['add', 'my-server', '-c', 'npx', '-a', '-y', 'my-pkg', '-p', filePath], { from: 'user' }); expect(backup.user).toBe('admin@test.com');
});
const written = JSON.parse(readFileSync(filePath, 'utf-8'));
expect(written.mcpServers['my-server']).toEqual({ it('quits impersonation and restores backup', async () => {
command: 'npx', // Set up current (impersonated) credentials
args: ['-y', 'my-pkg'], saveCredentials({ token: 'impersonated-tok', mcpdUrl: 'http://localhost:3100', user: 'other@test.com' }, { configDir: tmpDir });
}); // Set up backup (original) credentials
writeFileSync(join(tmpDir, 'credentials-backup'), JSON.stringify({
rmSync(tmpDir, { recursive: true, force: true }); token: 'admin-tok', mcpdUrl: 'http://localhost:3100', user: 'admin@test.com',
}); }));
it('adds server with env vars', () => { const cmd = createConfigCommand(
const filePath = join(tmpDir, '.mcp.json'); { configDeps: { configDir: tmpDir }, log },
const cmd = createClaudeCommand({ client, log }); { client, credentialsDeps: { configDir: tmpDir }, log },
cmd.parseAsync(['add', 'my-server', '-c', 'node', '-e', 'KEY=val', 'SECRET=abc', '-p', filePath], { from: 'user' }); );
await cmd.parseAsync(['impersonate', '--quit'], { from: 'user' });
const written = JSON.parse(readFileSync(filePath, 'utf-8'));
expect(written.mcpServers['my-server'].env).toEqual({ KEY: 'val', SECRET: 'abc' }); expect(output.join('\n')).toContain('Returned to admin@test.com');
rmSync(tmpDir, { recursive: true, force: true }); const creds = loadCredentials({ configDir: tmpDir });
}); expect(creds!.user).toBe('admin@test.com');
}); expect(creds!.token).toBe('admin-tok');
});
describe('remove', () => {
it('removes a server entry', () => { it('errors when not logged in', async () => {
const filePath = join(tmpDir, '.mcp.json'); const cmd = createConfigCommand(
writeFileSync(filePath, JSON.stringify({ { configDeps: { configDir: tmpDir }, log },
mcpServers: { 'slack': { command: 'npx', args: [] }, 'github': { command: 'npx', args: [] } }, { client, credentialsDeps: { configDir: tmpDir }, log },
})); );
await cmd.parseAsync(['impersonate', 'other@test.com'], { from: 'user' });
const cmd = createClaudeCommand({ client, log });
cmd.parseAsync(['remove', 'slack', '-p', filePath], { from: 'user' }); expect(output.join('\n')).toContain('Not logged in');
});
const written = JSON.parse(readFileSync(filePath, 'utf-8'));
expect(written.mcpServers['slack']).toBeUndefined(); it('errors when quitting with no backup', async () => {
expect(written.mcpServers['github']).toBeDefined(); const cmd = createConfigCommand(
expect(output.join('\n')).toContain("Removed 'slack'"); { configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
rmSync(tmpDir, { recursive: true, force: true }); );
}); await cmd.parseAsync(['impersonate', '--quit'], { from: 'user' });
it('reports when server not found', () => { expect(output.join('\n')).toContain('No impersonation session to quit');
const filePath = join(tmpDir, '.mcp.json');
writeFileSync(filePath, JSON.stringify({ mcpServers: {} }));
const cmd = createClaudeCommand({ client, log });
cmd.parseAsync(['remove', 'nonexistent', '-p', filePath], { from: 'user' });
expect(output.join('\n')).toContain('not found');
rmSync(tmpDir, { recursive: true, force: true });
});
}); });
}); });

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createCreateCommand } from '../../src/commands/create.js'; import { createCreateCommand } from '../../src/commands/create.js';
import type { ApiClient } from '../../src/api-client.js'; import { type ApiClient, ApiError } from '../../src/api-client.js';
function mockClient(): ApiClient { function mockClient(): ApiClient {
return { return {
@@ -46,8 +46,8 @@ describe('create command', () => {
'--command', 'python', '--command', 'python',
'--command', '-c', '--command', '-c',
'--command', 'print("hello")', '--command', 'print("hello")',
'--env-template', 'API_KEY:API key:true', '--env', 'API_KEY=secretRef:creds:API_KEY',
'--env-template', 'BASE_URL:Base URL:false', '--env', 'BASE_URL=http://localhost',
], { from: 'user' }); ], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', { expect(client.post).toHaveBeenCalledWith('/api/v1/servers', {
@@ -59,9 +59,9 @@ describe('create command', () => {
containerPort: 3000, containerPort: 3000,
replicas: 2, replicas: 2,
command: ['python', '-c', 'print("hello")'], command: ['python', '-c', 'print("hello")'],
envTemplate: [ env: [
{ name: 'API_KEY', description: 'API key', isSecret: true }, { name: 'API_KEY', valueFrom: { secretRef: { name: 'creds', key: 'API_KEY' } } },
{ name: 'BASE_URL', description: 'Base URL', isSecret: false }, { name: 'BASE_URL', value: 'http://localhost' },
], ],
}); });
}); });
@@ -73,51 +73,98 @@ describe('create command', () => {
transport: 'STDIO', transport: 'STDIO',
})); }));
}); });
});
describe('create profile', () => { it('strips null values from template when using --from-template', async () => {
it('creates a profile resolving server name', async () => { vi.mocked(client.get).mockResolvedValueOnce([{
vi.mocked(client.get).mockResolvedValue([ id: 'tpl-1',
{ id: 'srv-abc', name: 'ha-mcp' }, name: 'grafana',
]); version: '1.0.0',
const cmd = createCreateCommand({ client, log }); description: 'Grafana MCP',
await cmd.parseAsync(['profile', 'production', '--server', 'ha-mcp'], { from: 'user' }); packageName: '@leval/mcp-grafana',
expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({ dockerImage: null,
name: 'production', transport: 'STDIO',
serverId: 'srv-abc', repositoryUrl: 'https://github.com/test',
})); externalUrl: null,
}); command: null,
containerPort: null,
it('parses --env KEY=value entries', async () => { replicas: 1,
vi.mocked(client.get).mockResolvedValue([ env: [{ name: 'TOKEN', required: true, description: 'A token' }],
{ id: 'srv-1', name: 'test' }, healthCheck: { tool: 'test', arguments: {} },
]); createdAt: '2025-01-01',
updatedAt: '2025-01-01',
}] as never);
const cmd = createCreateCommand({ client, log }); const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([ await cmd.parseAsync([
'profile', 'dev', 'server', 'my-grafana', '--from-template=grafana',
'--server', 'test', '--env', 'TOKEN=secretRef:creds:TOKEN',
'--env', 'FOO=bar',
'--env', 'SECRET=s3cr3t',
], { from: 'user' }); ], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({ const call = vi.mocked(client.post).mock.calls[0]![1] as Record<string, unknown>;
envOverrides: { FOO: 'bar', SECRET: 's3cr3t' }, // 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('passes permissions', async () => { it('throws on 409 without --force', async () => {
vi.mocked(client.get).mockResolvedValue([ vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Server already exists: my-server"}'));
{ id: 'srv-1', name: 'test' }, 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 }); const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([ await cmd.parseAsync([
'profile', 'admin', 'secret', 'ha-creds',
'--server', 'test', '--data', 'TOKEN=abc123',
'--permissions', 'read', '--data', 'URL=https://ha.local',
'--permissions', 'write',
], { from: 'user' }); ], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({ expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', {
permissions: ['read', 'write'], 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");
}); });
}); });
@@ -128,6 +175,7 @@ describe('create command', () => {
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', { expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
name: 'my-project', name: 'my-project',
description: 'A test project', description: 'A test project',
proxyMode: 'direct',
}); });
expect(output.join('\n')).toContain("project 'test' created"); expect(output.join('\n')).toContain("project 'test' created");
}); });
@@ -138,6 +186,264 @@ describe('create command', () => {
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', { expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
name: 'minimal', name: 'minimal',
description: '', description: '',
proxyMode: 'direct',
});
});
it('updates existing project on 409 with --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Project already exists"}'));
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'proj-1', name: 'my-proj' }] as never);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'my-proj', '-d', 'updated', '--force'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/projects/proj-1', { description: 'updated', proxyMode: 'direct' });
expect(output.join('\n')).toContain("project 'my-proj' updated");
});
});
describe('create user', () => {
it('creates a user with password and name', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'usr-1', email: 'alice@test.com' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'user', 'alice@test.com',
'--password', 'secret123',
'--name', 'Alice',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/users', {
email: 'alice@test.com',
password: 'secret123',
name: 'Alice',
});
expect(output.join('\n')).toContain("user 'alice@test.com' created");
});
it('does not send role field (RBAC is the auth mechanism)', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'usr-1', email: 'admin@test.com' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'user', 'admin@test.com',
'--password', 'pass123',
], { from: 'user' });
const callBody = vi.mocked(client.post).mock.calls[0]![1] as Record<string, unknown>;
expect(callBody).not.toHaveProperty('role');
});
it('requires --password', async () => {
const cmd = createCreateCommand({ client, log });
await expect(cmd.parseAsync(['user', 'alice@test.com'], { from: 'user' })).rejects.toThrow('--password is required');
});
it('throws on 409 without --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"User already exists"}'));
const cmd = createCreateCommand({ client, log });
await expect(
cmd.parseAsync(['user', 'alice@test.com', '--password', 'pass'], { from: 'user' }),
).rejects.toThrow('API error 409');
});
it('updates existing user on 409 with --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"User already exists"}'));
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'usr-1', email: 'alice@test.com' }] as never);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'user', 'alice@test.com', '--password', 'newpass', '--name', 'Alice New', '--force',
], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/users/usr-1', {
password: 'newpass',
name: 'Alice New',
});
expect(output.join('\n')).toContain("user 'alice@test.com' updated");
});
});
describe('create group', () => {
it('creates a group with members', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'grp-1', name: 'dev-team' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'group', 'dev-team',
'--description', 'Development team',
'--member', 'alice@test.com',
'--member', 'bob@test.com',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/groups', {
name: 'dev-team',
description: 'Development team',
members: ['alice@test.com', 'bob@test.com'],
});
expect(output.join('\n')).toContain("group 'dev-team' created");
});
it('creates a group with no members', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'grp-1', name: 'empty-group' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['group', 'empty-group'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/groups', {
name: 'empty-group',
members: [],
});
});
it('throws on 409 without --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Group already exists"}'));
const cmd = createCreateCommand({ client, log });
await expect(
cmd.parseAsync(['group', 'dev-team'], { from: 'user' }),
).rejects.toThrow('API error 409');
});
it('updates existing group on 409 with --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Group already exists"}'));
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'grp-1', name: 'dev-team' }] as never);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'group', 'dev-team', '--member', 'new@test.com', '--force',
], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/groups/grp-1', {
members: ['new@test.com'],
});
expect(output.join('\n')).toContain("group 'dev-team' updated");
});
});
describe('create rbac', () => {
it('creates an RBAC definition with subjects and bindings', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'developers' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'rbac', 'developers',
'--subject', 'User:alice@test.com',
'--subject', 'Group:dev-team',
'--binding', 'edit:servers',
'--binding', 'view:instances',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', {
name: 'developers',
subjects: [
{ kind: 'User', name: 'alice@test.com' },
{ kind: 'Group', name: 'dev-team' },
],
roleBindings: [
{ role: 'edit', resource: 'servers' },
{ role: 'view', resource: 'instances' },
],
});
expect(output.join('\n')).toContain("rbac 'developers' created");
});
it('creates an RBAC definition with wildcard resource', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'admins' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'rbac', 'admins',
'--subject', 'User:admin@test.com',
'--binding', 'edit:*',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', {
name: 'admins',
subjects: [{ kind: 'User', name: 'admin@test.com' }],
roleBindings: [{ role: 'edit', resource: '*' }],
});
});
it('creates an RBAC definition with empty subjects and bindings', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'empty' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['rbac', 'empty'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', {
name: 'empty',
subjects: [],
roleBindings: [],
});
});
it('throws on invalid subject format', async () => {
const cmd = createCreateCommand({ client, log });
await expect(
cmd.parseAsync(['rbac', 'bad', '--subject', 'no-colon'], { from: 'user' }),
).rejects.toThrow('Invalid subject format');
});
it('throws on invalid binding format', async () => {
const cmd = createCreateCommand({ client, log });
await expect(
cmd.parseAsync(['rbac', 'bad', '--binding', 'no-colon'], { from: 'user' }),
).rejects.toThrow('Invalid binding format');
});
it('throws on 409 without --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"RBAC already exists"}'));
const cmd = createCreateCommand({ client, log });
await expect(
cmd.parseAsync(['rbac', 'developers', '--subject', 'User:a@b.com', '--binding', 'edit:servers'], { from: 'user' }),
).rejects.toThrow('API error 409');
});
it('updates existing RBAC on 409 with --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"RBAC already exists"}'));
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'rbac-1', name: 'developers' }] as never);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'rbac', 'developers',
'--subject', 'User:new@test.com',
'--binding', 'edit:*',
'--force',
], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/rbac/rbac-1', {
subjects: [{ kind: 'User', name: 'new@test.com' }],
roleBindings: [{ role: 'edit', resource: '*' }],
});
expect(output.join('\n')).toContain("rbac 'developers' updated");
});
it('creates an RBAC definition with operation bindings', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'ops' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'rbac', 'ops',
'--subject', 'Group:ops-team',
'--binding', 'edit:servers',
'--operation', 'logs',
'--operation', 'backup',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', {
name: 'ops',
subjects: [{ kind: 'Group', name: 'ops-team' }],
roleBindings: [
{ role: 'edit', resource: 'servers' },
{ role: 'run', action: 'logs' },
{ role: 'run', action: 'backup' },
],
});
expect(output.join('\n')).toContain("rbac 'ops' created");
});
it('creates an RBAC definition with name-scoped binding', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'ha-viewer' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'rbac', 'ha-viewer',
'--subject', 'User:alice@test.com',
'--binding', 'view:servers:my-ha',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', {
name: 'ha-viewer',
subjects: [{ kind: 'User', name: 'alice@test.com' }],
roleBindings: [
{ role: 'view', resource: 'servers', name: 'my-ha' },
],
}); });
}); });
}); });

View File

@@ -30,7 +30,7 @@ describe('describe command', () => {
transport: 'STDIO', transport: 'STDIO',
packageName: '@slack/mcp', packageName: '@slack/mcp',
dockerImage: null, dockerImage: null,
envTemplate: [], env: [],
createdAt: '2025-01-01', createdAt: '2025-01-01',
}); });
const cmd = createDescribeCommand(deps); const cmd = createDescribeCommand(deps);
@@ -50,10 +50,10 @@ describe('describe command', () => {
}); });
it('resolves resource aliases', async () => { it('resolves resource aliases', async () => {
const deps = makeDeps({ id: 'p1' }); const deps = makeDeps({ id: 's1' });
const cmd = createDescribeCommand(deps); const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'prof', 'p1']); await cmd.parseAsync(['node', 'test', 'sec', 's1']);
expect(deps.fetchResource).toHaveBeenCalledWith('profiles', 'p1'); expect(deps.fetchResource).toHaveBeenCalledWith('secrets', 's1');
}); });
it('outputs JSON format', async () => { it('outputs JSON format', async () => {
@@ -72,26 +72,6 @@ describe('describe command', () => {
expect(deps.output[0]).toContain('name: slack'); expect(deps.output[0]).toContain('name: slack');
}); });
it('shows profile with permissions and env overrides', async () => {
const deps = makeDeps({
id: 'p1',
name: 'production',
serverId: 'srv-1',
permissions: ['read', 'write'],
envOverrides: { FOO: 'bar', SECRET: 's3cr3t' },
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'profile', 'p1']);
const text = deps.output.join('\n');
expect(text).toContain('=== Profile: production ===');
expect(text).toContain('read, write');
expect(text).toContain('Environment Overrides:');
expect(text).toContain('FOO');
expect(text).toContain('bar');
});
it('shows project detail', async () => { it('shows project detail', async () => {
const deps = makeDeps({ const deps = makeDeps({
id: 'proj-1', id: 'proj-1',
@@ -109,6 +89,39 @@ describe('describe command', () => {
expect(text).toContain('user-1'); expect(text).toContain('user-1');
}); });
it('shows secret detail with masked values', async () => {
const deps = makeDeps({
id: 'sec-1',
name: 'ha-creds',
data: { TOKEN: 'abc123', URL: 'https://ha.local' },
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'secret', 'sec-1']);
const text = deps.output.join('\n');
expect(text).toContain('=== Secret: ha-creds ===');
expect(text).toContain('TOKEN');
expect(text).toContain('***');
expect(text).not.toContain('abc123');
expect(text).toContain('use --show-values to reveal');
});
it('shows secret detail with revealed values when --show-values', async () => {
const deps = makeDeps({
id: 'sec-1',
name: 'ha-creds',
data: { TOKEN: 'abc123' },
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'secret', 'sec-1', '--show-values']);
const text = deps.output.join('\n');
expect(text).toContain('abc123');
expect(text).not.toContain('***');
});
it('shows instance detail with container info', async () => { it('shows instance detail with container info', async () => {
const deps = makeDeps({ const deps = makeDeps({
id: 'inst-1', id: 'inst-1',
@@ -126,4 +139,558 @@ describe('describe command', () => {
expect(text).toContain('RUNNING'); expect(text).toContain('RUNNING');
expect(text).toContain('abc123'); expect(text).toContain('abc123');
}); });
it('resolves server name to instance for describe instance', async () => {
const deps = makeDeps({
id: 'inst-1',
serverId: 'srv-1',
server: { name: 'my-grafana' },
status: 'RUNNING',
containerId: 'abc123',
port: 3000,
});
// resolveNameOrId will throw (not a CUID, name won't match instances)
vi.mocked(deps.client.get)
.mockResolvedValueOnce([] as never) // instances list (no name match)
.mockResolvedValueOnce([{ id: 'srv-1', name: 'my-grafana' }] as never) // servers list
.mockResolvedValueOnce([{ id: 'inst-1', status: 'RUNNING' }] as never); // instances for server
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'instance', 'my-grafana']);
expect(deps.fetchResource).toHaveBeenCalledWith('instances', 'inst-1');
});
it('resolves server name and picks running instance over stopped', async () => {
const deps = makeDeps({
id: 'inst-2',
serverId: 'srv-1',
server: { name: 'my-ha' },
status: 'RUNNING',
containerId: 'def456',
});
vi.mocked(deps.client.get)
.mockResolvedValueOnce([] as never) // instances list
.mockResolvedValueOnce([{ id: 'srv-1', name: 'my-ha' }] as never)
.mockResolvedValueOnce([
{ id: 'inst-1', status: 'ERROR' },
{ id: 'inst-2', status: 'RUNNING' },
] as never);
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'instance', 'my-ha']);
expect(deps.fetchResource).toHaveBeenCalledWith('instances', 'inst-2');
});
it('throws when no instances found for server name', async () => {
const deps = makeDeps();
vi.mocked(deps.client.get)
.mockResolvedValueOnce([] as never) // instances list
.mockResolvedValueOnce([{ id: 'srv-1', name: 'my-server' }] as never)
.mockResolvedValueOnce([] as never); // no instances
const cmd = createDescribeCommand(deps);
await expect(cmd.parseAsync(['node', 'test', 'instance', 'my-server'])).rejects.toThrow(
/No instances found/,
);
});
it('shows instance with server name in header', async () => {
const deps = makeDeps({
id: 'inst-1',
serverId: 'srv-1',
server: { name: 'my-grafana' },
status: 'RUNNING',
containerId: 'abc123',
port: 3000,
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'instance', 'inst-1']);
const text = deps.output.join('\n');
expect(text).toContain('=== Instance: my-grafana ===');
});
it('shows instance health and events', async () => {
const deps = makeDeps({
id: 'inst-1',
serverId: 'srv-1',
server: { name: 'my-grafana' },
status: 'RUNNING',
containerId: 'abc123',
healthStatus: 'healthy',
lastHealthCheck: '2025-01-15T10:30:00Z',
events: [
{ timestamp: '2025-01-15T10:30:00Z', type: 'Normal', message: 'Health check passed (45ms)' },
],
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'instance', 'inst-1']);
const text = deps.output.join('\n');
expect(text).toContain('Health:');
expect(text).toContain('healthy');
expect(text).toContain('Events:');
expect(text).toContain('Health check passed');
});
it('shows server healthCheck section', async () => {
const deps = makeDeps({
id: 'srv-1',
name: 'my-grafana',
transport: 'STDIO',
healthCheck: {
tool: 'list_datasources',
arguments: {},
intervalSeconds: 60,
timeoutSeconds: 10,
failureThreshold: 3,
},
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
const text = deps.output.join('\n');
expect(text).toContain('Health Check:');
expect(text).toContain('list_datasources');
expect(text).toContain('60s');
expect(text).toContain('Failure Threshold:');
});
it('shows template detail with healthCheck and usage', async () => {
const deps = makeDeps({
id: 'tpl-1',
name: 'grafana',
transport: 'STDIO',
version: '1.0.0',
packageName: '@leval/mcp-grafana',
env: [
{ name: 'GRAFANA_URL', required: true, description: 'Grafana instance URL' },
],
healthCheck: {
tool: 'list_datasources',
arguments: {},
intervalSeconds: 60,
timeoutSeconds: 10,
failureThreshold: 3,
},
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'template', 'tpl-1']);
const text = deps.output.join('\n');
expect(text).toContain('=== Template: grafana ===');
expect(text).toContain('@leval/mcp-grafana');
expect(text).toContain('GRAFANA_URL');
expect(text).toContain('Health Check:');
expect(text).toContain('list_datasources');
expect(text).toContain('mcpctl create server my-grafana --from-template=grafana');
});
it('shows user detail (no Role field — RBAC is the auth mechanism)', async () => {
const deps = makeDeps({
id: 'usr-1',
email: 'alice@test.com',
name: 'Alice Smith',
provider: null,
createdAt: '2025-01-01',
updatedAt: '2025-01-15',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'user', 'usr-1']);
expect(deps.fetchResource).toHaveBeenCalledWith('users', 'usr-1');
const text = deps.output.join('\n');
expect(text).toContain('=== User: alice@test.com ===');
expect(text).toContain('Email:');
expect(text).toContain('alice@test.com');
expect(text).toContain('Name:');
expect(text).toContain('Alice Smith');
expect(text).not.toContain('Role:');
expect(text).toContain('Provider:');
expect(text).toContain('local');
expect(text).toContain('ID:');
expect(text).toContain('usr-1');
});
it('shows user with no name as dash', async () => {
const deps = makeDeps({
id: 'usr-2',
email: 'bob@test.com',
name: null,
provider: 'oidc',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'user', 'usr-2']);
const text = deps.output.join('\n');
expect(text).toContain('=== User: bob@test.com ===');
expect(text).toContain('Name:');
expect(text).toContain('-');
expect(text).not.toContain('Role:');
expect(text).toContain('oidc');
});
it('shows group detail with members', async () => {
const deps = makeDeps({
id: 'grp-1',
name: 'dev-team',
description: 'Development team',
members: [
{ user: { email: 'alice@test.com' }, createdAt: '2025-01-01' },
{ user: { email: 'bob@test.com' }, createdAt: '2025-01-02' },
],
createdAt: '2025-01-01',
updatedAt: '2025-01-15',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'group', 'grp-1']);
expect(deps.fetchResource).toHaveBeenCalledWith('groups', 'grp-1');
const text = deps.output.join('\n');
expect(text).toContain('=== Group: dev-team ===');
expect(text).toContain('Name:');
expect(text).toContain('dev-team');
expect(text).toContain('Description:');
expect(text).toContain('Development team');
expect(text).toContain('Members:');
expect(text).toContain('EMAIL');
expect(text).toContain('ADDED');
expect(text).toContain('alice@test.com');
expect(text).toContain('bob@test.com');
expect(text).toContain('ID:');
expect(text).toContain('grp-1');
});
it('shows group detail with no members', async () => {
const deps = makeDeps({
id: 'grp-2',
name: 'empty-group',
description: '',
members: [],
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'group', 'grp-2']);
const text = deps.output.join('\n');
expect(text).toContain('=== Group: empty-group ===');
// No Members section when empty
expect(text).not.toContain('EMAIL');
});
it('shows RBAC detail with subjects and bindings', async () => {
const deps = makeDeps({
id: 'rbac-1',
name: 'developers',
subjects: [
{ kind: 'User', name: 'alice@test.com' },
{ kind: 'Group', name: 'dev-team' },
],
roleBindings: [
{ role: 'edit', resource: 'servers' },
{ role: 'view', resource: 'instances' },
{ role: 'view', resource: 'projects' },
],
createdAt: '2025-01-01',
updatedAt: '2025-01-15',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1']);
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', 'rbac-1');
const text = deps.output.join('\n');
expect(text).toContain('=== RBAC: developers ===');
expect(text).toContain('Name:');
expect(text).toContain('developers');
// Subjects section
expect(text).toContain('Subjects:');
expect(text).toContain('KIND');
expect(text).toContain('NAME');
expect(text).toContain('User');
expect(text).toContain('alice@test.com');
expect(text).toContain('Group');
expect(text).toContain('dev-team');
// Role Bindings section
expect(text).toContain('Resource Bindings:');
expect(text).toContain('ROLE');
expect(text).toContain('RESOURCE');
expect(text).toContain('edit');
expect(text).toContain('servers');
expect(text).toContain('view');
expect(text).toContain('instances');
expect(text).toContain('projects');
expect(text).toContain('ID:');
expect(text).toContain('rbac-1');
});
it('shows RBAC detail with wildcard resource', async () => {
const deps = makeDeps({
id: 'rbac-2',
name: 'admins',
subjects: [{ kind: 'User', name: 'admin@test.com' }],
roleBindings: [{ role: 'edit', resource: '*' }],
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-2']);
const text = deps.output.join('\n');
expect(text).toContain('=== RBAC: admins ===');
expect(text).toContain('edit');
expect(text).toContain('*');
});
it('shows RBAC detail with empty subjects and bindings', async () => {
const deps = makeDeps({
id: 'rbac-3',
name: 'empty-rbac',
subjects: [],
roleBindings: [],
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-3']);
const text = deps.output.join('\n');
expect(text).toContain('=== RBAC: empty-rbac ===');
// No Subjects or Role Bindings sections when empty
expect(text).not.toContain('KIND');
expect(text).not.toContain('ROLE');
expect(text).not.toContain('RESOURCE');
});
it('shows RBAC detail with mixed resource and operation bindings', async () => {
const deps = makeDeps({
id: 'rbac-1',
name: 'admin-access',
subjects: [{ kind: 'Group', name: 'admin' }],
roleBindings: [
{ role: 'edit', resource: '*' },
{ role: 'run', resource: 'projects' },
{ role: 'run', action: 'logs' },
{ role: 'run', action: 'backup' },
],
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1']);
const text = deps.output.join('\n');
expect(text).toContain('Resource Bindings:');
expect(text).toContain('edit');
expect(text).toContain('*');
expect(text).toContain('run');
expect(text).toContain('projects');
expect(text).toContain('Operations:');
expect(text).toContain('ACTION');
expect(text).toContain('logs');
expect(text).toContain('backup');
});
it('shows RBAC detail with name-scoped resource binding', async () => {
const deps = makeDeps({
id: 'rbac-1',
name: 'ha-viewer',
subjects: [{ kind: 'User', name: 'alice@test.com' }],
roleBindings: [
{ role: 'view', resource: 'servers', name: 'my-ha' },
{ role: 'edit', resource: 'secrets' },
],
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1']);
const text = deps.output.join('\n');
expect(text).toContain('Resource Bindings:');
expect(text).toContain('NAME');
expect(text).toContain('my-ha');
expect(text).toContain('view');
expect(text).toContain('servers');
});
it('shows user with direct RBAC permissions', async () => {
const deps = makeDeps({
id: 'usr-1',
email: 'alice@test.com',
name: 'Alice',
provider: null,
});
vi.mocked(deps.client.get)
.mockResolvedValueOnce([] as never) // users list (resolveNameOrId)
.mockResolvedValueOnce([ // RBAC defs
{
name: 'dev-access',
subjects: [{ kind: 'User', name: 'alice@test.com' }],
roleBindings: [
{ role: 'edit', resource: 'servers' },
{ role: 'run', action: 'logs' },
],
},
] as never)
.mockResolvedValueOnce([] as never); // groups
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'user', 'usr-1']);
const text = deps.output.join('\n');
expect(text).toContain('=== User: alice@test.com ===');
expect(text).toContain('Access:');
expect(text).toContain('Direct (dev-access)');
expect(text).toContain('Resources:');
expect(text).toContain('edit');
expect(text).toContain('servers');
expect(text).toContain('Operations:');
expect(text).toContain('logs');
});
it('shows user with inherited group permissions', async () => {
const deps = makeDeps({
id: 'usr-1',
email: 'bob@test.com',
name: 'Bob',
provider: null,
});
vi.mocked(deps.client.get)
.mockResolvedValueOnce([] as never) // users list
.mockResolvedValueOnce([ // RBAC defs
{
name: 'team-perms',
subjects: [{ kind: 'Group', name: 'dev-team' }],
roleBindings: [
{ role: 'view', resource: '*' },
{ role: 'run', action: 'backup' },
],
},
] as never)
.mockResolvedValueOnce([ // groups
{ name: 'dev-team', members: [{ user: { email: 'bob@test.com' } }] },
] as never);
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'user', 'usr-1']);
const text = deps.output.join('\n');
expect(text).toContain('Groups:');
expect(text).toContain('dev-team');
expect(text).toContain('Access:');
expect(text).toContain('Inherited (dev-team)');
expect(text).toContain('view');
expect(text).toContain('*');
expect(text).toContain('backup');
});
it('shows user with no permissions', async () => {
const deps = makeDeps({
id: 'usr-1',
email: 'nobody@test.com',
name: null,
provider: null,
});
vi.mocked(deps.client.get)
.mockResolvedValueOnce([] as never)
.mockResolvedValueOnce([] as never)
.mockResolvedValueOnce([] as never);
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'user', 'usr-1']);
const text = deps.output.join('\n');
expect(text).toContain('Access: (none)');
});
it('shows group with RBAC permissions', async () => {
const deps = makeDeps({
id: 'grp-1',
name: 'admin',
description: 'Admin group',
members: [{ user: { email: 'alice@test.com' } }],
});
vi.mocked(deps.client.get)
.mockResolvedValueOnce([] as never) // groups list (resolveNameOrId)
.mockResolvedValueOnce([ // RBAC defs
{
name: 'admin-access',
subjects: [{ kind: 'Group', name: 'admin' }],
roleBindings: [
{ role: 'edit', resource: '*' },
{ role: 'run', action: 'backup' },
{ role: 'run', action: 'restore' },
],
},
] as never);
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'group', 'grp-1']);
const text = deps.output.join('\n');
expect(text).toContain('=== Group: admin ===');
expect(text).toContain('Access:');
expect(text).toContain('Granted (admin-access)');
expect(text).toContain('edit');
expect(text).toContain('*');
expect(text).toContain('backup');
expect(text).toContain('restore');
});
it('shows group with name-scoped permissions', async () => {
const deps = makeDeps({
id: 'grp-1',
name: 'ha-team',
description: 'HA team',
members: [],
});
vi.mocked(deps.client.get)
.mockResolvedValueOnce([] as never)
.mockResolvedValueOnce([ // RBAC defs
{
name: 'ha-access',
subjects: [{ kind: 'Group', name: 'ha-team' }],
roleBindings: [
{ role: 'edit', resource: 'servers', name: 'my-ha' },
{ role: 'view', resource: 'secrets' },
],
},
] as never);
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'group', 'grp-1']);
const text = deps.output.join('\n');
expect(text).toContain('Access:');
expect(text).toContain('Granted (ha-access)');
expect(text).toContain('my-ha');
expect(text).toContain('NAME');
});
it('outputs user detail as JSON', async () => {
const deps = makeDeps({ id: 'usr-1', email: 'alice@test.com', name: 'Alice', role: 'ADMIN' });
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'user', 'usr-1', '-o', 'json']);
const parsed = JSON.parse(deps.output[0] ?? '');
expect(parsed.email).toBe('alice@test.com');
expect(parsed.role).toBe('ADMIN');
});
it('outputs group detail as YAML', async () => {
const deps = makeDeps({ id: 'grp-1', name: 'dev-team', description: 'Devs' });
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'group', 'grp-1', '-o', 'yaml']);
expect(deps.output[0]).toContain('name: dev-team');
});
it('outputs rbac detail as JSON', async () => {
const deps = makeDeps({
id: 'rbac-1',
name: 'devs',
subjects: [{ kind: 'User', name: 'a@b.com' }],
roleBindings: [{ role: 'edit', resource: 'servers' }],
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1', '-o', 'json']);
const parsed = JSON.parse(deps.output[0] ?? '');
expect(parsed.subjects).toHaveLength(1);
expect(parsed.roleBindings[0].role).toBe('edit');
});
}); });

View File

@@ -150,31 +150,4 @@ describe('edit command', () => {
expect(output.join('\n')).toContain('immutable'); expect(output.join('\n')).toContain('immutable');
}); });
it('edits a profile', async () => {
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/profiles') return [{ id: 'prof-1', name: 'production' }];
return {
id: 'prof-1', name: 'production', serverId: 'srv-1',
permissions: ['read'], envOverrides: { FOO: 'bar' },
createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1,
};
});
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: (filePath) => {
const content = readFileSync(filePath, 'utf-8');
const modified = content.replace('FOO: bar', 'FOO: baz');
writeFileSync(filePath, modified, 'utf-8');
},
});
await cmd.parseAsync(['profile', 'production'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/profiles/prof-1', expect.objectContaining({
envOverrides: { FOO: 'baz' },
}));
});
}); });

View File

@@ -67,23 +67,15 @@ describe('get command', () => {
expect(text).not.toContain('createdAt:'); expect(text).not.toContain('createdAt:');
}); });
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');
});
it('lists instances with correct columns', async () => { it('lists instances with correct columns', async () => {
const deps = makeDeps([ 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); const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'instances']); await cmd.parseAsync(['node', 'test', 'instances']);
expect(deps.output[0]).toContain('NAME');
expect(deps.output[0]).toContain('STATUS'); expect(deps.output[0]).toContain('STATUS');
expect(deps.output.join('\n')).toContain('my-grafana');
expect(deps.output.join('\n')).toContain('RUNNING'); expect(deps.output.join('\n')).toContain('RUNNING');
}); });
@@ -93,4 +85,173 @@ describe('get command', () => {
await cmd.parseAsync(['node', 'test', 'servers']); await cmd.parseAsync(['node', 'test', 'servers']);
expect(deps.output[0]).toContain('No servers found'); expect(deps.output[0]).toContain('No servers found');
}); });
it('lists users with correct columns (no ROLE column)', async () => {
const deps = makeDeps([
{ id: 'usr-1', email: 'alice@test.com', name: 'Alice', provider: null },
{ id: 'usr-2', email: 'bob@test.com', name: null, provider: 'oidc' },
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'users']);
expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined);
const text = deps.output.join('\n');
expect(text).toContain('EMAIL');
expect(text).toContain('NAME');
expect(text).not.toContain('ROLE');
expect(text).toContain('PROVIDER');
expect(text).toContain('alice@test.com');
expect(text).toContain('Alice');
expect(text).toContain('bob@test.com');
expect(text).toContain('oidc');
});
it('resolves user alias', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'user']);
expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined);
});
it('lists groups with correct columns', async () => {
const deps = makeDeps([
{
id: 'grp-1',
name: 'dev-team',
description: 'Developers',
members: [{ user: { email: 'alice@test.com' } }, { user: { email: 'bob@test.com' } }],
},
{ id: 'grp-2', name: 'ops-team', description: 'Operations', members: [] },
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'groups']);
expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined);
const text = deps.output.join('\n');
expect(text).toContain('NAME');
expect(text).toContain('MEMBERS');
expect(text).toContain('DESCRIPTION');
expect(text).toContain('dev-team');
expect(text).toContain('2');
expect(text).toContain('ops-team');
expect(text).toContain('0');
});
it('resolves group alias', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'group']);
expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined);
});
it('lists rbac definitions with correct columns', async () => {
const deps = makeDeps([
{
id: 'rbac-1',
name: 'admins',
subjects: [{ kind: 'User', name: 'admin@test.com' }],
roleBindings: [{ role: 'edit', resource: '*' }],
},
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac']);
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined);
const text = deps.output.join('\n');
expect(text).toContain('NAME');
expect(text).toContain('SUBJECTS');
expect(text).toContain('BINDINGS');
expect(text).toContain('admins');
expect(text).toContain('User:admin@test.com');
expect(text).toContain('edit:*');
});
it('resolves rbac-definition alias', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac-definition']);
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined);
});
it('lists projects with new columns', async () => {
const deps = makeDeps([{
id: 'proj-1',
name: 'smart-home',
description: 'Home automation',
proxyMode: 'filtered',
ownerId: 'usr-1',
servers: [{ server: { name: 'grafana' } }],
members: [{ user: { email: 'a@b.com' }, role: 'admin' }, { user: { email: 'c@d.com' }, role: 'member' }],
}]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'projects']);
const text = deps.output.join('\n');
expect(text).toContain('MODE');
expect(text).toContain('SERVERS');
expect(text).toContain('MEMBERS');
expect(text).toContain('smart-home');
expect(text).toContain('filtered');
expect(text).toContain('1');
expect(text).toContain('2');
});
it('displays mixed resource and operation bindings', async () => {
const deps = makeDeps([
{
id: 'rbac-1',
name: 'admin-access',
subjects: [{ kind: 'Group', name: 'admin' }],
roleBindings: [
{ role: 'edit', resource: '*' },
{ role: 'run', action: 'logs' },
{ role: 'run', action: 'backup' },
],
},
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac']);
const text = deps.output.join('\n');
expect(text).toContain('edit:*');
expect(text).toContain('run>logs');
expect(text).toContain('run>backup');
});
it('displays name-scoped resource bindings', async () => {
const deps = makeDeps([
{
id: 'rbac-1',
name: 'ha-viewer',
subjects: [{ kind: 'User', name: 'alice@test.com' }],
roleBindings: [{ role: 'view', resource: 'servers', name: 'my-ha' }],
},
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac']);
const text = deps.output.join('\n');
expect(text).toContain('view:servers:my-ha');
});
it('shows no results message for empty users list', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'users']);
expect(deps.output[0]).toContain('No users found');
});
it('shows no results message for empty groups list', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'groups']);
expect(deps.output[0]).toContain('No groups found');
});
it('shows no results message for empty rbac list', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac']);
expect(deps.output[0]).toContain('No rbac found');
});
}); });

View File

@@ -45,12 +45,6 @@ describe('delete command', () => {
expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-abc'); expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-abc');
}); });
it('deletes a profile', async () => {
const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['profile', 'prof-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/profiles/prof-1');
});
it('deletes a project', async () => { it('deletes a project', async () => {
const cmd = createDeleteCommand({ client, log }); const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['project', 'proj-1'], { from: 'user' }); await cmd.parseAsync(['project', 'proj-1'], { from: 'user' });
@@ -74,16 +68,79 @@ describe('logs command', () => {
output = []; output = [];
}); });
it('shows logs', async () => { it('shows logs by instance ID', async () => {
vi.mocked(client.get).mockResolvedValue({ stdout: 'hello world\n', stderr: '' }); vi.mocked(client.get)
.mockResolvedValueOnce({ id: 'inst-1', status: 'RUNNING' } as never) // instance lookup
.mockResolvedValueOnce({ stdout: 'hello world\n', stderr: '' } as never); // logs
const cmd = createLogsCommand({ client, log }); const cmd = createLogsCommand({ client, log });
await cmd.parseAsync(['inst-1'], { from: 'user' }); await cmd.parseAsync(['inst-1'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1');
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs'); expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
expect(output.join('\n')).toContain('hello world'); expect(output.join('\n')).toContain('hello world');
}); });
it('resolves server name to instance ID', async () => {
vi.mocked(client.get)
.mockRejectedValueOnce(new Error('not found')) // instance lookup fails
.mockResolvedValueOnce([{ id: 'srv-1', name: 'my-grafana' }] as never) // servers list
.mockResolvedValueOnce([{ id: 'inst-1', status: 'RUNNING', containerId: 'abc' }] as never) // instances for server
.mockResolvedValueOnce({ stdout: 'grafana logs\n', stderr: '' } as never); // logs
const cmd = createLogsCommand({ client, log });
await cmd.parseAsync(['my-grafana'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
expect(output.join('\n')).toContain('grafana logs');
});
it('picks RUNNING instance over others', async () => {
vi.mocked(client.get)
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce([{ id: 'srv-1', name: 'ha-mcp' }] as never)
.mockResolvedValueOnce([
{ id: 'inst-err', status: 'ERROR', containerId: null },
{ id: 'inst-ok', status: 'RUNNING', containerId: 'abc' },
] as never)
.mockResolvedValueOnce({ stdout: 'running instance\n', stderr: '' } as never);
const cmd = createLogsCommand({ client, log });
await cmd.parseAsync(['ha-mcp'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-ok/logs');
});
it('selects specific replica with --instance', async () => {
vi.mocked(client.get)
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce([{ id: 'srv-1', name: 'ha-mcp' }] as never)
.mockResolvedValueOnce([
{ id: 'inst-0', status: 'RUNNING', containerId: 'a' },
{ id: 'inst-1', status: 'RUNNING', containerId: 'b' },
] as never)
.mockResolvedValueOnce({ stdout: 'replica 1\n', stderr: '' } as never);
const cmd = createLogsCommand({ client, log });
await cmd.parseAsync(['ha-mcp', '-i', '1'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
});
it('throws on out-of-range --instance index', async () => {
vi.mocked(client.get)
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce([{ id: 'srv-1', name: 'ha-mcp' }] as never)
.mockResolvedValueOnce([{ id: 'inst-0', status: 'RUNNING' }] as never);
const cmd = createLogsCommand({ client, log });
await expect(cmd.parseAsync(['ha-mcp', '-i', '5'], { from: 'user' })).rejects.toThrow('out of range');
});
it('throws when server has no instances', async () => {
vi.mocked(client.get)
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce([{ id: 'srv-1', name: 'empty-srv' }] as never)
.mockResolvedValueOnce([] as never);
const cmd = createLogsCommand({ client, log });
await expect(cmd.parseAsync(['empty-srv'], { from: 'user' })).rejects.toThrow('No instances found');
});
it('passes tail option', async () => { it('passes tail option', async () => {
vi.mocked(client.get).mockResolvedValue({ stdout: '', stderr: '' }); vi.mocked(client.get)
.mockResolvedValueOnce({ id: 'inst-1' } as never)
.mockResolvedValueOnce({ stdout: '', stderr: '' } as never);
const cmd = createLogsCommand({ client, log }); const cmd = createLogsCommand({ client, log });
await cmd.parseAsync(['inst-1', '-t', '50'], { from: 'user' }); await cmd.parseAsync(['inst-1', '-t', '50'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs?tail=50'); expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs?tail=50');

View File

@@ -1,17 +1,19 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createProjectCommand } from '../../src/commands/project.js'; import { createCreateCommand } from '../../src/commands/create.js';
import type { ApiClient } from '../../src/api-client.js'; import { createGetCommand } from '../../src/commands/get.js';
import { createDescribeCommand } from '../../src/commands/describe.js';
import { type ApiClient, ApiError } from '../../src/api-client.js';
function mockClient(): ApiClient { function mockClient(): ApiClient {
return { return {
get: vi.fn(async () => []), get: vi.fn(async () => []),
post: vi.fn(async () => ({ id: 'proj-1', name: 'my-project' })), post: vi.fn(async () => ({ id: 'new-id', name: 'test' })),
put: vi.fn(async () => ({})), put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}), delete: vi.fn(async () => {}),
} as unknown as ApiClient; } as unknown as ApiClient;
} }
describe('project command', () => { describe('project with new fields', () => {
let client: ReturnType<typeof mockClient>; let client: ReturnType<typeof mockClient>;
let output: string[]; let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' ')); const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
@@ -21,32 +23,116 @@ describe('project command', () => {
output = []; output = [];
}); });
describe('profiles', () => { describe('create project with enhanced options', () => {
it('lists profiles for a project', async () => { it('creates project with proxy mode and servers', async () => {
vi.mocked(client.get).mockResolvedValue([ const cmd = createCreateCommand({ client, log });
{ id: 'prof-1', name: 'default', serverId: 'srv-1' }, await cmd.parseAsync([
]); 'project', 'smart-home',
const cmd = createProjectCommand({ client, log }); '-d', 'Smart home project',
await cmd.parseAsync(['profiles', 'proj-1'], { from: 'user' }); '--proxy-mode', 'filtered',
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/proj-1/profiles'); '--llm-provider', 'gemini-cli',
expect(output.join('\n')).toContain('default'); '--llm-model', 'gemini-2.0-flash',
'--server', 'my-grafana',
'--server', 'my-ha',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
name: 'smart-home',
description: 'Smart home project',
proxyMode: 'filtered',
llmProvider: 'gemini-cli',
llmModel: 'gemini-2.0-flash',
servers: ['my-grafana', 'my-ha'],
}));
}); });
it('shows empty message when no profiles', async () => { it('creates project with members', async () => {
const cmd = createProjectCommand({ client, log }); const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['profiles', 'proj-1'], { from: 'user' }); await cmd.parseAsync([
expect(output.join('\n')).toContain('No profiles assigned'); 'project', 'team-project',
'--member', 'alice@test.com',
'--member', 'bob@test.com',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
name: 'team-project',
members: ['alice@test.com', 'bob@test.com'],
}));
});
it('defaults proxy mode to direct', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'basic'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
proxyMode: 'direct',
}));
}); });
}); });
describe('set-profiles', () => { describe('get projects shows new columns', () => {
it('sets profiles for a project', async () => { it('shows MODE, SERVERS, MEMBERS columns', async () => {
const cmd = createProjectCommand({ client, log }); const deps = {
await cmd.parseAsync(['set-profiles', 'proj-1', 'prof-1', 'prof-2'], { from: 'user' }); output: [] as string[],
expect(client.put).toHaveBeenCalledWith('/api/v1/projects/proj-1/profiles', { fetchResource: vi.fn(async () => [{
profileIds: ['prof-1', 'prof-2'], id: 'proj-1',
name: 'smart-home',
description: 'Test',
proxyMode: 'filtered',
ownerId: 'user-1',
servers: [{ server: { name: 'grafana' } }, { server: { name: 'ha' } }],
members: [{ user: { email: 'alice@test.com' } }],
}]),
log: (...args: string[]) => deps.output.push(args.join(' ')),
};
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'projects']);
const text = deps.output.join('\n');
expect(text).toContain('MODE');
expect(text).toContain('SERVERS');
expect(text).toContain('MEMBERS');
expect(text).toContain('smart-home');
}); });
expect(output.join('\n')).toContain('2 profile(s)'); });
describe('describe project shows full detail', () => {
it('shows servers and members', async () => {
const deps = {
output: [] as string[],
client: mockClient(),
fetchResource: vi.fn(async () => ({
id: 'proj-1',
name: 'smart-home',
description: 'Smart home',
proxyMode: 'filtered',
llmProvider: 'gemini-cli',
llmModel: 'gemini-2.0-flash',
ownerId: 'user-1',
servers: [
{ server: { name: 'my-grafana' } },
{ server: { name: 'my-ha' } },
],
members: [
{ user: { email: 'alice@test.com' } },
{ user: { email: 'bob@test.com' } },
],
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
})),
log: (...args: string[]) => deps.output.push(args.join(' ')),
};
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'project', 'proj-1']);
const text = deps.output.join('\n');
expect(text).toContain('=== Project: smart-home ===');
expect(text).toContain('filtered');
expect(text).toContain('gemini-cli');
expect(text).toContain('my-grafana');
expect(text).toContain('my-ha');
expect(text).toContain('alice@test.com');
expect(text).toContain('bob@test.com');
}); });
}); });
}); });

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

@@ -21,44 +21,44 @@ describe('CLI command registration (e2e)', () => {
expect(commandNames).toContain('apply'); expect(commandNames).toContain('apply');
expect(commandNames).toContain('create'); expect(commandNames).toContain('create');
expect(commandNames).toContain('edit'); expect(commandNames).toContain('edit');
expect(commandNames).toContain('setup');
expect(commandNames).toContain('claude');
expect(commandNames).toContain('project');
expect(commandNames).toContain('backup'); expect(commandNames).toContain('backup');
expect(commandNames).toContain('restore'); expect(commandNames).toContain('restore');
}); });
it('instance command is removed (use get/delete/logs instead)', () => { it('old project and claude top-level commands are removed', () => {
const program = createProgram(); const program = createProgram();
const commandNames = program.commands.map((c) => c.name()); const commandNames = program.commands.map((c) => c.name());
expect(commandNames).not.toContain('claude');
expect(commandNames).not.toContain('project');
expect(commandNames).not.toContain('instance'); expect(commandNames).not.toContain('instance');
}); });
it('claude command has config management subcommands', () => { it('config command has claude-generate and impersonate subcommands', () => {
const program = createProgram(); const program = createProgram();
const claude = program.commands.find((c) => c.name() === 'claude'); const config = program.commands.find((c) => c.name() === 'config');
expect(claude).toBeDefined(); expect(config).toBeDefined();
const subcommands = claude!.commands.map((c) => c.name()); const subcommands = config!.commands.map((c) => c.name());
expect(subcommands).toContain('generate'); expect(subcommands).toContain('claude-generate');
expect(subcommands).toContain('show'); expect(subcommands).toContain('impersonate');
expect(subcommands).toContain('add'); expect(subcommands).toContain('view');
expect(subcommands).toContain('remove'); expect(subcommands).toContain('set');
expect(subcommands).toContain('path');
expect(subcommands).toContain('reset');
}); });
it('project command has action subcommands only', () => { it('create command has user, group, rbac subcommands', () => {
const program = createProgram(); const program = createProgram();
const project = program.commands.find((c) => c.name() === 'project'); const create = program.commands.find((c) => c.name() === 'create');
expect(project).toBeDefined(); expect(create).toBeDefined();
const subcommands = project!.commands.map((c) => c.name()); const subcommands = create!.commands.map((c) => c.name());
expect(subcommands).toContain('profiles'); expect(subcommands).toContain('server');
expect(subcommands).toContain('set-profiles'); expect(subcommands).toContain('secret');
// create is now top-level (mcpctl create project) expect(subcommands).toContain('project');
expect(subcommands).not.toContain('create'); expect(subcommands).toContain('user');
expect(subcommands).not.toContain('list'); expect(subcommands).toContain('group');
expect(subcommands).not.toContain('show'); expect(subcommands).toContain('rbac');
expect(subcommands).not.toContain('delete');
}); });
it('displays version', () => { it('displays version', () => {

View File

@@ -15,13 +15,17 @@ model User {
name String? name String?
passwordHash String passwordHash String
role Role @default(USER) role Role @default(USER)
provider String?
externalId String?
version Int @default(1) version Int @default(1)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
sessions Session[] sessions Session[]
auditLogs AuditLog[] auditLogs AuditLog[]
projects Project[] ownedProjects Project[]
projectMemberships ProjectMember[]
groupMemberships GroupMember[]
@@index([email]) @@index([email])
} }
@@ -61,13 +65,17 @@ model McpServer {
command Json? command Json?
containerPort Int? containerPort Int?
replicas Int @default(1) replicas Int @default(1)
envTemplate Json @default("[]") env Json @default("[]")
healthCheck Json?
version Int @default(1) version Int @default(1)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
profiles McpProfile[] templateName String?
templateVersion String?
instances McpInstance[] instances McpInstance[]
projects ProjectServer[]
@@index([name]) @@index([name])
} }
@@ -78,23 +86,83 @@ enum Transport {
STREAMABLE_HTTP STREAMABLE_HTTP
} }
// ── MCP Profiles ── // ── MCP Templates ──
model McpProfile { model McpTemplate {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String @unique
serverId String version String @default("1.0.0")
permissions Json @default("[]") description String @default("")
envOverrides Json @default("{}") packageName String?
dockerImage String?
transport Transport @default(STDIO)
repositoryUrl String?
externalUrl String?
command Json?
containerPort Int?
replicas Int @default(1)
env Json @default("[]")
healthCheck Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([name])
}
// ── Secrets ──
model Secret {
id String @id @default(cuid())
name String @unique
data Json @default("{}")
version Int @default(1) version Int @default(1)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade) @@index([name])
projects ProjectMcpProfile[] }
@@unique([name, serverId]) // ── Groups ──
@@index([serverId])
model Group {
id String @id @default(cuid())
name String @unique
description String @default("")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members GroupMember[]
@@index([name])
}
model GroupMember {
id String @id @default(cuid())
groupId String
userId String
createdAt DateTime @default(now())
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([groupId, userId])
@@index([groupId])
@@index([userId])
}
// ── RBAC Definitions ──
model RbacDefinition {
id String @id @default(cuid())
name String @unique
subjects Json @default("[]")
roleBindings Json @default("[]")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([name])
} }
// ── Projects ── // ── Projects ──
@@ -103,31 +171,44 @@ model Project {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique name String @unique
description String @default("") description String @default("")
proxyMode String @default("direct")
llmProvider String?
llmModel String?
ownerId String ownerId String
version Int @default(1) version Int @default(1)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
profiles ProjectMcpProfile[] servers ProjectServer[]
members ProjectMember[]
@@index([name]) @@index([name])
@@index([ownerId]) @@index([ownerId])
} }
// ── Project <-> Profile join table ── model ProjectServer {
model ProjectMcpProfile {
id String @id @default(cuid()) id String @id @default(cuid())
projectId String projectId String
profileId String serverId String
createdAt DateTime @default(now())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
profile McpProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
@@unique([projectId, profileId]) @@unique([projectId, serverId])
@@index([projectId]) }
@@index([profileId])
model ProjectMember {
id String @id @default(cuid())
projectId String
userId String
createdAt DateTime @default(now())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([projectId, userId])
} }
// ── MCP Instances (running containers) ── // ── MCP Instances (running containers) ──
@@ -139,6 +220,9 @@ model McpInstance {
status InstanceStatus @default(STOPPED) status InstanceStatus @default(STOPPED)
port Int? port Int?
metadata Json @default("{}") metadata Json @default("{}")
healthStatus String?
lastHealthCheck DateTime?
events Json @default("[]")
version Int @default(1) version Int @default(1)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@@ -4,9 +4,9 @@ export type {
User, User,
Session, Session,
McpServer, McpServer,
McpProfile, McpTemplate,
Secret,
Project, Project,
ProjectMcpProfile,
McpInstance, McpInstance,
AuditLog, AuditLog,
Role, Role,
@@ -14,5 +14,5 @@ export type {
InstanceStatus, InstanceStatus,
} from '@prisma/client'; } from '@prisma/client';
export { seedMcpServers, defaultServers } from './seed/index.js'; export { seedTemplates } from './seed/index.js';
export type { SeedServer } 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; name: string;
description: string; description?: string;
packageName: string; required?: boolean;
transport: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP'; defaultValue?: string;
repositoryUrl: string;
envTemplate: Array<{
name: string;
description: string;
isSecret: boolean;
setupUrl?: string;
}>;
} }
export const defaultServers: SeedServer[] = [ export interface HealthCheckSpec {
{ tool: string;
name: 'slack', arguments?: Record<string, unknown>;
description: 'Slack MCP server for reading channels, messages, and user info', intervalSeconds?: number;
packageName: '@anthropic/slack-mcp', timeoutSeconds?: number;
transport: 'STDIO', failureThreshold?: number;
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 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, prisma: PrismaClient,
servers: SeedServer[] = defaultServers, templates: SeedTemplate[],
): Promise<number> { ): Promise<number> {
let created = 0; let upserted = 0;
for (const server of servers) { for (const tpl of templates) {
await prisma.mcpServer.upsert({ await prisma.mcpTemplate.upsert({
where: { name: server.name }, where: { name: tpl.name },
update: { update: {
description: server.description, version: tpl.version,
packageName: server.packageName, description: tpl.description,
transport: server.transport, packageName: tpl.packageName ?? null,
repositoryUrl: server.repositoryUrl, dockerImage: tpl.dockerImage ?? null,
envTemplate: server.envTemplate, 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: { create: {
name: server.name, name: tpl.name,
description: server.description, version: tpl.version,
packageName: server.packageName, description: tpl.description,
transport: server.transport, packageName: tpl.packageName ?? null,
repositoryUrl: server.repositoryUrl, dockerImage: tpl.dockerImage ?? null,
envTemplate: server.envTemplate, 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; return upserted;
}
// 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));
});
} }

View File

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

View File

@@ -23,11 +23,35 @@ async function createUser(overrides: { email?: string; name?: string; role?: 'US
data: { data: {
email: overrides.email ?? `test-${Date.now()}@example.com`, email: overrides.email ?? `test-${Date.now()}@example.com`,
name: overrides.name ?? 'Test User', name: overrides.name ?? 'Test User',
passwordHash: '$2b$10$test-hash-placeholder',
role: overrides.role ?? 'USER', role: overrides.role ?? 'USER',
}, },
}); });
} }
async function createGroup(overrides: { name?: string; description?: string } = {}) {
return prisma.group.create({
data: {
name: overrides.name ?? `group-${Date.now()}`,
description: overrides.description ?? 'Test group',
},
});
}
async function createProject(overrides: { name?: string; ownerId?: string } = {}) {
let ownerId = overrides.ownerId;
if (!ownerId) {
const user = await createUser();
ownerId = user.id;
}
return prisma.project.create({
data: {
name: overrides.name ?? `project-${Date.now()}`,
ownerId,
},
});
}
async function createServer(overrides: { name?: string; transport?: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP' } = {}) { async function createServer(overrides: { name?: string; transport?: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP' } = {}) {
return prisma.mcpServer.create({ return prisma.mcpServer.create({
data: { data: {
@@ -123,7 +147,7 @@ describe('McpServer', () => {
const server = await createServer(); const server = await createServer();
expect(server.transport).toBe('STDIO'); expect(server.transport).toBe('STDIO');
expect(server.version).toBe(1); expect(server.version).toBe(1);
expect(server.envTemplate).toEqual([]); expect(server.env).toEqual([]);
}); });
it('enforces unique name', async () => { it('enforces unique name', async () => {
@@ -131,18 +155,18 @@ describe('McpServer', () => {
await expect(createServer({ name: 'slack' })).rejects.toThrow(); 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({ const server = await prisma.mcpServer.create({
data: { data: {
name: 'with-env', name: 'with-env',
envTemplate: [ env: [
{ name: 'API_KEY', description: 'Key', isSecret: true }, { name: 'API_KEY', value: 'test-key' },
], ],
}, },
}); });
const envTemplate = server.envTemplate as Array<{ name: string }>; const env = server.env as Array<{ name: string }>;
expect(envTemplate).toHaveLength(1); expect(env).toHaveLength(1);
expect(envTemplate[0].name).toBe('API_KEY'); expect(env[0].name).toBe('API_KEY');
}); });
it('supports SSE transport', async () => { it('supports SSE transport', async () => {
@@ -151,43 +175,46 @@ describe('McpServer', () => {
}); });
}); });
// ── McpProfile model ── // ── Secret model ──
describe('McpProfile', () => { describe('Secret', () => {
it('creates a profile linked to server', async () => { it('creates a secret with defaults', async () => {
const server = await createServer(); const secret = await prisma.secret.create({
const profile = await prisma.mcpProfile.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: { data: {
name: 'readonly', name: 'api-keys',
serverId: server.id, data: { API_KEY: 'test-key', API_SECRET: 'test-secret' },
permissions: ['read'],
}, },
}); });
expect(profile.name).toBe('readonly'); const data = secret.data as Record<string, string>;
expect(profile.serverId).toBe(server.id); expect(data['API_KEY']).toBe('test-key');
expect(data['API_SECRET']).toBe('test-secret');
}); });
it('enforces unique name per server', async () => { it('enforces unique name', async () => {
const server = await createServer(); await prisma.secret.create({ data: { name: 'dup-secret' } });
const data = { name: 'default', serverId: server.id }; await expect(prisma.secret.create({ data: { name: 'dup-secret' } })).rejects.toThrow();
await prisma.mcpProfile.create({ data });
await expect(prisma.mcpProfile.create({ data })).rejects.toThrow();
}); });
it('allows same profile name on different servers', async () => { it('updates data', async () => {
const server1 = await createServer({ name: 'server-1' }); const secret = await prisma.secret.create({
const server2 = await createServer({ name: 'server-2' }); data: { name: 'updatable', data: { KEY: 'old' } },
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');
}); });
const updated = await prisma.secret.update({
it('cascades delete when server is deleted', async () => { where: { id: secret.id },
const server = await createServer(); data: { data: { KEY: 'new', EXTRA: 'added' } },
await prisma.mcpProfile.create({ data: { name: 'test', serverId: server.id } }); });
await prisma.mcpServer.delete({ where: { id: server.id } }); const data = updated.data as Record<string, string>;
const profiles = await prisma.mcpProfile.findMany({ where: { serverId: server.id } }); expect(data['KEY']).toBe('new');
expect(profiles).toHaveLength(0); expect(data['EXTRA']).toBe('added');
}); });
}); });
@@ -220,62 +247,6 @@ describe('Project', () => {
}); });
}); });
// ── ProjectMcpProfile (join table) ──
describe('ProjectMcpProfile', () => {
it('links project to profile', async () => {
const user = await createUser();
const server = await createServer();
const profile = await prisma.mcpProfile.create({
data: { name: 'default', serverId: server.id },
});
const project = await prisma.project.create({
data: { name: 'test-project', ownerId: user.id },
});
const link = await prisma.projectMcpProfile.create({
data: { projectId: project.id, profileId: profile.id },
});
expect(link.projectId).toBe(project.id);
expect(link.profileId).toBe(profile.id);
});
it('enforces unique project+profile combination', async () => {
const user = await createUser();
const server = await createServer();
const profile = await prisma.mcpProfile.create({
data: { name: 'default', serverId: server.id },
});
const project = await prisma.project.create({
data: { name: 'test-project', ownerId: user.id },
});
const data = { projectId: project.id, profileId: profile.id };
await prisma.projectMcpProfile.create({ data });
await expect(prisma.projectMcpProfile.create({ data })).rejects.toThrow();
});
it('loads profiles through project include', async () => {
const user = await createUser();
const server = await createServer();
const profile = await prisma.mcpProfile.create({
data: { name: 'slack-ro', serverId: server.id },
});
const project = await prisma.project.create({
data: { name: 'reports', ownerId: user.id },
});
await prisma.projectMcpProfile.create({
data: { projectId: project.id, profileId: profile.id },
});
const loaded = await prisma.project.findUnique({
where: { id: project.id },
include: { profiles: { include: { profile: true } } },
});
expect(loaded!.profiles).toHaveLength(1);
expect(loaded!.profiles[0].profile.name).toBe('slack-ro');
});
});
// ── McpInstance model ── // ── McpInstance model ──
@@ -362,3 +333,236 @@ describe('AuditLog', () => {
expect(logs).toHaveLength(0); expect(logs).toHaveLength(0);
}); });
}); });
// ── User SSO fields ──
describe('User SSO fields', () => {
it('stores provider and externalId', async () => {
const user = await prisma.user.create({
data: {
email: 'sso@example.com',
passwordHash: 'hash',
provider: 'oidc',
externalId: 'ext-123',
},
});
expect(user.provider).toBe('oidc');
expect(user.externalId).toBe('ext-123');
});
it('defaults provider and externalId to null', async () => {
const user = await createUser();
expect(user.provider).toBeNull();
expect(user.externalId).toBeNull();
});
});
// ── Group model ──
describe('Group', () => {
it('creates a group with defaults', async () => {
const group = await createGroup();
expect(group.id).toBeDefined();
expect(group.version).toBe(1);
});
it('enforces unique name', async () => {
await createGroup({ name: 'devs' });
await expect(createGroup({ name: 'devs' })).rejects.toThrow();
});
it('creates group members', async () => {
const group = await createGroup();
const user = await createUser();
const member = await prisma.groupMember.create({
data: { groupId: group.id, userId: user.id },
});
expect(member.groupId).toBe(group.id);
expect(member.userId).toBe(user.id);
});
it('enforces unique group-user pair', async () => {
const group = await createGroup();
const user = await createUser();
await prisma.groupMember.create({ data: { groupId: group.id, userId: user.id } });
await expect(
prisma.groupMember.create({ data: { groupId: group.id, userId: user.id } }),
).rejects.toThrow();
});
it('cascades delete when group is deleted', async () => {
const group = await createGroup();
const user = await createUser();
await prisma.groupMember.create({ data: { groupId: group.id, userId: user.id } });
await prisma.group.delete({ where: { id: group.id } });
const members = await prisma.groupMember.findMany({ where: { groupId: group.id } });
expect(members).toHaveLength(0);
});
});
// ── RbacDefinition model ──
describe('RbacDefinition', () => {
it('creates with defaults', async () => {
const rbac = await prisma.rbacDefinition.create({
data: { name: 'test-rbac' },
});
expect(rbac.subjects).toEqual([]);
expect(rbac.roleBindings).toEqual([]);
expect(rbac.version).toBe(1);
});
it('enforces unique name', async () => {
await prisma.rbacDefinition.create({ data: { name: 'dup-rbac' } });
await expect(prisma.rbacDefinition.create({ data: { name: 'dup-rbac' } })).rejects.toThrow();
});
it('stores subjects as JSON', async () => {
const rbac = await prisma.rbacDefinition.create({
data: {
name: 'with-subjects',
subjects: [{ kind: 'User', name: 'alice@test.com' }, { kind: 'Group', name: 'devs' }],
},
});
const subjects = rbac.subjects as Array<{ kind: string; name: string }>;
expect(subjects).toHaveLength(2);
expect(subjects[0].kind).toBe('User');
});
it('stores roleBindings as JSON', async () => {
const rbac = await prisma.rbacDefinition.create({
data: {
name: 'with-bindings',
roleBindings: [{ role: 'editor', resource: 'servers' }],
},
});
const bindings = rbac.roleBindings as Array<{ role: string; resource: string }>;
expect(bindings).toHaveLength(1);
expect(bindings[0].role).toBe('editor');
});
it('updates subjects and roleBindings', async () => {
const rbac = await prisma.rbacDefinition.create({ data: { name: 'updatable-rbac' } });
const updated = await prisma.rbacDefinition.update({
where: { id: rbac.id },
data: {
subjects: [{ kind: 'User', name: 'bob@test.com' }],
roleBindings: [{ role: 'admin', resource: '*' }],
},
});
expect((updated.subjects as unknown[]).length).toBe(1);
expect((updated.roleBindings as unknown[]).length).toBe(1);
});
});
// ── ProjectServer model ──
describe('ProjectServer', () => {
it('links project to server', async () => {
const project = await createProject();
const server = await createServer();
const ps = await prisma.projectServer.create({
data: { projectId: project.id, serverId: server.id },
});
expect(ps.projectId).toBe(project.id);
expect(ps.serverId).toBe(server.id);
});
it('enforces unique project-server pair', async () => {
const project = await createProject();
const server = await createServer();
await prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } });
await expect(
prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } }),
).rejects.toThrow();
});
it('cascades delete when project is deleted', async () => {
const project = await createProject();
const server = await createServer();
await prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } });
await prisma.project.delete({ where: { id: project.id } });
const links = await prisma.projectServer.findMany({ where: { projectId: project.id } });
expect(links).toHaveLength(0);
});
it('cascades delete when server is deleted', async () => {
const project = await createProject();
const server = await createServer();
await prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } });
await prisma.mcpServer.delete({ where: { id: server.id } });
const links = await prisma.projectServer.findMany({ where: { serverId: server.id } });
expect(links).toHaveLength(0);
});
});
// ── ProjectMember model ──
describe('ProjectMember', () => {
it('links project to user with role', async () => {
const user = await createUser();
const project = await createProject({ ownerId: user.id });
const pm = await prisma.projectMember.create({
data: { projectId: project.id, userId: user.id, role: 'admin' },
});
expect(pm.role).toBe('admin');
});
it('defaults role to member', async () => {
const user = await createUser();
const project = await createProject({ ownerId: user.id });
const pm = await prisma.projectMember.create({
data: { projectId: project.id, userId: user.id },
});
expect(pm.role).toBe('member');
});
it('enforces unique project-user pair', async () => {
const user = await createUser();
const project = await createProject({ ownerId: user.id });
await prisma.projectMember.create({ data: { projectId: project.id, userId: user.id } });
await expect(
prisma.projectMember.create({ data: { projectId: project.id, userId: user.id } }),
).rejects.toThrow();
});
it('cascades delete when project is deleted', async () => {
const user = await createUser();
const project = await createProject({ ownerId: user.id });
await prisma.projectMember.create({ data: { projectId: project.id, userId: user.id } });
await prisma.project.delete({ where: { id: project.id } });
const members = await prisma.projectMember.findMany({ where: { projectId: project.id } });
expect(members).toHaveLength(0);
});
});
// ── Project new fields ──
describe('Project new fields', () => {
it('defaults proxyMode to direct', async () => {
const project = await createProject();
expect(project.proxyMode).toBe('direct');
});
it('stores proxyMode, llmProvider, llmModel', async () => {
const user = await createUser();
const project = await prisma.project.create({
data: {
name: 'filtered-project',
ownerId: user.id,
proxyMode: 'filtered',
llmProvider: 'gemini-cli',
llmModel: 'gemini-2.0-flash',
},
});
expect(project.proxyMode).toBe('filtered');
expect(project.llmProvider).toBe('gemini-cli');
expect(project.llmModel).toBe('gemini-2.0-flash');
});
it('defaults llmProvider and llmModel to null', async () => {
const project = await createProject();
expect(project.llmProvider).toBeNull();
expect(project.llmModel).toBeNull();
});
});

View File

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

View File

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

View File

@@ -1,18 +1,26 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { PrismaClient } from '@prisma/client'; 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 { loadConfigFromEnv } from './config/index.js';
import { createServer } from './server.js'; import { createServer } from './server.js';
import { setupGracefulShutdown } from './utils/index.js'; import { setupGracefulShutdown } from './utils/index.js';
import { import {
McpServerRepository, McpServerRepository,
McpProfileRepository, SecretRepository,
McpInstanceRepository, McpInstanceRepository,
ProjectRepository, ProjectRepository,
AuditLogRepository, AuditLogRepository,
TemplateRepository,
RbacDefinitionRepository,
UserRepository,
GroupRepository,
} from './repositories/index.js'; } from './repositories/index.js';
import { import {
McpServerService, McpServerService,
McpProfileService, SecretService,
InstanceService, InstanceService,
ProjectService, ProjectService,
AuditLogService, AuditLogService,
@@ -23,10 +31,19 @@ import {
RestoreService, RestoreService,
AuthService, AuthService,
McpProxyService, McpProxyService,
TemplateService,
HealthProbeRunner,
RbacDefinitionService,
RbacService,
UserService,
GroupService,
} from './services/index.js'; } from './services/index.js';
import type { RbacAction } from './services/index.js';
import type { UpdateRbacDefinitionInput } from './validation/rbac-definition.schema.js';
import { createAuthMiddleware } from './middleware/auth.js';
import { import {
registerMcpServerRoutes, registerMcpServerRoutes,
registerMcpProfileRoutes, registerSecretRoutes,
registerInstanceRoutes, registerInstanceRoutes,
registerProjectRoutes, registerProjectRoutes,
registerAuditLogRoutes, registerAuditLogRoutes,
@@ -34,8 +51,116 @@ import {
registerBackupRoutes, registerBackupRoutes,
registerAuthRoutes, registerAuthRoutes,
registerMcpProxyRoutes, registerMcpProxyRoutes,
registerTemplateRoutes,
registerRbacRoutes,
registerUserRoutes,
registerGroupRoutes,
} from './routes/index.js'; } from './routes/index.js';
type PermissionCheck =
| { kind: 'resource'; resource: string; action: RbacAction; resourceName?: string }
| { kind: 'operation'; operation: string }
| { kind: 'skip' };
/**
* Map an HTTP method + URL to a permission check.
* Returns 'skip' for URLs that should not be RBAC-checked.
*/
function mapUrlToPermission(method: string, url: string): PermissionCheck {
const match = url.match(/^\/api\/v1\/([a-z-]+)/);
if (!match) return { kind: 'skip' };
const segment = match[1] as string;
// Operations (non-resource endpoints)
if (segment === 'backup') return { kind: 'operation', operation: 'backup' };
if (segment === 'restore') return { kind: 'operation', operation: 'restore' };
if (segment === 'audit-logs' && method === 'DELETE') return { kind: 'operation', operation: 'audit-purge' };
const resourceMap: Record<string, string | undefined> = {
'servers': 'servers',
'instances': 'instances',
'secrets': 'secrets',
'projects': 'projects',
'templates': 'templates',
'users': 'users',
'groups': 'groups',
'rbac': 'rbac',
'audit-logs': 'rbac',
'mcp': 'servers',
};
const resource = resourceMap[segment];
if (resource === undefined) return { kind: 'skip' };
// Map HTTP method to action
let action: RbacAction;
switch (method) {
case 'GET':
case 'HEAD':
action = 'view';
break;
case 'POST':
action = 'create';
break;
case 'DELETE':
action = 'delete';
break;
default: // PUT, PATCH
action = 'edit';
break;
}
// Extract resource name/ID from URL (3rd segment: /api/v1/servers/:nameOrId)
const nameMatch = url.match(/^\/api\/v1\/[a-z-]+\/([^/?]+)/);
const resourceName = nameMatch?.[1];
const check: PermissionCheck = { kind: 'resource', resource, action };
if (resourceName !== undefined) (check as { resourceName: string }).resourceName = resourceName;
return check;
}
/**
* Migrate legacy 'admin' role bindings → granular roles.
* Old format: { role: 'admin', resource: '*' }
* New format: { role: 'edit', resource: '*' }, { role: 'run', resource: '*' },
* plus operation bindings for impersonate, logs, backup, restore, audit-purge
*/
async function migrateAdminRole(rbacRepo: InstanceType<typeof RbacDefinitionRepository>): Promise<void> {
const definitions = await rbacRepo.findAll();
for (const def of definitions) {
const bindings = def.roleBindings as Array<Record<string, unknown>>;
const hasAdminRole = bindings.some((b) => b['role'] === 'admin');
if (!hasAdminRole) continue;
// Replace admin bindings with granular equivalents
const newBindings: Array<Record<string, string>> = [];
for (const b of bindings) {
if (b['role'] === 'admin') {
const resource = b['resource'] as string;
newBindings.push({ role: 'edit', resource });
newBindings.push({ role: 'run', resource });
} else {
newBindings.push(b as Record<string, string>);
}
}
// Add operation bindings (idempotent — only for wildcard admin)
const hasWildcard = bindings.some((b) => b['role'] === 'admin' && b['resource'] === '*');
if (hasWildcard) {
const ops = ['impersonate', 'logs', 'backup', 'restore', 'audit-purge'];
for (const op of ops) {
if (!newBindings.some((b) => b['action'] === op)) {
newBindings.push({ role: 'run', action: op });
}
}
}
await rbacRepo.update(def.id, { roleBindings: newBindings as UpdateRbacDefinitionInput['roleBindings'] });
// eslint-disable-next-line no-console
console.log(`mcpd: migrated RBAC '${def.name}' from admin → granular roles`);
}
}
async function main(): Promise<void> { async function main(): Promise<void> {
const config = loadConfigFromEnv(); const config = loadConfigFromEnv();
@@ -45,32 +170,68 @@ async function main(): Promise<void> {
}); });
await prisma.$connect(); await prisma.$connect();
// Seed default servers (upsert, safe to repeat) // Seed templates from YAML files
await seedMcpServers(prisma); 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 // Repositories
const serverRepo = new McpServerRepository(prisma); const serverRepo = new McpServerRepository(prisma);
const profileRepo = new McpProfileRepository(prisma); const secretRepo = new SecretRepository(prisma);
const instanceRepo = new McpInstanceRepository(prisma); const instanceRepo = new McpInstanceRepository(prisma);
const projectRepo = new ProjectRepository(prisma); const projectRepo = new ProjectRepository(prisma);
const auditLogRepo = new AuditLogRepository(prisma); const auditLogRepo = new AuditLogRepository(prisma);
const templateRepo = new TemplateRepository(prisma);
const rbacDefinitionRepo = new RbacDefinitionRepository(prisma);
const userRepo = new UserRepository(prisma);
const groupRepo = new GroupRepository(prisma);
// Migrate legacy 'admin' role → granular roles
await migrateAdminRole(rbacDefinitionRepo);
// Orchestrator // Orchestrator
const orchestrator = new DockerContainerManager(); const orchestrator = new DockerContainerManager();
// Services // Services
const serverService = new McpServerService(serverRepo); const serverService = new McpServerService(serverRepo);
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator); const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretRepo);
serverService.setInstanceService(instanceService); serverService.setInstanceService(instanceService);
const profileService = new McpProfileService(profileRepo, serverRepo); const secretService = new SecretService(secretRepo);
const projectService = new ProjectService(projectRepo, profileRepo, serverRepo); const projectService = new ProjectService(projectRepo, serverRepo, secretRepo, userRepo);
const auditLogService = new AuditLogService(auditLogRepo); const auditLogService = new AuditLogService(auditLogRepo);
const metricsCollector = new MetricsCollector(); const metricsCollector = new MetricsCollector();
const healthAggregator = new HealthAggregator(metricsCollector, orchestrator); const healthAggregator = new HealthAggregator(metricsCollector, orchestrator);
const backupService = new BackupService(serverRepo, profileRepo, projectRepo); const backupService = new BackupService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo);
const restoreService = new RestoreService(serverRepo, profileRepo, projectRepo); const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo);
const authService = new AuthService(prisma); const authService = new AuthService(prisma);
const templateService = new TemplateService(templateRepo);
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo); const mcpProxyService = new McpProxyService(instanceRepo, serverRepo);
const rbacDefinitionService = new RbacDefinitionService(rbacDefinitionRepo);
const rbacService = new RbacService(rbacDefinitionRepo, prisma);
const userService = new UserService(userRepo);
const groupService = new GroupService(groupRepo, userRepo);
// Auth middleware for global hooks
const authMiddleware = createAuthMiddleware({
findSession: (token) => authService.findSession(token),
});
// Server // Server
const app = await createServer(config, { const app = await createServer(config, {
@@ -86,28 +247,92 @@ async function main(): Promise<void> {
}, },
}); });
// ── Global auth hook ──
// Runs on all /api/v1/* routes EXCEPT auth endpoints and health checks.
// Tests that use createServer() directly are NOT affected — this hook
// is only registered here in main.ts.
app.addHook('preHandler', async (request, reply) => {
const url = request.url;
// Skip auth for health, auth, and root
if (url.startsWith('/api/v1/auth/') || url === '/healthz' || url === '/health') return;
if (!url.startsWith('/api/v1/')) return;
// Run auth middleware
await authMiddleware(request, reply);
});
// ── Global RBAC hook ──
// Runs after the auth hook. Maps URL to resource+action and checks permissions.
app.addHook('preHandler', async (request, reply) => {
if (reply.sent) return; // Auth hook already rejected
const url = request.url;
if (url.startsWith('/api/v1/auth/') || url === '/healthz' || url === '/health') return;
if (!url.startsWith('/api/v1/')) return;
if (request.userId === undefined) return; // Auth hook will handle 401
const check = mapUrlToPermission(request.method, url);
if (check.kind === 'skip') return;
let allowed: boolean;
if (check.kind === 'operation') {
allowed = await rbacService.canRunOperation(request.userId, check.operation);
} else {
allowed = await rbacService.canAccess(request.userId, check.action, check.resource, check.resourceName);
}
if (!allowed) {
reply.code(403).send({ error: 'Forbidden' });
}
});
// Routes // Routes
registerMcpServerRoutes(app, serverService, instanceService); registerMcpServerRoutes(app, serverService, instanceService);
registerMcpProfileRoutes(app, profileService); registerTemplateRoutes(app, templateService);
registerSecretRoutes(app, secretService);
registerInstanceRoutes(app, instanceService); registerInstanceRoutes(app, instanceService);
registerProjectRoutes(app, projectService); registerProjectRoutes(app, projectService);
registerAuditLogRoutes(app, auditLogService); registerAuditLogRoutes(app, auditLogService);
registerHealthMonitoringRoutes(app, { healthAggregator, metricsCollector }); registerHealthMonitoringRoutes(app, { healthAggregator, metricsCollector });
registerBackupRoutes(app, { backupService, restoreService }); registerBackupRoutes(app, { backupService, restoreService });
registerAuthRoutes(app, { authService }); registerAuthRoutes(app, { authService, userService, groupService, rbacDefinitionService, rbacService });
registerMcpProxyRoutes(app, { registerMcpProxyRoutes(app, {
mcpProxyService, mcpProxyService,
auditLogService, auditLogService,
authDeps: { findSession: (token) => authService.findSession(token) }, authDeps: { findSession: (token) => authService.findSession(token) },
}); });
registerRbacRoutes(app, rbacDefinitionService);
registerUserRoutes(app, userService);
registerGroupRoutes(app, groupService);
// Start // Start
await app.listen({ port: config.port, host: config.host }); await app.listen({ port: config.port, host: config.host });
app.log.info(`mcpd listening on ${config.host}:${config.port}`); app.log.info(`mcpd listening on ${config.host}:${config.port}`);
// Periodic container liveness sync — detect crashed containers
const SYNC_INTERVAL_MS = 30_000; // 30s
const syncTimer = setInterval(async () => {
try {
await instanceService.syncStatus();
} catch (err) {
app.log.error({ err }, 'Container status sync failed');
}
}, SYNC_INTERVAL_MS);
// Health probe runner — periodic MCP tool-call probes (like k8s livenessProbe)
const healthProbeRunner = new HealthProbeRunner(
instanceRepo,
serverRepo,
orchestrator,
{ info: (msg) => app.log.info(msg), error: (obj, msg) => app.log.error(obj, msg) },
);
healthProbeRunner.start(15_000);
// Graceful shutdown // Graceful shutdown
setupGracefulShutdown(app, { setupGracefulShutdown(app, {
disconnectDb: () => prisma.$disconnect(), disconnectDb: async () => {
clearInterval(syncTimer);
healthProbeRunner.stop();
await prisma.$disconnect();
},
}); });
} }

View File

@@ -0,0 +1,36 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
import type { RbacService, RbacAction } from '../services/rbac.service.js';
export function createRbacMiddleware(rbacService: RbacService) {
function requirePermission(resource: string, action: RbacAction, resourceName?: string) {
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
if (request.userId === undefined) {
reply.code(401).send({ error: 'Authentication required' });
return;
}
const allowed = await rbacService.canAccess(request.userId, action, resource, resourceName);
if (!allowed) {
reply.code(403).send({ error: 'Forbidden' });
return;
}
};
}
function requireOperation(operation: string) {
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
if (request.userId === undefined) {
reply.code(401).send({ error: 'Authentication required' });
return;
}
const allowed = await rbacService.canRunOperation(request.userId, operation);
if (!allowed) {
reply.code(403).send({ error: 'Forbidden' });
return;
}
};
}
return { requirePermission, requireOperation };
}

View File

@@ -0,0 +1,93 @@
import type { PrismaClient, Group } from '@prisma/client';
export interface GroupWithMembers extends Group {
members: Array<{ id: string; user: { id: string; email: string; name: string | null } }>;
}
export interface IGroupRepository {
findAll(): Promise<GroupWithMembers[]>;
findById(id: string): Promise<GroupWithMembers | null>;
findByName(name: string): Promise<GroupWithMembers | null>;
create(data: { name: string; description?: string }): Promise<Group>;
update(id: string, data: { description?: string }): Promise<Group>;
delete(id: string): Promise<void>;
setMembers(groupId: string, userIds: string[]): Promise<void>;
findGroupsForUser(userId: string): Promise<Array<{ id: string; name: string }>>;
}
const MEMBERS_INCLUDE = {
members: {
select: {
id: true,
user: {
select: { id: true, email: true, name: true },
},
},
},
} as const;
export class GroupRepository implements IGroupRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(): Promise<GroupWithMembers[]> {
return this.prisma.group.findMany({
orderBy: { name: 'asc' },
include: MEMBERS_INCLUDE,
});
}
async findById(id: string): Promise<GroupWithMembers | null> {
return this.prisma.group.findUnique({
where: { id },
include: MEMBERS_INCLUDE,
});
}
async findByName(name: string): Promise<GroupWithMembers | null> {
return this.prisma.group.findUnique({
where: { name },
include: MEMBERS_INCLUDE,
});
}
async create(data: { name: string; description?: string }): Promise<Group> {
const createData: Record<string, unknown> = { name: data.name };
if (data.description !== undefined) createData['description'] = data.description;
return this.prisma.group.create({
data: createData as Parameters<PrismaClient['group']['create']>[0]['data'],
});
}
async update(id: string, data: { description?: string }): Promise<Group> {
const updateData: Record<string, unknown> = {};
if (data.description !== undefined) updateData['description'] = data.description;
return this.prisma.group.update({ where: { id }, data: updateData });
}
async delete(id: string): Promise<void> {
await this.prisma.group.delete({ where: { id } });
}
async setMembers(groupId: string, userIds: string[]): Promise<void> {
await this.prisma.$transaction(async (tx) => {
await tx.groupMember.deleteMany({ where: { groupId } });
if (userIds.length > 0) {
await tx.groupMember.createMany({
data: userIds.map((userId) => ({ groupId, userId })),
});
}
});
}
async findGroupsForUser(userId: string): Promise<Array<{ id: string; name: string }>> {
const memberships = await this.prisma.groupMember.findMany({
where: { userId },
select: {
group: {
select: { id: true, name: true },
},
},
});
return memberships.map((m) => m.group);
}
}

View File

@@ -1,7 +1,15 @@
export type { IMcpServerRepository, IMcpProfileRepository, IMcpInstanceRepository, IAuditLogRepository, AuditLogFilter } from './interfaces.js'; export type { IMcpServerRepository, IMcpInstanceRepository, ISecretRepository, IAuditLogRepository, AuditLogFilter } from './interfaces.js';
export { McpServerRepository } from './mcp-server.repository.js'; export { 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 type { IProjectRepository, ProjectWithRelations } from './project.repository.js';
export { ProjectRepository } from './project.repository.js'; export { ProjectRepository } from './project.repository.js';
export { McpInstanceRepository } from './mcp-instance.repository.js'; export { McpInstanceRepository } from './mcp-instance.repository.js';
export { AuditLogRepository } from './audit-log.repository.js'; export { AuditLogRepository } from './audit-log.repository.js';
export type { ITemplateRepository } from './template.repository.js';
export { TemplateRepository } from './template.repository.js';
export type { IRbacDefinitionRepository } from './rbac-definition.repository.js';
export { RbacDefinitionRepository } from './rbac-definition.repository.js';
export type { IUserRepository, SafeUser } from './user.repository.js';
export { UserRepository } from './user.repository.js';
export type { IGroupRepository, GroupWithMembers } from './group.repository.js';
export { GroupRepository } from './group.repository.js';

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 { 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 { export interface IMcpServerRepository {
findAll(): Promise<McpServer[]>; findAll(): Promise<McpServer[]>;
@@ -16,16 +16,16 @@ export interface IMcpInstanceRepository {
findById(id: string): Promise<McpInstance | null>; findById(id: string): Promise<McpInstance | null>;
findByContainerId(containerId: 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>; 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>; delete(id: string): Promise<void>;
} }
export interface IMcpProfileRepository { export interface ISecretRepository {
findAll(serverId?: string): Promise<McpProfile[]>; findAll(): Promise<Secret[]>;
findById(id: string): Promise<McpProfile | null>; findById(id: string): Promise<Secret | null>;
findByServerAndName(serverId: string, name: string): Promise<McpProfile | null>; findByName(name: string): Promise<Secret | null>;
create(data: CreateMcpProfileInput): Promise<McpProfile>; create(data: CreateSecretInput): Promise<Secret>;
update(id: string, data: UpdateMcpProfileInput): Promise<McpProfile>; update(id: string, data: UpdateSecretInput): Promise<Secret>;
delete(id: string): Promise<void>; delete(id: string): Promise<void>;
} }

View File

@@ -11,12 +11,16 @@ export class McpInstanceRepository implements IMcpInstanceRepository {
} }
return this.prisma.mcpInstance.findMany({ return this.prisma.mcpInstance.findMany({
where, where,
include: { server: { select: { name: true } } },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
} }
async findById(id: string): Promise<McpInstance | null> { async findById(id: string): Promise<McpInstance | null> {
return this.prisma.mcpInstance.findUnique({ where: { id } }); return this.prisma.mcpInstance.findUnique({
where: { id },
include: { server: { select: { name: true } } },
});
} }
async findByContainerId(containerId: string): Promise<McpInstance | null> { async findByContainerId(containerId: string): Promise<McpInstance | null> {
@@ -44,7 +48,7 @@ export class McpInstanceRepository implements IMcpInstanceRepository {
async updateStatus( async updateStatus(
id: string, id: string,
status: InstanceStatus, 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> { ): Promise<McpInstance> {
const updateData: Prisma.McpInstanceUpdateInput = { const updateData: Prisma.McpInstanceUpdateInput = {
status, status,
@@ -59,6 +63,15 @@ export class McpInstanceRepository implements IMcpInstanceRepository {
if (fields?.metadata !== undefined) { if (fields?.metadata !== undefined) {
updateData.metadata = fields.metadata as Prisma.InputJsonValue; 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({ return this.prisma.mcpInstance.update({
where: { id }, where: { id },
data: updateData, 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

@@ -30,7 +30,8 @@ export class McpServerRepository implements IMcpServerRepository {
command: data.command ?? Prisma.DbNull, command: data.command ?? Prisma.DbNull,
containerPort: data.containerPort ?? null, containerPort: data.containerPort ?? null,
replicas: data.replicas, replicas: data.replicas,
envTemplate: data.envTemplate, env: data.env,
healthCheck: (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue,
}, },
}); });
} }
@@ -46,7 +47,8 @@ export class McpServerRepository implements IMcpServerRepository {
if (data.command !== undefined) updateData['command'] = data.command; if (data.command !== undefined) updateData['command'] = data.command;
if (data.containerPort !== undefined) updateData['containerPort'] = data.containerPort; if (data.containerPort !== undefined) updateData['containerPort'] = data.containerPort;
if (data.replicas !== undefined) updateData['replicas'] = data.replicas; if (data.replicas !== undefined) updateData['replicas'] = data.replicas;
if (data.envTemplate !== undefined) updateData['envTemplate'] = data.envTemplate; 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 }); return this.prisma.mcpServer.update({ where: { id }, data: updateData });
} }

View File

@@ -1,69 +1,89 @@
import type { PrismaClient, Project } from '@prisma/client'; import type { PrismaClient, Project } from '@prisma/client';
import type { CreateProjectInput, UpdateProjectInput } from '../validation/project.schema.js';
export interface ProjectWithRelations extends Project {
servers: Array<{ id: string; server: { id: string; name: string } }>;
members: Array<{ id: string; user: { id: string; email: string; name: string | null } }>;
}
const PROJECT_INCLUDE = {
servers: { include: { server: { select: { id: true, name: true } } } },
members: { include: { user: { select: { id: true, email: true, name: true } } } },
} as const;
export interface IProjectRepository { export interface IProjectRepository {
findAll(ownerId?: string): Promise<Project[]>; findAll(ownerId?: string): Promise<ProjectWithRelations[]>;
findById(id: string): Promise<Project | null>; findById(id: string): Promise<ProjectWithRelations | null>;
findByName(name: string): Promise<Project | null>; findByName(name: string): Promise<ProjectWithRelations | null>;
create(data: CreateProjectInput & { ownerId: string }): Promise<Project>; create(data: { name: string; description: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string }): Promise<ProjectWithRelations>;
update(id: string, data: UpdateProjectInput): Promise<Project>; update(id: string, data: Record<string, unknown>): Promise<ProjectWithRelations>;
delete(id: string): Promise<void>; delete(id: string): Promise<void>;
setProfiles(projectId: string, profileIds: string[]): Promise<void>; setServers(projectId: string, serverIds: string[]): Promise<void>;
getProfileIds(projectId: string): Promise<string[]>; setMembers(projectId: string, userIds: string[]): Promise<void>;
} }
export class ProjectRepository implements IProjectRepository { export class ProjectRepository implements IProjectRepository {
constructor(private readonly prisma: PrismaClient) {} constructor(private readonly prisma: PrismaClient) {}
async findAll(ownerId?: string): Promise<Project[]> { async findAll(ownerId?: string): Promise<ProjectWithRelations[]> {
const where = ownerId !== undefined ? { ownerId } : {}; const where = ownerId !== undefined ? { ownerId } : {};
return this.prisma.project.findMany({ where, orderBy: { name: 'asc' } }); return this.prisma.project.findMany({ where, orderBy: { name: 'asc' }, include: PROJECT_INCLUDE }) as unknown as Promise<ProjectWithRelations[]>;
} }
async findById(id: string): Promise<Project | null> { async findById(id: string): Promise<ProjectWithRelations | null> {
return this.prisma.project.findUnique({ where: { id } }); return this.prisma.project.findUnique({ where: { id }, include: PROJECT_INCLUDE }) as unknown as Promise<ProjectWithRelations | null>;
} }
async findByName(name: string): Promise<Project | null> { async findByName(name: string): Promise<ProjectWithRelations | null> {
return this.prisma.project.findUnique({ where: { name } }); return this.prisma.project.findUnique({ where: { name }, include: PROJECT_INCLUDE }) as unknown as Promise<ProjectWithRelations | null>;
} }
async create(data: CreateProjectInput & { ownerId: string }): Promise<Project> { async create(data: { name: string; description: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string }): Promise<ProjectWithRelations> {
return this.prisma.project.create({ const createData: Record<string, unknown> = {
data: {
name: data.name, name: data.name,
description: data.description, description: data.description,
ownerId: data.ownerId, ownerId: data.ownerId,
}, proxyMode: data.proxyMode,
}); };
if (data.llmProvider !== undefined) createData['llmProvider'] = data.llmProvider;
if (data.llmModel !== undefined) createData['llmModel'] = data.llmModel;
return this.prisma.project.create({
data: createData as Parameters<PrismaClient['project']['create']>[0]['data'],
include: PROJECT_INCLUDE,
}) as unknown as Promise<ProjectWithRelations>;
} }
async update(id: string, data: UpdateProjectInput): Promise<Project> { async update(id: string, data: Record<string, unknown>): Promise<ProjectWithRelations> {
const updateData: Record<string, unknown> = {}; return this.prisma.project.update({
if (data.description !== undefined) updateData['description'] = data.description; where: { id },
return this.prisma.project.update({ where: { id }, data: updateData }); data,
include: PROJECT_INCLUDE,
}) as unknown as Promise<ProjectWithRelations>;
} }
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
await this.prisma.project.delete({ where: { id } }); await this.prisma.project.delete({ where: { id } });
} }
async setProfiles(projectId: string, profileIds: string[]): Promise<void> { async setServers(projectId: string, serverIds: string[]): Promise<void> {
await this.prisma.$transaction([ await this.prisma.$transaction(async (tx) => {
this.prisma.projectMcpProfile.deleteMany({ where: { projectId } }), await tx.projectServer.deleteMany({ where: { projectId } });
...profileIds.map((profileId) => if (serverIds.length > 0) {
this.prisma.projectMcpProfile.create({ await tx.projectServer.createMany({
data: { projectId, profileId }, data: serverIds.map((serverId) => ({ projectId, serverId })),
}), });
), }
]); });
} }
async getProfileIds(projectId: string): Promise<string[]> { async setMembers(projectId: string, userIds: string[]): Promise<void> {
const links = await this.prisma.projectMcpProfile.findMany({ await this.prisma.$transaction(async (tx) => {
where: { projectId }, await tx.projectMember.deleteMany({ where: { projectId } });
select: { profileId: true }, if (userIds.length > 0) {
await tx.projectMember.createMany({
data: userIds.map((userId) => ({ projectId, userId })),
});
}
}); });
return links.map((l) => l.profileId);
} }
} }

View File

@@ -0,0 +1,48 @@
import type { PrismaClient, RbacDefinition } from '@prisma/client';
import type { CreateRbacDefinitionInput, UpdateRbacDefinitionInput } from '../validation/rbac-definition.schema.js';
export interface IRbacDefinitionRepository {
findAll(): Promise<RbacDefinition[]>;
findById(id: string): Promise<RbacDefinition | null>;
findByName(name: string): Promise<RbacDefinition | null>;
create(data: CreateRbacDefinitionInput): Promise<RbacDefinition>;
update(id: string, data: UpdateRbacDefinitionInput): Promise<RbacDefinition>;
delete(id: string): Promise<void>;
}
export class RbacDefinitionRepository implements IRbacDefinitionRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(): Promise<RbacDefinition[]> {
return this.prisma.rbacDefinition.findMany({ orderBy: { name: 'asc' } });
}
async findById(id: string): Promise<RbacDefinition | null> {
return this.prisma.rbacDefinition.findUnique({ where: { id } });
}
async findByName(name: string): Promise<RbacDefinition | null> {
return this.prisma.rbacDefinition.findUnique({ where: { name } });
}
async create(data: CreateRbacDefinitionInput): Promise<RbacDefinition> {
return this.prisma.rbacDefinition.create({
data: {
name: data.name,
subjects: data.subjects,
roleBindings: data.roleBindings,
},
});
}
async update(id: string, data: UpdateRbacDefinitionInput): Promise<RbacDefinition> {
const updateData: Record<string, unknown> = {};
if (data.subjects !== undefined) updateData['subjects'] = data.subjects;
if (data.roleBindings !== undefined) updateData['roleBindings'] = data.roleBindings;
return this.prisma.rbacDefinition.update({ where: { id }, data: updateData });
}
async delete(id: string): Promise<void> {
await this.prisma.rbacDefinition.delete({ where: { id } });
}
}

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

@@ -0,0 +1,76 @@
import type { PrismaClient, User } from '@prisma/client';
/** User without the passwordHash field — safe for API responses. */
export type SafeUser = Omit<User, 'passwordHash'>;
export interface IUserRepository {
findAll(): Promise<SafeUser[]>;
findById(id: string): Promise<SafeUser | null>;
findByEmail(email: string, includeHash?: boolean): Promise<SafeUser | null> | Promise<User | null>;
create(data: { email: string; passwordHash: string; name?: string; role?: string }): Promise<SafeUser>;
delete(id: string): Promise<void>;
count(): Promise<number>;
}
/** Fields to select when passwordHash must be excluded. */
const safeSelect = {
id: true,
email: true,
name: true,
role: true,
provider: true,
externalId: true,
version: true,
createdAt: true,
updatedAt: true,
} as const;
export class UserRepository implements IUserRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(): Promise<SafeUser[]> {
return this.prisma.user.findMany({
select: safeSelect,
orderBy: { email: 'asc' },
});
}
async findById(id: string): Promise<SafeUser | null> {
return this.prisma.user.findUnique({
where: { id },
select: safeSelect,
});
}
async findByEmail(email: string, includeHash?: boolean): Promise<User | SafeUser | null> {
if (includeHash === true) {
return this.prisma.user.findUnique({ where: { email } });
}
return this.prisma.user.findUnique({
where: { email },
select: safeSelect,
});
}
async create(data: { email: string; passwordHash: string; name?: string; role?: string }): Promise<SafeUser> {
const createData: Record<string, unknown> = {
email: data.email,
passwordHash: data.passwordHash,
};
if (data.name !== undefined) createData['name'] = data.name;
if (data.role !== undefined) createData['role'] = data.role;
return this.prisma.user.create({
data: createData as Parameters<PrismaClient['user']['create']>[0]['data'],
select: safeSelect,
});
}
async delete(id: string): Promise<void> {
await this.prisma.user.delete({ where: { id } });
}
async count(): Promise<number> {
return this.prisma.user.count();
}
}

View File

@@ -1,15 +1,76 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import type { AuthService } from '../services/auth.service.js'; import type { AuthService } from '../services/auth.service.js';
import type { UserService } from '../services/user.service.js';
import type { GroupService } from '../services/group.service.js';
import type { RbacDefinitionService } from '../services/rbac-definition.service.js';
import type { RbacService } from '../services/rbac.service.js';
import { createAuthMiddleware } from '../middleware/auth.js'; import { createAuthMiddleware } from '../middleware/auth.js';
import { createRbacMiddleware } from '../middleware/rbac.js';
export interface AuthRouteDeps { export interface AuthRouteDeps {
authService: AuthService; authService: AuthService;
userService: UserService;
groupService: GroupService;
rbacDefinitionService: RbacDefinitionService;
rbacService: RbacService;
} }
export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDeps): void { export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDeps): void {
const authMiddleware = createAuthMiddleware({ const authMiddleware = createAuthMiddleware({
findSession: (token) => deps.authService.findSession(token), findSession: (token) => deps.authService.findSession(token),
}); });
const { requireOperation } = createRbacMiddleware(deps.rbacService);
// GET /api/v1/auth/status — unauthenticated, returns whether any users exist
app.get('/api/v1/auth/status', async () => {
const count = await deps.userService.count();
return { hasUsers: count > 0 };
});
// POST /api/v1/auth/bootstrap — only works when no users exist (first-run setup)
app.post('/api/v1/auth/bootstrap', async (request, reply) => {
const count = await deps.userService.count();
if (count > 0) {
reply.code(409).send({ error: 'Users already exist. Use login instead.' });
return;
}
const { email, password, name } = request.body as { email: string; password: string; name?: string };
// Create the first admin user
await deps.userService.create({
email,
password,
...(name !== undefined ? { name } : {}),
});
// Create "admin" group and add the first user to it
await deps.groupService.create({
name: 'admin',
description: 'Bootstrap admin group',
members: [email],
});
// Create bootstrap RBAC: full resource access + all operations
await deps.rbacDefinitionService.create({
name: 'bootstrap-admin',
subjects: [{ kind: 'Group', name: 'admin' }],
roleBindings: [
{ role: 'edit', resource: '*' },
{ role: 'run', resource: '*' },
{ role: 'run', action: 'impersonate' },
{ role: 'run', action: 'logs' },
{ role: 'run', action: 'backup' },
{ role: 'run', action: 'restore' },
{ role: 'run', action: 'audit-purge' },
],
});
// Auto-login so the caller gets a token immediately
const session = await deps.authService.login(email, password);
reply.code(201);
return session;
});
// POST /api/v1/auth/login — no auth required // POST /api/v1/auth/login — no auth required
app.post<{ app.post<{
@@ -28,4 +89,15 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDeps): v
await deps.authService.logout(token); await deps.authService.logout(token);
return { success: true }; return { success: true };
}); });
// POST /api/v1/auth/impersonate — requires auth + run:impersonate operation
app.post(
'/api/v1/auth/impersonate',
{ preHandler: [authMiddleware, requireOperation('impersonate')] },
async (request) => {
const { email } = request.body as { email: string };
const result = await deps.authService.impersonate(email);
return result;
},
);
} }

View File

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

View File

@@ -0,0 +1,35 @@
import type { FastifyInstance } from 'fastify';
import type { GroupService } from '../services/group.service.js';
export function registerGroupRoutes(
app: FastifyInstance,
service: GroupService,
): void {
app.get('/api/v1/groups', async () => {
return service.list();
});
app.get<{ Params: { id: string } }>('/api/v1/groups/:id', async (request) => {
// Try by ID first, fall back to name lookup
try {
return await service.getById(request.params.id);
} catch {
return service.getByName(request.params.id);
}
});
app.post('/api/v1/groups', async (request, reply) => {
const group = await service.create(request.body);
reply.code(201);
return group;
});
app.put<{ Params: { id: string } }>('/api/v1/groups/:id', async (request) => {
return service.update(request.params.id, request.body);
});
app.delete<{ Params: { id: string } }>('/api/v1/groups/:id', async (request, reply) => {
await service.delete(request.params.id);
reply.code(204);
});
}

View File

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

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

@@ -8,7 +8,7 @@ export function registerProjectRoutes(app: FastifyInstance, service: ProjectServ
}); });
app.get<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => { app.get<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => {
return service.getById(request.params.id); return service.resolveAndGet(request.params.id);
}); });
app.post('/api/v1/projects', async (request, reply) => { app.post('/api/v1/projects', async (request, reply) => {
@@ -19,25 +19,24 @@ export function registerProjectRoutes(app: FastifyInstance, service: ProjectServ
}); });
app.put<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => { app.put<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => {
return service.update(request.params.id, request.body); const project = await service.resolveAndGet(request.params.id);
return service.update(project.id, request.body);
}); });
app.delete<{ Params: { id: string } }>('/api/v1/projects/:id', async (request, reply) => { app.delete<{ Params: { id: string } }>('/api/v1/projects/:id', async (request, reply) => {
await service.delete(request.params.id); const project = await service.resolveAndGet(request.params.id);
await service.delete(project.id);
reply.code(204); reply.code(204);
}); });
// Profile associations // Generate .mcp.json for a project
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) => { app.get<{ Params: { id: string } }>('/api/v1/projects/:id/mcp-config', async (request) => {
return service.getMcpConfig(request.params.id); return service.generateMcpConfig(request.params.id);
});
// List servers in a project (for mcplocal discovery)
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/servers', async (request) => {
const project = await service.resolveAndGet(request.params.id);
return project.servers.map((ps) => ps.server);
}); });
} }

View File

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

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

@@ -0,0 +1,31 @@
import type { FastifyInstance } from 'fastify';
import type { UserService } from '../services/user.service.js';
export function registerUserRoutes(
app: FastifyInstance,
service: UserService,
): void {
app.get('/api/v1/users', async () => {
return service.list();
});
app.get<{ Params: { id: string } }>('/api/v1/users/:id', async (request) => {
// Support lookup by email (contains @) or by id
const idOrEmail = request.params.id;
if (idOrEmail.includes('@')) {
return service.getByEmail(idOrEmail);
}
return service.getById(idOrEmail);
});
app.post('/api/v1/users', async (request, reply) => {
const user = await service.create(request.body);
reply.code(201);
return user;
});
app.delete<{ Params: { id: string } }>('/api/v1/users/:id', async (_request, reply) => {
await service.delete(_request.params.id);
reply.code(204);
});
}

View File

@@ -1,11 +1,45 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { PrismaClient } from '@prisma/client'; 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> { async function run(): Promise<void> {
const prisma = new PrismaClient(); const prisma = new PrismaClient();
try { try {
const count = await seedMcpServers(prisma); // Look for templates in common locations
console.log(`Seeded ${count} MCP servers`); 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 { } finally {
await prisma.$disconnect(); await prisma.$disconnect();
} }

View File

@@ -63,4 +63,32 @@ export class AuthService {
} }
return { userId: session.userId, expiresAt: session.expiresAt }; return { userId: session.userId, expiresAt: session.expiresAt };
} }
/**
* Create a session for a user by email without requiring their password.
* Used for admin impersonation.
*/
async impersonate(email: string): Promise<LoginResult> {
const user = await this.prisma.user.findUnique({ where: { email } });
if (user === null) {
throw new AuthenticationError('User not found');
}
const token = randomUUID();
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
await this.prisma.session.create({
data: {
token,
userId: user.id,
expiresAt,
},
});
return {
token,
expiresAt,
user: { id: user.id, email: user.email, role: user.role },
};
}
} }

View File

@@ -1,5 +1,8 @@
import type { IMcpServerRepository, IMcpProfileRepository } from '../../repositories/interfaces.js'; import type { IMcpServerRepository, ISecretRepository } from '../../repositories/interfaces.js';
import type { IProjectRepository } from '../../repositories/project.repository.js'; import type { IProjectRepository } from '../../repositories/project.repository.js';
import type { IUserRepository } from '../../repositories/user.repository.js';
import type { IGroupRepository } from '../../repositories/group.repository.js';
import type { IRbacDefinitionRepository } from '../../repositories/rbac-definition.repository.js';
import { encrypt, isSensitiveKey } from './crypto.js'; import { encrypt, isSensitiveKey } from './crypto.js';
import type { EncryptedPayload } from './crypto.js'; import type { EncryptedPayload } from './crypto.js';
import { APP_VERSION } from '@mcpctl/shared'; import { APP_VERSION } from '@mcpctl/shared';
@@ -10,8 +13,11 @@ export interface BackupBundle {
createdAt: string; createdAt: string;
encrypted: boolean; encrypted: boolean;
servers: BackupServer[]; servers: BackupServer[];
profiles: BackupProfile[]; secrets: BackupSecret[];
projects: BackupProject[]; projects: BackupProject[];
users?: BackupUser[];
groups?: BackupGroup[];
rbacBindings?: BackupRbacBinding[];
encryptedSecrets?: EncryptedPayload; encryptedSecrets?: EncryptedPayload;
} }
@@ -22,40 +28,67 @@ export interface BackupServer {
dockerImage: string | null; dockerImage: string | null;
transport: string; transport: string;
repositoryUrl: string | null; repositoryUrl: string | null;
envTemplate: unknown; env: unknown;
} }
export interface BackupProfile { export interface BackupSecret {
name: string; name: string;
serverName: string; data: Record<string, string>;
permissions: unknown;
envOverrides: unknown;
} }
export interface BackupProject { export interface BackupProject {
name: string; name: string;
description: string; description: string;
profileNames: string[]; proxyMode?: string;
llmProvider?: string | null;
llmModel?: string | null;
serverNames?: string[];
members?: string[];
}
export interface BackupUser {
email: string;
name: string | null;
role: string;
provider: string | null;
}
export interface BackupGroup {
name: string;
description: string;
memberEmails: string[];
}
export interface BackupRbacBinding {
name: string;
subjects: unknown;
roleBindings: unknown;
} }
export interface BackupOptions { export interface BackupOptions {
password?: string; password?: string;
resources?: Array<'servers' | 'profiles' | 'projects'>; resources?: Array<'servers' | 'secrets' | 'projects' | 'users' | 'groups' | 'rbac'>;
} }
export class BackupService { export class BackupService {
constructor( constructor(
private serverRepo: IMcpServerRepository, private serverRepo: IMcpServerRepository,
private profileRepo: IMcpProfileRepository,
private projectRepo: IProjectRepository, private projectRepo: IProjectRepository,
private secretRepo: ISecretRepository,
private userRepo?: IUserRepository,
private groupRepo?: IGroupRepository,
private rbacRepo?: IRbacDefinitionRepository,
) {} ) {}
async createBackup(options?: BackupOptions): Promise<BackupBundle> { async createBackup(options?: BackupOptions): Promise<BackupBundle> {
const resources = options?.resources ?? ['servers', 'profiles', 'projects']; const resources = options?.resources ?? ['servers', 'secrets', 'projects', 'users', 'groups', 'rbac'];
let servers: BackupServer[] = []; let servers: BackupServer[] = [];
let profiles: BackupProfile[] = []; let secrets: BackupSecret[] = [];
let projects: BackupProject[] = []; let projects: BackupProject[] = [];
let users: BackupUser[] = [];
let groups: BackupGroup[] = [];
let rbacBindings: BackupRbacBinding[] = [];
if (resources.includes('servers')) { if (resources.includes('servers')) {
const allServers = await this.serverRepo.findAll(); const allServers = await this.serverRepo.findAll();
@@ -66,44 +99,57 @@ export class BackupService {
dockerImage: s.dockerImage, dockerImage: s.dockerImage,
transport: s.transport, transport: s.transport,
repositoryUrl: s.repositoryUrl, repositoryUrl: s.repositoryUrl,
envTemplate: s.envTemplate, env: s.env,
})); }));
} }
if (resources.includes('profiles')) { if (resources.includes('secrets')) {
const allProfiles = await this.profileRepo.findAll(); const allSecrets = await this.secretRepo.findAll();
const serverMap = new Map<string, string>(); secrets = allSecrets.map((s) => ({
const allServers = await this.serverRepo.findAll(); name: s.name,
for (const s of allServers) { data: s.data as Record<string, string>,
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('projects')) { if (resources.includes('projects')) {
const allProjects = await this.projectRepo.findAll(); const allProjects = await this.projectRepo.findAll();
const allProfiles = await this.profileRepo.findAll(); projects = allProjects.map((proj) => ({
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, name: proj.name,
description: proj.description, description: proj.description,
profileNames: profileIds.map((id) => profileMap.get(id) ?? id), proxyMode: proj.proxyMode,
}; llmProvider: proj.llmProvider,
}), llmModel: proj.llmModel,
); serverNames: proj.servers.map((ps) => ps.server.name),
members: proj.members.map((pm) => pm.user.email),
}));
}
if (resources.includes('users') && this.userRepo) {
const allUsers = await this.userRepo.findAll();
users = allUsers.map((u) => ({
email: u.email,
name: u.name,
role: u.role,
provider: u.provider,
}));
}
if (resources.includes('groups') && this.groupRepo) {
const allGroups = await this.groupRepo.findAll();
groups = allGroups.map((g) => ({
name: g.name,
description: g.description,
memberEmails: g.members.map((m) => m.user.email),
}));
}
if (resources.includes('rbac') && this.rbacRepo) {
const allRbac = await this.rbacRepo.findAll();
rbacBindings = allRbac.map((r) => ({
name: r.name,
subjects: r.subjects,
roleBindings: r.roleBindings,
}));
} }
const bundle: BackupBundle = { const bundle: BackupBundle = {
@@ -112,29 +158,29 @@ export class BackupService {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
encrypted: false, encrypted: false,
servers, servers,
profiles, secrets,
projects, projects,
users,
groups,
rbacBindings,
}; };
if (options?.password) { if (options?.password && secrets.length > 0) {
// Collect sensitive values and encrypt them // Collect sensitive values from secrets and encrypt them
const secrets: Record<string, string> = {}; const sensitiveData: Record<string, string> = {};
for (const profile of profiles) { for (const secret of secrets) {
const overrides = profile.envOverrides as Record<string, string> | null; for (const [key, value] of Object.entries(secret.data)) {
if (overrides) {
for (const [key, value] of Object.entries(overrides)) {
if (isSensitiveKey(key)) { if (isSensitiveKey(key)) {
const secretKey = `profile:${profile.name}:${key}`; const secretKey = `secret:${secret.name}:${key}`;
secrets[secretKey] = value; sensitiveData[secretKey] = value;
(overrides as Record<string, string>)[key] = `__ENCRYPTED:${secretKey}__`; secret.data[key] = `__ENCRYPTED:${secretKey}__`;
}
} }
} }
} }
if (Object.keys(secrets).length > 0) { if (Object.keys(sensitiveData).length > 0) {
bundle.encrypted = true; 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 { 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 { RestoreService } from './restore-service.js';
export type { RestoreOptions, RestoreResult, ConflictStrategy } from './restore-service.js'; export type { RestoreOptions, RestoreResult, ConflictStrategy } from './restore-service.js';
export { encrypt, decrypt, isSensitiveKey } from './crypto.js'; export { encrypt, decrypt, isSensitiveKey } from './crypto.js';

View File

@@ -1,5 +1,9 @@
import type { IMcpServerRepository, IMcpProfileRepository } from '../../repositories/interfaces.js'; import type { IMcpServerRepository, ISecretRepository } from '../../repositories/interfaces.js';
import type { IProjectRepository } from '../../repositories/project.repository.js'; import type { IProjectRepository } from '../../repositories/project.repository.js';
import type { IUserRepository } from '../../repositories/user.repository.js';
import type { IGroupRepository } from '../../repositories/group.repository.js';
import type { IRbacDefinitionRepository } from '../../repositories/rbac-definition.repository.js';
import type { RbacRoleBinding } from '../../validation/rbac-definition.schema.js';
import { decrypt } from './crypto.js'; import { decrypt } from './crypto.js';
import type { BackupBundle } from './backup-service.js'; import type { BackupBundle } from './backup-service.js';
@@ -13,18 +17,27 @@ export interface RestoreOptions {
export interface RestoreResult { export interface RestoreResult {
serversCreated: number; serversCreated: number;
serversSkipped: number; serversSkipped: number;
profilesCreated: number; secretsCreated: number;
profilesSkipped: number; secretsSkipped: number;
projectsCreated: number; projectsCreated: number;
projectsSkipped: number; projectsSkipped: number;
usersCreated: number;
usersSkipped: number;
groupsCreated: number;
groupsSkipped: number;
rbacCreated: number;
rbacSkipped: number;
errors: string[]; errors: string[];
} }
export class RestoreService { export class RestoreService {
constructor( constructor(
private serverRepo: IMcpServerRepository, private serverRepo: IMcpServerRepository,
private profileRepo: IMcpProfileRepository,
private projectRepo: IProjectRepository, private projectRepo: IProjectRepository,
private secretRepo: ISecretRepository,
private userRepo?: IUserRepository,
private groupRepo?: IGroupRepository,
private rbacRepo?: IRbacDefinitionRepository,
) {} ) {}
validateBundle(bundle: unknown): bundle is BackupBundle { validateBundle(bundle: unknown): bundle is BackupBundle {
@@ -33,9 +46,10 @@ export class RestoreService {
return ( return (
typeof b['version'] === 'string' && typeof b['version'] === 'string' &&
Array.isArray(b['servers']) && Array.isArray(b['servers']) &&
Array.isArray(b['profiles']) && Array.isArray(b['secrets']) &&
Array.isArray(b['projects']) Array.isArray(b['projects'])
); );
// users, groups, rbacBindings are optional for backwards compatibility
} }
async restore(bundle: BackupBundle, options?: RestoreOptions): Promise<RestoreResult> { async restore(bundle: BackupBundle, options?: RestoreOptions): Promise<RestoreResult> {
@@ -43,46 +57,79 @@ export class RestoreService {
const result: RestoreResult = { const result: RestoreResult = {
serversCreated: 0, serversCreated: 0,
serversSkipped: 0, serversSkipped: 0,
profilesCreated: 0, secretsCreated: 0,
profilesSkipped: 0, secretsSkipped: 0,
projectsCreated: 0, projectsCreated: 0,
projectsSkipped: 0, projectsSkipped: 0,
usersCreated: 0,
usersSkipped: 0,
groupsCreated: 0,
groupsSkipped: 0,
rbacCreated: 0,
rbacSkipped: 0,
errors: [], errors: [],
}; };
// Decrypt secrets if encrypted // Decrypt secrets if encrypted
let secrets: Record<string, string> = {}; let decryptedSecrets: Record<string, string> = {};
if (bundle.encrypted && bundle.encryptedSecrets) { if (bundle.encrypted && bundle.encryptedSecrets) {
if (!options?.password) { if (!options?.password) {
result.errors.push('Backup is encrypted but no password provided'); result.errors.push('Backup is encrypted but no password provided');
return result; return result;
} }
try { 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 { } catch {
result.errors.push('Failed to decrypt backup - incorrect password or corrupted data'); result.errors.push('Failed to decrypt backup - incorrect password or corrupted data');
return result; return result;
} }
} }
// Restore secrets into profile envOverrides // Restore encrypted values into secret data
for (const profile of bundle.profiles) { for (const secret of bundle.secrets) {
const overrides = profile.envOverrides as Record<string, string> | null; for (const [key, value] of Object.entries(secret.data)) {
if (overrides) {
for (const [key, value] of Object.entries(overrides)) {
if (typeof value === 'string' && value.startsWith('__ENCRYPTED:') && value.endsWith('__')) { if (typeof value === 'string' && value.startsWith('__ENCRYPTED:') && value.endsWith('__')) {
const secretKey = value.slice(12, -2); const secretKey = value.slice(12, -2);
const decrypted = secrets[secretKey]; const decrypted = decryptedSecrets[secretKey];
if (decrypted !== undefined) { if (decrypted !== undefined) {
overrides[key] = decrypted; secret.data[key] = decrypted;
}
} }
} }
} }
} }
// Restore order: secrets → servers → users → groups → projects → rbacBindings
// Restore secrets
for (const secret of bundle.secrets) {
try {
const existing = await this.secretRepo.findByName(secret.name);
if (existing) {
if (strategy === 'fail') {
result.errors.push(`Secret "${secret.name}" already exists`);
return result;
}
if (strategy === 'skip') {
result.secretsSkipped++;
continue;
}
// overwrite
await this.secretRepo.update(existing.id, { data: secret.data });
result.secretsCreated++;
continue;
}
await this.secretRepo.create({
name: secret.name,
data: secret.data,
});
result.secretsCreated++;
} catch (err) {
result.errors.push(`Failed to restore secret "${secret.name}": ${err instanceof Error ? err.message : String(err)}`);
}
}
// Restore servers // Restore servers
const serverNameToId = new Map<string, string>();
for (const server of bundle.servers) { for (const server of bundle.servers) {
try { try {
const existing = await this.serverRepo.findByName(server.name); const existing = await this.serverRepo.findByName(server.name);
@@ -93,7 +140,6 @@ export class RestoreService {
} }
if (strategy === 'skip') { if (strategy === 'skip') {
result.serversSkipped++; result.serversSkipped++;
serverNameToId.set(server.name, existing.id);
continue; continue;
} }
// overwrite // overwrite
@@ -105,7 +151,6 @@ export class RestoreService {
if (server.dockerImage) updateData.dockerImage = server.dockerImage; if (server.dockerImage) updateData.dockerImage = server.dockerImage;
if (server.repositoryUrl) updateData.repositoryUrl = server.repositoryUrl; if (server.repositoryUrl) updateData.repositoryUrl = server.repositoryUrl;
await this.serverRepo.update(existing.id, updateData); await this.serverRepo.update(existing.id, updateData);
serverNameToId.set(server.name, existing.id);
result.serversCreated++; result.serversCreated++;
continue; continue;
} }
@@ -115,70 +160,87 @@ export class RestoreService {
description: server.description, description: server.description,
transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP', transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP',
replicas: (server as { replicas?: number }).replicas ?? 1, replicas: (server as { replicas?: number }).replicas ?? 1,
envTemplate: (server.envTemplate ?? []) as Array<{ name: string; description: string; isSecret: boolean }>, env: (server.env ?? []) as Array<{ name: string; value?: string; valueFrom?: { secretRef: { name: string; key: string } } }>,
}; };
if (server.packageName) createData.packageName = server.packageName; if (server.packageName) createData.packageName = server.packageName;
if (server.dockerImage) createData.dockerImage = server.dockerImage; if (server.dockerImage) createData.dockerImage = server.dockerImage;
if (server.repositoryUrl) createData.repositoryUrl = server.repositoryUrl; if (server.repositoryUrl) createData.repositoryUrl = server.repositoryUrl;
const created = await this.serverRepo.create(createData); await this.serverRepo.create(createData);
serverNameToId.set(server.name, created.id);
result.serversCreated++; result.serversCreated++;
} catch (err) { } catch (err) {
result.errors.push(`Failed to restore server "${server.name}": ${err instanceof Error ? err.message : String(err)}`); result.errors.push(`Failed to restore server "${server.name}": ${err instanceof Error ? err.message : String(err)}`);
} }
} }
// Restore profiles // Restore users
const profileNameToId = new Map<string, string>(); if (bundle.users && this.userRepo) {
for (const profile of bundle.profiles) { for (const user of bundle.users) {
try { try {
const serverId = serverNameToId.get(profile.serverName); const existing = await this.userRepo.findByEmail(user.email);
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);
if (existing) { if (existing) {
if (strategy === 'fail') { if (strategy === 'fail') {
result.errors.push(`Profile "${profile.name}" already exists for server "${profile.serverName}"`); result.errors.push(`User "${user.email}" already exists`);
return result;
}
result.usersSkipped++;
continue;
}
// Create with placeholder passwordHash — user must reset password
const createData: { email: string; passwordHash: string; name?: string; role?: string } = {
email: user.email,
passwordHash: '__RESTORED_MUST_RESET__',
role: user.role,
};
if (user.name !== null) createData.name = user.name;
await this.userRepo.create(createData);
result.usersCreated++;
} catch (err) {
result.errors.push(`Failed to restore user "${user.email}": ${err instanceof Error ? err.message : String(err)}`);
}
}
}
// Restore groups
if (bundle.groups && this.groupRepo && this.userRepo) {
for (const group of bundle.groups) {
try {
const existing = await this.groupRepo.findByName(group.name);
if (existing) {
if (strategy === 'fail') {
result.errors.push(`Group "${group.name}" already exists`);
return result; return result;
} }
if (strategy === 'skip') { if (strategy === 'skip') {
result.profilesSkipped++; result.groupsSkipped++;
profileNameToId.set(profile.name, existing.id);
continue; continue;
} }
// overwrite // overwrite: update description and re-set members
await this.profileRepo.update(existing.id, { await this.groupRepo.update(existing.id, { description: group.description });
permissions: profile.permissions as string[], if (group.memberEmails.length > 0) {
envOverrides: profile.envOverrides as Record<string, string>, const memberIds = await this.resolveUserEmails(group.memberEmails);
}); await this.groupRepo.setMembers(existing.id, memberIds);
profileNameToId.set(profile.name, existing.id); }
result.profilesCreated++; result.groupsCreated++;
continue; continue;
} }
const created = await this.profileRepo.create({ const created = await this.groupRepo.create({
name: profile.name, name: group.name,
serverId: sid, description: group.description,
permissions: profile.permissions as string[],
envOverrides: profile.envOverrides as Record<string, string>,
}); });
profileNameToId.set(profile.name, created.id); if (group.memberEmails.length > 0) {
result.profilesCreated++; const memberIds = await this.resolveUserEmails(group.memberEmails);
await this.groupRepo.setMembers(created.id, memberIds);
}
result.groupsCreated++;
} catch (err) { } catch (err) {
result.errors.push(`Failed to restore profile "${profile.name}": ${err instanceof Error ? err.message : String(err)}`); result.errors.push(`Failed to restore group "${group.name}": ${err instanceof Error ? err.message : String(err)}`);
}
} }
} }
// Restore projects // Restore projects (enriched)
for (const project of bundle.projects) { for (const project of bundle.projects) {
try { try {
const existing = await this.projectRepo.findByName(project.name); const existing = await this.projectRepo.findByName(project.name);
@@ -191,35 +253,121 @@ export class RestoreService {
result.projectsSkipped++; result.projectsSkipped++;
continue; continue;
} }
// overwrite - update and set profiles // overwrite
await this.projectRepo.update(existing.id, { description: project.description }); const updateData: Record<string, unknown> = { description: project.description };
const profileIds = project.profileNames if (project.proxyMode) updateData['proxyMode'] = project.proxyMode;
.map((name) => profileNameToId.get(name)) if (project.llmProvider !== undefined) updateData['llmProvider'] = project.llmProvider;
.filter((id): id is string => id !== undefined); if (project.llmModel !== undefined) updateData['llmModel'] = project.llmModel;
if (profileIds.length > 0) { await this.projectRepo.update(existing.id, updateData);
await this.projectRepo.setProfiles(existing.id, profileIds);
// Re-link servers and members
if (project.serverNames && project.serverNames.length > 0) {
const serverIds = await this.resolveServerNames(project.serverNames);
await this.projectRepo.setServers(existing.id, serverIds);
} }
if (project.members && project.members.length > 0 && this.userRepo) {
const memberData = await this.resolveProjectMembers(project.members);
await this.projectRepo.setMembers(existing.id, memberData);
}
result.projectsCreated++; result.projectsCreated++;
continue; continue;
} }
const created = await this.projectRepo.create({ const projectCreateData: { name: string; description: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string } = {
name: project.name, name: project.name,
description: project.description, description: project.description,
ownerId: 'system', ownerId: 'system',
}); proxyMode: project.proxyMode ?? 'direct',
const profileIds = project.profileNames };
.map((name) => profileNameToId.get(name)) if (project.llmProvider != null) projectCreateData.llmProvider = project.llmProvider;
.filter((id): id is string => id !== undefined); if (project.llmModel != null) projectCreateData.llmModel = project.llmModel;
if (profileIds.length > 0) { const created = await this.projectRepo.create(projectCreateData);
await this.projectRepo.setProfiles(created.id, profileIds);
// Link servers
if (project.serverNames && project.serverNames.length > 0) {
const serverIds = await this.resolveServerNames(project.serverNames);
await this.projectRepo.setServers(created.id, serverIds);
} }
// Link members
if (project.members && project.members.length > 0 && this.userRepo) {
const memberData = await this.resolveProjectMembers(project.members);
await this.projectRepo.setMembers(created.id, memberData);
}
result.projectsCreated++; result.projectsCreated++;
} catch (err) { } catch (err) {
result.errors.push(`Failed to restore project "${project.name}": ${err instanceof Error ? err.message : String(err)}`); result.errors.push(`Failed to restore project "${project.name}": ${err instanceof Error ? err.message : String(err)}`);
} }
} }
// Restore RBAC bindings
if (bundle.rbacBindings && this.rbacRepo) {
for (const rbac of bundle.rbacBindings) {
try {
const existing = await this.rbacRepo.findByName(rbac.name);
if (existing) {
if (strategy === 'fail') {
result.errors.push(`RBAC binding "${rbac.name}" already exists`);
return result; return result;
} }
if (strategy === 'skip') {
result.rbacSkipped++;
continue;
}
// overwrite
await this.rbacRepo.update(existing.id, {
subjects: rbac.subjects as Array<{ kind: 'User' | 'Group'; name: string }>,
roleBindings: rbac.roleBindings as RbacRoleBinding[],
});
result.rbacCreated++;
continue;
}
await this.rbacRepo.create({
name: rbac.name,
subjects: rbac.subjects as Array<{ kind: 'User' | 'Group'; name: string }>,
roleBindings: rbac.roleBindings as RbacRoleBinding[],
});
result.rbacCreated++;
} catch (err) {
result.errors.push(`Failed to restore RBAC binding "${rbac.name}": ${err instanceof Error ? err.message : String(err)}`);
}
}
}
return result;
}
/** Resolve email addresses to user IDs via the user repository. */
private async resolveUserEmails(emails: string[]): Promise<string[]> {
const ids: string[] = [];
for (const email of emails) {
const user = await this.userRepo!.findByEmail(email);
if (user) ids.push(user.id);
}
return ids;
}
/** Resolve server names to server IDs via the server repository. */
private async resolveServerNames(names: string[]): Promise<string[]> {
const ids: string[] = [];
for (const name of names) {
const server = await this.serverRepo.findByName(name);
if (server) ids.push(server.id);
}
return ids;
}
/** Resolve project member emails to user IDs. */
private async resolveProjectMembers(
members: string[],
): Promise<string[]> {
const resolved: string[] = [];
for (const email of members) {
const user = await this.userRepo!.findByEmail(email);
if (user) resolved.push(user.id);
}
return resolved;
}
} }

View File

@@ -1,11 +1,13 @@
import Docker from 'dockerode'; import Docker from 'dockerode';
import { PassThrough } from 'node:stream';
import type { import type {
McpOrchestrator, McpOrchestrator,
ContainerSpec, ContainerSpec,
ContainerInfo, ContainerInfo,
ContainerLogs, ContainerLogs,
ExecResult,
} from '../orchestrator.js'; } 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'; const MCPCTL_LABEL = 'mcpctl.managed';
@@ -54,7 +56,7 @@ export class DockerContainerManager implements McpOrchestrator {
async createContainer(spec: ContainerSpec): Promise<ContainerInfo> { async createContainer(spec: ContainerSpec): Promise<ContainerInfo> {
const memoryLimit = spec.memoryLimit ?? DEFAULT_MEMORY_LIMIT; 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 portBindings: Record<string, Array<{ HostPort: string }>> = {};
const exposedPorts: Record<string, Record<string, never>> = {}; const exposedPorts: Record<string, Record<string, never>> = {};
@@ -80,10 +82,13 @@ export class DockerContainerManager implements McpOrchestrator {
Env: envArr, Env: envArr,
ExposedPorts: exposedPorts, ExposedPorts: exposedPorts,
Labels: labels, Labels: labels,
// Keep stdin open for STDIO MCP servers (they read from stdin)
OpenStdin: true,
StdinOnce: false,
HostConfig: { HostConfig: {
PortBindings: portBindings, PortBindings: portBindings,
Memory: memoryLimit, Memory: memoryLimit,
NanoCpus: nanoCpus, ...(nanoCpus ? { NanoCpus: nanoCpus } : {}),
NetworkMode: spec.network ?? 'bridge', NetworkMode: spec.network ?? 'bridge',
}, },
}; };
@@ -133,6 +138,19 @@ export class DockerContainerManager implements McpOrchestrator {
if (port !== undefined) { if (port !== undefined) {
result.port = port; result.port = port;
} }
// Extract container IP from first non-default network
const networks = info.NetworkSettings?.Networks;
if (networks) {
for (const [, net] of Object.entries(networks)) {
const netInfo = net as { IPAddress?: string };
if (netInfo.IPAddress) {
result.ip = netInfo.IPAddress;
break;
}
}
}
return result; return result;
} }
@@ -158,4 +176,67 @@ export class DockerContainerManager implements McpOrchestrator {
// For simplicity we return everything as stdout. // For simplicity we return everything as stdout.
return { stdout: raw, stderr: '' }; return { stdout: raw, stderr: '' };
} }
async execInContainer(
containerId: string,
cmd: string[],
opts?: { stdin?: string; timeoutMs?: number },
): Promise<ExecResult> {
const container = this.docker.getContainer(containerId);
const hasStdin = opts?.stdin !== undefined;
const exec = await container.exec({
Cmd: cmd,
AttachStdin: hasStdin,
AttachStdout: true,
AttachStderr: true,
});
const stream = await exec.start({ hijack: hasStdin, stdin: hasStdin });
const timeoutMs = opts?.timeoutMs ?? 30_000;
return new Promise<ExecResult>((resolve, reject) => {
const stdout = new PassThrough();
const stderr = new PassThrough();
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
this.docker.modem.demuxStream(stream, stdout, stderr);
if (hasStdin) {
stream.write(opts!.stdin);
stream.end();
}
const timer = setTimeout(() => {
stream.destroy();
reject(new Error(`Exec timed out after ${timeoutMs}ms`));
}, timeoutMs);
stream.on('end', () => {
clearTimeout(timer);
exec.inspect().then((info) => {
resolve({
exitCode: (info as { ExitCode: number }).ExitCode,
stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
stderr: Buffer.concat(stderrChunks).toString('utf-8'),
});
}).catch((err) => {
resolve({
exitCode: -1,
stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
stderr: err instanceof Error ? err.message : String(err),
});
});
});
stream.on('error', (err: Error) => {
clearTimeout(timer);
reject(err);
});
});
}
} }

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

@@ -0,0 +1,89 @@
import type { GroupWithMembers, IGroupRepository } from '../repositories/group.repository.js';
import type { IUserRepository } from '../repositories/user.repository.js';
import { CreateGroupSchema, UpdateGroupSchema } from '../validation/group.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';
export class GroupService {
constructor(
private readonly groupRepo: IGroupRepository,
private readonly userRepo: IUserRepository,
) {}
async list(): Promise<GroupWithMembers[]> {
return this.groupRepo.findAll();
}
async getById(id: string): Promise<GroupWithMembers> {
const group = await this.groupRepo.findById(id);
if (group === null) {
throw new NotFoundError(`Group not found: ${id}`);
}
return group;
}
async getByName(name: string): Promise<GroupWithMembers> {
const group = await this.groupRepo.findByName(name);
if (group === null) {
throw new NotFoundError(`Group not found: ${name}`);
}
return group;
}
async create(input: unknown): Promise<GroupWithMembers> {
const data = CreateGroupSchema.parse(input);
const existing = await this.groupRepo.findByName(data.name);
if (existing !== null) {
throw new ConflictError(`Group already exists: ${data.name}`);
}
const group = await this.groupRepo.create({
name: data.name,
description: data.description,
});
if (data.members.length > 0) {
const userIds = await this.resolveEmails(data.members);
await this.groupRepo.setMembers(group.id, userIds);
}
const result = await this.groupRepo.findById(group.id);
// Should always exist since we just created it
return result!;
}
async update(id: string, input: unknown): Promise<GroupWithMembers> {
const data = UpdateGroupSchema.parse(input);
// Verify exists
await this.getById(id);
if (data.description !== undefined) {
await this.groupRepo.update(id, { description: data.description });
}
if (data.members !== undefined) {
const userIds = await this.resolveEmails(data.members);
await this.groupRepo.setMembers(id, userIds);
}
return this.getById(id);
}
async delete(id: string): Promise<void> {
await this.getById(id);
await this.groupRepo.delete(id);
}
private async resolveEmails(emails: string[]): Promise<string[]> {
const userIds: string[] = [];
for (const email of emails) {
const user = await this.userRepo.findByEmail(email);
if (user === null) {
throw new NotFoundError(`User not found: ${email}`);
}
userIds.push(user.id);
}
return userIds;
}
}

View File

@@ -0,0 +1,520 @@
import type { McpServer, McpInstance } from '@prisma/client';
import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js';
import type { McpOrchestrator } from './orchestrator.js';
export interface HealthCheckSpec {
tool: string;
arguments?: Record<string, unknown>;
intervalSeconds?: number;
timeoutSeconds?: number;
failureThreshold?: number;
}
export interface ProbeResult {
healthy: boolean;
latencyMs: number;
message: string;
}
interface ProbeState {
consecutiveFailures: number;
lastProbeAt: number;
}
/**
* Periodic health probe runner — calls MCP tools on running instances to verify
* they are alive and responsive. Mirrors Kubernetes liveness probe semantics.
*
* For STDIO servers: runs `docker exec` with a disposable MCP client script
* that sends initialize + tool/call via the package binary.
*
* For SSE/HTTP servers: sends HTTP JSON-RPC directly to the container port.
*/
export class HealthProbeRunner {
private probeStates = new Map<string, ProbeState>();
private timer: ReturnType<typeof setInterval> | null = null;
constructor(
private instanceRepo: IMcpInstanceRepository,
private serverRepo: IMcpServerRepository,
private orchestrator: McpOrchestrator,
private logger?: { info: (msg: string) => void; error: (obj: unknown, msg: string) => void },
) {}
/** Start the periodic probe loop. Runs every `tickIntervalMs` (default 15s). */
start(tickIntervalMs = 15_000): void {
if (this.timer) return;
this.timer = setInterval(() => {
this.tick().catch((err) => {
this.logger?.error({ err }, 'Health probe tick failed');
});
}, tickIntervalMs);
}
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
/** Single tick: probe all RUNNING instances that have healthCheck configs and are due. */
async tick(): Promise<void> {
const instances = await this.instanceRepo.findAll();
const running = instances.filter((i) => i.status === 'RUNNING' && i.containerId);
// Cache servers by ID to avoid repeated lookups
const serverCache = new Map<string, McpServer>();
for (const inst of running) {
let server = serverCache.get(inst.serverId);
if (!server) {
const s = await this.serverRepo.findById(inst.serverId);
if (!s) continue;
serverCache.set(inst.serverId, s);
server = s;
}
const healthCheck = server.healthCheck as HealthCheckSpec | null;
if (!healthCheck) continue;
const intervalMs = (healthCheck.intervalSeconds ?? 60) * 1000;
const state = this.probeStates.get(inst.id);
const now = Date.now();
// Skip if not due yet
if (state && (now - state.lastProbeAt) < intervalMs) continue;
await this.probeInstance(inst, server, healthCheck);
}
// Clean up states for instances that no longer exist
const activeIds = new Set(running.map((i) => i.id));
for (const key of this.probeStates.keys()) {
if (!activeIds.has(key)) {
this.probeStates.delete(key);
}
}
}
/** Probe a single instance and update its health status. */
async probeInstance(
instance: McpInstance,
server: McpServer,
healthCheck: HealthCheckSpec,
): Promise<ProbeResult> {
const timeoutMs = (healthCheck.timeoutSeconds ?? 10) * 1000;
const failureThreshold = healthCheck.failureThreshold ?? 3;
const now = new Date();
const start = Date.now();
let result: ProbeResult;
try {
if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') {
result = await this.probeHttp(instance, server, healthCheck, timeoutMs);
} else {
result = await this.probeStdio(instance, server, healthCheck, timeoutMs);
}
} catch (err) {
result = {
healthy: false,
latencyMs: Date.now() - start,
message: err instanceof Error ? err.message : String(err),
};
}
// Update probe state
const state = this.probeStates.get(instance.id) ?? { consecutiveFailures: 0, lastProbeAt: 0 };
state.lastProbeAt = Date.now();
if (result.healthy) {
state.consecutiveFailures = 0;
} else {
state.consecutiveFailures++;
}
this.probeStates.set(instance.id, state);
// Determine health status
const healthStatus = result.healthy
? 'healthy'
: state.consecutiveFailures >= failureThreshold
? 'unhealthy'
: 'degraded';
// Build event
const eventType = result.healthy ? 'Normal' : 'Warning';
const eventMessage = result.healthy
? `Health check passed (${result.latencyMs}ms)`
: `Health check failed: ${result.message}`;
const existingEvents = (instance.events as Array<{ timestamp: string; type: string; message: string }>) ?? [];
// Keep last 50 events
const events = [
...existingEvents.slice(-49),
{ timestamp: now.toISOString(), type: eventType, message: eventMessage },
];
// Update instance
await this.instanceRepo.updateStatus(instance.id, instance.status as 'RUNNING', {
healthStatus,
lastHealthCheck: now,
events,
});
this.logger?.info(
`[health] ${(instance as unknown as { server?: { name: string } }).server?.name ?? instance.serverId}: ${healthStatus} (${result.latencyMs}ms) - ${eventMessage}`,
);
return result;
}
/** Probe an HTTP/SSE MCP server by sending a JSON-RPC tool call. */
private async probeHttp(
instance: McpInstance,
server: McpServer,
healthCheck: HealthCheckSpec,
timeoutMs: number,
): Promise<ProbeResult> {
if (!instance.containerId) {
return { healthy: false, latencyMs: 0, message: 'No container ID' };
}
// Get container IP for internal network communication
// (mcpd and MCP containers share the mcp-servers network)
const containerInfo = await this.orchestrator.inspectContainer(instance.containerId);
const containerPort = (server.containerPort as number | null) ?? 3000;
let baseUrl: string;
if (containerInfo.ip) {
baseUrl = `http://${containerInfo.ip}:${containerPort}`;
} else if (instance.port) {
baseUrl = `http://localhost:${instance.port}`;
} else {
return { healthy: false, latencyMs: 0, message: 'No container IP or port' };
}
if (server.transport === 'SSE') {
return this.probeSse(baseUrl, healthCheck, timeoutMs);
}
return this.probeStreamableHttp(baseUrl, healthCheck, timeoutMs);
}
/**
* Probe a streamable-http MCP server (POST to root endpoint).
*/
private async probeStreamableHttp(
baseUrl: string,
healthCheck: HealthCheckSpec,
timeoutMs: number,
): Promise<ProbeResult> {
const start = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const initResp = await fetch(baseUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream' },
body: JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'mcpctl-health', version: '0.1.0' } },
}),
signal: controller.signal,
});
if (!initResp.ok) {
return { healthy: false, latencyMs: Date.now() - start, message: `Initialize HTTP ${initResp.status}` };
}
const sessionId = initResp.headers.get('mcp-session-id');
const headers: Record<string, string> = { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream' };
if (sessionId) headers['Mcp-Session-Id'] = sessionId;
await fetch(baseUrl, {
method: 'POST', headers,
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
signal: controller.signal,
});
const toolResp = await fetch(baseUrl, {
method: 'POST', headers,
body: JSON.stringify({
jsonrpc: '2.0', id: 2, method: 'tools/call',
params: { name: healthCheck.tool, arguments: healthCheck.arguments ?? {} },
}),
signal: controller.signal,
});
const latencyMs = Date.now() - start;
if (!toolResp.ok) {
return { healthy: false, latencyMs, message: `Tool call HTTP ${toolResp.status}` };
}
const body = await toolResp.text();
try {
const parsed = JSON.parse(body.includes('data: ') ? body.split('data: ')[1]!.split('\n')[0]! : body);
if (parsed.error) {
return { healthy: false, latencyMs, message: parsed.error.message ?? 'Tool call error' };
}
} catch {
// If parsing fails but HTTP was ok, consider it healthy
}
return { healthy: true, latencyMs, message: 'ok' };
} finally {
clearTimeout(timer);
}
}
/**
* Probe an SSE-transport MCP server.
* SSE protocol: GET /sse → endpoint event → POST /messages?session_id=...
*/
private async probeSse(
baseUrl: string,
healthCheck: HealthCheckSpec,
timeoutMs: number,
): Promise<ProbeResult> {
const start = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
// 1. Connect to SSE endpoint to get the message URL
const sseResp = await fetch(`${baseUrl}/sse`, {
method: 'GET',
headers: { 'Accept': 'text/event-stream' },
signal: controller.signal,
});
if (!sseResp.ok) {
return { healthy: false, latencyMs: Date.now() - start, message: `SSE connect HTTP ${sseResp.status}` };
}
// 2. Read the SSE stream to find the endpoint event
const reader = sseResp.body?.getReader();
if (!reader) {
return { healthy: false, latencyMs: Date.now() - start, message: 'No SSE stream body' };
}
const decoder = new TextDecoder();
let buffer = '';
let messagesUrl = '';
// Read until we get the endpoint event
while (!messagesUrl) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
for (const line of buffer.split('\n')) {
if (line.startsWith('data: ') && buffer.includes('event: endpoint')) {
const endpoint = line.slice(6).trim();
// Endpoint may be relative (e.g., /messages?session_id=...) or absolute
messagesUrl = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`;
}
}
// Keep only the last incomplete line
const lines = buffer.split('\n');
buffer = lines[lines.length - 1] ?? '';
}
if (!messagesUrl) {
reader.cancel();
return { healthy: false, latencyMs: Date.now() - start, message: 'No endpoint event from SSE' };
}
// 3. Initialize via the messages endpoint
const postHeaders = { 'Content-Type': 'application/json' };
const initResp = await fetch(messagesUrl, {
method: 'POST', headers: postHeaders,
body: JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'mcpctl-health', version: '0.1.0' } },
}),
signal: controller.signal,
});
if (!initResp.ok) {
reader.cancel();
return { healthy: false, latencyMs: Date.now() - start, message: `Initialize HTTP ${initResp.status}` };
}
// 4. Send initialized notification
await fetch(messagesUrl, {
method: 'POST', headers: postHeaders,
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
signal: controller.signal,
});
// 5. Call health check tool
const toolResp = await fetch(messagesUrl, {
method: 'POST', headers: postHeaders,
body: JSON.stringify({
jsonrpc: '2.0', id: 2, method: 'tools/call',
params: { name: healthCheck.tool, arguments: healthCheck.arguments ?? {} },
}),
signal: controller.signal,
});
const latencyMs = Date.now() - start;
// 6. Read tool response from SSE stream
// The response comes back on the SSE stream, not the POST response
let responseBuffer = '';
const readTimeout = setTimeout(() => reader.cancel(), 5000);
while (true) {
const { done, value } = await reader.read();
if (done) break;
responseBuffer += decoder.decode(value, { stream: true });
// Look for data lines containing our response (id: 2)
for (const line of responseBuffer.split('\n')) {
if (line.startsWith('data: ')) {
try {
const parsed = JSON.parse(line.slice(6));
if (parsed.id === 2) {
clearTimeout(readTimeout);
reader.cancel();
if (parsed.error) {
return { healthy: false, latencyMs, message: parsed.error.message ?? 'Tool call error' };
}
return { healthy: true, latencyMs, message: 'ok' };
}
} catch {
// Not valid JSON, skip
}
}
}
const respLines = responseBuffer.split('\n');
responseBuffer = respLines[respLines.length - 1] ?? '';
}
clearTimeout(readTimeout);
reader.cancel();
// If POST response itself was ok (202 for SSE), consider it healthy
if (toolResp.ok) {
return { healthy: true, latencyMs, message: 'ok' };
}
return { healthy: false, latencyMs, message: `Tool call HTTP ${toolResp.status}` };
} finally {
clearTimeout(timer);
}
}
/**
* Probe a STDIO MCP server by running `docker exec` with a disposable Node.js
* script that pipes JSON-RPC messages into the package binary.
*/
private async probeStdio(
instance: McpInstance,
server: McpServer,
healthCheck: HealthCheckSpec,
timeoutMs: number,
): Promise<ProbeResult> {
if (!instance.containerId) {
return { healthy: false, latencyMs: 0, message: 'No container ID' };
}
const start = Date.now();
const packageName = server.packageName as string | null;
if (!packageName) {
return { healthy: false, latencyMs: 0, message: 'No package name for STDIO server' };
}
// Build JSON-RPC messages for the health probe
const initMsg = JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'mcpctl-health', version: '0.1.0' },
},
});
const initializedMsg = JSON.stringify({
jsonrpc: '2.0', method: 'notifications/initialized',
});
const toolCallMsg = JSON.stringify({
jsonrpc: '2.0', id: 2, method: 'tools/call',
params: { name: healthCheck.tool, arguments: healthCheck.arguments ?? {} },
});
// Use a Node.js inline script that:
// 1. Spawns the MCP server binary via npx
// 2. Sends initialize + initialized + tool call via stdin
// 3. Reads responses from stdout
// 4. Exits with 0 if tool call succeeds, 1 if it fails
const probeScript = `
const { spawn } = require('child_process');
const proc = spawn('npx', ['--prefer-offline', '-y', ${JSON.stringify(packageName)}], { stdio: ['pipe', 'pipe', 'pipe'] });
let output = '';
let responded = false;
proc.stdout.on('data', d => {
output += d;
const lines = output.split('\\n');
for (const line of lines) {
if (!line.trim()) continue;
try {
const msg = JSON.parse(line);
if (msg.id === 2) {
responded = true;
if (msg.error) {
process.stdout.write('ERROR:' + (msg.error.message || 'unknown'));
proc.kill();
process.exit(1);
} else {
process.stdout.write('OK');
proc.kill();
process.exit(0);
}
}
} catch {}
}
output = lines[lines.length - 1] || '';
});
proc.stderr.on('data', () => {});
proc.on('error', e => { process.stdout.write('ERROR:' + e.message); process.exit(1); });
proc.on('exit', (code) => { if (!responded) { process.stdout.write('ERROR:process exited ' + code); process.exit(1); } });
setTimeout(() => { if (!responded) { process.stdout.write('ERROR:timeout'); proc.kill(); process.exit(1); } }, ${timeoutMs - 2000});
proc.stdin.write(${JSON.stringify(initMsg)} + '\\n');
setTimeout(() => {
proc.stdin.write(${JSON.stringify(initializedMsg)} + '\\n');
setTimeout(() => {
proc.stdin.write(${JSON.stringify(toolCallMsg)} + '\\n');
}, 500);
}, 500);
`.trim();
try {
const result = await this.orchestrator.execInContainer(
instance.containerId,
['node', '-e', probeScript],
{ timeoutMs },
);
const latencyMs = Date.now() - start;
if (result.exitCode === 0 && result.stdout.includes('OK')) {
return { healthy: true, latencyMs, message: 'ok' };
}
// Extract error message
const errorMatch = result.stdout.match(/ERROR:(.*)/);
const errorMsg = errorMatch?.[1] ?? (result.stderr.trim() || `exit code ${result.exitCode}`);
return { healthy: false, latencyMs, message: errorMsg };
} catch (err) {
return {
healthy: false,
latencyMs: Date.now() - start,
message: err instanceof Error ? err.message : String(err),
};
}
}
}

View File

@@ -1,10 +1,11 @@
export { McpServerService, NotFoundError, ConflictError } from './mcp-server.service.js'; 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 { ProjectService } from './project.service.js';
export { InstanceService, InvalidStateError } from './instance.service.js'; export { InstanceService, InvalidStateError } from './instance.service.js';
export { generateMcpConfig } from './mcp-config-generator.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 type { McpOrchestrator, ContainerSpec, ContainerInfo, ContainerLogs, ExecResult } from './orchestrator.js';
export { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from './orchestrator.js'; export { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from './orchestrator.js';
export { DockerContainerManager } from './docker/container-manager.js'; export { DockerContainerManager } from './docker/container-manager.js';
export { AuditLogService } from './audit-log.service.js'; export { AuditLogService } from './audit-log.service.js';
@@ -23,3 +24,11 @@ export { AuthService, AuthenticationError } from './auth.service.js';
export type { LoginResult } from './auth.service.js'; export type { LoginResult } from './auth.service.js';
export { McpProxyService } from './mcp-proxy-service.js'; export { McpProxyService } from './mcp-proxy-service.js';
export type { McpProxyRequest, McpProxyResponse } from './mcp-proxy-service.js'; export type { McpProxyRequest, McpProxyResponse } from './mcp-proxy-service.js';
export { TemplateService } from './template.service.js';
export { HealthProbeRunner } from './health-probe.service.js';
export type { HealthCheckSpec, ProbeResult } from './health-probe.service.js';
export { RbacDefinitionService } from './rbac-definition.service.js';
export { RbacService } from './rbac.service.js';
export type { RbacAction, Permission } from './rbac.service.js';
export { UserService } from './user.service.js';
export { GroupService } from './group.service.js';

View File

@@ -1,7 +1,14 @@
import type { McpInstance } from '@prisma/client'; 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 type { McpOrchestrator, ContainerSpec, ContainerInfo } from './orchestrator.js';
import { NotFoundError } from './mcp-server.service.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 { export class InvalidStateError extends Error {
readonly statusCode = 409; readonly statusCode = 409;
@@ -16,6 +23,7 @@ export class InstanceService {
private instanceRepo: IMcpInstanceRepository, private instanceRepo: IMcpInstanceRepository,
private serverRepo: IMcpServerRepository, private serverRepo: IMcpServerRepository,
private orchestrator: McpOrchestrator, private orchestrator: McpOrchestrator,
private secretRepo?: ISecretRepository,
) {} ) {}
async list(serverId?: string): Promise<McpInstance[]> { async list(serverId?: string): Promise<McpInstance[]> {
@@ -28,8 +36,41 @@ export class InstanceService {
return instance; return instance;
} }
/**
* Sync instance statuses with actual container state.
* Detects crashed/stopped containers and marks them ERROR.
*/
async syncStatus(): Promise<void> {
const instances = await this.instanceRepo.findAll();
for (const inst of instances) {
if ((inst.status === 'RUNNING' || inst.status === 'STARTING') && inst.containerId) {
try {
const info = await this.orchestrator.inspectContainer(inst.containerId);
if (info.state === 'stopped' || info.state === 'error') {
// Container died — get last logs for error context
let errorMsg = `Container ${info.state}`;
try {
const logs = await this.orchestrator.getContainerLogs(inst.containerId, { tail: 5 });
const lastLog = (logs.stdout || logs.stderr).trim().split('\n').pop();
if (lastLog) errorMsg = lastLog;
} catch { /* best-effort */ }
await this.instanceRepo.updateStatus(inst.id, 'ERROR', {
metadata: { error: errorMsg },
});
}
} catch {
// Container gone entirely
await this.instanceRepo.updateStatus(inst.id, 'ERROR', {
metadata: { error: 'Container not found' },
});
}
}
}
}
/** /**
* Reconcile instances for a server to match desired replica count. * Reconcile instances for a server to match desired replica count.
* - Syncs container statuses first (detect crashed containers)
* - If fewer running instances than replicas: start new ones * - If fewer running instances than replicas: start new ones
* - If more running instances than replicas: remove excess (oldest first) * - If more running instances than replicas: remove excess (oldest first)
*/ */
@@ -37,6 +78,9 @@ export class InstanceService {
const server = await this.serverRepo.findById(serverId); const server = await this.serverRepo.findById(serverId);
if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`); if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`);
// Sync container statuses before counting active instances
await this.syncStatus();
const instances = await this.instanceRepo.findAll(serverId); const instances = await this.instanceRepo.findAll(serverId);
const active = instances.filter((i) => i.status === 'RUNNING' || i.status === 'STARTING'); const active = instances.filter((i) => i.status === 'RUNNING' || i.status === 'STARTING');
const desired = server.replicas; const desired = server.replicas;
@@ -137,7 +181,23 @@ export class InstanceService {
}); });
} }
const image = server.dockerImage ?? server.packageName ?? server.name; // 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({ let instance = await this.instanceRepo.create({
serverId, serverId,
@@ -149,6 +209,7 @@ export class InstanceService {
image, image,
name: `mcpctl-${server.name}-${instance.id}`, name: `mcpctl-${server.name}-${instance.id}`,
hostPort: null, hostPort: null,
network: MCP_SERVERS_NETWORK,
labels: { labels: {
'mcpctl.server-id': serverId, 'mcpctl.server-id': serverId,
'mcpctl.instance-id': instance.id, 'mcpctl.instance-id': instance.id,
@@ -157,10 +218,36 @@ export class InstanceService {
if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') { if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') {
spec.containerPort = server.containerPort ?? 3000; 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; const command = server.command as string[] | null;
if (command) { if (command) {
spec.command = 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 containerInfo = await this.orchestrator.createContainer(spec);

View File

@@ -3,6 +3,7 @@ import type {
ContainerSpec, ContainerSpec,
ContainerInfo, ContainerInfo,
ContainerLogs, ContainerLogs,
ExecResult,
} from '../orchestrator.js'; } from '../orchestrator.js';
import { K8sClient } from './k8s-client.js'; import { K8sClient } from './k8s-client.js';
import type { K8sClientConfig } from './k8s-client.js'; import type { K8sClientConfig } from './k8s-client.js';
@@ -164,6 +165,15 @@ export class KubernetesOrchestrator implements McpOrchestrator {
return { stdout, stderr: '' }; return { stdout, stderr: '' };
} }
async execInContainer(
_containerId: string,
_cmd: string[],
_opts?: { stdin?: string; timeoutMs?: number },
): Promise<ExecResult> {
// K8s exec via API — future implementation
throw new Error('execInContainer not yet implemented for Kubernetes');
}
async listContainers(namespace?: string): Promise<ContainerInfo[]> { async listContainers(namespace?: string): Promise<ContainerInfo[]> {
const ns = namespace ?? this.namespace; const ns = namespace ?? this.namespace;
const res = await this.client.get<K8sPodList>( const res = await this.client.get<K8sPodList>(

View File

@@ -1,8 +1,10 @@
import type { McpServer, McpProfile } from '@prisma/client'; import type { McpServer } from '@prisma/client';
export interface McpConfigServer { export interface McpConfigServer {
command: string; command?: string;
args: string[]; args?: string[];
url?: string;
headers?: Record<string, string>;
env?: Record<string, string>; env?: Record<string, string>;
} }
@@ -10,49 +12,33 @@ export interface McpConfig {
mcpServers: Record<string, McpConfigServer>; mcpServers: Record<string, McpConfigServer>;
} }
export interface ProfileWithServer {
profile: McpProfile;
server: McpServer;
}
/** /**
* Generate .mcp.json config from a project's profiles. * Generate .mcp.json config from servers with their resolved env vars.
* Secret env vars are excluded from the output — they must be injected at runtime.
*/ */
export function generateMcpConfig(profiles: ProfileWithServer[]): McpConfig { export function generateMcpConfig(
servers: Array<{ server: McpServer; resolvedEnv: Record<string, string> }>,
): McpConfig {
const mcpServers: Record<string, McpConfigServer> = {}; const mcpServers: Record<string, McpConfigServer> = {};
for (const { profile, server } of profiles) { for (const { server, resolvedEnv } of servers) {
const key = `${server.name}--${profile.name}`; if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') {
const envTemplate = server.envTemplate as Array<{ // Point at mcpd proxy URL for non-STDIO transports
name: string; mcpServers[server.name] = {
isSecret: boolean; url: `http://localhost:3100/api/v1/mcp/proxy/${server.name}`,
defaultValue?: string; };
}>; } else {
const envOverrides = profile.envOverrides as Record<string, string>; // STDIO — npx command approach
// 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;
}
}
const config: McpConfigServer = { const config: McpConfigServer = {
command: 'npx', command: 'npx',
args: ['-y', server.packageName ?? server.name], args: ['-y', server.packageName ?? server.name],
}; };
if (Object.keys(env).length > 0) { if (Object.keys(resolvedEnv).length > 0) {
config.env = env; config.env = resolvedEnv;
} }
mcpServers[key] = config; mcpServers[server.name] = config;
}
} }
return { mcpServers }; 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

@@ -30,6 +30,8 @@ export interface ContainerInfo {
name: string; name: string;
state: 'running' | 'stopped' | 'starting' | 'error' | 'unknown'; state: 'running' | 'stopped' | 'starting' | 'error' | 'unknown';
port?: number; port?: number;
/** Container IP on the first non-default network (for internal communication) */
ip?: string;
createdAt: Date; createdAt: Date;
} }
@@ -38,6 +40,12 @@ export interface ContainerLogs {
stderr: string; stderr: string;
} }
export interface ExecResult {
exitCode: number;
stdout: string;
stderr: string;
}
export interface McpOrchestrator { export interface McpOrchestrator {
/** Pull an image if not present locally */ /** Pull an image if not present locally */
pullImage(image: string): Promise<void>; pullImage(image: string): Promise<void>;
@@ -57,6 +65,9 @@ export interface McpOrchestrator {
/** Get container logs */ /** Get container logs */
getContainerLogs(containerId: string, opts?: { tail?: number; since?: number }): Promise<ContainerLogs>; getContainerLogs(containerId: string, opts?: { tail?: number; since?: number }): Promise<ContainerLogs>;
/** Execute a command inside a running container with optional stdin */
execInContainer(containerId: string, cmd: string[], opts?: { stdin?: string; timeoutMs?: number }): Promise<ExecResult>;
/** Check if the orchestrator runtime is available */ /** Check if the orchestrator runtime is available */
ping(): Promise<boolean>; ping(): Promise<boolean>;
} }

View File

@@ -1,23 +1,26 @@
import type { Project } from '@prisma/client'; import type { McpServer } from '@prisma/client';
import type { IProjectRepository } from '../repositories/project.repository.js'; import type { IProjectRepository, ProjectWithRelations } from '../repositories/project.repository.js';
import type { IMcpProfileRepository, IMcpServerRepository } from '../repositories/interfaces.js'; import type { IMcpServerRepository, ISecretRepository } from '../repositories/interfaces.js';
import { CreateProjectSchema, UpdateProjectSchema, UpdateProjectProfilesSchema } from '../validation/project.schema.js'; import type { IUserRepository } from '../repositories/user.repository.js';
import { CreateProjectSchema, UpdateProjectSchema } from '../validation/project.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js'; import { NotFoundError, ConflictError } from './mcp-server.service.js';
import { resolveServerEnv } from './env-resolver.js';
import { generateMcpConfig } from './mcp-config-generator.js'; import { generateMcpConfig } from './mcp-config-generator.js';
import type { McpConfig, ProfileWithServer } from './mcp-config-generator.js'; import type { McpConfig } from './mcp-config-generator.js';
export class ProjectService { export class ProjectService {
constructor( constructor(
private readonly projectRepo: IProjectRepository, private readonly projectRepo: IProjectRepository,
private readonly profileRepo: IMcpProfileRepository,
private readonly serverRepo: IMcpServerRepository, private readonly serverRepo: IMcpServerRepository,
private readonly secretRepo: ISecretRepository,
private readonly userRepo: IUserRepository,
) {} ) {}
async list(ownerId?: string): Promise<Project[]> { async list(ownerId?: string): Promise<ProjectWithRelations[]> {
return this.projectRepo.findAll(ownerId); return this.projectRepo.findAll(ownerId);
} }
async getById(id: string): Promise<Project> { async getById(id: string): Promise<ProjectWithRelations> {
const project = await this.projectRepo.findById(id); const project = await this.projectRepo.findById(id);
if (project === null) { if (project === null) {
throw new NotFoundError(`Project not found: ${id}`); throw new NotFoundError(`Project not found: ${id}`);
@@ -25,7 +28,20 @@ export class ProjectService {
return project; return project;
} }
async create(input: unknown, ownerId: string): Promise<Project> { /** Resolve by ID or name. */
async resolveAndGet(idOrName: string): Promise<ProjectWithRelations> {
// Try by ID first
const byId = await this.projectRepo.findById(idOrName);
if (byId !== null) return byId;
// Fall back to name
const byName = await this.projectRepo.findByName(idOrName);
if (byName !== null) return byName;
throw new NotFoundError(`Project not found: ${idOrName}`);
}
async create(input: unknown, ownerId: string): Promise<ProjectWithRelations> {
const data = CreateProjectSchema.parse(input); const data = CreateProjectSchema.parse(input);
const existing = await this.projectRepo.findByName(data.name); const existing = await this.projectRepo.findByName(data.name);
@@ -33,13 +49,63 @@ export class ProjectService {
throw new ConflictError(`Project already exists: ${data.name}`); throw new ConflictError(`Project already exists: ${data.name}`);
} }
return this.projectRepo.create({ ...data, ownerId }); // Resolve server names to IDs
const serverIds = await this.resolveServerNames(data.servers);
// Resolve member emails to user IDs
const resolvedMembers = await this.resolveMemberEmails(data.members);
const project = await this.projectRepo.create({
name: data.name,
description: data.description,
ownerId,
proxyMode: data.proxyMode,
...(data.llmProvider !== undefined ? { llmProvider: data.llmProvider } : {}),
...(data.llmModel !== undefined ? { llmModel: data.llmModel } : {}),
});
// Link servers and members
if (serverIds.length > 0) {
await this.projectRepo.setServers(project.id, serverIds);
}
if (resolvedMembers.length > 0) {
await this.projectRepo.setMembers(project.id, resolvedMembers);
} }
async update(id: string, input: unknown): Promise<Project> { // Re-fetch to include relations
return this.getById(project.id);
}
async update(id: string, input: unknown): Promise<ProjectWithRelations> {
const data = UpdateProjectSchema.parse(input); const data = UpdateProjectSchema.parse(input);
await this.getById(id); const project = await this.getById(id);
return this.projectRepo.update(id, data);
// Build update data for scalar fields
const updateData: Record<string, unknown> = {};
if (data.description !== undefined) updateData['description'] = data.description;
if (data.proxyMode !== undefined) updateData['proxyMode'] = data.proxyMode;
if (data.llmProvider !== undefined) updateData['llmProvider'] = data.llmProvider;
if (data.llmModel !== undefined) updateData['llmModel'] = data.llmModel;
// Update scalar fields if any changed
if (Object.keys(updateData).length > 0) {
await this.projectRepo.update(project.id, updateData);
}
// Update servers if provided
if (data.servers !== undefined) {
const serverIds = await this.resolveServerNames(data.servers);
await this.projectRepo.setServers(project.id, serverIds);
}
// Update members if provided
if (data.members !== undefined) {
const resolvedMembers = await this.resolveMemberEmails(data.members);
await this.projectRepo.setMembers(project.id, resolvedMembers);
}
// Re-fetch to include updated relations
return this.getById(project.id);
} }
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
@@ -47,40 +113,47 @@ export class ProjectService {
await this.projectRepo.delete(id); await this.projectRepo.delete(id);
} }
async setProfiles(projectId: string, input: unknown): Promise<string[]> { async generateMcpConfig(idOrName: string): Promise<McpConfig> {
const { profileIds } = UpdateProjectProfilesSchema.parse(input); const project = await this.resolveAndGet(idOrName);
await this.getById(projectId);
// Verify all profiles exist if (project.proxyMode === 'filtered') {
for (const profileId of profileIds) { // Single entry pointing at mcplocal proxy
const profile = await this.profileRepo.findById(profileId); return {
if (profile === null) { mcpServers: {
throw new NotFoundError(`Profile not found: ${profileId}`); [project.name]: {
} url: `http://localhost:3100/api/v1/mcp/proxy/project/${project.name}`,
},
},
};
} }
await this.projectRepo.setProfiles(projectId, profileIds); // Direct mode: fetch full servers and resolve env
return profileIds; const serverEntries: Array<{ server: McpServer; resolvedEnv: Record<string, string> }> = [];
}
async getProfiles(projectId: string): Promise<string[]> { for (const ps of project.servers) {
await this.getById(projectId); const server = await this.serverRepo.findById(ps.server.id);
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; if (server === null) continue;
profilesWithServers.push({ profile, server });
const resolvedEnv = await resolveServerEnv(server, this.secretRepo);
serverEntries.push({ server, resolvedEnv });
} }
return generateMcpConfig(profilesWithServers); return generateMcpConfig(serverEntries);
}
private async resolveServerNames(names: string[]): Promise<string[]> {
return Promise.all(names.map(async (name) => {
const server = await this.serverRepo.findByName(name);
if (server === null) throw new NotFoundError(`Server not found: ${name}`);
return server.id;
}));
}
private async resolveMemberEmails(emails: string[]): Promise<string[]> {
return Promise.all(emails.map(async (email) => {
const user = await this.userRepo.findByEmail(email);
if (user === null) throw new NotFoundError(`User not found: ${email}`);
return user.id;
}));
} }
} }

View File

@@ -0,0 +1,54 @@
import type { RbacDefinition } from '@prisma/client';
import type { IRbacDefinitionRepository } from '../repositories/rbac-definition.repository.js';
import { CreateRbacDefinitionSchema, UpdateRbacDefinitionSchema } from '../validation/rbac-definition.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';
export class RbacDefinitionService {
constructor(private readonly repo: IRbacDefinitionRepository) {}
async list(): Promise<RbacDefinition[]> {
return this.repo.findAll();
}
async getById(id: string): Promise<RbacDefinition> {
const def = await this.repo.findById(id);
if (def === null) {
throw new NotFoundError(`RbacDefinition not found: ${id}`);
}
return def;
}
async getByName(name: string): Promise<RbacDefinition> {
const def = await this.repo.findByName(name);
if (def === null) {
throw new NotFoundError(`RbacDefinition not found: ${name}`);
}
return def;
}
async create(input: unknown): Promise<RbacDefinition> {
const data = CreateRbacDefinitionSchema.parse(input);
const existing = await this.repo.findByName(data.name);
if (existing !== null) {
throw new ConflictError(`RbacDefinition already exists: ${data.name}`);
}
return this.repo.create(data);
}
async update(id: string, input: unknown): Promise<RbacDefinition> {
const data = UpdateRbacDefinitionSchema.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,130 @@
import type { PrismaClient } from '@prisma/client';
import type { IRbacDefinitionRepository } from '../repositories/rbac-definition.repository.js';
import {
normalizeResource,
isResourceBinding,
isOperationBinding,
type RbacSubject,
type RbacRoleBinding,
} from '../validation/rbac-definition.schema.js';
export type RbacAction = 'view' | 'create' | 'delete' | 'edit' | 'run';
export interface ResourcePermission {
role: string;
resource: string;
name?: string;
}
export interface OperationPermission {
role: 'run';
action: string;
}
export type Permission = ResourcePermission | OperationPermission;
/** Maps roles to the set of actions they grant. */
const ROLE_ACTIONS: Record<string, readonly RbacAction[]> = {
edit: ['view', 'create', 'delete', 'edit'],
view: ['view'],
create: ['create'],
delete: ['delete'],
run: ['run'],
};
export class RbacService {
constructor(
private readonly rbacRepo: IRbacDefinitionRepository,
private readonly prisma: PrismaClient,
) {}
/**
* Check whether a user is allowed to perform an action on a resource.
* @param resourceName — optional specific resource name (e.g. 'my-ha').
* If provided, name-scoped bindings only match when their name equals this.
* If omitted (listing), name-scoped bindings still grant access.
*/
async canAccess(userId: string, action: RbacAction, resource: string, resourceName?: string): Promise<boolean> {
const permissions = await this.getPermissions(userId);
const normalized = normalizeResource(resource);
for (const perm of permissions) {
if (!('resource' in perm)) continue;
const actions = ROLE_ACTIONS[perm.role];
if (actions === undefined) continue;
if (!actions.includes(action)) continue;
const permResource = normalizeResource(perm.resource);
if (permResource !== '*' && permResource !== normalized) continue;
// Name-scoped check: if binding has a name AND caller specified a resourceName, must match
if (perm.name !== undefined && resourceName !== undefined && perm.name !== resourceName) continue;
return true;
}
return false;
}
/**
* Check whether a user is allowed to perform a named operation.
* Operations require an explicit 'run' role binding with a matching action.
*/
async canRunOperation(userId: string, operation: string): Promise<boolean> {
const permissions = await this.getPermissions(userId);
for (const perm of permissions) {
if ('action' in perm && perm.role === 'run' && perm.action === operation) {
return true;
}
}
return false;
}
/**
* Collect all permissions for a user across all matching RbacDefinitions.
*/
async getPermissions(userId: string): Promise<Permission[]> {
// 1. Resolve user email
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { email: true },
});
if (user === null) return [];
// 2. Resolve group names the user belongs to
const memberships = await this.prisma.groupMember.findMany({
where: { userId },
select: { group: { select: { name: true } } },
});
const groupNames = memberships.map((m) => m.group.name);
// 3. Load all RbacDefinitions
const definitions = await this.rbacRepo.findAll();
// 4. Find definitions where user is a subject
const permissions: Permission[] = [];
for (const def of definitions) {
const subjects = def.subjects as RbacSubject[];
const matched = subjects.some((s) => {
if (s.kind === 'User') return s.name === user.email;
if (s.kind === 'Group') return groupNames.includes(s.name);
return false;
});
if (!matched) continue;
// 5. Collect roleBindings
const bindings = def.roleBindings as RbacRoleBinding[];
for (const binding of bindings) {
if (isResourceBinding(binding)) {
const perm: ResourcePermission = { role: binding.role, resource: binding.resource };
if (binding.name !== undefined) perm.name = binding.name;
permissions.push(perm);
} else if (isOperationBinding(binding)) {
permissions.push({ role: 'run', action: binding.action });
}
}
}
return permissions;
}
}

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

@@ -0,0 +1,60 @@
import bcrypt from 'bcrypt';
import type { IUserRepository, SafeUser } from '../repositories/user.repository.js';
import { CreateUserSchema } from '../validation/user.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';
const SALT_ROUNDS = 10;
export class UserService {
constructor(private readonly userRepo: IUserRepository) {}
async list(): Promise<SafeUser[]> {
return this.userRepo.findAll();
}
async getById(id: string): Promise<SafeUser> {
const user = await this.userRepo.findById(id);
if (user === null) {
throw new NotFoundError(`User not found: ${id}`);
}
return user;
}
async getByEmail(email: string): Promise<SafeUser> {
const user = await this.userRepo.findByEmail(email);
if (user === null) {
throw new NotFoundError(`User not found: ${email}`);
}
return user;
}
async create(input: unknown): Promise<SafeUser> {
const data = CreateUserSchema.parse(input);
const existing = await this.userRepo.findByEmail(data.email);
if (existing !== null) {
throw new ConflictError(`User already exists: ${data.email}`);
}
const passwordHash = await bcrypt.hash(data.password, SALT_ROUNDS);
const createData: { email: string; passwordHash: string; name?: string } = {
email: data.email,
passwordHash,
};
if (data.name !== undefined) {
createData.name = data.name;
}
return this.userRepo.create(createData);
}
async delete(id: string): Promise<void> {
await this.getById(id);
await this.userRepo.delete(id);
}
async count(): Promise<number> {
return this.userRepo.count();
}
}

View File

@@ -0,0 +1,15 @@
import { z } from 'zod';
export const CreateGroupSchema = 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(''),
members: z.array(z.string().email()).default([]),
});
export const UpdateGroupSchema = z.object({
description: z.string().max(1000).optional(),
members: z.array(z.string().email()).optional(),
});
export type CreateGroupInput = z.infer<typeof CreateGroupSchema>;
export type UpdateGroupInput = z.infer<typeof UpdateGroupSchema>;

View File

@@ -1,6 +1,6 @@
export { CreateMcpServerSchema, UpdateMcpServerSchema } from './mcp-server.schema.js'; export { CreateMcpServerSchema, UpdateMcpServerSchema } from './mcp-server.schema.js';
export type { CreateMcpServerInput, UpdateMcpServerInput } from './mcp-server.schema.js'; export type { CreateMcpServerInput, UpdateMcpServerInput } from './mcp-server.schema.js';
export { CreateMcpProfileSchema, UpdateMcpProfileSchema } from './mcp-profile.schema.js'; export { CreateProjectSchema, UpdateProjectSchema } from './project.schema.js';
export type { CreateMcpProfileInput, UpdateMcpProfileInput } from './mcp-profile.schema.js'; export type { CreateProjectInput, UpdateProjectInput } from './project.schema.js';
export { CreateProjectSchema, UpdateProjectSchema, UpdateProjectProfilesSchema } from './project.schema.js'; export { CreateRbacDefinitionSchema, UpdateRbacDefinitionSchema, RbacSubjectSchema, RbacRoleBindingSchema, RBAC_ROLES, RBAC_RESOURCES } from './rbac-definition.schema.js';
export type { CreateProjectInput, UpdateProjectInput, UpdateProjectProfilesInput } from './project.schema.js'; export type { CreateRbacDefinitionInput, UpdateRbacDefinitionInput, RbacSubject, RbacRoleBinding } from './rbac-definition.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 { z } from 'zod';
import { HealthCheckSchema } from './template.schema.js';
const EnvTemplateEntrySchema = z.object({ const SecretRefSchema = z.object({
name: z.string().min(1).max(100), name: z.string().min(1),
description: z.string().max(500).default(''), key: z.string().min(1),
isSecret: z.boolean().default(false),
setupUrl: z.string().url().optional(),
}); });
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({ export const CreateMcpServerSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
description: z.string().max(1000).default(''), description: z.string().max(1000).default(''),
@@ -18,7 +30,8 @@ export const CreateMcpServerSchema = z.object({
command: z.array(z.string()).optional(), command: z.array(z.string()).optional(),
containerPort: z.number().int().min(1).max(65535).optional(), containerPort: z.number().int().min(1).max(65535).optional(),
replicas: z.number().int().min(0).max(10).default(1), replicas: z.number().int().min(0).max(10).default(1),
envTemplate: z.array(EnvTemplateEntrySchema).default([]), env: z.array(ServerEnvEntrySchema).default([]),
healthCheck: HealthCheckSchema.optional(),
}); });
export const UpdateMcpServerSchema = z.object({ export const UpdateMcpServerSchema = z.object({
@@ -31,7 +44,8 @@ export const UpdateMcpServerSchema = z.object({
command: z.array(z.string()).nullable().optional(), command: z.array(z.string()).nullable().optional(),
containerPort: z.number().int().min(1).max(65535).nullable().optional(), containerPort: z.number().int().min(1).max(65535).nullable().optional(),
replicas: z.number().int().min(0).max(10).optional(), replicas: z.number().int().min(0).max(10).optional(),
envTemplate: z.array(EnvTemplateEntrySchema).optional(), env: z.array(ServerEnvEntrySchema).optional(),
healthCheck: HealthCheckSchema.nullable().optional(),
}); });
export type CreateMcpServerInput = z.infer<typeof CreateMcpServerSchema>; export type CreateMcpServerInput = z.infer<typeof CreateMcpServerSchema>;

View File

@@ -3,16 +3,24 @@ import { z } from 'zod';
export const CreateProjectSchema = z.object({ export const CreateProjectSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
description: z.string().max(1000).default(''), description: z.string().max(1000).default(''),
}); proxyMode: z.enum(['direct', 'filtered']).default('direct'),
llmProvider: z.string().max(100).optional(),
llmModel: z.string().max(100).optional(),
servers: z.array(z.string().min(1)).default([]),
members: z.array(z.string().email()).default([]),
}).refine(
(d) => d.proxyMode !== 'filtered' || d.llmProvider,
{ message: 'llmProvider is required when proxyMode is "filtered"' },
);
export const UpdateProjectSchema = z.object({ export const UpdateProjectSchema = z.object({
description: z.string().max(1000).optional(), description: z.string().max(1000).optional(),
}); proxyMode: z.enum(['direct', 'filtered']).optional(),
llmProvider: z.string().max(100).nullable().optional(),
export const UpdateProjectProfilesSchema = z.object({ llmModel: z.string().max(100).nullable().optional(),
profileIds: z.array(z.string().min(1)).min(0), servers: z.array(z.string().min(1)).optional(),
members: z.array(z.string().email()).optional(),
}); });
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>; export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>; export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>;
export type UpdateProjectProfilesInput = z.infer<typeof UpdateProjectProfilesSchema>;

View File

@@ -0,0 +1,71 @@
import { z } from 'zod';
export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run'] as const;
export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'projects', 'templates', 'users', 'groups', 'rbac'] as const;
/** Singular→plural map for resource names. */
const RESOURCE_ALIASES: Record<string, string> = {
server: 'servers',
instance: 'instances',
secret: 'secrets',
project: 'projects',
template: 'templates',
user: 'users',
group: 'groups',
};
/** Normalize a resource name to its canonical plural form. */
export function normalizeResource(resource: string): string {
return RESOURCE_ALIASES[resource] ?? resource;
}
export const RbacSubjectSchema = z.object({
kind: z.enum(['User', 'Group']),
name: z.string().min(1),
});
/** Resource binding: role grants access to a resource type (optionally scoped to a named instance). */
export const ResourceBindingSchema = z.object({
role: z.enum(RBAC_ROLES),
resource: z.string().min(1).transform(normalizeResource),
name: z.string().min(1).optional(),
});
/** Operation binding: 'run' role grants access to a named operation. */
export const OperationBindingSchema = z.object({
role: z.literal('run'),
action: z.string().min(1),
});
/** Union of both binding types. */
export const RbacRoleBindingSchema = z.union([
ResourceBindingSchema,
OperationBindingSchema,
]);
export type RbacSubject = z.infer<typeof RbacSubjectSchema>;
export type ResourceBinding = z.infer<typeof ResourceBindingSchema>;
export type OperationBinding = z.infer<typeof OperationBindingSchema>;
export type RbacRoleBinding = z.infer<typeof RbacRoleBindingSchema>;
export function isResourceBinding(b: RbacRoleBinding): b is ResourceBinding {
return 'resource' in b;
}
export function isOperationBinding(b: RbacRoleBinding): b is OperationBinding {
return 'action' in b;
}
export const CreateRbacDefinitionSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
subjects: z.array(RbacSubjectSchema).min(1),
roleBindings: z.array(RbacRoleBindingSchema).min(1),
});
export const UpdateRbacDefinitionSchema = z.object({
subjects: z.array(RbacSubjectSchema).min(1).optional(),
roleBindings: z.array(RbacRoleBindingSchema).min(1).optional(),
});
export type CreateRbacDefinitionInput = z.infer<typeof CreateRbacDefinitionSchema>;
export type UpdateRbacDefinitionInput = z.infer<typeof UpdateRbacDefinitionSchema>;

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

@@ -0,0 +1,15 @@
import { z } from 'zod';
export const CreateUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(128),
name: z.string().max(100).optional(),
});
export const UpdateUserSchema = z.object({
name: z.string().max(100).optional(),
password: z.string().min(8).max(128).optional(),
});
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;

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