sap-mcp-bridge: new app — five SAP MCP servers behind one nginx-proxied port
This commit is contained in:
82
apps/sap-mcp-bridge/build/Dockerfile
Normal file
82
apps/sap-mcp-bridge/build/Dockerfile
Normal 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"]
|
||||||
95
apps/sap-mcp-bridge/build/README.md
Normal file
95
apps/sap-mcp-bridge/build/README.md
Normal 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:9001–9005 → 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 (9001–9005) 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.
|
||||||
30
apps/sap-mcp-bridge/build/build.sh
Normal file
30
apps/sap-mcp-bridge/build/build.sh
Normal 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)"
|
||||||
96
apps/sap-mcp-bridge/build/entrypoint.sh
Normal file
96
apps/sap-mcp-bridge/build/entrypoint.sh
Normal 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
|
||||||
86
apps/sap-mcp-bridge/build/nginx.conf
Normal file
86
apps/sap-mcp-bridge/build/nginx.conf
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
87
apps/sap-mcp-bridge/config.json
Normal file
87
apps/sap-mcp-bridge/config.json
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"name": "SAP MCP Bridge",
|
||||||
|
"id": "sap-mcp-bridge",
|
||||||
|
"available": true,
|
||||||
|
"short_desc": "Five SAP, UI5 and Fiori MCP servers behind a single HTTP gateway",
|
||||||
|
"author": "alexz",
|
||||||
|
"port": 8080,
|
||||||
|
"categories": ["development", "utilities"],
|
||||||
|
"tipi_version": 1,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"source": "https://git.alexzaw.dev/alexz/sap-mcp-bridge",
|
||||||
|
"exposable": true,
|
||||||
|
"dynamic_config": true,
|
||||||
|
"supported_architectures": ["amd64", "arm64"],
|
||||||
|
"min_tipi_version": "4.0.0",
|
||||||
|
"form_fields": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"label": "SAP ABAP URL",
|
||||||
|
"hint": "e.g. https://my429480-api.s4hana.cloud.sap (leave empty if not using sap-abap-adt)",
|
||||||
|
"required": false,
|
||||||
|
"env_variable": "SAP_URL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"label": "SAP Username",
|
||||||
|
"hint": "Basic-auth user for ABAP ADT",
|
||||||
|
"required": false,
|
||||||
|
"env_variable": "SAP_USERNAME"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "password",
|
||||||
|
"label": "SAP Password",
|
||||||
|
"hint": "Basic-auth password for ABAP ADT",
|
||||||
|
"required": false,
|
||||||
|
"env_variable": "SAP_PASSWORD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"label": "SAP Client",
|
||||||
|
"hint": "e.g. 100",
|
||||||
|
"required": false,
|
||||||
|
"env_variable": "SAP_CLIENT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"label": "BTP Destination Name",
|
||||||
|
"hint": "Destination configured in BTP cockpit (leave empty if not using sap-odata)",
|
||||||
|
"required": false,
|
||||||
|
"env_variable": "SAP_DESTINATION_NAME"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"label": "Destination Service URL",
|
||||||
|
"hint": "From the BTP destination service key",
|
||||||
|
"required": false,
|
||||||
|
"env_variable": "DESTINATION_SERVICE_URL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"label": "Destination Client ID",
|
||||||
|
"required": false,
|
||||||
|
"env_variable": "DESTINATION_CLIENT_ID"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "password",
|
||||||
|
"label": "Destination Client Secret",
|
||||||
|
"required": false,
|
||||||
|
"env_variable": "DESTINATION_CLIENT_SECRET"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"label": "Destination Auth URL",
|
||||||
|
"required": false,
|
||||||
|
"env_variable": "DESTINATION_AUTH_URL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"label": "OData Discovery Mode",
|
||||||
|
"hint": "all | patterns (optional)",
|
||||||
|
"required": false,
|
||||||
|
"env_variable": "ODATA_DISCOVERY_MODE"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_at": 1747624260000,
|
||||||
|
"updated_at": 1747624260000
|
||||||
|
}
|
||||||
42
apps/sap-mcp-bridge/docker-compose.json
Normal file
42
apps/sap-mcp-bridge/docker-compose.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schemas.runtipi.io/dynamic-compose.json",
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"name": "sap-mcp-bridge",
|
||||||
|
"image": "git.alexzaw.dev/alexz/sap-mcp-bridge:latest",
|
||||||
|
"isMain": true,
|
||||||
|
"internalPort": 8080,
|
||||||
|
"environment": [
|
||||||
|
{ "key": "TZ", "value": "${TZ}" },
|
||||||
|
{ "key": "SAP_URL", "value": "${SAP_URL}" },
|
||||||
|
{ "key": "SAP_USERNAME", "value": "${SAP_USERNAME}" },
|
||||||
|
{ "key": "SAP_PASSWORD", "value": "${SAP_PASSWORD}" },
|
||||||
|
{ "key": "SAP_CLIENT", "value": "${SAP_CLIENT}" },
|
||||||
|
{ "key": "SAP_DESTINATION_NAME", "value": "${SAP_DESTINATION_NAME}" },
|
||||||
|
{ "key": "DESTINATION_SERVICE_URL", "value": "${DESTINATION_SERVICE_URL}" },
|
||||||
|
{ "key": "DESTINATION_CLIENT_ID", "value": "${DESTINATION_CLIENT_ID}" },
|
||||||
|
{ "key": "DESTINATION_CLIENT_SECRET", "value": "${DESTINATION_CLIENT_SECRET}" },
|
||||||
|
{ "key": "DESTINATION_AUTH_URL", "value": "${DESTINATION_AUTH_URL}" },
|
||||||
|
{ "key": "ODATA_DISCOVERY_MODE", "value": "${ODATA_DISCOVERY_MODE}" }
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
{
|
||||||
|
"hostPath": "/etc/runtipi/user-config/runtipi/sap-mcp-bridge/workspace",
|
||||||
|
"containerPath": "/workspace",
|
||||||
|
"readOnly": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"healthCheck": {
|
||||||
|
"test": "curl -fsS http://127.0.0.1:8080/healthz || exit 1",
|
||||||
|
"interval": "30s",
|
||||||
|
"timeout": "5s",
|
||||||
|
"retries": 3,
|
||||||
|
"startPeriod": "30s"
|
||||||
|
},
|
||||||
|
"extraLabels": {
|
||||||
|
"runtipi.managed": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
123
apps/sap-mcp-bridge/metadata/description.md
Normal file
123
apps/sap-mcp-bridge/metadata/description.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# SAP MCP Bridge
|
||||||
|
|
||||||
|
Five SAP / UI5 / Fiori Model Context Protocol servers, packaged in a
|
||||||
|
single container and routed through an internal **nginx reverse
|
||||||
|
proxy** so the app exposes only **one external port** (Runtipi /
|
||||||
|
Traefik friendly).
|
||||||
|
|
||||||
|
## Bundled MCP servers
|
||||||
|
|
||||||
|
| Path prefix | MCP server | Use case |
|
||||||
|
| -------------- | ----------------------------------------- | ------------------------------------------ |
|
||||||
|
| `/cap/mcp` | `@iflow-mcp/cap-js-mcp-server` | SAP CAP (Cloud Application Programming) |
|
||||||
|
| `/abap/mcp` | `@iflow-mcp/mcp-abap-adt` | SAP ABAP via ADT (S/4HANA Cloud, on-prem) |
|
||||||
|
| `/odata/mcp` | `@iflow-mcp/btp-sap-odata-to-mcp-server` | SAP OData via BTP Destination service |
|
||||||
|
| `/ui5/mcp` | `@ui5/mcp-server` | SAPUI5 framework guidance + linting |
|
||||||
|
| `/fiori/mcp` | `@sap-ux/fiori-mcp-server` | SAP Fiori tools / app generation |
|
||||||
|
|
||||||
|
Each stdio MCP server is wrapped by `supergateway` and speaks
|
||||||
|
**Streamable HTTP** on its own internal port (9001–9005). An internal
|
||||||
|
nginx (port 8080 — the only EXPOSEd port) routes external requests by
|
||||||
|
URL path. Long timeouts and disabled buffering make MCP streaming
|
||||||
|
responses work correctly.
|
||||||
|
|
||||||
|
## Health endpoints
|
||||||
|
|
||||||
|
- `/healthz` — aggregate (probes the CAP gateway)
|
||||||
|
- `/cap/healthz`, `/abap/healthz`, `/odata/healthz`, `/ui5/healthz`, `/fiori/healthz`
|
||||||
|
|
||||||
|
## Using from Claude Code
|
||||||
|
|
||||||
|
Once the app is installed (and either exposed via a domain or
|
||||||
|
reachable on the LAN), wire the servers you want into your MCP
|
||||||
|
client. With the Claude Code CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add --transport http sap-cap https://sap-mcp.example.com/cap/mcp
|
||||||
|
claude mcp add --transport http sap-abap https://sap-mcp.example.com/abap/mcp
|
||||||
|
claude mcp add --transport http sap-odata https://sap-mcp.example.com/odata/mcp
|
||||||
|
claude mcp add --transport http ui5 https://sap-mcp.example.com/ui5/mcp
|
||||||
|
claude mcp add --transport http fiori https://sap-mcp.example.com/fiori/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
…or by adding the equivalent block to your project's `.mcp.json` /
|
||||||
|
`~/.claude.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"sap-cap": { "type": "http", "url": "https://sap-mcp.example.com/cap/mcp" },
|
||||||
|
"sap-abap": { "type": "http", "url": "https://sap-mcp.example.com/abap/mcp" },
|
||||||
|
"sap-odata": { "type": "http", "url": "https://sap-mcp.example.com/odata/mcp" },
|
||||||
|
"ui5": { "type": "http", "url": "https://sap-mcp.example.com/ui5/mcp" },
|
||||||
|
"fiori": { "type": "http", "url": "https://sap-mcp.example.com/fiori/mcp" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚠️ Security warning — read before exposing externally
|
||||||
|
|
||||||
|
This app intentionally enables **wildcard CORS** (`Access-Control-Allow-Origin: *`)
|
||||||
|
inside the supergateway wrappers, because MCP clients may call the
|
||||||
|
endpoint from a variety of origins. Combined with `exposable: true`
|
||||||
|
and **no built-in authentication**, this means: if you expose the app
|
||||||
|
on a public URL via Traefik or a Cloudflare Tunnel, *anyone who knows
|
||||||
|
the URL* can drive your configured SAP credentials.
|
||||||
|
|
||||||
|
Recommended deployments:
|
||||||
|
|
||||||
|
- **Local LAN only**: leave `exposable: false` (default) and reach it
|
||||||
|
via `http://<runtipi-host>:<port>` from inside the LAN.
|
||||||
|
- **Exposed to the internet**: put it behind a Traefik
|
||||||
|
**ForwardAuth** middleware (e.g. Authentik / Authelia), a
|
||||||
|
Cloudflare Access policy, or basic-auth in nginx-proxy-manager —
|
||||||
|
i.e. authenticate at the proxy, not the app.
|
||||||
|
|
||||||
|
Do NOT expose this publicly without an auth layer in front of it.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All credential form fields are **optional**. Empty values are fine —
|
||||||
|
servers that don't have their required env vars simply won't be
|
||||||
|
usable, but the others (and the container as a whole) will still run.
|
||||||
|
|
||||||
|
- **`sap-abap-adt` needs:** `SAP_URL`, `SAP_USERNAME`, `SAP_PASSWORD`,
|
||||||
|
`SAP_CLIENT`
|
||||||
|
- **`sap-odata` needs:** `SAP_DESTINATION_NAME`,
|
||||||
|
`DESTINATION_SERVICE_URL`, `DESTINATION_CLIENT_ID`,
|
||||||
|
`DESTINATION_CLIENT_SECRET`, `DESTINATION_AUTH_URL`
|
||||||
|
- **`sap-cap`, `ui5-mcp-server`, `fiori-mcp-server`** require no
|
||||||
|
credentials — they read your **project workspace** at request time.
|
||||||
|
|
||||||
|
## Workspace mount
|
||||||
|
|
||||||
|
The container's `/workspace` is bound to:
|
||||||
|
|
||||||
|
```
|
||||||
|
/etc/runtipi/user-config/runtipi/sap-mcp-bridge/workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
This lives under `user-config/`, which **survives Runtipi restarts
|
||||||
|
and `app update`**. Drop your CAP / UI5 / Fiori projects in there for
|
||||||
|
the `cap`, `ui5`, and `fiori` MCP servers to operate on.
|
||||||
|
|
||||||
|
The entrypoint chowns this directory to the in-container `mcp` user
|
||||||
|
(UID 10001) on every start, so a freshly auto-created mount works.
|
||||||
|
|
||||||
|
If you want `/workspace` to point at a different host path, create
|
||||||
|
`/etc/runtipi/user-config/runtipi/sap-mcp-bridge/docker-compose.yml`
|
||||||
|
with an overlay, e.g.:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
sap-mcp-bridge:
|
||||||
|
volumes:
|
||||||
|
- /your/other/path:/workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
## Image
|
||||||
|
|
||||||
|
Custom image:
|
||||||
|
`git.alexzaw.dev/alexz/sap-mcp-bridge:latest` — built from the
|
||||||
|
sources in `build/` (Dockerfile + entrypoint.sh + nginx.conf). See
|
||||||
|
`build/README.md` for the build & push procedure.
|
||||||
Reference in New Issue
Block a user