name: secure-mcp-stack services: coding-agent-mcp: build: context: ./coding-agent-mcp command: ["node", "src/coding-agent-index.js", "--transport", "http", "--host", "0.0.0.0", "--port", "3000", "--root", "/workspace"] environment: CODING_AGENT_MCP_API_KEYS: ${CODING_AGENT_MCP_API_KEYS} CODING_AGENT_MCP_EXECUTION_BACKEND: disabled volumes: - type: bind source: ${MCP_WORKSPACE_HOST_PATH} target: /workspace user: "${MCP_UID:-10001}:${MCP_GID:-10001}" read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=128m cap_drop: [ALL] security_opt: [no-new-privileges:true] pids_limit: 128 mem_limit: 512m cpus: 1 init: true expose: ["3000"] networks: [coding-control] restart: unless-stopped dev-server-mcp: build: context: ./dev-server-mcp args: MCP_UID: ${MCP_UID:-10001} MCP_GID: ${MCP_GID:-10001} command: ["node", "src/index.js"] environment: DEV_SERVER_MCP_API_KEYS: ${DEV_SERVER_MCP_API_KEYS} DEV_SERVER_WORKER_TOKEN: ${DEV_SERVER_WORKER_TOKEN} DEV_SERVER_WORKER_URL: http://dev-server-worker:4000 user: "${MCP_UID:-10001}:${MCP_GID:-10001}" read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=64m cap_drop: [ALL] security_opt: [no-new-privileges:true] pids_limit: 64 mem_limit: 256m cpus: 0.5 init: true expose: ["3000"] networks: [dev-server-control] depends_on: [dev-server-worker] restart: unless-stopped dev-server-worker: build: context: ./dev-server-mcp args: MCP_UID: ${MCP_UID:-10001} MCP_GID: ${MCP_GID:-10001} command: ["node", "src/worker-index.js"] environment: DEV_SERVER_WORKER_TOKEN: ${DEV_SERVER_WORKER_TOKEN} DEV_SERVER_CONFIG: /config/services.json DEV_SERVER_WORKSPACE_ROOT: /workspace # What the image carries is not the same as what a caller may run. npx is deliberately absent: # it fetches and executes an arbitrary package by name, which would hand back the free choice # of command this allowlist exists to remove. DEV_SERVER_ALLOWED_COMMANDS: ${DEV_SERVER_ALLOWED_COMMANDS:-node,npm,python3,./mvnw} # One throwaway copy of the tree per execution, on a volume: a node_modules carries native # .node modules that will not load from the noexec /tmp, and 333 MB of it in RAM would eat a # quarter of this container's memory limit. DEV_SERVER_INSTANCES_ROOT: /instances DEV_SERVER_INSTANCE_HOME: /instances/.shared-home DEV_SERVER_NPM_CACHE: /npm-cache # The worker has no route to the internet. Everything an install fetches goes through the # proxy, which allows only the package registries. npm and pip read these; a JVM does not, # so a Maven service carries -Dhttps.proxyHost in its declared arguments. DEV_SERVER_EGRESS_PROXY: ${DEV_SERVER_EGRESS_PROXY:-http://egress-proxy:3128} DEV_SERVER_NO_PROXY: ${DEV_SERVER_NO_PROXY:-localhost,127.0.0.1,dev-server-worker} DEV_SERVER_PORT_RANGE: ${DEV_SERVER_PORT_RANGE:-5200-5219} DEV_SERVER_MAX_INSTANCES: ${DEV_SERVER_MAX_INSTANCES:-4} DEV_SERVER_MAX_WORKSPACE_BYTES: ${DEV_SERVER_MAX_WORKSPACE_BYTES:-536870912} volumes: - type: bind source: ${MCP_WORKSPACE_HOST_PATH} target: /workspace read_only: true - type: bind source: ${DEV_SERVER_SERVICES_CONFIG:-./dev-server-mcp/services.example.json} target: /config/services.json read_only: true - dev-server-instances:/instances - npm-cache:/npm-cache user: "${MCP_UID:-10001}:${MCP_GID:-10001}" read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=512m cap_drop: [ALL] security_opt: [no-new-privileges:true] pids_limit: 256 mem_limit: 2g cpus: 2 init: true networks: [dev-server-control, workspace-browser, proxy-control] depends_on: [egress-proxy] restart: unless-stopped egress-proxy: # Installing dependencies means fetching and running other people's code, so the worker has no # route to the internet at all and this is the only way out. One proxy serves every toolchain - # npm, pip and Maven - instead of a caching mirror per ecosystem. # # It filters CONNECT by host and nothing else: no interception, no certificates, no inspection # of the tunnels. That is the honest boundary. A package fetched from an allowed registry is # still other people's code, which is why the install runs with --ignore-scripts. image: ubuntu/squid:edge volumes: - type: bind source: ./egress-proxy.squid.conf target: /etc/squid/squid.conf read_only: true cap_drop: [ALL] cap_add: [SETUID, SETGID] security_opt: [no-new-privileges:true] pids_limit: 128 mem_limit: 256m cpus: 1 init: true expose: ["3128"] networks: [proxy-control, egress] restart: unless-stopped browser-mcp: build: context: ./browser-mcp environment: BROWSER_MCP_API_KEYS: ${BROWSER_MCP_API_KEYS} # Both: the shared service's fixed port, and the range the per-execution ones are given. # Keep the range aligned with DEV_SERVER_PORT_RANGE or a preview starts on a port the # browser is not allowed to open - which reads as a broken app, not as a refused origin. BROWSER_MCP_ALLOWED_ORIGINS: ${BROWSER_MCP_ALLOWED_ORIGINS:-http://dev-server-worker:5173,http://dev-server-worker:5200-5219} BROWSER_MCP_MAX_SESSIONS: ${BROWSER_MCP_MAX_SESSIONS:-8} user: "1000:1000" read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=1g shm_size: 1gb cap_drop: [ALL] cap_add: [SYS_CHROOT] security_opt: - no-new-privileges:true - seccomp:./browser-mcp/seccomp_profile.json pids_limit: 512 mem_limit: 2g cpus: 2 init: true expose: ["3000"] networks: [workspace-browser] restart: unless-stopped mcp-gateway: image: caddy:2 user: "${MCP_UID:-10001}:${MCP_GID:-10001}" read_only: true tmpfs: # /config only. /data is a volume instead, because Caddy keeps the certificate and its ACME # account key there: on tmpfs both would be thrown away at every restart, and asking the CA # for a fresh certificate each time runs into its duplicate-issuance limit within a week. - /config:rw,noexec,nosuid,size=16m volumes: - type: bind source: ${MCP_GATEWAY_CADDYFILE:-./mcp-stack.Caddyfile} target: /etc/caddy/Caddyfile read_only: true # Where Caddy keeps the certificate and its account key. A volume, so a restart does not # ask the CA for a new one: repeated issuance runs into rate limits and looks like abuse. - caddy-data:/data cap_drop: [ALL] cap_add: [NET_BIND_SERVICE] security_opt: [no-new-privileges:true] pids_limit: 64 mem_limit: 128m cpus: 0.5 environment: # Read by the VM Caddyfile. On a laptop the plain-HTTP one ignores them. MCP_SITE_ADDRESS: ${MCP_SITE_ADDRESS:-:3100} MCP_TLS_CONTACT: ${MCP_TLS_CONTACT:-} ports: # One port serving every server by path, which is the shape the workflow manager's catalog # uses. The three below stay for clients configured before it existed. # # Loopback, always. These are the servers without TLS in front of them; the VM overlay adds # 443 beside them rather than replacing them, and compose merges port lists by appending - # so anything opened here would stay open there, as a plaintext way around the gateway. - "127.0.0.1:${MCP_GATEWAY_PORT:-3100}:3100" - "127.0.0.1:${CODING_AGENT_MCP_PORT:-3101}:3101" - "127.0.0.1:${DEV_SERVER_MCP_PORT:-3102}:3102" - "127.0.0.1:${BROWSER_MCP_PORT:-3103}:3103" networks: [gateway, coding-control, dev-server-control, workspace-browser] depends_on: [coding-agent-mcp, dev-server-mcp, browser-mcp] restart: unless-stopped networks: gateway: coding-control: internal: true dev-server-control: internal: true workspace-browser: internal: true # The worker and the proxy meet here, and nothing else does. Internal, so joining it grants no # route out: the proxy's own egress comes from the separate network below, which only it joins. proxy-control: internal: true egress: volumes: # Not tmpfs and not the workspace mount: the copies need to be writable, executable and on disk. dev-server-instances: npm-cache: caddy-data: