Skip to content

[Feature]: Spec 111 compose example with stdio servers in sidecar containers on an internal network #1707

Open

Description

@james-crowley

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:echo returned 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 /mcp returned 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. A healthcheck on the bridge and depends_on: condition: service_healthy would avoid that in the example.

Proposed solution

For the compose and Kubernetes examples (FR-038, FR-040) and the installation guide:

  1. 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).
  2. Keep the proxy's own stdio spawning for trusted servers, and say which is which.
  3. 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 needed trusted_hosts set 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) so docker logs collects 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

  1. james-crowley commented on Oct 20, 2026

    @james-crowley
    ContributorAuthor

    Related: #1704 (the exposure this layout avoids), #1705 (Podman as the isolation backend), #1706 (file-based API key).

  2. Dumbris commented on Oct 20, 2026

    @Dumbris
    Member

    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.

  3. james-crowley commented on Oct 20, 2026

    @james-crowley
    ContributorAuthor

    Fair questions, and I should be exact about what was tested. Only @modelcontextprotocol/server-everything and 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 (bump returns a per-process count) and called it three times in one MCP session:

    • default (stateless): count=1, count=1, count=1, and no Mcp-Session-Id is returned. Every request gets a fresh child process.
    • --stateful: count=1, count=2, count=3 with a session id, one child per session (idle timeout 30 min by default via --sessionTimeout).

    So the example needs --stateful for anything with state. If mcpproxy reconnects it opens a new session and so a new child. supergateway 4.2.0 also has --apiKey / --apiKeyFile, --healthEndpoint with --healthCheck server, --tools and --config (several servers from one mcpServers file), which would let the example add an API key and a real healthcheck. I will rerun the example with --stateful and 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.

  4. james-crowley commented on Oct 22, 2026

    @james-crowley
    ContributorAuthor

    I reran the layout with supergateway 4.2.0 using --stateful, --apiKeyFile, and --healthEndpoint /healthz --healthCheck server plus 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-server v0.71.1 image, pinned by digest) is on a normal network and an internal: true network. Each stdio server runs in its own sidecar (supergateway 4.2.0 plus the server binary) on the internal network only. mcpproxy's /data volume 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 as Authorization: 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):

    Server Result
    Terraform MCP 1.3.0 Starts, 9 tools through mcpproxy. A lookup call returns an error with no egress and 3.9.1 once that one sidecar is also on a non-internal network.
    mcp-grafana 2.1.1 Starts, 79 tools through mcpproxy with a fake URL and token. A call fails on the fake URL, as expected.
    Playwright MCP 0.0.83 Starts, 25 tools. browser_navigate to a static page served on the internal network works, and later calls in the same session still see the page.
    chrome-devtools-mcp 1.11.0 Starts, 32 tools. new_page, list_pages and take_snapshot work across calls.

    Per-session state: with --stateful, Playwright keeps its page between calls, directly and through mcpproxy. Without it there is no Mcp-Session-Id, and the call after browser_navigate sees about: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 reach http://mcpproxy:8080/mcp. With the default require_mcp_auth: false it got a session without a key. The example sets require_mcp_auth: true, and the sidecar then gets 401.

    Problems found:

    1. mcp-go's StreamableHTTP.Close() sends its DELETE without the configured headers, the OAuth token or the header function's headers (v1.0.0 through v1.3.0 and main). 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 custom http.Client transport (WithHTTPBasicClient), which would cover the DELETE too. The example uses --sessionTimeout=120000; mcpproxy's own ping keeps a live session alive (a page survived 170 s idle).
    2. v0.71.1 ignores MCPPROXY_API_KEY_FILE (it works on main), so the example uses MCPPROXY_API_KEY.
    3. mcpproxy saves its config by rename, so a single-file bind mount fails with "device or resource busy". Mount the directory instead.
    4. mcpproxy keeps one upstream session per server for all its clients. A second MCP client saw the first client's browser page, so --stateful is 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
    # mcpproxy + stdio MCP servers in sidecar containers behind supergateway 4.2.0.
    #
    #   front : normal bridge. mcpproxy only. Published on 127.0.0.1.
    #   back  : internal: true (no gateway, no route out). mcpproxy, sidecars, static site.
    #
    # Sidecars have no route to the internet and mount nothing from mcpproxy: its
    # /data volume is mounted in the mcpproxy container only, and no container gets
    # docker.sock. Run ./gen-secrets.sh once before `docker compose up --build`.
    
    name: sgw
    
    # Every sidecar runs supergateway 4.2.0 as: stdio -> streamable HTTP, --stateful (one child per
    # MCP session; without it every request gets a fresh child and loses its state), an API key
    # from a file secret, and /healthz that really starts the child.
    # --sessionTimeout=120000: mcpproxy pings every 30 s, so its session never idles out, but a
    # session it abandons (its DELETE carries no Authorization header, see notes) is reaped in 2 min
    # instead of the 30 min default.
    x-sidecar: &sidecar
      restart: unless-stopped
      networks: [back]
      secrets: [sgw_api_key]
      read_only: true
      tmpfs: [/tmp]
      cap_drop: [ALL]
      security_opt: ["no-new-privileges:true"]
      healthcheck:
        # supergateway answers /healthz without the API key. With --healthCheck server
        # it starts the stdio child, runs initialize + ping, stops it (result cached 10 s).
        test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8000/healthz').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
        interval: 30s
        timeout: 15s
        retries: 3
        start_period: 20s
    
    services:
      mcpproxy:
        image: ${MCPPROXY_IMAGE:-ghcr.io/smart-mcp-proxy/mcpproxy-server@sha256:8cb21a0a4a620e856d5ffaec9326e8b2a2e536b6e9ee84ed491a4904397125e2}  # v0.71.1
        command: ["serve", "--listen=0.0.0.0:8080", "--config=/config/mcp_config.json", "--data-dir=/data"]
        # SGW_API_KEY (for ${env:SGW_API_KEY} in the config) and MCPPROXY_API_KEY.
        # After the first release with MCPPROXY_API_KEY_FILE (on main, not in v0.71.1) the second
        # can move to: environment: {MCPPROXY_API_KEY_FILE: /run/secrets/mcpproxy_api_key} + secrets.
        env_file: [./secrets/mcpproxy.env]
        volumes:
          - ./mcpproxy:/config        # a directory, not a single file: mcpproxy saves the config by rename(2)
          - mcpproxy_data:/data       # mounted ONLY here
        ports: ["127.0.0.1:${MCPPROXY_PORT:-18080}:8080"]
        networks: [front, back]
        restart: unless-stopped
        depends_on:
          terraform:  {condition: service_healthy}
          grafana:    {condition: service_healthy}
          playwright: {condition: service_healthy}
          chrome-devtools: {condition: service_healthy}
    
      terraform:
        <<: *sidecar
        build: ./images/terraform
        image: sgw-terraform:1.3.0
        command:
          - --stdio=terraform-mcp-server stdio
          - --outputTransport=streamableHttp
          - --stateful
          - --sessionTimeout=120000
          - --port=8000
          - --apiKeyFile=/run/secrets/sgw_api_key
          - --healthEndpoint=/healthz
          - --healthCheck=server
        # Real answers (registry/docs lookups) need egress to registry.terraform.io.
        # Offline (default here) tools/list works and lookup calls fail. To allow it,
        # use: docker compose -f compose.yaml -f compose.egress.yaml up
    
      grafana:
        <<: *sidecar
        build: ./images/grafana
        image: sgw-grafana:2.1.1
        environment:
          GRAFANA_URL: http://grafana.invalid:3000          # fake
          GRAFANA_SERVICE_ACCOUNT_TOKEN: fake-token-not-real # fake
        command:
          - --stdio=mcp-grafana --transport stdio
          - --outputTransport=streamableHttp
          - --stateful
          - --sessionTimeout=120000
          - --port=8000
          - --apiKeyFile=/run/secrets/sgw_api_key
          - --healthEndpoint=/healthz
          - --healthCheck=server
    
      playwright:
        <<: *sidecar
        build: ./images/playwright
        image: sgw-playwright:0.0.83
        shm_size: 512m
        environment: {HOME: /tmp}
        command:
          - --stdio=playwright-mcp --headless --browser chromium --no-sandbox --isolated
          - --outputTransport=streamableHttp
          - --stateful
          - --sessionTimeout=120000
          - --port=8000
          - --apiKeyFile=/run/secrets/sgw_api_key
          - --healthEndpoint=/healthz
          - --healthCheck=server
    
      chrome-devtools:
        <<: *sidecar
        build: ./images/chrome-devtools
        image: sgw-chrome-devtools:1.11.0
        shm_size: 512m
        environment: {HOME: /tmp, CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS: "1"}
        command:
          - --stdio=chrome-devtools-mcp --headless --isolated --executablePath=/usr/bin/chromium --chromeArg=--no-sandbox --no-usage-statistics --no-performance-crux
          - --outputTransport=streamableHttp
          - --stateful
          - --sessionTimeout=120000
          - --port=8000
          - --apiKeyFile=/run/secrets/sgw_api_key
          - --healthEndpoint=/healthz
          - --healthCheck=server
    
      site:                                  # static page for the Playwright test, internal only
        image: supercorp/supergateway@sha256:61ffc26bf72b0b252a3aba926ff67b9d78e7181d19905f1f9c8fc6b7f6c3ad3d
        entrypoint: ["node", "-e", "require('http').createServer((q,s)=>{s.setHeader('content-type','text/html');s.end(require('fs').readFileSync('/www/index.html'))}).listen(8080)"]
        volumes: ["./site:/www:ro"]
        networks: [back]
        read_only: true
    
    networks:
      front: {}
      back:
        internal: true
    
    volumes:
      mcpproxy_data: {}
    
    secrets:
      sgw_api_key:      {file: ./secrets/sgw_api_key}
    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, or npm install -g supergateway@4.2.0 <server>@<version> on node:24-bookworm-slim for the two browser servers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions