devcontainer-mcp

vovayartsev-dice/devcontainer-mcp
0 starsCommunity

Install to Claude Code

This server doesn't publish a one-line install command. Follow the setup in the source repository.

Summary

A minimal MCP server that exposes a single bash tool to execute commands inside a devcontainer, returning real output and exit codes.

README.md

devcontainer-mcp

A Model Context Protocol 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 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

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 — no install step:

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):

{
  "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:

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:

   colima stop
   colima start --ssh-agent
  1. 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.

  1. In the project's .devcontainer/docker-compose.yml, mount the forwarded

socket and point SSH_AUTH_SOCK at it (service name varies per project):

   environment:
     SSH_AUTH_SOCK: /run/host-services/ssh-auth.sock
   volumes:
     - /run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock
  1. 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 -lssh-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 stopcolima start --ssh-agent.

  • Headless / CI alternative — skip SSH entirely by rewriting the remote to

an HTTPS token URL inside the container:

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

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). 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:

{
  "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.

## 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:

{
  "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

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

Related MCP servers

Browse all →