94 lines
9.3 KiB
Markdown
94 lines
9.3 KiB
Markdown
# 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 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:
|
|
|
|
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 service's 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 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`.
|