dev-mcps/dev-server-mcp/README.md

9.3 KiB
Raw Permalink Blame History

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-mcp is the authenticated MCP control plane. It has no workspace mount and never executes project code.
  • dev-server-worker has 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:

  • shared serves one fixed tree inside the workspace mount, with its dependencies installed ahead of time by the operator. This is the original behaviour.
  • execution serves 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 executions.

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:

  1. 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. npx is 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.
  2. The services own conventions. It has to take its port from an argument or from an environment variable — the env map is templated too, so "PORT": "${port}" covers the frameworks that read PORT instead of a flag — and bind 0.0.0.0. healthUrl is optional: omit it and a service that speaks no HTTP at all still starts, just without a health check.
  3. 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 on 127.0.0.1 then 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-separated SUBJECT=TOKEN values; 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, and DEV_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 workers only route out. Set to the stacks 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 Mavens ~/.m2 and pips ~/.cache end up isolated without a line of per-toolchain code. That matters most for Maven: npms 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 clients request timeout — 120 s in the workflow manager, not the workers 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.