Deploy the MCP servers from published images

The compose files, the gateway's Caddyfiles, the egress proxy's configuration and
the example .env, moved out of the mcps repository. That repository builds the four
servers and publishes their images to Docker Hub (luciolelii/*); this one only pulls
them, so a server needs neither the sources nor a build toolchain, and a deploy is a
new image tag.

The compose file names its images by one tag, MCP_IMAGE_TAG, defaulting to the build
it was last checked against. File names are unchanged, so existing commands and the
project name - and with it the volumes - stay as they were.

The dev-server image is built with a fixed user, 10001:10001, and the two volumes the
worker mounts take their owner from it; MCP_UID and MCP_GID therefore stay at 10001
and the workspace directory is given to that user, which the docs now say.

The VM settings in .env.example are commented out. They were live, so copying the file
to a laptop started Caddy in HTTPS mode and had it ask a certificate authority for the
production host name from a machine that is not that host.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-10-02 11:48:15 +02:00
commit 2e89c7e0b7
13 changed files with 1931 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
### Secrets and local configuration ###
# Holds the API keys of whoever runs the stack. The .example files are the ones to read.
.env
*/.env
# The live service definitions of one deployment; services.example.json is the documented one.
dev-server-mcp/services.json
browser-mcp/services.json
### Editor and system ###
.DS_Store
.idea/
.vscode/

197
ENVIRONMENT.md Normal file
View File

@ -0,0 +1,197 @@
# Environment variables
Everything an operator can set for the MCP stack, in three parts:
1. [The stack's `.env`](#1-the-stacks-env) - what you write in `.env` next to `mcp-stack.compose.yml`.
Split into **required** and **optional**.
2. [What the compose file sets itself](#2-what-the-compose-file-sets-itself) - do not put these in `.env`.
3. [Per-server variables](#3-per-server-variables-running-a-server-by-hand) - only for starting one
image by hand with `docker run`, outside Compose.
`mcp-stack.env.example` is the template: `cp mcp-stack.env.example .env`. `.env` is ignored by git
and holds secrets, so it stays on the machine that runs the stack.
> Compose substitutes an unset variable with an empty string and only warns. A "required" variable
> below is one whose absence makes the service refuse to start or the stack unusable - not one
> Compose itself rejects.
## 1. The stack's `.env`
### Required
| Variable | What it is | Notes |
|---|---|---|
| `CODING_AGENT_MCP_API_KEYS` | Bearer tokens that may call the coding-agent MCP. Comma-separated, optionally `subject=token`. | Each token at least 32 characters. The server refuses to start with none. This is the `key` in the node's catalog entry. |
| `DEV_SERVER_MCP_API_KEYS` | Same, for the dev-server MCP. | Use a different token from the other servers. |
| `BROWSER_MCP_API_KEYS` | Same, for the browser MCP. | |
| `MINIO_MCP_API_KEYS` | Same, for the MinIO MCP. | |
| `DEV_SERVER_WORKER_TOKEN` | Shared secret between `dev-server-mcp` and `dev-server-worker`. | At least 32 characters; both refuse to start otherwise. Never leaves the stack, so it is not a catalog value. |
| `MCP_WORKSPACE_HOST_PATH` | Absolute path of the host directory mounted as `/workspace`. | Bind-mount source, so empty fails. A dedicated project directory - never a home directory or `/`. |
Generate a token with `openssl rand -hex 32`.
### Required in practice
| Variable | Default | Why |
|---|---|---|
| `MCP_UID`, `MCP_GID` | `10001` / `10001` | Keep them. The images run as 10001:10001, and the dev-server's two volumes take their owner from the image, so another value leaves the worker unable to write to them. Give `MCP_WORKSPACE_HOST_PATH` to `10001:10001` instead. This file is read literally, no shell runs over it. |
### Optional
**Images**
| Variable | Default | Meaning |
|---|---|---|
| `MCP_IMAGE_TAG` | the hash named in `mcp-stack.compose.yml` | Which build of the four `luciolelii/*` images to run: a commit hash of the `mcps` repository, or `latest`. Bumping it is a deploy. |
**MinIO MCP**
| Variable | Default | Meaning |
|---|---|---|
| `MINIO_MCP_INTERNAL_BUCKET_PREFIX` | `exec-` | Every internal-mode bucket is `<prefix><execution id>`. Cannot be empty; at most 27 lowercase letters, digits or `-`. It is the boundary that keeps the API key inside buckets this server made. |
| `MINIO_MCP_ALLOWED_ENDPOINTS` | empty (any) | Comma-separated S3 origins, e.g. `https://s3.example.org`. **Set it on an exposed server.** It applies to both modes, so the internal MinIO's endpoint must be on it. |
| `MINIO_MCP_DEFAULT_REGION` | `us-east-1` | Region when a session names none. |
| `MINIO_MCP_MAX_OBJECT_BYTES` | `1048576` (1 MiB) | Limit for one read and one write. Ceiling 32 MiB. |
| `MINIO_MCP_MAX_CONNECTIONS_PER_SESSION` | `8` | External mode only. Ceiling 64. |
The internal MinIO's endpoint and keys are **not** variables here: they are headers of the
`minio-mcp-internal` entry in the workflow manager's catalog (`x-minio-endpoint`,
`x-minio-access-key`, `x-minio-secret-key`, optionally `x-minio-region`).
**Dev server**
| Variable | Default | Meaning |
|---|---|---|
| `DEV_SERVER_SERVICES_CONFIG` | `./dev-server-mcp/services.example.json` | The services a caller may start. Copy `services.example.json` and edit the copy. Mounted read-only. |
| `DEV_SERVER_ALLOWED_COMMANDS` | `node,npm,python3,./mvnw` | What a service may run. `npx` is left out on purpose: it runs an arbitrary package by name. |
| `DEV_SERVER_PORT_RANGE` | `5200-5219` | Ports given to per-execution instances. Keep aligned with `BROWSER_MCP_ALLOWED_ORIGINS`. |
| `DEV_SERVER_MAX_INSTANCES` | `4` | Concurrent instances. Size by memory: two frontend builds saturate the worker's 2 GB. |
| `DEV_SERVER_MAX_WORKSPACE_BYTES` | `536870912` (512 MiB) | Largest workspace copied for an instance. |
| `DEV_SERVER_IDLE_TIMEOUT_SECONDS` | `7200` | Idle time after which an instance is stopped (its copy is kept). |
| `DEV_SERVER_MAX_LIFETIME_SECONDS` | `86400` | Absolute lifetime; then everything, copy included, is discarded. |
| `DEV_SERVER_PREVIEW_BASE_URL` | empty | Public address a person opens a preview at. Empty means no preview proxy and no preview links. |
| `DEV_SERVER_PREVIEW_PORT` | `4500` | Port of the preview proxy inside the worker. |
| `DEV_SERVER_EGRESS_PROXY` | `http://egress-proxy:3128` | The only route out the worker has, for installs. |
| `DEV_SERVER_NO_PROXY` | `localhost,127.0.0.1,dev-server-worker` | Hosts that bypass that proxy. |
**Browser**
| Variable | Default | Meaning |
|---|---|---|
| `BROWSER_MCP_ALLOWED_ORIGINS` | `http://dev-server-worker:5173,http://dev-server-worker:5200-5219` | Origins the browser may open, exact match. Keep the range aligned with `DEV_SERVER_PORT_RANGE`, or a preview looks like a broken app. |
| `BROWSER_MCP_MAX_SESSIONS` | `8` | Concurrent browser sessions. |
**Published ports** (all on loopback, `127.0.0.1`)
| Variable | Default | |
|---|---|---|
| `MCP_GATEWAY_PORT` | `3100` | One port serving every server by path (`/coding-agent/`, `/dev-server/`, `/browser/`, `/minio/`). What the catalog expects. |
| `CODING_AGENT_MCP_PORT` | `3101` | Per-server ports, kept for clients configured before the gateway. |
| `DEV_SERVER_MCP_PORT` | `3102` | |
| `BROWSER_MCP_PORT` | `3103` | |
| `MINIO_MCP_PORT` | `3104` | |
**Dedicated VM** (with the overlay `mcp-stack.vm.compose.yml`)
| Variable | Default | Meaning |
|---|---|---|
| `MCP_GATEWAY_CADDYFILE` | `./mcp-stack.Caddyfile` | Set to `./mcp-stack.vm.Caddyfile` on the VM: it serves HTTPS. |
| `MCP_SITE_ADDRESS` | `:3100` | The public host name Caddy gets a certificate for, e.g. `mcp-stack.example.org`. Required with the VM Caddyfile. |
| `MCP_TLS_CONTACT` | empty | Contact e-mail for the certificate authority. Set it on the VM. |
| `MCP_BIND_ADDRESS` | `0.0.0.0` | Address ports 80 and 443 bind to. |
## 2. What the compose file sets itself
Fixed in `mcp-stack.compose.yml`. They are not read from `.env`, and overriding them there has no
effect.
| Variable | Set on | Value |
|---|---|---|
| `CODING_AGENT_MCP_EXECUTION_BACKEND` | coding-agent-mcp | `disabled`, so no command-running tool is offered. |
| `DEV_SERVER_WORKER_URL` | dev-server-mcp | `http://dev-server-worker:4000` |
| `DEV_SERVER_CONFIG` | dev-server-worker | `/config/services.json` |
| `DEV_SERVER_WORKSPACE_ROOT` | dev-server-worker | `/workspace` |
| `DEV_SERVER_INSTANCES_ROOT`, `DEV_SERVER_INSTANCE_HOME`, `DEV_SERVER_NPM_CACHE` | dev-server-worker | volumes `/instances`, `/instances/.shared-home`, `/npm-cache` |
Container paths, user ids of the browser (`1000:1000`), memory and CPU limits are fixed in the file
too.
## 3. Per-server variables (running an image by hand)
Only for `docker run` of one image, outside Compose. The stack does not forward these, so they
cannot be set through `.env`. Defaults shown are the ones in the code.
### coding-agent-mcp
| Variable | Default | |
|---|---|---|
| `CODING_AGENT_MCP_API_KEYS` | none, **required** over HTTP | Also read from `MCP_API_KEYS` when unset. |
| `CODING_AGENT_MCP_ROOTS` | the working directory | Workspace root(s), when no `--root` flag is given. |
| `CODING_AGENT_MCP_EXECUTION_BACKEND` | `local` on stdio, `disabled` on HTTP | Whether command execution is offered. |
| `CODING_AGENT_MCP_CORS_ORIGINS` | empty | Comma-separated browser origins allowed. |
| `CODING_AGENT_MCP_AUDIT_LOG` | none | Path of an audit log file. |
| `MCP_DEBUG_REQUESTS` | off | `1`/`true`/`yes`/`on` logs requests. Leave it off in production. |
### dev-server-mcp
| Variable | Default | |
|---|---|---|
| `DEV_SERVER_MCP_API_KEYS` | none, **required** | |
| `DEV_SERVER_WORKER_TOKEN` | none, **required** | At least 32 characters. |
| `DEV_SERVER_WORKER_URL` | `http://dev-server-worker:4000` | |
| `DEV_SERVER_MCP_HOST` / `_PORT` | `0.0.0.0` / `3000` | |
| `DEV_SERVER_MCP_MAX_SESSIONS` | `64` | |
| `DEV_SERVER_MCP_SESSION_TTL_SECONDS` | `1800` | |
| `DEV_SERVER_MCP_CORS_ORIGINS` | empty | |
### dev-server-worker
| Variable | Default | |
|---|---|---|
| `DEV_SERVER_WORKER_TOKEN` | none, **required** | At least 32 characters. |
| `DEV_SERVER_CONFIG` | `/config/services.json` | The services file. |
| `DEV_SERVER_WORKSPACE_ROOT` | `/workspace` | |
| `DEV_SERVER_ALLOWED_COMMANDS` | none, **required** | The worker refuses to start with it empty, and every service's `command` must be on it. |
| `DEV_SERVER_INSTANCES_ROOT` | none | Where per-execution copies live. |
| `DEV_SERVER_INSTANCE_HOME` | `/tmp/dev-server` | |
| `DEV_SERVER_NPM_CACHE` | none | |
| `DEV_SERVER_NPM_REGISTRY` | none | Registry override. |
| `DEV_SERVER_PORT_RANGE` | `5200-5219` | Ports for instances: a rising range above 1023. |
| `DEV_SERVER_MAX_INSTANCES` | `4` | |
| `DEV_SERVER_MAX_WORKSPACE_BYTES` / `_ENTRIES` | 512 MiB / `20000` | |
| `DEV_SERVER_IDLE_TIMEOUT_SECONDS` / `_MAX_LIFETIME_SECONDS` | `7200` / `86400` | |
| `DEV_SERVER_EGRESS_PROXY` / `_NO_PROXY` | none | Proxy for installs. |
| `DEV_SERVER_PREVIEW_BASE_URL` | none | |
| `DEV_SERVER_PREVIEW_HOST` / `_PORT` | `0.0.0.0` / `4500` | |
| `DEV_SERVER_CHILD_PATH` | `/opt/java/openjdk/bin:/usr/local/bin:/usr/bin:/bin` | `PATH` of started services. |
| `JAVA_HOME` | none | Passed to started services. |
| `DEV_SERVER_WORKER_HOST` / `_PORT` | `0.0.0.0` / `4000` | |
| `DEV_SERVER_WORKER_REQUEST_TIMEOUT_MS` | `620000` | |
### browser-mcp
| Variable | Default | |
|---|---|---|
| `BROWSER_MCP_API_KEYS` | none, **required** | |
| `BROWSER_MCP_ALLOWED_ORIGINS` | none, **required** | The server refuses to start with it empty. Exact origins, plus ranges such as `http://host:5200-5219`. |
| `BROWSER_MCP_MAX_SESSIONS` | `16` | |
| `BROWSER_MCP_DEFAULT_TIMEOUT_MS` | `15000` | |
| `BROWSER_MCP_HOST` / `_PORT` | `0.0.0.0` / `3000` | |
| `BROWSER_MCP_SESSION_TTL_SECONDS` | `1800` | |
| `BROWSER_MCP_CORS_ORIGINS` | empty | |
### minio-mcp
| Variable | Default | |
|---|---|---|
| `MINIO_MCP_API_KEYS` | none, **required** | |
| `MINIO_MCP_INTERNAL_BUCKET_PREFIX` | `exec-` | See above. |
| `MINIO_MCP_ALLOWED_ENDPOINTS` | empty (any) | |
| `MINIO_MCP_DEFAULT_REGION` | `us-east-1` | |
| `MINIO_MCP_MAX_OBJECT_BYTES` | `1048576` | Ceiling 32 MiB. |
| `MINIO_MCP_MAX_CONNECTIONS_PER_SESSION` | `8` | Ceiling 64. |
| `MINIO_MCP_MAX_SESSIONS` | `32` | |
| `MINIO_MCP_SESSION_TTL_SECONDS` | `1800` | |
| `MINIO_MCP_REQUEST_MAX_BYTES` | derived from `MAX_OBJECT_BYTES` | HTTP body limit; base64 adds a third. |
| `MINIO_MCP_HOST` / `_PORT` | `0.0.0.0` / `3000` | |
| `MINIO_MCP_CORS_ORIGINS` | empty | |

85
MCP-STACK.md Normal file
View File

@ -0,0 +1,85 @@
# Secure MCP stack
The Docker Compose deployment of the MCP servers: the compose files, the gateway's Caddyfiles, the
egress proxy's configuration and the example `.env`. The servers themselves live in the `mcps`
repository, which builds their images and publishes them to Docker Hub; this repository only pulls
them, so a server running it needs neither the sources nor a build toolchain.
| Image | What it is |
|---|---|
| `luciolelii/coding-agent-mcp` | Scoped workspace editing. Task execution is disabled in this stack. |
| `luciolelii/dev-server-mcp` | Authenticated facade for a private, allowlisted development-server worker. One instance per execution, each in a throwaway copy of that execution's tree and on its own port. The same image runs the facade and the worker. |
| `luciolelii/browser-mcp` | Playwright automation restricted to exact allowed origins. |
| `luciolelii/minio-mcp` | Bounded list/read/stat/write access to object storage, with no delete tool. In internal mode (catalog entry `minio-mcp-internal`, header `x-minio-scope`) it uses the platform's MinIO whose endpoint and keys the catalog entry sends as headers, and one bucket per execution, `<MINIO_MCP_INTERNAL_BUCKET_PREFIX><execution id>`, created when the session opens; in external mode the model opens session-scoped connections to MinIO deployments itself. |
`egress-proxy` is the development worker's only route to package registries: it allows their CONNECT
requests and refuses everything else.
The MCP client orchestrates the servers; they do not share MCP sessions or credentials.
## Start
1. Copy `mcp-stack.env.example` to `.env` and replace every token with an independent random value. Every variable, required or optional, is documented in [ENVIRONMENT.md](ENVIRONMENT.md).
2. Set `MCP_WORKSPACE_HOST_PATH` to one dedicated project directory, owned by `10001:10001`
(`sudo chown -R 10001:10001 <dir>`): that is the user the images run as.
3. Copy and edit `dev-server-mcp/services.example.json`, and point `DEV_SERVER_SERVICES_CONFIG` at the copy. It ships four worked service definitions: a shared Vite tree, and per-execution ones for Node, Java (through the project's own `./mvnw`) and Python.
Keep `BROWSER_MCP_ALLOWED_ORIGINS` aligned with `DEV_SERVER_PORT_RANGE`: the browser reaches a preview only on a port that range covers.
4. Optionally set `MINIO_MCP_ALLOWED_ENDPOINTS` to the comma-separated MinIO S3 origins clients may use.
5. If the Docker Hub repositories are private, run `docker login` on this host first.
6. Pull and run:
```sh
docker compose --env-file .env -f mcp-stack.compose.yml pull
docker compose --env-file .env -f mcp-stack.compose.yml up -d
```
On a dedicated VM add the overlay to **both** commands, or the gateway loses ports 80 and 443:
`-f mcp-stack.compose.yml -f mcp-stack.vm.compose.yml`.
## Updating
A deploy is a new image tag. Images are published from `mcps` and tagged with its commit hash and
`latest`. Set `MCP_IMAGE_TAG` in `.env` (or edit the default in `mcp-stack.compose.yml`) to the new
hash, then `pull` and `up -d` as above. To restart only some services without touching the others,
name them and add `--no-deps`: `up -d --no-deps minio-mcp mcp-gateway`.
Local MCP endpoints:
- `http://127.0.0.1:3101/mcp` — coding agent
- `http://127.0.0.1:3102/mcp` — development server
- `http://127.0.0.1:3103/mcp` — browser
- `http://127.0.0.1:3104/mcp` — MinIO
The same endpoints are also available through the unified gateway at `/coding-agent/mcp`,
`/dev-server/mcp`, `/browser/mcp` and `/minio/mcp` on port 3100 (or the configured HTTPS host on
the VM deployment).
Example Codex client configuration (`.codex/config.toml` in a trusted project):
```toml
[mcp_servers.coding_agent]
url = "http://127.0.0.1:3101/mcp"
bearer_token_env_var = "CODING_AGENT_MCP_TOKEN"
[mcp_servers.dev_server]
url = "http://127.0.0.1:3102/mcp"
bearer_token_env_var = "DEV_SERVER_MCP_TOKEN"
[mcp_servers.browser]
url = "http://127.0.0.1:3103/mcp"
bearer_token_env_var = "BROWSER_MCP_TOKEN"
[mcp_servers.minio]
url = "http://127.0.0.1:3104/mcp"
bearer_token_env_var = "MINIO_MCP_TOKEN"
```
Set those four variables in the MCP client's environment to the token portions configured for the corresponding servers. Do not reuse a token between servers.
Only the minimal Caddy gateway publishes loopback ports. The coding, development and browser services stay exclusively on internal networks. `minio-mcp` has outbound connectivity because each MCP session can open connections to remote S3 endpoints. Set `MINIO_MCP_ALLOWED_ENDPOINTS` whenever those endpoints are known; leaving it empty gives authenticated MCP clients an intentional network-request capability. For remote use, use the included VM overlay so Caddy terminates TLS, and add rate limiting at the network edge when appropriate.
The worker and browser networks are marked internal, no service uses host networking or the Docker socket, and the development-server workspace mount is read-only. Container isolation reduces the blast radius but is not a substitute for an ephemeral VM/microVM when executing hostile multi-tenant code.
Installing dependencies means fetching and running other people's code, so three things stand between it and the rest of the stack: the tree runs in a copy of itself and never in the workspace mount, the install runs with package scripts disabled, and the only way out of the worker is a proxy that allows the registries and nothing else. Each execution also gets a `HOME` of its own, which is what keeps Maven's `~/.m2` from becoming a channel between executions — npm's shared cache is safe because `npm ci` verifies every package against the lockfile, and Maven has no lockfile to verify against.
`coding-agent-mcp` intentionally retains write access to the selected project because editing is its purpose. Point `MCP_WORKSPACE_HOST_PATH` only at a dedicated, backed-up directory on storage with a disk quota; never point it at a home directory, repository collection, or filesystem root.

16
README.md Normal file
View File

@ -0,0 +1,16 @@
# MCP stack - Docker Compose deployment
Runs the MCP servers (coding agent, development server, browser, MinIO) behind one Caddy gateway.
The servers' code and their images live in the `mcps` repository; this repository only deploys them
from Docker Hub (`luciolelii/*`), so the host needs no sources and no build toolchain.
```sh
cp mcp-stack.env.example .env # then fill it in: see ENVIRONMENT.md
docker compose --env-file .env -f mcp-stack.compose.yml pull
docker compose --env-file .env -f mcp-stack.compose.yml up -d
```
On a dedicated VM add `-f mcp-stack.vm.compose.yml` to both commands.
- [MCP-STACK.md](MCP-STACK.md) - what runs, how to start and update it, security notes.
- [ENVIRONMENT.md](ENVIRONMENT.md) - every environment variable, required and optional.

View File

@ -0,0 +1,831 @@
{
"defaultAction": "SCMP_ACT_ERRNO",
"archMap": [
{
"architecture": "SCMP_ARCH_X86_64",
"subArchitectures": [
"SCMP_ARCH_X86",
"SCMP_ARCH_X32"
]
},
{
"architecture": "SCMP_ARCH_AARCH64",
"subArchitectures": [
"SCMP_ARCH_ARM"
]
},
{
"architecture": "SCMP_ARCH_MIPS64",
"subArchitectures": [
"SCMP_ARCH_MIPS",
"SCMP_ARCH_MIPS64N32"
]
},
{
"architecture": "SCMP_ARCH_MIPS64N32",
"subArchitectures": [
"SCMP_ARCH_MIPS",
"SCMP_ARCH_MIPS64"
]
},
{
"architecture": "SCMP_ARCH_MIPSEL64",
"subArchitectures": [
"SCMP_ARCH_MIPSEL",
"SCMP_ARCH_MIPSEL64N32"
]
},
{
"architecture": "SCMP_ARCH_MIPSEL64N32",
"subArchitectures": [
"SCMP_ARCH_MIPSEL",
"SCMP_ARCH_MIPSEL64"
]
},
{
"architecture": "SCMP_ARCH_S390X",
"subArchitectures": [
"SCMP_ARCH_S390"
]
}
],
"syscalls": [
{
"comment": "Allow create user namespaces",
"names": [
"clone",
"setns",
"unshare"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"includes": {},
"excludes": {}
},
{
"names": [
"accept",
"accept4",
"access",
"adjtimex",
"alarm",
"bind",
"brk",
"capget",
"capset",
"chdir",
"chmod",
"chown",
"chown32",
"clock_adjtime",
"clock_adjtime64",
"clock_getres",
"clock_getres_time64",
"clock_gettime",
"clock_gettime64",
"clock_nanosleep",
"clock_nanosleep_time64",
"close",
"connect",
"copy_file_range",
"creat",
"dup",
"dup2",
"dup3",
"epoll_create",
"epoll_create1",
"epoll_ctl",
"epoll_ctl_old",
"epoll_pwait",
"epoll_wait",
"epoll_wait_old",
"eventfd",
"eventfd2",
"execve",
"execveat",
"exit",
"exit_group",
"faccessat",
"fadvise64",
"fadvise64_64",
"fallocate",
"fanotify_mark",
"fchdir",
"fchmod",
"fchmodat",
"fchown",
"fchown32",
"fchownat",
"fcntl",
"fcntl64",
"fdatasync",
"fgetxattr",
"flistxattr",
"flock",
"fork",
"fremovexattr",
"fsetxattr",
"fstat",
"fstat64",
"fstatat64",
"fstatfs",
"fstatfs64",
"fsync",
"ftruncate",
"ftruncate64",
"futex",
"futex_time64",
"futimesat",
"getcpu",
"getcwd",
"getdents",
"getdents64",
"getegid",
"getegid32",
"geteuid",
"geteuid32",
"getgid",
"getgid32",
"getgroups",
"getgroups32",
"getitimer",
"getpeername",
"getpgid",
"getpgrp",
"getpid",
"getppid",
"getpriority",
"getrandom",
"getresgid",
"getresgid32",
"getresuid",
"getresuid32",
"getrlimit",
"get_robust_list",
"getrusage",
"getsid",
"getsockname",
"getsockopt",
"get_thread_area",
"gettid",
"gettimeofday",
"getuid",
"getuid32",
"getxattr",
"inotify_add_watch",
"inotify_init",
"inotify_init1",
"inotify_rm_watch",
"io_cancel",
"ioctl",
"io_destroy",
"io_getevents",
"io_pgetevents",
"io_pgetevents_time64",
"ioprio_get",
"ioprio_set",
"io_setup",
"io_submit",
"io_uring_enter",
"io_uring_register",
"io_uring_setup",
"ipc",
"kill",
"lchown",
"lchown32",
"lgetxattr",
"link",
"linkat",
"listen",
"listxattr",
"llistxattr",
"_llseek",
"lremovexattr",
"lseek",
"lsetxattr",
"lstat",
"lstat64",
"madvise",
"membarrier",
"memfd_create",
"mincore",
"mkdir",
"mkdirat",
"mknod",
"mknodat",
"mlock",
"mlock2",
"mlockall",
"mmap",
"mmap2",
"mprotect",
"mq_getsetattr",
"mq_notify",
"mq_open",
"mq_timedreceive",
"mq_timedreceive_time64",
"mq_timedsend",
"mq_timedsend_time64",
"mq_unlink",
"mremap",
"msgctl",
"msgget",
"msgrcv",
"msgsnd",
"msync",
"munlock",
"munlockall",
"munmap",
"nanosleep",
"newfstatat",
"_newselect",
"open",
"openat",
"pause",
"pipe",
"pipe2",
"poll",
"ppoll",
"ppoll_time64",
"prctl",
"pread64",
"preadv",
"preadv2",
"prlimit64",
"pselect6",
"pselect6_time64",
"pwrite64",
"pwritev",
"pwritev2",
"read",
"readahead",
"readlink",
"readlinkat",
"readv",
"recv",
"recvfrom",
"recvmmsg",
"recvmmsg_time64",
"recvmsg",
"remap_file_pages",
"removexattr",
"rename",
"renameat",
"renameat2",
"restart_syscall",
"rmdir",
"rseq",
"rt_sigaction",
"rt_sigpending",
"rt_sigprocmask",
"rt_sigqueueinfo",
"rt_sigreturn",
"rt_sigsuspend",
"rt_sigtimedwait",
"rt_sigtimedwait_time64",
"rt_tgsigqueueinfo",
"sched_getaffinity",
"sched_getattr",
"sched_getparam",
"sched_get_priority_max",
"sched_get_priority_min",
"sched_getscheduler",
"sched_rr_get_interval",
"sched_rr_get_interval_time64",
"sched_setaffinity",
"sched_setattr",
"sched_setparam",
"sched_setscheduler",
"sched_yield",
"seccomp",
"select",
"semctl",
"semget",
"semop",
"semtimedop",
"semtimedop_time64",
"send",
"sendfile",
"sendfile64",
"sendmmsg",
"sendmsg",
"sendto",
"setfsgid",
"setfsgid32",
"setfsuid",
"setfsuid32",
"setgid",
"setgid32",
"setgroups",
"setgroups32",
"setitimer",
"setpgid",
"setpriority",
"setregid",
"setregid32",
"setresgid",
"setresgid32",
"setresuid",
"setresuid32",
"setreuid",
"setreuid32",
"setrlimit",
"set_robust_list",
"setsid",
"setsockopt",
"set_thread_area",
"set_tid_address",
"setuid",
"setuid32",
"setxattr",
"shmat",
"shmctl",
"shmdt",
"shmget",
"shutdown",
"sigaltstack",
"signalfd",
"signalfd4",
"sigprocmask",
"sigreturn",
"socket",
"socketcall",
"socketpair",
"splice",
"stat",
"stat64",
"statfs",
"statfs64",
"statx",
"symlink",
"symlinkat",
"sync",
"sync_file_range",
"syncfs",
"sysinfo",
"tee",
"tgkill",
"time",
"timer_create",
"timer_delete",
"timer_getoverrun",
"timer_gettime",
"timer_gettime64",
"timer_settime",
"timer_settime64",
"timerfd_create",
"timerfd_gettime",
"timerfd_gettime64",
"timerfd_settime",
"timerfd_settime64",
"times",
"tkill",
"truncate",
"truncate64",
"ugetrlimit",
"umask",
"uname",
"unlink",
"unlinkat",
"utime",
"utimensat",
"utimensat_time64",
"utimes",
"vfork",
"vmsplice",
"wait4",
"waitid",
"waitpid",
"write",
"writev"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {},
"excludes": {}
},
{
"names": [
"ptrace"
],
"action": "SCMP_ACT_ALLOW",
"args": null,
"comment": "",
"includes": {
"minKernel": "4.8"
},
"excludes": {}
},
{
"names": [
"personality"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 0,
"value": 0,
"valueTwo": 0,
"op": "SCMP_CMP_EQ"
}
],
"comment": "",
"includes": {},
"excludes": {}
},
{
"names": [
"personality"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 0,
"value": 8,
"valueTwo": 0,
"op": "SCMP_CMP_EQ"
}
],
"comment": "",
"includes": {},
"excludes": {}
},
{
"names": [
"personality"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 0,
"value": 131072,
"valueTwo": 0,
"op": "SCMP_CMP_EQ"
}
],
"comment": "",
"includes": {},
"excludes": {}
},
{
"names": [
"personality"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 0,
"value": 131080,
"valueTwo": 0,
"op": "SCMP_CMP_EQ"
}
],
"comment": "",
"includes": {},
"excludes": {}
},
{
"names": [
"personality"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 0,
"value": 4294967295,
"valueTwo": 0,
"op": "SCMP_CMP_EQ"
}
],
"comment": "",
"includes": {},
"excludes": {}
},
{
"names": [
"sync_file_range2"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"arches": [
"ppc64le"
]
},
"excludes": {}
},
{
"names": [
"arm_fadvise64_64",
"arm_sync_file_range",
"sync_file_range2",
"breakpoint",
"cacheflush",
"set_tls"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"arches": [
"arm",
"arm64"
]
},
"excludes": {}
},
{
"names": [
"arch_prctl"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"arches": [
"amd64",
"x32"
]
},
"excludes": {}
},
{
"names": [
"modify_ldt"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"arches": [
"amd64",
"x32",
"x86"
]
},
"excludes": {}
},
{
"names": [
"s390_pci_mmio_read",
"s390_pci_mmio_write",
"s390_runtime_instr"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"arches": [
"s390",
"s390x"
]
},
"excludes": {}
},
{
"names": [
"open_by_handle_at"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_DAC_READ_SEARCH"
]
},
"excludes": {}
},
{
"names": [
"bpf",
"clone",
"fanotify_init",
"lookup_dcookie",
"mount",
"name_to_handle_at",
"perf_event_open",
"quotactl",
"setdomainname",
"sethostname",
"setns",
"syslog",
"umount",
"umount2",
"unshare"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_SYS_ADMIN"
]
},
"excludes": {}
},
{
"names": [
"clone"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 0,
"value": 2114060288,
"valueTwo": 0,
"op": "SCMP_CMP_MASKED_EQ"
}
],
"comment": "",
"includes": {},
"excludes": {
"caps": [
"CAP_SYS_ADMIN"
],
"arches": [
"s390",
"s390x"
]
}
},
{
"names": [
"clone"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 1,
"value": 2114060288,
"valueTwo": 0,
"op": "SCMP_CMP_MASKED_EQ"
}
],
"comment": "s390 parameter ordering for clone is different",
"includes": {
"arches": [
"s390",
"s390x"
]
},
"excludes": {
"caps": [
"CAP_SYS_ADMIN"
]
}
},
{
"names": [
"reboot"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_SYS_BOOT"
]
},
"excludes": {}
},
{
"names": [
"chroot"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_SYS_CHROOT"
]
},
"excludes": {}
},
{
"names": [
"delete_module",
"init_module",
"finit_module"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_SYS_MODULE"
]
},
"excludes": {}
},
{
"names": [
"acct"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_SYS_PACCT"
]
},
"excludes": {}
},
{
"names": [
"kcmp",
"process_vm_readv",
"process_vm_writev",
"ptrace"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_SYS_PTRACE"
]
},
"excludes": {}
},
{
"names": [
"iopl",
"ioperm"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_SYS_RAWIO"
]
},
"excludes": {}
},
{
"names": [
"settimeofday",
"stime",
"clock_settime"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_SYS_TIME"
]
},
"excludes": {}
},
{
"names": [
"vhangup"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_SYS_TTY_CONFIG"
]
},
"excludes": {}
},
{
"names": [
"get_mempolicy",
"mbind",
"set_mempolicy"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_SYS_NICE"
]
},
"excludes": {}
},
{
"names": [
"syslog"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_SYSLOG"
]
},
"excludes": {}
}
]
}

View File

@ -0,0 +1,168 @@
{
"_comment": [
"Operator-owned. An MCP caller selects a service id and nothing else: commands, arguments,",
"directories and environment all come from here.",
"",
"workspaceMode 'shared' serves one fixed tree in the workspace mount, with its dependencies",
"installed ahead of time by the operator. 'execution' serves the tree the current execution",
"wrote, in a throwaway copy of it, and may take an allocated ${port}.",
"",
"Two flags are not optional in a service definition, both learned the hard way:",
" --host 0.0.0.0 a dev server left to its default binds IPv6 loopback only, so a health",
" check on 127.0.0.1 declares a perfectly live server dead, and no other",
" container can reach it.",
" --strictPort without it a dev server whose port is busy moves to another one and says",
" so only in its log, leaving the preview at an address nobody looks at."
],
"_comment_java": [
"./mvnw and not mvn: the wrapper pins the Maven version the project is built with, so the",
"image carries no Maven of its own to be used by mistake.",
"",
"The proxy is passed as system properties because a JVM ignores HTTP_PROXY entirely - the",
"worker sets those variables, and Maven is the one toolchain that cannot read them.",
"",
"A first build downloads the whole dependency tree into this execution's own ~/.m2, which is",
"why the timeouts here are the largest in the file. Past the MCP client's own 120 s request",
"timeout, use workspaceMode 'shared' with a prepared repository instead."
],
"_comment_python": [
"python3 -m uvicorn and not the uvicorn script: a --user install puts its console scripts in",
"$HOME/.local/bin, which is not on the sanitised PATH, while the module itself is found",
"because the per-execution HOME puts its user site on sys.path.",
"",
"--break-system-packages reads worse than it is: Debian marks its own python as externally",
"managed, and this flag only lifts that refusal. Combined with --user, nothing outside this",
"execution's HOME is written."
],
"services": {
"web": {
"command": "npm",
"args": [
"run",
"dev",
"--",
"--host",
"0.0.0.0",
"--port",
"5173",
"--strictPort"
],
"cwd": ".",
"workspaceMode": "shared",
"publicUrl": "http://dev-server-worker:5173",
"healthUrl": "http://127.0.0.1:5173/",
"startupTimeoutMs": 20000,
"shutdownTimeoutMs": 5000,
"env": {
"PORT": "5173"
}
},
"execution-web": {
"command": "npm",
"args": [
"run",
"dev",
"--",
"--host",
"0.0.0.0",
"--port",
"${port}",
"--strictPort"
],
"cwd": ".",
"workspaceMode": "execution",
"agentInstructions": "This service runs npm ci and then npm run dev. Before starting, create a valid package.json with a dev script and the matching package-lock.json. The dev server must honour PORT and bind to 0.0.0.0. Navigate only to the publicUrl returned by a successful start_service.",
"install": {
"command": "npm",
"args": [
"ci",
"--ignore-scripts",
"--no-audit",
"--no-fund"
],
"timeoutMs": 90000
},
"publicUrl": "http://dev-server-worker:${port}",
"healthUrl": "http://127.0.0.1:${port}/",
"startupTimeoutMs": 60000,
"shutdownTimeoutMs": 5000,
"env": {
"PORT": "${port}"
}
},
"execution-node": {
"command": "node",
"args": ["server.js", "${port}"],
"cwd": ".",
"workspaceMode": "execution",
"agentInstructions": "Use this for a dependency-free page in an empty workspace. Create server.js in the workspace root; it must serve the page, listen on 0.0.0.0, and use process.env.PORT. No package.json or lockfile is required. Navigate only to the publicUrl returned by a successful start_service.",
"publicUrl": "http://dev-server-worker:${port}",
"healthUrl": "http://127.0.0.1:${port}/",
"startupTimeoutMs": 20000,
"shutdownTimeoutMs": 5000,
"env": {
"PORT": "${port}"
}
},
"execution-api": {
"command": "./mvnw",
"args": [
"-B",
"-q",
"-Dhttps.proxyHost=egress-proxy",
"-Dhttps.proxyPort=3128",
"spring-boot:run",
"-Dspring-boot.run.arguments=--server.port=${port} --server.address=0.0.0.0"
],
"cwd": ".",
"workspaceMode": "execution",
"install": {
"command": "./mvnw",
"args": [
"-B",
"-q",
"-Dhttps.proxyHost=egress-proxy",
"-Dhttps.proxyPort=3128",
"dependency:go-offline"
],
"timeoutMs": 600000
},
"publicUrl": "http://dev-server-worker:${port}",
"healthUrl": "http://127.0.0.1:${port}/actuator/health",
"startupTimeoutMs": 120000,
"shutdownTimeoutMs": 15000
},
"execution-python": {
"command": "python3",
"args": [
"-m",
"uvicorn",
"main:app",
"--host",
"0.0.0.0",
"--port",
"${port}"
],
"cwd": ".",
"workspaceMode": "execution",
"install": {
"command": "python3",
"args": [
"-m",
"pip",
"install",
"--user",
"--break-system-packages",
"--no-input",
"-r",
"requirements.txt"
],
"timeoutMs": 180000
},
"publicUrl": "http://dev-server-worker:${port}",
"healthUrl": "http://127.0.0.1:${port}/",
"startupTimeoutMs": 60000,
"shutdownTimeoutMs": 5000
}
}
}

