No description
  • TypeScript 96.2%
  • JavaScript 2.6%
  • Dockerfile 1.2%
Find a file
2026-10-21 19:51:33 +00:00
app Initial commit: remote Docker Compose runner MCP server 2026-10-21 19:51:33 +00:00
config Initial commit: remote Docker Compose runner MCP server 2026-10-21 19:51:33 +00:00
docker Initial commit: remote Docker Compose runner MCP server 2026-10-21 19:51:33 +00:00
.dockerignore Initial commit: remote Docker Compose runner MCP server 2026-10-21 19:51:33 +00:00
.gitignore Initial commit: remote Docker Compose runner MCP server 2026-10-21 19:51:33 +00:00
compose.yaml Initial commit: remote Docker Compose runner MCP server 2026-10-21 19:51:33 +00:00
LICENSE Initial commit: remote Docker Compose runner MCP server 2026-10-21 19:51:33 +00:00
README.md Initial commit: remote Docker Compose runner MCP server 2026-10-21 19:51:33 +00:00

dev-runner-mcp

Remote MCP server for OpenChamber/OpenCode that runs Docker Compose projects on a dedicated Debian VM. It copies the current development state from your development machine, starts the compose stack in a namespaced workspace, and tears everything down again.

The server is written in TypeScript and runs directly with Node.js 22.18+ / 24 — there is no build step in development. It can run as a systemd service on the VM or as the included Docker Compose stack; the container image contains Node, rsync, ssh and the Docker CLI.

Architecture

┌─────────────────────────────┐        MCP (Streamable HTTP)        ┌──────────────────────────────┐
│ Development machine         │  OpenCode/OpenChamber  ───────────▶ │ Debian VM "runner"           │
│                             │                                     │                              │
│  OpenChamber / OpenCode     │                                     │  dev-runner-mcp (Node)       │
│  project files (uncommitted)│ ◀──── rsync (pull) ─────────────── │   ├─ workspaces/<id>/src     │
│  rsync daemon (no auth)     │                                     │   ├─ docker compose -p ocw-… │
└─────────────────────────────┘                                     │   └─ GC / TTL cleanup        │
                                                                    └──────────────┬───────────────┘
                                                                                   │ docker socket
                                                                           ┌───────▼────────┐
                                                                           │ Docker Engine  │
                                                                           │ app + database │
                                                                           └────────────────┘

The MCP server only executes commands on the runner. That is why files are pulled by the runner instead of pushed by the agent: the agent cannot run rsync on your laptop from inside an MCP tool call. The default source is an unauthenticated rsync daemon; SSH with a key is an optional alternative. See File transfer.

Features

  • Workspaces: create, list, inspect, sync, reset, delete, idle TTL cleanup.
  • Pull the current state (including uncommitted files) from an rsync daemon, optionally over SSH.
  • Docker Compose: up/down/restart, status, logs, exec, one-off run, profiles, builds.
  • Isolated compose projects (ocw-<workspace-id>), volume reset and image cleanup.
  • Hard safety rails: source allowlist, path traversal protection, output caps, timeouts, command timeouts, capacity and free-disk guards, per-workspace locking.
  • Single static bearer token, optional unauthenticated loopback mode for development.
  • No build step: Node.js strips the TypeScript types at startup.

Requirements

Runner VM (Debian 12/13):

  • Docker Engine with the Compose v2 plugin, 2.20+ (docker compose version; the server uses config --format json).
  • Either the container deployment (Docker only) or the systemd deployment: Node.js >= 22.18 (24 LTS recommended), rsync (recommended), openssh-client, and a dedicated Unix user in the docker group. The container image already contains Node, rsync, ssh and the Docker CLI.
  • Docker socket access is effectively root on the VM in both variants.

Development machine:

  • An rsync daemon module reachable from the runner (default), or an SSH server if you choose the optional SSH mode.
  • rsync on the daemon/SSH host; nothing extra for tar.

Install on the runner (systemd)

This is alternative A. The container deployment is described in Run as a container. Both variants use the same layout:

