#!/usr/bin/env bash # sap-mcp-bridge entrypoint # # Lifecycle: # 1. (as root) Ensure /workspace exists and is owned by the mcp user, # then re-exec ourselves as mcp via gosu. This makes the mount # writable even when Docker auto-creates the host path as root. # 2. (as mcp) Install the signal trap BEFORE forking any children, so # a SIGTERM arriving during start-up still tears the whole group # down cleanly. # 3. (as mcp) Launch one supergateway per SAP MCP server on # 127.0.0.1:9001..9005. They speak Streamable HTTP at /mcp and # expose /healthz. # 4. (as mcp) Wait for every gateway's /healthz to answer (with a # cap), then launch nginx in the foreground. This avoids the # cold-start window where nginx 502s because upstreams haven't # bound yet. # 5. (as mcp) `wait -n` — exit as soon as ANY direct child (gateway # or nginx) dies. Docker restarts the container, so a crashed # gateway resets the whole group (acceptable for homelab use). set -euo pipefail WORKDIR_HOST="${WORKDIR_HOST:-/workspace}" APP_UID=10001 APP_GID=65534 # nogroup APP_USER="mcp" GATEWAY_PORTS=(9001 9002 9003 9004 9005) # --- Stage 1: privilege handling ----------------------------------- if [ "$(id -u)" = "0" ]; then mkdir -p "$WORKDIR_HOST" # Skip the chown if the dir is already owned correctly — recursive # chown on a large mounted workspace is slow and runs every restart. # The chown can fail on read-only / fuse / overlay mounts; we tolerate # failure rather than crash (the mcp user just won't be able to write). current_owner="$(stat -c '%u' "$WORKDIR_HOST" 2>/dev/null || echo "")" if [ "$current_owner" != "$APP_UID" ]; then chown -R "${APP_UID}:${APP_GID}" "$WORKDIR_HOST" 2>/dev/null || true fi exec gosu "${APP_USER}" "$0" "$@" fi # --- Stage 2: install trap BEFORE children ------------------------- # kill 0 sends SIGTERM to every process in our group; tini will reap # them. Done now so a SIGTERM that lands during start-up still tears # the whole group down cleanly. trap 'echo "[sap-mcp-bridge] received signal, shutting down"; kill 0' SIGTERM SIGINT # --- Stage 3: launch gateways -------------------------------------- cd "$WORKDIR_HOST" 2>/dev/null || cd /home/mcp start() { local name="$1" port="$2" cmd="$3" echo "[sap-mcp-bridge] starting ${name} on 127.0.0.1:${port} -> ${cmd}" # --stateful gives each MCP client its own subprocess (safer for the # SAP servers, which keep auth state per-session). supergateway \ --stdio "${cmd}" \ --outputTransport streamableHttp \ --port "${port}" \ --streamableHttpPath /mcp \ --healthEndpoint /healthz \ --cors \ --stateful \ --logLevel info \ & } start "sap-cap" 9001 "iflow-mcp_cap-js-mcp-server" start "sap-abap-adt" 9002 "mcp-abap-adt" start "sap-odata" 9003 "btp-sap-odata-to-mcp-server" start "ui5-mcp-server" 9004 "ui5mcp" start "fiori-mcp-server" 9005 "fiori-mcp" # --- Stage 4: wait for upstreams, then nginx ----------------------- # Poll each gateway's /healthz for up to ~30s. Without this nginx can # come up before the Node processes finish booting and serve 502s on # the first requests (and to the Traefik healthcheck). for port in "${GATEWAY_PORTS[@]}"; do for _ in $(seq 30); do if curl -fsS -m 1 "http://127.0.0.1:${port}/healthz" >/dev/null 2>&1; then echo "[sap-mcp-bridge] upstream :${port} ready" break fi sleep 1 done done echo "[sap-mcp-bridge] starting nginx reverse proxy on :8080" nginx -g 'daemon off;' & # --- Stage 5: shut down on first child death ----------------------- wait -n echo "[sap-mcp-bridge] a child exited, taking the container down" kill 0 2>/dev/null || true exit 1