dev-mcps/mcp-stack.compose.yml

217 lines
8.2 KiB
YAML

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
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: "10001: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
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: "10001: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: