Skip to main content
Glama
vovayartsev-dice

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