diff --git a/.github/hooks/devcontainer-guard.json b/.github/hooks/devcontainer-guard.json
new file mode 100644
index 0000000..a08ef1c
--- /dev/null
+++ b/.github/hooks/devcontainer-guard.json
@@ -0,0 +1,12 @@
+{
+ "version": 1,
+ "hooks": {
+ "preToolUse": [
+ {
+ "type": "command",
+ "bash": "./.github/hooks/devcontainer-guard.sh",
+ "timeoutSec": 5
+ }
+ ]
+ }
+}
diff --git a/.github/hooks/devcontainer-guard.sh b/.github/hooks/devcontainer-guard.sh
new file mode 100755
index 0000000..430e308
--- /dev/null
+++ b/.github/hooks/devcontainer-guard.sh
@@ -0,0 +1,129 @@
+#!/usr/bin/env bash
+# devcontainer-guard.sh — PreToolUse hook for Claude Code & GitHub Copilot CLI
+#
+# Blocks bash/shell tool calls when .devcontainer/devcontainer.json exists in
+# the working directory, forcing agents to use devcontainer-mcp MCP tools
+# instead of running commands directly on the host.
+#
+# Read-only tools (view, grep, glob) and file edits are allowed through — only
+# command execution is blocked.
+#
+# Host-safe commands (git, gh, curl, etc.) are allowlisted and always permitted
+# since they operate on the repo/host, not the project's build environment.
+#
+# Bypass: include USER_CONFIRMED_HOST_OPERATION=1 in the command.
+#
+# Supports both agent payload formats:
+# Claude Code: { tool_name, tool_input, cwd, ... }
+# Copilot CLI: { toolName, toolArgs, cwd, ... }
+
+set -euo pipefail
+
+INPUT=$(cat)
+
+# --- Detect agent format and extract fields ---
+
+# Try Claude Code fields first (snake_case), fall back to Copilot CLI (camelCase)
+TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // .toolName // empty')
+CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
+
+# Only guard bash/shell tool calls — allow everything else through
+case "$TOOL_NAME" in
+ Bash|bash|shell|powershell|Shell|PowerShell) ;;
+ *) exit 0 ;;
+esac
+
+TOOL_INPUT=$(echo "$INPUT" | jq -r '(.tool_input // .toolArgs // {}) | tostring')
+
+# Check for the bypass string anywhere in the tool input
+if echo "$TOOL_INPUT" | grep -q 'USER_CONFIRMED_HOST_OPERATION=1'; then
+ exit 0
+fi
+
+# Check if a devcontainer exists in the working directory
+if [ -z "$CWD" ]; then
+ # No cwd in payload — can't determine context, allow through
+ exit 0
+fi
+
+if [ ! -f "${CWD}/.devcontainer/devcontainer.json" ]; then
+ # No devcontainer — allow through
+ exit 0
+fi
+
+# --- Devcontainer exists: check allowlist before blocking ---
+
+# Extract the command string from tool input (handles both formats)
+COMMAND=$(echo "$INPUT" | jq -r '(.tool_input.command // .toolArgs.command // "") | tostring')
+
+# Commands that are safe to run on the host even when a devcontainer exists.
+# These operate on the repo/host itself, not on the project's build environment.
+ALLOWED_HOST_COMMANDS=(
+ git
+ gh
+)
+
+# Extract all meaningful commands from a shell string, skipping env vars
+# (KEY=VALUE) and cd/pushd/popd. Splits on &&, ||, ;, and | to catch every
+# command in a chain or pipeline.
+all_commands() {
+ local cmd="$1"
+ while IFS= read -r segment; do
+ segment="${segment#"${segment%%[![:space:]]*}"}"
+ [ -z "$segment" ] && continue
+ for token in $segment; do
+ if [[ "$token" == *=* && "$token" != -* ]]; then
+ continue
+ fi
+ case "$token" in
+ cd|pushd|popd) break ;;
+ esac
+ basename "$token"
+ break
+ done
+ done < <(echo "$cmd" | sed 's/ *&& */\n/g; s/ *|| */\n/g; s/ *; */\n/g; s/ *| */\n/g')
+}
+
+# Every command in the chain must be on the allowlist
+ALL_ALLOWED=true
+while IFS= read -r cmd_name; do
+ [ -z "$cmd_name" ] && continue
+ FOUND=false
+ for allowed in "${ALLOWED_HOST_COMMANDS[@]}"; do
+ if [ "$cmd_name" = "$allowed" ]; then
+ FOUND=true
+ break
+ fi
+ done
+ if [ "$FOUND" = false ]; then
+ ALL_ALLOWED=false
+ break
+ fi
+done < <(all_commands "$COMMAND")
+
+if [ "$ALL_ALLOWED" = true ] && [ -n "$(all_commands "$COMMAND")" ]; then
+ exit 0
+fi
+
+# --- Not on the allowlist: block the tool call ---
+
+DENY_REASON="Host execution blocked. This project has a devcontainer. Use devcontainer-mcp tools (devcontainer_exec, devpod_ssh, codespaces_ssh, and file operation tools) instead of running commands directly on the host."
+
+# Detect which agent format to use for the response
+if echo "$INPUT" | jq -e '.tool_name // empty' >/dev/null 2>&1 && \
+ [ -n "$(echo "$INPUT" | jq -r '.tool_name // empty')" ]; then
+ # Claude Code format
+ jq -n --arg reason "$DENY_REASON" '{
+ hookSpecificOutput: {
+ hookEventName: "PreToolUse",
+ permissionDecision: "deny",
+ permissionDecisionReason: $reason
+ }
+ }'
+else
+ # Copilot CLI format
+ jq -n --arg reason "$DENY_REASON" '{
+ permissionDecision: "deny",
+ permissionDecisionReason: $reason
+ }'
+fi
diff --git a/.github/hooks/devcontainer-skill-loader.json b/.github/hooks/devcontainer-skill-loader.json
new file mode 100644
index 0000000..1a6a8ce
--- /dev/null
+++ b/.github/hooks/devcontainer-skill-loader.json
@@ -0,0 +1,12 @@
+{
+ "version": 1,
+ "hooks": {
+ "sessionStart": [
+ {
+ "type": "command",
+ "bash": "./.github/hooks/devcontainer-skill-loader.sh",
+ "timeoutSec": 5
+ }
+ ]
+ }
+}
diff --git a/.github/hooks/devcontainer-skill-loader.sh b/.github/hooks/devcontainer-skill-loader.sh
new file mode 100755
index 0000000..5dcfbc9
--- /dev/null
+++ b/.github/hooks/devcontainer-skill-loader.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+# devcontainer-skill-loader.sh — SessionStart hook for Claude Code & Copilot CLI
+#
+# When a session starts in a directory with .devcontainer/devcontainer.json,
+# injects the devcontainer-mcp SKILL.md content as additionalContext so the
+# agent automatically knows how to use devcontainer-mcp tools.
+#
+# Supports both agent payload formats:
+# Claude Code: { tool_name, tool_input, cwd, ... }
+# Copilot CLI: { toolName, toolArgs, cwd, ... }
+
+set -euo pipefail
+
+INPUT=$(cat)
+
+# Extract working directory from the payload
+CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
+
+if [ -z "$CWD" ]; then
+ exit 0
+fi
+
+if [ ! -f "${CWD}/.devcontainer/devcontainer.json" ]; then
+ exit 0
+fi
+
+# Look for SKILL.md in order of preference
+SKILL_PATH=""
+SEARCH_PATHS=(
+ "${HOME}/.local/share/devcontainer-mcp/SKILL.md"
+ "${HOME}/.copilot/skills/devcontainer-mcp/SKILL.md"
+ "${HOME}/.claude/skills/devcontainer-mcp/SKILL.md"
+ "${HOME}/.agents/skills/devcontainer-mcp/SKILL.md"
+)
+
+for p in "${SEARCH_PATHS[@]}"; do
+ if [ -f "$p" ]; then
+ SKILL_PATH="$p"
+ break
+ fi
+done
+
+if [ -z "$SKILL_PATH" ]; then
+ exit 0
+fi
+
+SKILL_CONTENT=$(cat "$SKILL_PATH")
+
+jq -n --arg ctx "$SKILL_CONTENT" '{ "additionalContext": $ctx }'
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a4eea12..461d9f2 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -4,13 +4,12 @@ on:
release:
types: [created]
-permissions:
- contents: write
-
jobs:
build:
name: Build release binaries
runs-on: ubuntu-latest
+ permissions:
+ contents: write
steps:
- uses: actions/checkout@v5
@@ -49,6 +48,8 @@ jobs:
build-macos:
name: Build ${{ matrix.artifact }}
runs-on: macos-latest
+ permissions:
+ contents: write
strategy:
matrix:
include:
@@ -80,6 +81,8 @@ jobs:
upload-install-script:
name: Upload install scripts
runs-on: ubuntu-latest
+ permissions:
+ contents: write
steps:
- uses: actions/checkout@v5
- name: Upload install.sh and install.ps1
@@ -88,3 +91,49 @@ jobs:
files: |
install.sh
install.ps1
+
+ publish-mcp-registry:
+ name: Publish to MCP Registry
+ runs-on: ubuntu-latest
+ needs: [build, build-macos]
+ permissions:
+ id-token: write
+ contents: read
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Install mcp-publisher
+ run: |
+ curl -fsSL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" \
+ | tar xz mcp-publisher
+
+ - name: Authenticate to MCP Registry (OIDC)
+ run: ./mcp-publisher login github-oidc
+
+ - name: Patch server.json with version, URLs, and SHA-256 hashes
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+
+ TAG="${{ github.event.release.tag_name }}"
+ VERSION="${TAG#v}"
+ BASE="https://github.com/aniongithub/devcontainer-mcp/releases/download/${TAG}"
+
+ # Set top-level version
+ jq --arg v "$VERSION" '.version = $v' server.json > server.tmp && mv server.tmp server.json
+
+ # Download each platform artifact, compute SHA-256, patch identifier + hash
+ PLATFORMS=("linux-x64" "linux-arm64" "darwin-x64" "darwin-arm64")
+ for platform in "${PLATFORMS[@]}"; do
+ FILE="devcontainer-mcp-${platform}.tar.gz"
+ curl -fsSL --retry 5 --retry-delay 3 -o "$FILE" "${BASE}/${FILE}"
+ SHA="$(sha256sum "$FILE" | awk '{print $1}')"
+ URL="${BASE}/${FILE}"
+ jq --arg platform "$platform" --arg url "$URL" --arg sha "$SHA" \
+ '(.packages[] | select(.identifier | contains($platform))) |= (.identifier = $url | .fileSha256 = $sha)' \
+ server.json > server.tmp && mv server.tmp server.json
+ done
+
+ - name: Publish to MCP Registry
+ run: ./mcp-publisher publish
diff --git a/Cargo.lock b/Cargo.lock
index 2987616..06dd48c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -298,13 +298,16 @@ name = "devcontainer-mcp"
version = "0.1.0"
dependencies = [
"anyhow",
+ "async-trait",
"clap",
"devcontainer-mcp-core",
"rmcp",
"schemars 1.2.1",
"serde",
"serde_json",
+ "shlex",
"tokio",
+ "tokio-util",
"tracing",
"tracing-subscriber",
]
@@ -317,11 +320,15 @@ dependencies = [
"base64",
"bollard",
"futures-util",
+ "jsonc-parser",
+ "libc",
"serde",
"serde_json",
- "shell-escape",
+ "shlex",
+ "tempfile",
"thiserror",
"tokio",
+ "tokio-util",
"tracing",
]
@@ -358,12 +365,24 @@ dependencies = [
"windows-sys",
]
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -461,12 +480,34 @@ dependencies = [
"slab",
]
+[[package]]
+name = "getrandom"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasip3",
+]
+
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
[[package]]
name = "hashbrown"
version = "0.17.0"
@@ -707,6 +748,12 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
[[package]]
name = "ident_case"
version = "1.0.1"
@@ -779,18 +826,39 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "jsonc-parser"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d6d80e6d70e7911a29f3cf3f44f452df85d06f73572b494ca99a2cad3fcf8f4"
+dependencies = [
+ "serde_json",
+]
+
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
[[package]]
name = "libc"
version = "0.2.185"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
[[package]]
name = "litemap"
version = "0.8.2"
@@ -930,6 +998,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -948,6 +1026,12 @@ dependencies = [
"proc-macro2",
]
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -1029,6 +1113,19 @@ dependencies = [
"syn",
]
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -1085,6 +1182,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
[[package]]
name = "serde"
version = "1.0.228"
@@ -1189,12 +1292,6 @@ dependencies = [
"lazy_static",
]
-[[package]]
-name = "shell-escape"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "45bb67a18fa91266cc7807181f62f9178a6873bfad7dc788c42e6430db40184f"
-
[[package]]
name = "shlex"
version = "1.3.0"
@@ -1267,6 +1364,19 @@ dependencies = [
"syn",
]
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
[[package]]
name = "thiserror"
version = "2.0.18"
@@ -1457,6 +1567,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
[[package]]
name = "url"
version = "2.5.8"
@@ -1502,6 +1618,24 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+[[package]]
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen 0.57.1",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen 0.51.0",
+]
+
[[package]]
name = "wasm-bindgen"
version = "0.2.118"
@@ -1547,6 +1681,40 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap 2.14.0",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags",
+ "hashbrown 0.15.5",
+ "indexmap 2.14.0",
+ "semver",
+]
+
[[package]]
name = "winapi"
version = "0.3.9"
@@ -1637,6 +1805,100 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap 2.14.0",
+ "prettyplease",
+ "syn",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags",
+ "indexmap 2.14.0",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap 2.14.0",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
[[package]]
name = "writeable"
version = "0.6.3"
diff --git a/README.md b/README.md
index f1931ac..e920864 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,18 @@
# devcontainer-mcp
[](https://github.com/aniongithub/devcontainer-mcp/actions/workflows/ci.yml)
+[](https://www.anionline.me/devcontainer-mcp)
**Give your AI agent its own dev environment — not yours.**
`devcontainer-mcp` is an MCP server that lets AI coding agents create, manage, and work inside [dev containers](https://containers.dev/) across three backends: local Docker, [DevPod](https://devpod.sh/), and [GitHub Codespaces](https://github.com/features/codespaces). The agent builds, tests, and ships code in an isolated container — your laptop stays clean.
+
+
+
+
+> Works with **GitHub Copilot**, **Claude**, **Cursor**, **opencode**, and any MCP-compatible client.
+
## The Problem
When AI agents write code, they need to run it somewhere. Today that means your host machine:
@@ -14,6 +21,7 @@ When AI agents write code, they need to run it somewhere. Today that means your
- 🔴 **"Works on my machine"** — agents assume your local toolchain matches production
- 🔴 **No isolation** — one project's dependencies break another
- 🔴 **Security risk** — agents run arbitrary commands with your user privileges
+- 🔴 **Hardware constraints** — you're limited to your local machine's resources
## The Solution
@@ -36,6 +44,10 @@ Agent: "Let me build this project..."
## Quick Install
+
+
+
+
### Linux / macOS
```bash
@@ -79,6 +91,10 @@ graph TD
## Three Backends, One Interface
+
+
+
+
| Backend | Best for | Requires | Auth needed? |
|---------|----------|----------|:---:|
| **devcontainer CLI** (`devcontainer_*`) | Local Docker — fast, simple | [@devcontainers/cli](https://github.com/devcontainers/cli) + Docker | No |
@@ -98,7 +114,7 @@ Codespaces tools require an auth handle (e.g. `"github-aniongithub"`). The MCP s
Supported providers: **GitHub**, **AWS**, **Azure**, **GCP**, **Kubernetes**
-## MCP Tools (45 total)
+## MCP Tools (46 total)
### Auth (4 tools)
@@ -133,7 +149,7 @@ Supported providers: **GitHub**, **AWS**, **Azure**, **GCP**, **Kubernetes**
| `devpod_file_edit` | Surgical string replacement — old_str → new_str |
| `devpod_file_list` | List directory contents (non-hidden, 2 levels deep) |
-### devcontainer CLI (11 tools)
+### devcontainer CLI (12 tools)
| Tool | Description |
|------|-------------|
@@ -141,6 +157,7 @@ Supported providers: **GitHub**, **AWS**, **Azure**, **GCP**, **Kubernetes**
| `devcontainer_exec` | Execute a command inside a running dev container |
| `devcontainer_build` | Build a dev container image |
| `devcontainer_read_config` | Read merged devcontainer configuration as JSON |
+| `devcontainer_list_configs` | Discover all devcontainer.json files in a workspace (single + multi-container) |
| `devcontainer_stop` | Stop a dev container (via Docker API) |
| `devcontainer_remove` | Remove a dev container and its resources |
| `devcontainer_status` | Get dev container state by workspace folder |
@@ -205,6 +222,15 @@ Install backend CLIs as needed — the MCP server detects them at runtime and re
When `devcontainer_up`, `devpod_up`, or `codespaces_create` fails, the full build output (including errors) is returned to the agent. The agent can read the error, fix the `Dockerfile` or `devcontainer.json`, and retry — making the dev environment a **dynamic, agent-managed asset** rather than a static prerequisite.
+## Multi-container workspaces
+
+The devcontainer spec supports [connecting to multiple containers](https://code.visualstudio.com/remote/advancedcontainers/connect-multiple-containers) in one workspace by placing per-service configs at `.devcontainer//devcontainer.json`, each pointing at a shared `docker-compose.yml`. `devcontainer-mcp` supports this pattern end-to-end:
+
+- **Discovery** — `devcontainer_list_configs` returns every config it finds (root `.devcontainer.json`, `.devcontainer/devcontainer.json`, and each `.devcontainer/*/devcontainer.json`) with its kind (`image` / `dockerfile` / `compose`), service name, and absolute path.
+- **Targeting** — Every devcontainer tool (`up`, `exec`, `build`, `stop`, `remove`, `status`, `read_config`, `file_*`) accepts an optional `config` parameter pointing at a specific `devcontainer.json`. Single-container workflows continue to work unchanged — `config` defaults to whatever the devcontainer CLI auto-detects.
+- **Ambiguity handling** — When a workspace has multiple configs and no `config` is provided, lookup-style tools (`status`, `exec`, `stop`, `remove`, `file_*`) return a structured `Ambiguous` result listing every matching container so the agent can pick the right one. `status` reports this as `{"state":"Ambiguous","candidates":[...],"hint":"..."}`.
+- **Robust container matching** — Sibling compose containers are identified via `com.docker.compose.service` + `com.docker.compose.project.config_files` (not the unreliable `devcontainer.local_folder` label, which is only stamped on the first container).
+
## Development
This project eats its own dogfood — development happens inside its own devcontainer.
diff --git a/SKILL.md b/SKILL.md
index 367de62..9e09880 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -185,6 +185,8 @@ If `devpod_up`, `devcontainer_up`, or `codespaces_create` returns errors:
- ✅ DO use `devpod_ssh`, `devcontainer_exec`, or `codespaces_ssh` for everything
- ✅ DO check `.devcontainer/devcontainer.json` first
+> **Note:** Host-protection hooks are installed for supported agent environments (Claude Code, GitHub Copilot CLI) that automatically block shell commands when a devcontainer is detected. If a command is blocked, use the appropriate MCP tool instead.
+
## File Operations
**All backends support built-in file operations — no need to construct shell commands.**
diff --git a/crates/devcontainer-mcp-core/Cargo.toml b/crates/devcontainer-mcp-core/Cargo.toml
index b737647..18c6f2e 100644
--- a/crates/devcontainer-mcp-core/Cargo.toml
+++ b/crates/devcontainer-mcp-core/Cargo.toml
@@ -8,6 +8,7 @@ repository.workspace = true
[dependencies]
tokio = { workspace = true }
+tokio-util = "0.7"
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
@@ -16,4 +17,11 @@ tracing = { workspace = true }
futures-util = "0.3"
async-trait = "0.1"
base64 = "0.22"
-shell-escape = "0.1"
+shlex = "1"
+jsonc-parser = { version = "0.26", features = ["serde"] }
+
+[target.'cfg(unix)'.dependencies]
+libc = "0.2"
+
+[dev-dependencies]
+tempfile = "3"
diff --git a/crates/devcontainer-mcp-core/src/cli.rs b/crates/devcontainer-mcp-core/src/cli.rs
index cee82ec..18e72c8 100644
--- a/crates/devcontainer-mcp-core/src/cli.rs
+++ b/crates/devcontainer-mcp-core/src/cli.rs
@@ -1,7 +1,10 @@
use serde::Serialize;
use std::collections::HashMap;
use std::process::Stdio;
+use std::sync::Arc;
+use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
+use tokio_util::sync::CancellationToken;
use crate::error::{Error, Result};
@@ -57,8 +60,38 @@ impl CliBinary {
}
}
+/// Which output stream a chunk came from.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum OutputStream {
+ Stdout,
+ Stderr,
+}
+
+/// A chunk of output from a streaming CLI invocation.
+#[derive(Debug, Clone)]
+pub struct OutputChunk {
+ pub stream: OutputStream,
+ /// One line of output (without trailing newline).
+ pub line: String,
+}
+
+/// Sink for streamed output chunks.
+///
+/// Implementations should be fast — they're awaited inline by the
+/// background dispatcher and slow sinks will buffer up in the bounded
+/// channel. Errors must be swallowed inside the sink (we don't want to
+/// abort the underlying process because progress reporting failed).
+#[async_trait::async_trait]
+pub trait ChunkSink: Send + Sync + 'static {
+ async fn on_chunk(&self, chunk: OutputChunk);
+}
+
/// Run a CLI command, capturing stdout/stderr/exit_code.
/// If `parse_json` is true, attempts to parse stdout as JSON.
+///
+/// Convenience wrapper around [`run_cli_streaming`] with no progress sink
+/// and a fresh (never-fired) cancellation token. Callers that want
+/// cancellation or progress should use [`run_cli_streaming`] directly.
pub async fn run_cli(binary: &CliBinary, args: &[&str], parse_json: bool) -> Result {
run_cli_with_env(binary, args, parse_json, None).await
}
@@ -69,9 +102,48 @@ pub async fn run_cli_with_env(
args: &[&str],
parse_json: bool,
env: Option<&HashMap>,
+) -> Result {
+ run_cli_streaming(
+ binary,
+ args,
+ parse_json,
+ env,
+ &CancellationToken::new(),
+ None,
+ )
+ .await
+}
+
+/// Run a CLI command with full control over cancellation and progress.
+///
+/// - `cancel`: if cancelled while the child is running, the child is killed
+/// (SIGTERM, then SIGKILL after a 2s grace period via `kill_on_drop`)
+/// and [`Error::Cancelled`] is returned. Any output produced before
+/// cancellation is forwarded to the sink but discarded from the return
+/// value.
+/// - `on_chunk`: optional sink invoked for every line of stdout/stderr as
+/// it arrives. Useful for emitting MCP progress notifications.
+///
+/// Regardless of streaming, the full stdout/stderr is also accumulated
+/// and returned in [`CliOutput`] for callers that need the final result.
+pub async fn run_cli_streaming(
+ binary: &CliBinary,
+ args: &[&str],
+ parse_json: bool,
+ env: Option<&HashMap>,
+ cancel: &CancellationToken,
+ on_chunk: Option>,
) -> Result {
let mut cmd = Command::new(binary.command_name());
- cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
+ cmd.args(args)
+ .stdin(Stdio::null())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ // Belt-and-braces: if this future is dropped (e.g. caller bails
+ // before we finish handling cancel), tokio will SIGKILL the
+ // child for us. Without this, a cancelled but un-awaited child
+ // would survive as a zombie / runaway process.
+ .kill_on_drop(true);
if let Some(env_vars) = env {
for (k, v) in env_vars {
@@ -79,7 +151,7 @@ pub async fn run_cli_with_env(
}
}
- let output = cmd.output().await.map_err(|e| {
+ let mut child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
binary.not_found_error()
} else {
@@ -87,20 +159,452 @@ pub async fn run_cli_with_env(
}
})?;
- let stdout = String::from_utf8_lossy(&output.stdout).to_string();
- let stderr = String::from_utf8_lossy(&output.stderr).to_string();
- let exit_code = output.status.code().unwrap_or(-1);
+ let stdout = child.stdout.take().expect("stdout was piped");
+ let stderr = child.stderr.take().expect("stderr was piped");
+
+ let stdout_task = tokio::spawn(drain_stream(
+ stdout,
+ OutputStream::Stdout,
+ on_chunk.clone(),
+ ));
+ let stderr_task = tokio::spawn(drain_stream(
+ stderr,
+ OutputStream::Stderr,
+ on_chunk.clone(),
+ ));
+
+ // Wait for either: child exits, or cancellation fires.
+ let status = tokio::select! {
+ biased;
+ _ = cancel.cancelled() => {
+ tracing::warn!(
+ bin = binary.command_name(),
+ "cancellation received; reaping process tree"
+ );
+ // SIGTERM the entire descendant tree (including any
+ // in-container processes spawned via `devcontainer exec`
+ // → `docker exec` → containerd-shim). Container PID
+ // namespacing hides PIDs from each other but not from
+ // the host, so /proc walking sees through it.
+ if let Some(root_pid) = child.id() {
+ crate::process_tree::reap(root_pid, std::time::Duration::from_secs(2)).await;
+ }
+ // The root child should now be dead; wait briefly to
+ // collect its exit status and let the reader tasks observe
+ // EOF on stdout/stderr. `kill_on_drop(true)` is our
+ // backstop if any of this fails.
+ let _ = tokio::time::timeout(
+ std::time::Duration::from_secs(1),
+ child.wait(),
+ )
+ .await;
+ let _ = stdout_task.await;
+ let _ = stderr_task.await;
+ return Err(Error::Cancelled);
+ }
+ status = child.wait() => status?,
+ };
+
+ // Child exited; collect the accumulated output.
+ let stdout_buf = stdout_task.await.unwrap_or_default();
+ let stderr_buf = stderr_task.await.unwrap_or_default();
+ let exit_code = status.code().unwrap_or(-1);
let json = if parse_json {
- serde_json::from_str(&stdout).ok()
+ serde_json::from_str(&stdout_buf).ok()
} else {
None
};
Ok(CliOutput {
exit_code,
+ stdout: stdout_buf,
+ stderr: stderr_buf,
+ json,
+ })
+}
+
+/// Read `reader` line-by-line, forwarding each line to `sink` (if set)
+/// and accumulating into the returned buffer. EOF or error ends the loop.
+async fn drain_stream(
+ reader: R,
+ stream: OutputStream,
+ sink: Option>,
+) -> String {
+ let mut lines = BufReader::new(reader).lines();
+ let mut buf = String::new();
+ loop {
+ match lines.next_line().await {
+ Ok(Some(line)) => {
+ if !buf.is_empty() {
+ buf.push('\n');
+ }
+ buf.push_str(&line);
+ if let Some(sink) = sink.as_ref() {
+ sink.on_chunk(OutputChunk {
+ stream,
+ line,
+ })
+ .await;
+ }
+ }
+ Ok(None) => break,
+ Err(e) => {
+ tracing::debug!(%e, ?stream, "error reading child output stream");
+ break;
+ }
+ }
+ }
+ if !buf.is_empty() {
+ buf.push('\n');
+ }
+ buf
+}
+
+/// Drain a stream that may contain the [`crate::exec_shim`] sentinel.
+///
+/// Lines matching `__DCMCP_PGID=N__` are intercepted: `N` is stored
+/// into `pgid_out` and the line is suppressed from both the sink and
+/// the accumulated buffer. All other lines flow through normally.
+async fn drain_stream_with_sentinel(
+ reader: R,
+ stream: OutputStream,
+ sink: Option>,
+ pgid_out: Arc>>,
+) -> String {
+ let mut lines = BufReader::new(reader).lines();
+ let mut buf = String::new();
+ loop {
+ match lines.next_line().await {
+ Ok(Some(line)) => {
+ if let Some(found) = crate::exec_shim::try_parse_sentinel(&line) {
+ *pgid_out.lock().expect("pgid lock") = Some(found);
+ tracing::debug!(pgid = found, ?stream, "captured remote PGID");
+ continue;
+ }
+ if !buf.is_empty() {
+ buf.push('\n');
+ }
+ buf.push_str(&line);
+ if let Some(sink) = sink.as_ref() {
+ sink.on_chunk(OutputChunk { stream, line }).await;
+ }
+ }
+ Ok(None) => break,
+ Err(e) => {
+ tracing::debug!(%e, ?stream, "error reading child output stream");
+ break;
+ }
+ }
+ }
+ if !buf.is_empty() {
+ buf.push('\n');
+ }
+ buf
+}
+
+/// Trait for a backend-specific "send a kill into the remote target".
+///
+/// Used by [`run_with_shim`] to deliver SIGTERM/SIGKILL to a remote
+/// process group when cancellation fires. The runner calls this with
+/// the PGID captured from the shim's sentinel; the implementor knows
+/// how to reach into the target (docker exec, devpod ssh, gh ssh, …).
+///
+/// Implementations should be *idempotent* and *forgiving*: a kill of
+/// an already-exited process is fine, and the trait is invoked on a
+/// best-effort basis.
+#[async_trait::async_trait]
+pub trait RemoteKiller: Send + Sync {
+ /// Send `signal` (a POSIX signal name like "TERM" or "KILL") to
+ /// the entire process group `pgid` on the remote target.
+ async fn kill_pgid(&self, pgid: i32, signal: &str);
+}
+
+/// Run a CLI command whose remote target requires the [`crate::exec_shim`]
+/// shim for cancellation.
+///
+/// Differences from [`run_cli_streaming`]:
+///
+/// - The stderr reader filters out the sentinel line emitted by the
+/// shim and stores the captured remote PGID in shared state.
+/// - On cancel, before reaping the host-side process tree, the helper
+/// asks the supplied [`RemoteKiller`] to deliver SIGTERM then SIGKILL
+/// to the captured PGID (with a 2-second grace between them). This
+/// reaches workloads that have been reparented to the remote's init
+/// (docker daemon's containerd-shim, sshd, …) and would otherwise
+/// survive the death of our host-side wrapper.
+///
+/// If the sentinel never arrives (extremely fast cancellation, or a
+/// target without `setsid`/base64), the helper falls back to host-tree
+/// reaping only — the remote process *may* leak in that narrow window.
+pub async fn run_with_shim(
+ binary: &CliBinary,
+ args: &[&str],
+ env: Option<&HashMap>,
+ cancel: &CancellationToken,
+ on_chunk: Option>,
+ killer: Arc,
+) -> Result {
+ let mut cmd = Command::new(binary.command_name());
+ cmd.args(args)
+ .stdin(Stdio::null())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .kill_on_drop(true);
+
+ if let Some(env_vars) = env {
+ for (k, v) in env_vars {
+ cmd.env(k, v);
+ }
+ }
+
+ let mut child = cmd.spawn().map_err(|e| {
+ if e.kind() == std::io::ErrorKind::NotFound {
+ binary.not_found_error()
+ } else {
+ Error::Io(e)
+ }
+ })?;
+
+ let stdout = child.stdout.take().expect("stdout was piped");
+ let stderr = child.stderr.take().expect("stderr was piped");
+
+ // Shared captured PGID. Filled in by the stderr reader as soon as
+ // the shim's sentinel line arrives; consumed by the cancel branch.
+ let pgid: Arc>> = Arc::new(std::sync::Mutex::new(None));
+
+ // stdout: no sentinel to scrub (the shim writes it to stderr).
+ let stdout_task = tokio::spawn(drain_stream(
stdout,
+ OutputStream::Stdout,
+ on_chunk.clone(),
+ ));
+ // stderr: filter the sentinel line, forward everything else.
+ let stderr_task = tokio::spawn(drain_stream_with_sentinel(
stderr,
- json,
+ OutputStream::Stderr,
+ on_chunk.clone(),
+ pgid.clone(),
+ ));
+
+ let status = tokio::select! {
+ biased;
+ _ = cancel.cancelled() => {
+ tracing::warn!(
+ bin = binary.command_name(),
+ "cancellation received; killing remote process group then host wrapper"
+ );
+
+ let captured = *pgid.lock().expect("pgid lock");
+ match captured {
+ Some(pgid_val) => {
+ // Remote kill first: if we kill the host wrapper
+ // first, the transport to the target closes and
+ // the remote work keeps running.
+ killer.kill_pgid(pgid_val, "TERM").await;
+ tokio::time::sleep(std::time::Duration::from_secs(2)).await;
+ killer.kill_pgid(pgid_val, "KILL").await;
+ }
+ None => {
+ tracing::warn!(
+ bin = binary.command_name(),
+ "no remote PGID captured; relying on host reap (may leak)"
+ );
+ }
+ }
+
+ // Host-side reap of the wrapper and any host descendants
+ // we *can* see. For SSH-based backends this catches the
+ // local SSH client process.
+ if let Some(root_pid) = child.id() {
+ crate::process_tree::reap(root_pid, std::time::Duration::from_secs(2)).await;
+ }
+
+ let _ = tokio::time::timeout(
+ std::time::Duration::from_secs(1),
+ child.wait(),
+ )
+ .await;
+ let _ = stdout_task.await;
+ let _ = stderr_task.await;
+ return Err(Error::Cancelled);
+ }
+ status = child.wait() => status?,
+ };
+
+ let stdout_buf = stdout_task.await.unwrap_or_default();
+ let stderr_buf = stderr_task.await.unwrap_or_default();
+
+ Ok(CliOutput {
+ exit_code: status.code().unwrap_or(-1),
+ stdout: stdout_buf,
+ stderr: stderr_buf,
+ json: None,
})
}
+
+#[cfg(test)]
+mod shim_runner_tests {
+ use super::*;
+ use std::sync::atomic::{AtomicU64, Ordering};
+ use std::sync::Mutex;
+
+ /// Records every `kill_pgid` invocation so tests can assert on
+ /// the order and arguments.
+ struct RecordingKiller {
+ calls: Mutex>,
+ }
+
+ #[async_trait::async_trait]
+ impl RemoteKiller for RecordingKiller {
+ async fn kill_pgid(&self, pgid: i32, signal: &str) {
+ self.calls.lock().unwrap().push((pgid, signal.to_string()));
+ }
+ }
+
+ /// Sink that just counts received chunks.
+ struct CountingSink {
+ stdout_lines: AtomicU64,
+ stderr_lines: AtomicU64,
+ }
+ #[async_trait::async_trait]
+ impl ChunkSink for CountingSink {
+ async fn on_chunk(&self, chunk: OutputChunk) {
+ match chunk.stream {
+ OutputStream::Stdout => {
+ self.stdout_lines.fetch_add(1, Ordering::Relaxed);
+ }
+ OutputStream::Stderr => {
+ self.stderr_lines.fetch_add(1, Ordering::Relaxed);
+ }
+ }
+ }
+ }
+
+ /// Create a `devpod` shim script in `dir` that ignores all args
+ /// except the value after `--command` and runs that under `sh -c`.
+ /// This lets us drive `run_with_shim` end-to-end without a real
+ /// DevPod install — `binary.command_name()` is `devpod`, and our
+ /// shim picks it up from `PATH`.
+ fn install_devpod_shim(dir: &std::path::Path) -> std::path::PathBuf {
+ let script = dir.join("devpod");
+ // The fake devpod: parse out `--command ` and exec it
+ // via `sh -c`. Everything else (workspace name, --user, etc.)
+ // is discarded — we only care that the shim's body runs.
+ let body = r#"#!/bin/sh
+cmd=""
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --command)
+ shift
+ cmd="$1"
+ shift
+ ;;
+ *)
+ shift
+ ;;
+ esac
+done
+exec sh -c "$cmd"
+"#;
+ std::fs::write(&script, body).expect("write shim");
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mut perms = std::fs::metadata(&script).unwrap().permissions();
+ perms.set_mode(0o755);
+ std::fs::set_permissions(&script, perms).unwrap();
+ }
+ script
+ }
+
+ /// End-to-end: shim wraps a sleep, runner captures PGID from
+ /// stderr, cancellation triggers RemoteKiller in the right order
+ /// (TERM then KILL with a 2s gap), and the call returns
+ /// Error::Cancelled.
+ ///
+ /// This exercises the full `run_with_shim` flow without docker
+ /// or any real backend — the `RemoteKiller` is a recording fake
+ /// and the "remote" is just a local shell invoked by a stub
+ /// `devpod` shim on PATH.
+ #[tokio::test]
+ async fn run_with_shim_invokes_killer_in_order_on_cancel() {
+ let tmp = tempfile::tempdir().expect("tmpdir");
+ install_devpod_shim(tmp.path());
+ let new_path = format!(
+ "{}:{}",
+ tmp.path().display(),
+ std::env::var("PATH").unwrap_or_default()
+ );
+ // Safe because tests in this module are single-threaded by
+ // tokio::test's default runtime; if we move to multi_thread
+ // we'd need a process-wide PATH lock.
+ // SAFETY: see comment above.
+ unsafe {
+ std::env::set_var("PATH", &new_path);
+ }
+
+ let killer = Arc::new(RecordingKiller {
+ calls: Mutex::new(Vec::new()),
+ });
+ let sink = Arc::new(CountingSink {
+ stdout_lines: AtomicU64::new(0),
+ stderr_lines: AtomicU64::new(0),
+ });
+ let cancel = CancellationToken::new();
+
+ // The "user command" — backgrounded sleeps + wait. The shim
+ // wraps this so the runner sees the sentinel on stderr.
+ let user_cmd = "sleep 30 & sleep 30 & wait";
+ let wrapped = crate::exec_shim::wrap_self_contained(user_cmd);
+
+ // Spawn the call and cancel it shortly after.
+ let killer_for_spawn: Arc = killer.clone();
+ let sink_for_spawn: Arc = sink.clone();
+ let cancel_for_spawn = cancel.clone();
+ let handle = tokio::spawn(async move {
+ let args = vec!["ssh", "fake-ws", "--command", wrapped.as_str()];
+ run_with_shim(
+ &CliBinary::DevPod,
+ &args,
+ None,
+ &cancel_for_spawn,
+ Some(sink_for_spawn),
+ killer_for_spawn,
+ )
+ .await
+ });
+
+ // Give the shim time to emit the sentinel.
+ tokio::time::sleep(std::time::Duration::from_millis(500)).await;
+ cancel.cancel();
+
+ let result = handle.await.expect("join");
+ match result {
+ Err(Error::Cancelled) => { /* expected */ }
+ other => panic!("expected Error::Cancelled, got {:?}", other),
+ }
+
+ // Killer should have been called once with TERM, then once
+ // with KILL, both targeting the same PGID (>0). The 2s gap
+ // is checked separately below.
+ let calls = killer.calls.lock().unwrap().clone();
+ assert_eq!(calls.len(), 2, "expected exactly 2 kill calls, got {calls:?}");
+ let (pgid1, sig1) = &calls[0];
+ let (pgid2, sig2) = &calls[1];
+ assert_eq!(sig1, "TERM");
+ assert_eq!(sig2, "KILL");
+ assert!(*pgid1 > 0, "captured PGID should be positive, got {pgid1}");
+ assert_eq!(pgid1, pgid2, "both signals should target the same PGID");
+
+ // Sentinel must NOT have leaked into the sink's stderr count
+ // — if it had, sink.stderr_lines would include at least the
+ // sentinel line for every chunk. Concretely, the user_cmd
+ // produces no stderr at all, so stderr_lines should be 0.
+ assert_eq!(
+ sink.stderr_lines.load(Ordering::Relaxed),
+ 0,
+ "sentinel should be scrubbed from stderr forwarded to sink"
+ );
+ }
+}
diff --git a/crates/devcontainer-mcp-core/src/codespaces.rs b/crates/devcontainer-mcp-core/src/codespaces.rs
index 2324f2a..4c6f92f 100644
--- a/crates/devcontainer-mcp-core/src/codespaces.rs
+++ b/crates/devcontainer-mcp-core/src/codespaces.rs
@@ -1,6 +1,11 @@
use std::collections::HashMap;
+use std::sync::Arc;
+use tokio_util::sync::CancellationToken;
-use crate::cli::{run_cli_with_env, CliBinary, CliOutput};
+use crate::cli::{
+ run_cli_streaming, run_cli_with_env, run_with_shim, ChunkSink, CliBinary, CliOutput,
+ RemoteKiller,
+};
use crate::error::Result;
const LIST_FIELDS: &str =
@@ -57,6 +62,58 @@ pub async fn create(
run_gh_cs(&args, false, Some(env)).await
}
+/// `gh codespace create` — cancellable, streaming variant.
+///
+/// Codespace creation takes 3–5 minutes (image pull, devcontainer
+/// build, postCreate, …). Streams `gh codespace create` output as
+/// progress notifications and honors cancellation — when the client
+/// times out we kill the host `gh` process. Note: this only reaps
+/// the host wrapper; if the GitHub API has already started
+/// provisioning the codespace remotely, the codespace itself will
+/// continue to come up and bill until the caller explicitly deletes
+/// it via `codespaces_delete`. The wire-level cancel is the most we
+/// can do — there's no `gh codespace cancel-create` API.
+//
+// `clippy::too_many_arguments` — each parameter mirrors a distinct
+// `gh codespace create` flag, and the non-streaming sibling
+// [`create`] has the same shape with one fewer pair. Bundling into
+// a struct would obscure the call site for no real readability win.
+#[allow(clippy::too_many_arguments)]
+pub async fn create_streaming(
+ env: &HashMap,
+ repo: &str,
+ branch: Option<&str>,
+ machine: Option<&str>,
+ devcontainer_path: Option<&str>,
+ display_name: Option<&str>,
+ idle_timeout: Option<&str>,
+ cancel: &CancellationToken,
+ on_chunk: Option>,
+) -> Result {
+ let mut args = vec!["codespace", "create", "--repo", repo];
+ if let Some(b) = branch {
+ args.push("--branch");
+ args.push(b);
+ }
+ if let Some(m) = machine {
+ args.push("--machine");
+ args.push(m);
+ }
+ if let Some(d) = devcontainer_path {
+ args.push("--devcontainer-path");
+ args.push(d);
+ }
+ if let Some(n) = display_name {
+ args.push("--display-name");
+ args.push(n);
+ }
+ if let Some(t) = idle_timeout {
+ args.push("--idle-timeout");
+ args.push(t);
+ }
+ run_cli_streaming(&CliBinary::Gh, &args, false, Some(env), cancel, on_chunk).await
+}
+
/// `gh codespace list` — list codespaces.
pub async fn list(env: &HashMap, repo: Option<&str>) -> Result {
let mut args = vec!["list", "--json", LIST_FIELDS];
@@ -77,6 +134,64 @@ pub async fn ssh_exec(
run_gh_cs(&args, false, Some(env)).await
}
+/// `gh codespace ssh` — cancellable, streaming variant.
+///
+/// Same model as the DevPod backend: the user's command is wrapped
+/// with [`crate::exec_shim::wrap_self_contained`] (the codespace
+/// transport has no `--remote-env`), the captured PGID is stripped
+/// from stderr, and on cancel a second `gh codespace ssh` delivers
+/// `kill -TERM -` then `kill -KILL -` against the same
+/// codespace.
+pub async fn ssh_exec_streaming(
+ env: &HashMap,
+ codespace: &str,
+ command: &str,
+ cancel: &CancellationToken,
+ on_chunk: Option>,
+) -> Result {
+ let wrapped = crate::exec_shim::wrap_self_contained(command);
+ // `gh codespace ssh -c -- ` — the `--` separates gh's
+ // own args from the remote command, so the wrapped shim lands as
+ // the single remote argument.
+ let full_args: Vec<&str> = vec!["codespace", "ssh", "-c", codespace, "--", &wrapped];
+
+ let killer: Arc = Arc::new(CodespaceKiller {
+ env: env.clone(),
+ codespace: codespace.to_string(),
+ });
+
+ run_with_shim(
+ &CliBinary::Gh,
+ &full_args,
+ Some(env),
+ cancel,
+ on_chunk,
+ killer,
+ )
+ .await
+}
+
+/// Delivers `kill - -` inside a Codespace by spawning a
+/// fresh short-lived `gh codespace ssh`. Same trade-off as the DevPod
+/// backend — every kill is its own SSH session, slow but reliable.
+struct CodespaceKiller {
+ env: HashMap,
+ codespace: String,
+}
+
+#[async_trait::async_trait]
+impl RemoteKiller for CodespaceKiller {
+ async fn kill_pgid(&self, pgid: i32, signal: &str) {
+ let cmd = format!("kill -{signal} -{pgid} 2>/dev/null || true");
+ let args = vec!["codespace", "ssh", "-c", &self.codespace, "--", &cmd];
+ if let Err(e) =
+ run_cli_with_env(&CliBinary::Gh, &args, false, Some(&self.env)).await
+ {
+ tracing::debug!(%e, pgid, signal, "gh codespace ssh kill failed");
+ }
+ }
+}
+
/// `gh codespace stop` — stop a running codespace.
pub async fn stop(env: &HashMap, codespace: &str) -> Result {
let args = vec!["stop", "-c", codespace];
diff --git a/crates/devcontainer-mcp-core/src/devcontainer.rs b/crates/devcontainer-mcp-core/src/devcontainer.rs
index 167fc0d..ca037c9 100644
--- a/crates/devcontainer-mcp-core/src/devcontainer.rs
+++ b/crates/devcontainer-mcp-core/src/devcontainer.rs
@@ -1,12 +1,74 @@
-use crate::cli::{run_cli, CliBinary, CliOutput};
-use crate::docker;
+use crate::cli::{
+ run_cli, run_cli_streaming, run_with_shim, ChunkSink, CliBinary, CliOutput, RemoteKiller,
+};
+use crate::devcontainer_config::{resolve_config, ResolvedConfig};
+use crate::docker::{self, DevcontainerLookup};
use crate::error::Result;
+use std::sync::Arc;
+use tokio_util::sync::CancellationToken;
/// Run a `devcontainer` CLI command.
async fn run_devcontainer(args: &[&str], parse_json: bool) -> Result {
run_cli(&CliBinary::Devcontainer, args, parse_json).await
}
+/// Best-effort resolve of `config` against `workspace_folder`. Returns
+/// `None` if `config` is `None` *or* if parsing fails (we log + fall back
+/// to no-config matching so a malformed config file never blocks lookups
+/// the agent could otherwise satisfy).
+fn try_resolve(workspace_folder: &str, config: Option<&str>) -> Option {
+ let cfg = config?;
+ match resolve_config(workspace_folder, cfg) {
+ Ok(r) => Some(r),
+ Err(e) => {
+ tracing::warn!(%e, config = %cfg, "failed to resolve devcontainer config; lookup will fall back to no-config matching");
+ None
+ }
+ }
+}
+
+/// Make `config` absolute against `workspace_folder` so the devcontainer
+/// CLI resolves it correctly regardless of the MCP server's CWD. If
+/// already absolute, returned unchanged. Falls back to the input when
+/// canonicalize fails (e.g. file doesn't exist yet).
+fn abs_config(workspace_folder: &str, config: &str) -> String {
+ let p = std::path::Path::new(config);
+ let joined = if p.is_absolute() {
+ p.to_path_buf()
+ } else {
+ std::path::Path::new(workspace_folder).join(p)
+ };
+ std::fs::canonicalize(&joined)
+ .map(|c| c.to_string_lossy().into_owned())
+ .unwrap_or_else(|_| joined.to_string_lossy().into_owned())
+}
+
+/// Build a uniform "ambiguous match" error string for stop/remove/status
+/// callers when multiple containers match `workspace_folder` and no
+/// `config` was supplied. Lists each candidate's id, name, compose service,
+/// and devcontainer.config_file label so the agent has everything it needs
+/// to retry with the right `config`.
+fn ambiguous_error(workspace_folder: &str, candidates: &[docker::ContainerInfo]) -> String {
+ let lines: Vec = candidates
+ .iter()
+ .map(|c| {
+ let svc = c.compose_service().unwrap_or("(none)");
+ let cfg = c.devcontainer_config_file().unwrap_or("(unlabeled)");
+ format!(
+ " - id={short} name={name} service={svc} config_file={cfg}",
+ short = c.id.chars().take(12).collect::(),
+ name = c.name,
+ )
+ })
+ .collect();
+ format!(
+ "Multiple containers match workspace `{workspace_folder}`. \
+ Re-run with `config` set to the devcontainer.json path of the one you want. \
+ Use `devcontainer_list_configs` to enumerate. Candidates:\n{}",
+ lines.join("\n")
+ )
+}
+
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
@@ -18,7 +80,8 @@ pub async fn up(
extra_args: &[&str],
) -> Result {
let mut args = vec!["up", "--workspace-folder", workspace_folder];
- if let Some(c) = config {
+ let cfg_abs = config.map(|c| abs_config(workspace_folder, c));
+ if let Some(c) = cfg_abs.as_deref() {
args.push("--config");
args.push(c);
}
@@ -26,28 +89,281 @@ pub async fn up(
run_devcontainer(&args, true).await
}
+/// `devcontainer up` — cancellable, streaming variant.
+///
+/// `devcontainer up` does a docker build + container create + the
+/// devcontainer lifecycle commands (postCreate, postStart, etc.) all
+/// in one invocation. That can easily take several minutes on a
+/// cold image. Same rationale as `crate::devpod::up_streaming`:
+/// cancellation prevents leaked partial-up containers, and progress
+/// streaming keeps client transports warm.
+pub async fn up_streaming(
+ workspace_folder: &str,
+ config: Option<&str>,
+ extra_args: &[&str],
+ cancel: &CancellationToken,
+ on_chunk: Option>,
+) -> Result {
+ let mut args = vec!["up", "--workspace-folder", workspace_folder];
+ let cfg_abs = config.map(|c| abs_config(workspace_folder, c));
+ if let Some(c) = cfg_abs.as_deref() {
+ args.push("--config");
+ args.push(c);
+ }
+ args.extend_from_slice(extra_args);
+ run_cli_streaming(
+ &CliBinary::Devcontainer,
+ &args,
+ true,
+ None,
+ cancel,
+ on_chunk,
+ )
+ .await
+}
+
/// `devcontainer exec` — execute a command in a running dev container.
+///
+/// Resolves the target container via our reliable label-based lookup and
+/// passes its id to the CLI via `--container-id`. This works for sibling
+/// compose containers that the devcontainer CLI's own workspace-folder
+/// lookup can't see (because only the first container in a compose stack
+/// gets stamped with `devcontainer.*` labels).
pub async fn exec(
workspace_folder: &str,
+ config: Option<&str>,
command: &str,
command_args: &[&str],
) -> Result {
- let mut args = vec!["exec", "--workspace-folder", workspace_folder, command];
+ let container = lookup_one_or_err(workspace_folder, config).await?;
+ let mut args = vec![
+ "exec",
+ "--container-id",
+ &container.id,
+ "--workspace-folder",
+ workspace_folder,
+ ];
+ let cfg_abs = config.map(|c| abs_config(workspace_folder, c));
+ if let Some(c) = cfg_abs.as_deref() {
+ args.push("--config");
+ args.push(c);
+ }
+ args.push(command);
args.extend_from_slice(command_args);
run_devcontainer(&args, false).await
}
+/// `devcontainer exec` — cancellable, streaming variant.
+///
+/// `cancel` is honored at any point during the child's lifetime; if it
+/// fires, every descendant inside the container is reaped via a
+/// bollard `create_exec` + `start_exec` of `kill - -`
+/// against the process group the shim established. `on_chunk`, if
+/// supplied, receives every line of stdout/stderr as the child emits
+/// it — typically wired to an MCP progress notification on the server
+/// side.
+///
+/// Container descendants are not in the host PID namespace lineage of
+/// `devcontainer exec` (the docker daemon reparents them under
+/// containerd-shim), so a `/proc` walk on the host would miss them.
+/// We install a tiny `setsid` + sentinel shim around the user command
+/// and use the captured PGID to reap them on cancel.
+pub async fn exec_streaming(
+ workspace_folder: &str,
+ config: Option<&str>,
+ command: &str,
+ command_args: &[&str],
+ cancel: &CancellationToken,
+ on_chunk: Option>,
+) -> Result {
+ // Build the user-side command string. The MCP handler invokes us
+ // as `("sh", &["-c", &full_cmd])`, which is the fast path: peel
+ // off the `-c` arg so the shim wraps the body directly instead of
+ // nesting an extra shell. For any other shape, fall back to a
+ // best-effort shell-quoted concatenation.
+ let user_cmd: String = if command == "sh" && command_args.len() == 2 && command_args[0] == "-c"
+ {
+ command_args[1].to_string()
+ } else {
+ let mut parts = vec![crate::file_ops::shell_quote(command)];
+ for a in command_args {
+ parts.push(crate::file_ops::shell_quote(a));
+ }
+ parts.join(" ")
+ };
+
+ let wrapped = crate::exec_shim::wrap();
+ // The user command is passed to the shim via an env var so it
+ // doesn't need to be quoted into a nested `sh -c '...'`. We use
+ // `--remote-env NAME=VALUE` so the devcontainer CLI propagates
+ // it inside the container. This is the env-var path; the
+ // self-contained `wrap_self_contained` variant is for backends
+ // (DevPod, Codespaces) that can't pass remote env vars.
+ let remote_env_arg = format!("{}={}", crate::exec_shim::USER_CMD_ENV, user_cmd);
+
+ // Resolve target container up-front (see `exec` docs for rationale).
+ let container = lookup_one_or_err(workspace_folder, config).await?;
+
+ let mut all_args: Vec<&str> = vec![
+ "exec",
+ "--container-id",
+ &container.id,
+ "--workspace-folder",
+ workspace_folder,
+ ];
+ let cfg_abs = config.map(|c| abs_config(workspace_folder, c));
+ if let Some(c) = cfg_abs.as_deref() {
+ all_args.push("--config");
+ all_args.push(c);
+ }
+ all_args.extend_from_slice(&["--remote-env", &remote_env_arg, "sh", "-c"]);
+ all_args.push(&wrapped);
+
+ let killer: Arc = Arc::new(DevcontainerKiller {
+ workspace_folder: workspace_folder.to_string(),
+ config: config.map(str::to_string),
+ });
+
+ run_with_shim(
+ &CliBinary::Devcontainer,
+ &all_args,
+ None,
+ cancel,
+ on_chunk,
+ killer,
+ )
+ .await
+}
+
+/// Delivers `kill - -` inside the devcontainer associated
+/// with a workspace folder, using bollard's exec API. When `config` is
+/// provided, the target container is disambiguated using the same
+/// resolution as the rest of the lifecycle tools.
+struct DevcontainerKiller {
+ workspace_folder: String,
+ config: Option,
+}
+
+#[async_trait::async_trait]
+impl RemoteKiller for DevcontainerKiller {
+ async fn kill_pgid(&self, pgid: i32, signal: &str) {
+ use bollard::exec::{CreateExecOptions, StartExecOptions};
+
+ let client = match docker::connect() {
+ Ok(c) => c,
+ Err(e) => {
+ tracing::warn!(%e, "failed to connect to docker for in-container kill");
+ return;
+ }
+ };
+ let resolved = try_resolve(&self.workspace_folder, self.config.as_deref());
+ let container =
+ match docker::find_devcontainer(&client, &self.workspace_folder, resolved.as_ref())
+ .await
+ {
+ Ok(DevcontainerLookup::One(c)) => c,
+ Ok(DevcontainerLookup::Many(candidates)) => {
+ // Best effort: pick the first; we may be racing the
+ // user's own teardown, so an imperfect kill is fine.
+ tracing::warn!(
+ workspace = %self.workspace_folder,
+ count = candidates.len(),
+ "ambiguous container lookup during in-container kill; picking first"
+ );
+ candidates.into_iter().next().unwrap()
+ }
+ Ok(DevcontainerLookup::None) => {
+ tracing::warn!(
+ workspace = %self.workspace_folder,
+ "no devcontainer found for in-container kill"
+ );
+ return;
+ }
+ Err(e) => {
+ tracing::warn!(%e, "container lookup failed during in-container kill");
+ return;
+ }
+ };
+
+ // `kill - -` signals every process in the group.
+ // The POSIX `--` argument separator is deliberately omitted
+ // because BusyBox/dash `kill` (common in slim container
+ // images) rejects it as "Illegal number".
+ let cmd = format!("kill -{signal} -{pgid} 2>/dev/null || true");
+ let create = CreateExecOptions {
+ cmd: Some(vec!["sh".to_string(), "-c".to_string(), cmd]),
+ attach_stdout: Some(false),
+ attach_stderr: Some(false),
+ ..Default::default()
+ };
+ let res = match client.create_exec(&container.id, create).await {
+ Ok(r) => r,
+ Err(e) => {
+ tracing::debug!(%e, "create_exec for in-container kill failed");
+ return;
+ }
+ };
+ // `detach: true` makes start_exec return as soon as the docker
+ // daemon has spawned the kill; we don't need to stream its
+ // (empty) output.
+ let opts = StartExecOptions {
+ detach: true,
+ ..Default::default()
+ };
+ if let Err(e) = client.start_exec(&res.id, Some(opts)).await {
+ tracing::debug!(%e, "start_exec for in-container kill failed");
+ }
+ }
+}
+
/// `devcontainer build` — build a dev container image.
-pub async fn build(workspace_folder: &str, extra_args: &[&str]) -> Result {
+pub async fn build(
+ workspace_folder: &str,
+ config: Option<&str>,
+ extra_args: &[&str],
+) -> Result {
let mut args = vec!["build", "--workspace-folder", workspace_folder];
+ let cfg_abs = config.map(|c| abs_config(workspace_folder, c));
+ if let Some(c) = cfg_abs.as_deref() {
+ args.push("--config");
+ args.push(c);
+ }
args.extend_from_slice(extra_args);
run_devcontainer(&args, true).await
}
+/// `devcontainer build` — cancellable, streaming variant. See
+/// [`up_streaming`].
+pub async fn build_streaming(
+ workspace_folder: &str,
+ config: Option<&str>,
+ extra_args: &[&str],
+ cancel: &CancellationToken,
+ on_chunk: Option>,
+) -> Result {
+ let mut args = vec!["build", "--workspace-folder", workspace_folder];
+ let cfg_abs = config.map(|c| abs_config(workspace_folder, c));
+ if let Some(c) = cfg_abs.as_deref() {
+ args.push("--config");
+ args.push(c);
+ }
+ args.extend_from_slice(extra_args);
+ run_cli_streaming(
+ &CliBinary::Devcontainer,
+ &args,
+ true,
+ None,
+ cancel,
+ on_chunk,
+ )
+ .await
+}
+
/// `devcontainer read-configuration` — read devcontainer config as JSON.
pub async fn read_configuration(workspace_folder: &str, config: Option<&str>) -> Result {
let mut args = vec!["read-configuration", "--workspace-folder", workspace_folder];
- if let Some(c) = config {
+ let cfg_abs = config.map(|c| abs_config(workspace_folder, c));
+ if let Some(c) = cfg_abs.as_deref() {
args.push("--config");
args.push(c);
}
@@ -59,40 +375,65 @@ pub async fn read_configuration(workspace_folder: &str, config: Option<&str>) ->
// Lifecycle via bollard (devcontainer CLI has no stop/down)
// ---------------------------------------------------------------------------
+/// Look up the single container for `workspace_folder` + `config`,
+/// returning a structured error for `None` / `Many`. Used by stop/remove,
+/// which refuse to act on ambiguous matches.
+async fn lookup_one_or_err(
+ workspace_folder: &str,
+ config: Option<&str>,
+) -> Result {
+ let client = docker::connect()?;
+ let resolved = try_resolve(workspace_folder, config);
+ match docker::find_devcontainer(&client, workspace_folder, resolved.as_ref()).await? {
+ DevcontainerLookup::One(c) => Ok(c),
+ DevcontainerLookup::None => Err(crate::error::Error::Io(std::io::Error::new(
+ std::io::ErrorKind::NotFound,
+ format!("No devcontainer found for workspace: {workspace_folder}"),
+ ))),
+ DevcontainerLookup::Many(candidates) => Err(crate::error::Error::Io(
+ std::io::Error::other(ambiguous_error(workspace_folder, &candidates)),
+ )),
+ }
+}
+
/// Stop a dev container found by its workspace folder label.
-pub async fn stop(workspace_folder: &str) -> Result {
+pub async fn stop(workspace_folder: &str, config: Option<&str>) -> Result {
let client = docker::connect()?;
- let container = docker::find_container_by_local_folder(&client, workspace_folder)
- .await?
- .ok_or_else(|| {
- crate::error::Error::Io(std::io::Error::new(
- std::io::ErrorKind::NotFound,
- format!("No devcontainer found for workspace: {workspace_folder}"),
- ))
- })?;
+ let container = lookup_one_or_err(workspace_folder, config).await?;
docker::stop_container(&client, &container.id).await?;
Ok(format!("Stopped container {}", container.name))
}
/// Remove a dev container found by its workspace folder label.
-pub async fn remove(workspace_folder: &str, force: bool) -> Result {
+pub async fn remove(workspace_folder: &str, config: Option<&str>, force: bool) -> Result {
let client = docker::connect()?;
- let container = docker::find_container_by_local_folder(&client, workspace_folder)
- .await?
- .ok_or_else(|| {
- crate::error::Error::Io(std::io::Error::new(
- std::io::ErrorKind::NotFound,
- format!("No devcontainer found for workspace: {workspace_folder}"),
- ))
- })?;
+ let container = lookup_one_or_err(workspace_folder, config).await?;
docker::remove_container(&client, &container.id, force).await?;
Ok(format!("Removed container {}", container.name))
}
-/// Get status of a dev container by workspace folder label.
-pub async fn status(workspace_folder: &str) -> Result