Purpose Path
Application /opt/runner
Config and token /etc/runner
State (HOME, caches) /var/runner
Workspaces /opt/projects
# 1. System packages
sudo apt update
sudo apt install -y rsync openssh-client ca-certificates curl
# Docker: https://docs.docker.com/engine/install/debian/
# Node 24: https://github.com/nodesource/distributions  (or nvm for a single user)

# 2. Dedicated user and directories
sudo useradd --system --create-home --home-dir /var/runner --shell /usr/sbin/nologin dev-runner
sudo usermod -aG docker dev-runner
sudo install -d -o dev-runner -g dev-runner /opt/runner /opt/projects /var/runner /etc/runner

# 3. Code (no build step)
sudo -u dev-runner -H git clone <this-repository> /opt/runner
cd /opt/runner/app
sudo -u dev-runner -H npm ci --omit=dev

# 4. No SSH key is needed for an unauthenticated rsync daemon.
#    The source is configured as rsync://host/module in config/config.yaml.

The default transport is the rsync daemon protocol: no username, password, SSH key or known_hosts entry is required. The optional SSH alternative (host, sshKey) is still supported; see File transfer.

Configuration and token live in one YAML file (config/config.yaml in the repository):

sudo install -o dev-runner -g dev-runner -m 600 /opt/runner/config/config.yaml /etc/runner/config.yaml
sudo editor /etc/runner/config.yaml    # token, rsync url, optional tuning

The service reads it through RUNNER_CONFIG=/etc/runner/config.yaml (set in the unit). The file contains the bearer token, so it is owned by dev-runner and mode 0600.

Quick check:

sudo -u dev-runner env RUNNER_CONFIG=/etc/runner/config.yaml \
  node /opt/runner/app/src/index.ts --check

The command prints JSON with Docker/Compose versions, free disk, binaries and configured sources, and exits non-zero if Docker or tar is missing.

Install the service:

sudo install -m 644 /opt/runner/docker/systemd/dev-runner.service /etc/systemd/system/dev-runner.service
sudo systemctl daemon-reload
sudo systemctl enable --now dev-runner
sudo journalctl -u dev-runner -f

Run as a container (Docker Compose)

The repository is arranged for copying it to the runner as-is:

compose.yaml              # docker compose up -d from here
.dockerignore
app/                      # Node/TypeScript sources, tests, package files
  package.json
  package-lock.json
  src/
  test/
docker/
  Dockerfile              # build context is the repo root
  systemd/                # alternative systemd unit
config/                   # mounted read-only to /etc/runner
  config.yaml             # default config; edit token + rsync url in place
state/                    # /var/runner: caches (and known_hosts for optional SSH)

The Dockerfile lives in docker/, but copies app/... because the build context is the repository root. config/ is where the user edits the real configuration directly; there is no example copy step.

The container runs the MCP server and the Docker client; project containers are siblings on the host daemon through /var/run/docker.sock. No --privileged mode is needed. Inside the container the paths are split consistently:

Purpose Container Host / source
Application /opt/runner image
Config /etc/runner config/ (read-only mount)
Config file /etc/runner/config.yaml config/config.yaml
State /var/runner state/
Workspaces /opt/projects /opt/projects (same path)

Host preparation from the repository root:

sudo install -d -m 0755 /opt/projects
cd <repository>
install -d -m 700 config state
chmod 600 config/config.yaml
# edit config/config.yaml: token (openssl rand -hex 32) and rsync url

