sap-mcp-bridge: new app — five SAP MCP servers behind one nginx-proxied port

This commit is contained in:
2026-05-19 09:48:55 -07:00
parent bcc9060aab
commit 92ed300f97
8 changed files with 641 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
# syntax=docker/dockerfile:1.7
#
# sap-mcp-bridge
# -------------------------------------------------------------------
# One container, five SAP MCP servers, all reachable through a single
# external HTTP port via an internal nginx reverse proxy. This makes
# the container deployable behind Traefik/Runtipi where only one port
# per app can be exposed.
#
# External (single port — Runtipi binds it):
# / landing page
# /healthz aggregate health probe
# /cap/mcp sap-cap (cap-js-mcp-server)
# /cap/healthz
# /abap/mcp sap-abap-adt (mcp-abap-adt)
# /abap/healthz
# /odata/mcp sap-odata (btp-sap-odata-to-mcp-server)
# /odata/healthz
# /ui5/mcp ui5-mcp-server (ui5mcp)
# /ui5/healthz
# /fiori/mcp fiori-mcp-server (fiori-mcp)
# /fiori/healthz
#
# Internally:
# 127.0.0.1:9001..9005 individual supergateway HTTP endpoints
# 127.0.0.1:8080 nginx (the only port EXPOSEd)
# -------------------------------------------------------------------
FROM node:24-bookworm-slim
ENV NODE_ENV=production \
NPM_CONFIG_FUND=false \
NPM_CONFIG_AUDIT=false \
NPM_CONFIG_UPDATE_NOTIFIER=false
# tini : PID-1 init that reaps zombies and forwards SIGTERM cleanly
# nginx : in-container reverse proxy (single externally-visible port)
# gosu : drop privileges from root → mcp after we've chowned /workspace
# curl : healthcheck client
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
tini ca-certificates curl nginx gosu \
&& rm -rf /var/lib/apt/lists/* /var/log/nginx/*
# Install all MCP servers + the stdio<->HTTP bridge globally.
# All versions pinned for reproducibility — a broken supergateway or
# MCP-server release would silently break every endpoint on the next
# rebuild. Bump deliberately, not by accident.
RUN npm install -g --omit=dev --no-audit --no-fund \
supergateway@3.4.3 \
@iflow-mcp/cap-js-mcp-server@0.0.3 \
@iflow-mcp/mcp-abap-adt@1.1.0 \
@iflow-mcp/btp-sap-odata-to-mcp-server@1.0.0 \
@ui5/mcp-server@0.2.11 \
@sap-ux/fiori-mcp-server@0.6.49
# Non-root runtime user. Container still starts as root so the
# entrypoint can chown /workspace; it then drops to this user via gosu.
# (No -r flag: Debian's SYS_UID_MAX is 999, and we want UID 10001 — the
# warning useradd -r produces is harmless but ugly in build logs.)
RUN useradd -u 10001 -g nogroup -d /home/mcp -m mcp
COPY nginx.conf /etc/nginx/nginx.conf
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# /workspace is the project root the cap / ui5 / fiori servers operate on.
# It's the volume mount target — host-side it's bound to a user-config path
# from the Runtipi compose. Pre-creating it here makes "no volume bound"
# (e.g. local docker run) still work.
RUN mkdir -p /workspace && chown -R mcp:nogroup /workspace
WORKDIR /workspace
EXPOSE 8080
# Cheap liveness: nginx aggregates /healthz; if it answers 200, the
# container is "alive enough" from Traefik's perspective.
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/usr/local/bin/entrypoint.sh"]

View File

@@ -0,0 +1,95 @@
# sap-mcp-bridge — build sources
This directory contains the Docker build context for the
**`git.alexzaw.dev/alexz/sap-mcp-bridge`** image that the Runtipi app
references. The same files are mirrored under
`~/projects/sap-mcp-bridge/` so you can iterate on the image outside
the Runtipi tree if you prefer; treat the two locations as
copies-in-sync.
## Files
| File | Role |
| -------------- | ----------------------------------------------------------------------------------- |
| `Dockerfile` | Node 24 + nginx + gosu + tini, installs 5 MCP servers + supergateway globally. |
| `entrypoint.sh`| Boots as root → chowns `/workspace` → drops to `mcp` via gosu → launches 5 gateways on 127.0.0.1:90019005 → execs nginx on :8080. |
| `nginx.conf` | Path-prefixed reverse proxy (`/cap/mcp`, `/abap/mcp`, …) with SSE/streaming-safe defaults (no buffering, 24h timeouts). |
| `build.sh` | `docker build && docker push` to `git.alexzaw.dev/alexz/sap-mcp-bridge:<tag>`. |
## How it works
The MCP servers are stdio-only. `supergateway` wraps each one as a
small HTTP server speaking the MCP "Streamable HTTP" transport. Each
gateway binds its own port (90019005) on the loopback interface,
inside the container only. An internal **nginx** is the only thing
that listens on the EXPOSEd port (8080) and routes requests by URL
path:
```
8080/cap/mcp -> 127.0.0.1:9001/mcp
8080/abap/mcp -> 127.0.0.1:9002/mcp
8080/odata/mcp -> 127.0.0.1:9003/mcp
8080/ui5/mcp -> 127.0.0.1:9004/mcp
8080/fiori/mcp -> 127.0.0.1:9005/mcp
8080/healthz -> 127.0.0.1:9001/healthz (aggregate probe)
```
This single-port design is what makes the container Runtipi-deployable
behind Traefik, which only routes one port per app.
## Build & push
```bash
cd /etc/runtipi/repos/runtipi/apps/sap-mcp-bridge/build
./build.sh # builds + pushes :latest
IMAGE_TAG=1.0.0 ./build.sh # tag a specific version
./build.sh --no-cache # forwarded to docker build
```
You must be logged in to the Gitea Docker registry:
```bash
docker login git.alexzaw.dev
```
## Deploy to Runtipi
After `build.sh` succeeds:
```bash
cd /etc/runtipi/repos/runtipi
sudo git add apps/sap-mcp-bridge
sudo git commit -m "sap-mcp-bridge: <what changed>"
sudo git push origin main
cd /etc/runtipi
sudo ./runtipi-cli appstore update
sudo ./runtipi-cli app update sap-mcp-bridge:runtipi # for an already-installed app
# (first time: install via the Runtipi dashboard so form fields are written)
```
## Local sanity check (no Runtipi)
```bash
docker run --rm -p 8080:8080 \
-v "$PWD/workspace:/workspace" \
git.alexzaw.dev/alexz/sap-mcp-bridge:latest
# In another terminal:
curl http://127.0.0.1:8080/ # landing page lists endpoints
curl http://127.0.0.1:8080/healthz # 200
curl http://127.0.0.1:8080/cap/healthz # 200 once cap-js-mcp-server boots
```
## Image dependencies (pinned in the Dockerfile)
- Base: `node:24-bookworm-slim`
- `supergateway@latest`
- `@iflow-mcp/cap-js-mcp-server@latest`
- `@iflow-mcp/mcp-abap-adt@1.1.0`
- `@iflow-mcp/btp-sap-odata-to-mcp-server@1.0.0`
- `@ui5/mcp-server@0.2.11`
- `@sap-ux/fiori-mcp-server@0.6.49`
- OS packages: `tini ca-certificates curl nginx gosu`
Bump the pinned versions in `Dockerfile` then rebuild.

View File

@@ -0,0 +1,30 @@
#!/bin/bash
# Build and push the sap-mcp-bridge Docker image to the Gitea registry.
# Run this from the build/ directory of the Runtipi app.
#
# Usage:
# ./build.sh # build + push :latest
# IMAGE_TAG=1.0.0 ./build.sh # tag a specific version
# ./build.sh --no-cache # extra flags are forwarded to docker build
set -euo pipefail
IMAGE_NAME="${IMAGE_NAME:-git.alexzaw.dev/alexz/sap-mcp-bridge}"
IMAGE_TAG="${IMAGE_TAG:-latest}"
echo "Building ${IMAGE_NAME}:${IMAGE_TAG}..."
docker build "$@" -t "${IMAGE_NAME}:${IMAGE_TAG}" .
docker push "${IMAGE_NAME}:${IMAGE_TAG}"
echo ""
echo "Build complete!"
echo ""
echo "To test locally (without Runtipi):"
echo " docker run --rm -p 8080:8080 -v \$PWD/workspace:/workspace ${IMAGE_NAME}:${IMAGE_TAG}"
echo " curl http://127.0.0.1:8080/healthz"
echo ""
echo "After pushing, deploy to Runtipi:"
echo " cd /etc/runtipi/repos/runtipi && sudo git add . && sudo git commit -m 'sap-mcp-bridge: bump' && sudo git push origin main"
echo " sudo /etc/runtipi/runtipi-cli appstore update"
echo " sudo /etc/runtipi/runtipi-cli app update sap-mcp-bridge:runtipi # (or install via the dashboard the first time)"

View File

@@ -0,0 +1,96 @@
#!/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

View File

@@ -0,0 +1,86 @@
# sap-mcp-bridge :: in-container reverse proxy
#
# Routes a single external port (8080) to five internal supergateway
# instances on 127.0.0.1:9001..9005, based on URL path prefix. Tuned
# for MCP Streamable HTTP, which uses long-lived streaming responses
# (effectively SSE) — so buffering is OFF and timeouts are HOURS,
# not seconds.
worker_processes 1;
pid /tmp/nginx.pid;
error_log /dev/stderr warn;
events {
worker_connections 256;
}
http {
# All temp paths must be writable by the non-root mcp user.
client_body_temp_path /tmp/nginx-client-body;
proxy_temp_path /tmp/nginx-proxy;
fastcgi_temp_path /tmp/nginx-fastcgi;
uwsgi_temp_path /tmp/nginx-uwsgi;
scgi_temp_path /tmp/nginx-scgi;
access_log off;
server_tokens off;
types_hash_max_size 2048;
default_type application/octet-stream;
# --- Streaming-HTTP / SSE friendly defaults ---------------------
# MCP responses can be long-lived streams. Buffering would break
# the stream; default 60s timeouts would kill it mid-flight.
proxy_http_version 1.1;
proxy_buffering off;
proxy_request_buffering off;
proxy_read_timeout 24h;
proxy_send_timeout 24h;
proxy_connect_timeout 10s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Empty Connection header keeps keepalive to upstream working
# (the default would inherit the client's Connection header).
proxy_set_header Connection "";
upstream sap_cap { server 127.0.0.1:9001; keepalive 8; }
upstream sap_abap { server 127.0.0.1:9002; keepalive 8; }
upstream sap_odata { server 127.0.0.1:9003; keepalive 8; }
upstream ui5_srv { server 127.0.0.1:9004; keepalive 8; }
upstream fiori_srv { server 127.0.0.1:9005; keepalive 8; }
server {
listen 8080 default_server;
# Plain-text landing page so curl / a browser hit on / gives a
# cue rather than 404 noise.
location = / {
default_type text/plain;
return 200 "sap-mcp-bridge\nendpoints:\n /cap/mcp\n /abap/mcp\n /odata/mcp\n /ui5/mcp\n /fiori/mcp\n";
}
# Aggregate health: probe one upstream. If nginx itself is up
# and that gateway answers 200, Traefik will treat the
# container as healthy. (Per-gateway probes are exposed below
# for finer-grained checks.)
location = /healthz {
access_log off;
proxy_pass http://sap_cap/healthz;
}
# ---------------- MCP endpoints ---------------------------
location = /cap/mcp { proxy_pass http://sap_cap/mcp; }
location = /abap/mcp { proxy_pass http://sap_abap/mcp; }
location = /odata/mcp { proxy_pass http://sap_odata/mcp; }
location = /ui5/mcp { proxy_pass http://ui5_srv/mcp; }
location = /fiori/mcp { proxy_pass http://fiori_srv/mcp; }
# ---------------- Per-gateway health ----------------------
location = /cap/healthz { access_log off; proxy_pass http://sap_cap/healthz; }
location = /abap/healthz { access_log off; proxy_pass http://sap_abap/healthz; }
location = /odata/healthz { access_log off; proxy_pass http://sap_odata/healthz; }
location = /ui5/healthz { access_log off; proxy_pass http://ui5_srv/healthz; }
location = /fiori/healthz { access_log off; proxy_pass http://fiori_srv/healthz; }
}
}