Compare commits
22 Commits
feat/repla
...
feat/healt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cde1c59fd6 | ||
| daa5860ed2 | |||
|
|
ecbf48dd49 | ||
| d38b5aac60 | |||
|
|
d07d4d11dd | ||
| fa58c1b5ed | |||
|
|
dd1dfc629d | ||
| 7b3dab142e | |||
|
|
4c127a7dc3 | ||
| c1e3e4aed6 | |||
|
|
e45c6079c1 | ||
| e4aef3acf1 | |||
|
|
a2cda38850 | ||
| 081e90de0f | |||
|
|
4e3d896ef6 | ||
| 0823e965bf | |||
|
|
c97219f85e | ||
| 93adcd4be7 | |||
|
|
d58e6e153f | ||
|
|
1e8847bb63 | ||
|
|
2a0deaa225 | ||
| 4eef6e38a2 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,6 +9,8 @@ dist/
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
stack/.env
|
||||||
|
.portainer_password
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs/
|
logs/
|
||||||
|
|||||||
398
deploy.sh
Executable file
398
deploy.sh
Executable file
@@ -0,0 +1,398 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy mcpctl stack to Portainer
|
||||||
|
# Usage: ./deploy.sh [--dry-run]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
STACK_DIR="$SCRIPT_DIR/stack"
|
||||||
|
COMPOSE_FILE="$STACK_DIR/docker-compose.yml"
|
||||||
|
ENV_FILE="$STACK_DIR/.env"
|
||||||
|
|
||||||
|
# Portainer configuration
|
||||||
|
PORTAINER_URL="${PORTAINER_URL:-http://10.0.0.194:9000}"
|
||||||
|
PORTAINER_USER="${PORTAINER_USER:-michal}"
|
||||||
|
STACK_NAME="mcpctl"
|
||||||
|
ENDPOINT_ID="2"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
log_info() { echo -e "${GREEN}[INFO]${NC} $1" >&2; }
|
||||||
|
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1" >&2; }
|
||||||
|
log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
|
||||||
|
|
||||||
|
check_files() {
|
||||||
|
if [[ ! -f "$COMPOSE_FILE" ]]; then
|
||||||
|
log_error "Compose file not found: $COMPOSE_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -f "$ENV_FILE" ]]; then
|
||||||
|
log_error "Environment file not found: $ENV_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log_info "Found compose file: $COMPOSE_FILE"
|
||||||
|
log_info "Found env file: $ENV_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
get_password() {
|
||||||
|
if [[ -n "$PORTAINER_PASSWORD" ]]; then
|
||||||
|
echo "$PORTAINER_PASSWORD"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [[ -f "$SCRIPT_DIR/.portainer_password" ]]; then
|
||||||
|
cat "$SCRIPT_DIR/.portainer_password"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [[ -f "$HOME/.portainer_password" ]]; then
|
||||||
|
cat "$HOME/.portainer_password"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
read -s -p "Enter Portainer password for $PORTAINER_USER: " password
|
||||||
|
echo >&2
|
||||||
|
echo "$password"
|
||||||
|
}
|
||||||
|
|
||||||
|
get_jwt_token() {
|
||||||
|
local password="$1"
|
||||||
|
log_info "Authenticating to Portainer..."
|
||||||
|
|
||||||
|
local escaped_password
|
||||||
|
escaped_password=$(printf '%s' "$password" | jq -Rs .)
|
||||||
|
|
||||||
|
local response
|
||||||
|
response=$(curl -s -X POST "$PORTAINER_URL/api/auth" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"Username\":\"$PORTAINER_USER\",\"Password\":$escaped_password}")
|
||||||
|
|
||||||
|
local token
|
||||||
|
token=$(echo "$response" | jq -r '.jwt // empty')
|
||||||
|
|
||||||
|
if [[ -z "$token" ]]; then
|
||||||
|
log_error "Authentication failed: $(echo "$response" | jq -r '.message // "Unknown error"')"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "$token"
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_env_to_json() {
|
||||||
|
local env_file="$1"
|
||||||
|
local json_array="["
|
||||||
|
local first=true
|
||||||
|
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
[[ "$line" =~ ^#.*$ ]] && continue
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
|
||||||
|
local name="${line%%=*}"
|
||||||
|
local value="${line#*=}"
|
||||||
|
[[ "$name" == "$line" ]] && continue
|
||||||
|
|
||||||
|
if [[ "$first" == "true" ]]; then
|
||||||
|
first=false
|
||||||
|
else
|
||||||
|
json_array+=","
|
||||||
|
fi
|
||||||
|
|
||||||
|
value=$(echo "$value" | sed 's/\\/\\\\/g; s/"/\\"/g')
|
||||||
|
json_array+="{\"name\":\"$name\",\"value\":\"$value\"}"
|
||||||
|
done < "$env_file"
|
||||||
|
|
||||||
|
json_array+="]"
|
||||||
|
echo "$json_array"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find existing stack by name
|
||||||
|
find_stack_id() {
|
||||||
|
local token="$1"
|
||||||
|
local response
|
||||||
|
response=$(curl -s -X GET "$PORTAINER_URL/api/stacks" \
|
||||||
|
-H "Authorization: Bearer $token")
|
||||||
|
|
||||||
|
echo "$response" | jq -r --arg name "$STACK_NAME" \
|
||||||
|
'.[] | select(.Name == $name) | .Id // empty'
|
||||||
|
}
|
||||||
|
|
||||||
|
get_stack_info() {
|
||||||
|
local token="$1"
|
||||||
|
local stack_id="$2"
|
||||||
|
curl -s -X GET "$PORTAINER_URL/api/stacks/$stack_id" \
|
||||||
|
-H "Authorization: Bearer $token" \
|
||||||
|
-H "Content-Type: application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
get_stack_file() {
|
||||||
|
local token="$1"
|
||||||
|
local stack_id="$2"
|
||||||
|
local response
|
||||||
|
response=$(curl -s -X GET "$PORTAINER_URL/api/stacks/$stack_id/file" \
|
||||||
|
-H "Authorization: Bearer $token" \
|
||||||
|
-H "Content-Type: application/json")
|
||||||
|
|
||||||
|
if echo "$response" | jq -e '.StackFileContent' > /dev/null 2>&1; then
|
||||||
|
echo "$response" | jq -r '.StackFileContent'
|
||||||
|
else
|
||||||
|
echo "# Could not retrieve current compose file"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
show_diff() {
|
||||||
|
local token="$1"
|
||||||
|
local stack_id="$2"
|
||||||
|
local env_json="$3"
|
||||||
|
|
||||||
|
log_info "Fetching current state from Portainer..."
|
||||||
|
|
||||||
|
local current_compose
|
||||||
|
current_compose=$(get_stack_file "$token" "$stack_id")
|
||||||
|
|
||||||
|
local current_env
|
||||||
|
local stack_info
|
||||||
|
stack_info=$(get_stack_info "$token" "$stack_id")
|
||||||
|
current_env=$(echo "$stack_info" | jq -r 'if .Env then .Env[] | "\(.name)=\(.value)" else empty end' 2>/dev/null | sort)
|
||||||
|
|
||||||
|
local new_env
|
||||||
|
new_env=$(echo "$env_json" | jq -r '.[] | "\(.name)=\(.value)"' | sort)
|
||||||
|
|
||||||
|
local tmp_dir
|
||||||
|
tmp_dir=$(mktemp -d)
|
||||||
|
|
||||||
|
echo "$current_compose" > "$tmp_dir/current_compose.yml"
|
||||||
|
cat "$COMPOSE_FILE" > "$tmp_dir/new_compose.yml"
|
||||||
|
echo "$current_env" > "$tmp_dir/current_env.txt"
|
||||||
|
echo "$new_env" > "$tmp_dir/new_env.txt"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== ENVIRONMENT VARIABLES DIFF ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if diff -u "$tmp_dir/current_env.txt" "$tmp_dir/new_env.txt" > "$tmp_dir/env_diff.txt" 2>&1; then
|
||||||
|
echo -e "${GREEN}No changes in environment variables${NC}"
|
||||||
|
else
|
||||||
|
while IFS= read -r line; do
|
||||||
|
if [[ "$line" == ---* ]] || [[ "$line" == +++* ]] || [[ "$line" == @@* ]]; then
|
||||||
|
echo -e "${YELLOW}$line${NC}"
|
||||||
|
elif [[ "$line" == -* ]]; then
|
||||||
|
echo -e "${RED}$line${NC}"
|
||||||
|
elif [[ "$line" == +* ]]; then
|
||||||
|
echo -e "${GREEN}$line${NC}"
|
||||||
|
else
|
||||||
|
echo "$line"
|
||||||
|
fi
|
||||||
|
done < "$tmp_dir/env_diff.txt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== COMPOSE FILE DIFF ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if diff -u "$tmp_dir/current_compose.yml" "$tmp_dir/new_compose.yml" > "$tmp_dir/compose_diff.txt" 2>&1; then
|
||||||
|
echo -e "${GREEN}No changes in compose file${NC}"
|
||||||
|
else
|
||||||
|
while IFS= read -r line; do
|
||||||
|
if [[ "$line" == ---* ]] || [[ "$line" == +++* ]] || [[ "$line" == @@* ]]; then
|
||||||
|
echo -e "${YELLOW}$line${NC}"
|
||||||
|
elif [[ "$line" == -* ]]; then
|
||||||
|
echo -e "${RED}$line${NC}"
|
||||||
|
elif [[ "$line" == +* ]]; then
|
||||||
|
echo -e "${GREEN}$line${NC}"
|
||||||
|
else
|
||||||
|
echo "$line"
|
||||||
|
fi
|
||||||
|
done < "$tmp_dir/compose_diff.txt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$tmp_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
create_stack() {
|
||||||
|
local token="$1"
|
||||||
|
local env_json="$2"
|
||||||
|
|
||||||
|
local compose_content
|
||||||
|
compose_content=$(cat "$COMPOSE_FILE")
|
||||||
|
|
||||||
|
local compose_escaped
|
||||||
|
compose_escaped=$(echo "$compose_content" | jq -Rs .)
|
||||||
|
|
||||||
|
log_info "Creating new stack '$STACK_NAME'..."
|
||||||
|
|
||||||
|
local payload
|
||||||
|
payload=$(jq -n \
|
||||||
|
--arg name "$STACK_NAME" \
|
||||||
|
--argjson env "$env_json" \
|
||||||
|
--argjson stackFileContent "$compose_escaped" \
|
||||||
|
'{
|
||||||
|
"name": $name,
|
||||||
|
"env": $env,
|
||||||
|
"stackFileContent": $stackFileContent
|
||||||
|
}')
|
||||||
|
|
||||||
|
local response
|
||||||
|
response=$(curl -s -X POST "$PORTAINER_URL/api/stacks?type=2&method=string&endpointId=$ENDPOINT_ID" \
|
||||||
|
-H "Authorization: Bearer $token" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$payload")
|
||||||
|
|
||||||
|
local error_msg
|
||||||
|
error_msg=$(echo "$response" | jq -r '.message // empty')
|
||||||
|
|
||||||
|
if [[ -n "$error_msg" ]]; then
|
||||||
|
log_error "Stack creation failed: $error_msg"
|
||||||
|
echo "$response" | jq .
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local new_id
|
||||||
|
new_id=$(echo "$response" | jq -r '.Id')
|
||||||
|
log_info "Stack created successfully! (ID: $new_id)"
|
||||||
|
echo "$response" | jq '{Id, Name, Status, CreationDate}'
|
||||||
|
}
|
||||||
|
|
||||||
|
update_stack() {
|
||||||
|
local token="$1"
|
||||||
|
local stack_id="$2"
|
||||||
|
local dry_run="$3"
|
||||||
|
|
||||||
|
local compose_content
|
||||||
|
compose_content=$(cat "$COMPOSE_FILE")
|
||||||
|
|
||||||
|
local env_json
|
||||||
|
env_json=$(parse_env_to_json "$ENV_FILE")
|
||||||
|
|
||||||
|
if [[ "$dry_run" == "true" ]]; then
|
||||||
|
log_warn "DRY RUN - Not actually deploying"
|
||||||
|
show_diff "$token" "$stack_id" "$env_json"
|
||||||
|
echo ""
|
||||||
|
log_warn "DRY RUN complete - no changes made"
|
||||||
|
log_info "Run without --dry-run to apply these changes"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local env_count
|
||||||
|
env_count=$(echo "$env_json" | jq 'length')
|
||||||
|
log_info "Deploying $env_count environment variables"
|
||||||
|
log_info "Updating stack '$STACK_NAME' (ID: $stack_id)..."
|
||||||
|
|
||||||
|
local compose_escaped
|
||||||
|
compose_escaped=$(echo "$compose_content" | jq -Rs .)
|
||||||
|
|
||||||
|
local payload
|
||||||
|
payload=$(jq -n \
|
||||||
|
--argjson env "$env_json" \
|
||||||
|
--argjson stackFileContent "$compose_escaped" \
|
||||||
|
'{
|
||||||
|
"env": $env,
|
||||||
|
"stackFileContent": $stackFileContent,
|
||||||
|
"prune": true,
|
||||||
|
"pullImage": true
|
||||||
|
}')
|
||||||
|
|
||||||
|
local response
|
||||||
|
response=$(curl -s -X PUT "$PORTAINER_URL/api/stacks/$stack_id?endpointId=$ENDPOINT_ID" \
|
||||||
|
-H "Authorization: Bearer $token" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$payload")
|
||||||
|
|
||||||
|
local error_msg
|
||||||
|
error_msg=$(echo "$response" | jq -r '.message // empty')
|
||||||
|
|
||||||
|
if [[ -n "$error_msg" ]]; then
|
||||||
|
log_error "Deployment failed: $error_msg"
|
||||||
|
echo "$response" | jq .
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Stack updated successfully!"
|
||||||
|
echo "$response" | jq '{Id, Name, Status, CreationDate, UpdateDate}'
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local dry_run=false
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--dry-run)
|
||||||
|
dry_run=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: $0 [--dry-run]"
|
||||||
|
echo ""
|
||||||
|
echo "Deploy mcpctl stack to Portainer"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " --dry-run Show what would be deployed without actually deploying"
|
||||||
|
echo " --help Show this help message"
|
||||||
|
echo ""
|
||||||
|
echo "Environment variables:"
|
||||||
|
echo " PORTAINER_URL Portainer URL (default: http://10.0.0.194:9000)"
|
||||||
|
echo " PORTAINER_USER Portainer username (default: michal)"
|
||||||
|
echo " PORTAINER_PASSWORD Portainer password (or store in ~/.portainer_password)"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_error "Unknown option: $1"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo " mcpctl Stack Deployment"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
check_files
|
||||||
|
|
||||||
|
local password
|
||||||
|
password=$(get_password)
|
||||||
|
|
||||||
|
local token
|
||||||
|
token=$(get_jwt_token "$password")
|
||||||
|
log_info "Authentication successful"
|
||||||
|
|
||||||
|
# Find or create stack
|
||||||
|
local stack_id
|
||||||
|
stack_id=$(find_stack_id "$token")
|
||||||
|
|
||||||
|
if [[ -z "$stack_id" ]]; then
|
||||||
|
if [[ "$dry_run" == "true" ]]; then
|
||||||
|
log_warn "Stack '$STACK_NAME' does not exist yet"
|
||||||
|
log_info "A real deploy would create it"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Stack '$STACK_NAME' not found, creating..."
|
||||||
|
local env_json
|
||||||
|
env_json=$(parse_env_to_json "$ENV_FILE")
|
||||||
|
create_stack "$token" "$env_json"
|
||||||
|
else
|
||||||
|
local stack_info
|
||||||
|
stack_info=$(get_stack_info "$token" "$stack_id")
|
||||||
|
local status_code
|
||||||
|
status_code=$(echo "$stack_info" | jq -r '.Status // 0')
|
||||||
|
local status_text="Unknown"
|
||||||
|
case "$status_code" in
|
||||||
|
1) status_text="Active" ;;
|
||||||
|
2) status_text="Inactive" ;;
|
||||||
|
esac
|
||||||
|
log_info "Current stack status: $status_text (ID: $stack_id, Env vars: $(echo "$stack_info" | jq '.Env | length'))"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
update_stack "$token" "$stack_id" "$dry_run"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
log_info "Done!"
|
||||||
|
|
||||||
|
if [[ "$dry_run" == "false" ]]; then
|
||||||
|
log_info "Check Portainer UI to verify containers are running"
|
||||||
|
log_info "URL: $PORTAINER_URL/#!/$ENDPOINT_ID/docker/stacks/$STACK_NAME"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -49,6 +49,9 @@ COPY --from=builder /app/src/shared/dist/ src/shared/dist/
|
|||||||
COPY --from=builder /app/src/db/dist/ src/db/dist/
|
COPY --from=builder /app/src/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
|
||||||
|
|||||||
13
deploy/Dockerfile.node-runner
Normal file
13
deploy/Dockerfile.node-runner
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Base container for npm-based MCP servers (STDIO transport).
|
||||||
|
# mcpd uses this image to run `npx -y <packageName>` when a server
|
||||||
|
# has packageName but no dockerImage.
|
||||||
|
# Using slim (Debian) instead of alpine for better npm package compatibility.
|
||||||
|
FROM node:20-slim
|
||||||
|
|
||||||
|
WORKDIR /mcp
|
||||||
|
|
||||||
|
# Pre-warm npx cache directory
|
||||||
|
RUN mkdir -p /root/.npm
|
||||||
|
|
||||||
|
# Default entrypoint — overridden by mcpd via container command
|
||||||
|
ENTRYPOINT ["npx", "-y"]
|
||||||
@@ -30,6 +30,8 @@ services:
|
|||||||
MCPD_PORT: "3100"
|
MCPD_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:
|
||||||
|
|||||||
@@ -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
15
deploy/mcplocal.service
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=mcpctl local MCP proxy
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/mcpctl-local
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
Environment=MCPLOCAL_MCPD_URL=http://10.0.0.194:3100
|
||||||
|
Environment=MCPLOCAL_HTTP_PORT=3200
|
||||||
|
Environment=MCPLOCAL_HTTP_HOST=127.0.0.1
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
26
installlocal.sh
Executable file
26
installlocal.sh
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build (if needed) and install mcpctl RPM locally
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
RPM_FILE=$(ls dist/mcpctl-*.rpm 2>/dev/null | head -1)
|
||||||
|
|
||||||
|
# Build if no RPM exists or if source is newer than the RPM
|
||||||
|
if [[ -z "$RPM_FILE" ]] || [[ $(find src/ -name '*.ts' -newer "$RPM_FILE" 2>/dev/null | head -1) ]]; then
|
||||||
|
echo "==> Building RPM..."
|
||||||
|
bash scripts/build-rpm.sh
|
||||||
|
RPM_FILE=$(ls dist/mcpctl-*.rpm 2>/dev/null | head -1)
|
||||||
|
else
|
||||||
|
echo "==> RPM is up to date: $RPM_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Installing $RPM_FILE..."
|
||||||
|
sudo rpm -Uvh --force "$RPM_FILE"
|
||||||
|
|
||||||
|
echo "==> Reloading systemd user units..."
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
|
||||||
|
echo "==> Done!"
|
||||||
|
echo " Enable mcplocal: systemctl --user enable --now mcplocal"
|
||||||
@@ -10,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:
|
||||||
|
|||||||
@@ -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
6
pnpm-lock.yaml
generated
@@ -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
32
scripts/build-mcpd.sh
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build mcpd Docker image and push to Gitea container registry
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
# Load .env for GITEA_TOKEN
|
||||||
|
if [ -f .env ]; then
|
||||||
|
set -a; source .env; set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Push directly to internal address (external proxy has body size limit)
|
||||||
|
REGISTRY="10.0.0.194:3012"
|
||||||
|
IMAGE="mcpd"
|
||||||
|
TAG="${1:-latest}"
|
||||||
|
|
||||||
|
echo "==> Building mcpd image..."
|
||||||
|
podman build -t "$IMAGE:$TAG" -f deploy/Dockerfile.mcpd .
|
||||||
|
|
||||||
|
echo "==> Tagging as $REGISTRY/michal/$IMAGE:$TAG..."
|
||||||
|
podman tag "$IMAGE:$TAG" "$REGISTRY/michal/$IMAGE:$TAG"
|
||||||
|
|
||||||
|
echo "==> Logging in to $REGISTRY..."
|
||||||
|
podman login --tls-verify=false -u michal -p "$GITEA_TOKEN" "$REGISTRY"
|
||||||
|
|
||||||
|
echo "==> Pushing to $REGISTRY/michal/$IMAGE:$TAG..."
|
||||||
|
podman push --tls-verify=false "$REGISTRY/michal/$IMAGE:$TAG"
|
||||||
|
|
||||||
|
echo "==> Done!"
|
||||||
|
echo " Image: $REGISTRY/michal/$IMAGE:$TAG"
|
||||||
@@ -16,10 +16,11 @@ export PATH="$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH"
|
|||||||
echo "==> Building TypeScript..."
|
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/
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ 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({
|
const ServerEnvEntrySchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
value: z.string().optional(),
|
value: z.string().optional(),
|
||||||
@@ -24,6 +32,7 @@ const ServerSpecSchema = z.object({
|
|||||||
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),
|
||||||
env: z.array(ServerEnvEntrySchema).default([]),
|
env: z.array(ServerEnvEntrySchema).default([]),
|
||||||
|
healthCheck: HealthCheckSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const SecretSpecSchema = z.object({
|
const SecretSpecSchema = z.object({
|
||||||
@@ -31,6 +40,29 @@ const SecretSpecSchema = z.object({
|
|||||||
data: z.record(z.string()).default({}),
|
data: z.record(z.string()).default({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const TemplateEnvEntrySchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
description: z.string().optional(),
|
||||||
|
required: z.boolean().optional(),
|
||||||
|
defaultValue: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const TemplateSpecSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
version: z.string().default('1.0.0'),
|
||||||
|
description: z.string().default(''),
|
||||||
|
packageName: z.string().optional(),
|
||||||
|
dockerImage: z.string().optional(),
|
||||||
|
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
|
||||||
|
repositoryUrl: z.string().optional(),
|
||||||
|
externalUrl: z.string().optional(),
|
||||||
|
command: z.array(z.string()).optional(),
|
||||||
|
containerPort: z.number().int().min(1).max(65535).optional(),
|
||||||
|
replicas: z.number().int().min(0).max(10).default(1),
|
||||||
|
env: z.array(TemplateEnvEntrySchema).default([]),
|
||||||
|
healthCheck: HealthCheckSchema.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
const ProjectSpecSchema = z.object({
|
const ProjectSpecSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
description: z.string().default(''),
|
description: z.string().default(''),
|
||||||
@@ -40,6 +72,7 @@ const ApplyConfigSchema = z.object({
|
|||||||
servers: z.array(ServerSpecSchema).default([]),
|
servers: z.array(ServerSpecSchema).default([]),
|
||||||
secrets: z.array(SecretSpecSchema).default([]),
|
secrets: z.array(SecretSpecSchema).default([]),
|
||||||
projects: z.array(ProjectSpecSchema).default([]),
|
projects: z.array(ProjectSpecSchema).default([]),
|
||||||
|
templates: z.array(TemplateSpecSchema).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ApplyConfig = z.infer<typeof ApplyConfigSchema>;
|
export type ApplyConfig = z.infer<typeof ApplyConfigSchema>;
|
||||||
@@ -64,6 +97,7 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command {
|
|||||||
if (config.servers.length > 0) log(` ${config.servers.length} server(s)`);
|
if (config.servers.length > 0) log(` ${config.servers.length} server(s)`);
|
||||||
if (config.secrets.length > 0) log(` ${config.secrets.length} secret(s)`);
|
if (config.secrets.length > 0) log(` ${config.secrets.length} secret(s)`);
|
||||||
if (config.projects.length > 0) log(` ${config.projects.length} project(s)`);
|
if (config.projects.length > 0) log(` ${config.projects.length} project(s)`);
|
||||||
|
if (config.templates.length > 0) log(` ${config.templates.length} template(s)`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +171,22 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
|
|||||||
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findByName(client: ApiClient, resource: string, name: string): Promise<unknown | null> {
|
async function findByName(client: ApiClient, resource: string, name: string): Promise<unknown | null> {
|
||||||
|
|||||||
@@ -1,5 +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';
|
||||||
export interface CreateCommandDeps {
|
export interface CreateCommandDeps {
|
||||||
client: ApiClient;
|
client: ApiClient;
|
||||||
log: (...args: unknown[]) => void;
|
log: (...args: unknown[]) => void;
|
||||||
@@ -61,33 +61,107 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
|||||||
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 <entry>', 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, 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.env.length > 0) body.env = parseServerEnv(opts.env);
|
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 secret ---
|
// --- create secret ---
|
||||||
@@ -95,13 +169,25 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
|||||||
.description('Create a secret')
|
.description('Create a secret')
|
||||||
.argument('<name>', 'Secret name (lowercase, hyphens allowed)')
|
.argument('<name>', 'Secret name (lowercase, hyphens allowed)')
|
||||||
.option('--data <entry>', 'Secret data KEY=value (repeat for multiple)', collect, [])
|
.option('--data <entry>', 'Secret data KEY=value (repeat for multiple)', collect, [])
|
||||||
|
.option('--force', 'Update if already exists')
|
||||||
.action(async (name: string, opts) => {
|
.action(async (name: string, opts) => {
|
||||||
const data = parseEnvEntries(opts.data);
|
const data = parseEnvEntries(opts.data);
|
||||||
|
try {
|
||||||
const secret = await client.post<{ id: string; name: string }>('/api/v1/secrets', {
|
const secret = await client.post<{ id: string; name: string }>('/api/v1/secrets', {
|
||||||
name,
|
name,
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
log(`secret '${secret.name}' created (id: ${secret.id})`);
|
log(`secret '${secret.name}' created (id: ${secret.id})`);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 409 && opts.force) {
|
||||||
|
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/secrets')).find((s) => s.name === name);
|
||||||
|
if (!existing) throw err;
|
||||||
|
await client.put(`/api/v1/secrets/${existing.id}`, { data });
|
||||||
|
log(`secret '${name}' updated (id: ${existing.id})`);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- create project ---
|
// --- create project ---
|
||||||
@@ -109,12 +195,24 @@ 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('--force', 'Update if already exists')
|
||||||
.action(async (name: string, opts) => {
|
.action(async (name: string, opts) => {
|
||||||
|
try {
|
||||||
const project = await client.post<{ id: string; name: string }>('/api/v1/projects', {
|
const project = await client.post<{ id: string; name: string }>('/api/v1/projects', {
|
||||||
name,
|
name,
|
||||||
description: opts.description,
|
description: opts.description,
|
||||||
});
|
});
|
||||||
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;
|
||||||
|
await client.put(`/api/v1/projects/${existing.id}`, { description: opts.description });
|
||||||
|
log(`project '${name}' updated (id: ${existing.id})`);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return cmd;
|
return cmd;
|
||||||
|
|||||||
@@ -50,6 +50,19 @@ function formatServerDetail(server: Record<string, unknown>): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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:');
|
||||||
lines.push(` ${pad('ID:', 12)}${server.id}`);
|
lines.push(` ${pad('ID:', 12)}${server.id}`);
|
||||||
@@ -61,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('');
|
||||||
@@ -88,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}`);
|
||||||
@@ -143,6 +180,66 @@ function formatSecretDetail(secret: Record<string, unknown>, showValues: boolean
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTemplateDetail(template: Record<string, unknown>): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`=== Template: ${template.name} ===`);
|
||||||
|
lines.push(`${pad('Name:')}${template.name}`);
|
||||||
|
lines.push(`${pad('Version:')}${template.version ?? '1.0.0'}`);
|
||||||
|
lines.push(`${pad('Transport:')}${template.transport ?? 'STDIO'}`);
|
||||||
|
lines.push(`${pad('Replicas:')}${template.replicas ?? 1}`);
|
||||||
|
if (template.dockerImage) lines.push(`${pad('Docker Image:')}${template.dockerImage}`);
|
||||||
|
if (template.packageName) lines.push(`${pad('Package:')}${template.packageName}`);
|
||||||
|
if (template.externalUrl) lines.push(`${pad('External URL:')}${template.externalUrl}`);
|
||||||
|
if (template.repositoryUrl) lines.push(`${pad('Repository:')}${template.repositoryUrl}`);
|
||||||
|
if (template.containerPort) lines.push(`${pad('Container Port:')}${template.containerPort}`);
|
||||||
|
if (template.description) lines.push(`${pad('Description:')}${template.description}`);
|
||||||
|
|
||||||
|
const command = template.command as string[] | null;
|
||||||
|
if (command && command.length > 0) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Command:');
|
||||||
|
lines.push(` ${command.join(' ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = template.env as Array<{ name: string; description?: string; required?: boolean; defaultValue?: string }> | undefined;
|
||||||
|
if (env && env.length > 0) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Environment Variables:');
|
||||||
|
const nameW = Math.max(6, ...env.map((e) => e.name.length)) + 2;
|
||||||
|
lines.push(` ${'NAME'.padEnd(nameW)}${'REQUIRED'.padEnd(10)}DESCRIPTION`);
|
||||||
|
for (const e of env) {
|
||||||
|
const req = e.required ? 'yes' : 'no';
|
||||||
|
const desc = e.description ?? '';
|
||||||
|
lines.push(` ${e.name.padEnd(nameW)}${req.padEnd(10)}${desc}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hc = template.healthCheck as { tool: string; arguments?: Record<string, unknown>; intervalSeconds?: number; timeoutSeconds?: number; failureThreshold?: number } | null;
|
||||||
|
if (hc) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Health Check:');
|
||||||
|
lines.push(` ${pad('Tool:', 22)}${hc.tool}`);
|
||||||
|
if (hc.arguments && Object.keys(hc.arguments).length > 0) {
|
||||||
|
lines.push(` ${pad('Arguments:', 22)}${JSON.stringify(hc.arguments)}`);
|
||||||
|
}
|
||||||
|
lines.push(` ${pad('Interval:', 22)}${hc.intervalSeconds ?? 60}s`);
|
||||||
|
lines.push(` ${pad('Timeout:', 22)}${hc.timeoutSeconds ?? 10}s`);
|
||||||
|
lines.push(` ${pad('Failure Threshold:', 22)}${hc.failureThreshold ?? 3}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Usage:');
|
||||||
|
lines.push(` mcpctl create server my-${template.name} --from-template=${template.name}`);
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Metadata:');
|
||||||
|
lines.push(` ${pad('ID:', 12)}${template.id}`);
|
||||||
|
if (template.createdAt) lines.push(` ${pad('Created:', 12)}${template.createdAt}`);
|
||||||
|
if (template.updatedAt) lines.push(` ${pad('Updated:', 12)}${template.updatedAt}`);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
function formatGenericDetail(obj: Record<string, unknown>): string {
|
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)) {
|
||||||
@@ -181,11 +278,33 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
|
|||||||
|
|
||||||
// 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>;
|
||||||
|
|
||||||
@@ -216,6 +335,9 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
|
|||||||
case 'secrets':
|
case 'secrets':
|
||||||
deps.log(formatSecretDetail(item, opts.showValues === true));
|
deps.log(formatSecretDetail(item, opts.showValues === true));
|
||||||
break;
|
break;
|
||||||
|
case 'templates':
|
||||||
|
deps.log(formatTemplateDetail(item));
|
||||||
|
break;
|
||||||
case 'projects':
|
case 'projects':
|
||||||
deps.log(formatProjectDetail(item));
|
deps.log(formatProjectDetail(item));
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -30,12 +30,23 @@ interface SecretRow {
|
|||||||
data: Record<string, 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>[] = [
|
||||||
@@ -59,9 +70,18 @@ const secretColumns: Column<SecretRow>[] = [
|
|||||||
{ header: 'ID', key: 'id' },
|
{ 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' },
|
||||||
@@ -75,6 +95,8 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
|
|||||||
return projectColumns as unknown as Column<Record<string, unknown>>[];
|
return projectColumns as unknown as Column<Record<string, unknown>>[];
|
||||||
case 'secrets':
|
case 'secrets':
|
||||||
return secretColumns as unknown as Column<Record<string, unknown>>[];
|
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>>[];
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export interface ProjectCommandDeps {
|
|||||||
log: (...args: unknown[]) => void;
|
log: (...args: unknown[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createProjectCommand(deps: ProjectCommandDeps): Command {
|
export function createProjectCommand(_deps: ProjectCommandDeps): Command {
|
||||||
const cmd = new Command('project')
|
const cmd = new Command('project')
|
||||||
.alias('proj')
|
.alias('proj')
|
||||||
.description('Project-specific actions (create with "create project", list with "get projects")');
|
.description('Project-specific actions (create with "create project", list with "get projects")');
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export const RESOURCE_ALIASES: Record<string, string> = {
|
|||||||
inst: 'instances',
|
inst: 'instances',
|
||||||
secret: 'secrets',
|
secret: 'secrets',
|
||||||
sec: 'secrets',
|
sec: 'secrets',
|
||||||
|
template: 'templates',
|
||||||
|
tpl: 'templates',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resolveResource(name: string): string {
|
export function resolveResource(name: string): string {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { createClaudeCommand } from './commands/claude.js';
|
|||||||
import { createProjectCommand } from './commands/project.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';
|
||||||
@@ -50,6 +50,10 @@ export function createProgram(): Command {
|
|||||||
|
|
||||||
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);
|
||||||
@@ -139,5 +143,21 @@ 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) {
|
||||||
|
let msg: string;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(err.body) as { error?: string; message?: string };
|
||||||
|
msg = parsed.error ?? parsed.message ?? err.body;
|
||||||
|
} catch {
|
||||||
|
msg = err.body;
|
||||||
|
}
|
||||||
|
console.error(`Error: ${msg}`);
|
||||||
|
} else if (err instanceof Error) {
|
||||||
|
console.error(`Error: ${err.message}`);
|
||||||
|
} else {
|
||||||
|
console.error(`Error: ${String(err)}`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -73,6 +73,59 @@ describe('create command', () => {
|
|||||||
transport: 'STDIO',
|
transport: 'STDIO',
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('strips null values from template when using --from-template', async () => {
|
||||||
|
vi.mocked(client.get).mockResolvedValueOnce([{
|
||||||
|
id: 'tpl-1',
|
||||||
|
name: 'grafana',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Grafana MCP',
|
||||||
|
packageName: '@leval/mcp-grafana',
|
||||||
|
dockerImage: null,
|
||||||
|
transport: 'STDIO',
|
||||||
|
repositoryUrl: 'https://github.com/test',
|
||||||
|
externalUrl: null,
|
||||||
|
command: null,
|
||||||
|
containerPort: null,
|
||||||
|
replicas: 1,
|
||||||
|
env: [{ name: 'TOKEN', required: true, description: 'A token' }],
|
||||||
|
healthCheck: { tool: 'test', arguments: {} },
|
||||||
|
createdAt: '2025-01-01',
|
||||||
|
updatedAt: '2025-01-01',
|
||||||
|
}] as never);
|
||||||
|
const cmd = createCreateCommand({ client, log });
|
||||||
|
await cmd.parseAsync([
|
||||||
|
'server', 'my-grafana', '--from-template=grafana',
|
||||||
|
'--env', 'TOKEN=secretRef:creds:TOKEN',
|
||||||
|
], { from: 'user' });
|
||||||
|
const call = vi.mocked(client.post).mock.calls[0]![1] as Record<string, unknown>;
|
||||||
|
// null fields from template should NOT be in the body
|
||||||
|
expect(call).not.toHaveProperty('dockerImage');
|
||||||
|
expect(call).not.toHaveProperty('externalUrl');
|
||||||
|
expect(call).not.toHaveProperty('command');
|
||||||
|
expect(call).not.toHaveProperty('containerPort');
|
||||||
|
// non-null fields should be present
|
||||||
|
expect(call.packageName).toBe('@leval/mcp-grafana');
|
||||||
|
expect(call.healthCheck).toEqual({ tool: 'test', arguments: {} });
|
||||||
|
expect(call.templateName).toBe('grafana');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on 409 without --force', async () => {
|
||||||
|
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Server already exists: my-server"}'));
|
||||||
|
const cmd = createCreateCommand({ client, log });
|
||||||
|
await expect(cmd.parseAsync(['server', 'my-server'], { from: 'user' })).rejects.toThrow('API error 409');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates existing server on 409 with --force', async () => {
|
||||||
|
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Server already exists"}'));
|
||||||
|
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'srv-1', name: 'my-server' }] as never);
|
||||||
|
const cmd = createCreateCommand({ client, log });
|
||||||
|
await cmd.parseAsync(['server', 'my-server', '--force'], { from: 'user' });
|
||||||
|
expect(client.put).toHaveBeenCalledWith('/api/v1/servers/srv-1', expect.objectContaining({
|
||||||
|
transport: 'STDIO',
|
||||||
|
}));
|
||||||
|
expect(output.join('\n')).toContain("server 'my-server' updated");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('create secret', () => {
|
describe('create secret', () => {
|
||||||
@@ -98,6 +151,21 @@ describe('create command', () => {
|
|||||||
data: {},
|
data: {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('throws on 409 without --force', async () => {
|
||||||
|
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Secret already exists: my-creds"}'));
|
||||||
|
const cmd = createCreateCommand({ client, log });
|
||||||
|
await expect(cmd.parseAsync(['secret', 'my-creds', '--data', 'KEY=val'], { from: 'user' })).rejects.toThrow('API error 409');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates existing secret on 409 with --force', async () => {
|
||||||
|
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Secret already exists"}'));
|
||||||
|
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'sec-1', name: 'my-creds' }] as never);
|
||||||
|
const cmd = createCreateCommand({ client, log });
|
||||||
|
await cmd.parseAsync(['secret', 'my-creds', '--data', 'KEY=val', '--force'], { from: 'user' });
|
||||||
|
expect(client.put).toHaveBeenCalledWith('/api/v1/secrets/sec-1', { data: { KEY: 'val' } });
|
||||||
|
expect(output.join('\n')).toContain("secret 'my-creds' updated");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('create project', () => {
|
describe('create project', () => {
|
||||||
@@ -119,5 +187,14 @@ describe('create command', () => {
|
|||||||
description: '',
|
description: '',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updates existing project on 409 with --force', async () => {
|
||||||
|
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Project already exists"}'));
|
||||||
|
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'proj-1', name: 'my-proj' }] as never);
|
||||||
|
const cmd = createCreateCommand({ client, log });
|
||||||
|
await cmd.parseAsync(['project', 'my-proj', '-d', 'updated', '--force'], { from: 'user' });
|
||||||
|
expect(client.put).toHaveBeenCalledWith('/api/v1/projects/proj-1', { description: 'updated' });
|
||||||
|
expect(output.join('\n')).toContain("project 'my-proj' updated");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -139,4 +139,152 @@ 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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,11 +69,13 @@ describe('get command', () => {
|
|||||||
|
|
||||||
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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -68,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');
|
||||||
|
|||||||
@@ -62,10 +62,14 @@ model McpServer {
|
|||||||
containerPort Int?
|
containerPort Int?
|
||||||
replicas Int @default(1)
|
replicas Int @default(1)
|
||||||
env 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
|
||||||
|
|
||||||
|
templateName String?
|
||||||
|
templateVersion String?
|
||||||
|
|
||||||
instances McpInstance[]
|
instances McpInstance[]
|
||||||
|
|
||||||
@@index([name])
|
@@index([name])
|
||||||
@@ -77,6 +81,29 @@ enum Transport {
|
|||||||
STREAMABLE_HTTP
|
STREAMABLE_HTTP
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── MCP Templates ──
|
||||||
|
|
||||||
|
model McpTemplate {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
version String @default("1.0.0")
|
||||||
|
description String @default("")
|
||||||
|
packageName String?
|
||||||
|
dockerImage String?
|
||||||
|
transport Transport @default(STDIO)
|
||||||
|
repositoryUrl String?
|
||||||
|
externalUrl String?
|
||||||
|
command Json?
|
||||||
|
containerPort Int?
|
||||||
|
replicas Int @default(1)
|
||||||
|
env Json @default("[]")
|
||||||
|
healthCheck Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([name])
|
||||||
|
}
|
||||||
|
|
||||||
// ── Secrets ──
|
// ── Secrets ──
|
||||||
|
|
||||||
model Secret {
|
model Secret {
|
||||||
@@ -116,6 +143,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
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type {
|
|||||||
User,
|
User,
|
||||||
Session,
|
Session,
|
||||||
McpServer,
|
McpServer,
|
||||||
|
McpTemplate,
|
||||||
Secret,
|
Secret,
|
||||||
Project,
|
Project,
|
||||||
McpInstance,
|
McpInstance,
|
||||||
@@ -13,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';
|
||||||
|
|||||||
@@ -1,94 +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;
|
|
||||||
env: Array<{
|
|
||||||
name: string;
|
|
||||||
value?: string;
|
|
||||||
valueFrom?: { secretRef: { name: string; key: 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',
|
}
|
||||||
env: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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',
|
|
||||||
env: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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',
|
|
||||||
env: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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',
|
|
||||||
env: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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,
|
||||||
env: server.env,
|
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,
|
||||||
env: server.env,
|
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));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,5 +53,6 @@ export async function clearAllTables(client: PrismaClient): Promise<void> {
|
|||||||
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.user.deleteMany();
|
await client.user.deleteMany();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,53 +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);
|
|
||||||
expect(count).toBe(defaultServers.length);
|
|
||||||
|
|
||||||
const servers = await prisma.mcpServer.findMany({ orderBy: { name: 'asc' } });
|
|
||||||
expect(servers).toHaveLength(defaultServers.length);
|
|
||||||
|
|
||||||
const names = servers.map((s) => s.name);
|
|
||||||
expect(names).toContain('slack');
|
|
||||||
expect(names).toContain('github');
|
|
||||||
expect(names).toContain('jira');
|
|
||||||
expect(names).toContain('terraform');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is idempotent (upsert)', async () => {
|
|
||||||
await seedMcpServers(prisma);
|
|
||||||
const count = await seedMcpServers(prisma);
|
|
||||||
expect(count).toBe(defaultServers.length);
|
|
||||||
|
|
||||||
const servers = await prisma.mcpServer.findMany();
|
|
||||||
expect(servers).toHaveLength(defaultServers.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('seeds env correctly', async () => {
|
|
||||||
await seedMcpServers(prisma);
|
|
||||||
const slack = await prisma.mcpServer.findUnique({ where: { name: 'slack' } });
|
|
||||||
const env = slack!.env as Array<{ name: string; value?: string }>;
|
|
||||||
expect(env).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts custom server list', async () => {
|
|
||||||
const custom = [
|
|
||||||
{
|
{
|
||||||
name: 'custom-server',
|
name: 'github',
|
||||||
description: 'Custom test server',
|
version: '1.0.0',
|
||||||
packageName: '@test/custom',
|
description: 'GitHub MCP server',
|
||||||
transport: 'STDIO' as const,
|
packageName: '@anthropic/github-mcp',
|
||||||
repositoryUrl: 'https://example.com',
|
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: [],
|
env: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const count = await seedMcpServers(prisma, custom);
|
|
||||||
|
describe('seedTemplates', () => {
|
||||||
|
it('seeds templates', async () => {
|
||||||
|
const count = await seedTemplates(prisma, testTemplates);
|
||||||
|
expect(count).toBe(2);
|
||||||
|
|
||||||
|
const templates = await prisma.mcpTemplate.findMany({ orderBy: { name: 'asc' } });
|
||||||
|
expect(templates).toHaveLength(2);
|
||||||
|
expect(templates.map((t) => t.name)).toEqual(['github', 'slack']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent (upsert)', async () => {
|
||||||
|
await seedTemplates(prisma, testTemplates);
|
||||||
|
const count = await seedTemplates(prisma, testTemplates);
|
||||||
|
expect(count).toBe(2);
|
||||||
|
|
||||||
|
const templates = await prisma.mcpTemplate.findMany();
|
||||||
|
expect(templates).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds env correctly', async () => {
|
||||||
|
await seedTemplates(prisma, testTemplates);
|
||||||
|
const github = await prisma.mcpTemplate.findUnique({ where: { name: 'github' } });
|
||||||
|
const env = github!.env as Array<{ name: string; description?: string; required?: boolean }>;
|
||||||
|
expect(env).toHaveLength(1);
|
||||||
|
expect(env[0].name).toBe('GITHUB_TOKEN');
|
||||||
|
expect(env[0].required).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts custom template list', async () => {
|
||||||
|
const custom: SeedTemplate[] = [
|
||||||
|
{
|
||||||
|
name: 'custom-template',
|
||||||
|
version: '2.0.0',
|
||||||
|
description: 'Custom test template',
|
||||||
|
packageName: '@test/custom',
|
||||||
|
transport: 'STDIO',
|
||||||
|
env: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
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';
|
||||||
@@ -9,6 +13,7 @@ import {
|
|||||||
McpInstanceRepository,
|
McpInstanceRepository,
|
||||||
ProjectRepository,
|
ProjectRepository,
|
||||||
AuditLogRepository,
|
AuditLogRepository,
|
||||||
|
TemplateRepository,
|
||||||
} from './repositories/index.js';
|
} from './repositories/index.js';
|
||||||
import {
|
import {
|
||||||
McpServerService,
|
McpServerService,
|
||||||
@@ -23,6 +28,8 @@ import {
|
|||||||
RestoreService,
|
RestoreService,
|
||||||
AuthService,
|
AuthService,
|
||||||
McpProxyService,
|
McpProxyService,
|
||||||
|
TemplateService,
|
||||||
|
HealthProbeRunner,
|
||||||
} from './services/index.js';
|
} from './services/index.js';
|
||||||
import {
|
import {
|
||||||
registerMcpServerRoutes,
|
registerMcpServerRoutes,
|
||||||
@@ -34,6 +41,7 @@ import {
|
|||||||
registerBackupRoutes,
|
registerBackupRoutes,
|
||||||
registerAuthRoutes,
|
registerAuthRoutes,
|
||||||
registerMcpProxyRoutes,
|
registerMcpProxyRoutes,
|
||||||
|
registerTemplateRoutes,
|
||||||
} from './routes/index.js';
|
} from './routes/index.js';
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
@@ -45,8 +53,27 @@ 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);
|
||||||
@@ -54,6 +81,7 @@ async function main(): Promise<void> {
|
|||||||
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);
|
||||||
|
|
||||||
// Orchestrator
|
// Orchestrator
|
||||||
const orchestrator = new DockerContainerManager();
|
const orchestrator = new DockerContainerManager();
|
||||||
@@ -63,13 +91,14 @@ async function main(): Promise<void> {
|
|||||||
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretRepo);
|
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretRepo);
|
||||||
serverService.setInstanceService(instanceService);
|
serverService.setInstanceService(instanceService);
|
||||||
const secretService = new SecretService(secretRepo);
|
const secretService = new SecretService(secretRepo);
|
||||||
const projectService = new ProjectService(projectRepo, serverRepo);
|
const projectService = new ProjectService(projectRepo);
|
||||||
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, projectRepo, secretRepo);
|
const backupService = new BackupService(serverRepo, projectRepo, secretRepo);
|
||||||
const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo);
|
const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo);
|
||||||
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);
|
||||||
|
|
||||||
// Server
|
// Server
|
||||||
@@ -88,6 +117,7 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
registerMcpServerRoutes(app, serverService, instanceService);
|
registerMcpServerRoutes(app, serverService, instanceService);
|
||||||
|
registerTemplateRoutes(app, templateService);
|
||||||
registerSecretRoutes(app, secretService);
|
registerSecretRoutes(app, secretService);
|
||||||
registerInstanceRoutes(app, instanceService);
|
registerInstanceRoutes(app, instanceService);
|
||||||
registerProjectRoutes(app, projectService);
|
registerProjectRoutes(app, projectService);
|
||||||
@@ -105,9 +135,32 @@ async function main(): Promise<void> {
|
|||||||
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();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ export type { IProjectRepository } 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';
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ 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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export class McpServerRepository implements IMcpServerRepository {
|
|||||||
containerPort: data.containerPort ?? null,
|
containerPort: data.containerPort ?? null,
|
||||||
replicas: data.replicas,
|
replicas: data.replicas,
|
||||||
env: data.env,
|
env: data.env,
|
||||||
|
healthCheck: (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -47,6 +48,7 @@ export class McpServerRepository implements IMcpServerRepository {
|
|||||||
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.env !== undefined) updateData['env'] = data.env;
|
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 });
|
||||||
}
|
}
|
||||||
|
|||||||
82
src/mcpd/src/repositories/template.repository.ts
Normal file
82
src/mcpd/src/repositories/template.repository.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { type PrismaClient, type McpTemplate, Prisma } from '@prisma/client';
|
||||||
|
import type { CreateTemplateInput, UpdateTemplateInput } from '../validation/template.schema.js';
|
||||||
|
|
||||||
|
export interface ITemplateRepository {
|
||||||
|
findAll(): Promise<McpTemplate[]>;
|
||||||
|
findById(id: string): Promise<McpTemplate | null>;
|
||||||
|
findByName(name: string): Promise<McpTemplate | null>;
|
||||||
|
search(pattern: string): Promise<McpTemplate[]>;
|
||||||
|
create(data: CreateTemplateInput): Promise<McpTemplate>;
|
||||||
|
update(id: string, data: UpdateTemplateInput): Promise<McpTemplate>;
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TemplateRepository implements ITemplateRepository {
|
||||||
|
constructor(private readonly prisma: PrismaClient) {}
|
||||||
|
|
||||||
|
async findAll(): Promise<McpTemplate[]> {
|
||||||
|
return this.prisma.mcpTemplate.findMany({ orderBy: { name: 'asc' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<McpTemplate | null> {
|
||||||
|
return this.prisma.mcpTemplate.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByName(name: string): Promise<McpTemplate | null> {
|
||||||
|
return this.prisma.mcpTemplate.findUnique({ where: { name } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(pattern: string): Promise<McpTemplate[]> {
|
||||||
|
// Convert glob * to SQL %
|
||||||
|
const sqlPattern = pattern.replace(/\*/g, '%');
|
||||||
|
return this.prisma.mcpTemplate.findMany({
|
||||||
|
where: { name: { contains: sqlPattern.replace(/%/g, ''), mode: 'insensitive' } },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: CreateTemplateInput): Promise<McpTemplate> {
|
||||||
|
return this.prisma.mcpTemplate.create({
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
version: data.version,
|
||||||
|
description: data.description,
|
||||||
|
packageName: data.packageName ?? null,
|
||||||
|
dockerImage: data.dockerImage ?? null,
|
||||||
|
transport: data.transport,
|
||||||
|
repositoryUrl: data.repositoryUrl ?? null,
|
||||||
|
externalUrl: data.externalUrl ?? null,
|
||||||
|
command: (data.command ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||||
|
containerPort: data.containerPort ?? null,
|
||||||
|
replicas: data.replicas,
|
||||||
|
env: (data.env ?? []) as unknown as Prisma.InputJsonValue,
|
||||||
|
healthCheck: (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: UpdateTemplateInput): Promise<McpTemplate> {
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
if (data.version !== undefined) updateData.version = data.version;
|
||||||
|
if (data.description !== undefined) updateData.description = data.description;
|
||||||
|
if (data.packageName !== undefined) updateData.packageName = data.packageName;
|
||||||
|
if (data.dockerImage !== undefined) updateData.dockerImage = data.dockerImage;
|
||||||
|
if (data.transport !== undefined) updateData.transport = data.transport;
|
||||||
|
if (data.repositoryUrl !== undefined) updateData.repositoryUrl = data.repositoryUrl;
|
||||||
|
if (data.externalUrl !== undefined) updateData.externalUrl = data.externalUrl;
|
||||||
|
if (data.command !== undefined) updateData.command = (data.command ?? Prisma.JsonNull) as Prisma.InputJsonValue;
|
||||||
|
if (data.containerPort !== undefined) updateData.containerPort = data.containerPort;
|
||||||
|
if (data.replicas !== undefined) updateData.replicas = data.replicas;
|
||||||
|
if (data.env !== undefined) updateData.env = (data.env ?? []) as Prisma.InputJsonValue;
|
||||||
|
if (data.healthCheck !== undefined) updateData.healthCheck = (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue;
|
||||||
|
|
||||||
|
return this.prisma.mcpTemplate.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.prisma.mcpTemplate.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,3 +13,4 @@ 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';
|
||||||
|
|||||||
31
src/mcpd/src/routes/templates.ts
Normal file
31
src/mcpd/src/routes/templates.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import type { TemplateService } from '../services/template.service.js';
|
||||||
|
|
||||||
|
export function registerTemplateRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
service: TemplateService,
|
||||||
|
): void {
|
||||||
|
app.get<{ Querystring: { name?: string } }>('/api/v1/templates', async (request) => {
|
||||||
|
const namePattern = request.query.name;
|
||||||
|
return service.list(namePattern);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>('/api/v1/templates/:id', async (request) => {
|
||||||
|
return service.getById(request.params.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/templates', async (request, reply) => {
|
||||||
|
const template = await service.create(request.body);
|
||||||
|
reply.code(201);
|
||||||
|
return template;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string } }>('/api/v1/templates/:id', async (request) => {
|
||||||
|
return service.update(request.params.id, request.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>('/api/v1/templates/:id', async (request, reply) => {
|
||||||
|
await service.delete(request.params.id);
|
||||||
|
reply.code(204);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export class RestoreService {
|
|||||||
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);
|
||||||
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)}`);
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -158,4 +163,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
357
src/mcpd/src/services/health-probe.service.ts
Normal file
357
src/mcpd/src/services/health-probe.service.ts
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
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, 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,
|
||||||
|
healthCheck: HealthCheckSpec,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<ProbeResult> {
|
||||||
|
if (!instance.port) {
|
||||||
|
return { healthy: false, latencyMs: 0, message: 'No port assigned' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
// For HTTP servers, we need to initialize a session first, then call the tool
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initialize
|
||||||
|
const initResp = await fetch(`http://localhost:${instance.port}`, {
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Send initialized notification
|
||||||
|
await fetch(`http://localhost:${instance.port}`, {
|
||||||
|
method: 'POST', headers,
|
||||||
|
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call health check tool
|
||||||
|
const toolResp = await fetch(`http://localhost:${instance.port}`, {
|
||||||
|
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();
|
||||||
|
// Check for JSON-RPC error in response
|
||||||
|
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 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ 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 } 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';
|
||||||
@@ -24,3 +24,6 @@ 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';
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import type { McpOrchestrator, ContainerSpec, ContainerInfo } from './orchestrat
|
|||||||
import { NotFoundError } from './mcp-server.service.js';
|
import { NotFoundError } from './mcp-server.service.js';
|
||||||
import { resolveServerEnv } from './env-resolver.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;
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
@@ -30,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)
|
||||||
*/
|
*/
|
||||||
@@ -39,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;
|
||||||
@@ -139,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,
|
||||||
@@ -151,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,
|
||||||
@@ -159,10 +218,16 @@ 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
|
// Resolve env vars from inline values and secret refs
|
||||||
if (this.secretRepo) {
|
if (this.secretRepo) {
|
||||||
@@ -177,6 +242,13 @@ export class InstanceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
const updateFields: { containerId: string; port?: number } = {
|
const updateFields: { containerId: string; port?: number } = {
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -38,6 +38,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 +63,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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import type { Project } from '@prisma/client';
|
import type { Project } from '@prisma/client';
|
||||||
import type { IProjectRepository } from '../repositories/project.repository.js';
|
import type { IProjectRepository } from '../repositories/project.repository.js';
|
||||||
import type { IMcpServerRepository } from '../repositories/interfaces.js';
|
|
||||||
import { CreateProjectSchema, UpdateProjectSchema } from '../validation/project.schema.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';
|
||||||
|
|
||||||
export class ProjectService {
|
export class ProjectService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly projectRepo: IProjectRepository,
|
private readonly projectRepo: IProjectRepository,
|
||||||
private readonly serverRepo: IMcpServerRepository,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(ownerId?: string): Promise<Project[]> {
|
async list(ownerId?: string): Promise<Project[]> {
|
||||||
|
|||||||
53
src/mcpd/src/services/template.service.ts
Normal file
53
src/mcpd/src/services/template.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { HealthCheckSchema } from './template.schema.js';
|
||||||
|
|
||||||
const SecretRefSchema = z.object({
|
const SecretRefSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
@@ -30,6 +31,7 @@ export const CreateMcpServerSchema = z.object({
|
|||||||
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),
|
||||||
env: z.array(ServerEnvEntrySchema).default([]),
|
env: z.array(ServerEnvEntrySchema).default([]),
|
||||||
|
healthCheck: HealthCheckSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const UpdateMcpServerSchema = z.object({
|
export const UpdateMcpServerSchema = z.object({
|
||||||
@@ -43,6 +45,7 @@ export const UpdateMcpServerSchema = z.object({
|
|||||||
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(),
|
||||||
env: z.array(ServerEnvEntrySchema).optional(),
|
env: z.array(ServerEnvEntrySchema).optional(),
|
||||||
|
healthCheck: HealthCheckSchema.nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateMcpServerInput = z.infer<typeof CreateMcpServerSchema>;
|
export type CreateMcpServerInput = z.infer<typeof CreateMcpServerSchema>;
|
||||||
|
|||||||
39
src/mcpd/src/validation/template.schema.ts
Normal file
39
src/mcpd/src/validation/template.schema.ts
Normal 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>;
|
||||||
@@ -2,8 +2,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||||||
import { ProjectService } from '../src/services/project.service.js';
|
import { ProjectService } from '../src/services/project.service.js';
|
||||||
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
|
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
|
||||||
import type { IProjectRepository } from '../src/repositories/project.repository.js';
|
import type { IProjectRepository } from '../src/repositories/project.repository.js';
|
||||||
import type { IMcpServerRepository } from '../src/repositories/interfaces.js';
|
|
||||||
|
|
||||||
function mockProjectRepo(): IProjectRepository {
|
function mockProjectRepo(): IProjectRepository {
|
||||||
return {
|
return {
|
||||||
findAll: vi.fn(async () => []),
|
findAll: vi.fn(async () => []),
|
||||||
@@ -26,26 +24,13 @@ function mockProjectRepo(): IProjectRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockServerRepo(): IMcpServerRepository {
|
|
||||||
return {
|
|
||||||
findAll: vi.fn(async () => []),
|
|
||||||
findById: vi.fn(async () => null),
|
|
||||||
findByName: vi.fn(async () => null),
|
|
||||||
create: vi.fn(async () => ({} as never)),
|
|
||||||
update: vi.fn(async () => ({} as never)),
|
|
||||||
delete: vi.fn(async () => {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ProjectService', () => {
|
describe('ProjectService', () => {
|
||||||
let projectRepo: ReturnType<typeof mockProjectRepo>;
|
let projectRepo: ReturnType<typeof mockProjectRepo>;
|
||||||
let serverRepo: ReturnType<typeof mockServerRepo>;
|
|
||||||
let service: ProjectService;
|
let service: ProjectService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
projectRepo = mockProjectRepo();
|
projectRepo = mockProjectRepo();
|
||||||
serverRepo = mockServerRepo();
|
service = new ProjectService(projectRepo);
|
||||||
service = new ProjectService(projectRepo, serverRepo);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
|
|||||||
355
src/mcpd/tests/services/health-probe.test.ts
Normal file
355
src/mcpd/tests/services/health-probe.test.ts
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { HealthProbeRunner } from '../../src/services/health-probe.service.js';
|
||||||
|
import type { HealthCheckSpec } from '../../src/services/health-probe.service.js';
|
||||||
|
import type { IMcpInstanceRepository, IMcpServerRepository } from '../../src/repositories/interfaces.js';
|
||||||
|
import type { McpOrchestrator, ExecResult } from '../../src/services/orchestrator.js';
|
||||||
|
import type { McpInstance, McpServer } from '@prisma/client';
|
||||||
|
|
||||||
|
function makeInstance(overrides: Partial<McpInstance> = {}): McpInstance {
|
||||||
|
return {
|
||||||
|
id: 'inst-1',
|
||||||
|
serverId: 'srv-1',
|
||||||
|
status: 'RUNNING',
|
||||||
|
containerId: 'container-abc',
|
||||||
|
port: null,
|
||||||
|
healthStatus: null,
|
||||||
|
lastHealthCheck: null,
|
||||||
|
events: [],
|
||||||
|
metadata: {},
|
||||||
|
version: 1,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...overrides,
|
||||||
|
} as McpInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeServer(overrides: Partial<McpServer> = {}): McpServer {
|
||||||
|
return {
|
||||||
|
id: 'srv-1',
|
||||||
|
name: 'my-grafana',
|
||||||
|
transport: 'STDIO',
|
||||||
|
packageName: '@leval/mcp-grafana',
|
||||||
|
dockerImage: null,
|
||||||
|
externalUrl: null,
|
||||||
|
containerPort: null,
|
||||||
|
repositoryUrl: null,
|
||||||
|
description: null,
|
||||||
|
command: null,
|
||||||
|
env: [],
|
||||||
|
replicas: 1,
|
||||||
|
projectId: null,
|
||||||
|
healthCheck: {
|
||||||
|
tool: 'list_datasources',
|
||||||
|
arguments: {},
|
||||||
|
intervalSeconds: 60,
|
||||||
|
timeoutSeconds: 10,
|
||||||
|
failureThreshold: 3,
|
||||||
|
},
|
||||||
|
version: 1,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...overrides,
|
||||||
|
} as McpServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockInstanceRepo(): IMcpInstanceRepository {
|
||||||
|
return {
|
||||||
|
findAll: vi.fn(async () => []),
|
||||||
|
findById: vi.fn(async () => null),
|
||||||
|
findByContainerId: vi.fn(async () => null),
|
||||||
|
create: vi.fn(async (data) => makeInstance(data)),
|
||||||
|
updateStatus: vi.fn(async (id, status, fields) => makeInstance({ id, status, ...fields })),
|
||||||
|
delete: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockServerRepo(): IMcpServerRepository {
|
||||||
|
return {
|
||||||
|
findAll: vi.fn(async () => []),
|
||||||
|
findById: vi.fn(async () => null),
|
||||||
|
findByName: vi.fn(async () => null),
|
||||||
|
create: vi.fn(async () => makeServer()),
|
||||||
|
update: vi.fn(async () => makeServer()),
|
||||||
|
delete: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockOrchestrator(): McpOrchestrator {
|
||||||
|
return {
|
||||||
|
pullImage: vi.fn(async () => {}),
|
||||||
|
createContainer: vi.fn(async () => ({ containerId: 'c1', name: 'test', state: 'running' as const, createdAt: new Date() })),
|
||||||
|
stopContainer: vi.fn(async () => {}),
|
||||||
|
removeContainer: vi.fn(async () => {}),
|
||||||
|
inspectContainer: vi.fn(async () => ({ containerId: 'c1', name: 'test', state: 'running' as const, createdAt: new Date() })),
|
||||||
|
getContainerLogs: vi.fn(async () => ({ stdout: '', stderr: '' })),
|
||||||
|
execInContainer: vi.fn(async () => ({ exitCode: 0, stdout: 'OK', stderr: '' })),
|
||||||
|
ping: vi.fn(async () => true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HealthProbeRunner', () => {
|
||||||
|
let instanceRepo: IMcpInstanceRepository;
|
||||||
|
let serverRepo: IMcpServerRepository;
|
||||||
|
let orchestrator: McpOrchestrator;
|
||||||
|
let runner: HealthProbeRunner;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
instanceRepo = mockInstanceRepo();
|
||||||
|
serverRepo = mockServerRepo();
|
||||||
|
orchestrator = mockOrchestrator();
|
||||||
|
runner = new HealthProbeRunner(instanceRepo, serverRepo, orchestrator);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips instances without healthCheck config', async () => {
|
||||||
|
const instance = makeInstance();
|
||||||
|
const server = makeServer({ healthCheck: null });
|
||||||
|
|
||||||
|
vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]);
|
||||||
|
vi.mocked(serverRepo.findById).mockResolvedValue(server);
|
||||||
|
|
||||||
|
await runner.tick();
|
||||||
|
|
||||||
|
expect(orchestrator.execInContainer).not.toHaveBeenCalled();
|
||||||
|
expect(instanceRepo.updateStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips non-RUNNING instances', async () => {
|
||||||
|
const instance = makeInstance({ status: 'ERROR' });
|
||||||
|
vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]);
|
||||||
|
|
||||||
|
await runner.tick();
|
||||||
|
|
||||||
|
expect(serverRepo.findById).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('probes STDIO instance with exec and marks healthy on success', async () => {
|
||||||
|
const instance = makeInstance();
|
||||||
|
const server = makeServer();
|
||||||
|
|
||||||
|
vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]);
|
||||||
|
vi.mocked(serverRepo.findById).mockResolvedValue(server);
|
||||||
|
vi.mocked(orchestrator.execInContainer).mockResolvedValue({
|
||||||
|
exitCode: 0,
|
||||||
|
stdout: 'OK',
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runner.tick();
|
||||||
|
|
||||||
|
expect(orchestrator.execInContainer).toHaveBeenCalledWith(
|
||||||
|
'container-abc',
|
||||||
|
expect.arrayContaining(['node', '-e']),
|
||||||
|
expect.objectContaining({ timeoutMs: 10000 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(instanceRepo.updateStatus).toHaveBeenCalledWith(
|
||||||
|
'inst-1',
|
||||||
|
'RUNNING',
|
||||||
|
expect.objectContaining({
|
||||||
|
healthStatus: 'healthy',
|
||||||
|
lastHealthCheck: expect.any(Date),
|
||||||
|
events: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ type: 'Normal', message: expect.stringContaining('passed') }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks unhealthy after failureThreshold consecutive failures', async () => {
|
||||||
|
const instance = makeInstance();
|
||||||
|
const healthCheck: HealthCheckSpec = {
|
||||||
|
tool: 'list_datasources',
|
||||||
|
arguments: {},
|
||||||
|
intervalSeconds: 0, // always due
|
||||||
|
failureThreshold: 2,
|
||||||
|
};
|
||||||
|
const server = makeServer({ healthCheck: healthCheck as unknown as undefined });
|
||||||
|
|
||||||
|
vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]);
|
||||||
|
vi.mocked(serverRepo.findById).mockResolvedValue(server);
|
||||||
|
vi.mocked(orchestrator.execInContainer).mockResolvedValue({
|
||||||
|
exitCode: 1,
|
||||||
|
stdout: 'ERROR:connection refused',
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// First failure → degraded
|
||||||
|
await runner.tick();
|
||||||
|
expect(instanceRepo.updateStatus).toHaveBeenCalledWith(
|
||||||
|
'inst-1',
|
||||||
|
'RUNNING',
|
||||||
|
expect.objectContaining({ healthStatus: 'degraded' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second failure → unhealthy (threshold = 2)
|
||||||
|
await runner.tick();
|
||||||
|
expect(instanceRepo.updateStatus).toHaveBeenCalledWith(
|
||||||
|
'inst-1',
|
||||||
|
'RUNNING',
|
||||||
|
expect.objectContaining({ healthStatus: 'unhealthy' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets failure count on success', async () => {
|
||||||
|
const instance = makeInstance();
|
||||||
|
const healthCheck: HealthCheckSpec = {
|
||||||
|
tool: 'list_datasources',
|
||||||
|
arguments: {},
|
||||||
|
intervalSeconds: 0,
|
||||||
|
failureThreshold: 3,
|
||||||
|
};
|
||||||
|
const server = makeServer({ healthCheck: healthCheck as unknown as undefined });
|
||||||
|
|
||||||
|
vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]);
|
||||||
|
vi.mocked(serverRepo.findById).mockResolvedValue(server);
|
||||||
|
|
||||||
|
// Two failures
|
||||||
|
vi.mocked(orchestrator.execInContainer).mockResolvedValue({
|
||||||
|
exitCode: 1, stdout: 'ERROR:fail', stderr: '',
|
||||||
|
});
|
||||||
|
await runner.tick();
|
||||||
|
await runner.tick();
|
||||||
|
|
||||||
|
// Then success — should reset to healthy
|
||||||
|
vi.mocked(orchestrator.execInContainer).mockResolvedValue({
|
||||||
|
exitCode: 0, stdout: 'OK', stderr: '',
|
||||||
|
});
|
||||||
|
await runner.tick();
|
||||||
|
|
||||||
|
const lastCall = vi.mocked(instanceRepo.updateStatus).mock.calls.at(-1);
|
||||||
|
expect(lastCall?.[2]).toEqual(expect.objectContaining({ healthStatus: 'healthy' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles exec timeout as failure', async () => {
|
||||||
|
const instance = makeInstance();
|
||||||
|
const server = makeServer();
|
||||||
|
|
||||||
|
vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]);
|
||||||
|
vi.mocked(serverRepo.findById).mockResolvedValue(server);
|
||||||
|
vi.mocked(orchestrator.execInContainer).mockRejectedValue(new Error('Exec timed out after 10000ms'));
|
||||||
|
|
||||||
|
await runner.tick();
|
||||||
|
|
||||||
|
expect(instanceRepo.updateStatus).toHaveBeenCalledWith(
|
||||||
|
'inst-1',
|
||||||
|
'RUNNING',
|
||||||
|
expect.objectContaining({
|
||||||
|
healthStatus: 'degraded',
|
||||||
|
events: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ type: 'Warning', message: expect.stringContaining('timed out') }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends events without losing history', async () => {
|
||||||
|
const existingEvents = [
|
||||||
|
{ timestamp: '2025-01-01T00:00:00Z', type: 'Normal', message: 'old event' },
|
||||||
|
];
|
||||||
|
const instance = makeInstance({ events: existingEvents });
|
||||||
|
const server = makeServer({
|
||||||
|
healthCheck: { tool: 'test', intervalSeconds: 0 } as McpServer['healthCheck'],
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]);
|
||||||
|
vi.mocked(serverRepo.findById).mockResolvedValue(server);
|
||||||
|
vi.mocked(orchestrator.execInContainer).mockResolvedValue({
|
||||||
|
exitCode: 0, stdout: 'OK', stderr: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runner.tick();
|
||||||
|
|
||||||
|
const events = vi.mocked(instanceRepo.updateStatus).mock.calls[0]?.[2]?.events as unknown[];
|
||||||
|
expect(events).toHaveLength(2);
|
||||||
|
expect((events[0] as { message: string }).message).toBe('old event');
|
||||||
|
expect((events[1] as { message: string }).message).toContain('passed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects interval — skips probing if not due', async () => {
|
||||||
|
const instance = makeInstance();
|
||||||
|
const server = makeServer({
|
||||||
|
healthCheck: { tool: 'test', intervalSeconds: 300 } as McpServer['healthCheck'],
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]);
|
||||||
|
vi.mocked(serverRepo.findById).mockResolvedValue(server);
|
||||||
|
vi.mocked(orchestrator.execInContainer).mockResolvedValue({
|
||||||
|
exitCode: 0, stdout: 'OK', stderr: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// First tick: should probe
|
||||||
|
await runner.tick();
|
||||||
|
expect(orchestrator.execInContainer).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Second tick immediately: should skip (300s interval not elapsed)
|
||||||
|
await runner.tick();
|
||||||
|
expect(orchestrator.execInContainer).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up probe states for removed instances', async () => {
|
||||||
|
const instance = makeInstance();
|
||||||
|
const server = makeServer({
|
||||||
|
healthCheck: { tool: 'test', intervalSeconds: 0 } as McpServer['healthCheck'],
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]);
|
||||||
|
vi.mocked(serverRepo.findById).mockResolvedValue(server);
|
||||||
|
|
||||||
|
await runner.tick();
|
||||||
|
expect(orchestrator.execInContainer).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Instance removed
|
||||||
|
vi.mocked(instanceRepo.findAll).mockResolvedValue([]);
|
||||||
|
await runner.tick();
|
||||||
|
|
||||||
|
// Re-add same instance — should probe again (state was cleaned)
|
||||||
|
vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]);
|
||||||
|
await runner.tick();
|
||||||
|
expect(orchestrator.execInContainer).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips STDIO instances without containerId', async () => {
|
||||||
|
const instance = makeInstance({ containerId: null });
|
||||||
|
const server = makeServer();
|
||||||
|
|
||||||
|
// containerId is null, but status is RUNNING — shouldn't be probed
|
||||||
|
vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]);
|
||||||
|
|
||||||
|
await runner.tick();
|
||||||
|
expect(serverRepo.findById).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('probeInstance returns result directly', async () => {
|
||||||
|
const instance = makeInstance();
|
||||||
|
const server = makeServer();
|
||||||
|
const healthCheck: HealthCheckSpec = {
|
||||||
|
tool: 'list_datasources',
|
||||||
|
arguments: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(orchestrator.execInContainer).mockResolvedValue({
|
||||||
|
exitCode: 0, stdout: 'OK', stderr: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runner.probeInstance(instance, server, healthCheck);
|
||||||
|
expect(result.healthy).toBe(true);
|
||||||
|
expect(result.latencyMs).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(result.message).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles STDIO exec failure with error message', async () => {
|
||||||
|
const instance = makeInstance();
|
||||||
|
const server = makeServer();
|
||||||
|
const healthCheck: HealthCheckSpec = { tool: 'list_datasources', arguments: {} };
|
||||||
|
|
||||||
|
vi.mocked(orchestrator.execInContainer).mockResolvedValue({
|
||||||
|
exitCode: 1,
|
||||||
|
stdout: 'ERROR:ECONNREFUSED 10.0.0.1:3000',
|
||||||
|
stderr: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runner.probeInstance(instance, server, healthCheck);
|
||||||
|
expect(result.healthy).toBe(false);
|
||||||
|
expect(result.message).toBe('ECONNREFUSED 10.0.0.1:3000');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"types": ["node"]
|
"types": ["node", "js-yaml"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
"references": [
|
"references": [
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export { loadHttpConfig } from './config.js';
|
|||||||
export type { HttpConfig } from './config.js';
|
export type { HttpConfig } from './config.js';
|
||||||
export { McpdClient, AuthenticationError, ConnectionError } from './mcpd-client.js';
|
export { McpdClient, AuthenticationError, ConnectionError } from './mcpd-client.js';
|
||||||
export { registerProxyRoutes } from './routes/proxy.js';
|
export { registerProxyRoutes } from './routes/proxy.js';
|
||||||
|
export { registerMcpEndpoint } from './mcp-endpoint.js';
|
||||||
|
|||||||
100
src/mcplocal/src/http/mcp-endpoint.ts
Normal file
100
src/mcplocal/src/http/mcp-endpoint.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Streamable HTTP MCP protocol endpoint.
|
||||||
|
*
|
||||||
|
* Exposes the McpRouter over HTTP at /mcp so Claude Code can connect
|
||||||
|
* via `{ "type": "http", "url": "http://localhost:3200/mcp" }` in .mcp.json.
|
||||||
|
*
|
||||||
|
* Each client session gets its own StreamableHTTPServerTransport, but all
|
||||||
|
* share the same McpRouter (and therefore the same upstream connections).
|
||||||
|
*/
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
|
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import type { McpRouter } from '../router.js';
|
||||||
|
import type { JsonRpcRequest } from '../types.js';
|
||||||
|
|
||||||
|
interface SessionEntry {
|
||||||
|
transport: StreamableHTTPServerTransport;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerMcpEndpoint(app: FastifyInstance, router: McpRouter): void {
|
||||||
|
const sessions = new Map<string, SessionEntry>();
|
||||||
|
|
||||||
|
// POST /mcp — JSON-RPC requests (initialize, tools/call, etc.)
|
||||||
|
app.post('/mcp', async (request, reply) => {
|
||||||
|
const sessionId = request.headers['mcp-session-id'] as string | undefined;
|
||||||
|
|
||||||
|
if (sessionId && sessions.has(sessionId)) {
|
||||||
|
// Existing session
|
||||||
|
const session = sessions.get(sessionId)!;
|
||||||
|
await session.transport.handleRequest(request.raw, reply.raw, request.body);
|
||||||
|
// Fastify must not send its own response — the transport already did
|
||||||
|
reply.hijack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionId && !sessions.has(sessionId)) {
|
||||||
|
// Unknown session
|
||||||
|
reply.code(404).send({ error: 'Session not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New session — no session ID header
|
||||||
|
const transport = new StreamableHTTPServerTransport({
|
||||||
|
sessionIdGenerator: () => randomUUID(),
|
||||||
|
onsessioninitialized: (id) => {
|
||||||
|
sessions.set(id, { transport });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wire transport messages to the router
|
||||||
|
transport.onmessage = async (message: JSONRPCMessage) => {
|
||||||
|
// The transport sends us JSON-RPC messages; route them through McpRouter
|
||||||
|
if ('method' in message && 'id' in message) {
|
||||||
|
const response = await router.route(message as unknown as JsonRpcRequest);
|
||||||
|
await transport.send(response as unknown as JSONRPCMessage);
|
||||||
|
}
|
||||||
|
// Notifications (no id) are ignored — router doesn't handle inbound notifications
|
||||||
|
};
|
||||||
|
|
||||||
|
transport.onclose = () => {
|
||||||
|
const id = transport.sessionId;
|
||||||
|
if (id) {
|
||||||
|
sessions.delete(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await transport.handleRequest(request.raw, reply.raw, request.body);
|
||||||
|
reply.hijack();
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /mcp — SSE stream for server-initiated notifications
|
||||||
|
app.get('/mcp', async (request, reply) => {
|
||||||
|
const sessionId = request.headers['mcp-session-id'] as string | undefined;
|
||||||
|
|
||||||
|
if (!sessionId || !sessions.has(sessionId)) {
|
||||||
|
reply.code(400).send({ error: 'Invalid or missing session ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = sessions.get(sessionId)!;
|
||||||
|
await session.transport.handleRequest(request.raw, reply.raw);
|
||||||
|
reply.hijack();
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /mcp — Session cleanup
|
||||||
|
app.delete('/mcp', async (request, reply) => {
|
||||||
|
const sessionId = request.headers['mcp-session-id'] as string | undefined;
|
||||||
|
|
||||||
|
if (!sessionId || !sessions.has(sessionId)) {
|
||||||
|
reply.code(400).send({ error: 'Invalid or missing session ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = sessions.get(sessionId)!;
|
||||||
|
await session.transport.handleRequest(request.raw, reply.raw);
|
||||||
|
sessions.delete(sessionId);
|
||||||
|
reply.hijack();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { APP_VERSION } from '@mcpctl/shared';
|
|||||||
import type { HttpConfig } from './config.js';
|
import type { HttpConfig } from './config.js';
|
||||||
import { McpdClient } from './mcpd-client.js';
|
import { McpdClient } from './mcpd-client.js';
|
||||||
import { registerProxyRoutes } from './routes/proxy.js';
|
import { registerProxyRoutes } from './routes/proxy.js';
|
||||||
|
import { registerMcpEndpoint } from './mcp-endpoint.js';
|
||||||
import type { McpRouter } from '../router.js';
|
import type { McpRouter } from '../router.js';
|
||||||
import type { HealthMonitor } from '../health.js';
|
import type { HealthMonitor } from '../health.js';
|
||||||
import type { TieredHealthMonitor } from '../health/tiered.js';
|
import type { TieredHealthMonitor } from '../health/tiered.js';
|
||||||
@@ -81,5 +82,8 @@ export async function createHttpServer(
|
|||||||
const mcpdClient = new McpdClient(config.mcpdUrl, config.mcpdToken);
|
const mcpdClient = new McpdClient(config.mcpdUrl, config.mcpdToken);
|
||||||
registerProxyRoutes(app, mcpdClient);
|
registerProxyRoutes(app, mcpdClient);
|
||||||
|
|
||||||
|
// Streamable HTTP MCP protocol endpoint at /mcp
|
||||||
|
registerMcpEndpoint(app, deps.router);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,7 +141,10 @@ export async function main(argv: string[] = process.argv): Promise<MainResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run when executed directly
|
// Run when executed directly
|
||||||
const isMain = process.argv[1]?.endsWith('main.js') || process.argv[1]?.endsWith('main.ts');
|
const isMain =
|
||||||
|
process.argv[1]?.endsWith('main.js') ||
|
||||||
|
process.argv[1]?.endsWith('main.ts') ||
|
||||||
|
process.argv[1]?.endsWith('mcpctl-local');
|
||||||
if (isMain) {
|
if (isMain) {
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
process.stderr.write(`Fatal: ${err}\n`);
|
process.stderr.write(`Fatal: ${err}\n`);
|
||||||
|
|||||||
@@ -1,207 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import {
|
|
||||||
ProfileRegistry,
|
|
||||||
defaultRegistry,
|
|
||||||
profileTemplateSchema,
|
|
||||||
validateTemplate,
|
|
||||||
getMissingEnvVars,
|
|
||||||
instantiateProfile,
|
|
||||||
generateMcpJsonEntry,
|
|
||||||
filesystemTemplate,
|
|
||||||
githubTemplate,
|
|
||||||
postgresTemplate,
|
|
||||||
slackTemplate,
|
|
||||||
memoryTemplate,
|
|
||||||
fetchTemplate,
|
|
||||||
} from '../src/profiles/index.js';
|
|
||||||
|
|
||||||
const allTemplates = [
|
|
||||||
filesystemTemplate,
|
|
||||||
githubTemplate,
|
|
||||||
postgresTemplate,
|
|
||||||
slackTemplate,
|
|
||||||
memoryTemplate,
|
|
||||||
fetchTemplate,
|
|
||||||
];
|
|
||||||
|
|
||||||
describe('ProfileTemplate schema', () => {
|
|
||||||
it.each(allTemplates)('validates $id template', (template) => {
|
|
||||||
const result = profileTemplateSchema.safeParse(template);
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects template with missing required fields', () => {
|
|
||||||
const result = profileTemplateSchema.safeParse({ id: 'x' });
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects template with invalid id format', () => {
|
|
||||||
const result = profileTemplateSchema.safeParse({
|
|
||||||
...filesystemTemplate,
|
|
||||||
id: 'Invalid ID!',
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects template with invalid category', () => {
|
|
||||||
const result = profileTemplateSchema.safeParse({
|
|
||||||
...filesystemTemplate,
|
|
||||||
category: 'nonexistent',
|
|
||||||
});
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ProfileRegistry', () => {
|
|
||||||
it('default registry contains all builtin templates', () => {
|
|
||||||
const ids = defaultRegistry.getAll().map((t) => t.id);
|
|
||||||
expect(ids).toContain('filesystem');
|
|
||||||
expect(ids).toContain('github');
|
|
||||||
expect(ids).toContain('postgres');
|
|
||||||
expect(ids).toContain('slack');
|
|
||||||
expect(ids).toContain('memory');
|
|
||||||
expect(ids).toContain('fetch');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has no duplicate IDs', () => {
|
|
||||||
const ids = defaultRegistry.getAll().map((t) => t.id);
|
|
||||||
expect(new Set(ids).size).toBe(ids.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getById returns correct template', () => {
|
|
||||||
expect(defaultRegistry.getById('github')).toBe(githubTemplate);
|
|
||||||
expect(defaultRegistry.getById('nonexistent')).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getByCategory filters correctly', () => {
|
|
||||||
const integrations = defaultRegistry.getByCategory('integration');
|
|
||||||
expect(integrations.map((t) => t.id)).toEqual(
|
|
||||||
expect.arrayContaining(['github', 'slack']),
|
|
||||||
);
|
|
||||||
for (const t of integrations) {
|
|
||||||
expect(t.category).toBe('integration');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getCategories returns unique categories', () => {
|
|
||||||
const cats = defaultRegistry.getCategories();
|
|
||||||
expect(cats.length).toBeGreaterThan(0);
|
|
||||||
expect(new Set(cats).size).toBe(cats.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('search finds by name', () => {
|
|
||||||
const results = defaultRegistry.search('git');
|
|
||||||
expect(results.some((t) => t.id === 'github')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('search finds by description', () => {
|
|
||||||
const results = defaultRegistry.search('knowledge graph');
|
|
||||||
expect(results.some((t) => t.id === 'memory')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('search returns empty for no match', () => {
|
|
||||||
expect(defaultRegistry.search('zzzznotfound')).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('register adds a custom template', () => {
|
|
||||||
const registry = new ProfileRegistry([]);
|
|
||||||
registry.register(filesystemTemplate);
|
|
||||||
expect(registry.has('filesystem')).toBe(true);
|
|
||||||
expect(registry.getAll()).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('validateTemplate', () => {
|
|
||||||
it('returns success for valid template', () => {
|
|
||||||
const result = validateTemplate(filesystemTemplate);
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns errors for invalid template', () => {
|
|
||||||
const result = validateTemplate({ id: '' });
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
if (!result.success) {
|
|
||||||
expect(result.errors.length).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getMissingEnvVars', () => {
|
|
||||||
it('returns empty for template with no required env vars', () => {
|
|
||||||
expect(getMissingEnvVars(filesystemTemplate, {})).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns missing vars for github template', () => {
|
|
||||||
const missing = getMissingEnvVars(githubTemplate, {});
|
|
||||||
expect(missing).toContain('GITHUB_PERSONAL_ACCESS_TOKEN');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty when all vars provided', () => {
|
|
||||||
const missing = getMissingEnvVars(githubTemplate, {
|
|
||||||
GITHUB_PERSONAL_ACCESS_TOKEN: 'ghp_xxx',
|
|
||||||
});
|
|
||||||
expect(missing).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('instantiateProfile', () => {
|
|
||||||
it('creates profile from template without env vars', () => {
|
|
||||||
const profile = instantiateProfile(filesystemTemplate, {});
|
|
||||||
expect(profile.name).toBe('filesystem');
|
|
||||||
expect(profile.templateId).toBe('filesystem');
|
|
||||||
expect(profile.command).toBe('npx');
|
|
||||||
expect(profile.args).toEqual(['-y', '@modelcontextprotocol/server-filesystem']);
|
|
||||||
expect(profile.env).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates profile with env vars', () => {
|
|
||||||
const profile = instantiateProfile(githubTemplate, {
|
|
||||||
GITHUB_PERSONAL_ACCESS_TOKEN: 'ghp_test123',
|
|
||||||
});
|
|
||||||
expect(profile.env.GITHUB_PERSONAL_ACCESS_TOKEN).toBe('ghp_test123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws on missing required env vars', () => {
|
|
||||||
expect(() => instantiateProfile(githubTemplate, {})).toThrow(
|
|
||||||
'Missing required environment variables',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('includes optional env vars when provided', () => {
|
|
||||||
const profile = instantiateProfile(filesystemTemplate, {
|
|
||||||
SOME_OPTIONAL: 'value',
|
|
||||||
});
|
|
||||||
// Optional vars not in template are not included
|
|
||||||
expect(profile.env).toEqual({});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('generateMcpJsonEntry', () => {
|
|
||||||
it('generates valid .mcp.json entry', () => {
|
|
||||||
const profile = instantiateProfile(githubTemplate, {
|
|
||||||
GITHUB_PERSONAL_ACCESS_TOKEN: 'ghp_abc',
|
|
||||||
});
|
|
||||||
const entry = generateMcpJsonEntry(profile);
|
|
||||||
expect(entry).toEqual({
|
|
||||||
github: {
|
|
||||||
command: 'npx',
|
|
||||||
args: ['-y', '@modelcontextprotocol/server-github'],
|
|
||||||
env: {
|
|
||||||
GITHUB_PERSONAL_ACCESS_TOKEN: 'ghp_abc',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates entry with empty env for no-env template', () => {
|
|
||||||
const profile = instantiateProfile(memoryTemplate, {});
|
|
||||||
const entry = generateMcpJsonEntry(profile);
|
|
||||||
expect(entry).toEqual({
|
|
||||||
memory: {
|
|
||||||
command: 'npx',
|
|
||||||
args: ['-y', '@modelcontextprotocol/server-memory'],
|
|
||||||
env: {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
5
stack/.env.example
Normal file
5
stack/.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
POSTGRES_USER=mcpctl
|
||||||
|
POSTGRES_PASSWORD=CHANGE_ME
|
||||||
|
POSTGRES_DB=mcpctl
|
||||||
|
MCPD_PORT=3100
|
||||||
|
MCPD_LOG_LEVEL=info
|
||||||
58
stack/docker-compose.yml
Normal file
58
stack/docker-compose.yml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: mcpctl-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
volumes:
|
||||||
|
- mcpctl-pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- mcpctl
|
||||||
|
|
||||||
|
mcpd:
|
||||||
|
image: mysources.co.uk/michal/mcpd:latest
|
||||||
|
container_name: mcpctl-mcpd
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${MCPD_PORT:-3100}:3100"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
||||||
|
MCPD_PORT: "3100"
|
||||||
|
MCPD_HOST: "0.0.0.0"
|
||||||
|
MCPD_LOG_LEVEL: ${MCPD_LOG_LEVEL:-info}
|
||||||
|
MCPD_NODE_RUNNER_IMAGE: mysources.co.uk/michal/mcpctl-node-runner:latest
|
||||||
|
MCPD_MCP_NETWORK: mcp-servers
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
networks:
|
||||||
|
- mcpctl
|
||||||
|
- mcp-servers
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q --spider http://localhost:3100/healthz || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
mcpctl:
|
||||||
|
driver: bridge
|
||||||
|
mcp-servers:
|
||||||
|
name: mcp-servers
|
||||||
|
driver: bridge
|
||||||
|
# Not internal — MCP servers need outbound access for external APIs.
|
||||||
|
# Isolation enforced by not binding host ports on MCP containers.
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mcpctl-pgdata:
|
||||||
6
templates/filesystem.yaml
Normal file
6
templates/filesystem.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name: filesystem
|
||||||
|
version: "1.0.0"
|
||||||
|
description: Filesystem MCP server for reading and writing files
|
||||||
|
packageName: "@anthropic/filesystem-mcp"
|
||||||
|
transport: STDIO
|
||||||
|
repositoryUrl: https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem
|
||||||
14
templates/github.yaml
Normal file
14
templates/github.yaml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
name: github
|
||||||
|
version: "1.0.0"
|
||||||
|
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
|
||||||
|
healthCheck:
|
||||||
|
tool: search_repositories
|
||||||
|
arguments:
|
||||||
|
query: "test"
|
||||||
|
env:
|
||||||
|
- name: GITHUB_TOKEN
|
||||||
|
description: Personal access token with repo scope
|
||||||
|
required: true
|
||||||
16
templates/grafana.yaml
Normal file
16
templates/grafana.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
name: grafana
|
||||||
|
version: "1.0.0"
|
||||||
|
description: Grafana MCP server for dashboards, datasources, and alerts
|
||||||
|
packageName: "@leval/mcp-grafana"
|
||||||
|
transport: STDIO
|
||||||
|
repositoryUrl: https://github.com/levalhq/mcp-grafana
|
||||||
|
healthCheck:
|
||||||
|
tool: list_datasources
|
||||||
|
arguments: {}
|
||||||
|
env:
|
||||||
|
- name: GRAFANA_URL
|
||||||
|
description: Grafana instance URL (e.g. https://grafana.example.com)
|
||||||
|
required: true
|
||||||
|
- name: GRAFANA_SERVICE_ACCOUNT_TOKEN
|
||||||
|
description: Grafana service account token (glsa_...)
|
||||||
|
required: true
|
||||||
16
templates/home-assistant.yaml
Normal file
16
templates/home-assistant.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
name: home-assistant
|
||||||
|
version: "1.0.0"
|
||||||
|
description: Home Assistant MCP server for smart home control and entity management
|
||||||
|
packageName: "home-assistant-mcp-server"
|
||||||
|
transport: STDIO
|
||||||
|
repositoryUrl: https://github.com/tevonsb/homeassistant-mcp
|
||||||
|
healthCheck:
|
||||||
|
tool: get_entities
|
||||||
|
arguments: {}
|
||||||
|
env:
|
||||||
|
- name: HASS_URL
|
||||||
|
description: Home Assistant instance URL (e.g. http://homeassistant.local:8123)
|
||||||
|
required: true
|
||||||
|
- name: HASS_TOKEN
|
||||||
|
description: Home Assistant long-lived access token
|
||||||
|
required: true
|
||||||
21
templates/jira.yaml
Normal file
21
templates/jira.yaml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
name: jira
|
||||||
|
version: "1.0.0"
|
||||||
|
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
|
||||||
|
healthCheck:
|
||||||
|
tool: search_issues
|
||||||
|
arguments:
|
||||||
|
jql: "created >= -1d"
|
||||||
|
maxResults: 1
|
||||||
|
env:
|
||||||
|
- name: JIRA_URL
|
||||||
|
description: Jira instance URL (e.g. https://company.atlassian.net)
|
||||||
|
required: true
|
||||||
|
- name: JIRA_EMAIL
|
||||||
|
description: Jira account email
|
||||||
|
required: true
|
||||||
|
- name: JIRA_API_TOKEN
|
||||||
|
description: Jira API token
|
||||||
|
required: true
|
||||||
16
templates/node-red.yaml
Normal file
16
templates/node-red.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
name: node-red
|
||||||
|
version: "1.0.0"
|
||||||
|
description: Node-RED MCP server for flow management and automation
|
||||||
|
packageName: "mcp-node-red"
|
||||||
|
transport: STDIO
|
||||||
|
repositoryUrl: https://github.com/fx/mcp-node-red
|
||||||
|
healthCheck:
|
||||||
|
tool: get_settings
|
||||||
|
arguments: {}
|
||||||
|
env:
|
||||||
|
- name: NODE_RED_URL
|
||||||
|
description: Node-RED instance URL (e.g. http://nodered.local:1880)
|
||||||
|
required: true
|
||||||
|
- name: NODE_RED_TOKEN
|
||||||
|
description: Node-RED access token (optional if no auth)
|
||||||
|
required: false
|
||||||
14
templates/postgres.yaml
Normal file
14
templates/postgres.yaml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
name: postgres
|
||||||
|
version: "1.0.0"
|
||||||
|
description: PostgreSQL MCP server for database queries and schema inspection
|
||||||
|
packageName: "@anthropic/postgres-mcp"
|
||||||
|
transport: STDIO
|
||||||
|
repositoryUrl: https://github.com/modelcontextprotocol/servers/tree/main/src/postgres
|
||||||
|
healthCheck:
|
||||||
|
tool: query
|
||||||
|
arguments:
|
||||||
|
sql: "SELECT 1"
|
||||||
|
env:
|
||||||
|
- name: POSTGRES_CONNECTION_STRING
|
||||||
|
description: PostgreSQL connection string (e.g. postgresql://user:pass@host:5432/db)
|
||||||
|
required: true
|
||||||
13
templates/slack.yaml
Normal file
13
templates/slack.yaml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
name: slack
|
||||||
|
version: "1.0.0"
|
||||||
|
description: Slack MCP server for reading channels, messages, and user info
|
||||||
|
packageName: "@anthropic/slack-mcp"
|
||||||
|
transport: STDIO
|
||||||
|
repositoryUrl: https://github.com/modelcontextprotocol/servers/tree/main/src/slack
|
||||||
|
healthCheck:
|
||||||
|
tool: list_channels
|
||||||
|
arguments: {}
|
||||||
|
env:
|
||||||
|
- name: SLACK_BOT_TOKEN
|
||||||
|
description: Slack bot token (xoxb-...)
|
||||||
|
required: true
|
||||||
6
templates/terraform.yaml
Normal file
6
templates/terraform.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
name: terraform
|
||||||
|
version: "1.0.0"
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user