The complete application configuration is the YAML file. It is mounted read-only to /etc/runner/config.yaml; there is no .env and no env-based configuration. sources[].url is the unauthenticated rsync daemon base (rsync://host/module); project paths are relative to it. Compose itself has fixed paths, a fixed socket and fixed port 7331; no Compose variables are involved.

Build and start:

docker compose build
docker compose up -d
docker compose ps
docker compose logs -f
docker compose exec dev-runner npm run --silent healthcheck

Alternatively cd app && npm run docker:up (the script runs Compose from the repo root).

Two invariants matter:

  1. The workspace root must be mounted at the same absolute path inside and outside. compose.yaml mounts /opt/projects to /opt/projects (no variable). Project compose files contain bind mounts like ./data; the host daemon resolves them, so the path it sees must match the path the MCP server wrote to. Do not mount it as /app/workspaces on the inside. /opt/projects is runtime data; config and small state stay in the repository root.
  2. The rsync daemon source needs no key: configure url: rsync://host/module in config/config.yaml. If you use the optional SSH mode instead, the key belongs at /etc/runner/id_ed25519 and the state directory needs a known_hosts entry.

Diagnostics and updates:

docker compose run --rm --entrypoint node dev-runner src/index.ts --check
docker compose build --pull && docker compose up -d
docker compose down          # running project stacks are NOT stopped

state/ keeps docker login credentials and caches (and known_hosts if you use the optional SSH mode); workspaces live in /opt/projects on the host. The container starts as root because the Docker socket is root-equivalent, and files it creates in /opt/projects are root-owned. To use a non-root UID,

set user:/group_add for the Docker socket GID and chown the workspace root and state/ accordingly. Registry logins:

docker compose run --rm --entrypoint sh dev-runner -c 'docker login registry.example.com'

The container is deliberately read-only (read_only: true) with writable mounts for state/ (/var/runner), /opt/projects, /tmp, and the Docker socket. Socket path, workspace path, image tag and port are hardcoded in compose.yaml; there are no Compose variables at all. All application settings live in config/config.yaml.

The image HEALTHCHECK and the compose healthcheck both run npm run --silent healthcheck (app/src/healthcheck.ts). It probes port 7331 on 127.0.0.1, validates the JSON response and exits non-zero with a readable error; no curl needed. Manual runs inside the running container use the same command.

Connect OpenChamber / OpenCode

OpenCode V2 uses mcp.servers and the remote Streamable HTTP transport. Disable OAuth and send the static token as a header:

{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "servers": {
      "dev-runner": {
        "type": "remote",
        "url": "http://192.168.1.50:7331/mcp",
        "oauth": false,
        "headers": {
          "Authorization": "Bearer {env:DEV_RUNNER_TOKEN}"
        },
        "timeout": {
          "execution": 900000
        }
      }
    }
  }
}

Put DEV_RUNNER_TOKEN into OpenCode's environment (e.g. shell profile or service unit) with the same value as token in config/config.yaml, not into the JSON file. Tools show up as dev_runner_workspace_create, dev_runner_stack_up, …; with Code Mode they are available as tools.dev_runner.*. Check the connection with opencode mcp list.

If the VM is not reachable directly, use WireGuard/Tailscale, or publish the port through a reverse proxy with TLS. Do not expose an unauthenticated runner to a network.

Configuration

All settings live in one YAML file. The container mounts config/ at /etc/runner (read-only); systemd uses the same config/config.yaml copied to /etc/runner/config.yaml. RUNNER_CONFIG only names that file. The deployment uses no .env and no JSON configuration.

root: /opt/projects
bind: 0.0.0.0
port: 7331
path: /mcp
token: ""                  # required: openssl rand -hex 32

defaultSource: workstation
workspaceTtlMinutes: 720
gcIntervalSeconds: 300
maxWorkspaces: 20
minFreeMb: 2048
cleanupImage: alpine:3.20

sources:
  - name: workstation
    strategy: rsync
    url: rsync://192.168.1.20/projects
    defaultExcludes: true
    excludes: []

config/config.yaml is the actual configuration file, shipped with an empty token and ready to edit in place. Deployment is entirely the YAML file; RUNNER_CONFIG only tells the process where it is. Restart the container or systemd service after editing it. If you use a git checkout, keep the edited token out of commits (or deploy a copy without committing).

