From deb3d81119cf26bcef86a92b436cc73ee8d5d6a2 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Fri, 2 Oct 2026 11:49:09 +0200 Subject: [PATCH] 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 --- .gitignore | 3 - ENVIRONMENT.md | 191 ------ MCP-STACK.md | 61 -- README.md | 31 + browser-mcp/Dockerfile | 2 +- browser-mcp/README.md | 2 +- browser-mcp/seccomp_profile.json | 831 ------------------------- dev-server-mcp/README.md | 2 +- dev-server-mcp/services.example.json | 168 ----- dev-server-mcp/services.localhost.json | 29 - egress-proxy.squid.conf | 48 -- mcp-stack.Caddyfile | 74 --- mcp-stack.compose.yml | 292 --------- mcp-stack.env.example | 82 --- mcp-stack.vm.Caddyfile | 72 --- mcp-stack.vm.compose.yml | 18 - publish-images.sh | 36 ++ 17 files changed, 70 insertions(+), 1872 deletions(-) delete mode 100644 ENVIRONMENT.md delete mode 100644 MCP-STACK.md create mode 100644 README.md delete mode 100644 browser-mcp/seccomp_profile.json delete mode 100644 dev-server-mcp/services.example.json delete mode 100644 dev-server-mcp/services.localhost.json delete mode 100644 egress-proxy.squid.conf delete mode 100644 mcp-stack.Caddyfile delete mode 100644 mcp-stack.compose.yml delete mode 100644 mcp-stack.env.example delete mode 100644 mcp-stack.vm.Caddyfile delete mode 100644 mcp-stack.vm.compose.yml create mode 100755 publish-images.sh diff --git a/.gitignore b/.gitignore index a2730dd..bff2b5b 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md deleted file mode 100644 index 3f3ca29..0000000 --- a/ENVIRONMENT.md +++ /dev/null @@ -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 ``. 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 | | diff --git a/MCP-STACK.md b/MCP-STACK.md deleted file mode 100644 index 145b8e0..0000000 --- a/MCP-STACK.md +++ /dev/null @@ -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, ``, 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..487b145 --- /dev/null +++ b/README.md @@ -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/` 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. diff --git a/browser-mcp/Dockerfile b/browser-mcp/Dockerfile index 5a0e57a..2f2a5ce 100644 --- a/browser-mcp/Dockerfile +++ b/browser-mcp/Dockerfile @@ -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. diff --git a/browser-mcp/README.md b/browser-mcp/README.md index 9dbdd44..57d62dd 100644 --- a/browser-mcp/README.md +++ b/browser-mcp/README.md @@ -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 diff --git a/browser-mcp/seccomp_profile.json b/browser-mcp/seccomp_profile.json deleted file mode 100644 index fddc05f..0000000 --- a/browser-mcp/seccomp_profile.json +++ /dev/null @@ -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": {} - } - ] -} diff --git a/dev-server-mcp/README.md b/dev-server-mcp/README.md index 02d48a2..6608f1f 100644 --- a/dev-server-mcp/README.md +++ b/dev-server-mcp/README.md @@ -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. diff --git a/dev-server-mcp/services.example.json b/dev-server-mcp/services.example.json deleted file mode 100644 index cfc47ed..0000000 --- a/dev-server-mcp/services.example.json +++ /dev/null @@ -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 - } - } -} diff --git a/dev-server-mcp/services.localhost.json b/dev-server-mcp/services.localhost.json deleted file mode 100644 index 23e0844..0000000 --- a/dev-server-mcp/services.localhost.json +++ /dev/null @@ -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}" - } - } - } -} diff --git a/egress-proxy.squid.conf b/egress-proxy.squid.conf deleted file mode 100644 index 01dcea6..0000000 --- a/egress-proxy.squid.conf +++ /dev/null @@ -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 diff --git a/mcp-stack.Caddyfile b/mcp-stack.Caddyfile deleted file mode 100644 index 7a22758..0000000 --- a/mcp-stack.Caddyfile +++ /dev/null @@ -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 //... 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// 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 - } -} diff --git a/mcp-stack.compose.yml b/mcp-stack.compose.yml deleted file mode 100644 index 3d322ab..0000000 --- a/mcp-stack.compose.yml +++ /dev/null @@ -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 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: diff --git a/mcp-stack.env.example b/mcp-stack.env.example deleted file mode 100644 index 0b6d90f..0000000 --- a/mcp-stack.env.example +++ /dev/null @@ -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 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 diff --git a/mcp-stack.vm.Caddyfile b/mcp-stack.vm.Caddyfile deleted file mode 100644 index fab4005..0000000 --- a/mcp-stack.vm.Caddyfile +++ /dev/null @@ -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 //... 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// 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 - } -} diff --git a/mcp-stack.vm.compose.yml b/mcp-stack.vm.compose.yml deleted file mode 100644 index 88ac5c5..0000000 --- a/mcp-stack.vm.compose.yml +++ /dev/null @@ -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. diff --git a/publish-images.sh b/publish-images.sh new file mode 100755 index 0000000..0aaeec5 --- /dev/null +++ b/publish-images.sh @@ -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."