devcontainer-mcp
README.md
# devcontainer-mcp
A [Model Context Protocol](https://modelcontextprotocol.io) server that runs
shell commands **inside a devcontainer** (mirroring Claude Code's built-in
`Bash` tool) and manages the container's **lifecycle** — start, rebuild, stop,
tear down — plus a container lister with **orphan detection**. It also ships a
small CLI used by the Claude Code `WorktreeRemove` hook to tear a container
down when its worktree is deleted.
Tools are addressed as `mcp__devcontainer__<tool>` (server name `devcontainer`):
`bash`, `up`, `rebuild`, `stop`, `down`, `list_containers`.
## Why
This is a Python rewrite of the TypeScript
[`mcp-devcontainers`](https://github.com/vovayartsev-dice/mcp-devcontainers)
server. The original `bash`-equivalent returned a fixed string and tee'd output
to a file, so the model never saw what actually happened. Here, **real combined
stdout/stderr and the exit status flow back** in the tool result, and a non-zero
exit (or a timeout) flags the result as an error.
Container lifecycle is **explicit and agent-driven**: `bash` never brings the
container up. The agent calls `up` once for a fresh worktree, `rebuild` after
changing the devcontainer config, `stop` to pause, and `down` to tear down.
There are no `SessionStart` / `SessionEnd` hooks — only a `WorktreeRemove` hook
(see below) to clean up after a deleted worktree.
## Requirements
- Python ≥ 3.10
- The [`@devcontainers/cli`](https://github.com/devcontainers/cli) available as
`devcontainer` on `PATH` (or override via `DEVCONTAINER_CLI`, e.g.
`npx @devcontainers/cli`) — used by `bash`, `up`, `rebuild`.
- Docker with the Compose v2 plugin (`docker compose`); falls back to the
legacy `docker-compose` binary — used by `stop`, `down`, `list_containers`.
## Install / run
Run straight from the repo with [`uvx`](https://docs.astral.sh/uv/) — no install
step:
```bash
uvx --from git+https://github.com/vovayartsev-dice/devcontainer-mcp devcontainer-mcp
```
The server speaks MCP over **stdio**.
## `.mcp.json`
Add it to your project's `.mcp.json` (the server key is what produces the
`mcp__devcontainer__*` tool prefix):
```json
{
"mcpServers": {
"devcontainer": {
"command": "uvx",
"args": [
"--from",
"git+https://github.com/vovayartsev-dice/devcontainer-mcp",
"devcontainer-mcp"
],
"env": {
"DEVCONTAINER_WORKSPACE_FOLDER": "${workspaceFolder}"
}
}
}
}
```
## Configuration (environment variables)
| Variable | Default | Purpose |
| ------------------------------- | -------------- | ------------------------------------------------------------------------ |
| `DEVCONTAINER_CLI` | `devcontainer` | devcontainer CLI invocation; shell-split, so `npx @devcontainers/cli` works. |
| `DEVCONTAINER_DOCKER` | `docker` | Path to the `docker` binary. |
| `DEVCONTAINER_WORKSPACE_FOLDER` | current dir | Default host workspace folder when the `workspaceFolder` arg is omitted. |
| `DEVCONTAINER_PROJECT_PREFIX` | *(from origin)* | Compose project **base** name. Takes priority over the git `origin` remote when set. |
| `DEVCONTAINER_CONTAINER_WORKSPACE_FOLDER` | *(from `read-configuration`)* | In-container workspace folder for the linked-worktree `.git` overlay. Overrides `devcontainer read-configuration`'s `workspaceFolder`. |
### Project naming
The Compose project name is derived automatically, with no hardcoded project
name. First a **base** shared by all worktrees of one repo:
```
base = slug($DEVCONTAINER_PROJECT_PREFIX) if set (explicit config wins)
| slug("<org>/<repo>") from `git -C <wf> remote get-url origin`
| error if neither is available → the tool fails
slug(s) = lowercase(s), "/" → "-", keep only [a-z0-9_-]
```
Then the worktree:
```
project(wf) = base # main checkout
= base + "-" + slug(basename(wf)) # linked worktree
```
`origin git@github.com:acme/widgets.git` → base `acme-widgets`, so the main
checkout is `acme-widgets` and a linked worktree `feature-x` is
`acme-widgets-feature-x`. Main vs linked is detected from git itself
(`--git-dir` vs `--git-common-dir`), not from the folder name.
### Git decoupling (auto-derived, **no global `GIT_*` env**)
Lifecycle tools/commands that start or recreate a container (`up`, `rebuild`,
`stop`, `down` — tool **and** the WorktreeRemove CLI) inject a small environment,
computed **purely from git** in the workspace folder (argv-only, no shell). The
user and the compose file never set these by hand; they work identically for a
main checkout and a linked worktree. The MCP merges them into a copy of
`os.environ` for the child `devcontainer` / `docker compose` process.
- `COMPOSE_PROJECT_NAME` = `project(wf)` (above).
- `HOST_GIT_COMMON_DIR` = `realpath(git -C <wf> rev-parse --git-common-dir)` —
host abs path of the shared `.git` (the bind-mount **source** for
`/home/app/git/common`).
**No `GIT_DIR` / `GIT_COMMON_DIR` / `GIT_WORK_TREE` is ever exported.** A global
env var can't be scoped to "only the project worktree", so it leaks into git
invocations for *dependency* repos — e.g. `mix deps.get` clones a git dep and
runs `git --git-dir=.git config …`; a global `GIT_COMMON_DIR` redirects that
write to the shared, read-only common config →
`error: could not write config file … Device or resource busy`. Instead the
*project's* git is decoupled by overlaying its `.git` via **bind mounts** (no
env), done by `up` / `rebuild`:
- **Main checkout** — `.git` is a real directory arriving via the workspace
mount; plain git already works. Nothing git-specific is added.
- **Linked worktree** — `.git` is a *file* containing `gitdir: <HOST abs path>`
(absent in the container). `up` / `rebuild` stage two tiny host files under
`<wf>/.devcontainer/.gitwiring/` (gitignored; created *before* `devcontainer
up` so the mount sources exist) and pass them as container-only
`devcontainer up --mount type=bind,…` flags — never rewriting the host's
shared git files:
- `dotgit` = `gitdir: /home/app/git/common/<rel>` → mounted over
`<container-workspace>/.git`. git resolves the gitdir under the shared
common mount, whose `commondir` (`../..`) lands at `/home/app/git/common`,
and infers the work-tree as the dir holding `.git`.
- `reverse_gitdir` = `<container-workspace>/.git` → mounted over
`/home/app/git/common/<rel>/gitdir` so git's worktree↔gitdir consistency
check passes in the container.
`<rel>` is the gitdir's path relative to the common dir (`.` for main,
`worktrees/<name>` for a worktree). The **in-container** workspace folder
(`<container-workspace>`, e.g. `/home/app/kimelixir`) is resolved from
`$DEVCONTAINER_CONTAINER_WORKSPACE_FOLDER` if set, else from `devcontainer
read-configuration --workspace-folder <wf>` (its `workspaceFolder`). For a
linked worktree that can't be resolved, `up` / `rebuild` fail with a clear
message rather than silently starting a container with broken project git.
> **Consuming repos:** add `.devcontainer/.gitwiring/` to `.gitignore`.
If `wf` isn't a git repo (or git is missing) `HOST_GIT_COMMON_DIR` is omitted
and compose falls back to its default mount source.
The compose file mounts the shared common dir and sets **no** `GIT_*` env:
```yaml
volumes:
- ${HOST_GIT_COMMON_DIR:-../.git}:/home/app/git/common
- ${HOST_GIT_COMMON_DIR:-../.git}/hooks:/home/app/git/common/hooks:ro
- ${HOST_GIT_COMMON_DIR:-../.git}/config:/home/app/git/common/config:ro
```
## SSH agent forwarding (Colima)
Symptom — git-over-SSH fails **inside** the container even though it works in a
plain `devcontainer exec bash` on the host:
```
Could not open a connection to your authentication agent.
git@github.com: Permission denied (publickey).
```
`mix deps.get`, `bundle`, `go get`, etc. that pull a **private** dep over
`git@github.com:` then fail with `Could not read from remote repository`.
**Why.** The MCP `bash` tool runs `devcontainer exec … bash -lc` from the
**long-lived MCP server process**, whose environment is a snapshot taken when
Claude Code launched it — detached from your interactive login shell. So unlike
your terminal, it usually has **no `SSH_AUTH_SOCK`**, and the devcontainer CLI
has no agent to forward. Note the `bash` tool only forwards env vars you pass
explicitly via `env` (as `--remote-env`); it never forwards `SSH_AUTH_SOCK` from
the host, and forwarding one variable is useless unless the agent socket is also
mounted into the container.
**With Colima this is worse:** the docker daemon runs inside a Lima VM, and the
macOS agent socket (`/private/tmp/com.apple.launchd.*/Listeners`) is **not** a
path that exists inside the VM, so neither the CLI's auto-forward nor a bind
mount of that path can reach it.
**Fix — forward the agent at the Colima layer and mount the forwarded socket in
compose** (this sidesteps the MCP-env problem entirely, since the mount is wired
at container-create time, not via the MCP's `SSH_AUTH_SOCK`):
1. Start Colima with agent forwarding (documented flag is `--ssh-agent`; there
is no `-s` short form). This exposes the agent in the VM at the
Docker-Desktop-compatible path `/run/host-services/ssh-auth.sock`:
```bash
colima stop
colima start --ssh-agent
```
2. On the **Mac**, make sure the key is loaded: `ssh-add -l` (add it with
`ssh-add ~/.ssh/<key>` if empty), then `ssh -T git@github.com` should greet
you rather than deny publickey.
3. In the project's `.devcontainer/docker-compose.yml`, mount the forwarded
socket and point `SSH_AUTH_SOCK` at it (service name varies per project):
```yaml
environment:
SSH_AUTH_SOCK: /run/host-services/ssh-auth.sock
volumes:
- /run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock
```
4. **Recreate** the container so the mount applies — `rebuild` (or `down` then
`up`). A plain `bash`/`exec` is not enough: mounts are established only at
container creation.
Verify inside the container with `ssh -T git@github.com` (not `ssh-add -l` —
`ssh-add` may be absent, and the agent itself lives on the Mac).
Caveats:
- **Restart ordering** (a known Lima/virtiofs issue): if you `colima stop` while
containers using the agent are still up, the forward can break after the next
`colima start`. Stop those containers first, then `colima stop` → `colima
start --ssh-agent`.
- **Headless / CI alternative — skip SSH entirely** by rewriting the remote to
an HTTPS token URL inside the container:
```bash
git config --global url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "git@github.com:"
```
Cleaner for CI; for local dev the agent forward above keeps the key off disk.
## Tools
All tools accept an optional **`workspaceFolder`** (host path to the worktree
holding the `.devcontainer` config); it defaults to
`$DEVCONTAINER_WORKSPACE_FOLDER` or the server's working directory.
Every tool returns the underlying command's **combined stdout/stderr** (the
`bash`/lifecycle tools also append the exit code on failure), with `isError`
set on a non-zero exit or timeout.
Output is returned **lazily**: by default `bash`, `up` and `rebuild` send back
only a head+tail **preview** of the first/last `maxLines` lines (default
`200`); the full output is retained and any line range can be re-read with the
[`bash_output`](#bash_output) tool, or the whole thing fetched in one call with
`maxLines: 0`. Whatever is returned is also char-capped to ~30 000 chars as a
final safety net. **A preview is not the full output** — see
[Guidance for consuming agents](#guidance-for-consuming-agents).
### `bash`
Runs a command inside the container via
`devcontainer exec --workspace-folder <wf> [--remote-env K=V ...] bash -lc "<command>"`.
It does **not** start the container — call `up` first.
| Field | Type | Required | Default | Notes |
| ----------------- | ----------------------------------------- | -------- | ----------------------- | --------------------------------------------------------------------------- |
| `command` | string | ✅ | — | Run via `bash -lc` inside the container. Pipes/`&&`/globs/redirects work. |
| `cwd` | string | | workspace root | Working dir inside the container: `cd <cwd> && ( <command> )`. |
| `env` | object `{KEY: VAL}` **or** list `KEY=VAL` | | — | Forwarded as repeated `--remote-env`. |
| `timeout` | integer (ms) | | `120000` (max `600000`) | On timeout the process group is killed; result flagged as error. |
| `maxLines` | integer | | `200` | Max lines in the returned head+tail preview. `0`/negative → return everything. Full output is retained regardless. |
| `description` | string | | — | Logged for observability; otherwise unused. |
| `workspaceFolder` | string | | cwd / env var | Host path holding the `.devcontainer` config. |
### `bash_output`
Reads any line range from the full output of an earlier `bash`/`up`/`rebuild`
run that was truncated to a preview. Outputs are retained per session (most
recent runs kept); an evicted/unknown id returns an error (just re-run).
| Field | Type | Required | Default | Notes |
| -------------------- | ------- | -------- | ------- | -------------------------------------------------------------- |
| `bash_execution_id` | string | ✅ | — | The id reported by a truncated run (e.g. `"exec_3"`). |
| `offset` | integer | | `1` | 1-based start line. |
| `limit` | integer | | `200` | Number of lines to return. `0`/negative → to end of output. |
### `up`
`devcontainer up --workspace-folder <wf>` (plus the `COMPOSE_PROJECT_NAME` /
`HOST_GIT_COMMON_DIR` env above, and — for a linked worktree — the `.git`
overlay `--mount` flags from [Git decoupling](#git-decoupling-auto-derived-no-global-git_-env)).
Starts and, if needed, creates the container. Call once for a fresh worktree
before `bash`.
| Field | Type | Required | Default | Notes |
| ---------- | ------- | -------- | ------- | ---------------------------------------------------------------------------------------------- |
| `verbose` | boolean | | `false` | `true` raises the CLI log level to `trace` (`--log-level trace`) for the fullest detail. The CLI has no level below its default `info`, so this only adds detail; it cannot make a run quieter. |
| `maxLines` | integer | | `200` | Max lines in the returned preview. `0`/negative → return the full log. Full log is retained for `bash_output`. |
### `rebuild`
`devcontainer up --workspace-folder <wf> --remove-existing-container [--build-no-cache]`.
Recreates the container (which triggers an image build); use after changing the
Dockerfile / devcontainer config. Two strategies:
| Field | Type | Required | Default | Notes |
| ---------- | ------- | -------- | ------- | -------------------------------------------------------------------------------------- |
| `noCache` | boolean | | `false` | `false` → cached rebuild (`--remove-existing-container`). `true` → from-scratch build (`--build-no-cache`). |
| `verbose` | boolean | | `false` | `true` raises the CLI log level to `trace` for the fullest build log (build steps included). |
| `maxLines` | integer | | `200` | Max lines in the returned preview. `0`/negative → return the full build log; it is retained for `bash_output` regardless. |
> Note: `--build` is a `docker compose` flag, **not** a `devcontainer up` flag —
> passing it errors `Unknown argument: build`.
### `stop`
`docker compose --project-directory <wf>/.devcontainer -p <project> stop`. Stops
the container but keeps it and its named volumes for a fast resume (`up`).
### `down`
`docker compose -p <project> down [-v]`. Full teardown of containers and
networks. **Label-based**: `<project>` is read from the container whose
`working_dir` label points at `workspaceFolder` (not recomputed from the path),
so it also tears down orphans whose worktree dir is already gone. A workspace
with no matching container is a no-op.
| Field | Type | Required | Default | Notes |
| --------------- | ------- | -------- | ------- | ---------------------------------------------------- |
| `removeVolumes` | boolean | | `false` | Also remove named volumes (`docker compose down -v`).|
### `list_containers`
Lists this repo's devcontainer containers — those whose compose project is the
repo `base` or `base-<worktree>` (see *Project naming*). Takes no arguments and
returns a JSON array of:
```jsonc
{
"name": "acme-widgets-feature-x-app-1",
"project": "acme-widgets-feature-x",
"status": "Up 2 hours",
"workspaceFolder": "/home/me/work/feature-x", // working_dir label, "/.devcontainer" stripped
"orphan": true // worktree root no longer exists on disk
}
```
**Deleting orphan devcontainers** composes from the primitives — no separate
prune tool: call `list_containers`, then `down` (by `workspaceFolder`, with
`removeVolumes: true` to also drop volumes) on every entry with
`"orphan": true`. Teardown is label-based — it reads the container's real
compose project rather than recomputing it from the path — so `down` works on
orphans whose worktree dir is already gone, same as the `WorktreeRemove` CLI
below.
## Guidance for consuming agents
The tool descriptions already teach this, but if a project wants to reinforce it
for the agents working in that repo, drop a section like the following into the
**consuming project's** `CLAUDE.md` (this is the repo reached *through* the
`bash` tool — e.g. your app, not this server). Tool descriptions reach every
consumer automatically; a `CLAUDE.md` only helps the one repo that has it, so
treat this as belt-and-suspenders.
```markdown
## Running commands in the devcontainer (MCP `devcontainer` tools)
All shell work runs **inside the devcontainer** via `mcp__devcontainer__bash`,
not on the host. Call `mcp__devcontainer__up` once per fresh worktree before the
first `bash` call.
**`bash` returns a PREVIEW for large output, not the full thing.** When output
exceeds `maxLines` (default 200) only a head+tail slice comes back; the middle
is omitted and the result carries a `bash_execution_id`. Do not act on a
truncated preview as if it were the complete result.
To get the full output:
- **Need everything** (reading a file, parsing a command's full result,
inspecting complete test/migration/build logs) → pass `maxLines: 0` on the
`bash` call.
- **Need only part** of a long result → make a separate
`mcp__devcontainer__bash_output` call with the reported `bash_execution_id`,
paging via `offset`/`limit`.
Setting up test databases is full-output work — run it with `maxLines: 0`:
bash({ command: "mix ecto.create && mix ecto.migrate", maxLines: 0 })
```
## WorktreeRemove hook (CLI mode)
`devcontainer-mcp down` (with the Claude Code hook JSON on **stdin**) tears down
the container that belonged to the removed worktree. Cleanup is **label-based**:
it asks docker for the container(s) whose compose `working_dir` points at
`worktree_path`, reads their *real* `com.docker.compose.project` label, and runs
`docker compose -p <project> down`. It never recomputes the project name from
the path, so it keeps working when invoked from the `uvx` cache *after* the
worktree directory (and its compose file) has been deleted — and regardless of
the naming scheme. No matching container, bad input, or docker being
unavailable are no-ops (exit 0); only a real `docker compose` failure exits
non-zero.
Pass `-v` (`--volumes` / `--remove-volumes`) to tear down with `docker compose
down -v`, removing the named volumes as well. Use it in the hook — once a
worktree is removed there is no path left to reach its volumes, so without `-v`
they leak. This makes the hook a complete teardown (containers + networks +
volumes); the matching git cleanup (`git worktree remove`, `git branch -D`) is
already handled by Claude Code's own worktree removal that fires this hook.
Register it in your Claude Code settings:
```json
{
"hooks": {
"WorktreeRemove": [
{
"hooks": [
{
"type": "command",
"command": "uvx --from git+https://github.com/vovayartsev-dice/devcontainer-mcp devcontainer-mcp down -v"
}
]
}
]
}
}
```
## Security / scope
- All subprocesses are launched **argv-only** (never `shell=True`, never a host
`sh -c`). The only shell that interprets a command string is `bash` **inside
the container**, so host-side shell injection isn't possible.
- The container is the blast radius — this server mounts no Docker socket into
the container and runs nothing `--privileged`.
- No background execution / streaming, and no auto-`up` from `bash`.
## Development
```bash
uv run devcontainer-mcp # run the server from a checkout
uv run devcontainer-mcp down # run the WorktreeRemove CLI (reads JSON on stdin)
uv run devcontainer-mcp down -v # ...and also remove the named volumes
uv build # build wheel + sdist
```
TDQS
A4.2/5.0
Scored across 1 tool
Disambiguation5/5
Only one tool exists, so there is no ambiguity between tools. The single 'bash' tool is clearly defined for running commands inside a devcontainer.
Naming Consistency5/5
With only one tool named 'bash', naming is trivially consistent. The name is simple and lowercase, which is fine for a single tool.
Tool Count3/5
The server has only one tool, which feels thin for a server named 'devcontainer-mcp'. Most devcontainer workflows involve more than just running commands, so the count is borderline low.
Completeness3/5
The server covers only shell execution inside a devcontainer. While this is core functionality, it lacks tools for container lifecycle management (start, stop, config), leaving notable gaps for typical devcontainer use.
Maintenance
ActivityMaintained
ResponsivenessSyncing