From d58e6e153f23cc1a0921301c50e21b9be3b879a4 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 22 Feb 2026 22:24:35 +0000 Subject: [PATCH] feat: add MCP server templates and deployment infrastructure 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 --- .gitignore | 2 + deploy.sh | 398 ++++++++++++++++++ deploy/Dockerfile.mcpd | 3 + deploy/entrypoint.sh | 4 +- deploy/mcplocal.service | 15 + installlocal.sh | 26 ++ nfpm.yaml | 8 + package.json | 6 +- pnpm-lock.yaml | 6 + scripts/build-mcpd.sh | 32 ++ scripts/build-rpm.sh | 5 +- src/cli/src/commands/apply.ts | 40 ++ src/cli/src/commands/create.ts | 72 +++- src/cli/src/commands/describe.ts | 50 +++ src/cli/src/commands/get.ts | 19 + src/cli/src/commands/shared.ts | 2 + src/cli/src/index.ts | 4 + src/db/prisma/schema.prisma | 25 ++ src/db/src/index.ts | 5 +- src/db/src/seed/index.ts | 128 +++--- src/db/tests/helpers.ts | 1 + src/db/tests/seed.test.ts | 79 ++-- src/mcpd/package.json | 2 + src/mcpd/src/main.ts | 34 +- src/mcpd/src/repositories/index.ts | 2 + .../src/repositories/template.repository.ts | 80 ++++ src/mcpd/src/routes/index.ts | 1 + src/mcpd/src/routes/templates.ts | 31 ++ src/mcpd/src/seed-runner.ts | 39 +- src/mcpd/src/services/index.ts | 1 + src/mcpd/src/services/template.service.ts | 53 +++ src/mcpd/src/validation/template.schema.ts | 28 ++ src/mcpd/tsconfig.json | 2 +- src/mcplocal/src/http/index.ts | 1 + src/mcplocal/src/http/mcp-endpoint.ts | 100 +++++ src/mcplocal/src/http/server.ts | 4 + src/mcplocal/src/main.ts | 5 +- src/shared/tests/profiles.test.ts | 207 --------- stack/.env.example | 5 + stack/docker-compose.yml | 54 +++ templates/filesystem.yaml | 6 + templates/github.yaml | 10 + templates/jira.yaml | 16 + templates/postgres.yaml | 10 + templates/slack.yaml | 10 + templates/terraform.yaml | 6 + 46 files changed, 1299 insertions(+), 338 deletions(-) create mode 100755 deploy.sh create mode 100644 deploy/mcplocal.service create mode 100755 installlocal.sh create mode 100755 scripts/build-mcpd.sh create mode 100644 src/mcpd/src/repositories/template.repository.ts create mode 100644 src/mcpd/src/routes/templates.ts create mode 100644 src/mcpd/src/services/template.service.ts create mode 100644 src/mcpd/src/validation/template.schema.ts create mode 100644 src/mcplocal/src/http/mcp-endpoint.ts delete mode 100644 src/shared/tests/profiles.test.ts create mode 100644 stack/.env.example create mode 100644 stack/docker-compose.yml create mode 100644 templates/filesystem.yaml create mode 100644 templates/github.yaml create mode 100644 templates/jira.yaml create mode 100644 templates/postgres.yaml create mode 100644 templates/slack.yaml create mode 100644 templates/terraform.yaml diff --git a/.gitignore b/.gitignore index fad7abb..1fbfc64 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ dist/ .env .env.local .env.*.local +stack/.env +.portainer_password # Logs logs/ diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..a20f77c --- /dev/null +++ b/deploy.sh @@ -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": false + }') + + 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 "$@" diff --git a/deploy/Dockerfile.mcpd b/deploy/Dockerfile.mcpd index 6139eae..32389d0 100644 --- a/deploy/Dockerfile.mcpd +++ b/deploy/Dockerfile.mcpd @@ -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 diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh index 74f314c..976731f 100755 --- a/deploy/entrypoint.sh +++ b/deploy/entrypoint.sh @@ -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 diff --git a/deploy/mcplocal.service b/deploy/mcplocal.service new file mode 100644 index 0000000..fc26b40 --- /dev/null +++ b/deploy/mcplocal.service @@ -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 diff --git a/installlocal.sh b/installlocal.sh new file mode 100755 index 0000000..3c07dd7 --- /dev/null +++ b/installlocal.sh @@ -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" diff --git a/nfpm.yaml b/nfpm.yaml index 50f48d4..430c0d4 100644 --- a/nfpm.yaml +++ b/nfpm.yaml @@ -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: diff --git a/package.json b/package.json index 3fcea89..5ca5cb0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 059eff9..7a46d64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/build-mcpd.sh b/scripts/build-mcpd.sh new file mode 100755 index 0000000..39596df --- /dev/null +++ b/scripts/build-mcpd.sh @@ -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" diff --git a/scripts/build-rpm.sh b/scripts/build-rpm.sh index d32eca6..87acc43 100755 --- a/scripts/build-rpm.sh +++ b/scripts/build-rpm.sh @@ -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/ diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts index 55506a9..a47a168 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -31,6 +31,28 @@ 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([]), +}); + const ProjectSpecSchema = z.object({ name: z.string().min(1), description: z.string().default(''), @@ -40,6 +62,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; @@ -64,6 +87,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 +161,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 { diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index 7c0d768..4344c8e 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -61,30 +61,88 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { cmd.command('server') .description('Create an MCP server definition') .argument('', 'Server name (lowercase, hyphens allowed)') - .option('-d, --description ', 'Server description', '') + .option('-d, --description ', 'Server description') .option('--package-name ', 'NPM package name') .option('--docker-image ', 'Docker image') - .option('--transport ', 'Transport type (STDIO, SSE, STREAMABLE_HTTP)', 'STDIO') + .option('--transport ', 'Transport type (STDIO, SSE, STREAMABLE_HTTP)') .option('--repository-url ', 'Source repository URL') .option('--external-url ', 'External endpoint URL') .option('--command ', 'Command argument (repeat for multiple)', collect, []) .option('--container-port ', 'Container port number') - .option('--replicas ', 'Number of replicas', '1') + .option('--replicas ', 'Number of replicas') .option('--env ', 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, repeat for multiple)', collect, []) + .option('--from-template ', 'Create from template (name or name:version)') .action(async (name: string, opts) => { + let base: Record = {}; + + // 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>>(`/api/v1/templates?name=${encodeURIComponent(tplName)}`); + let template: Record | 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 fields) + const { id: _id, createdAt: _c, updatedAt: _u, ...tplFields } = template; + base = { ...tplFields }; + + // 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 = { + ...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; + } const server = await client.post<{ id: string; name: string }>('/api/v1/servers', body); log(`server '${server.name}' created (id: ${server.id})`); diff --git a/src/cli/src/commands/describe.ts b/src/cli/src/commands/describe.ts index 7126c8b..4250b5f 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -143,6 +143,53 @@ function formatSecretDetail(secret: Record, showValues: boolean return lines.join('\n'); } +function formatTemplateDetail(template: Record): 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}`); + } + } + + 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 { const lines: string[] = []; for (const [key, value] of Object.entries(obj)) { @@ -216,6 +263,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; diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index af9ce7d..ba0ec29 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -30,6 +30,15 @@ interface SecretRow { data: Record; } +interface TemplateRow { + id: string; + name: string; + version: string; + transport: string; + packageName: string | null; + description: string; +} + interface InstanceRow { id: string; serverId: string; @@ -59,6 +68,14 @@ const secretColumns: Column[] = [ { header: 'ID', key: 'id' }, ]; +const templateColumns: Column[] = [ + { 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[] = [ { header: 'STATUS', key: 'status', width: 10 }, { header: 'SERVER ID', key: 'serverId' }, @@ -75,6 +92,8 @@ function getColumnsForResource(resource: string): Column return projectColumns as unknown as Column>[]; case 'secrets': return secretColumns as unknown as Column>[]; + case 'templates': + return templateColumns as unknown as Column>[]; case 'instances': return instanceColumns as unknown as Column>[]; default: diff --git a/src/cli/src/commands/shared.ts b/src/cli/src/commands/shared.ts index 1efcab9..baa75a0 100644 --- a/src/cli/src/commands/shared.ts +++ b/src/cli/src/commands/shared.ts @@ -9,6 +9,8 @@ export const RESOURCE_ALIASES: Record = { inst: 'instances', secret: 'secrets', sec: 'secrets', + template: 'templates', + tpl: 'templates', }; export function resolveResource(name: string): string { diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 5c83977..dd8afec 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -50,6 +50,10 @@ export function createProgram(): Command { const fetchResource = async (resource: string, nameOrId?: string): Promise => { if (nameOrId) { + // Glob pattern — use query param filtering + if (nameOrId.includes('*')) { + return client.get(`/api/v1/${resource}?name=${encodeURIComponent(nameOrId)}`); + } let id: string; try { id = await resolveNameOrId(client, resource, nameOrId); diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index 6f64821..bd9da5e 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -66,6 +66,9 @@ model McpServer { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + templateName String? + templateVersion String? + instances McpInstance[] @@index([name]) @@ -77,6 +80,28 @@ 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("[]") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([name]) +} + // ── Secrets ── model Secret { diff --git a/src/db/src/index.ts b/src/db/src/index.ts index 796e9e3..c991d1f 100644 --- a/src/db/src/index.ts +++ b/src/db/src/index.ts @@ -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 } from './seed/index.js'; diff --git a/src/db/src/seed/index.ts b/src/db/src/seed/index.ts index 41d4289..2e6a872 100644 --- a/src/db/src/seed/index.ts +++ b/src/db/src/seed/index.ts @@ -1,94 +1,66 @@ -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 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[]; +} -export async function seedMcpServers( +export async function seedTemplates( prisma: PrismaClient, - servers: SeedServer[] = defaultServers, + templates: SeedTemplate[], ): Promise { - 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, }, 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, }, }); - 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; } diff --git a/src/db/tests/helpers.ts b/src/db/tests/helpers.ts index ae3a5b3..951e7f7 100644 --- a/src/db/tests/helpers.ts +++ b/src/db/tests/helpers.ts @@ -53,5 +53,6 @@ export async function clearAllTables(client: PrismaClient): Promise { await client.session.deleteMany(); await client.project.deleteMany(); await client.mcpServer.deleteMany(); + await client.mcpTemplate.deleteMany(); await client.user.deleteMany(); } diff --git a/src/db/tests/seed.test.ts b/src/db/tests/seed.test.ts index 4190d1f..8879b56 100644 --- a/src/db/tests/seed.test.ts +++ b/src/db/tests/seed.test.ts @@ -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 testTemplates: SeedTemplate[] = [ + { + name: 'github', + version: '1.0.0', + description: 'GitHub MCP server', + packageName: '@anthropic/github-mcp', + transport: 'STDIO', + env: [{ name: 'GITHUB_TOKEN', description: 'Personal access token', required: true }], + }, + { + name: 'slack', + version: '1.0.0', + description: 'Slack MCP server', + packageName: '@anthropic/slack-mcp', + transport: 'STDIO', + env: [], + }, +]; - const servers = await prisma.mcpServer.findMany({ orderBy: { name: 'asc' } }); - expect(servers).toHaveLength(defaultServers.length); +describe('seedTemplates', () => { + it('seeds templates', async () => { + const count = await seedTemplates(prisma, testTemplates); + expect(count).toBe(2); - const names = servers.map((s) => s.name); - expect(names).toContain('slack'); - expect(names).toContain('github'); - expect(names).toContain('jira'); - expect(names).toContain('terraform'); + const templates = await prisma.mcpTemplate.findMany({ orderBy: { name: 'asc' } }); + expect(templates).toHaveLength(2); + expect(templates.map((t) => t.name)).toEqual(['github', 'slack']); }); it('is idempotent (upsert)', async () => { - await seedMcpServers(prisma); - const count = await seedMcpServers(prisma); - expect(count).toBe(defaultServers.length); + await seedTemplates(prisma, testTemplates); + const count = await seedTemplates(prisma, testTemplates); + expect(count).toBe(2); - const servers = await prisma.mcpServer.findMany(); - expect(servers).toHaveLength(defaultServers.length); + const templates = await prisma.mcpTemplate.findMany(); + expect(templates).toHaveLength(2); }); it('seeds 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([]); + await seedTemplates(prisma, testTemplates); + const github = await prisma.mcpTemplate.findUnique({ where: { name: 'github' } }); + const env = github!.env as Array<{ name: string; description?: string; required?: boolean }>; + expect(env).toHaveLength(1); + expect(env[0].name).toBe('GITHUB_TOKEN'); + expect(env[0].required).toBe(true); }); - it('accepts custom server list', async () => { - const custom = [ + it('accepts custom template list', async () => { + const custom: SeedTemplate[] = [ { - name: 'custom-server', - description: 'Custom test server', + name: 'custom-template', + version: '2.0.0', + description: 'Custom test template', packageName: '@test/custom', - transport: 'STDIO' as const, - repositoryUrl: 'https://example.com', + transport: 'STDIO', env: [], }, ]; - const count = await seedMcpServers(prisma, custom); + const count = await seedTemplates(prisma, custom); expect(count).toBe(1); - const servers = await prisma.mcpServer.findMany(); - expect(servers).toHaveLength(1); - expect(servers[0].name).toBe('custom-server'); + const templates = await prisma.mcpTemplate.findMany(); + expect(templates).toHaveLength(1); + expect(templates[0].name).toBe('custom-template'); }); }); diff --git a/src/mcpd/package.json b/src/mcpd/package.json index 73c8e37..5d75382 100644 --- a/src/mcpd/package.json +++ b/src/mcpd/package.json @@ -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" } } diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 4a28f94..7306d7a 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -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 { @@ -45,8 +52,26 @@ async function main(): Promise { }); 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 ?? '', + }; + }); + await seedTemplates(prisma, templates); // Repositories const serverRepo = new McpServerRepository(prisma); @@ -54,6 +79,7 @@ async function main(): Promise { 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(); @@ -70,6 +96,7 @@ async function main(): Promise { 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 +115,7 @@ async function main(): Promise { // Routes registerMcpServerRoutes(app, serverService, instanceService); + registerTemplateRoutes(app, templateService); registerSecretRoutes(app, secretService); registerInstanceRoutes(app, instanceService); registerProjectRoutes(app, projectService); diff --git a/src/mcpd/src/repositories/index.ts b/src/mcpd/src/repositories/index.ts index 5f72c38..4d09960 100644 --- a/src/mcpd/src/repositories/index.ts +++ b/src/mcpd/src/repositories/index.ts @@ -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'; diff --git a/src/mcpd/src/repositories/template.repository.ts b/src/mcpd/src/repositories/template.repository.ts new file mode 100644 index 0000000..be15015 --- /dev/null +++ b/src/mcpd/src/repositories/template.repository.ts @@ -0,0 +1,80 @@ +import { type PrismaClient, type McpTemplate, Prisma } from '@prisma/client'; +import type { CreateTemplateInput, UpdateTemplateInput } from '../validation/template.schema.js'; + +export interface ITemplateRepository { + findAll(): Promise; + findById(id: string): Promise; + findByName(name: string): Promise; + search(pattern: string): Promise; + create(data: CreateTemplateInput): Promise; + update(id: string, data: UpdateTemplateInput): Promise; + delete(id: string): Promise; +} + +export class TemplateRepository implements ITemplateRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(): Promise { + return this.prisma.mcpTemplate.findMany({ orderBy: { name: 'asc' } }); + } + + async findById(id: string): Promise { + return this.prisma.mcpTemplate.findUnique({ where: { id } }); + } + + async findByName(name: string): Promise { + return this.prisma.mcpTemplate.findUnique({ where: { name } }); + } + + async search(pattern: string): Promise { + // 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 { + 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, + }, + }); + } + + async update(id: string, data: UpdateTemplateInput): Promise { + const updateData: Record = {}; + 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; + + return this.prisma.mcpTemplate.update({ + where: { id }, + data: updateData, + }); + } + + async delete(id: string): Promise { + await this.prisma.mcpTemplate.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/routes/index.ts b/src/mcpd/src/routes/index.ts index fe7ba0e..c7bf00a 100644 --- a/src/mcpd/src/routes/index.ts +++ b/src/mcpd/src/routes/index.ts @@ -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'; diff --git a/src/mcpd/src/routes/templates.ts b/src/mcpd/src/routes/templates.ts new file mode 100644 index 0000000..b51591f --- /dev/null +++ b/src/mcpd/src/routes/templates.ts @@ -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); + }); +} diff --git a/src/mcpd/src/seed-runner.ts b/src/mcpd/src/seed-runner.ts index 4977c3c..75d03fc 100644 --- a/src/mcpd/src/seed-runner.ts +++ b/src/mcpd/src/seed-runner.ts @@ -1,11 +1,44 @@ +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 ?? '', + }); + } + } + + return templates; +} async function run(): Promise { 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(); } diff --git a/src/mcpd/src/services/index.ts b/src/mcpd/src/services/index.ts index 34e0f57..9800d2e 100644 --- a/src/mcpd/src/services/index.ts +++ b/src/mcpd/src/services/index.ts @@ -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'; diff --git a/src/mcpd/src/services/template.service.ts b/src/mcpd/src/services/template.service.ts new file mode 100644 index 0000000..9b853f6 --- /dev/null +++ b/src/mcpd/src/services/template.service.ts @@ -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 { + if (namePattern) { + return this.repo.search(namePattern); + } + return this.repo.findAll(); + } + + async getById(id: string): Promise { + const template = await this.repo.findById(id); + if (template === null) { + throw new NotFoundError(`Template not found: ${id}`); + } + return template; + } + + async getByName(name: string): Promise { + const template = await this.repo.findByName(name); + if (template === null) { + throw new NotFoundError(`Template not found: ${name}`); + } + return template; + } + + async create(input: unknown): Promise { + 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 { + const data = UpdateTemplateSchema.parse(input); + await this.getById(id); + return this.repo.update(id, data); + } + + async delete(id: string): Promise { + await this.getById(id); + await this.repo.delete(id); + } +} diff --git a/src/mcpd/src/validation/template.schema.ts b/src/mcpd/src/validation/template.schema.ts new file mode 100644 index 0000000..7d8a91a --- /dev/null +++ b/src/mcpd/src/validation/template.schema.ts @@ -0,0 +1,28 @@ +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 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([]), +}); + +export const UpdateTemplateSchema = CreateTemplateSchema.partial().omit({ name: true }); + +export type CreateTemplateInput = z.infer; +export type UpdateTemplateInput = z.infer; diff --git a/src/mcpd/tsconfig.json b/src/mcpd/tsconfig.json index be275fe..666302a 100644 --- a/src/mcpd/tsconfig.json +++ b/src/mcpd/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", - "types": ["node"] + "types": ["node", "js-yaml"] }, "include": ["src/**/*.ts"], "references": [ diff --git a/src/mcplocal/src/http/index.ts b/src/mcplocal/src/http/index.ts index 8ac49bd..3a5cf63 100644 --- a/src/mcplocal/src/http/index.ts +++ b/src/mcplocal/src/http/index.ts @@ -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'; diff --git a/src/mcplocal/src/http/mcp-endpoint.ts b/src/mcplocal/src/http/mcp-endpoint.ts new file mode 100644 index 0000000..3e42d9f --- /dev/null +++ b/src/mcplocal/src/http/mcp-endpoint.ts @@ -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(); + + // 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(); + }); +} diff --git a/src/mcplocal/src/http/server.ts b/src/mcplocal/src/http/server.ts index c344861..6f77ca2 100644 --- a/src/mcplocal/src/http/server.ts +++ b/src/mcplocal/src/http/server.ts @@ -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; } diff --git a/src/mcplocal/src/main.ts b/src/mcplocal/src/main.ts index 3b1fdc3..0e74edb 100644 --- a/src/mcplocal/src/main.ts +++ b/src/mcplocal/src/main.ts @@ -141,7 +141,10 @@ export async function main(argv: string[] = process.argv): Promise { } // 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`); diff --git a/src/shared/tests/profiles.test.ts b/src/shared/tests/profiles.test.ts deleted file mode 100644 index aeb3a16..0000000 --- a/src/shared/tests/profiles.test.ts +++ /dev/null @@ -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: {}, - }, - }); - }); -}); diff --git a/stack/.env.example b/stack/.env.example new file mode 100644 index 0000000..3830b78 --- /dev/null +++ b/stack/.env.example @@ -0,0 +1,5 @@ +POSTGRES_USER=mcpctl +POSTGRES_PASSWORD=CHANGE_ME +POSTGRES_DB=mcpctl +MCPD_PORT=3100 +MCPD_LOG_LEVEL=info diff --git a/stack/docker-compose.yml b/stack/docker-compose.yml new file mode 100644 index 0000000..780064f --- /dev/null +++ b/stack/docker-compose.yml @@ -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: diff --git a/templates/filesystem.yaml b/templates/filesystem.yaml new file mode 100644 index 0000000..4c14e7b --- /dev/null +++ b/templates/filesystem.yaml @@ -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 diff --git a/templates/github.yaml b/templates/github.yaml new file mode 100644 index 0000000..ec9ffdc --- /dev/null +++ b/templates/github.yaml @@ -0,0 +1,10 @@ +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 +env: + - name: GITHUB_TOKEN + description: Personal access token with repo scope + required: true diff --git a/templates/jira.yaml b/templates/jira.yaml new file mode 100644 index 0000000..2844720 --- /dev/null +++ b/templates/jira.yaml @@ -0,0 +1,16 @@ +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 +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 diff --git a/templates/postgres.yaml b/templates/postgres.yaml new file mode 100644 index 0000000..d1208a4 --- /dev/null +++ b/templates/postgres.yaml @@ -0,0 +1,10 @@ +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 +env: + - name: POSTGRES_CONNECTION_STRING + description: PostgreSQL connection string (e.g. postgresql://user:pass@host:5432/db) + required: true diff --git a/templates/slack.yaml b/templates/slack.yaml new file mode 100644 index 0000000..20c5ec9 --- /dev/null +++ b/templates/slack.yaml @@ -0,0 +1,10 @@ +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 +env: + - name: SLACK_BOT_TOKEN + description: Slack bot token (xoxb-...) + required: true diff --git a/templates/terraform.yaml b/templates/terraform.yaml new file mode 100644 index 0000000..9fd4049 --- /dev/null +++ b/templates/terraform.yaml @@ -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 -- 2.49.1