YAML key Default Meaning
root /opt/projects Workspace storage; fixed by the container mount
bind 0.0.0.0 HTTP bind address
port 7331 HTTP port (the compose stack publishes 7331)
path /mcp MCP endpoint
token — Bearer token, required for non-loopback binds, min. 16 chars
allowUnauthenticated false Only for trusted namespaces
sources [] List of source definitions (below)
defaultSource single source Source used when a tool omits source
workspaceTtlMinutes 720 Idle cleanup; 0 disables the timer
gcIntervalSeconds 300 GC interval; 0 disables
maxWorkspaces 20 Concurrency/capacity guard
commandTimeoutMs 120000 Generic command timeout
upTimeoutMs 900000 compose up timeout
syncTimeoutMs 900000 Sync timeout
composeWaitTimeoutSeconds 180 Default --wait-timeout
maxOutputBytes 262144 Tool output cap
minFreeMb 2048 Refuse create/up below this free space
sessionIdleMinutes 60 Prune idle MCP sessions
cleanupImage alpine:3.20 Root helper image for deleting container-owned files

Sources

Field Required Meaning
name yes Tool-facing name ([A-Za-z0-9][A-Za-z0-9._-]*)
strategy yes rsync (recommended), tar or local
url rsync daemon rsync://host/module[/path], no authentication
root SSH/local Absolute project root on the dev machine (or locally for local)
host SSH/tar SSH alternative: user@hostname or user@ip
sshKey no SSH only: absolute path to the private key
sshPort no SSH only: port, default 22
excludes no Extra rsync/tar exclude patterns
defaultExcludes no false disables the built-in list

url and host/root are mutually exclusive. For the unauthenticated daemon mode only url is needed; project paths are then relative to the module.

Built-in excludes: .git/, .hg/, .svn/, node_modules/, .pnpm-store/, .yarn/cache/, .turbo/, .next/, .nuxt/, .svelte-kit/, dist/, build/, coverage/, .cache/, .DS_Store, *.log. .env files are copied on purpose. If a build needs git metadata (git rev-parse, commit hashes in the UI), set defaultExcludes: false for that source and accept slower syncs, or add the metadata through env.

Tools only accept a source name and a path relative to its daemon module or root; arbitrary hosts or absolute paths cannot be selected through MCP.

File transfer

The default is an unauthenticated rsync daemon in pull direction:

runner: rsync -a --delete --protect-args \
        rsync://192.168.1.20/projects/apps/api/ /opt/projects/workspaces/<id>/src/

Why this is the default:

  • No SSH key, username or password on the runner.
  • Incremental and fast enough for repeated runs; only changed files cross the network.
  • --delete removes files that disappeared locally (except excluded directories).
  • Works with the current, uncommitted state — exactly what "current dev state" means.

Trade-offs and alternatives:

Option Implemented Pros Cons
rsync daemon, no auth yes (default) No key/credentials, incremental, fast The daemon must be reachable from the runner; no transport encryption
rsync over SSH yes (host + sshKey) Encrypted, authenticated Needs sshd and a key
tar over SSH yes (strategy: "tar") No rsync on the runner; one stream Copies everything each time; removed local files are not deleted
local (NFS/SMB/SSHFS mount) yes (strategy: "local") No transfer step Mount latency/permissions; macOS + Docker file-sharing caveats
Git remote no Exact commits, reproducible, CI-like Requires commits; uncommitted work needs extra snapshotting
Syncthing/mutagen no Continuous sync, watch-mode friendly Extra daemon, conflict handling, hard to reset to zero reliably
Push agent on dev machine no Works behind NAT Another long-running service and credentials; more moving parts

For the optional SSH mode, if the development machine is not reachable from the VM (NAT), use a reverse SSH tunnel opened by the dev machine:

ssh -N -R 2222:127.0.0.1:22 dev-runner@runner-vm
# source: { "host": "dev@127.0.0.1", "sshPort": 2222, ... }

For high-frequency test loops, workspace_reset with mode=source is the reliable option: it stops the stack, removes volumes, deletes the whole synced tree and pulls a fresh copy. An incremental workspace_sync is for convenience while iterating; with the tar strategy it does not delete files that were removed on the dev machine.

