Repository navigation
[Feature]: Spec 111 compose example with stdio servers in sidecar containers on an internal network #1707
Description
Problem / motivation
Spec 111 FR-038 calls for a docker-compose example for the full image that CI builds and runs. As written (Spec 111 US1 and the full variant), stdio servers run as child processes in the same container as /data, and a compromised stdio package can read the data volume, including api_key and the OAuth token database (see the separate issue on stdio children reading the data directory). The compose example is the place users copy from, so it should show a layout that avoids that, as an alternative to running stdio servers inside the proxy container.
I tested this layout with rootless Podman, using docker compose pointed at the Podman API socket (DOCKER_HOST=unix://.../podman.sock), mcpproxy from main (3558c53):
name: isostack
services:
bridge: # one stdio MCP server wrapped to HTTP, in its own container
image: localhost/iso-bridge # node:22-alpine + supergateway + @modelcontextprotocol/server-everything, USER node
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges"]
tmpfs: [/tmp]
networks: [back] # internal network: no internet, reachable only from the proxy
proxy:
image: <mcpproxy image>
volumes: [ "./data:/data" ]
ports: ["127.0.0.1:8080:8080"]
networks: [back, front]
depends_on: [bridge]
networks:
back: {internal: true}
front: {}with the upstream configured as {"name":"everything","protocol":"streamable-http","url":"http://bridge:8000/mcp"} and require_mcp_auth: true. Results:
- The proxy connected and discovered 13 tools;
mcpproxy call tool-read --tool-name=everything:echoreturned through the proxy. - The bridge container has no route out (
Network unreachable), cannot see/data, runs read-only as uid 1000 with no capabilities. - Unauthenticated
POST /mcpreturned 401. - On first start the proxy tried the bridge before it was listening and reported
connection refused; it connected on its own about 40 s later. Ahealthcheckon the bridge anddepends_on: condition: service_healthywould avoid that in the example.
Proposed solution
For the compose and Kubernetes examples (FR-038, FR-040) and the installation guide:
- Show this "stdio server in a sidecar" pattern as the recommended layout for servers you do not fully trust, with the bridge being any stdio-to-HTTP wrapper (I used
supergateway; I did not compare it with other wrappers). - Keep the proxy's own stdio spawning for trusted servers, and say which is which.
- In the same example, mention fronting the proxy with a TLS-terminating reverse proxy. I tested Caddy (
tls internal) in front of the listener, and it neededtrusted_hostsset to the proxy's host name; that detail belongs in the docs. For a stricter setup, the audit log can go to the container's stdout (audit_log.stdout) sodocker logscollects it.
The sidecar pattern needs no change in mcpproxy. I can contribute the example and the docs text for review if the layout is acceptable, including the healthcheck.
Alternatives considered
- Docker isolation from the proxy container (needs a daemon socket; see the Podman issue).
- A separate, per-server proxy instance. More moving parts for the same isolation.
Which edition?
Personal.
- I searched existing issues and discussions for a similar request (searched compose, reverse proxy, TLS example, sidecar; nothing found; related: Spec 111 FR-038/FR-040, the stdio-children issue)
Activity
james-crowley commentedon Oct 20, 2026
Related: #1704 (the exposure this layout avoids), #1705 (Podman as the isolation backend), #1706 (file-based API key).
Dumbris commentedon Oct 20, 2026
Useful layout, thanks. Which stdio servers do you actually run behind the bridge, and did they all work with supergateway? I would like the example to use a wrapper that holds up beyond the demo server, and I am not sure how supergateway handles servers that keep per-session state. Also note the personal image this targets does not exist yet, so this will land as an advanced example once it does, or against the server image if you want it sooner.
james-crowley commentedon Oct 20, 2026
Fair questions, and I should be exact about what was tested. Only
@modelcontextprotocol/server-everythingand a small Python counter server went through supergateway. The servers we plan to put behind a bridge are n8n, Gong, Playwright, chrome-devtools, Terraform, Grafana, a git+ssh one (fe-dispatcher) and aws-mcp, and none of those has been tried yet.On per-session state, supergateway 4.2.0 does behave badly by default. I ran a counter server (
bumpreturns a per-process count) and called it three times in one MCP session:count=1, count=1, count=1, and noMcp-Session-Idis returned. Every request gets a fresh child process.--stateful:count=1, count=2, count=3with a session id, one child per session (idle timeout 30 min by default via--sessionTimeout).So the example needs
--statefulfor anything with state. If mcpproxy reconnects it opens a new session and so a new child. supergateway 4.2.0 also has--apiKey/--apiKeyFile,--healthEndpointwith--healthCheck server,--toolsand--config(several servers from onemcpServersfile), which would let the example add an API key and a real healthcheck. I will rerun the example with--statefuland healthchecks, and test two or three of the real servers before proposing it, and I am happy to hold it until the Personal image exists.james-crowley commentedon Oct 22, 2026
I reran the layout with supergateway 4.2.0 using
--stateful,--apiKeyFile, and--healthEndpoint /healthz --healthCheck serverplus a compose healthcheck, and tried four real servers. The compose file and a minimal config are at the end of this comment.Layout. mcpproxy (official
mcpproxy-serverv0.71.1 image, pinned by digest) is on a normal network and aninternal: truenetwork. Each stdio server runs in its own sidecar (supergateway 4.2.0 plus the server binary) on the internal network only. mcpproxy's/datavolume is mounted only in the proxy container, and no container has a docker socket. Each sidecar requires an API key read from a file secret. mcpproxy sends it asAuthorization: Bearer ${env:SGW_API_KEY}, because${file:}is not on main yet (#1717).What I tested (rootless Podman 5.7 through
docker compose; I could not run it on Docker, see below):3.9.1once that one sidecar is also on a non-internal network.browser_navigateto a static page served on the internal network works, and later calls in the same session still see the page.new_page,list_pagesandtake_snapshotwork across calls.Per-session state: with
--stateful, Playwright keeps its page between calls, directly and through mcpproxy. Without it there is noMcp-Session-Id, and the call afterbrowser_navigateseesabout:blank. Terraform and Grafana have no state I could observe, so I could not check them either way. Each mcpproxy start is a new session and a new child process. If a sidecar restarts, mcpproxy re-initializes on the 404 by itself, and Playwright's page is gone.Footprint: Playwright is 1.34 GB with about 360 MB RSS once Chromium runs. chrome-devtools is about 1 GB and 500 MB. Terraform and Grafana are 220-260 MB and 50-75 MB.
Isolation, checked from inside the sidecars. The proxy's data dir is not visible. A sidecar cannot reach the internet by IP or DNS. No container has a docker socket, and only mcpproxy publishes a port (
127.0.0.1). One gap: mcpproxy has to be on the internal network, so a sidecar can reachhttp://mcpproxy:8080/mcp. With the defaultrequire_mcp_auth: falseit got a session without a key. The example setsrequire_mcp_auth: true, and the sidecar then gets 401.Problems found:
StreamableHTTP.Close()sends itsDELETEwithout the configured headers, the OAuth token or the header function's headers (v1.0.0 through v1.3.0 andmain). A key-protected sidecar refuses it, so the session and its child process stay until--sessionTimeout(30 min by default). I wrote the fix with tests, but mcp-go limits PRs and issues to collaborators, so it sits on a fork branch: https://github.com/james-crowley/mcp-go/tree/fix/close-delete-headers (one commit). Until it lands upstream, mcpproxy could set the upstream headers in a customhttp.Clienttransport (WithHTTPBasicClient), which would cover theDELETEtoo. The example uses--sessionTimeout=120000; mcpproxy's own ping keeps a live session alive (a page survived 170 s idle).MCPPROXY_API_KEY_FILE(it works on main), so the example usesMCPPROXY_API_KEY.--statefulis per proxy, not per client.Not tested: n8n, Gong, aws-mcp and the git+ssh server; Docker itself (a firewall on my box blocks container-to-container traffic on Docker bridges, so I used Podman, and the file uses nothing Podman-specific);
${file:}from #1717; Terraform with a real token; Grafana against a real instance; chrome-devtools without--stateful.compose.yaml
mcp_config.json (minimal)
{ "listen": "0.0.0.0:8080", "enable_socket": false, "data_dir": "/data", "require_mcp_auth": true, "mcpServers": [ {"name": "terraform", "url": "http://terraform:8000/mcp", "protocol": "streamable-http", "headers": {"Authorization": "Bearer ${env:SGW_API_KEY}"}, "enabled": true}, {"name": "grafana", "url": "http://grafana:8000/mcp", "protocol": "streamable-http", "headers": {"Authorization": "Bearer ${env:SGW_API_KEY}"}, "enabled": true}, {"name": "playwright", "url": "http://playwright:8000/mcp", "protocol": "streamable-http", "headers": {"Authorization": "Bearer ${env:SGW_API_KEY}"}, "enabled": true}, {"name": "chrome-devtools", "url": "http://chrome-devtools:8000/mcp", "protocol": "streamable-http", "headers": {"Authorization": "Bearer ${env:SGW_API_KEY}"}, "enabled": true} ] }Each sidecar image is supergateway 4.2.0 (
supercorp/supergateway, pinned by digest) with the server's binary copied in, ornpm install -g supergateway@4.2.0 <server>@<version>onnode:24-bookworm-slimfor the two browser servers.