Skip to main content
Glama
vovayartsev-dice

devcontainer-mcp

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.

Related MCP server: Shell Server

Requirements

  • Python ≥ 3.10

  • The @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 — 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
  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):

    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 -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
Install Server
A
license - permissive license
A
quality
F
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Tools

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/vovayartsev-dice/devcontainer-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server