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:
commit
2e89c7e0b7
|
|
@ -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/
|
||||
|
|
@ -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 | |
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
Loading…
Reference in New Issue