- TypeScript 96.2%
- JavaScript 2.6%
- Dockerfile 1.2%
| app | ||
| config | ||
| docker | ||
| .dockerignore | ||
| .gitignore | ||
| compose.yaml | ||
| LICENSE | ||
| README.md | ||
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 usesconfig --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 thedockergroup. 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.
rsyncon the daemon/SSH host; nothing extra fortar.
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:
- The workspace root must be mounted at the same absolute path inside and outside.
compose.yamlmounts/opt/projectsto/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/workspaceson the inside./opt/projectsis runtime data; config and small state stay in the repository root. - The rsync daemon source needs no key: configure
url: rsync://host/moduleinconfig/config.yaml. If you use the optional SSH mode instead, the key belongs at/etc/runner/id_ed25519and the state directory needs aknown_hostsentry.
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.
--deleteremoves 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
tokeninconfig.yaml(mode0600); bind to a private network (WireGuard/Tailscale) or put a TLS reverse proxy in front of it./healthzis 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_execandstack_runintentionally 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_createare stored mode0600in the workspace and injected into compose interpolation; they are never returned by tools. Values passed tostack_upare 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
0755so 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; containerdocker compose logs -f. - Health:
npm run healthcheck(orcurl http://127.0.0.1:7331/healthz); in the container usedocker compose exec dev-runner npm run --silent healthcheck. - Diagnostics:
node app/src/index.ts --check, ordocker 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; containerdocker 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 withdocker 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 holdsknown_hosts, registry logins and caches.
Troubleshooting
docker_unavailableinsystem_info: the service user cannot reach the Docker socket. Checkgroups dev-runner(must includedocker) andsystemctl status docker.rsync_missing: installrsyncon the runner, or set"strategy": "tar". Withtar, files removed on the dev machine are not deleted byworkspace_sync.- rsync daemon connection fails: check
urlinconfig/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) orstate/.ssh/known_hosts(container). Password prompts are disabled byBatchMode=yes. compose_missing: the synced path has no compose file. Usesource_browse, create the workspace at the directory that containscompose.yaml, or passcomposeFiles.- 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_nameor 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_deleteolder workspaces,docker system df, and raiseminFreeMbinconfig.yamlif 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 throughenv/build secrets. The runner does not manage registry credentials. - After a runner crash,
system_info.runner.orphanDirectorieslists directories without metadata; remove them manually after checkingdocker 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
watchmanagement. - No per-workspace port remapping; compose files must define usable ports.
- No OAuth or per-user authorization, only one shared bearer token.
- The
tarfallback does not delete removed files; rsync or a full reset is authoritative.