Keep the MCP servers' code here and deploy them from another repository
The compose files, the gateway's Caddyfiles, the egress proxy's configuration, the example
.env, the seccomp profile, the dev-server's example service definitions and the stack's two
documents moved to mcps-docker-compose. With the images published to Docker Hub, a server
runs them without the sources, and a deploy is a new image tag; keeping both here meant the
repository that changes with the code also carried what only changes when a host does.
publish-images.sh builds the four images for amd64 and arm64 and pushes them tagged with
this repository's commit hash and "latest". It refuses a dirty working tree, since the
tag names a commit. The images now published were built from 921d54c.
Each server keeps its README and tests, and coding-agent-mcp its own standalone compose
files. The three references that pointed at moved files now say where they went.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
921d54cb06
commit
deb3d81119
|
|
@ -2,9 +2,6 @@
|
|||
# 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
|
||||
|
||||
### Installed and generated ###
|
||||
node_modules/
|
||||
|
|
|
|||
191
ENVIRONMENT.md
191
ENVIRONMENT.md
|
|
@ -1,191 +0,0 @@
|
|||
# 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
|
||||
server 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` | Must be the numeric owner of `MCP_WORKSPACE_HOST_PATH`, or the containers cannot write to it. Write what `id -u` and `id -g` print: this file is read literally, no shell runs over it. |
|
||||
|
||||
### Optional
|
||||
|
||||
**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 a server by hand)
|
||||
|
||||
Only for `node src/index.js` and similar, 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 | |
|
||||
61
MCP-STACK.md
61
MCP-STACK.md
|
|
@ -1,61 +0,0 @@
|
|||
# Secure MCP stack
|
||||
|
||||
This workspace contains four independent Streamable HTTP MCP servers:
|
||||
|
||||
- `coding-agent-mcp`: scoped workspace editing. Task execution is disabled in this stack.
|
||||
- `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.
|
||||
- `egress-proxy`: the development worker's only route to package registries; it allows their CONNECT requests and refuses everything else.
|
||||
- `browser-mcp`: Playwright automation restricted to exact allowed origins.
|
||||
- `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.
|
||||
|
||||
The MCP client orchestrates them; servers 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.
|
||||
3. Copy and edit `dev-server-mcp/services.example.json`. 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. Run `docker compose --env-file .env -f mcp-stack.compose.yml up --build`.
|
||||
|
||||
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,31 @@
|
|||
# MCP servers
|
||||
|
||||
Four independent Streamable HTTP MCP servers, each in its own directory with its own README and tests:
|
||||
|
||||
- `coding-agent-mcp` - scoped workspace editing.
|
||||
- `dev-server-mcp` - authenticated facade for an allowlisted development-server worker (the same image runs the facade and the worker).
|
||||
- `browser-mcp` - Playwright automation restricted to exact allowed origins.
|
||||
- `minio-mcp` - bounded list/read/stat/write access to object storage.
|
||||
|
||||
This repository holds their code. **Deploying them is a separate repository**,
|
||||
[`mcps-docker-compose`](https://gitea-s2i2s.isti.cnr.it/lelii/mcps-docker-compose): the compose files,
|
||||
the gateway's Caddyfiles, the example `.env` and the documentation of every environment variable. It pulls
|
||||
the images published from here, so a server needs no sources and no build toolchain.
|
||||
|
||||
## Publishing the images
|
||||
|
||||
```sh
|
||||
docker login -u luciolelii # once; use an access token with read and write permission
|
||||
./publish-images.sh # all four
|
||||
./publish-images.sh minio-mcp # or just some
|
||||
```
|
||||
|
||||
Each image is built for `linux/amd64` and `linux/arm64` and pushed to `luciolelii/<name>` tagged with
|
||||
this repository's commit hash and `latest`. The script refuses to run on a dirty working tree, because
|
||||
the tag names a commit. Afterwards, deploy by setting `MCP_IMAGE_TAG` in the deployment repository (or
|
||||
the default in its `mcp-stack.compose.yml`) to the printed hash.
|
||||
|
||||
The dev-server image fixes its user at `10001:10001` when it is built, because the two volumes its worker
|
||||
mounts take their owner from the image. See the deployment repository for what that means at run time.
|
||||
|
||||
Tests: `npm test` inside each server's directory.
|
||||
|
|
@ -8,7 +8,7 @@ FROM node:24-bookworm-slim
|
|||
|
||||
ENV NODE_ENV=production
|
||||
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||
# Outside /tmp on purpose: the container mounts /tmp as tmpfs (see mcp-stack.compose.yml), so a
|
||||
# Outside /tmp on purpose: the container mounts /tmp as tmpfs (see the compose file in the mcps-docker-compose repository), so a
|
||||
# browser installed under HOME=/tmp/... at build time would be gone the instant the real container
|
||||
# started - a mistake made once while building this image, and the reason it is called out here.
|
||||
# /ms-playwright is where Microsoft's own image keeps browsers, for the identical reason.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ Playwright browser automation exposed only through MCP Streamable HTTP.
|
|||
- Bounded screenshots, text snapshots, console logs, network errors, request bodies, and session counts.
|
||||
- API authentication is mandatory and browser-origin CORS is denied unless explicitly allowlisted.
|
||||
|
||||
The container runs as `pwuser` with the Chromium sandbox enabled. The stack applies Playwright's official default-derived `seccomp_profile.json`, which permits the user-namespace calls required by the sandbox, and restores only `SYS_CHROOT` after dropping every capability. Keep the container on an internal network that can reach only the development application. This is a hardened browser worker, not a VM-grade boundary.
|
||||
The container runs as `pwuser` with the Chromium sandbox enabled. The stack (the `mcps-docker-compose` repository) applies Playwright's official default-derived `seccomp_profile.json`, which permits the user-namespace calls required by the sandbox, and restores only `SYS_CHROOT` after dropping every capability. Keep the container on an internal network that can reach only the development application. This is a hardened browser worker, not a VM-grade boundary.
|
||||
|
||||
## Tools
|
||||
|
||||
|
|
|
|||
|
|
@ -1,831 +0,0 @@
|
|||
{
|
||||
"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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -52,7 +52,7 @@ A frontend and a backend together is two service definitions, each with its own
|
|||
|
||||
## Configuration
|
||||
|
||||
Copy `services.example.json` to an operator-controlled path and mount it read-only at `/config/services.json`. `DEV_SERVER_ALLOWED_COMMANDS` is a second allowlist applied when configuration is loaded, and it covers install commands too.
|
||||
Copy `dev-server-mcp/services.example.json` from the `mcps-docker-compose` repository to an operator-controlled path and mount it read-only at `/config/services.json`. `DEV_SERVER_ALLOWED_COMMANDS` is a second allowlist applied when configuration is loaded, and it covers install commands too.
|
||||
|
||||
`${port}` is the only substitution a definition may contain, and writing it anywhere is what asks for an allocated port. An unknown placeholder is refused at load time.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,168 +0,0 @@
|
|||
{
|
||||
"_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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
"_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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,292 +0,0 @@
|
|||
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:
|
||||
build:
|
||||
context: ./coding-agent-mcp
|
||||
command: ["node", "src/coding-agent-index.js", "--transport", "http", "--host", "0.0.0.0", "--port", "3000", "--root", "/workspace"]
|
||||
environment:
|
||||
CODING_AGENT_MCP_API_KEYS: ${CODING_AGENT_MCP_API_KEYS}
|
||||
CODING_AGENT_MCP_EXECUTION_BACKEND: disabled
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${MCP_WORKSPACE_HOST_PATH}
|
||||
target: /workspace
|
||||
user: "${MCP_UID:-10001}:${MCP_GID:-10001}"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,size=128m
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
pids_limit: 128
|
||||
mem_limit: 512m
|
||||
cpus: 1
|
||||
init: true
|
||||
expose: ["3000"]
|
||||
networks: [coding-control]
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
|
||||
dev-server-mcp:
|
||||
build:
|
||||
context: ./dev-server-mcp
|
||||
args:
|
||||
MCP_UID: ${MCP_UID:-10001}
|
||||
MCP_GID: ${MCP_GID:-10001}
|
||||
command: ["node", "src/index.js"]
|
||||
environment:
|
||||
DEV_SERVER_MCP_API_KEYS: ${DEV_SERVER_MCP_API_KEYS}
|
||||
DEV_SERVER_WORKER_TOKEN: ${DEV_SERVER_WORKER_TOKEN}
|
||||
DEV_SERVER_WORKER_URL: http://dev-server-worker:4000
|
||||
user: "${MCP_UID:-10001}:${MCP_GID:-10001}"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,size=64m
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
pids_limit: 64
|
||||
mem_limit: 256m
|
||||
cpus: 0.5
|
||||
init: true
|
||||
expose: ["3000"]
|
||||
networks: [dev-server-control]
|
||||
depends_on: [dev-server-worker]
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
|
||||
dev-server-worker:
|
||||
build:
|
||||
context: ./dev-server-mcp
|
||||
args:
|
||||
MCP_UID: ${MCP_UID:-10001}
|
||||
MCP_GID: ${MCP_GID:-10001}
|
||||
command: ["node", "src/worker-index.js"]
|
||||
environment:
|
||||
DEV_SERVER_WORKER_TOKEN: ${DEV_SERVER_WORKER_TOKEN}
|
||||
DEV_SERVER_CONFIG: /config/services.json
|
||||
DEV_SERVER_WORKSPACE_ROOT: /workspace
|
||||
# What the image carries is not the same as what a caller may run. npx is deliberately absent:
|
||||
# it fetches and executes an arbitrary package by name, which would hand back the free choice
|
||||
# of command this allowlist exists to remove.
|
||||
DEV_SERVER_ALLOWED_COMMANDS: ${DEV_SERVER_ALLOWED_COMMANDS:-node,npm,python3,./mvnw}
|
||||
# One throwaway copy of the tree per execution, on a volume: a node_modules carries native
|
||||
# .node modules that will not load from the noexec /tmp, and 333 MB of it in RAM would eat a
|
||||
# quarter of this container's memory limit.
|
||||
DEV_SERVER_INSTANCES_ROOT: /instances
|
||||
DEV_SERVER_INSTANCE_HOME: /instances/.shared-home
|
||||
DEV_SERVER_NPM_CACHE: /npm-cache
|
||||
# The worker has no route to the internet. Everything an install fetches goes through the
|
||||
# proxy, which allows only the package registries. npm and pip read these; a JVM does not,
|
||||
# so a Maven service carries -Dhttps.proxyHost in its declared arguments.
|
||||
DEV_SERVER_EGRESS_PROXY: ${DEV_SERVER_EGRESS_PROXY:-http://egress-proxy:3128}
|
||||
DEV_SERVER_NO_PROXY: ${DEV_SERVER_NO_PROXY:-localhost,127.0.0.1,dev-server-worker}
|
||||
DEV_SERVER_PORT_RANGE: ${DEV_SERVER_PORT_RANGE:-5200-5219}
|
||||
DEV_SERVER_MAX_INSTANCES: ${DEV_SERVER_MAX_INSTANCES:-4}
|
||||
DEV_SERVER_MAX_WORKSPACE_BYTES: ${DEV_SERVER_MAX_WORKSPACE_BYTES:-536870912}
|
||||
# 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:
|
||||
build:
|
||||
context: ./browser-mcp
|
||||
environment:
|
||||
BROWSER_MCP_API_KEYS: ${BROWSER_MCP_API_KEYS}
|
||||
# Both: the shared service's fixed port, and the range the per-execution ones are given.
|
||||
# Keep the range aligned with DEV_SERVER_PORT_RANGE or a preview starts on a port the
|
||||
# browser is not allowed to open - which reads as a broken app, not as a refused origin.
|
||||
BROWSER_MCP_ALLOWED_ORIGINS: ${BROWSER_MCP_ALLOWED_ORIGINS:-http://dev-server-worker:5173,http://dev-server-worker:5200-5219}
|
||||
BROWSER_MCP_MAX_SESSIONS: ${BROWSER_MCP_MAX_SESSIONS:-8}
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,size=1g
|
||||
shm_size: 1gb
|
||||
cap_drop: [ALL]
|
||||
cap_add: [SYS_CHROOT]
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
- seccomp:./browser-mcp/seccomp_profile.json
|
||||
pids_limit: 512
|
||||
mem_limit: 2g
|
||||
cpus: 2
|
||||
init: true
|
||||
expose: ["3000"]
|
||||
networks: [workspace-browser]
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
|
||||
minio-mcp:
|
||||
build:
|
||||
context: ./minio-mcp
|
||||
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:
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
# 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.
|
||||
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 --build
|
||||
#
|
||||
# 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.
|
||||
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
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
{
|
||||
# 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# 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 --build
|
||||
#
|
||||
# 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.
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
#!/bin/sh
|
||||
# Builds the MCP images for amd64 and arm64 and pushes them to Docker Hub, tagged with this
|
||||
# repository's commit hash and "latest". Usage: ./publish-images.sh [image ...] (default: all four)
|
||||
#
|
||||
# The tag names a commit, so the working tree must be clean: an image built from uncommitted code
|
||||
# would carry a hash that does not describe it.
|
||||
set -eu
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
REGISTRY="${REGISTRY:-luciolelii}"
|
||||
PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}"
|
||||
BUILDER="${BUILDER:-mcp-publisher}"
|
||||
ALL="coding-agent-mcp dev-server-mcp browser-mcp minio-mcp"
|
||||
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "The working tree is not clean; commit or stash first, so the tag describes what was built." >&2
|
||||
exit 1
|
||||
fi
|
||||
TAG="$(git rev-parse --short HEAD)"
|
||||
|
||||
# A multi-platform build needs a builder that can run both architectures; Docker's default one
|
||||
# cannot push a multi-platform image.
|
||||
docker buildx inspect "$BUILDER" >/dev/null 2>&1 \
|
||||
|| docker buildx create --name "$BUILDER" --driver docker-container --bootstrap
|
||||
|
||||
for image in ${*:-$ALL}; do
|
||||
[ -f "$image/Dockerfile" ] || { echo "No such image: $image" >&2; exit 1; }
|
||||
echo "==> $REGISTRY/$image:$TAG ($PLATFORMS)"
|
||||
docker buildx build --builder "$BUILDER" --platform "$PLATFORMS" \
|
||||
-t "$REGISTRY/$image:$TAG" -t "$REGISTRY/$image:latest" \
|
||||
--push "$image"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "Published with tag $TAG. To deploy it, set MCP_IMAGE_TAG=$TAG in the deployment repository."
|
||||
Loading…
Reference in New Issue