223 lines
8.4 KiB
YAML
223 lines
8.4 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
|
|
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:
|