# SAP-MCP-Stack (C#) — RELEASE-Compose für Kunden-Deployments (liegt via CI als # docker-compose.yml im öffentlichen Repo kraemerweb/easymcp-release). Referenziert # NUR vorgebaute Images aus der öffentlichen Gitea-Registry (kein build:) — # der Kunde zieht Updates per 'docker compose pull && docker compose up -d'. # APP_VERSION setzt setup.sh in der .env (Default: latest, pinbar auf Versionstags). # # Härtung wie im Entwicklungs-Compose: alle Dienste no-new-privileges; die # .NET-Dienste + Hydra zusätzlich cap_drop ALL; login/mcp/admin read_only # (nur /tmp als tmpfs). Postgres behält Default-Caps und bleibt schreibbar. name: sapb1mcp # Log-Rotation für die Dauerläufer (Docker-Default rotiert nicht). Max ~30 MB/Dienst. x-logging: &default-logging driver: json-file options: max-size: "10m" max-file: "3" services: postgres: image: postgres:17-alpine container_name: sapb1mcp-postgres restart: unless-stopped logging: *default-logging security_opt: - no-new-privileges:true environment: POSTGRES_USER: mcp POSTGRES_PASSWORD: ${PG_PASSWORD:?PG_PASSWORD fehlt in .env — setup.sh ausführen} POSTGRES_DB: mcp volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U mcp -d mcp"] interval: 5s timeout: 3s retries: 12 # App-Schema (sap_credentials, mcp_user_access, audit_entries) via EF Core. # Nutzt das mcp-Image, ruft es aber im --migrate-Modus auf und beendet sich. db-migrate: image: git.kraemerweb.de/kraemerweb/sapb1mcp-mcp:${APP_VERSION:-latest} container_name: sapb1mcp-db-migrate restart: "no" security_opt: - no-new-privileges:true depends_on: postgres: condition: service_healthy environment: POSTGRES_CONNECTION: "Host=postgres;Port=5432;Database=mcp;Username=mcp;Password=${PG_PASSWORD}" entrypoint: ["dotnet", "SapB1McpServer.dll", "--migrate"] # Hydra-Schema (eigene Tabellen in derselben DB). hydra-migrate: image: oryd/hydra:v2.3.0 container_name: sapb1mcp-hydra-migrate restart: "no" security_opt: - no-new-privileges:true depends_on: postgres: condition: service_healthy environment: DSN: postgres://mcp:${PG_PASSWORD}@postgres:5432/mcp?sslmode=disable volumes: - ./hydra.yml:/etc/hydra/hydra.yml:ro command: migrate sql -e --yes --config /etc/hydra/hydra.yml hydra: image: oryd/hydra:v2.3.0 container_name: sapb1mcp-hydra restart: unless-stopped logging: *default-logging security_opt: - no-new-privileges:true cap_drop: - ALL depends_on: hydra-migrate: condition: service_completed_successfully environment: DSN: postgres://mcp:${PG_PASSWORD}@postgres:5432/mcp?sslmode=disable SECRETS_SYSTEM: ${SECRETS_SYSTEM:?SECRETS_SYSTEM fehlt in .env — setup.sh ausführen} URLS_SELF_ISSUER: ${PUBLIC_ISSUER:?PUBLIC_ISSUER fehlt in .env} URLS_LOGIN: ${LOGIN_URL}/login URLS_CONSENT: ${LOGIN_URL}/consent # Hydra leitet registration_endpoint NICHT automatisch ab — ohne diesen # Eintrag fehlt es im Discovery-Dokument und Clients halten DCR für # nicht unterstützt. WEBFINGER_OIDC_DISCOVERY_CLIENT_REGISTRATION_URL: ${PUBLIC_ISSUER}/oauth2/register volumes: - ./hydra.yml:/etc/hydra/hydra.yml:ro command: serve all --config /etc/hydra/hydra.yml ports: # Nur an die interne Host-IP (BIND_ADDR) binden, die der Reverse Proxy # erreicht — nie 0.0.0.0. Default 127.0.0.1 = sicher (Proxy auf demselben Host). - "${BIND_ADDR:-127.0.0.1}:${HYDRA_PUBLIC_HOST_PORT:-4444}:4444" # öffentlich (OAuth-Issuer) # Admin-API 4445 bleibt intern im Compose-Netz (nur login/admin-Container). login: image: git.kraemerweb.de/kraemerweb/sapb1mcp-login:${APP_VERSION:-latest} container_name: sapb1mcp-login restart: unless-stopped logging: *default-logging security_opt: - no-new-privileges:true cap_drop: - ALL read_only: true tmpfs: - /tmp depends_on: db-migrate: condition: service_completed_successfully hydra: condition: service_started environment: ASPNETCORE_URLS: http://0.0.0.0:8081 POSTGRES_CONNECTION: "Host=postgres;Port=5432;Database=mcp;Username=mcp;Password=${PG_PASSWORD}" Hydra__AdminUrl: http://hydra:4445 SAPServiceLayer__BaseUrl: ${SL_BASE_URL:?SL_BASE_URL fehlt in .env} SAPServiceLayer__CompanyDB: ${SL_COMPANYDB:?SL_COMPANYDB fehlt in .env} SAPServiceLayer__IgnoreSslErrors: ${SL_INSECURE:-false} CREDS_KEY_FILE: /run/secrets/creds_key # MCP-Zugriff: immer erlaubte SAP-Benutzer (Allowlist, default-deny für den Rest). MCP_ALLOWED_USERS: ${MCP_ALLOWED_USERS:-manager} secrets: - creds_key ports: - "${BIND_ADDR:-127.0.0.1}:${LOGIN_HOST_PORT:-8081}:8081" healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8081/healthz"] interval: 10s timeout: 3s retries: 6 mcp: image: git.kraemerweb.de/kraemerweb/sapb1mcp-mcp:${APP_VERSION:-latest} container_name: sapb1mcp-mcp restart: unless-stopped logging: *default-logging security_opt: - no-new-privileges:true cap_drop: - ALL read_only: true tmpfs: - /tmp depends_on: db-migrate: condition: service_completed_successfully hydra: condition: service_started environment: McpHttp__Port: "8080" # Öffentliche Resource-URL dieses MCP-Servers (RFC-9728-Metadaten + WWW-Authenticate). MCP_RESOURCE_URL: ${MCP_RESOURCE_URL:?MCP_RESOURCE_URL fehlt in .env} # Hydra-Issuer für JWT-Validierung (Authority). MUSS per HTTPS erreichbar sein # (RequireHttpsMetadata=true) — im Betrieb der Reverse-Proxy-vHost. Hydra__PublicIssuer: ${PUBLIC_ISSUER} SAPServiceLayer__BaseUrl: ${SL_BASE_URL} SAPServiceLayer__CompanyDB: ${SL_COMPANYDB} SAPServiceLayer__IgnoreSslErrors: ${SL_INSECURE:-false} POSTGRES_CONNECTION: "Host=postgres;Port=5432;Database=mcp;Username=mcp;Password=${PG_PASSWORD}" CREDS_KEY_FILE: /run/secrets/creds_key MCP_ALLOWED_USERS: ${MCP_ALLOWED_USERS:-manager} # Scope-Pflicht fürs Token: leer = aus (claude.ai/ChatGPT-Tokens führen den # mcp-Scope nicht — ohne Durchreichen greift der Code-Default "mcp" und # JEDER Tool-Call endet 403). Härtung per .env aktivierbar. MCP_REQUIRED_SCOPE: ${MCP_REQUIRED_SCOPE:-} secrets: - creds_key ports: - "${BIND_ADDR:-127.0.0.1}:${MCP_HOST_PORT:-8080}:8080" healthcheck: # Der MCP-Server hat kein /healthz; die anonyme RFC-9728-Metadaten-Route # antwortet ohne Token mit 200 und dient als Health-Signal. test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/.well-known/oauth-protected-resource"] interval: 10s timeout: 3s retries: 6 # Docker-Socket-Proxy: EINZIGER Container, der den Docker-Socket sieht. Filtert die # Engine-API auf das Minimum, das das Admin-UI braucht (Container listen/Logs/Restart). docker-socket-proxy: image: tecnativa/docker-socket-proxy:0.3.0 container_name: sapb1mcp-docker-proxy restart: unless-stopped logging: *default-logging security_opt: - no-new-privileges:true # Kein read_only: der tecnativa-Entrypoint generiert seine haproxy.cfg zur Laufzeit # aus der im Image liegenden Template — read_only dort crasht den Proxy. environment: # Nur Container-Bereich + POST (für /restart) freigeben, alles andere bleibt 0. CONTAINERS: 1 POST: 1 # Explizit geschlossen halten (Defaults, zur Klarheit dokumentiert): EXEC: 0 IMAGES: 0 VOLUMES: 0 NETWORKS: 0 INFO: 0 SERVICES: 0 TASKS: 0 SWARM: 0 SYSTEM: 0 AUTH: 0 SECRETS: 0 CONFIGS: 0 NODES: 0 PLUGINS: 0 DISTRIBUTION: 0 volumes: - /var/run/docker.sock:/var/run/docker.sock:ro networks: - dockerproxy admin: image: git.kraemerweb.de/kraemerweb/sapb1mcp-admin:${APP_VERSION:-latest} container_name: sapb1mcp-admin restart: unless-stopped logging: *default-logging security_opt: - no-new-privileges:true # Kein Docker-Socket-Mount: der Admin erreicht die Engine-API ausschließlich # über den gefilterten Proxy (siehe DOCKER_HOST). cap_drop: - ALL read_only: true tmpfs: - /tmp depends_on: db-migrate: condition: service_completed_successfully docker-socket-proxy: condition: service_started environment: ASPNETCORE_URLS: http://0.0.0.0:8082 POSTGRES_CONNECTION: "Host=postgres;Port=5432;Database=mcp;Username=mcp;Password=${PG_PASSWORD}" Hydra__AdminUrl: http://hydra:4445 # Login-Gate: nur dieser SAP-User darf das Admin-UI öffnen (SL-verifiziert). # Pflicht (fail-closed) — der Dienst lässt sonst niemanden hinein. ADMIN_SAP_USER: ${ADMIN_SAP_USER:?ADMIN_SAP_USER fehlt in .env} SAPServiceLayer__BaseUrl: ${SL_BASE_URL} SAPServiceLayer__CompanyDB: ${SL_COMPANYDB} SAPServiceLayer__IgnoreSslErrors: ${SL_INSECURE:-false} MCP_ALLOWED_USERS: ${MCP_ALLOWED_USERS:-manager} # Container-Steuerung/Logs NUR über den gefilterten Proxy (nicht über den rohen Socket). COMPOSE_PROJECT: sapb1mcp DOCKER_HOST: tcp://docker-socket-proxy:2375 # Update-Seite („Pull & Update"): startet den Einweg-Updater-Container. # PROJECT_DIR = Host-Pfad des Deployments (setzt setup.sh); der Updater mountet # ihn pfad-identisch, damit die relativen Binds der Compose aufgehen. APP_VERSION: ${APP_VERSION:-latest} PROJECT_DIR: ${PROJECT_DIR:?PROJECT_DIR fehlt in .env — setup.sh ausführen} UPDATES_VOLUME: sapb1mcp_updates # Updater bewusst auf :latest — er ist kein Compose-Dienst; setup.sh pullt ihn # initial, updater-run.sh hält ihn bei jedem Update selbst aktuell. UPDATER_IMAGE: git.kraemerweb.de/kraemerweb/sapb1mcp-updater:latest volumes: # Geteilte Ablage für update.log/update.status — so kann auch der beim # Update NEU erstellte Admin das Ergebnis noch anzeigen. - updates:/updates networks: - default - dockerproxy ports: # Admin-UI inkl. /audit liegt hinter dem SAP-Login-Gate (nur /login + /healthz frei). # Trotzdem nur intern binden: Default loopback; ADMIN_BIND in .env auf die interne # Host-IP setzen, die der Reverse Proxy erreicht — nie 0.0.0.0. - "${ADMIN_BIND:-127.0.0.1}:${ADMIN_HOST_PORT:-8082}:8082" healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8082/healthz"] interval: 10s timeout: 3s retries: 6 volumes: pgdata: # Staging-Volume der Update-Funktion (Name auf dem Host: sapb1mcp_updates — # muss zur UPDATES_VOLUME-Env des Admin passen, der Updater mountet es per Name). updates: networks: # Standard-Netz für alle Dienste (Postgres, Hydra, login, mcp, admin). default: # Isoliertes, nach außen abgeschottetes Netz nur für admin ↔ docker-socket-proxy. dockerproxy: internal: true secrets: creds_key: file: ./secrets/creds_key