A named volume whose mount point is absent from the image is created owned by root, so the worker - which drops root - could not have written a single copy into /instances. The failure would have surfaced at the first start_service, as a refused execution far from the line that caused it. Creating the two directories in the image and giving them to the runtime user is what makes the volume inherit that ownership. The user id follows as a build argument, because one number has to satisfy three things at once: who the process is, who owns the volumes, and who owns the workspace directory on the host. Left hardcoded, changing MCP_UID to match a deployment's own user would have brought the same fault back. The guard around useradd is for the ordinary case of pointing it at an id the base image already uses - 1000 is the node user - which is otherwise a build failure. Verified by building both ways and writing into a fresh volume as 10001 and as 1000, and by checking the image carries node 24, Python 3.11 and OpenJDK 25. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| src | ||
| test | ||
| .dockerignore | ||
| .gitignore | ||
| Dockerfile | ||
| README.md | ||
| package-lock.json | ||
| package.json | ||
| services.example.json | ||
README.md
dev-server-mcp
Streamable HTTP MCP server for starting and observing preconfigured development services. MCP callers select only a service id; commands, arguments, directories, URLs, and environment values come from an operator-owned read-only configuration.
Security boundary
The deployment has two processes in separate containers:
dev-server-mcpis the authenticated MCP control plane. It has no workspace mount and never executes project code.dev-server-workerhas a read-only workspace mount and launches configured project processes. It has no MCP API keys and is not published on the host.
Neither container needs the Docker socket. The worker is still a container boundary, not a VM boundary; use an ephemeral VM or microVM for hostile multi-tenant code.
The project mount stays read-only. Configure framework caches, PID
files, and temporary output under /tmp; if a tool
fundamentally requires a writable tree, run it against an ephemeral copy
instead of changing the host bind mount to read-write.
Tools
list_services, service_status,
start_service, stop_service,
restart_service, discard_service, and
service_logs.
There is deliberately no arbitrary command, argument, environment, port, or working-directory parameter.
One instance per execution
A service declares workspaceMode:
sharedserves one fixed tree inside the workspace mount, with its dependencies installed ahead of time by the operator. This is the original behaviour.executionserves the tree the current execution wrote. The worker copies/workspace/<key>into a throwaway directory of its own, installs the declared dependencies there, and runs the service in the copy. Several executions run at once, each on its own allocated port.
The execution key is never a tool argument. It
arrives as the x-preview-key header, read once when the MCP
session is opened and pinned to it; a later request carrying a different
value is refused. A model can therefore work only inside the scope its
session was opened for, and cannot name another execution’s.
The copy is what makes the mode safe to offer. The tree is written by
whoever the flow gave write access to, so it is untrusted input:
symlinks are dropped rather than followed, an execution directory
replaced by a symlink is refused outright, node_modules and
.git are skipped, and the whole copy is refused past
declared entry and byte limits. The workspace mount itself stays
read-only.
A restart_service re-copies before starting, so it
serves the code as it is now. A discard_service stops the
service and deletes the copy; nothing else reclaims that disk.
Dependencies are installed with the command the operator declares
(install), once per lockfile: the worker records a
fingerprint of the lockfiles it installed for and skips the install
while they match. With no lockfile at all, the install is never treated
as current.
What it can run
Nothing in this server knows what a framework is. Three things decide what it can start:
- The command allowlist
(
DEV_SERVER_ALLOWED_COMMANDS). Any project with an npm script works as it is: Angular, Vite, Next, Nuxt, SvelteKit, Astro, webpack-dev-server, Storybook, a hand-written Express.npxis deliberately not in the default: it fetches and runs an arbitrary package by name, which hands back the free choice of command this allowlist exists to remove. - The service’s own conventions. It has to take its
port from an argument or from an environment variable — the
envmap is templated too, so"PORT": "${port}"covers the frameworks that readPORTinstead of a flag — and bind0.0.0.0.healthUrlis optional: omit it and a service that speaks no HTTP at all still starts, just without a health check. - The image, which is the real limit.
The image carries Node 24, a JDK 25 and Python 3. It is Debian and not Alpine on purpose: on musl, pip cannot use the manylinux wheels nearly every package ships, so it falls back to building from source and needs a compiler — a bigger image than Debian, reached by a longer road.
There is no Maven in it. A Spring project carries
./mvnw, which pins the version the project is built with;
installing a second Maven would only give an agent a way to use the
wrong one.
Adding a toolchain means changing the image, not the code. Two things
travel with that decision: the install command (install
takes any allowlisted command, so pip install -r and
./mvnw dependency:go-offline are just configuration), and
the egress allowlist in the proxy — a Go or Rust project needs its
module proxy added there or its install will simply find nothing.
A frontend and a backend together is two service definitions, each
with its own instance and port. Both count against
DEV_SERVER_MAX_INSTANCES.
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.
${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.
Two flags in a service definition are not optional, both learned by running this:
--host 0.0.0.0: a dev server left to its default binds IPv6 loopback only. A health check on127.0.0.1then declares a 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 mentions it only in its log, leaving the preview at an address nobody looks at.
Every install logs the npm and node the sanitised PATH
resolved to. That is not decoration: the toolchain decides which
platform-specific optional dependencies land in
node_modules, and getting it wrong produces an install that
exits 0 followed by a dev server dying on a missing native module — a
stack trace that says nothing about the real cause.
The MCP endpoint is POST /mcp. Initialize first and send
the returned Mcp-Session-Id on subsequent
POST, optional GET, and final
DELETE requests. Legacy SSE endpoints and stdio are not
implemented.
Required environment variables:
DEV_SERVER_MCP_API_KEYS: comma-separatedSUBJECT=TOKENvalues; tokens must contain at least 32 characters.DEV_SERVER_WORKER_TOKEN: independent token shared only by the MCP facade and private worker.- Worker only:
DEV_SERVER_CONFIG,DEV_SERVER_WORKSPACE_ROOT, andDEV_SERVER_ALLOWED_COMMANDS.
Worker settings for the per-execution mode:
| variable | default | notes |
|---|---|---|
DEV_SERVER_INSTANCES_ROOT |
unset | Where the copies live. Required for execution services;
without it they are refused. Must be writable, on disk, and allow
execution — not the noexec RAM-backed /tmp,
where native .node modules will not load and a 333 MB
dependency tree would eat a quarter of the memory limit. |
DEV_SERVER_PORT_RANGE |
5200-5219 |
Ports handed to instances. A port is offered only after the worker has bound it itself, so a port held by an orphan is skipped. |
DEV_SERVER_MAX_INSTANCES |
4 |
Concurrent running services. Sized by memory: two frontend builds saturate a 2 GB container. |
DEV_SERVER_EGRESS_PROXY |
unset | The worker’s only route out. Set to the stack’s proxy, which allows
CONNECT to the package registries and nothing else. npm and pip read the
variables it produces; a JVM does not, so a Maven service carries
-Dhttps.proxyHost in its declared arguments. |
DEV_SERVER_NO_PROXY |
unset | Hosts to reach directly, the loopback among them. |
DEV_SERVER_NPM_REGISTRY |
unset | Only needed if you put a caching mirror in front of npm; unset means the public registry, through the proxy. |
DEV_SERVER_NPM_CACHE |
unset | Shared between executions, which is safe because npm ci
verifies every package against the lockfile: a poisoned cache fails the
install instead of passing code through. Worth it — it took a cold
install of a real 523-package project from 14 s to 2.5 s. |
DEV_SERVER_MAX_WORKSPACE_BYTES |
536870912 |
Refuses a runaway tree instead of filling the volume. |
DEV_SERVER_MAX_WORKSPACE_ENTRIES |
20000 |
The same, by file count. |
DEV_SERVER_CHILD_PATH |
/usr/local/bin:/usr/bin:/bin |
The sanitised PATH children run with. Configurable only
because a host outside the image keeps its toolchain elsewhere. |
DEV_SERVER_INSTANCE_HOME |
/tmp/dev-server |
HOME for shared services only. A
per-execution service gets a HOME of its own instead, which is how
Maven’s ~/.m2 and pip’s ~/.cache end up
isolated without a line of per-toolchain code. That matters most for
Maven: npm’s shared cache is safe because npm ci verifies
every package against the lockfile, while Maven has no lockfile to
verify against, so a shared local repository would be a channel from one
execution into the next. |
JAVA_HOME |
from the image | Forwarded to children. A JVM finds its home through this before it
looks at PATH, and ./mvnw refuses to run
without one of the two. |
A start_service that installs holds its request open, so
the real ceiling on an install is the MCP client’s request timeout — 120
s in the workflow manager, not the worker’s own limits. A project whose
install is slower than that belongs in shared mode with its
dependencies installed ahead of time.
Run tests with npm test.