Bind mounts inside compose files resolve relative to the project directory that was synced into src/. If a compose file builds from or mounts ../shared, create the workspace at the monorepo root instead of the app subdirectory.

Tool reference

Tool Purpose
source_browse List directories/compose files below a source root
workspace_create Sync project, validate compose file, return workspace id
workspace_list All workspaces with running state and expiry
workspace_get Services, containers, disk usage, warnings, last sync
workspace_sync Pull newer files; reload=none|restart|recreate|rebuild
workspace_reset mode=source (full zero) or clean; optional start
workspace_delete compose down -v, remove images, delete workspace
stack_up compose up -d, optional services/profiles/build/wait
stack_down Stop stack, keep volumes unless removeVolumes
stack_status Configured services + live containers
stack_logs Recent logs, all services or one, tail/since
stack_exec Run argv in a running service (no shell unless you pass sh -lc)
stack_run One-off compose run --rm (migrations, tests)
system_info Runner health, binaries, disk, sources, workspaces

Typical loop:

// find the project
source_browse({ path: "apps" })
// create and start
workspace_create({ path: "apps/api", start: true })
stack_logs({ id: "api-a1b2c3", tail: 100 })
stack_exec({ id: "api-a1b2c3", service: "app", command: ["npm", "test"] })
// after local edits
workspace_sync({ id: "api-a1b2c3", reload: "restart" })
// pristine run for a flaky test
workspace_reset({ id: "api-a1b2c3", mode: "source", start: true })
// done
workspace_delete({ id: "api-a1b2c3" })

start: true on reset reuses the services, profiles and build flag of the last stack_up.

Reset semantics

Action Containers/networks Named volumes Synced tree (incl. generated files) Images
stack_down removed kept kept kept
workspace_reset mode=clean removed removed kept kept (unless removeImages)
workspace_reset mode=source removed removed deleted + re-synced kept (unless removeImages)
workspace_delete removed removed deleted kept (unless removeImages)

Every workspace is a separate compose project named ocw-<id>. Anonymous volumes and networks are therefore namespaced automatically. Fixed container_name values, fixed volume names and external volumes are reported as warnings because they can collide between workspaces. Ports from compose files bind on the VM; they cannot be renumbered automatically.

Containers often create root-owned files in bind mounts (node_modules, database data). If the service user cannot delete them, reset/delete starts a short-lived root container from cleanupImage (default alpine:3.20) with only that workspace directory mounted, then removes the tree. Pull the image once on the runner if it has no registry access at cleanup time.

Idle workspaces are deleted by the GC after their TTL (default 12 hours). GC deletes containers and volumes even if they are still running — the TTL is measured from the last MCP tool call. Use ttlMinutes: 0 on create to disable expiry for a workspace.

Security

  • Docker socket access equals root on the VM. Run the runner on a dedicated VM, not on your workstation.
  • The default rsync daemon has no authentication. Restrict it to the runner's address on a trusted network and configure the module read-only (read only = yes) where possible.
  • Always set token in config.yaml (mode 0600); bind to a private network (WireGuard/Tailscale) or put a TLS reverse proxy in front of it. /healthz is unauthenticated and only returns a status/uptime.
  • Source endpoints are fixed by configuration. Tool input cannot select a host, only a relative path below the configured module/root; traversal is rejected. For rsync daemon sources the module controls what is exposed; for SSH sources the configured account does.
  • stack_exec and stack_run intentionally execute arbitrary argv inside workspace containers. Anyone with the token can also build/run compose files, so treat the token like root access to the VM.
  • Environment values passed to workspace_create are stored mode 0600 in the workspace and injected into compose interpolation; they are never returned by tools. Values passed to stack_up are one-off. Compose errors and log excerpts can still echo values that the compose file interpolates, so avoid long-lived secrets where possible.
  • Workspace directories are 0755 so non-root containers can traverse their bind mounts, and synced source files keep the permissions they have on the dev machine. A dedicated runner VM is therefore required; other local users could read project files.
  • Output, runtime and workspace count are capped. Reset/delete remove resources through an isolated compose project name, never globally.

