Compare commits

..

8 Commits

Author SHA1 Message Date
Michal
4e3d896ef6 fix: proper error handling and --force flag for create commands
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Add global error handler: clean messages instead of stack traces
- Add --force flag to create server/secret/project: updates on 409 conflict
- Strip null values and template-only fields from --from-template payload
- Add tests: 409 handling, --force update, null-stripping from templates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:06:33 +00:00
0823e965bf Merge pull request 'feat: MCP healthcheck probes + new templates' (#9) from feat/healthcheck-probes into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-22 22:50:10 +00:00
Michal
c97219f85e feat: add MCP healthcheck probes and new templates (grafana, home-assistant, node-red)
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Add healthCheck spec to templates and servers (tool, arguments, interval, timeout, failureThreshold)
- Add healthStatus, lastHealthCheck, events fields to instances
- Create grafana, home-assistant, node-red templates with healthcheck probes
- Add healthcheck probes to existing templates (github, slack, postgres, jira)
- Show HEALTH column in `get instances` and Events section in `describe instance`
- Display healthCheck details in `describe server` and `describe template`
- Schema + storage + display only; actual probe runner is future work

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:48:59 +00:00
93adcd4be7 Merge pull request 'feat: add MCP server templates and deployment infrastructure' (#8) from feat/mcp-templates into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-22 22:25:02 +00:00
Michal
d58e6e153f feat: add MCP server templates and deployment infrastructure
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
Introduce a Helm-chart-like template system for MCP servers. Templates are
YAML files in templates/ that get seeded into the DB on startup. Users can
browse them with `mcpctl get templates`, inspect with `mcpctl describe
template`, and instantiate with `mcpctl create server --from-template=`.

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:24:35 +00:00
Michal
1e8847bb63 fix: remove unused variables from profile cleanup
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:43:32 +00:00
Michal
2a0deaa225 fix: unused deps parameter in project command
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:42:16 +00:00
4eef6e38a2 Merge pull request 'feat: replace profiles with kubernetes-style secrets' (#7) from feat/replace-profiles-with-secrets into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-22 18:41:44 +00:00
58 changed files with 1628 additions and 381 deletions

2
.gitignore vendored
View File

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

398
deploy.sh Executable file
View File

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

View File

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

View File

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

15
deploy/mcplocal.service Normal file
View File

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

26
installlocal.sh Executable file
View File

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

View File

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

View File

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

6
pnpm-lock.yaml generated
View File

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

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

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

View File

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

View File

@@ -4,6 +4,14 @@ import yaml from 'js-yaml';
import { z } from 'zod';
import type { ApiClient } from '../api-client.js';
const HealthCheckSchema = z.object({
tool: z.string().min(1),
arguments: z.record(z.unknown()).default({}),
intervalSeconds: z.number().int().min(5).max(3600).default(60),
timeoutSeconds: z.number().int().min(1).max(120).default(10),
failureThreshold: z.number().int().min(1).max(20).default(3),
});
const ServerEnvEntrySchema = z.object({
name: z.string().min(1),
value: z.string().optional(),
@@ -24,6 +32,7 @@ const ServerSpecSchema = z.object({
containerPort: z.number().int().min(1).max(65535).optional(),
replicas: z.number().int().min(0).max(10).default(1),
env: z.array(ServerEnvEntrySchema).default([]),
healthCheck: HealthCheckSchema.optional(),
});
const SecretSpecSchema = z.object({
@@ -31,6 +40,29 @@ const SecretSpecSchema = z.object({
data: z.record(z.string()).default({}),
});
const TemplateEnvEntrySchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
required: z.boolean().optional(),
defaultValue: z.string().optional(),
});
const TemplateSpecSchema = z.object({
name: z.string().min(1),
version: z.string().default('1.0.0'),
description: z.string().default(''),
packageName: z.string().optional(),
dockerImage: z.string().optional(),
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
repositoryUrl: z.string().optional(),
externalUrl: z.string().optional(),
command: z.array(z.string()).optional(),
containerPort: z.number().int().min(1).max(65535).optional(),
replicas: z.number().int().min(0).max(10).default(1),
env: z.array(TemplateEnvEntrySchema).default([]),
healthCheck: HealthCheckSchema.optional(),
});
const ProjectSpecSchema = z.object({
name: z.string().min(1),
description: z.string().default(''),
@@ -40,6 +72,7 @@ const ApplyConfigSchema = z.object({
servers: z.array(ServerSpecSchema).default([]),
secrets: z.array(SecretSpecSchema).default([]),
projects: z.array(ProjectSpecSchema).default([]),
templates: z.array(TemplateSpecSchema).default([]),
});
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.secrets.length > 0) log(` ${config.secrets.length} secret(s)`);
if (config.projects.length > 0) log(` ${config.projects.length} project(s)`);
if (config.templates.length > 0) log(` ${config.templates.length} template(s)`);
return;
}
@@ -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}`);
}
}
// 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> {

View File

@@ -1,5 +1,5 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
import { type ApiClient, ApiError } from '../api-client.js';
export interface CreateCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
@@ -61,33 +61,107 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
cmd.command('server')
.description('Create an MCP server definition')
.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('--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('--external-url <url>', 'External endpoint URL')
.option('--command <arg>', 'Command argument (repeat for multiple)', collect, [])
.option('--container-port <port>', 'Container port number')
.option('--replicas <count>', 'Number of replicas', '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('--from-template <name>', 'Create from template (name or name:version)')
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => {
let base: Record<string, unknown> = {};
// If --from-template, fetch template and use as base
if (opts.fromTemplate) {
const tplRef = opts.fromTemplate as string;
const [tplName, tplVersion] = tplRef.includes(':')
? [tplRef.slice(0, tplRef.indexOf(':')), tplRef.slice(tplRef.indexOf(':') + 1)]
: [tplRef, undefined];
const templates = await client.get<Array<Record<string, unknown>>>(`/api/v1/templates?name=${encodeURIComponent(tplName)}`);
let template: Record<string, unknown> | undefined;
if (tplVersion) {
template = templates.find((t) => t.name === tplName && t.version === tplVersion);
if (!template) throw new Error(`Template '${tplName}' version '${tplVersion}' not found`);
} else {
template = templates.find((t) => t.name === tplName);
if (!template) throw new Error(`Template '${tplName}' not found`);
}
// Copy template fields as base (strip template-only, internal, and null fields)
const { id: _id, createdAt: _c, updatedAt: _u, version: _v, name: _n, ...tplFields } = template;
base = {};
for (const [k, v] of Object.entries(tplFields)) {
if (v !== null && v !== undefined) base[k] = v;
}
// Convert template env (description/required) to server env (name/value/valueFrom)
const tplEnv = template.env as Array<{ name: string; description?: string; required?: boolean; defaultValue?: string }> | undefined;
if (tplEnv && tplEnv.length > 0) {
base.env = tplEnv.map((e) => ({ name: e.name, value: e.defaultValue ?? '' }));
}
// Track template origin
base.templateName = tplName;
base.templateVersion = (template.version as string) ?? '1.0.0';
}
// Build body: template base → CLI overrides (last wins)
const body: Record<string, unknown> = {
...base,
name,
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.dockerImage) body.dockerImage = opts.dockerImage;
if (opts.repositoryUrl) body.repositoryUrl = opts.repositoryUrl;
if (opts.externalUrl) body.externalUrl = opts.externalUrl;
if (opts.command.length > 0) body.command = opts.command;
if (opts.containerPort) body.containerPort = parseInt(opts.containerPort, 10);
if (opts.env.length > 0) 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);
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 ---
@@ -95,13 +169,25 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.description('Create a secret')
.argument('<name>', 'Secret name (lowercase, hyphens allowed)')
.option('--data <entry>', 'Secret data KEY=value (repeat for multiple)', collect, [])
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => {
const data = parseEnvEntries(opts.data);
try {
const secret = await client.post<{ id: string; name: string }>('/api/v1/secrets', {
name,
data,
});
log(`secret '${secret.name}' created (id: ${secret.id})`);
} catch (err) {
if (err instanceof ApiError && err.status === 409 && opts.force) {
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/secrets')).find((s) => s.name === name);
if (!existing) throw err;
await client.put(`/api/v1/secrets/${existing.id}`, { data });
log(`secret '${name}' updated (id: ${existing.id})`);
} else {
throw err;
}
}
});
// --- create project ---
@@ -109,12 +195,24 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.description('Create a project')
.argument('<name>', 'Project name')
.option('-d, --description <text>', 'Project description', '')
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => {
try {
const project = await client.post<{ id: string; name: string }>('/api/v1/projects', {
name,
description: opts.description,
});
log(`project '${project.name}' created (id: ${project.id})`);
} catch (err) {
if (err instanceof ApiError && err.status === 409 && opts.force) {
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/projects')).find((p) => p.name === name);
if (!existing) throw err;
await client.put(`/api/v1/projects/${existing.id}`, { description: opts.description });
log(`project '${name}' updated (id: ${existing.id})`);
} else {
throw err;
}
}
});
return cmd;

View File

@@ -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('Metadata:');
lines.push(` ${pad('ID:', 12)}${server.id}`);
@@ -67,6 +80,16 @@ function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Recor
lines.push(`${pad('Container ID:')}${instance.containerId ?? '-'}`);
lines.push(`${pad('Port:')}${instance.port ?? '-'}`);
// Health section
const healthStatus = instance.healthStatus as string | null;
const lastHealthCheck = instance.lastHealthCheck as string | null;
if (healthStatus || lastHealthCheck) {
lines.push('');
lines.push('Health:');
lines.push(` ${pad('Status:', 16)}${healthStatus ?? 'unknown'}`);
if (lastHealthCheck) lines.push(` ${pad('Last Check:', 16)}${lastHealthCheck}`);
}
const metadata = instance.metadata as Record<string, unknown> | undefined;
if (metadata && Object.keys(metadata).length > 0) {
lines.push('');
@@ -88,6 +111,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(` ${pad('ID:', 12)}${instance.id}`);
if (instance.createdAt) lines.push(` ${pad('Created:', 12)}${instance.createdAt}`);
@@ -143,6 +179,66 @@ function formatSecretDetail(secret: Record<string, unknown>, showValues: boolean
return lines.join('\n');
}
function formatTemplateDetail(template: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`=== Template: ${template.name} ===`);
lines.push(`${pad('Name:')}${template.name}`);
lines.push(`${pad('Version:')}${template.version ?? '1.0.0'}`);
lines.push(`${pad('Transport:')}${template.transport ?? 'STDIO'}`);
lines.push(`${pad('Replicas:')}${template.replicas ?? 1}`);
if (template.dockerImage) lines.push(`${pad('Docker Image:')}${template.dockerImage}`);
if (template.packageName) lines.push(`${pad('Package:')}${template.packageName}`);
if (template.externalUrl) lines.push(`${pad('External URL:')}${template.externalUrl}`);
if (template.repositoryUrl) lines.push(`${pad('Repository:')}${template.repositoryUrl}`);
if (template.containerPort) lines.push(`${pad('Container Port:')}${template.containerPort}`);
if (template.description) lines.push(`${pad('Description:')}${template.description}`);
const command = template.command as string[] | null;
if (command && command.length > 0) {
lines.push('');
lines.push('Command:');
lines.push(` ${command.join(' ')}`);
}
const env = template.env as Array<{ name: string; description?: string; required?: boolean; defaultValue?: string }> | undefined;
if (env && env.length > 0) {
lines.push('');
lines.push('Environment Variables:');
const nameW = Math.max(6, ...env.map((e) => e.name.length)) + 2;
lines.push(` ${'NAME'.padEnd(nameW)}${'REQUIRED'.padEnd(10)}DESCRIPTION`);
for (const e of env) {
const req = e.required ? 'yes' : 'no';
const desc = e.description ?? '';
lines.push(` ${e.name.padEnd(nameW)}${req.padEnd(10)}${desc}`);
}
}
const hc = template.healthCheck as { tool: string; arguments?: Record<string, unknown>; intervalSeconds?: number; timeoutSeconds?: number; failureThreshold?: number } | null;
if (hc) {
lines.push('');
lines.push('Health Check:');
lines.push(` ${pad('Tool:', 22)}${hc.tool}`);
if (hc.arguments && Object.keys(hc.arguments).length > 0) {
lines.push(` ${pad('Arguments:', 22)}${JSON.stringify(hc.arguments)}`);
}
lines.push(` ${pad('Interval:', 22)}${hc.intervalSeconds ?? 60}s`);
lines.push(` ${pad('Timeout:', 22)}${hc.timeoutSeconds ?? 10}s`);
lines.push(` ${pad('Failure Threshold:', 22)}${hc.failureThreshold ?? 3}`);
}
lines.push('');
lines.push('Usage:');
lines.push(` mcpctl create server my-${template.name} --from-template=${template.name}`);
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${template.id}`);
if (template.createdAt) lines.push(` ${pad('Created:', 12)}${template.createdAt}`);
if (template.updatedAt) lines.push(` ${pad('Updated:', 12)}${template.updatedAt}`);
return lines.join('\n');
}
function formatGenericDetail(obj: Record<string, unknown>): string {
const lines: string[] = [];
for (const [key, value] of Object.entries(obj)) {
@@ -216,6 +312,9 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
case 'secrets':
deps.log(formatSecretDetail(item, opts.showValues === true));
break;
case 'templates':
deps.log(formatTemplateDetail(item));
break;
case 'projects':
deps.log(formatProjectDetail(item));
break;

View File

@@ -30,12 +30,22 @@ interface SecretRow {
data: Record<string, string>;
}
interface TemplateRow {
id: string;
name: string;
version: string;
transport: string;
packageName: string | null;
description: string;
}
interface InstanceRow {
id: string;
serverId: string;
status: string;
containerId: string | null;
port: number | null;
healthStatus: string | null;
}
const serverColumns: Column<ServerRow>[] = [
@@ -59,8 +69,17 @@ const secretColumns: Column<SecretRow>[] = [
{ header: 'ID', key: 'id' },
];
const templateColumns: Column<TemplateRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'VERSION', key: 'version', width: 10 },
{ header: 'TRANSPORT', key: 'transport', width: 16 },
{ header: 'PACKAGE', key: (r) => r.packageName ?? '-' },
{ header: 'DESCRIPTION', key: 'description', width: 50 },
];
const instanceColumns: Column<InstanceRow>[] = [
{ header: 'STATUS', key: 'status', width: 10 },
{ header: 'HEALTH', key: (r) => r.healthStatus ?? '-', width: 10 },
{ header: 'SERVER ID', key: 'serverId' },
{ 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 },
@@ -75,6 +94,8 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
return projectColumns as unknown as Column<Record<string, unknown>>[];
case 'secrets':
return secretColumns as unknown as Column<Record<string, unknown>>[];
case 'templates':
return templateColumns as unknown as Column<Record<string, unknown>>[];
case 'instances':
return instanceColumns as unknown as Column<Record<string, unknown>>[];
default:

View File

@@ -6,7 +6,7 @@ export interface ProjectCommandDeps {
log: (...args: unknown[]) => void;
}
export function createProjectCommand(deps: ProjectCommandDeps): Command {
export function createProjectCommand(_deps: ProjectCommandDeps): Command {
const cmd = new Command('project')
.alias('proj')
.description('Project-specific actions (create with "create project", list with "get projects")');

View File

@@ -9,6 +9,8 @@ export const RESOURCE_ALIASES: Record<string, string> = {
inst: 'instances',
secret: 'secrets',
sec: 'secrets',
template: 'templates',
tpl: 'templates',
};
export function resolveResource(name: string): string {

View File

@@ -14,7 +14,7 @@ import { createClaudeCommand } from './commands/claude.js';
import { createProjectCommand } from './commands/project.js';
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
import { ApiClient } from './api-client.js';
import { ApiClient, ApiError } from './api-client.js';
import { loadConfig } from './config/index.js';
import { loadCredentials } from './auth/index.js';
import { resolveNameOrId } from './commands/shared.js';
@@ -50,6 +50,10 @@ export function createProgram(): Command {
const fetchResource = async (resource: string, nameOrId?: string): Promise<unknown[]> => {
if (nameOrId) {
// Glob pattern — use query param filtering
if (nameOrId.includes('*')) {
return client.get<unknown[]>(`/api/v1/${resource}?name=${encodeURIComponent(nameOrId)}`);
}
let id: string;
try {
id = await resolveNameOrId(client, resource, nameOrId);
@@ -139,5 +143,21 @@ const isDirectRun =
import.meta.url === `file://${process.argv[1]}`;
if (isDirectRun) {
createProgram().parseAsync(process.argv);
createProgram().parseAsync(process.argv).catch((err: unknown) => {
if (err instanceof ApiError) {
let msg: string;
try {
const parsed = JSON.parse(err.body) as { error?: string; message?: string };
msg = parsed.error ?? parsed.message ?? err.body;
} catch {
msg = err.body;
}
console.error(`Error: ${msg}`);
} else if (err instanceof Error) {
console.error(`Error: ${err.message}`);
} else {
console.error(`Error: ${String(err)}`);
}
process.exit(1);
});
}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
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 {
return {
@@ -73,6 +73,59 @@ describe('create command', () => {
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', () => {
@@ -98,6 +151,21 @@ describe('create command', () => {
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', () => {
@@ -119,5 +187,14 @@ describe('create command', () => {
description: '',
});
});
it('updates existing project on 409 with --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Project already exists"}'));
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'proj-1', name: 'my-proj' }] as never);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'my-proj', '-d', 'updated', '--force'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/projects/proj-1', { description: 'updated' });
expect(output.join('\n')).toContain("project 'my-proj' updated");
});
});
});

View File

@@ -62,10 +62,14 @@ model McpServer {
containerPort Int?
replicas Int @default(1)
env Json @default("[]")
healthCheck Json?
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
templateName String?
templateVersion String?
instances McpInstance[]
@@index([name])
@@ -77,6 +81,29 @@ enum Transport {
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 ──
model Secret {
@@ -116,6 +143,9 @@ model McpInstance {
status InstanceStatus @default(STOPPED)
port Int?
metadata Json @default("{}")
healthStatus String?
lastHealthCheck DateTime?
events Json @default("[]")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

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

View File

@@ -1,94 +1,77 @@
import { PrismaClient } from '@prisma/client';
import { PrismaClient, Prisma } from '@prisma/client';
export interface SeedServer {
export interface TemplateEnvEntry {
name: string;
description: string;
packageName: string;
transport: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP';
repositoryUrl: string;
env: Array<{
name: string;
value?: string;
valueFrom?: { secretRef: { name: string; key: string } };
}>;
description?: string;
required?: boolean;
defaultValue?: string;
}
export const defaultServers: SeedServer[] = [
{
name: 'slack',
description: 'Slack MCP server for reading channels, messages, and user info',
packageName: '@anthropic/slack-mcp',
transport: 'STDIO',
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/slack',
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 interface HealthCheckSpec {
tool: string;
arguments?: Record<string, unknown>;
intervalSeconds?: number;
timeoutSeconds?: number;
failureThreshold?: number;
}
export async function seedMcpServers(
export interface SeedTemplate {
name: string;
version: string;
description: string;
packageName?: string;
dockerImage?: string;
transport: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP';
repositoryUrl?: string;
externalUrl?: string;
command?: string[];
containerPort?: number;
replicas?: number;
env?: TemplateEnvEntry[];
healthCheck?: HealthCheckSpec;
}
export async function seedTemplates(
prisma: PrismaClient,
servers: SeedServer[] = defaultServers,
templates: SeedTemplate[],
): Promise<number> {
let created = 0;
let upserted = 0;
for (const server of servers) {
await prisma.mcpServer.upsert({
where: { name: server.name },
for (const tpl of templates) {
await prisma.mcpTemplate.upsert({
where: { name: tpl.name },
update: {
description: server.description,
packageName: server.packageName,
transport: server.transport,
repositoryUrl: server.repositoryUrl,
env: server.env,
version: tpl.version,
description: tpl.description,
packageName: tpl.packageName ?? null,
dockerImage: tpl.dockerImage ?? null,
transport: tpl.transport,
repositoryUrl: tpl.repositoryUrl ?? null,
externalUrl: tpl.externalUrl ?? null,
command: (tpl.command ?? Prisma.JsonNull) as Prisma.InputJsonValue,
containerPort: tpl.containerPort ?? null,
replicas: tpl.replicas ?? 1,
env: (tpl.env ?? []) as unknown as Prisma.InputJsonValue,
healthCheck: (tpl.healthCheck ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue,
},
create: {
name: server.name,
description: server.description,
packageName: server.packageName,
transport: server.transport,
repositoryUrl: server.repositoryUrl,
env: server.env,
name: tpl.name,
version: tpl.version,
description: tpl.description,
packageName: tpl.packageName ?? null,
dockerImage: tpl.dockerImage ?? null,
transport: tpl.transport,
repositoryUrl: tpl.repositoryUrl ?? null,
externalUrl: tpl.externalUrl ?? null,
command: (tpl.command ?? Prisma.JsonNull) as Prisma.InputJsonValue,
containerPort: tpl.containerPort ?? null,
replicas: tpl.replicas ?? 1,
env: (tpl.env ?? []) as unknown as Prisma.InputJsonValue,
healthCheck: (tpl.healthCheck ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue,
},
});
created++;
upserted++;
}
return created;
}
// CLI entry point
if (import.meta.url === `file://${process.argv[1]}`) {
const prisma = new PrismaClient();
seedMcpServers(prisma)
.then((count) => {
console.log(`Seeded ${count} MCP servers`);
return prisma.$disconnect();
})
.catch((e) => {
console.error(e);
return prisma.$disconnect().then(() => process.exit(1));
});
return upserted;
}

View File

@@ -53,5 +53,6 @@ export async function clearAllTables(client: PrismaClient): Promise<void> {
await client.session.deleteMany();
await client.project.deleteMany();
await client.mcpServer.deleteMany();
await client.mcpTemplate.deleteMany();
await client.user.deleteMany();
}

View File

@@ -1,7 +1,8 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js';
import { seedMcpServers, defaultServers } from '../src/seed/index.js';
import { seedTemplates } from '../src/seed/index.js';
import type { SeedTemplate } from '../src/seed/index.js';
let prisma: PrismaClient;
@@ -17,53 +18,69 @@ beforeEach(async () => {
await clearAllTables(prisma);
});
describe('seedMcpServers', () => {
it('seeds all default servers', async () => {
const count = await seedMcpServers(prisma);
expect(count).toBe(defaultServers.length);
const 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 = [
const testTemplates: SeedTemplate[] = [
{
name: 'custom-server',
description: 'Custom test server',
packageName: '@test/custom',
transport: 'STDIO' as const,
repositoryUrl: 'https://example.com',
name: 'github',
version: '1.0.0',
description: 'GitHub MCP server',
packageName: '@anthropic/github-mcp',
transport: 'STDIO',
env: [{ name: 'GITHUB_TOKEN', description: 'Personal access token', required: true }],
},
{
name: 'slack',
version: '1.0.0',
description: 'Slack MCP server',
packageName: '@anthropic/slack-mcp',
transport: 'STDIO',
env: [],
},
];
const 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);
const servers = await prisma.mcpServer.findMany();
expect(servers).toHaveLength(1);
expect(servers[0].name).toBe('custom-server');
const templates = await prisma.mcpTemplate.findMany();
expect(templates).toHaveLength(1);
expect(templates[0].name).toBe('custom-template');
});
});

View File

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

View File

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

View File

@@ -5,3 +5,5 @@ export type { IProjectRepository } from './project.repository.js';
export { ProjectRepository } from './project.repository.js';
export { McpInstanceRepository } from './mcp-instance.repository.js';
export { AuditLogRepository } from './audit-log.repository.js';
export type { ITemplateRepository } from './template.repository.js';
export { TemplateRepository } from './template.repository.js';

View File

@@ -16,7 +16,7 @@ export interface IMcpInstanceRepository {
findById(id: string): Promise<McpInstance | null>;
findByContainerId(containerId: string): Promise<McpInstance | null>;
create(data: { serverId: string; containerId?: string; status?: InstanceStatus; port?: number; metadata?: Record<string, unknown> }): Promise<McpInstance>;
updateStatus(id: string, status: InstanceStatus, fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown> }): Promise<McpInstance>;
updateStatus(id: string, status: InstanceStatus, fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown>; healthStatus?: string; lastHealthCheck?: Date; events?: unknown[] }): Promise<McpInstance>;
delete(id: string): Promise<void>;
}

View File

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

View File

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

View File

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

View File

@@ -13,3 +13,4 @@ export { registerAuthRoutes } from './auth.js';
export type { AuthRouteDeps } from './auth.js';
export { registerMcpProxyRoutes } from './mcp-proxy.js';
export type { McpProxyRouteDeps } from './mcp-proxy.js';
export { registerTemplateRoutes } from './templates.js';

View File

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

View File

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

View File

@@ -114,7 +114,7 @@ export class RestoreService {
if (server.packageName) createData.packageName = server.packageName;
if (server.dockerImage) createData.dockerImage = server.dockerImage;
if (server.repositoryUrl) createData.repositoryUrl = server.repositoryUrl;
const created = await this.serverRepo.create(createData);
await this.serverRepo.create(createData);
result.serversCreated++;
} catch (err) {
result.errors.push(`Failed to restore server "${server.name}": ${err instanceof Error ? err.message : String(err)}`);

View File

@@ -24,3 +24,4 @@ export { AuthService, AuthenticationError } from './auth.service.js';
export type { LoginResult } from './auth.service.js';
export { McpProxyService } from './mcp-proxy-service.js';
export type { McpProxyRequest, McpProxyResponse } from './mcp-proxy-service.js';
export { TemplateService } from './template.service.js';

View File

@@ -1,13 +1,11 @@
import type { Project } from '@prisma/client';
import type { IProjectRepository } from '../repositories/project.repository.js';
import type { IMcpServerRepository } from '../repositories/interfaces.js';
import { CreateProjectSchema, UpdateProjectSchema } from '../validation/project.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';
export class ProjectService {
constructor(
private readonly projectRepo: IProjectRepository,
private readonly serverRepo: IMcpServerRepository,
) {}
async list(ownerId?: string): Promise<Project[]> {

View File

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

View File

@@ -1,4 +1,5 @@
import { z } from 'zod';
import { HealthCheckSchema } from './template.schema.js';
const SecretRefSchema = z.object({
name: z.string().min(1),
@@ -30,6 +31,7 @@ export const CreateMcpServerSchema = z.object({
containerPort: z.number().int().min(1).max(65535).optional(),
replicas: z.number().int().min(0).max(10).default(1),
env: z.array(ServerEnvEntrySchema).default([]),
healthCheck: HealthCheckSchema.optional(),
});
export const UpdateMcpServerSchema = z.object({
@@ -43,6 +45,7 @@ export const UpdateMcpServerSchema = z.object({
containerPort: z.number().int().min(1).max(65535).nullable().optional(),
replicas: z.number().int().min(0).max(10).optional(),
env: z.array(ServerEnvEntrySchema).optional(),
healthCheck: HealthCheckSchema.nullable().optional(),
});
export type CreateMcpServerInput = z.infer<typeof CreateMcpServerSchema>;

View File

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

View File

@@ -2,8 +2,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ProjectService } from '../src/services/project.service.js';
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import type { IProjectRepository } from '../src/repositories/project.repository.js';
import type { IMcpServerRepository } from '../src/repositories/interfaces.js';
function mockProjectRepo(): IProjectRepository {
return {
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', () => {
let projectRepo: ReturnType<typeof mockProjectRepo>;
let serverRepo: ReturnType<typeof mockServerRepo>;
let service: ProjectService;
beforeEach(() => {
projectRepo = mockProjectRepo();
serverRepo = mockServerRepo();
service = new ProjectService(projectRepo, serverRepo);
service = new ProjectService(projectRepo);
});
describe('create', () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -141,7 +141,10 @@ export async function main(argv: string[] = process.argv): Promise<MainResult> {
}
// 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) {
main().catch((err) => {
process.stderr.write(`Fatal: ${err}\n`);

View File

@@ -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
View File

@@ -0,0 +1,5 @@
POSTGRES_USER=mcpctl
POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_DB=mcpctl
MCPD_PORT=3100
MCPD_LOG_LEVEL=info

54
stack/docker-compose.yml Normal file
View File

@@ -0,0 +1,54 @@
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}
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:
driver: bridge
internal: true
volumes:
mcpctl-pgdata:

View 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
View 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
View 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

View 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
View 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
View 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
View 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
View 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
View 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