View File

@ -0,0 +1,29 @@
{
"_comment": [
"A first end-to-end configuration, deliberately dependency-free.",
"",
"'node-app' runs whatever server.js the execution wrote, with node alone and no install step.",
"That is the point: npm ci needs a lockfile, so the shortest honest loop for an agent starting",
"from an empty directory is a server built on node's own http module. Once that works, move to",
"services.example.json, where execution-web installs a real dependency tree.",
"",
"The service reads its port from PORT as well as from the argument, because an agent writing a",
"server from scratch is more likely to reach for process.env.PORT than to parse argv."
],
"services": {
"node-app": {
"command": "node",
"args": ["server.js", "${port}"],
"cwd": ".",
"workspaceMode": "execution",
"agentInstructions": "For an empty workspace, create server.js in the workspace root. It is started with Node and must serve the page on host 0.0.0.0 using process.env.PORT. After a successful start, navigate only to the publicUrl returned by start_service.",
"publicUrl": "http://dev-server-worker:${port}",
"healthUrl": "http://127.0.0.1:${port}/",
"startupTimeoutMs": 20000,
"shutdownTimeoutMs": 5000,
"env": {
"PORT": "${port}"
}
}
}
}

48
egress-proxy.squid.conf Normal file
View File

@ -0,0 +1,48 @@
# The only route out of the dev-server worker.
#
# It allows CONNECT tunnels to the package registries and refuses everything else. There is no
# interception and no certificate of ours in the middle: the proxy sees the host a client asks for
# and nothing more. That is the whole boundary, and it is worth being clear about what it is not -
# a package pulled from an allowed registry is still third-party code, which is why installs run
# with scripts disabled.
#
# Add a host here only when an install has failed for the want of it, and add the exact host.
http_port 3128
# One directive per line, and each domain written with a leading dot so it covers the host and
# its subdomains. Squid refuses a list that names both a domain and something beneath it, so
# registry.npmjs.org is not spelled out: .npmjs.org already includes it.
acl registries dstdomain .npmjs.org
acl registries dstdomain .pypi.org
acl registries dstdomain .pythonhosted.org
acl registries dstdomain .maven.apache.org
acl registries dstdomain .maven.org
acl ssl_ports port 443
acl connect_method method CONNECT
# CONNECT to an allowed registry on 443, and that is all. Plain HTTP is not allowed even to these
# hosts: every one of them serves HTTPS, so a plain request would be a downgrade, not a fallback.
http_access allow connect_method registries ssl_ports
http_access deny all
# Nothing is cached: with no cache there is no cache to poison, and the measured benefit of
# caching was npm's alone - where the worker's own shared npm cache already provides it.
cache deny all
cache_mem 8 MB
# Left at squid's own defaults, under /var/log/squid inside the container. Pointing them at
# /dev/stdout so they would reach `docker logs` does not work here: squid drops to the proxy user
# and the container's stdout is a root-owned pipe, which it then cannot open - and squid treats
# that as fatal, so the proxy would not start at all.
#
# docker compose logs egress-proxy startup and configuration errors
# docker compose exec egress-proxy tail -f /var/log/squid/access.log who asked for what
# A client that cannot reach the internet should learn so quickly rather than hang.
connect_timeout 15 seconds
request_timeout 60 seconds
forwarded_for delete
httpd_suppress_version_string on

