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

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`.