Operations

  • Logs: systemd journalctl -u dev-runner -f; container docker compose logs -f.
  • Health: npm run healthcheck (or curl http://127.0.0.1:7331/healthz); in the container use docker compose exec dev-runner npm run --silent healthcheck.
  • Diagnostics: node app/src/index.ts --check, or docker compose run --rm --entrypoint node dev-runner src/index.ts --check.
  • Disk: docker system df (build cache is shared), du -sh /opt/projects/workspaces/*.
  • Upgrades: systemd cd /opt/runner && git pull && npm ci --omit=dev && systemctl restart dev-runner; container docker compose build --pull && docker compose up -d. Restarting the service keeps running containers; workspaces and metadata are read from disk.
  • Orphan check after a crash: docker ps -a --filter label=com.docker.compose.project=ocw- and remove leftovers with docker compose -p <project> down -v.
  • No project data is stored beyond workspaces and Docker resources. Database data lives in compose volumes and is deleted with the workspace. state/ (container) or /var/runner (systemd) only holds known_hosts, registry logins and caches.

Troubleshooting

  • docker_unavailable in system_info: the service user cannot reach the Docker socket. Check groups dev-runner (must include docker) and systemctl status docker.
  • rsync_missing: install rsync on the runner, or set "strategy": "tar". With tar, files removed on the dev machine are not deleted by workspace_sync.
  • rsync daemon connection fails: check url in config/config.yaml, that the daemon is reachable from the runner and that the module exists (rsync rsync://host/).
  • Optional SSH mode fails with host key verification: add the dev host to /var/runner/.ssh/known_hosts (systemd) or state/.ssh/known_hosts (container). Password prompts are disabled by BatchMode=yes.
  • compose_missing: the synced path has no compose file. Use source_browse, create the workspace at the directory that contains compose.yaml, or pass composeFiles.
  • Build/bind paths outside the project: warnings say "outside the synced project". Create the workspace at the monorepo root so the relative paths exist in src/.
  • Port already allocated: compose files publish ports on the VM. Stop the conflicting workspace or change the port mapping; there is no automatic port remapping.
  • container_name or fixed volume name warnings: those names are global on the Docker host. Remove them and let Compose use the project prefix (ocw-<id>).
  • A bind mount is not writable in the container: the workspace root is traversable (0755), but files keep the permissions from the dev machine. Use named volumes for data, run the container as a matching UID, or fix the source permissions.
  • Disk full: workspace_delete older workspaces, docker system df, and raise minFreeMb in config.yaml if needed. Build cache is shared across workspaces.
  • Private registry or private npm packages: log in on the runner once (docker login, ~dev-runner/.npmrc) or pass tokens through env/build secrets. The runner does not manage registry credentials.
  • After a runner crash, system_info.runner.orphanDirectories lists directories without metadata; remove them manually after checking docker ps -a --filter label=com.docker.compose.project.

Development

cd app
npm install
npm test            # unit + protocol tests, no Docker daemon required
npm run typecheck
npm run test:e2e    # RUNNER_E2E=1 real Docker lifecycle (pulls alpine)
npm run dev         # HTTP server with --watch
npm run stdio       # stdio transport for local OpenCode debugging (no token needed)
npm run healthcheck # probe port 7331 /healthz and exit 0/1

No build step: node app/src/index.ts runs the TypeScript sources via Node's type stripping. This requires erasable TypeScript syntax (no enums, namespaces or parameter properties) and explicit .ts import extensions.

Tests use a fake Docker engine, so workspace logic, compose discovery, sync, reset, HTTP auth and the MCP handshake are covered without a Docker daemon.

Limits / possible next steps

  • One runner instance per workspace root (download locks are in-process; Docker namespacing is safe across instances, but metadata/GC are not coordinated).
  • No push/reverse sync, no file watcher, no compose watch management.
  • No per-workspace port remapping; compose files must define usable ports.
  • No OAuth or per-user authorization, only one shared bearer token.
  • The tar fallback does not delete removed files; rsync or a full reset is authoritative.