74
mcp-stack.Caddyfile Normal file
View File

@ -0,0 +1,74 @@
{
auto_https off
admin off
}
# One host, one path per server. This is the shape the catalog in the workflow manager expects -
# ${{host}}/coding-agent/mcp and its siblings - so a flow configured against a remote deployment
# runs against this stack by changing the host and nothing else.
:3100 {
handle_path /coding-agent/* {
reverse_proxy coding-agent-mcp:3000 {
flush_interval -1
}
}
handle_path /dev-server/* {
reverse_proxy dev-server-mcp:3000 {
flush_interval -1
}
}
handle_path /browser/* {
reverse_proxy browser-mcp:3000 {
flush_interval -1
}
}
handle_path /minio/* {
reverse_proxy minio-mcp:3000 {
flush_interval -1
}
}
# The one door a person opens, as opposed to the MCP routes above, which a machine opens with an API
# key. handle_path strips the prefix, so the worker's proxy sees /<execution-key>/... and the
# application behind it sees the path it would see at the root.
#
# Same origin as the MCP endpoints above, which is a compromise worth naming: JavaScript in a
# previewed page can reach /dev-server/mcp. It gets 401 - those endpoints authenticate by
# bearer token, never by cookie, so there is no ambient authority for a page to borrow - and
# the preview's own cookie is scoped to /preview/<key>/ and travels nowhere else.
handle_path /preview/* {
reverse_proxy dev-server-worker:4500
}
handle {
respond "No MCP server is published at this path" 404
}
}
# The original one-port-per-server listeners, kept for clients configured before the paths existed.
:3101 {
reverse_proxy coding-agent-mcp:3000 {
flush_interval -1
}
}
:3102 {
reverse_proxy dev-server-mcp:3000 {
flush_interval -1
}
}
:3103 {
reverse_proxy browser-mcp:3000 {
flush_interval -1
}
}
:3104 {
reverse_proxy minio-mcp:3000 {
flush_interval -1
}
}

289
mcp-stack.compose.yml Normal file
View File

@ -0,0 +1,289 @@
# Deployment of the MCP servers. The four images are built and published from the `mcps` repository
# (its publish-images.sh) and only pulled here, so this host needs neither the sources nor a
# toolchain. MCP_IMAGE_TAG picks the build; it defaults to the one this file was last checked
# against, and bumping it is what a deploy is.
#
# The dev-server image fixes its user at 10001:10001 when it is built: the two volumes the worker
# mounts (/instances, /npm-cache) inherit that owner from the image. Keep MCP_UID and MCP_GID at
# 10001 for it, or the worker cannot write into them and the first execution fails to start.
name: secure-mcp-stack
# Docker's default json-file driver never rotates on its own, so a container that runs for weeks -
# which every one of these does - writes an unbounded log file. One definition, reused by every
# service below, so the cap lives in exactly one place.
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
coding-agent-mcp:
image: luciolelii/coding-agent-mcp:${MCP_IMAGE_TAG:-921d54c}
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
logging: *default-logging
dev-server-mcp:
image: luciolelii/dev-server-mcp:${MCP_IMAGE_TAG:-921d54c}
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
logging: *default-logging
dev-server-worker:
image: luciolelii/dev-server-mcp:${MCP_IMAGE_TAG:-921d54c}
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}
# Where a person reaches a preview. Unset means no preview proxy at all - the dev loop works
# without one, since the browser reaches instances over the internal network; this is only
# for the flows where a human has to look at what was built.
DEV_SERVER_PREVIEW_BASE_URL: ${DEV_SERVER_PREVIEW_BASE_URL:-}
DEV_SERVER_PREVIEW_PORT: ${DEV_SERVER_PREVIEW_PORT:-4500}
DEV_SERVER_IDLE_TIMEOUT_SECONDS: ${DEV_SERVER_IDLE_TIMEOUT_SECONDS:-7200}
DEV_SERVER_MAX_LIFETIME_SECONDS: ${DEV_SERVER_MAX_LIFETIME_SECONDS:-86400}
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
logging: *default-logging
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
# This is the one service without read_only: true, because squid needs somewhere to write
# access.log and cache.log - and unlike Docker's own logging driver above, squid never rotates
# those on its own. Bounded here at the container level, the same as /tmp elsewhere in this
# file: capped and lost on restart beats unbounded and kept.
#
# uid/gid=13 is squid's own "proxy" user inside this image, which it drops to before it ever
# opens these files. Compose's long tmpfs syntax has no uid/gid field, so this needs the raw
# mount-options string the top-level tmpfs: list passes straight through to Docker.
tmpfs:
- /var/log/squid:rw,size=64m,uid=13,gid=13
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
logging: *default-logging
browser-mcp:
image: luciolelii/browser-mcp:${MCP_IMAGE_TAG:-921d54c}
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
logging: *default-logging
minio-mcp:
image: luciolelii/minio-mcp:${MCP_IMAGE_TAG:-921d54c}
environment:
MINIO_MCP_API_KEYS: ${MINIO_MCP_API_KEYS}
MINIO_MCP_DEFAULT_REGION: ${MINIO_MCP_DEFAULT_REGION:-us-east-1}
MINIO_MCP_MAX_OBJECT_BYTES: ${MINIO_MCP_MAX_OBJECT_BYTES:-1048576}
MINIO_MCP_MAX_CONNECTIONS_PER_SESSION: ${MINIO_MCP_MAX_CONNECTIONS_PER_SESSION:-8}
# Optional comma-separated origins. Empty permits any HTTP(S) MinIO origin; restrict this
# when the set of deployments is known, because every allowed MCP client can open sessions.
MINIO_MCP_ALLOWED_ENDPOINTS: ${MINIO_MCP_ALLOWED_ENDPOINTS:-}
# Internal mode: the workflow manager's catalog entry minio-mcp-internal sends the platform
# MinIO's endpoint and keys with x-minio-scope, and the bucket <prefix><execution id> is
# created on first use. Only the prefix is this server's. The endpoint is reached over
# minio-egress and, when the allowlist above is set, has to be on it.
MINIO_MCP_INTERNAL_BUCKET_PREFIX: ${MINIO_MCP_INTERNAL_BUCKET_PREFIX:-exec-}
user: "${MCP_UID:-10001}:${MCP_GID:-10001}"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=16m
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
pids_limit: 64
mem_limit: 256m
cpus: 0.5
init: true
expose: ["3000"]
# minio-control is the private path from Caddy. minio-egress is needed because each logical
# session may point at a different remote MinIO S3 endpoint, and the internal one is reached
# the same way.
networks: [minio-control, minio-egress]
restart: unless-stopped
logging: *default-logging
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 dedicated ports 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"
- "127.0.0.1:${MINIO_MCP_PORT:-3104}:3104"
networks: [gateway, coding-control, dev-server-control, workspace-browser, minio-control]
depends_on: [coding-agent-mcp, dev-server-mcp, browser-mcp, minio-mcp]
restart: unless-stopped
logging: *default-logging
networks:
gateway:
coding-control:
internal: true
dev-server-control:
internal: true
workspace-browser:
internal: true
minio-control:
internal: true
minio-egress:
# 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:

92
mcp-stack.env.example Normal file
View File

@ -0,0 +1,92 @@
# Which build of the four MCP images to run, as published to Docker Hub under luciolelii/. A commit
# hash of the `mcps` repository, or `latest`. Leave it unset to take the one the compose file names.
# MCP_IMAGE_TAG=921d54c
# Generate each token independently, for example: openssl rand -hex 32
CODING_AGENT_MCP_API_KEYS=agent=replace-with-at-least-32-random-characters
DEV_SERVER_MCP_API_KEYS=agent=replace-with-a-different-32-char-token
BROWSER_MCP_API_KEYS=agent=replace-with-a-third-32-char-token
MINIO_MCP_API_KEYS=agent=replace-with-a-fourth-32-char-token
DEV_SERVER_WORKER_TOKEN=replace-with-a-private-worker-token-32-chars
# External mode: URL, bucket and credentials are passed once to minio_open_session, not configured
# on this long-running container. It can therefore hold sessions to different MinIO installations.
MINIO_MCP_DEFAULT_REGION=us-east-1
MINIO_MCP_MAX_CONNECTIONS_PER_SESSION=8
# Optional comma-separated S3 endpoint origins. Leave empty only when every MCP client is trusted
# to make this server connect to arbitrary HTTP(S) destinations.
MINIO_MCP_ALLOWED_ENDPOINTS=
# Applies independently to reads and writes. Base64 overhead is handled by the HTTP body limit.
MINIO_MCP_MAX_OBJECT_BYTES=1048576
# The internal mode is chosen by the catalog entry minio-mcp-internal rather than by the model, and
# its MinIO endpoint and keys are that entry's headers in the workflow manager's catalog - not
# variables here. The session arrives with x-minio-scope set to the execution's id, and this server
# creates the bucket <prefix><execution id> on first use. Those keys must be allowed to create
# buckets, and the endpoint must be on MINIO_MCP_ALLOWED_ENDPOINTS when that is set.
#
MINIO_MCP_INTERNAL_BUCKET_PREFIX=exec-
# Use a dedicated project directory, never a home directory or filesystem root.
MCP_WORKSPACE_HOST_PATH=/absolute/path/to/project
# Whoever owns the workspace directory on the host. This file is read literally - no shell runs
# over it - so write the numbers that 'id -u' and 'id -g' print, not the commands themselves.
# Keep 10001: the dev-server image is built with that user, and the volumes the worker mounts take
# their owner from it. Give the workspace directory to 10001:10001 (sudo chown -R 10001:10001 <dir>)
# rather than changing these.
MCP_UID=10001
MCP_GID=10001
# Copy services.example.json and edit the copy. It is mounted read-only.
DEV_SERVER_SERVICES_CONFIG=./dev-server-mcp/services.example.json
# What a caller may run, which is narrower than what the image carries on purpose. npx is left out
# because it fetches and runs an arbitrary package by name.
DEV_SERVER_ALLOWED_COMMANDS=node,npm,python3,./mvnw
# One instance per execution, each on its own port. Size the range and the cap by memory: two
# frontend builds saturate the worker's 2 GB, and a Spring project's .m2 is 300 MB of disk apiece.
DEV_SERVER_PORT_RANGE=5200-5219
DEV_SERVER_MAX_INSTANCES=4
# Every port an execution can be given has to be reachable by the browser, and the allowlist is by
# exact origin - so the range is spelled out here. Keep it aligned with DEV_SERVER_PORT_RANGE.
BROWSER_MCP_ALLOWED_ORIGINS=http://dev-server-worker:5173,http://dev-server-worker:5200-5219
CODING_AGENT_MCP_PORT=3101
DEV_SERVER_MCP_PORT=3102
BROWSER_MCP_PORT=3103
MINIO_MCP_PORT=3104
# The gateway publishes every server under one host by path, which is what the catalog expects.
MCP_GATEWAY_PORT=3100
### Dedicated VM ###
# Used with the overlay:
# docker compose --env-file .env -f mcp-stack.compose.yml -f mcp-stack.vm.compose.yml up -d
#
# Caddy obtains the certificate itself, from Let's Encrypt, as long as the host answers on 80 or
# 443 from the internet. Worth knowing when the name is chosen: CAA is evaluated on the canonical
# name, so a name that is a CNAME into a zone authorising Let's Encrypt is issued without trouble -
# the way the institute's existing hosts are - while a name resolving straight to an address under
# isti.cnr.it would be refused, since that zone authorises digicert and sectigo for plain names.
# Either way Caddy's log says which happened, and the manual fallback is in mcp-stack.vm.Caddyfile.
# Left commented so that copying this file onto a laptop never starts Caddy in HTTPS mode, which
# would try to obtain a certificate for the name below from a machine that is not that host.
# Uncomment all four on the VM.
# MCP_GATEWAY_CADDYFILE=./mcp-stack.vm.Caddyfile
# MCP_SITE_ADDRESS=mcp-stack.isti.cnr.it
# MCP_TLS_CONTACT=lucio.lelii@isti.cnr.it
# MCP_BIND_ADDRESS=0.0.0.0
# The public address a person opens a preview at - the same host the gateway serves, since the
# preview rides the /preview/ route on it. Leave unset on a laptop stack: without it the dev loop
# still works (the browser reaches instances internally) and no preview links are handed out.
DEV_SERVER_PREVIEW_BASE_URL=https://mcp.sse.cloud.isti.cnr.it
# Two clocks on a running instance. Idle asks whether anybody is still looking - a preview request
# or an MCP call resets it - and running out stops the process while keeping the copy, so coming
# back costs a restart and not a reinstall. The absolute one runs regardless and discards
# everything, which is the only thing that ever gives the disk back.
DEV_SERVER_IDLE_TIMEOUT_SECONDS=7200
DEV_SERVER_MAX_LIFETIME_SECONDS=86400

72
mcp-stack.vm.Caddyfile Normal file
View File

@ -0,0 +1,72 @@
{
# Automatic HTTPS. Caddy asks Let's Encrypt on its own; the host has to be reachable from the
# internet on 80 or 443 for the challenge, which this VM is.
#
# One thing to know before choosing the name. CAA says who may issue, and isti.cnr.it
# authorises digicert.com and sectigo.com for a plain name, with Let's Encrypt allowed for
# wildcards only. That is not the obstacle it looks like: CAA is evaluated on the canonical
# name, so a friendly name that is a CNAME into a zone which does authorise Let's Encrypt is
# issued without trouble - which is exactly how the institute's own hosts already work.
# A name pointing straight at an address with an A record, under isti.cnr.it, would be refused.
#
# If issuance is ever refused, Caddy's log says so in as many words. The way out is a
# certificate obtained by hand:
#
# auto_https off (in this block)
# tls /certs/host.pem /certs/host-key.pem (in the site block)
#
# and mount /certs read-only. The private key of a name the whole institute trusts does not
# belong in an image, and even less in one built to run code an agent wrote.
admin off
email {$MCP_TLS_CONTACT}
}
{$MCP_SITE_ADDRESS} {
handle_path /coding-agent/* {
reverse_proxy coding-agent-mcp:3000 {
flush_interval -1
}
}
handle_path /dev-server/* {
reverse_proxy dev-server-mcp:3000 {
flush_interval -1
}
}
handle_path /browser/* {
reverse_proxy browser-mcp:3000 {
flush_interval -1
}
}
handle_path /minio/* {
reverse_proxy minio-mcp:3000 {
flush_interval -1
}
}
# The one door a person opens, as opposed to the MCP routes above, which a machine opens with an API
# key. handle_path strips the prefix, so the worker's proxy sees /<execution-key>/... and the
# application behind it sees the path it would see at the root.
#
# Same origin as the MCP endpoints above, which is a compromise worth naming: JavaScript in a
# previewed page can reach /dev-server/mcp. It gets 401 - those endpoints authenticate by
# bearer token, never by cookie, so there is no ambient authority for a page to borrow - and
# the preview's own cookie is scoped to /preview/<key>/ and travels nowhere else.
handle_path /preview/* {
reverse_proxy dev-server-worker:4500
}
handle {
respond "No MCP server is published at this path" 404
}
# One line per request. This gateway is the only door into a machine that runs code an agent
# wrote, so it should be able to say who knocked. Caddy redacts Authorization and Cookie in its
# access log by default, so the token itself is not written down.
log {
output stdout
format json
}
}

18
mcp-stack.vm.compose.yml Normal file
View File

@ -0,0 +1,18 @@
# Overlay for the dedicated VM. Use it on top of the base file:
#
# docker compose --env-file .env -f mcp-stack.compose.yml -f mcp-stack.vm.compose.yml up -d
#
# The base file alone stays what it is: a stack bound to loopback on somebody's machine.
name: secure-mcp-stack
services:
mcp-gateway:
ports:
# 80 and 443 on the VM's own address. Both, because automatic certificates need them: the
# HTTP-01 challenge is answered on 80 and TLS-ALPN-01 on 443, and Caddy picks whichever the
# CA offers. 80 also carries the redirect to HTTPS for anyone who types the bare host name.
- "${MCP_BIND_ADDRESS:-0.0.0.0}:80:80"
- "${MCP_BIND_ADDRESS:-0.0.0.0}:443:443"
#
# Nothing else is added: the base file's ports stay on loopback, which is where the same
# servers without TLS in front of them belong.