diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f42b4c6..925562a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,5 +1,5 @@ { - "name": "devpod-mcp", + "name": "devcontainer-mcp", "build": { "dockerfile": "Dockerfile" }, @@ -14,6 +14,12 @@ "ghcr.io/devcontainers/features/docker-in-docker:2": { "moby": true, "dockerDashComposeVersion": "v2" + }, + "ghcr.io/devcontainers/features/sshd:1": { + "version": "latest" + }, + "ghcr.io/devcontainers/features/github-cli:1": { + "version": "latest" } }, "postCreateCommand": ". /usr/local/cargo/env && cargo fetch", diff --git a/.devcontainer/initialize.sh b/.devcontainer/initialize.sh new file mode 100755 index 0000000..28618b5 --- /dev/null +++ b/.devcontainer/initialize.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# initialize.sh — runs on the HOST before the container starts. +# Grabs the GitHub token and writes it to gh.env for container use. +# If multiple accounts are logged in, uses the active one. +# Set DEVCONTAINER_GH_USER= to override. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GH_ENV_FILE="${SCRIPT_DIR}/gh.env" + +# --- Verify gh CLI --- +if ! command -v gh &>/dev/null; then + echo "⚠️ gh CLI not found on host. Codespaces backend will not work." + touch "${GH_ENV_FILE}" + exit 0 +fi + +echo "🔐 Acquiring GitHub token for devcontainer..." + +# Get authenticated accounts from keyring (ignore GH_TOKEN env) +ACCOUNTS_JSON=$(GH_TOKEN="" gh auth status --json hosts 2>/dev/null || echo '{}') + +# If user specified an account, use it +if [ -n "${DEVCONTAINER_GH_USER:-}" ]; then + ACCOUNT="${DEVCONTAINER_GH_USER}" + echo "Using specified GitHub account: ${ACCOUNT}" +else + # Pick account: prefer active, fall back to first, prompt if interactive + ACCOUNT=$(echo "$ACCOUNTS_JSON" | python3 -c " +import sys, json +data = json.load(sys.stdin) +hosts = data.get('hosts', {}).get('github.com', []) +ok = [h for h in hosts if h.get('state') == 'success'] +active = [h for h in ok if h.get('active')] +pick = active[0] if active else (ok[0] if ok else None) +if pick: + print(pick['login']) +" 2>/dev/null || true) + + ACCOUNT_COUNT=$(echo "$ACCOUNTS_JSON" | python3 -c " +import sys, json +data = json.load(sys.stdin) +hosts = data.get('hosts', {}).get('github.com', []) +print(len([h for h in hosts if h.get('state') == 'success'])) +" 2>/dev/null || echo 0) + + if [ "$ACCOUNT_COUNT" -gt 1 ] && [ -t 0 ]; then + # Interactive terminal — let user choose + ALL_ACCOUNTS=$(echo "$ACCOUNTS_JSON" | python3 -c " +import sys, json +data = json.load(sys.stdin) +hosts = data.get('hosts', {}).get('github.com', []) +for h in hosts: + if h.get('state') == 'success': + marker = ' (active)' if h.get('active') else '' + print(h['login'] + marker) +" 2>/dev/null || true) + echo "" + echo "Multiple GitHub accounts detected:" + i=1 + while IFS= read -r acct; do + echo " ${i}) ${acct}" + i=$((i + 1)) + done <<< "$ALL_ACCOUNTS" + echo "" + read -rp "Which account? [1-${ACCOUNT_COUNT}] (default: ${ACCOUNT}): " CHOICE + if [ -n "$CHOICE" ]; then + PICKED=$(echo "$ACCOUNTS_JSON" | python3 -c " +import sys, json +data = json.load(sys.stdin) +hosts = data.get('hosts', {}).get('github.com', []) +ok = [h['login'] for h in hosts if h.get('state') == 'success'] +idx = int(sys.argv[1]) - 1 +print(ok[idx] if 0 <= idx < len(ok) else '') +" "$CHOICE" 2>/dev/null || true) + [ -n "$PICKED" ] && ACCOUNT="$PICKED" + fi + fi +fi + +if [ -z "$ACCOUNT" ]; then + echo "🔑 No GitHub accounts found. Logging in..." + GH_TOKEN="" gh auth login -h github.com -p https -w + GH_TOKEN=$(GH_TOKEN="" gh auth token -h github.com 2>/dev/null || true) +else + echo "Using GitHub account: ${ACCOUNT}" + GH_TOKEN=$(GH_TOKEN="" gh auth token -h github.com --user "${ACCOUNT}" 2>/dev/null || true) +fi + +if [ -n "${GH_TOKEN:-}" ]; then + echo "GH_TOKEN=${GH_TOKEN}" > "${GH_ENV_FILE}" + echo "✅ GitHub token acquired (${ACCOUNT:-unknown})" +else + touch "${GH_ENV_FILE}" + echo "⚠️ Could not acquire GitHub token — codespaces tools won't work" +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8919e6..b10a774 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,6 @@ jobs: push: never runCmd: | cargo fmt --all -- --check - cargo check -p devpod-mcp-core -p devpod-mcp - cargo test -p devpod-mcp-core -p devpod-mcp - cargo clippy -p devpod-mcp-core -p devpod-mcp -- -D warnings + cargo check -p devcontainer-mcp-core -p devcontainer-mcp + cargo test -p devcontainer-mcp-core -p devcontainer-mcp + cargo clippy -p devcontainer-mcp-core -p devcontainer-mcp -- -D warnings diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 056d01f..a283035 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,27 +26,27 @@ jobs: rustup target add aarch64-unknown-linux-gnu x86_64-unknown-linux-gnu # Build linux-x64 (native) - cargo build --release --target x86_64-unknown-linux-gnu -p devpod-mcp + cargo build --release --target x86_64-unknown-linux-gnu -p devcontainer-mcp # Build linux-arm64 (cross-compile with correct linker) CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \ - cargo build --release --target aarch64-unknown-linux-gnu -p devpod-mcp + cargo build --release --target aarch64-unknown-linux-gnu -p devcontainer-mcp # Copy binaries to output dir mkdir -p /tmp/release - cp target/x86_64-unknown-linux-gnu/release/devpod-mcp /tmp/release/devpod-mcp-linux-x64 - cp target/aarch64-unknown-linux-gnu/release/devpod-mcp /tmp/release/devpod-mcp-linux-arm64 + cp target/x86_64-unknown-linux-gnu/release/devcontainer-mcp /tmp/release/devcontainer-mcp-linux-x64 + cp target/aarch64-unknown-linux-gnu/release/devcontainer-mcp /tmp/release/devcontainer-mcp-linux-arm64 chmod +x /tmp/release/* - name: Upload linux-x64 uses: softprops/action-gh-release@v3 with: - files: /tmp/release/devpod-mcp-linux-x64 + files: /tmp/release/devcontainer-mcp-linux-x64 - name: Upload linux-arm64 uses: softprops/action-gh-release@v3 with: - files: /tmp/release/devpod-mcp-linux-arm64 + files: /tmp/release/devcontainer-mcp-linux-arm64 build-macos: name: Build ${{ matrix.artifact }} @@ -55,9 +55,9 @@ jobs: matrix: include: - target: x86_64-apple-darwin - artifact: devpod-mcp-darwin-x64 + artifact: devcontainer-mcp-darwin-x64 - target: aarch64-apple-darwin - artifact: devpod-mcp-darwin-arm64 + artifact: devcontainer-mcp-darwin-arm64 steps: - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@stable @@ -68,11 +68,11 @@ jobs: key: ${{ matrix.target }} - name: Build - run: cargo build --release --target ${{ matrix.target }} -p devpod-mcp + run: cargo build --release --target ${{ matrix.target }} -p devcontainer-mcp - name: Package binary run: | - cp target/${{ matrix.target }}/release/devpod-mcp ${{ matrix.artifact }} + cp target/${{ matrix.target }}/release/devcontainer-mcp ${{ matrix.artifact }} chmod +x ${{ matrix.artifact }} - name: Upload release asset diff --git a/.gitignore b/.gitignore index 124f353..6209e6b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ # DevPod / DevContainer internals .devcontainer/.devpod-internal/ .devcontainer/devcontainer-lock.json +.devcontainer/gh.env # IDE .idea/ diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 74a5238..ffe6784 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -3,12 +3,13 @@ "tasks": [ { "label": "Build", - "type": "cargo", - "command": "build", + "type": "shell", + "command": "cargo", "args": [ + "build", "--release", "-p", - "devpod-mcp" + "devcontainer-mcp" ], "group": { "kind": "build", @@ -22,7 +23,7 @@ "command": "bash", "args": [ "-c", - "set -e && targets=(x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu x86_64-apple-darwin aarch64-apple-darwin) && for t in \"${targets[@]}\"; do echo \"\\n==> Building $t\" && rustup target add \"$t\" 2>/dev/null && cargo build --release --target \"$t\" -p devpod-mcp && echo \" ✓ target/$t/release/devpod-mcp\"; done" + "set -e && targets=(x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu x86_64-apple-darwin aarch64-apple-darwin) && for t in \"${targets[@]}\"; do echo \"\\n==> Building $t\" && rustup target add \"$t\" 2>/dev/null && cargo build --release --target \"$t\" -p devcontainer-mcp && echo \" ✓ target/$t/release/devcontainer-mcp\"; done" ], "group": "build", "problemMatcher": ["$rustc"] diff --git a/Cargo.lock b/Cargo.lock index f478ce2..8049d55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,6 +76,17 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -255,12 +266,12 @@ dependencies = [ ] [[package]] -name = "devpod-mcp" +name = "devcontainer-mcp" version = "0.1.0" dependencies = [ "anyhow", "clap", - "devpod-mcp-core", + "devcontainer-mcp-core", "rmcp", "serde", "serde_json", @@ -270,9 +281,10 @@ dependencies = [ ] [[package]] -name = "devpod-mcp-core" +name = "devcontainer-mcp-core" version = "0.1.0" dependencies = [ + "async-trait", "bollard", "futures-util", "serde", diff --git a/Cargo.toml b/Cargo.toml index 6e3d1f7..8d12a05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,14 @@ [workspace] resolver = "2" members = [ - "crates/devpod-mcp-core", - "crates/devpod-mcp", + "crates/devcontainer-mcp-core", + "crates/devcontainer-mcp", ] [workspace.package] edition = "2021" license = "MIT" -repository = "https://github.com/aniongithub/devpod-mcp" +repository = "https://github.com/aniongithub/devcontainer-mcp" [workspace.dependencies] tokio = { version = "1", features = ["full"] } diff --git a/README.md b/README.md index 4383c41..af67a80 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,127 @@ -# devpod-mcp +# devcontainer-mcp -[![CI](https://github.com/aniongithub/devpod-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/aniongithub/devpod-mcp/actions/workflows/ci.yml) - -An MCP server that wraps the [DevPod](https://devpod.sh/) CLI to give AI coding agents full control over isolated development environments — so work happens inside the right container, not on the host. +[![CI](https://github.com/aniongithub/devcontainer-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/aniongithub/devcontainer-mcp/actions/workflows/ci.yml) +A unified MCP server that gives AI coding agents full control over dev container environments across **three backends** — so work happens inside the right container, not on the host. ## Quick Install ```bash -# Install MCP server + DevPod CLI (auto-detects OS/arch, installs DevPod if missing) -curl -fsSL https://raw.githubusercontent.com/aniongithub/devpod-mcp/main/install.sh | bash +# Install the MCP server binary +curl -fsSL https://raw.githubusercontent.com/aniongithub/devcontainer-mcp/main/install.sh | bash ``` +Backend CLIs (`devpod`, `devcontainer`, `gh`) are detected at runtime — if one is missing, the MCP server returns a helpful error telling you how to install it. + Binaries are available for **linux-x64**, **linux-arm64**, **darwin-x64**, and **darwin-arm64**. ## Why? AI coding agents suffer from **Host Contamination** and **Context Drift**. They install packages on the host, assume local dependencies exist, and produce code that works "on my machine" but fails in production. -[DevPod](https://github.com/loft-sh/devpod) solves the hard container management problem — supporting Docker, Kubernetes, cloud VMs, compose, and more. **This project** bridges the gap by exposing every DevPod capability as MCP tools that AI agents can call directly. +The [devcontainer spec](https://containers.dev/) solves this with reproducible, container-based environments. **This project** bridges the gap by exposing every dev container operation as MCP tools that AI agents can call directly — across multiple backends. ## Architecture ```mermaid graph TD - A[AI Agent / MCP Client] -->|stdio JSON-RPC| B[devpod-mcp binary] + A[AI Agent / MCP Client] -->|stdio JSON-RPC| B[devcontainer-mcp] - subgraph "devpod-mcp" - B --> C[15 MCP Tools] - C --> D[devpod-mcp-core lib] + subgraph "devcontainer-mcp" + B --> C[29 MCP Tools] + C --> D[devcontainer-mcp-core] end D -->|subprocess| E[DevPod CLI] - D -->|bollard API| F[Docker Engine] + D -->|subprocess| F[devcontainer CLI] + D -->|subprocess| G[gh CLI] + D -->|bollard API| H[Docker Engine] - E --> G[DevPod Workspace Container] - F --> G + E --> I[Docker / K8s / Cloud VMs] + F --> J[Local Docker] + G --> K[GitHub Codespaces] ``` +## Backends + +| Backend | Best for | Requires | +|---------|----------|----------| +| **DevPod** (`devpod_*`) | Multi-provider: Docker, K8s, AWS, GCP, etc. | [DevPod CLI](https://devpod.sh) | +| **devcontainer CLI** (`devcontainer_*`) | Local Docker development | [@devcontainers/cli](https://github.com/devcontainers/cli) | +| **Codespaces** (`codespaces_*`) | GitHub-hosted cloud environments | [gh CLI](https://cli.github.com/) + auth | + ## MCP Tools -### Workspace Lifecycle +### Auth (3 tools) + | Tool | Description | |------|-------------| -| `devpod_up` | Create and start a workspace from a git URL, local path, or image. Returns full build output for self-healing. | +| `auth_status` | Check auth status for a provider. Returns available auth handles and accounts. | +| `auth_login` | Initiate login flow — opens browser, copies device code to clipboard. | +| `auth_select` | Verify an auth handle is still valid. | + +Codespaces tools require a GitHub auth handle (e.g. `"github-aniongithub"`). Get one via `auth_status` or `auth_login`, then pass it as the `auth` parameter. The agent never sees raw tokens. + +### DevPod (15 tools) + +#### Workspace Lifecycle +| Tool | Description | +|------|-------------| +| `devpod_up` | Create and start a workspace from a git URL, local path, or image | | `devpod_stop` | Stop a running workspace | | `devpod_delete` | Delete a workspace and its resources | | `devpod_build` | Build a workspace image without starting it | -| `devpod_status` | Get workspace state (`Running`, `Stopped`, `Busy`, `NotFound`) as JSON | +| `devpod_status` | Get workspace state (`Running`, `Stopped`, `Busy`, `NotFound`) | | `devpod_list` | List all workspaces with IDs, sources, providers, and status | -### Command Execution +#### Command Execution | Tool | Description | |------|-------------| -| `devpod_ssh` | Execute a command inside a workspace via SSH. Returns stdout, stderr, and exit code. | +| `devpod_ssh` | Execute a command inside a workspace via SSH | -### Provider Management +#### Provider Management | Tool | Description | |------|-------------| | `devpod_provider_list` | List all configured providers | | `devpod_provider_add` | Add a new provider | | `devpod_provider_delete` | Remove a provider | -### Context Management +#### Context Management | Tool | Description | |------|-------------| | `devpod_context_list` | List all contexts | | `devpod_context_use` | Switch to a different context | -### Logs & Docker +#### Logs & Docker | Tool | Description | |------|-------------| -| `devpod_logs` | Get workspace logs (useful for diagnosing build failures) | +| `devpod_logs` | Get workspace logs | | `devpod_container_inspect` | Direct Docker inspect for labels, ports, mounts | -| `devpod_container_logs` | Stream container logs via Docker API (bollard) | +| `devpod_container_logs` | Stream container logs via Docker API | + +### devcontainer CLI (7 tools) + +| Tool | Description | +|------|-------------| +| `devcontainer_up` | Create and start a dev container from a workspace folder | +| `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_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 | + +### GitHub Codespaces (7 tools) + +| Tool | Description | +|------|-------------| +| `codespaces_create` | Create a new codespace for a repository | +| `codespaces_list` | List your codespaces with state and machine info | +| `codespaces_ssh` | Execute a command inside a codespace via SSH | +| `codespaces_stop` | Stop a running codespace | +| `codespaces_delete` | Delete a codespace | +| `codespaces_view` | View detailed codespace info (state, machine, config) | +| `codespaces_ports` | List forwarded ports with visibility and URLs | ## MCP Server Configuration @@ -82,8 +130,8 @@ graph TD ```json { "mcpServers": { - "devpod-mcp": { - "command": "devpod-mcp", + "devcontainer-mcp": { + "command": "devcontainer-mcp", "args": ["serve"] } } @@ -95,8 +143,8 @@ graph TD Add to your MCP settings: ```json { - "devpod-mcp": { - "command": "devpod-mcp", + "devcontainer-mcp": { + "command": "devcontainer-mcp", "args": ["serve"] } } @@ -104,36 +152,37 @@ Add to your MCP settings: ## Prerequisites -- [DevPod](https://devpod.sh/docs/getting-started/install) CLI installed (or use `--with-devpod` in the install script) -- [Docker](https://docs.docker.com/get-docker/) (or another DevPod provider like Kubernetes) +Install backend CLIs as needed — the MCP server detects them at runtime and returns helpful errors if missing: + +- **DevPod**: [DevPod CLI](https://devpod.sh/docs/getting-started/install) + [Docker](https://docs.docker.com/get-docker/) (or another provider) +- **devcontainer CLI**: `npm install -g @devcontainers/cli` + [Docker](https://docs.docker.com/get-docker/) +- **Codespaces**: [GitHub CLI](https://cli.github.com/) — auth is handled by the `auth_login` tool ## Self-Healing Loop -When `devpod_up` fails (bad Dockerfile, missing dependency, etc.), the full build output — including error messages — is returned to the AI agent. The agent can then: +When `devpod_up` or `devcontainer_up` fails (bad Dockerfile, missing dependency, etc.), the full build output — including error messages — is returned to the AI agent. The agent can then: 1. Read the error from `stderr` 2. Fix the `Dockerfile` or `devcontainer.json` -3. Call `devpod_up` again with `--recreate` +3. Call the up command again 4. Repeat until the environment builds successfully This makes the dev environment a **dynamic, agent-managed asset** rather than a static prerequisite. ## Development -This project eats its own dogfood — development happens inside a DevPod workspace. +This project eats its own dogfood — development happens inside its own devcontainer. ```bash -# Create and start the dev workspace -devpod up . --id devpod-mcp --provider docker --open-ide=false - -# Build inside the workspace -devpod ssh devpod-mcp --command "cd /workspaces/devpod-mcp && cargo build --workspace" - -# Run tests -devpod ssh devpod-mcp --command "cd /workspaces/devpod-mcp && cargo test --workspace" - -# Build release binary -devpod ssh devpod-mcp --command "cd /workspaces/devpod-mcp && cargo build --release -p devpod-mcp" +# Using the devcontainer CLI +devcontainer up --workspace-folder . +devcontainer exec --workspace-folder . cargo build --workspace +devcontainer exec --workspace-folder . cargo test --workspace +devcontainer exec --workspace-folder . cargo build --release -p devcontainer-mcp + +# Or using DevPod +devpod up . --id devcontainer-mcp --provider docker --open-ide=false +devpod ssh devcontainer-mcp --command "cd /workspaces/devcontainer-mcp && cargo build --workspace" ``` ### CI/CD diff --git a/SKILL.md b/SKILL.md index e3a8cff..717f541 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,10 @@ --- -name: devpod-mcp -description: Manage isolated DevPod development environments via MCP tools +name: devcontainer-mcp +description: Manage dev container environments via MCP tools (DevPod, devcontainer CLI, Codespaces) tools: + - auth_status + - auth_login + - auth_select - devpod_up - devpod_stop - devpod_delete @@ -17,70 +20,145 @@ tools: - devpod_context_use - devpod_container_inspect - devpod_container_logs + - devcontainer_up + - devcontainer_exec + - devcontainer_build + - devcontainer_read_config + - devcontainer_stop + - devcontainer_remove + - devcontainer_status + - codespaces_create + - codespaces_list + - codespaces_ssh + - codespaces_stop + - codespaces_delete + - codespaces_view + - codespaces_ports --- -# DevPod MCP Skill +# DevContainer MCP Skill -You have access to `devpod-mcp`, an MCP server that manages isolated development environments via [DevPod](https://devpod.sh/). +You have access to `devcontainer-mcp`, an MCP server that manages dev container environments across three backends: +- **DevPod** (`devpod_*` tools) — multi-provider: Docker, K8s, AWS, GCP, etc. +- **devcontainer CLI** (`devcontainer_*` tools) — local Docker via the official CLI +- **GitHub Codespaces** (`codespaces_*` tools) — cloud-hosted environments ## Core Rule -**If a project has `.devcontainer/devcontainer.json`, ALL work MUST happen inside a DevPod workspace — never install dependencies, run builds, or execute code directly on the host.** +**If a project has `.devcontainer/devcontainer.json`, ALL work MUST happen inside a dev container — never install dependencies, run builds, or execute code directly on the host.** -## Workflow +## Authentication -### 1. Check for devcontainer config -Before doing any work on a project, check if `.devcontainer/devcontainer.json` exists. +**Before using Codespaces tools, you MUST obtain an auth handle.** -### 2. Create or start the workspace +### 1. Check available accounts +``` +auth_status(provider: "github") +→ returns accounts with IDs like "github-aniongithub" +``` + +### 2. If multiple accounts, ask the user which one to use + +### 3. If no accounts or missing scopes, initiate login +``` +auth_login(provider: "github", scopes: "codespace") +→ opens browser, copies device code to clipboard +→ tell the user: "Approve in the browser, code is on your clipboard" +``` + +### 4. Pass the auth handle to all codespaces_* tools +``` +codespaces_create(auth: "github-aniongithub", repo: "owner/repo", ...) +``` + +**The agent never sees raw tokens.** Auth handles are opaque IDs resolved by the MCP server. + +Supported auth providers: `github`, `aws`, `azure`, `gcloud`, `kubernetes` + +## Choosing a Backend + +1. **Local Docker + devcontainer CLI** — simplest for local development, no auth needed +2. **DevPod** — when you need multi-provider support or the project uses DevPod +3. **Codespaces** — when you need cloud-hosted environments (requires GitHub auth) + +## Workflow: DevPod + +### 1. Create or start the workspace ``` devpod_up(args: "/path/to/project --id my-project --provider docker") ``` -- Always use `--provider docker` unless the user specifies otherwise. -- The workspace ID should be a short, descriptive kebab-case name. -### 3. Verify the workspace is running +### 2. Verify the workspace is running ``` devpod_status(workspace: "my-project") ``` -Expect `"state": "Running"`. -### 4. Execute all commands inside the workspace +### 3. Execute commands inside the workspace ``` devpod_ssh(workspace: "my-project", command: "cargo build") -devpod_ssh(workspace: "my-project", command: "npm install && npm test") ``` -- Use `devpod_ssh` for ALL commands: builds, tests, linting, package installs, etc. -- Never run these on the host. -### 5. Handle build failures (self-healing) -If `devpod_up` returns errors in stderr: -1. Read the error output carefully -2. Fix the `Dockerfile` or `devcontainer.json` in the project -3. Call `devpod_up` again — DevPod will rebuild with the fix -4. Repeat until successful +### 4. Stop when done +``` +devpod_stop(workspace: "my-project") +``` -If `devpod_ssh` commands fail, check `devpod_logs` for container-level issues. +## Workflow: devcontainer CLI -### 6. Stop when done +### 1. Start the dev container ``` -devpod_stop(workspace: "my-project") +devcontainer_up(workspace_folder: "/path/to/project") ``` -## What NOT to do +### 2. Execute commands +``` +devcontainer_exec(workspace_folder: "/path/to/project", command: "npm test") +``` -- ❌ Do NOT install packages on the host (npm install, pip install, apt install, etc.) -- ❌ Do NOT run builds on the host -- ❌ Do NOT modify the host's global config (PATH, env vars, etc.) -- ❌ Do NOT assume host tools match what the project needs -- ✅ DO use `devpod_ssh` for everything -- ✅ DO check `.devcontainer/devcontainer.json` first -- ✅ DO return build errors to the user for devcontainer config issues +### 3. Stop when done +``` +devcontainer_stop(workspace_folder: "/path/to/project") +``` + +## Workflow: Codespaces -## Inspecting containers +### 1. Authenticate +``` +auth_status(provider: "github") +# If no accounts: auth_login(provider: "github", scopes: "codespace") +# If multiple: ask the user which account +``` -Use `devpod_container_inspect` and `devpod_container_logs` when you need low-level Docker details (ports, labels, mounts, raw container logs) that `devpod_status` and `devpod_logs` don't cover. +### 2. Create a codespace — ask user for machine type +``` +codespaces_create(auth: "github-USERNAME", repo: "owner/repo", machine: "basicLinux32gb") +``` +Machine types: `basicLinux32gb` (2 cores, 8 GB), `standardLinux32gb` (4 cores, 16 GB), `premiumLinux` (8 cores, 32 GB), `largePremiumLinux` (16 cores, 64 GB) -## Managing multiple workspaces +### 3. Execute commands +``` +codespaces_ssh(auth: "github-USERNAME", codespace: "codespace-name", command: "npm test") +``` -Use `devpod_list` to see all workspaces. Each workspace is independent — you can run multiple projects simultaneously in separate containers. +### 4. Stop when done +``` +codespaces_stop(auth: "github-USERNAME", codespace: "codespace-name") +``` + +## Self-Healing + +If `devpod_up`, `devcontainer_up`, or `codespaces_create` returns errors: +1. Read the error output carefully +2. Fix the `Dockerfile` or `devcontainer.json` +3. Call the up/create command again +4. Repeat until successful + +## What NOT to do + +- ❌ Do NOT install packages on the host +- ❌ Do NOT run builds on the host +- ❌ Do NOT modify the host's global config +- ✅ DO authenticate before using codespaces tools +- ✅ DO ask the user which account/machine type to use +- ✅ DO use `devpod_ssh`, `devcontainer_exec`, or `codespaces_ssh` for everything +- ✅ DO check `.devcontainer/devcontainer.json` first diff --git a/crates/devpod-mcp-core/Cargo.toml b/crates/devcontainer-mcp-core/Cargo.toml similarity index 68% rename from crates/devpod-mcp-core/Cargo.toml rename to crates/devcontainer-mcp-core/Cargo.toml index ac50007..ba6f344 100644 --- a/crates/devpod-mcp-core/Cargo.toml +++ b/crates/devcontainer-mcp-core/Cargo.toml @@ -1,7 +1,7 @@ [package] -name = "devpod-mcp-core" +name = "devcontainer-mcp-core" version = "0.1.0" -description = "Core library for devpod-mcp: DevPod CLI wrapper and Docker queries" +description = "Core library for devcontainer-mcp: DevPod/Codespaces CLI wrapper and Docker queries" edition.workspace = true license.workspace = true repository.workspace = true @@ -14,3 +14,4 @@ thiserror = { workspace = true } bollard = { workspace = true } tracing = { workspace = true } futures-util = "0.3" +async-trait = "0.1" diff --git a/crates/devcontainer-mcp-core/src/auth/aws.rs b/crates/devcontainer-mcp-core/src/auth/aws.rs new file mode 100644 index 0000000..d751e38 --- /dev/null +++ b/crates/devcontainer-mcp-core/src/auth/aws.rs @@ -0,0 +1,179 @@ +use std::collections::HashMap; +use std::process::Stdio; + +use async_trait::async_trait; +use tokio::process::Command; + +use super::{AuthAccount, AuthLoginResult, AuthProvider, AuthStatus}; +use crate::cli::{run_cli, CliBinary}; +use crate::error::Result; + +pub struct AwsAuth; + +#[async_trait] +impl AuthProvider for AwsAuth { + fn name(&self) -> &str { + "aws" + } + + async fn status(&self) -> Result { + // Check if aws is installed by running sts get-caller-identity + let output = run_cli( + &CliBinary::Aws, + &["sts", "get-caller-identity", "--output", "json"], + false, + ) + .await; + let output = match output { + Ok(o) => o, + Err(_) => { + return Ok(AuthStatus { + provider: "aws".into(), + cli_installed: false, + accounts: vec![], + }); + } + }; + + let mut accounts = vec![]; + + // If the default profile works, add it + if output.exit_code == 0 { + if let Ok(parsed) = serde_json::from_str::(&output.stdout) { + let account = parsed + .get("Account") + .and_then(|a| a.as_str()) + .unwrap_or("") + .to_string(); + let arn = parsed + .get("Arn") + .and_then(|a| a.as_str()) + .unwrap_or("") + .to_string(); + let user_id = parsed + .get("UserId") + .and_then(|u| u.as_str()) + .unwrap_or("") + .to_string(); + + accounts.push(AuthAccount { + id: "aws-default".into(), + login: arn.to_string(), + active: true, + metadata: serde_json::json!({ + "profile": "default", + "account": account, + "user_id": user_id, + "arn": arn, + }), + }); + } + } + + // Try to list named profiles from config + let profiles_output = + run_cli(&CliBinary::Aws, &["configure", "list-profiles"], false).await; + if let Ok(po) = profiles_output { + if po.exit_code == 0 { + for profile in po.stdout.lines() { + let profile = profile.trim(); + if profile.is_empty() || profile == "default" { + continue; + } + accounts.push(AuthAccount { + id: format!("aws-{profile}"), + login: profile.to_string(), + active: false, + metadata: serde_json::json!({ "profile": profile }), + }); + } + } + } + + Ok(AuthStatus { + provider: "aws".into(), + cli_installed: true, + accounts, + }) + } + + async fn login(&self, scopes: Option<&str>) -> Result { + // scopes is used as profile name for SSO login + let profile = scopes.unwrap_or("default"); + let child = Command::new("aws") + .args(["sso", "login", "--profile", profile]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|_| crate::error::Error::AwsCliNotFound)?; + + let output = child + .wait_with_output() + .await + .map_err(crate::error::Error::Io)?; + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + if output.status.success() { + Ok(AuthLoginResult { + id: Some(format!("aws-{profile}")), + action: "success".into(), + message: format!("AWS SSO login complete for profile '{profile}'."), + browser_opened: combined.contains("browser"), + code_copied: false, + }) + } else { + Ok(AuthLoginResult { + id: None, + action: "error".into(), + message: format!("AWS login failed: {}", combined.trim()), + browser_opened: false, + code_copied: false, + }) + } + } + + async fn verify(&self, handle: &str) -> Result> { + let profile = handle.strip_prefix("aws-").unwrap_or(handle); + let output = run_cli( + &CliBinary::Aws, + &[ + "sts", + "get-caller-identity", + "--profile", + profile, + "--output", + "json", + ], + false, + ) + .await?; + + if output.exit_code == 0 { + if let Ok(parsed) = serde_json::from_str::(&output.stdout) { + let arn = parsed + .get("Arn") + .and_then(|a| a.as_str()) + .unwrap_or("") + .to_string(); + return Ok(Some(AuthAccount { + id: handle.to_string(), + login: arn, + active: profile == "default", + metadata: parsed, + })); + } + } + Ok(None) + } + + async fn resolve_env(&self, handle: &str) -> Result> { + let profile = handle.strip_prefix("aws-").unwrap_or(handle); + let mut env = HashMap::new(); + env.insert("AWS_PROFILE".into(), profile.to_string()); + Ok(env) + } +} diff --git a/crates/devcontainer-mcp-core/src/auth/azure.rs b/crates/devcontainer-mcp-core/src/auth/azure.rs new file mode 100644 index 0000000..c2c4c69 --- /dev/null +++ b/crates/devcontainer-mcp-core/src/auth/azure.rs @@ -0,0 +1,134 @@ +use std::collections::HashMap; +use std::process::Stdio; + +use async_trait::async_trait; +use tokio::process::Command; + +use super::{AuthAccount, AuthLoginResult, AuthProvider, AuthStatus}; +use crate::cli::{run_cli, CliBinary}; +use crate::error::Result; + +pub struct AzureAuth; + +#[async_trait] +impl AuthProvider for AzureAuth { + fn name(&self) -> &str { + "azure" + } + + async fn status(&self) -> Result { + let output = run_cli(&CliBinary::Az, &["account", "list", "-o", "json"], false).await; + let output = match output { + Ok(o) => o, + Err(_) => { + return Ok(AuthStatus { + provider: "azure".into(), + cli_installed: false, + accounts: vec![], + }); + } + }; + + let mut accounts = vec![]; + if let Ok(parsed) = serde_json::from_str::(&output.stdout) { + if let Some(arr) = parsed.as_array() { + for entry in arr { + let name = entry + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or("unknown") + .to_string(); + let sub_id = entry + .get("id") + .and_then(|i| i.as_str()) + .unwrap_or("") + .to_string(); + let user = entry + .get("user") + .and_then(|u| u.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("") + .to_string(); + let is_default = entry + .get("isDefault") + .and_then(|d| d.as_bool()) + .unwrap_or(false); + let state = entry + .get("state") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + + accounts.push(AuthAccount { + id: format!("azure-{sub_id}"), + login: format!("{name} ({user})"), + active: is_default, + metadata: serde_json::json!({ + "subscription_id": sub_id, + "subscription_name": name, + "user": user, + "state": state, + }), + }); + } + } + } + + Ok(AuthStatus { + provider: "azure".into(), + cli_installed: true, + accounts, + }) + } + + async fn login(&self, _scopes: Option<&str>) -> Result { + let child = Command::new("az") + .args(["login", "--use-device-code"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|_| crate::error::Error::AzCliNotFound)?; + + let output = child + .wait_with_output() + .await + .map_err(crate::error::Error::Io)?; + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if output.status.success() { + let status = self.status().await?; + let active = status.accounts.into_iter().find(|a| a.active); + Ok(AuthLoginResult { + id: active.map(|a| a.id), + action: "success".into(), + message: "Azure authentication complete.".into(), + browser_opened: stderr.contains("open"), + code_copied: false, + }) + } else { + Ok(AuthLoginResult { + id: None, + action: "error".into(), + message: format!("Azure authentication failed: {}", stderr.trim()), + browser_opened: false, + code_copied: false, + }) + } + } + + async fn verify(&self, handle: &str) -> Result> { + let sub_id = handle.strip_prefix("azure-").unwrap_or(handle); + let status = self.status().await?; + Ok(status.accounts.into_iter().find(|a| { + a.id == handle + || a.metadata.get("subscription_id").and_then(|s| s.as_str()) == Some(sub_id) + })) + } + + async fn resolve_env(&self, handle: &str) -> Result> { + let sub_id = handle.strip_prefix("azure-").unwrap_or(handle); + let mut env = HashMap::new(); + env.insert("AZURE_SUBSCRIPTION_ID".into(), sub_id.to_string()); + Ok(env) + } +} diff --git a/crates/devcontainer-mcp-core/src/auth/gcloud.rs b/crates/devcontainer-mcp-core/src/auth/gcloud.rs new file mode 100644 index 0000000..1691c90 --- /dev/null +++ b/crates/devcontainer-mcp-core/src/auth/gcloud.rs @@ -0,0 +1,120 @@ +use std::collections::HashMap; +use std::process::Stdio; + +use async_trait::async_trait; +use tokio::process::Command; + +use super::{AuthAccount, AuthLoginResult, AuthProvider, AuthStatus}; +use crate::cli::{run_cli, CliBinary}; +use crate::error::Result; + +pub struct GcloudAuth; + +#[async_trait] +impl AuthProvider for GcloudAuth { + fn name(&self) -> &str { + "gcloud" + } + + async fn status(&self) -> Result { + let output = run_cli( + &CliBinary::Gcloud, + &["auth", "list", "--format=json"], + false, + ) + .await; + let output = match output { + Ok(o) => o, + Err(_) => { + return Ok(AuthStatus { + provider: "gcloud".into(), + cli_installed: false, + accounts: vec![], + }); + } + }; + + let mut accounts = vec![]; + if let Ok(parsed) = serde_json::from_str::(&output.stdout) { + if let Some(arr) = parsed.as_array() { + for entry in arr { + let account = entry + .get("account") + .and_then(|a| a.as_str()) + .unwrap_or("") + .to_string(); + let status = entry + .get("status") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + + accounts.push(AuthAccount { + id: format!("gcloud-{account}"), + login: account, + active: status == "ACTIVE", + metadata: serde_json::json!({ "status": status }), + }); + } + } + } + + Ok(AuthStatus { + provider: "gcloud".into(), + cli_installed: true, + accounts, + }) + } + + async fn login(&self, _scopes: Option<&str>) -> Result { + let child = Command::new("gcloud") + .args(["auth", "login", "--no-browser"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|_| crate::error::Error::GcloudCliNotFound)?; + + let output = child + .wait_with_output() + .await + .map_err(crate::error::Error::Io)?; + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + if output.status.success() { + let status = self.status().await?; + let active = status.accounts.into_iter().find(|a| a.active); + Ok(AuthLoginResult { + id: active.map(|a| a.id), + action: "success".into(), + message: "Google Cloud authentication complete.".into(), + browser_opened: false, + code_copied: false, + }) + } else { + Ok(AuthLoginResult { + id: None, + action: "error".into(), + message: format!("Google Cloud authentication failed: {}", combined.trim()), + browser_opened: false, + code_copied: false, + }) + } + } + + async fn verify(&self, handle: &str) -> Result> { + let account = handle.strip_prefix("gcloud-").unwrap_or(handle); + let status = self.status().await?; + Ok(status.accounts.into_iter().find(|a| a.login == account)) + } + + async fn resolve_env(&self, handle: &str) -> Result> { + let account = handle.strip_prefix("gcloud-").unwrap_or(handle); + let mut env = HashMap::new(); + env.insert("CLOUDSDK_CORE_ACCOUNT".into(), account.to_string()); + Ok(env) + } +} diff --git a/crates/devcontainer-mcp-core/src/auth/github.rs b/crates/devcontainer-mcp-core/src/auth/github.rs new file mode 100644 index 0000000..3a8a3d3 --- /dev/null +++ b/crates/devcontainer-mcp-core/src/auth/github.rs @@ -0,0 +1,155 @@ +use std::collections::HashMap; +use std::process::Stdio; + +use async_trait::async_trait; +use tokio::process::Command; + +use super::{AuthAccount, AuthLoginResult, AuthProvider, AuthStatus}; +use crate::cli::{run_cli, CliBinary}; +use crate::error::Result; + +pub struct GitHubAuth; + +#[async_trait] +impl AuthProvider for GitHubAuth { + fn name(&self) -> &str { + "github" + } + + async fn status(&self) -> Result { + // Check if gh is installed + let output = run_cli( + &CliBinary::Gh, + &["auth", "status", "--json", "hosts"], + false, + ) + .await; + let output = match output { + Ok(o) => o, + Err(_) => { + return Ok(AuthStatus { + provider: "github".into(), + cli_installed: false, + accounts: vec![], + }); + } + }; + + let mut accounts = vec![]; + if let Ok(parsed) = serde_json::from_str::(&output.stdout) { + if let Some(hosts) = parsed.get("hosts").and_then(|h| h.get("github.com")) { + if let Some(arr) = hosts.as_array() { + for entry in arr { + if entry.get("state").and_then(|s| s.as_str()) == Some("success") { + let login = entry + .get("login") + .and_then(|l| l.as_str()) + .unwrap_or("unknown") + .to_string(); + let active = entry + .get("active") + .and_then(|a| a.as_bool()) + .unwrap_or(false); + let scopes = entry + .get("scopes") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + + accounts.push(AuthAccount { + id: format!("github-{login}"), + login, + active, + metadata: serde_json::json!({ "scopes": scopes }), + }); + } + } + } + } + } + + Ok(AuthStatus { + provider: "github".into(), + cli_installed: true, + accounts, + }) + } + + async fn login(&self, scopes: Option<&str>) -> Result { + let mut args = vec!["auth", "login", "-h", "github.com", "-p", "https", "-w"]; + let scope_str; + if let Some(s) = scopes { + scope_str = s.to_string(); + args.push("-s"); + args.push(&scope_str); + } + + // Spawn the login process and read its output for the device code + let child = Command::new("gh") + .args(&args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|_| crate::error::Error::GhCliNotFound)?; + + let output = child + .wait_with_output() + .await + .map_err(crate::error::Error::Io)?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let combined = format!("{stdout}{stderr}"); + + if output.status.success() { + // Try to figure out which account was authenticated + let status = self.status().await?; + let active = status.accounts.into_iter().find(|a| a.active); + let id = active.as_ref().map(|a| a.id.clone()); + + Ok(AuthLoginResult { + id, + action: "success".into(), + message: "Authentication complete.".into(), + browser_opened: true, + code_copied: combined.contains("copied"), + }) + } else { + Ok(AuthLoginResult { + id: None, + action: "error".into(), + message: format!("Authentication failed: {}", combined.trim()), + browser_opened: false, + code_copied: false, + }) + } + } + + async fn verify(&self, handle: &str) -> Result> { + let login = handle.strip_prefix("github-").unwrap_or(handle); + let status = self.status().await?; + Ok(status.accounts.into_iter().find(|a| a.login == login)) + } + + async fn resolve_env(&self, handle: &str) -> Result> { + let login = handle.strip_prefix("github-").unwrap_or(handle); + let output = run_cli( + &CliBinary::Gh, + &["auth", "token", "-h", "github.com", "--user", login], + false, + ) + .await?; + + let token = output.stdout.trim().to_string(); + if token.is_empty() { + return Err(crate::error::Error::Io(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!("Could not get token for GitHub account: {login}"), + ))); + } + + let mut env = HashMap::new(); + env.insert("GH_TOKEN".into(), token); + Ok(env) + } +} diff --git a/crates/devcontainer-mcp-core/src/auth/kubernetes.rs b/crates/devcontainer-mcp-core/src/auth/kubernetes.rs new file mode 100644 index 0000000..13253a1 --- /dev/null +++ b/crates/devcontainer-mcp-core/src/auth/kubernetes.rs @@ -0,0 +1,119 @@ +use std::collections::HashMap; + +use async_trait::async_trait; + +use super::{AuthAccount, AuthLoginResult, AuthProvider, AuthStatus}; +use crate::cli::{run_cli, CliBinary}; +use crate::error::Result; + +pub struct KubernetesAuth; + +#[async_trait] +impl AuthProvider for KubernetesAuth { + fn name(&self) -> &str { + "kubernetes" + } + + async fn status(&self) -> Result { + let output = run_cli( + &CliBinary::Kubectl, + &["config", "get-contexts", "-o", "name"], + false, + ) + .await; + let output = match output { + Ok(o) => o, + Err(_) => { + return Ok(AuthStatus { + provider: "kubernetes".into(), + cli_installed: false, + accounts: vec![], + }); + } + }; + + // Get current context + let current = run_cli(&CliBinary::Kubectl, &["config", "current-context"], false) + .await + .ok() + .map(|o| o.stdout.trim().to_string()) + .unwrap_or_default(); + + let mut accounts = vec![]; + for line in output.stdout.lines() { + let ctx = line.trim(); + if ctx.is_empty() { + continue; + } + accounts.push(AuthAccount { + id: format!("k8s-{ctx}"), + login: ctx.to_string(), + active: ctx == current, + metadata: serde_json::json!({ "context": ctx }), + }); + } + + Ok(AuthStatus { + provider: "kubernetes".into(), + cli_installed: true, + accounts, + }) + } + + async fn login(&self, scopes: Option<&str>) -> Result { + // Kubernetes doesn't have a login command — contexts are configured + // via cloud CLIs (gcloud, az, aws) or kubeconfig files. + // If a context name is provided as scopes, switch to it. + if let Some(context) = scopes { + let output = run_cli( + &CliBinary::Kubectl, + &["config", "use-context", context], + false, + ) + .await?; + if output.exit_code == 0 { + return Ok(AuthLoginResult { + id: Some(format!("k8s-{context}")), + action: "success".into(), + message: format!("Switched to Kubernetes context '{context}'."), + browser_opened: false, + code_copied: false, + }); + } else { + return Ok(AuthLoginResult { + id: None, + action: "error".into(), + message: format!("Failed to switch context: {}", output.stderr.trim()), + browser_opened: false, + code_copied: false, + }); + } + } + + Ok(AuthLoginResult { + id: None, + action: "error".into(), + message: "Kubernetes auth is managed via kubeconfig contexts. \ + Use auth_status(provider: 'kubernetes') to list available contexts, \ + then auth_login(provider: 'kubernetes', scopes: '') to switch." + .into(), + browser_opened: false, + code_copied: false, + }) + } + + async fn verify(&self, handle: &str) -> Result> { + let context = handle.strip_prefix("k8s-").unwrap_or(handle); + let status = self.status().await?; + Ok(status.accounts.into_iter().find(|a| a.login == context)) + } + + async fn resolve_env(&self, handle: &str) -> Result> { + let context = handle.strip_prefix("k8s-").unwrap_or(handle); + let mut env = HashMap::new(); + // Use KUBECONFIG context via --context flag is better, + // but for env-based resolution we can set the variable + env.insert("KUBECTL_CONTEXT".into(), context.to_string()); + Ok(env) + } +} diff --git a/crates/devcontainer-mcp-core/src/auth/mod.rs b/crates/devcontainer-mcp-core/src/auth/mod.rs new file mode 100644 index 0000000..bee38c5 --- /dev/null +++ b/crates/devcontainer-mcp-core/src/auth/mod.rs @@ -0,0 +1,104 @@ +pub mod aws; +pub mod azure; +pub mod gcloud; +pub mod github; +pub mod kubernetes; + +use async_trait::async_trait; +use serde::Serialize; + +/// An auth account/identity discovered from a provider's CLI. +#[derive(Debug, Clone, Serialize)] +pub struct AuthAccount { + /// Opaque handle the agent passes to tools, e.g. "github-aniongithub" + pub id: String, + /// Display name / login + pub login: String, + /// Whether this is the CLI's currently active account + pub active: bool, + /// Provider-specific metadata (scopes, subscription, project, etc.) + pub metadata: serde_json::Value, +} + +/// Result of checking auth status for a provider. +#[derive(Debug, Clone, Serialize)] +pub struct AuthStatus { + pub provider: String, + pub cli_installed: bool, + pub accounts: Vec, +} + +/// Result of an auth_login flow. +#[derive(Debug, Clone, Serialize)] +pub struct AuthLoginResult { + /// The auth handle for the newly authenticated account, if successful. + pub id: Option, + /// What happened: "success", "device_code", "browser", "error" + pub action: String, + /// Human-readable message for the agent to relay. + pub message: String, + /// Whether the browser was opened automatically. + pub browser_opened: bool, + /// Whether a device code was copied to the clipboard. + pub code_copied: bool, +} + +/// Trait implemented by each auth provider (github, aws, azure, etc.) +#[async_trait] +pub trait AuthProvider: Send + Sync { + /// Provider name, e.g. "github", "aws" + fn name(&self) -> &str; + + /// Check which accounts/identities are available. + async fn status(&self) -> crate::error::Result; + + /// Initiate a login flow. May open a browser, copy device codes, etc. + /// `scopes` is provider-specific (e.g. "codespace" for GitHub). + async fn login(&self, scopes: Option<&str>) -> crate::error::Result; + + /// Verify that a handle is still valid and return its account info. + async fn verify(&self, handle: &str) -> crate::error::Result>; + + /// Resolve a handle to the environment variables needed by the subprocess. + /// e.g. github → { "GH_TOKEN": "" } + async fn resolve_env( + &self, + handle: &str, + ) -> crate::error::Result>; +} + +/// Get a provider by name. +pub fn get_provider(name: &str) -> Option> { + match name { + "github" => Some(Box::new(github::GitHubAuth)), + "azure" => Some(Box::new(azure::AzureAuth)), + "aws" => Some(Box::new(aws::AwsAuth)), + "gcloud" => Some(Box::new(gcloud::GcloudAuth)), + "kubernetes" => Some(Box::new(kubernetes::KubernetesAuth)), + _ => None, + } +} + +/// Extract the provider name from a handle (e.g. "github-aniongithub" → "github"). +pub fn provider_from_handle(handle: &str) -> Option<&str> { + handle.split('-').next() +} + +/// Resolve a handle to env vars by looking up the right provider. +pub async fn resolve_handle_env( + handle: &str, +) -> crate::error::Result> { + let provider_name = provider_from_handle(handle).ok_or_else(|| { + crate::error::Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("Invalid auth handle: {handle}"), + )) + })?; + let provider = get_provider(provider_name).ok_or_else(|| { + crate::error::Error::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("Unknown auth provider: {provider_name}"), + )) + })?; + provider.resolve_env(handle).await +} diff --git a/crates/devcontainer-mcp-core/src/cli.rs b/crates/devcontainer-mcp-core/src/cli.rs new file mode 100644 index 0000000..cee82ec --- /dev/null +++ b/crates/devcontainer-mcp-core/src/cli.rs @@ -0,0 +1,106 @@ +use serde::Serialize; +use std::collections::HashMap; +use std::process::Stdio; +use tokio::process::Command; + +use crate::error::{Error, Result}; + +/// Raw output from a CLI invocation (shared across all backends). +#[derive(Debug, Clone, Serialize)] +pub struct CliOutput { + pub exit_code: i32, + pub stdout: String, + pub stderr: String, + /// Parsed JSON from stdout, if applicable. + pub json: Option, +} + +/// Which CLI binary to invoke. +pub enum CliBinary { + DevPod, + Devcontainer, + /// GitHub CLI — the actual binary is `gh`. + Gh, + /// Azure CLI + Az, + /// AWS CLI + Aws, + /// Google Cloud CLI + Gcloud, + /// Kubernetes CLI + Kubectl, +} + +impl CliBinary { + pub fn command_name(&self) -> &'static str { + match self { + CliBinary::DevPod => "devpod", + CliBinary::Devcontainer => "devcontainer", + CliBinary::Gh => "gh", + CliBinary::Az => "az", + CliBinary::Aws => "aws", + CliBinary::Gcloud => "gcloud", + CliBinary::Kubectl => "kubectl", + } + } + + fn not_found_error(&self) -> Error { + match self { + CliBinary::DevPod => Error::DevPodNotFound, + CliBinary::Devcontainer => Error::DevcontainerCliNotFound, + CliBinary::Gh => Error::GhCliNotFound, + CliBinary::Az => Error::AzCliNotFound, + CliBinary::Aws => Error::AwsCliNotFound, + CliBinary::Gcloud => Error::GcloudCliNotFound, + CliBinary::Kubectl => Error::KubectlNotFound, + } + } +} + +/// Run a CLI command, capturing stdout/stderr/exit_code. +/// If `parse_json` is true, attempts to parse stdout as JSON. +pub async fn run_cli(binary: &CliBinary, args: &[&str], parse_json: bool) -> Result { + run_cli_with_env(binary, args, parse_json, None).await +} + +/// Run a CLI command with optional environment variable overrides. +pub async fn run_cli_with_env( + binary: &CliBinary, + args: &[&str], + parse_json: bool, + env: Option<&HashMap>, +) -> Result { + let mut cmd = Command::new(binary.command_name()); + cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); + + if let Some(env_vars) = env { + for (k, v) in env_vars { + cmd.env(k, v); + } + } + + let output = cmd.output().await.map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + binary.not_found_error() + } else { + Error::Io(e) + } + })?; + + 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 json = if parse_json { + serde_json::from_str(&stdout).ok() + } else { + None + }; + + Ok(CliOutput { + exit_code, + stdout, + stderr, + json, + }) +} diff --git a/crates/devcontainer-mcp-core/src/codespaces.rs b/crates/devcontainer-mcp-core/src/codespaces.rs new file mode 100644 index 0000000..0342d95 --- /dev/null +++ b/crates/devcontainer-mcp-core/src/codespaces.rs @@ -0,0 +1,109 @@ +use std::collections::HashMap; + +use crate::cli::{run_cli_with_env, CliBinary, CliOutput}; +use crate::error::Result; + +const LIST_FIELDS: &str = + "name,displayName,state,repository,gitStatus,createdAt,lastUsedAt,machineName"; +const VIEW_FIELDS: &str = "name,displayName,state,owner,location,repository,gitStatus,devcontainerPath,machineName,machineDisplayName,prebuild,createdAt,lastUsedAt,idleTimeoutMinutes,retentionPeriodDays"; +const PORT_FIELDS: &str = "sourcePort,visibility,label,browseUrl"; + +/// Run a `gh codespace` subcommand with auth env vars. +async fn run_gh_cs( + args: &[&str], + parse_json: bool, + env: Option<&HashMap>, +) -> Result { + let mut full_args = vec!["codespace"]; + full_args.extend_from_slice(args); + run_cli_with_env(&CliBinary::Gh, &full_args, parse_json, env).await +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +/// `gh codespace create` — create a new codespace. +pub async fn create( + env: &HashMap, + repo: &str, + branch: Option<&str>, + machine: Option<&str>, + devcontainer_path: Option<&str>, + display_name: Option<&str>, + idle_timeout: Option<&str>, +) -> Result { + let mut args = vec!["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_gh_cs(&args, false, Some(env)).await +} + +/// `gh codespace list` — list codespaces. +pub async fn list(env: &HashMap, repo: Option<&str>) -> Result { + let mut args = vec!["list", "--json", LIST_FIELDS]; + if let Some(r) = repo { + args.push("--repo"); + args.push(r); + } + run_gh_cs(&args, true, Some(env)).await +} + +/// `gh codespace ssh` — execute a command in a codespace. +pub async fn ssh_exec( + env: &HashMap, + codespace: &str, + command: &str, +) -> Result { + let args = vec!["ssh", "-c", codespace, "--", command]; + run_gh_cs(&args, false, Some(env)).await +} + +/// `gh codespace stop` — stop a running codespace. +pub async fn stop(env: &HashMap, codespace: &str) -> Result { + let args = vec!["stop", "-c", codespace]; + run_gh_cs(&args, false, Some(env)).await +} + +/// `gh codespace delete` — delete a codespace. +pub async fn delete( + env: &HashMap, + codespace: &str, + force: bool, +) -> Result { + let mut args = vec!["delete", "-c", codespace]; + if force { + args.push("--force"); + } + run_gh_cs(&args, false, Some(env)).await +} + +/// `gh codespace view` — view codespace details as JSON. +pub async fn view(env: &HashMap, codespace: &str) -> Result { + let args = vec!["view", "-c", codespace, "--json", VIEW_FIELDS]; + run_gh_cs(&args, true, Some(env)).await +} + +/// `gh codespace ports` — list forwarded ports as JSON. +pub async fn ports(env: &HashMap, codespace: &str) -> Result { + let args = vec!["ports", "-c", codespace, "--json", PORT_FIELDS]; + run_gh_cs(&args, true, Some(env)).await +} diff --git a/crates/devcontainer-mcp-core/src/devcontainer.rs b/crates/devcontainer-mcp-core/src/devcontainer.rs new file mode 100644 index 0000000..ed888a3 --- /dev/null +++ b/crates/devcontainer-mcp-core/src/devcontainer.rs @@ -0,0 +1,96 @@ +use crate::cli::{run_cli, CliBinary, CliOutput}; +use crate::docker; +use crate::error::Result; + +/// Run a `devcontainer` CLI command. +async fn run_devcontainer(args: &[&str], parse_json: bool) -> Result { + run_cli(&CliBinary::Devcontainer, args, parse_json).await +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +/// `devcontainer up` — create and start a dev container. +pub async fn up( + workspace_folder: &str, + config: Option<&str>, + extra_args: &[&str], +) -> Result { + let mut args = vec!["up", "--workspace-folder", workspace_folder]; + if let Some(c) = config { + args.push("--config"); + args.push(c); + } + args.extend_from_slice(extra_args); + run_devcontainer(&args, true).await +} + +/// `devcontainer exec` — execute a command in a running dev container. +pub async fn exec( + workspace_folder: &str, + command: &str, + command_args: &[&str], +) -> Result { + let mut args = vec!["exec", "--workspace-folder", workspace_folder, command]; + args.extend_from_slice(command_args); + run_devcontainer(&args, false).await +} + +/// `devcontainer build` — build a dev container image. +pub async fn build(workspace_folder: &str, extra_args: &[&str]) -> Result { + let mut args = vec!["build", "--workspace-folder", workspace_folder]; + args.extend_from_slice(extra_args); + run_devcontainer(&args, true).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 { + args.push("--config"); + args.push(c); + } + args.push("--include-merged-configuration"); + run_devcontainer(&args, true).await +} + +// --------------------------------------------------------------------------- +// Lifecycle via bollard (devcontainer CLI has no stop/down) +// --------------------------------------------------------------------------- + +/// Stop a dev container found by its workspace folder label. +pub async fn stop(workspace_folder: &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}"), + )) + })?; + 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 { + 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}"), + )) + })?; + 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> { + let client = docker::connect()?; + docker::find_container_by_local_folder(&client, workspace_folder).await +} diff --git a/crates/devpod-mcp-core/src/devpod.rs b/crates/devcontainer-mcp-core/src/devpod.rs similarity index 69% rename from crates/devpod-mcp-core/src/devpod.rs rename to crates/devcontainer-mcp-core/src/devpod.rs index 72dbc03..393cfe6 100644 --- a/crates/devpod-mcp-core/src/devpod.rs +++ b/crates/devcontainer-mcp-core/src/devpod.rs @@ -1,52 +1,11 @@ use serde::{Deserialize, Serialize}; -use std::process::Stdio; -use tokio::process::Command; +use crate::cli::{run_cli, CliBinary, CliOutput}; use crate::error::{Error, Result}; -/// Raw output from a DevPod CLI invocation. -#[derive(Debug, Clone, Serialize)] -pub struct DevPodOutput { - pub exit_code: i32, - pub stdout: String, - pub stderr: String, - /// Parsed JSON from stdout, if the command was invoked with --output json. - pub json: Option, -} - /// Run a devpod CLI command with the given args. -/// If `parse_json` is true, attempts to parse stdout as JSON. -async fn run_devpod(args: &[&str], parse_json: bool) -> Result { - let output = Command::new("devpod") - .args(args) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - Error::DevPodNotFound - } else { - Error::Io(e) - } - })?; - - 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 json = if parse_json { - serde_json::from_str(&stdout).ok() - } else { - None - }; - - Ok(DevPodOutput { - exit_code, - stdout, - stderr, - json, - }) +async fn run_devpod(args: &[&str], parse_json: bool) -> Result { + run_cli(&CliBinary::DevPod, args, parse_json).await } /// Check that the `devpod` CLI is available on PATH. @@ -64,19 +23,19 @@ pub async fn check_cli() -> Result { // --------------------------------------------------------------------------- /// `devpod up` — create and start a workspace. -pub async fn up(args: &[&str]) -> Result { +pub async fn up(args: &[&str]) -> Result { let mut cmd_args = vec!["up", "--open-ide=false"]; cmd_args.extend_from_slice(args); run_devpod(&cmd_args, false).await } /// `devpod stop` — stop a workspace. -pub async fn stop(workspace: &str) -> Result { +pub async fn stop(workspace: &str) -> Result { run_devpod(&["stop", workspace], false).await } /// `devpod delete` — delete a workspace. -pub async fn delete(workspace: &str, force: bool) -> Result { +pub async fn delete(workspace: &str, force: bool) -> Result { let mut args = vec!["delete", workspace]; if force { args.push("--force"); @@ -85,7 +44,7 @@ pub async fn delete(workspace: &str, force: bool) -> Result { } /// `devpod build` — build a workspace image. -pub async fn build(args: &[&str]) -> Result { +pub async fn build(args: &[&str]) -> Result { let mut cmd_args = vec!["build"]; cmd_args.extend_from_slice(args); run_devpod(&cmd_args, false).await @@ -105,7 +64,7 @@ pub struct WorkspaceStatus { } /// `devpod status` — get workspace status as JSON. -pub async fn status(workspace: &str, timeout: Option<&str>) -> Result { +pub async fn status(workspace: &str, timeout: Option<&str>) -> Result { let mut args = vec!["status", workspace, "--output", "json"]; if let Some(t) = timeout { args.push("--timeout"); @@ -122,7 +81,7 @@ pub struct WorkspaceListEntry { } /// `devpod list` — list all workspaces as JSON. -pub async fn list() -> Result { +pub async fn list() -> Result { run_devpod(&["list", "--output", "json"], true).await } @@ -136,7 +95,7 @@ pub async fn ssh_exec( command: &str, user: Option<&str>, workdir: Option<&str>, -) -> Result { +) -> Result { let mut args = vec!["ssh", workspace, "--command", command]; if let Some(u) = user { args.push("--user"); @@ -154,7 +113,7 @@ pub async fn ssh_exec( // --------------------------------------------------------------------------- /// `devpod logs` — get workspace logs. -pub async fn logs(workspace: &str) -> Result { +pub async fn logs(workspace: &str) -> Result { run_devpod(&["logs", workspace], false).await } @@ -163,19 +122,19 @@ pub async fn logs(workspace: &str) -> Result { // --------------------------------------------------------------------------- /// `devpod provider list` — list providers. -pub async fn provider_list() -> Result { +pub async fn provider_list() -> Result { run_devpod(&["provider", "list", "--output", "json"], true).await } /// `devpod provider add` — add a provider. -pub async fn provider_add(provider: &str, options: &[&str]) -> Result { +pub async fn provider_add(provider: &str, options: &[&str]) -> Result { let mut args = vec!["provider", "add", provider]; args.extend_from_slice(options); run_devpod(&args, false).await } /// `devpod provider delete` — delete a provider. -pub async fn provider_delete(provider: &str) -> Result { +pub async fn provider_delete(provider: &str) -> Result { run_devpod(&["provider", "delete", provider], false).await } @@ -184,12 +143,12 @@ pub async fn provider_delete(provider: &str) -> Result { // --------------------------------------------------------------------------- /// `devpod context list` — list contexts. -pub async fn context_list() -> Result { +pub async fn context_list() -> Result { run_devpod(&["context", "list", "--output", "json"], true).await } /// `devpod context use` — switch context. -pub async fn context_use(context: &str) -> Result { +pub async fn context_use(context: &str) -> Result { run_devpod(&["context", "use", context], false).await } @@ -198,13 +157,13 @@ pub async fn context_use(context: &str) -> Result { // --------------------------------------------------------------------------- /// `devpod import` — import a workspace. -pub async fn import(args: &[&str]) -> Result { +pub async fn import(args: &[&str]) -> Result { let mut cmd_args = vec!["import"]; cmd_args.extend_from_slice(args); run_devpod(&cmd_args, false).await } /// `devpod export` — export a workspace. -pub async fn export(workspace: &str) -> Result { +pub async fn export(workspace: &str) -> Result { run_devpod(&["export", workspace], false).await } diff --git a/crates/devpod-mcp-core/src/docker.rs b/crates/devcontainer-mcp-core/src/docker.rs similarity index 82% rename from crates/devpod-mcp-core/src/docker.rs rename to crates/devcontainer-mcp-core/src/docker.rs index 47d6c63..48fc878 100644 --- a/crates/devpod-mcp-core/src/docker.rs +++ b/crates/devcontainer-mcp-core/src/docker.rs @@ -1,4 +1,6 @@ -use bollard::container::{ListContainersOptions, LogsOptions}; +use bollard::container::{ + ListContainersOptions, LogsOptions, RemoveContainerOptions, StopContainerOptions, +}; use bollard::Docker; use futures_util::StreamExt; use serde::Serialize; @@ -119,3 +121,25 @@ pub async fn container_logs(docker: &Docker, container_id: &str, tail: usize) -> Ok(output) } + +/// Stop a container by name or ID. +pub async fn stop_container(docker: &Docker, name_or_id: &str) -> Result<()> { + docker + .stop_container(name_or_id, Some(StopContainerOptions { t: 10 })) + .await?; + Ok(()) +} + +/// Remove a container by name or ID. +pub async fn remove_container(docker: &Docker, name_or_id: &str, force: bool) -> Result<()> { + docker + .remove_container( + name_or_id, + Some(RemoveContainerOptions { + force, + ..Default::default() + }), + ) + .await?; + Ok(()) +} diff --git a/crates/devcontainer-mcp-core/src/error.rs b/crates/devcontainer-mcp-core/src/error.rs new file mode 100644 index 0000000..549501c --- /dev/null +++ b/crates/devcontainer-mcp-core/src/error.rs @@ -0,0 +1,40 @@ +use thiserror::Error; + +/// Unified error type for devcontainer-mcp-core. +#[derive(Debug, Error)] +pub enum Error { + #[error("Docker error: {0}")] + Docker(#[from] bollard::errors::Error), + + #[error("DevPod CLI not found. Install from: https://devpod.sh/docs/getting-started/install")] + DevPodNotFound, + + #[error("devcontainer CLI not found. Install with: npm install -g @devcontainers/cli")] + DevcontainerCliNotFound, + + #[error("GitHub CLI (gh) not found. Install from: https://cli.github.com/")] + GhCliNotFound, + + #[error("Azure CLI (az) not found. Install from: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli")] + AzCliNotFound, + + #[error("AWS CLI not found. Install from: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html")] + AwsCliNotFound, + + #[error("Google Cloud CLI (gcloud) not found. Install from: https://cloud.google.com/sdk/docs/install")] + GcloudCliNotFound, + + #[error("kubectl not found. Install from: https://kubernetes.io/docs/tasks/tools/")] + KubectlNotFound, + + #[error("DevPod command failed (exit code {exit_code}): {stderr}")] + DevPodCommand { exit_code: i32, stderr: String }, + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), +} + +pub type Result = std::result::Result; diff --git a/crates/devcontainer-mcp-core/src/lib.rs b/crates/devcontainer-mcp-core/src/lib.rs new file mode 100644 index 0000000..962721a --- /dev/null +++ b/crates/devcontainer-mcp-core/src/lib.rs @@ -0,0 +1,7 @@ +pub mod auth; +pub mod cli; +pub mod codespaces; +pub mod devcontainer; +pub mod devpod; +pub mod docker; +pub mod error; diff --git a/crates/devpod-mcp/Cargo.toml b/crates/devcontainer-mcp/Cargo.toml similarity index 83% rename from crates/devpod-mcp/Cargo.toml rename to crates/devcontainer-mcp/Cargo.toml index a734ab9..d511325 100644 --- a/crates/devpod-mcp/Cargo.toml +++ b/crates/devcontainer-mcp/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "devpod-mcp" +name = "devcontainer-mcp" version = "0.1.0" description = "MCP server and CLI for managing DevContainers" edition.workspace = true @@ -7,7 +7,7 @@ license.workspace = true repository.workspace = true [dependencies] -devpod-mcp-core = { path = "../devpod-mcp-core" } +devcontainer-mcp-core = { path = "../devcontainer-mcp-core" } tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/devpod-mcp/src/main.rs b/crates/devcontainer-mcp/src/main.rs similarity index 87% rename from crates/devpod-mcp/src/main.rs rename to crates/devcontainer-mcp/src/main.rs index d834b20..65c8871 100644 --- a/crates/devpod-mcp/src/main.rs +++ b/crates/devcontainer-mcp/src/main.rs @@ -5,7 +5,7 @@ use rmcp::ServiceExt; use tokio::io::{stdin, stdout}; #[derive(Parser)] -#[command(name = "devpod-mcp")] +#[command(name = "devcontainer-mcp")] #[command(about = "MCP server and CLI for managing DevContainers")] #[command(version)] struct Cli { @@ -30,7 +30,7 @@ async fn main() -> anyhow::Result<()> { match cli.command { Commands::Serve => { - tracing::info!("Starting devpod-mcp MCP server over stdio"); + tracing::info!("Starting devcontainer-mcp MCP server over stdio"); let service = tools::DevContainerMcp::new(); let server = service.serve((stdin(), stdout())).await?; server.waiting().await?; diff --git a/crates/devcontainer-mcp/src/tools.rs b/crates/devcontainer-mcp/src/tools.rs new file mode 100644 index 0000000..20f2850 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools.rs @@ -0,0 +1,772 @@ +use rmcp::model::ServerInfo; +use rmcp::{tool, ServerHandler}; + +use devcontainer_mcp_core::{auth, cli::CliOutput, codespaces, devcontainer, devpod, docker}; + +#[derive(Debug, Clone)] +pub struct DevContainerMcp; + +impl DevContainerMcp { + pub fn new() -> Self { + Self + } +} + +/// Helper: format a CliOutput as a JSON string for MCP responses. +fn format_output(output: &CliOutput) -> String { + serde_json::json!({ + "exit_code": output.exit_code, + "stdout": output.stdout, + "stderr": output.stderr, + "json": output.json, + }) + .to_string() +} + +#[tool(tool_box)] +impl DevContainerMcp { + // ----------------------------------------------------------------------- + // Workspace lifecycle + // ----------------------------------------------------------------------- + + #[tool( + name = "devpod_up", + description = "Create and start a DevPod workspace. Pass the source (git URL, local path, or image) and any flags as space-separated args. Returns full build output for self-healing." + )] + async fn up( + &self, + #[tool(param)] + #[schemars( + description = "All arguments for 'devpod up', e.g. 'https://github.com/org/repo --provider docker --id my-ws'" + )] + args: String, + ) -> String { + let parts: Vec<&str> = args.split_whitespace().collect(); + match devpod::up(&parts).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + #[tool(name = "devpod_stop", description = "Stop a running DevPod workspace.")] + async fn stop( + &self, + #[tool(param)] + #[schemars(description = "Workspace name or ID")] + workspace: String, + ) -> String { + match devpod::stop(&workspace).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devpod_delete", + description = "Delete a DevPod workspace. Stops and removes all associated resources." + )] + async fn delete( + &self, + #[tool(param)] + #[schemars(description = "Workspace name or ID")] + workspace: String, + #[tool(param)] + #[schemars(description = "Force delete even if workspace is not found remotely")] + force: Option, + ) -> String { + match devpod::delete(&workspace, force.unwrap_or(false)).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devpod_build", + description = "Build a DevPod workspace image without starting it." + )] + async fn build( + &self, + #[tool(param)] + #[schemars( + description = "All arguments for 'devpod build', e.g. 'my-workspace --provider docker'" + )] + args: String, + ) -> String { + let parts: Vec<&str> = args.split_whitespace().collect(); + match devpod::build(&parts).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + // ----------------------------------------------------------------------- + // Workspace queries + // ----------------------------------------------------------------------- + + #[tool( + name = "devpod_status", + description = "Get the status of a DevPod workspace. Returns structured JSON with state (Running, Stopped, Busy, NotFound)." + )] + async fn status( + &self, + #[tool(param)] + #[schemars(description = "Workspace name or ID")] + workspace: String, + #[tool(param)] + #[schemars(description = "Timeout for status check, e.g. '30s'")] + timeout: Option, + ) -> String { + match devpod::status(&workspace, timeout.as_deref()).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devpod_list", + description = "List all DevPod workspaces. Returns JSON array with workspace IDs, sources, providers, and status." + )] + async fn list(&self) -> String { + match devpod::list().await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + // ----------------------------------------------------------------------- + // Command execution + // ----------------------------------------------------------------------- + + #[tool( + name = "devpod_ssh", + description = "Execute a command inside a DevPod workspace via SSH. Returns stdout, stderr, and exit code." + )] + async fn ssh( + &self, + #[tool(param)] + #[schemars(description = "Workspace name or ID")] + workspace: String, + #[tool(param)] + #[schemars(description = "Command to execute inside the workspace")] + command: String, + #[tool(param)] + #[schemars(description = "User to run the command as")] + user: Option, + #[tool(param)] + #[schemars(description = "Working directory inside the workspace")] + workdir: Option, + ) -> String { + match devpod::ssh_exec(&workspace, &command, user.as_deref(), workdir.as_deref()).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + // ----------------------------------------------------------------------- + // Logs + // ----------------------------------------------------------------------- + + #[tool( + name = "devpod_logs", + description = "Get logs from a DevPod workspace." + )] + async fn logs( + &self, + #[tool(param)] + #[schemars(description = "Workspace name or ID")] + workspace: String, + ) -> String { + match devpod::logs(&workspace).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + // ----------------------------------------------------------------------- + // Provider management + // ----------------------------------------------------------------------- + + #[tool( + name = "devpod_provider_list", + description = "List all configured DevPod providers." + )] + async fn provider_list(&self) -> String { + match devpod::provider_list().await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + #[tool(name = "devpod_provider_add", description = "Add a DevPod provider.")] + async fn provider_add( + &self, + #[tool(param)] + #[schemars(description = "Provider name or URL to add")] + provider: String, + #[tool(param)] + #[schemars(description = "Additional options as space-separated KEY=VALUE pairs")] + options: Option, + ) -> String { + let opt_parts: Vec<&str> = options + .as_deref() + .map(|o| o.split_whitespace().collect()) + .unwrap_or_default(); + match devpod::provider_add(&provider, &opt_parts).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devpod_provider_delete", + description = "Delete a DevPod provider." + )] + async fn provider_delete( + &self, + #[tool(param)] + #[schemars(description = "Provider name to delete")] + provider: String, + ) -> String { + match devpod::provider_delete(&provider).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + // ----------------------------------------------------------------------- + // Context management + // ----------------------------------------------------------------------- + + #[tool( + name = "devpod_context_list", + description = "List all DevPod contexts." + )] + async fn context_list(&self) -> String { + match devpod::context_list().await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devpod_context_use", + description = "Switch to a different DevPod context." + )] + async fn context_use( + &self, + #[tool(param)] + #[schemars(description = "Context name to switch to")] + context: String, + ) -> String { + match devpod::context_use(&context).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + // ----------------------------------------------------------------------- + // Direct Docker (via bollard) + // ----------------------------------------------------------------------- + + #[tool( + name = "devpod_container_inspect", + description = "Inspect a Docker container directly — returns labels, ports, mounts, and state. Useful for details DevPod CLI doesn't expose." + )] + async fn container_inspect( + &self, + #[tool(param)] + #[schemars(description = "Container name or ID")] + container: String, + ) -> String { + let client = match docker::connect() { + Ok(c) => c, + Err(e) => return format!("Error connecting to Docker: {e}"), + }; + match docker::inspect_container(&client, &container).await { + Ok(info) => serde_json::to_string(&info).unwrap_or_else(|e| format!("Error: {e}")), + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devpod_container_logs", + description = "Get Docker container logs directly via the Docker API. Supports tail parameter for last N lines." + )] + async fn container_logs( + &self, + #[tool(param)] + #[schemars(description = "Container name or ID")] + container: String, + #[tool(param)] + #[schemars(description = "Number of lines from the end to return (0 = all)")] + tail: Option, + ) -> String { + let client = match docker::connect() { + Ok(c) => c, + Err(e) => return format!("Error connecting to Docker: {e}"), + }; + match docker::container_logs(&client, &container, tail.unwrap_or(100)).await { + Ok(logs) => logs, + Err(e) => format!("Error: {e}"), + } + } + + // ======================================================================= + // devcontainer CLI tools + // ======================================================================= + + #[tool( + name = "devcontainer_up", + description = "Create and start a local dev container using the devcontainer CLI. Requires a workspace folder with a devcontainer.json." + )] + async fn devcontainer_up( + &self, + #[tool(param)] + #[schemars( + description = "Path to the workspace folder containing .devcontainer/devcontainer.json" + )] + workspace_folder: String, + #[tool(param)] + #[schemars( + description = "Path to a specific devcontainer.json (overrides auto-detection)" + )] + config: Option, + #[tool(param)] + #[schemars( + description = "Additional flags as space-separated args, e.g. '--remove-existing-container --build-no-cache'" + )] + extra_args: Option, + ) -> String { + let extra: Vec<&str> = extra_args + .as_deref() + .map(|a| a.split_whitespace().collect()) + .unwrap_or_default(); + match devcontainer::up(&workspace_folder, config.as_deref(), &extra).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devcontainer_exec", + description = "Execute a command inside a running local dev container." + )] + async fn devcontainer_exec( + &self, + #[tool(param)] + #[schemars(description = "Path to the workspace folder")] + workspace_folder: String, + #[tool(param)] + #[schemars(description = "Command to execute inside the container")] + command: String, + #[tool(param)] + #[schemars(description = "Arguments for the command as a space-separated string")] + args: Option, + ) -> String { + let cmd_args: Vec<&str> = args + .as_deref() + .map(|a| a.split_whitespace().collect()) + .unwrap_or_default(); + match devcontainer::exec(&workspace_folder, &command, &cmd_args).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devcontainer_build", + description = "Build a dev container image without starting it." + )] + async fn devcontainer_build( + &self, + #[tool(param)] + #[schemars(description = "Path to the workspace folder")] + workspace_folder: String, + #[tool(param)] + #[schemars( + description = "Additional flags as space-separated args, e.g. '--no-cache --image-name my-image'" + )] + extra_args: Option, + ) -> String { + let extra: Vec<&str> = extra_args + .as_deref() + .map(|a| a.split_whitespace().collect()) + .unwrap_or_default(); + match devcontainer::build(&workspace_folder, &extra).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devcontainer_read_config", + description = "Read and return the merged devcontainer configuration as JSON." + )] + async fn devcontainer_read_config( + &self, + #[tool(param)] + #[schemars(description = "Path to the workspace folder")] + workspace_folder: String, + #[tool(param)] + #[schemars(description = "Path to a specific devcontainer.json")] + config: Option, + ) -> String { + match devcontainer::read_configuration(&workspace_folder, config.as_deref()).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devcontainer_stop", + description = "Stop a running local dev container (via Docker). The devcontainer CLI has no stop command, so this uses the Docker API directly." + )] + async fn devcontainer_stop( + &self, + #[tool(param)] + #[schemars( + description = "Path to the workspace folder (used to find the container by label)" + )] + workspace_folder: String, + ) -> String { + match devcontainer::stop(&workspace_folder).await { + Ok(msg) => msg, + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devcontainer_remove", + description = "Remove a local dev container and its resources (via Docker). Stops the container first if running." + )] + async fn devcontainer_remove( + &self, + #[tool(param)] + #[schemars( + description = "Path to the workspace folder (used to find the container by label)" + )] + workspace_folder: String, + #[tool(param)] + #[schemars(description = "Force removal even if the container is running")] + force: Option, + ) -> String { + match devcontainer::remove(&workspace_folder, force.unwrap_or(false)).await { + Ok(msg) => msg, + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devcontainer_status", + description = "Get the status of a local dev container. Returns container info (state, image, labels) or null if not found." + )] + async fn devcontainer_status( + &self, + #[tool(param)] + #[schemars(description = "Path to the workspace folder")] + workspace_folder: String, + ) -> String { + match devcontainer::status(&workspace_folder).await { + Ok(Some(info)) => { + serde_json::to_string(&info).unwrap_or_else(|e| format!("Error: {e}")) + } + Ok(None) => r#"{"state":"NotFound"}"#.to_string(), + Err(e) => format!("Error: {e}"), + } + } + + // ======================================================================= + // Auth tools + // ======================================================================= + + #[tool( + name = "auth_status", + description = "Check authentication status for a provider. Returns available auth handles and account info. Providers: 'github', 'aws', 'azure', 'gcloud', 'kubernetes'." + )] + async fn auth_status( + &self, + #[tool(param)] + #[schemars(description = "Auth provider name (e.g. 'github', 'aws', 'azure', 'gcloud')")] + provider: String, + ) -> String { + match auth::get_provider(&provider) { + Some(p) => match p.status().await { + Ok(status) => { + serde_json::to_string(&status).unwrap_or_else(|e| format!("Error: {e}")) + } + Err(e) => format!("Error: {e}"), + }, + None => format!("Unknown auth provider: {provider}"), + } + } + + #[tool( + name = "auth_login", + description = "Initiate authentication for a provider. Opens browser, copies device code to clipboard, and waits for approval. Returns an auth handle on success." + )] + async fn auth_login( + &self, + #[tool(param)] + #[schemars(description = "Auth provider name (e.g. 'github')")] + provider: String, + #[tool(param)] + #[schemars( + description = "Additional OAuth scopes to request (e.g. 'codespace' for GitHub)" + )] + scopes: Option, + ) -> String { + match auth::get_provider(&provider) { + Some(p) => match p.login(scopes.as_deref()).await { + Ok(result) => { + serde_json::to_string(&result).unwrap_or_else(|e| format!("Error: {e}")) + } + Err(e) => format!("Error: {e}"), + }, + None => format!("Unknown auth provider: {provider}"), + } + } + + #[tool( + name = "auth_select", + description = "Verify that an auth handle is still valid. Returns account info if valid, null if expired/invalid." + )] + async fn auth_select( + &self, + #[tool(param)] + #[schemars(description = "Auth handle to verify (e.g. 'github-aniongithub', 'aws-prod')")] + id: String, + ) -> String { + let provider_name = auth::provider_from_handle(&id).unwrap_or("unknown"); + match auth::get_provider(provider_name) { + Some(p) => match p.verify(&id).await { + Ok(Some(account)) => { + serde_json::to_string(&account).unwrap_or_else(|e| format!("Error: {e}")) + } + Ok(None) => format!("Auth handle not valid: {id}"), + Err(e) => format!("Error: {e}"), + }, + None => format!("Unknown auth provider in handle: {id}"), + } + } + + // ======================================================================= + // GitHub Codespaces tools + // ======================================================================= + + #[tool( + name = "codespaces_create", + description = "Create a new GitHub Codespace for a repository. Requires a GitHub auth handle (get one via auth_status or auth_login)." + )] + #[allow(clippy::too_many_arguments)] + async fn codespaces_create( + &self, + #[tool(param)] + #[schemars( + description = "GitHub auth handle from auth_status/auth_login (e.g. 'github-aniongithub')" + )] + auth: String, + #[tool(param)] + #[schemars(description = "Repository in owner/repo format")] + repo: String, + #[tool(param)] + #[schemars(description = "Branch to create the codespace from")] + branch: Option, + #[tool(param)] + #[schemars( + description = "Machine type — ask the user. Options: 'basicLinux32gb' (2 cores, 8 GB RAM), 'standardLinux32gb' (4 cores, 16 GB RAM), 'premiumLinux' (8 cores, 32 GB RAM), 'largePremiumLinux' (16 cores, 64 GB RAM)" + )] + machine: Option, + #[tool(param)] + #[schemars(description = "Path to devcontainer.json within the repo")] + devcontainer_path: Option, + #[tool(param)] + #[schemars(description = "Display name for the codespace (max 48 chars)")] + display_name: Option, + #[tool(param)] + #[schemars(description = "Idle timeout before auto-stop, e.g. '10m', '1h'")] + idle_timeout: Option, + ) -> String { + let env = match auth::resolve_handle_env(&auth).await { + Ok(e) => e, + Err(e) => return format!("Auth error: {e}"), + }; + match codespaces::create( + &env, + &repo, + branch.as_deref(), + machine.as_deref(), + devcontainer_path.as_deref(), + display_name.as_deref(), + idle_timeout.as_deref(), + ) + .await + { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "codespaces_list", + description = "List your GitHub Codespaces. Requires a GitHub auth handle." + )] + async fn codespaces_list( + &self, + #[tool(param)] + #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] + auth: String, + #[tool(param)] + #[schemars(description = "Filter by repository (owner/repo format)")] + repo: Option, + ) -> String { + let env = match auth::resolve_handle_env(&auth).await { + Ok(e) => e, + Err(e) => return format!("Auth error: {e}"), + }; + match codespaces::list(&env, repo.as_deref()).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "codespaces_ssh", + description = "Execute a command inside a GitHub Codespace via SSH. Requires a GitHub auth handle." + )] + async fn codespaces_ssh( + &self, + #[tool(param)] + #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] + auth: String, + #[tool(param)] + #[schemars(description = "Codespace name (from codespaces_list or codespaces_create)")] + codespace: String, + #[tool(param)] + #[schemars(description = "Command to execute inside the codespace")] + command: String, + ) -> String { + let env = match auth::resolve_handle_env(&auth).await { + Ok(e) => e, + Err(e) => return format!("Auth error: {e}"), + }; + match codespaces::ssh_exec(&env, &codespace, &command).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "codespaces_stop", + description = "Stop a running GitHub Codespace. Requires a GitHub auth handle." + )] + async fn codespaces_stop( + &self, + #[tool(param)] + #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] + auth: String, + #[tool(param)] + #[schemars(description = "Codespace name")] + codespace: String, + ) -> String { + let env = match auth::resolve_handle_env(&auth).await { + Ok(e) => e, + Err(e) => return format!("Auth error: {e}"), + }; + match codespaces::stop(&env, &codespace).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "codespaces_delete", + description = "Delete a GitHub Codespace. Requires a GitHub auth handle." + )] + async fn codespaces_delete( + &self, + #[tool(param)] + #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] + auth: String, + #[tool(param)] + #[schemars(description = "Codespace name")] + codespace: String, + #[tool(param)] + #[schemars(description = "Force delete even with unsaved changes")] + force: Option, + ) -> String { + let env = match auth::resolve_handle_env(&auth).await { + Ok(e) => e, + Err(e) => return format!("Auth error: {e}"), + }; + match codespaces::delete(&env, &codespace, force.unwrap_or(false)).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "codespaces_view", + description = "View detailed information about a GitHub Codespace. Requires a GitHub auth handle." + )] + async fn codespaces_view( + &self, + #[tool(param)] + #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] + auth: String, + #[tool(param)] + #[schemars(description = "Codespace name")] + codespace: String, + ) -> String { + let env = match auth::resolve_handle_env(&auth).await { + Ok(e) => e, + Err(e) => return format!("Auth error: {e}"), + }; + match codespaces::view(&env, &codespace).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "codespaces_ports", + description = "List forwarded ports for a GitHub Codespace. Requires a GitHub auth handle." + )] + async fn codespaces_ports( + &self, + #[tool(param)] + #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] + auth: String, + #[tool(param)] + #[schemars(description = "Codespace name")] + codespace: String, + ) -> String { + let env = match auth::resolve_handle_env(&auth).await { + Ok(e) => e, + Err(e) => return format!("Auth error: {e}"), + }; + match codespaces::ports(&env, &codespace).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} + +#[tool(tool_box)] +impl ServerHandler for DevContainerMcp { + fn get_info(&self) -> ServerInfo { + ServerInfo { + instructions: Some( + "DevContainer MCP — a unified MCP server for managing dev containers across \ + multiple backends. Supports DevPod (devpod_* tools), the devcontainer CLI \ + (devcontainer_* tools), and GitHub Codespaces (codespaces_* tools). \ + Use the appropriate tool prefix based on the backend you want to use." + .into(), + ), + server_info: rmcp::model::Implementation { + name: "devcontainer-mcp".into(), + version: env!("CARGO_PKG_VERSION").into(), + }, + ..Default::default() + } + } +} diff --git a/crates/devpod-mcp-core/src/error.rs b/crates/devpod-mcp-core/src/error.rs deleted file mode 100644 index 473f4ae..0000000 --- a/crates/devpod-mcp-core/src/error.rs +++ /dev/null @@ -1,22 +0,0 @@ -use thiserror::Error; - -/// Unified error type for devpod-mcp-core. -#[derive(Debug, Error)] -pub enum Error { - #[error("Docker error: {0}")] - Docker(#[from] bollard::errors::Error), - - #[error("DevPod CLI not found. Install from: https://devpod.sh/docs/getting-started/install")] - DevPodNotFound, - - #[error("DevPod command failed (exit code {exit_code}): {stderr}")] - DevPodCommand { exit_code: i32, stderr: String }, - - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - - #[error("JSON error: {0}")] - Json(#[from] serde_json::Error), -} - -pub type Result = std::result::Result; diff --git a/crates/devpod-mcp-core/src/lib.rs b/crates/devpod-mcp-core/src/lib.rs deleted file mode 100644 index e7155e7..0000000 --- a/crates/devpod-mcp-core/src/lib.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod devpod; -pub mod docker; -pub mod error; diff --git a/crates/devpod-mcp/src/tools.rs b/crates/devpod-mcp/src/tools.rs deleted file mode 100644 index 2fea9ad..0000000 --- a/crates/devpod-mcp/src/tools.rs +++ /dev/null @@ -1,332 +0,0 @@ -use rmcp::model::ServerInfo; -use rmcp::{tool, ServerHandler}; - -use devpod_mcp_core::{devpod, docker}; - -#[derive(Debug, Clone)] -pub struct DevContainerMcp; - -impl DevContainerMcp { - pub fn new() -> Self { - Self - } -} - -/// Helper: format a DevPodOutput as a JSON string for MCP responses. -fn format_output(output: &devpod::DevPodOutput) -> String { - serde_json::json!({ - "exit_code": output.exit_code, - "stdout": output.stdout, - "stderr": output.stderr, - "json": output.json, - }) - .to_string() -} - -#[tool(tool_box)] -impl DevContainerMcp { - // ----------------------------------------------------------------------- - // Workspace lifecycle - // ----------------------------------------------------------------------- - - #[tool( - name = "devpod_up", - description = "Create and start a DevPod workspace. Pass the source (git URL, local path, or image) and any flags as space-separated args. Returns full build output for self-healing." - )] - async fn up( - &self, - #[tool(param)] - #[schemars( - description = "All arguments for 'devpod up', e.g. 'https://github.com/org/repo --provider docker --id my-ws'" - )] - args: String, - ) -> String { - let parts: Vec<&str> = args.split_whitespace().collect(); - match devpod::up(&parts).await { - Ok(output) => format_output(&output), - Err(e) => format!("Error: {e}"), - } - } - - #[tool(name = "devpod_stop", description = "Stop a running DevPod workspace.")] - async fn stop( - &self, - #[tool(param)] - #[schemars(description = "Workspace name or ID")] - workspace: String, - ) -> String { - match devpod::stop(&workspace).await { - Ok(output) => format_output(&output), - Err(e) => format!("Error: {e}"), - } - } - - #[tool( - name = "devpod_delete", - description = "Delete a DevPod workspace. Stops and removes all associated resources." - )] - async fn delete( - &self, - #[tool(param)] - #[schemars(description = "Workspace name or ID")] - workspace: String, - #[tool(param)] - #[schemars(description = "Force delete even if workspace is not found remotely")] - force: Option, - ) -> String { - match devpod::delete(&workspace, force.unwrap_or(false)).await { - Ok(output) => format_output(&output), - Err(e) => format!("Error: {e}"), - } - } - - #[tool( - name = "devpod_build", - description = "Build a DevPod workspace image without starting it." - )] - async fn build( - &self, - #[tool(param)] - #[schemars( - description = "All arguments for 'devpod build', e.g. 'my-workspace --provider docker'" - )] - args: String, - ) -> String { - let parts: Vec<&str> = args.split_whitespace().collect(); - match devpod::build(&parts).await { - Ok(output) => format_output(&output), - Err(e) => format!("Error: {e}"), - } - } - - // ----------------------------------------------------------------------- - // Workspace queries - // ----------------------------------------------------------------------- - - #[tool( - name = "devpod_status", - description = "Get the status of a DevPod workspace. Returns structured JSON with state (Running, Stopped, Busy, NotFound)." - )] - async fn status( - &self, - #[tool(param)] - #[schemars(description = "Workspace name or ID")] - workspace: String, - #[tool(param)] - #[schemars(description = "Timeout for status check, e.g. '30s'")] - timeout: Option, - ) -> String { - match devpod::status(&workspace, timeout.as_deref()).await { - Ok(output) => format_output(&output), - Err(e) => format!("Error: {e}"), - } - } - - #[tool( - name = "devpod_list", - description = "List all DevPod workspaces. Returns JSON array with workspace IDs, sources, providers, and status." - )] - async fn list(&self) -> String { - match devpod::list().await { - Ok(output) => format_output(&output), - Err(e) => format!("Error: {e}"), - } - } - - // ----------------------------------------------------------------------- - // Command execution - // ----------------------------------------------------------------------- - - #[tool( - name = "devpod_ssh", - description = "Execute a command inside a DevPod workspace via SSH. Returns stdout, stderr, and exit code." - )] - async fn ssh( - &self, - #[tool(param)] - #[schemars(description = "Workspace name or ID")] - workspace: String, - #[tool(param)] - #[schemars(description = "Command to execute inside the workspace")] - command: String, - #[tool(param)] - #[schemars(description = "User to run the command as")] - user: Option, - #[tool(param)] - #[schemars(description = "Working directory inside the workspace")] - workdir: Option, - ) -> String { - match devpod::ssh_exec(&workspace, &command, user.as_deref(), workdir.as_deref()).await { - Ok(output) => format_output(&output), - Err(e) => format!("Error: {e}"), - } - } - - // ----------------------------------------------------------------------- - // Logs - // ----------------------------------------------------------------------- - - #[tool( - name = "devpod_logs", - description = "Get logs from a DevPod workspace." - )] - async fn logs( - &self, - #[tool(param)] - #[schemars(description = "Workspace name or ID")] - workspace: String, - ) -> String { - match devpod::logs(&workspace).await { - Ok(output) => format_output(&output), - Err(e) => format!("Error: {e}"), - } - } - - // ----------------------------------------------------------------------- - // Provider management - // ----------------------------------------------------------------------- - - #[tool( - name = "devpod_provider_list", - description = "List all configured DevPod providers." - )] - async fn provider_list(&self) -> String { - match devpod::provider_list().await { - Ok(output) => format_output(&output), - Err(e) => format!("Error: {e}"), - } - } - - #[tool(name = "devpod_provider_add", description = "Add a DevPod provider.")] - async fn provider_add( - &self, - #[tool(param)] - #[schemars(description = "Provider name or URL to add")] - provider: String, - #[tool(param)] - #[schemars(description = "Additional options as space-separated KEY=VALUE pairs")] - options: Option, - ) -> String { - let opt_parts: Vec<&str> = options - .as_deref() - .map(|o| o.split_whitespace().collect()) - .unwrap_or_default(); - match devpod::provider_add(&provider, &opt_parts).await { - Ok(output) => format_output(&output), - Err(e) => format!("Error: {e}"), - } - } - - #[tool( - name = "devpod_provider_delete", - description = "Delete a DevPod provider." - )] - async fn provider_delete( - &self, - #[tool(param)] - #[schemars(description = "Provider name to delete")] - provider: String, - ) -> String { - match devpod::provider_delete(&provider).await { - Ok(output) => format_output(&output), - Err(e) => format!("Error: {e}"), - } - } - - // ----------------------------------------------------------------------- - // Context management - // ----------------------------------------------------------------------- - - #[tool( - name = "devpod_context_list", - description = "List all DevPod contexts." - )] - async fn context_list(&self) -> String { - match devpod::context_list().await { - Ok(output) => format_output(&output), - Err(e) => format!("Error: {e}"), - } - } - - #[tool( - name = "devpod_context_use", - description = "Switch to a different DevPod context." - )] - async fn context_use( - &self, - #[tool(param)] - #[schemars(description = "Context name to switch to")] - context: String, - ) -> String { - match devpod::context_use(&context).await { - Ok(output) => format_output(&output), - Err(e) => format!("Error: {e}"), - } - } - - // ----------------------------------------------------------------------- - // Direct Docker (via bollard) - // ----------------------------------------------------------------------- - - #[tool( - name = "devpod_container_inspect", - description = "Inspect a Docker container directly — returns labels, ports, mounts, and state. Useful for details DevPod CLI doesn't expose." - )] - async fn container_inspect( - &self, - #[tool(param)] - #[schemars(description = "Container name or ID")] - container: String, - ) -> String { - let client = match docker::connect() { - Ok(c) => c, - Err(e) => return format!("Error connecting to Docker: {e}"), - }; - match docker::inspect_container(&client, &container).await { - Ok(info) => serde_json::to_string(&info).unwrap_or_else(|e| format!("Error: {e}")), - Err(e) => format!("Error: {e}"), - } - } - - #[tool( - name = "devpod_container_logs", - description = "Get Docker container logs directly via the Docker API. Supports tail parameter for last N lines." - )] - async fn container_logs( - &self, - #[tool(param)] - #[schemars(description = "Container name or ID")] - container: String, - #[tool(param)] - #[schemars(description = "Number of lines from the end to return (0 = all)")] - tail: Option, - ) -> String { - let client = match docker::connect() { - Ok(c) => c, - Err(e) => return format!("Error connecting to Docker: {e}"), - }; - match docker::container_logs(&client, &container, tail.unwrap_or(100)).await { - Ok(logs) => logs, - Err(e) => format!("Error: {e}"), - } - } -} - -#[tool(tool_box)] -impl ServerHandler for DevContainerMcp { - fn get_info(&self) -> ServerInfo { - ServerInfo { - instructions: Some( - "DevContainer MCP — wraps the DevPod CLI to give AI agents full control over \ - isolated development environments. Use devpod_list to see workspaces, devpod_up \ - to create one, devpod_ssh to run commands, devpod_stop/devpod_delete for lifecycle." - .into(), - ), - server_info: rmcp::model::Implementation { - name: "devpod-mcp".into(), - version: env!("CARGO_PKG_VERSION").into(), - }, - ..Default::default() - } - } -} diff --git a/install.sh b/install.sh index 43c130d..79a5a2c 100755 --- a/install.sh +++ b/install.sh @@ -1,26 +1,24 @@ #!/usr/bin/env bash set -euo pipefail -# devpod-mcp installer -# Downloads the latest release binary and installs DevPod CLI if not present. +# devcontainer-mcp installer +# Downloads the latest release binary. +# Backend CLIs (devpod, devcontainer, gh) are detected at runtime — +# if missing, the MCP server returns a helpful error message. # # Usage: -# curl -fsSL https://raw.githubusercontent.com/aniongithub/devpod-mcp/main/install.sh | bash +# curl -fsSL https://raw.githubusercontent.com/aniongithub/devcontainer-mcp/main/install.sh | bash # curl -fsSL ... | bash -s -- --install-dir /usr/local/bin -# curl -fsSL ... | bash -s -- --skip-devpod -REPO="aniongithub/devpod-mcp" +REPO="aniongithub/devcontainer-mcp" INSTALL_DIR="${HOME}/.local/bin" -SKIP_DEVPOD=false # Parse args while [[ $# -gt 0 ]]; do case "$1" in - --skip-devpod) SKIP_DEVPOD=true; shift ;; --install-dir) INSTALL_DIR="$2"; shift 2 ;; --help|-h) - echo "Usage: install.sh [--skip-devpod] [--install-dir DIR]" - echo " --skip-devpod Skip automatic DevPod CLI installation" + echo "Usage: install.sh [--install-dir DIR]" echo " --install-dir Installation directory (default: ~/.local/bin)" exit 0 ;; @@ -67,20 +65,20 @@ if [[ -z "$VERSION" ]]; then fi echo "==> Latest version: ${VERSION}" -BINARY_NAME="devpod-mcp-${PLATFORM}" +BINARY_NAME="devcontainer-mcp-${PLATFORM}" DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/${BINARY_NAME}" # Create install directory mkdir -p "$INSTALL_DIR" echo "==> Downloading ${BINARY_NAME}..." -curl -fsSL -o "${INSTALL_DIR}/devpod-mcp" "$DOWNLOAD_URL" -chmod +x "${INSTALL_DIR}/devpod-mcp" -echo "==> Installed devpod-mcp to ${INSTALL_DIR}/devpod-mcp" +curl -fsSL -o "${INSTALL_DIR}/devcontainer-mcp" "$DOWNLOAD_URL" +chmod +x "${INSTALL_DIR}/devcontainer-mcp" +echo "==> Installed devcontainer-mcp to ${INSTALL_DIR}/devcontainer-mcp" # Verify -if "${INSTALL_DIR}/devpod-mcp" --version >/dev/null 2>&1; then - echo "==> $(${INSTALL_DIR}/devpod-mcp --version)" +if "${INSTALL_DIR}/devcontainer-mcp" --version >/dev/null 2>&1; then + echo "==> $(${INSTALL_DIR}/devcontainer-mcp --version)" else echo "Warning: Binary downloaded but failed to run. Check platform compatibility." fi @@ -92,39 +90,12 @@ if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then echo " export PATH=\"${INSTALL_DIR}:\$PATH\"" fi -# Ensure DevPod CLI is available -if command -v devpod >/dev/null 2>&1; then - echo "" - echo "==> DevPod CLI already installed: $(devpod version)" -elif ! $SKIP_DEVPOD; then - echo "" - echo "==> DevPod CLI not found — installing..." - DEVPOD_OS="$(uname -s | tr '[:upper:]' '[:lower:]')" - DEVPOD_ARCH="$(uname -m)" - - case "$DEVPOD_ARCH" in - x86_64|amd64) DEVPOD_ARCH="amd64" ;; - aarch64|arm64) DEVPOD_ARCH="arm64" ;; - esac - - DEVPOD_URL="https://github.com/loft-sh/devpod/releases/latest/download/devpod-${DEVPOD_OS}-${DEVPOD_ARCH}" - curl -fsSL -o "${INSTALL_DIR}/devpod" "$DEVPOD_URL" - chmod +x "${INSTALL_DIR}/devpod" - echo "==> Installed DevPod CLI to ${INSTALL_DIR}/devpod" - echo "==> $(${INSTALL_DIR}/devpod version)" -else - echo "" - echo "Warning: DevPod CLI not found and --skip-devpod was set." - echo "The MCP server requires DevPod to function. Install it from:" - echo " https://devpod.sh/docs/getting-started/install" -fi - # Install SKILL.md for agent discovery SKILL_URL="https://raw.githubusercontent.com/${REPO}/main/SKILL.md" SKILL_DIRS=( - "${HOME}/.copilot/skills/devpod-mcp" - "${HOME}/.claude/skills/devpod-mcp" - "${HOME}/.agents/skills/devpod-mcp" + "${HOME}/.copilot/skills/devcontainer-mcp" + "${HOME}/.claude/skills/devcontainer-mcp" + "${HOME}/.agents/skills/devcontainer-mcp" ) echo "" @@ -135,12 +106,19 @@ for dir in "${SKILL_DIRS[@]}"; do echo " ${dir}/SKILL.md" || true done +# Detect available backends +echo "" +echo "Backend CLIs detected (install as needed — MCP server gives helpful errors if missing):" +command -v devpod >/dev/null 2>&1 && echo " ✓ devpod" || echo " ✗ devpod — https://devpod.sh/docs/getting-started/install" +command -v devcontainer >/dev/null 2>&1 && echo " ✓ devcontainer" || echo " ✗ devcontainer — npm install -g @devcontainers/cli" +command -v gh >/dev/null 2>&1 && echo " ✓ gh (codespaces)" || echo " ✗ gh (codespaces) — https://cli.github.com/" + echo "" echo "Done! Configure your MCP client:" echo ' {' echo ' "mcpServers": {' -echo ' "devpod-mcp": {' -echo " \"command\": \"${INSTALL_DIR}/devpod-mcp\"," +echo ' "devcontainer-mcp": {' +echo " \"command\": \"${INSTALL_DIR}/devcontainer-mcp\"," echo ' "args": ["serve"]' echo ' }' echo ' }'