From d3b24a7074f1e49d1a22ad65b5d8aff641b26919 Mon Sep 17 00:00:00 2001 From: Ani Balasubramaniam Date: Tue, 21 Apr 2026 15:24:55 -0700 Subject: [PATCH 1/7] refactor: rename to devcontainer-mcp, add devcontainer CLI and Codespaces backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename devpod-mcp → devcontainer-mcp across all crates, CI, docs, install script - Add shared CliOutput type and run_cli() helper in cli.rs - Add devcontainer CLI backend (up/exec/build/read-config + bollard stop/remove) - Add GitHub Codespaces backend (create/list/ssh/stop/delete/view/ports) - Add 14 new MCP tools (7 devcontainer_* + 7 codespaces_*), 29 total - Update install.sh with --backends flag for selective backend setup - Update README.md and SKILL.md for all three backends --- .devcontainer/devcontainer.json | 2 +- .github/workflows/ci.yml | 6 +- .github/workflows/release.yml | 20 +- .vscode/tasks.json | 4 +- Cargo.lock | 6 +- Cargo.toml | 6 +- README.md | 114 +++- SKILL.md | 113 +-- .../Cargo.toml | 4 +- crates/devcontainer-mcp-core/src/cli.rs | 76 +++ .../devcontainer-mcp-core/src/codespaces.rs | 94 +++ .../devcontainer-mcp-core/src/devcontainer.rs | 96 +++ .../src/devpod.rs | 77 +-- .../src/docker.rs | 26 +- .../src/error.rs | 8 +- crates/devcontainer-mcp-core/src/lib.rs | 6 + .../Cargo.toml | 4 +- .../src/main.rs | 4 +- crates/devcontainer-mcp/src/tools.rs | 643 ++++++++++++++++++ crates/devpod-mcp-core/src/lib.rs | 3 - crates/devpod-mcp/src/tools.rs | 332 --------- install.sh | 144 ++-- 22 files changed, 1247 insertions(+), 541 deletions(-) rename crates/{devpod-mcp-core => devcontainer-mcp-core}/Cargo.toml (71%) create mode 100644 crates/devcontainer-mcp-core/src/cli.rs create mode 100644 crates/devcontainer-mcp-core/src/codespaces.rs create mode 100644 crates/devcontainer-mcp-core/src/devcontainer.rs rename crates/{devpod-mcp-core => devcontainer-mcp-core}/src/devpod.rs (69%) rename crates/{devpod-mcp-core => devcontainer-mcp-core}/src/docker.rs (82%) rename crates/{devpod-mcp-core => devcontainer-mcp-core}/src/error.rs (67%) create mode 100644 crates/devcontainer-mcp-core/src/lib.rs rename crates/{devpod-mcp => devcontainer-mcp}/Cargo.toml (83%) rename crates/{devpod-mcp => devcontainer-mcp}/src/main.rs (87%) create mode 100644 crates/devcontainer-mcp/src/tools.rs delete mode 100644 crates/devpod-mcp-core/src/lib.rs delete mode 100644 crates/devpod-mcp/src/tools.rs diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f42b4c6..e75957d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,5 +1,5 @@ { - "name": "devpod-mcp", + "name": "devcontainer-mcp", "build": { "dockerfile": "Dockerfile" }, 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/.vscode/tasks.json b/.vscode/tasks.json index 74a5238..b0224c6 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -8,7 +8,7 @@ "args": [ "--release", "-p", - "devpod-mcp" + "devcontainer-mcp" ], "group": { "kind": "build", @@ -22,7 +22,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..4c5c7d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -255,12 +255,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,7 +270,7 @@ dependencies = [ ] [[package]] -name = "devpod-mcp-core" +name = "devcontainer-mcp-core" version = "0.1.0" dependencies = [ "bollard", 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..0d45e15 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,17 @@ -# 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 MCP server + all backend CLIs +curl -fsSL https://raw.githubusercontent.com/aniongithub/devcontainer-mcp/main/install.sh | bash + +# Or pick specific backends +curl -fsSL ... | bash -s -- --backends devpod,codespaces ``` Binaries are available for **linux-x64**, **linux-arm64**, **darwin-x64**, and **darwin-arm64**. @@ -18,62 +20,99 @@ Binaries are available for **linux-x64**, **linux-arm64**, **darwin-x64**, and * 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 +### DevPod (15 tools) + +#### Workspace Lifecycle | Tool | Description | |------|-------------| -| `devpod_up` | Create and start a workspace from a git URL, local path, or image. Returns full build output for self-healing. | +| `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 +121,8 @@ graph TD ```json { "mcpServers": { - "devpod-mcp": { - "command": "devpod-mcp", + "devcontainer-mcp": { + "command": "devcontainer-mcp", "args": ["serve"] } } @@ -95,8 +134,8 @@ graph TD Add to your MCP settings: ```json { - "devpod-mcp": { - "command": "devpod-mcp", + "devcontainer-mcp": { + "command": "devcontainer-mcp", "args": ["serve"] } } @@ -104,16 +143,19 @@ 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) +At least one backend CLI must be installed: + +- **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/) authenticated with `gh auth login` ## 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. @@ -124,16 +166,16 @@ This project eats its own dogfood — development happens inside a DevPod worksp ```bash # Create and start the dev workspace -devpod up . --id devpod-mcp --provider docker --open-ide=false +devpod up . --id devcontainer-mcp --provider docker --open-ide=false # Build inside the workspace -devpod ssh devpod-mcp --command "cd /workspaces/devpod-mcp && cargo build --workspace" +devpod ssh devcontainer-mcp --command "cd /workspaces/devcontainer-mcp && cargo build --workspace" # Run tests -devpod ssh devpod-mcp --command "cd /workspaces/devpod-mcp && cargo test --workspace" +devpod ssh devcontainer-mcp --command "cd /workspaces/devcontainer-mcp && cargo test --workspace" # Build release binary -devpod ssh devpod-mcp --command "cd /workspaces/devpod-mcp && cargo build --release -p devpod-mcp" +devpod ssh devcontainer-mcp --command "cd /workspaces/devcontainer-mcp && cargo build --release -p devcontainer-mcp" ``` ### CI/CD diff --git a/SKILL.md b/SKILL.md index e3a8cff..33016c4 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- -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: - devpod_up - devpod_stop @@ -17,70 +17,107 @@ 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 +## Choosing a Backend -### 1. Check for devcontainer config -Before doing any work on a project, check if `.devcontainer/devcontainer.json` exists. +1. **Local Docker + devcontainer CLI** — simplest for local development +2. **DevPod** — when you need multi-provider support or the project uses DevPod +3. **Codespaces** — when you need cloud-hosted environments or the user prefers GitHub infrastructure -### 2. Create or start the workspace +## 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") +``` -## Inspecting containers +## Workflow: Codespaces -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. +### 1. Create a codespace +``` +codespaces_create(repo: "owner/repo", machine: "basicLinux") +``` + +### 2. Execute commands +``` +codespaces_ssh(codespace: "codespace-name", command: "npm test") +``` + +### 3. Stop when done +``` +codespaces_stop(codespace: "codespace-name") +``` -## Managing multiple workspaces +## Self-Healing -Use `devpod_list` to see all workspaces. Each workspace is independent — you can run multiple projects simultaneously in separate containers. +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 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 71% rename from crates/devpod-mcp-core/Cargo.toml rename to crates/devcontainer-mcp-core/Cargo.toml index ac50007..ac7190b 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 diff --git a/crates/devcontainer-mcp-core/src/cli.rs b/crates/devcontainer-mcp-core/src/cli.rs new file mode 100644 index 0000000..d81d93d --- /dev/null +++ b/crates/devcontainer-mcp-core/src/cli.rs @@ -0,0 +1,76 @@ +use serde::Serialize; +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, +} + +impl CliBinary { + pub fn command_name(&self) -> &'static str { + match self { + CliBinary::DevPod => "devpod", + CliBinary::Devcontainer => "devcontainer", + CliBinary::Gh => "gh", + } + } + + fn not_found_error(&self) -> Error { + match self { + CliBinary::DevPod => Error::DevPodNotFound, + CliBinary::Devcontainer => Error::DevcontainerCliNotFound, + CliBinary::Gh => Error::GhCliNotFound, + } + } +} + +/// 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 { + let output = Command::new(binary.command_name()) + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .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..4834181 --- /dev/null +++ b/crates/devcontainer-mcp-core/src/codespaces.rs @@ -0,0 +1,94 @@ +use crate::cli::{run_cli, 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. +async fn run_gh_cs(args: &[&str], parse_json: bool) -> Result { + let mut full_args = vec!["codespace"]; + full_args.extend_from_slice(args); + run_cli(&CliBinary::Gh, &full_args, parse_json).await +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +/// `gh codespace create` — create a new codespace. +pub async fn create( + 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).await +} + +/// `gh codespace list` — list codespaces. +pub async fn list(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).await +} + +/// `gh codespace ssh` — execute a command in a codespace. +pub async fn ssh_exec(codespace: &str, command: &str) -> Result { + let args = vec!["ssh", "-c", codespace, "--", command]; + run_gh_cs(&args, false).await +} + +/// `gh codespace stop` — stop a running codespace. +pub async fn stop(codespace: &str) -> Result { + let args = vec!["stop", "-c", codespace]; + run_gh_cs(&args, false).await +} + +/// `gh codespace delete` — delete a codespace. +pub async fn delete(codespace: &str, force: bool) -> Result { + let mut args = vec!["delete", "-c", codespace]; + if force { + args.push("--force"); + } + run_gh_cs(&args, false).await +} + +/// `gh codespace view` — view codespace details as JSON. +pub async fn view(codespace: &str) -> Result { + let args = vec!["view", "-c", codespace, "--json", VIEW_FIELDS]; + run_gh_cs(&args, true).await +} + +/// `gh codespace ports` — list forwarded ports as JSON. +pub async fn ports(codespace: &str) -> Result { + let args = vec!["ports", "-c", codespace, "--json", PORT_FIELDS]; + run_gh_cs(&args, true).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/devpod-mcp-core/src/error.rs b/crates/devcontainer-mcp-core/src/error.rs similarity index 67% rename from crates/devpod-mcp-core/src/error.rs rename to crates/devcontainer-mcp-core/src/error.rs index 473f4ae..d43643e 100644 --- a/crates/devpod-mcp-core/src/error.rs +++ b/crates/devcontainer-mcp-core/src/error.rs @@ -1,6 +1,6 @@ use thiserror::Error; -/// Unified error type for devpod-mcp-core. +/// Unified error type for devcontainer-mcp-core. #[derive(Debug, Error)] pub enum Error { #[error("Docker error: {0}")] @@ -9,6 +9,12 @@ pub enum 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("DevPod command failed (exit code {exit_code}): {stderr}")] DevPodCommand { exit_code: i32, stderr: String }, diff --git a/crates/devcontainer-mcp-core/src/lib.rs b/crates/devcontainer-mcp-core/src/lib.rs new file mode 100644 index 0000000..15c731f --- /dev/null +++ b/crates/devcontainer-mcp-core/src/lib.rs @@ -0,0 +1,6 @@ +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..b852569 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools.rs @@ -0,0 +1,643 @@ +use rmcp::model::ServerInfo; +use rmcp::{tool, ServerHandler}; + +use devcontainer_mcp_core::{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}"), + } + } + + // ======================================================================= + // GitHub Codespaces tools + // ======================================================================= + + #[tool( + name = "codespaces_create", + description = "Create a new GitHub Codespace for a repository. Requires the gh CLI authenticated with the codespace scope." + )] + async fn codespaces_create( + &self, + #[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 (e.g. 'basicLinux', 'standardLinux')")] + 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 { + match codespaces::create( + &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. Returns JSON with name, state, repository, and machine info." + )] + async fn codespaces_list( + &self, + #[tool(param)] + #[schemars(description = "Filter by repository (owner/repo format)")] + repo: Option, + ) -> String { + match codespaces::list(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. Returns stdout, stderr, and exit code." + )] + async fn codespaces_ssh( + &self, + #[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 { + match codespaces::ssh_exec(&codespace, &command).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "codespaces_stop", + description = "Stop a running GitHub Codespace." + )] + async fn codespaces_stop( + &self, + #[tool(param)] + #[schemars(description = "Codespace name")] + codespace: String, + ) -> String { + match codespaces::stop(&codespace).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "codespaces_delete", + description = "Delete a GitHub Codespace. Stops it first if running." + )] + async fn codespaces_delete( + &self, + #[tool(param)] + #[schemars(description = "Codespace name")] + codespace: String, + #[tool(param)] + #[schemars(description = "Force delete even with unsaved changes")] + force: Option, + ) -> String { + match codespaces::delete(&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. Returns JSON with state, machine, config, and timing info." + )] + async fn codespaces_view( + &self, + #[tool(param)] + #[schemars(description = "Codespace name")] + codespace: String, + ) -> String { + match codespaces::view(&codespace).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "codespaces_ports", + description = "List forwarded ports for a GitHub Codespace. Returns JSON with port numbers, visibility, and browse URLs." + )] + async fn codespaces_ports( + &self, + #[tool(param)] + #[schemars(description = "Codespace name")] + codespace: String, + ) -> String { + match codespaces::ports(&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/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..6396a0f 100755 --- a/install.sh +++ b/install.sh @@ -1,27 +1,32 @@ #!/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 and installs backend CLIs as needed. # # 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 +# curl -fsSL ... | bash -s -- --backends devpod,codespaces -REPO="aniongithub/devpod-mcp" +REPO="aniongithub/devcontainer-mcp" INSTALL_DIR="${HOME}/.local/bin" -SKIP_DEVPOD=false +BACKENDS="devpod,devcontainer,codespaces" # Parse args while [[ $# -gt 0 ]]; do case "$1" in - --skip-devpod) SKIP_DEVPOD=true; shift ;; + --backends) BACKENDS="$2"; shift 2 ;; --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 [--backends LIST] [--install-dir DIR]" + echo " --backends Comma-separated backends to set up (default: devpod,devcontainer,codespaces)" echo " --install-dir Installation directory (default: ~/.local/bin)" + echo "" + echo "Backends:" + echo " devpod Install DevPod CLI if missing" + echo " devcontainer Install @devcontainers/cli via npm if missing" + echo " codespaces Verify gh CLI is installed and authenticated" exit 0 ;; *) echo "Unknown option: $1"; exit 1 ;; @@ -67,20 +72,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 +97,86 @@ 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)" +# --------------------------------------------------------------------------- +# Backend CLIs +# --------------------------------------------------------------------------- - case "$DEVPOD_ARCH" in - x86_64|amd64) DEVPOD_ARCH="amd64" ;; - aarch64|arm64) DEVPOD_ARCH="arm64" ;; - esac +BACKEND_STATUS=() - 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" +# Helper: check if a backend is requested +has_backend() { + echo ",$BACKENDS," | grep -q ",$1," +} + +# --- DevPod --- +if has_backend "devpod"; then + if command -v devpod >/dev/null 2>&1; then + echo "" + echo "==> DevPod CLI already installed: $(devpod version)" + BACKEND_STATUS+=(" ✓ devpod") + else + 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" + BACKEND_STATUS+=(" ✓ devpod (just installed)") + fi +fi + +# --- devcontainer CLI --- +if has_backend "devcontainer"; then + if command -v devcontainer >/dev/null 2>&1; then + echo "" + echo "==> devcontainer CLI already installed: $(devcontainer --version)" + BACKEND_STATUS+=(" ✓ devcontainer") + elif command -v npm >/dev/null 2>&1; then + echo "" + echo "==> devcontainer CLI not found — installing via npm..." + npm install -g @devcontainers/cli 2>&1 | tail -3 + BACKEND_STATUS+=(" ✓ devcontainer (just installed)") + else + echo "" + echo "Warning: devcontainer CLI not found and npm is not available." + echo "Install Node.js first, then: npm install -g @devcontainers/cli" + BACKEND_STATUS+=(" ✗ devcontainer (npm not found)") + fi +fi + +# --- Codespaces (gh CLI) --- +if has_backend "codespaces"; then + if command -v gh >/dev/null 2>&1; then + if gh auth status >/dev/null 2>&1; then + echo "" + echo "==> gh CLI installed and authenticated" + BACKEND_STATUS+=(" ✓ codespaces") + else + echo "" + echo "Warning: gh CLI found but not authenticated. Run: gh auth login" + BACKEND_STATUS+=(" ✗ codespaces (not authenticated)") + fi + else + echo "" + echo "Warning: gh CLI not found. Install from: https://cli.github.com/" + BACKEND_STATUS+=(" ✗ codespaces (gh not found)") + fi 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 +187,18 @@ for dir in "${SKILL_DIRS[@]}"; do echo " ${dir}/SKILL.md" || true done +echo "" +echo "Backend status:" +for status in "${BACKEND_STATUS[@]}"; do + echo "$status" +done + 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 ' }' From a6ed23841a8ecc6a62d6f0dd0b582f518edae9a9 Mon Sep 17 00:00:00 2001 From: Ani Balasubramaniam Date: Tue, 21 Apr 2026 15:40:39 -0700 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20auto-auth=20flow=20for=20Codespaces?= =?UTF-8?q?=20=E2=80=94=20copies=20device=20code=20to=20clipboard,=20opens?= =?UTF-8?q?=20browser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a codespaces_* tool fails due to a missing OAuth scope, automatically: 1. Run gh auth refresh --clipboard to copy the device code 2. Open the browser to github.com/login/device 3. Return a structured JSON response telling the agent to ask the user to approve --- .../devcontainer-mcp-core/src/codespaces.rs | 96 ++++++++++++++++++- 1 file changed, 94 insertions(+), 2 deletions(-) diff --git a/crates/devcontainer-mcp-core/src/codespaces.rs b/crates/devcontainer-mcp-core/src/codespaces.rs index 4834181..a917a23 100644 --- a/crates/devcontainer-mcp-core/src/codespaces.rs +++ b/crates/devcontainer-mcp-core/src/codespaces.rs @@ -1,3 +1,6 @@ +use std::process::Stdio; +use tokio::process::Command as TokioCommand; + use crate::cli::{run_cli, CliBinary, CliOutput}; use crate::error::Result; @@ -6,11 +9,100 @@ const LIST_FIELDS: &str = 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. +/// Detect whether a CLI output indicates a missing OAuth scope. +fn needs_auth_scope(output: &CliOutput) -> Option { + let combined = format!("{}{}", output.stdout, output.stderr); + if combined.contains("gh auth refresh") || combined.contains("gh auth login") { + // Extract the suggested scope from the error message + // e.g. 'This API operation needs the "codespace" scope.' + if let Some(start) = combined.find("needs the \"") { + let rest = &combined[start + 11..]; + if let Some(end) = rest.find('"') { + return Some(rest[..end].to_string()); + } + } + // Fallback: generic codespace scope + Some("codespace".to_string()) + } else { + None + } +} + +/// Run `gh auth refresh` with --clipboard to copy the device code, +/// then open the browser to the device auth page. +/// Returns a user-friendly message with instructions. +pub async fn request_auth_scope(scope: &str) -> Result { + // Run gh auth refresh with --clipboard to copy device code + let auth_output = run_cli( + &CliBinary::Gh, + &[ + "auth", + "refresh", + "-h", + "github.com", + "-s", + scope, + "--clipboard", + ], + false, + ) + .await?; + + // Try to open the browser to the device auth page + let open_cmd = if cfg!(target_os = "macos") { + "open" + } else { + "xdg-open" + }; + let _ = TokioCommand::new(open_cmd) + .arg("https://github.com/login/device") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + + Ok(auth_output) +} + +/// Run a `gh codespace` subcommand. If the command fails due to a missing +/// OAuth scope, automatically trigger the device-code auth flow. async fn run_gh_cs(args: &[&str], parse_json: bool) -> Result { let mut full_args = vec!["codespace"]; full_args.extend_from_slice(args); - run_cli(&CliBinary::Gh, &full_args, parse_json).await + let output = run_cli(&CliBinary::Gh, &full_args, parse_json).await?; + + if output.exit_code != 0 { + if let Some(scope) = needs_auth_scope(&output) { + let auth_result = request_auth_scope(&scope).await?; + let combined = format!("{}{}", auth_result.stdout, auth_result.stderr); + + // Extract the device code from output + let code_hint = if let Some(pos) = combined.find("one-time code:") { + let rest = &combined[pos..]; + rest.lines().next().unwrap_or("").to_string() + } else { + String::new() + }; + + return Ok(CliOutput { + exit_code: 1, + stdout: String::new(), + stderr: String::new(), + json: Some(serde_json::json!({ + "auth_required": true, + "scope": scope, + "message": format!( + "GitHub auth scope '{}' required. Device code copied to clipboard. \ + Approve in the browser that just opened, then retry the command.", + scope + ), + "detail": code_hint, + "browser_opened": "https://github.com/login/device", + })), + }); + } + } + + Ok(output) } // --------------------------------------------------------------------------- From ab587601fa15b63982e3fc4058c9d9eb190b321e Mon Sep 17 00:00:00 2001 From: Ani Balasubramaniam Date: Tue, 21 Apr 2026 15:48:17 -0700 Subject: [PATCH 3/7] fix: add sshd and gh CLI features to devcontainer for Codespaces SSH support --- .devcontainer/devcontainer.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e75957d..925562a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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", From cf839fca032b3fc8a7a0f7f346db76fa0e4b165b Mon Sep 17 00:00:00 2001 From: Ani Balasubramaniam Date: Tue, 21 Apr 2026 20:42:37 -0700 Subject: [PATCH 4/7] feat: add initializeCommand for host-side GitHub auth Uses the agency_devcontainer pattern: - initialize.sh runs on host, grabs gh token, writes .devcontainer/gh.env - runArgs --env-file injects GH_TOKEN into container - gh.env is gitignored (contains secrets) - Also adds sshd + gh CLI features for Codespaces SSH support --- .devcontainer/devcontainer.json | 4 ++++ .devcontainer/initialize.sh | 32 ++++++++++++++++++++++++++++++++ .gitignore | 1 + 3 files changed, 37 insertions(+) create mode 100755 .devcontainer/initialize.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 925562a..2ef4259 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,6 +3,10 @@ "build": { "dockerfile": "Dockerfile" }, + "runArgs": [ + "--env-file", ".devcontainer/gh.env" + ], + "initializeCommand": ".devcontainer/initialize.sh", "remoteUser": "vscode", "features": { "ghcr.io/devcontainers/features/rust:1": { diff --git a/.devcontainer/initialize.sh b/.devcontainer/initialize.sh new file mode 100755 index 0000000..fd3c62f --- /dev/null +++ b/.devcontainer/initialize.sh @@ -0,0 +1,32 @@ +#!/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. +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..." + +# Try to get token from gh CLI (works with keyring or GH_TOKEN) +GH_TOKEN=$(gh auth token -h github.com 2>/dev/null || true) +if [ -z "${GH_TOKEN}" ]; then + echo "🔑 GitHub CLI login required..." + gh auth login -h github.com -p https -w + GH_TOKEN=$(gh auth token -h github.com 2>/dev/null || true) +fi + +if [ -n "${GH_TOKEN}" ]; then + echo "GH_TOKEN=${GH_TOKEN}" > "${GH_ENV_FILE}" + echo "✅ GitHub token acquired" +else + touch "${GH_ENV_FILE}" + echo "⚠️ Could not acquire GitHub token — codespaces tools won't work" +fi 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/ From 5e0aa36e6d590fcbce672a223e193e2f3003274f Mon Sep 17 00:00:00 2001 From: Ani Balasubramaniam Date: Tue, 21 Apr 2026 22:25:55 -0700 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20auth=20broker=20=E2=80=94=20GitHub?= =?UTF-8?q?=20provider=20+=20Codespaces=20auth=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auth broker design: MCP server manages credentials, agent uses opaque handles. - auth/mod.rs: AuthProvider trait, handle resolution, provider registry - auth/github.rs: gh auth status/login/token integration - cli.rs: run_cli_with_env() for subprocess env overrides - codespaces.rs: all functions take auth env, auto-auth hack removed - tools.rs: 3 new auth tools (auth_status, auth_login, auth_select) + all codespaces_* tools now require auth handle param - devcontainer.json: removed initializeCommand/env-file hacks - tasks.json: fix cargo task type to shell Tested end-to-end: auth_status → codespaces_create → codespaces_list → codespaces_delete --- .devcontainer/devcontainer.json | 4 - .devcontainer/initialize.sh | 80 ++++++++- .vscode/tasks.json | 5 +- Cargo.lock | 12 ++ crates/devcontainer-mcp-core/Cargo.toml | 1 + .../devcontainer-mcp-core/src/auth/github.rs | 155 +++++++++++++++++ crates/devcontainer-mcp-core/src/auth/mod.rs | 100 +++++++++++ crates/devcontainer-mcp-core/src/cli.rs | 40 +++-- .../devcontainer-mcp-core/src/codespaces.rs | 139 ++++----------- crates/devcontainer-mcp-core/src/lib.rs | 1 + crates/devcontainer-mcp/src/tools.rs | 159 ++++++++++++++++-- 11 files changed, 546 insertions(+), 150 deletions(-) create mode 100644 crates/devcontainer-mcp-core/src/auth/github.rs create mode 100644 crates/devcontainer-mcp-core/src/auth/mod.rs diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2ef4259..925562a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,10 +3,6 @@ "build": { "dockerfile": "Dockerfile" }, - "runArgs": [ - "--env-file", ".devcontainer/gh.env" - ], - "initializeCommand": ".devcontainer/initialize.sh", "remoteUser": "vscode", "features": { "ghcr.io/devcontainers/features/rust:1": { diff --git a/.devcontainer/initialize.sh b/.devcontainer/initialize.sh index fd3c62f..28618b5 100755 --- a/.devcontainer/initialize.sh +++ b/.devcontainer/initialize.sh @@ -1,6 +1,8 @@ #!/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)" @@ -15,17 +17,79 @@ fi echo "🔐 Acquiring GitHub token for devcontainer..." -# Try to get token from gh CLI (works with keyring or GH_TOKEN) -GH_TOKEN=$(gh auth token -h github.com 2>/dev/null || true) -if [ -z "${GH_TOKEN}" ]; then - echo "🔑 GitHub CLI login required..." - gh auth login -h github.com -p https -w - GH_TOKEN=$(gh auth token -h github.com 2>/dev/null || true) +# 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 +if [ -n "${GH_TOKEN:-}" ]; then echo "GH_TOKEN=${GH_TOKEN}" > "${GH_ENV_FILE}" - echo "✅ GitHub token acquired" + echo "✅ GitHub token acquired (${ACCOUNT:-unknown})" else touch "${GH_ENV_FILE}" echo "⚠️ Could not acquire GitHub token — codespaces tools won't work" diff --git a/.vscode/tasks.json b/.vscode/tasks.json index b0224c6..ffe6784 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -3,9 +3,10 @@ "tasks": [ { "label": "Build", - "type": "cargo", - "command": "build", + "type": "shell", + "command": "cargo", "args": [ + "build", "--release", "-p", "devcontainer-mcp" diff --git a/Cargo.lock b/Cargo.lock index 4c5c7d0..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" @@ -273,6 +284,7 @@ dependencies = [ name = "devcontainer-mcp-core" version = "0.1.0" dependencies = [ + "async-trait", "bollard", "futures-util", "serde", diff --git a/crates/devcontainer-mcp-core/Cargo.toml b/crates/devcontainer-mcp-core/Cargo.toml index ac7190b..ba6f344 100644 --- a/crates/devcontainer-mcp-core/Cargo.toml +++ b/crates/devcontainer-mcp-core/Cargo.toml @@ -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/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/mod.rs b/crates/devcontainer-mcp-core/src/auth/mod.rs new file mode 100644 index 0000000..b3c2116 --- /dev/null +++ b/crates/devcontainer-mcp-core/src/auth/mod.rs @@ -0,0 +1,100 @@ +pub mod github; + +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)), + // Future: "aws" => Some(Box::new(aws::AwsAuth)), + // Future: "azure" => Some(Box::new(azure::AzureAuth)), + // Future: "gcloud" => Some(Box::new(gcloud::GcloudAuth)), + // Future: "kubernetes" => Some(Box::new(kubernetes::K8sAuth)), + _ => 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 index d81d93d..27e81cf 100644 --- a/crates/devcontainer-mcp-core/src/cli.rs +++ b/crates/devcontainer-mcp-core/src/cli.rs @@ -1,4 +1,5 @@ use serde::Serialize; +use std::collections::HashMap; use std::process::Stdio; use tokio::process::Command; @@ -43,19 +44,32 @@ impl CliBinary { /// 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 { - let output = Command::new(binary.command_name()) - .args(args) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .await - .map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - binary.not_found_error() - } else { - Error::Io(e) - } - })?; + 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(); diff --git a/crates/devcontainer-mcp-core/src/codespaces.rs b/crates/devcontainer-mcp-core/src/codespaces.rs index a917a23..0342d95 100644 --- a/crates/devcontainer-mcp-core/src/codespaces.rs +++ b/crates/devcontainer-mcp-core/src/codespaces.rs @@ -1,7 +1,6 @@ -use std::process::Stdio; -use tokio::process::Command as TokioCommand; +use std::collections::HashMap; -use crate::cli::{run_cli, CliBinary, CliOutput}; +use crate::cli::{run_cli_with_env, CliBinary, CliOutput}; use crate::error::Result; const LIST_FIELDS: &str = @@ -9,100 +8,15 @@ const LIST_FIELDS: &str = 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"; -/// Detect whether a CLI output indicates a missing OAuth scope. -fn needs_auth_scope(output: &CliOutput) -> Option { - let combined = format!("{}{}", output.stdout, output.stderr); - if combined.contains("gh auth refresh") || combined.contains("gh auth login") { - // Extract the suggested scope from the error message - // e.g. 'This API operation needs the "codespace" scope.' - if let Some(start) = combined.find("needs the \"") { - let rest = &combined[start + 11..]; - if let Some(end) = rest.find('"') { - return Some(rest[..end].to_string()); - } - } - // Fallback: generic codespace scope - Some("codespace".to_string()) - } else { - None - } -} - -/// Run `gh auth refresh` with --clipboard to copy the device code, -/// then open the browser to the device auth page. -/// Returns a user-friendly message with instructions. -pub async fn request_auth_scope(scope: &str) -> Result { - // Run gh auth refresh with --clipboard to copy device code - let auth_output = run_cli( - &CliBinary::Gh, - &[ - "auth", - "refresh", - "-h", - "github.com", - "-s", - scope, - "--clipboard", - ], - false, - ) - .await?; - - // Try to open the browser to the device auth page - let open_cmd = if cfg!(target_os = "macos") { - "open" - } else { - "xdg-open" - }; - let _ = TokioCommand::new(open_cmd) - .arg("https://github.com/login/device") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn(); - - Ok(auth_output) -} - -/// Run a `gh codespace` subcommand. If the command fails due to a missing -/// OAuth scope, automatically trigger the device-code auth flow. -async fn run_gh_cs(args: &[&str], parse_json: bool) -> Result { +/// 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); - let output = run_cli(&CliBinary::Gh, &full_args, parse_json).await?; - - if output.exit_code != 0 { - if let Some(scope) = needs_auth_scope(&output) { - let auth_result = request_auth_scope(&scope).await?; - let combined = format!("{}{}", auth_result.stdout, auth_result.stderr); - - // Extract the device code from output - let code_hint = if let Some(pos) = combined.find("one-time code:") { - let rest = &combined[pos..]; - rest.lines().next().unwrap_or("").to_string() - } else { - String::new() - }; - - return Ok(CliOutput { - exit_code: 1, - stdout: String::new(), - stderr: String::new(), - json: Some(serde_json::json!({ - "auth_required": true, - "scope": scope, - "message": format!( - "GitHub auth scope '{}' required. Device code copied to clipboard. \ - Approve in the browser that just opened, then retry the command.", - scope - ), - "detail": code_hint, - "browser_opened": "https://github.com/login/device", - })), - }); - } - } - - Ok(output) + run_cli_with_env(&CliBinary::Gh, &full_args, parse_json, env).await } // --------------------------------------------------------------------------- @@ -111,6 +25,7 @@ async fn run_gh_cs(args: &[&str], parse_json: bool) -> Result { /// `gh codespace create` — create a new codespace. pub async fn create( + env: &HashMap, repo: &str, branch: Option<&str>, machine: Option<&str>, @@ -139,48 +54,56 @@ pub async fn create( args.push("--idle-timeout"); args.push(t); } - run_gh_cs(&args, false).await + run_gh_cs(&args, false, Some(env)).await } /// `gh codespace list` — list codespaces. -pub async fn list(repo: Option<&str>) -> Result { +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).await + run_gh_cs(&args, true, Some(env)).await } /// `gh codespace ssh` — execute a command in a codespace. -pub async fn ssh_exec(codespace: &str, command: &str) -> Result { +pub async fn ssh_exec( + env: &HashMap, + codespace: &str, + command: &str, +) -> Result { let args = vec!["ssh", "-c", codespace, "--", command]; - run_gh_cs(&args, false).await + run_gh_cs(&args, false, Some(env)).await } /// `gh codespace stop` — stop a running codespace. -pub async fn stop(codespace: &str) -> Result { +pub async fn stop(env: &HashMap, codespace: &str) -> Result { let args = vec!["stop", "-c", codespace]; - run_gh_cs(&args, false).await + run_gh_cs(&args, false, Some(env)).await } /// `gh codespace delete` — delete a codespace. -pub async fn delete(codespace: &str, force: bool) -> Result { +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).await + run_gh_cs(&args, false, Some(env)).await } /// `gh codespace view` — view codespace details as JSON. -pub async fn view(codespace: &str) -> Result { +pub async fn view(env: &HashMap, codespace: &str) -> Result { let args = vec!["view", "-c", codespace, "--json", VIEW_FIELDS]; - run_gh_cs(&args, true).await + run_gh_cs(&args, true, Some(env)).await } /// `gh codespace ports` — list forwarded ports as JSON. -pub async fn ports(codespace: &str) -> Result { +pub async fn ports(env: &HashMap, codespace: &str) -> Result { let args = vec!["ports", "-c", codespace, "--json", PORT_FIELDS]; - run_gh_cs(&args, true).await + run_gh_cs(&args, true, Some(env)).await } diff --git a/crates/devcontainer-mcp-core/src/lib.rs b/crates/devcontainer-mcp-core/src/lib.rs index 15c731f..962721a 100644 --- a/crates/devcontainer-mcp-core/src/lib.rs +++ b/crates/devcontainer-mcp-core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod cli; pub mod codespaces; pub mod devcontainer; diff --git a/crates/devcontainer-mcp/src/tools.rs b/crates/devcontainer-mcp/src/tools.rs index b852569..20f2850 100644 --- a/crates/devcontainer-mcp/src/tools.rs +++ b/crates/devcontainer-mcp/src/tools.rs @@ -1,7 +1,7 @@ use rmcp::model::ServerInfo; use rmcp::{tool, ServerHandler}; -use devcontainer_mcp_core::{cli::CliOutput, codespaces, devcontainer, devpod, docker}; +use devcontainer_mcp_core::{auth, cli::CliOutput, codespaces, devcontainer, devpod, docker}; #[derive(Debug, Clone)] pub struct DevContainerMcp; @@ -475,24 +475,106 @@ impl DevContainerMcp { } } + // ======================================================================= + // 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 the gh CLI authenticated with the codespace scope." + 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 (e.g. 'basicLinux', 'standardLinux')")] + #[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")] @@ -504,7 +586,12 @@ impl DevContainerMcp { #[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(), @@ -521,15 +608,22 @@ impl DevContainerMcp { #[tool( name = "codespaces_list", - description = "List your GitHub Codespaces. Returns JSON with name, state, repository, and machine info." + 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 { - match codespaces::list(repo.as_deref()).await { + 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}"), } @@ -537,18 +631,25 @@ impl DevContainerMcp { #[tool( name = "codespaces_ssh", - description = "Execute a command inside a GitHub Codespace via SSH. Returns stdout, stderr, and exit code." + 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 { - match codespaces::ssh_exec(&codespace, &command).await { + 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}"), } @@ -556,15 +657,22 @@ impl DevContainerMcp { #[tool( name = "codespaces_stop", - description = "Stop a running GitHub Codespace." + 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 { - match codespaces::stop(&codespace).await { + 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}"), } @@ -572,18 +680,25 @@ impl DevContainerMcp { #[tool( name = "codespaces_delete", - description = "Delete a GitHub Codespace. Stops it first if running." + 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 { - match codespaces::delete(&codespace, force.unwrap_or(false)).await { + 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}"), } @@ -591,15 +706,22 @@ impl DevContainerMcp { #[tool( name = "codespaces_view", - description = "View detailed information about a GitHub Codespace. Returns JSON with state, machine, config, and timing info." + 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 { - match codespaces::view(&codespace).await { + 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}"), } @@ -607,15 +729,22 @@ impl DevContainerMcp { #[tool( name = "codespaces_ports", - description = "List forwarded ports for a GitHub Codespace. Returns JSON with port numbers, visibility, and browse URLs." + 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 { - match codespaces::ports(&codespace).await { + 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}"), } From 9ebe67b41431bbc2abe01f8d4e7b16a9a628ecb8 Mon Sep 17 00:00:00 2001 From: Ani Balasubramaniam Date: Tue, 21 Apr 2026 22:31:34 -0700 Subject: [PATCH 6/7] docs: update README, SKILL.md, install.sh for auth broker - SKILL.md: add auth tools, auth workflow, machine type guidance - README.md: add auth tools section, update prerequisites and install - install.sh: simplify to just binary + SKILL.md, detect backends at runtime --- README.md | 21 ++++++++---- SKILL.md | 57 ++++++++++++++++++++++++++----- install.sh | 98 +++++------------------------------------------------- 3 files changed, 73 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index 0d45e15..a8c5772 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,12 @@ A unified MCP server that gives AI coding agents full control over dev container ## Quick Install ```bash -# Install MCP server + all backend CLIs +# Install the MCP server binary curl -fsSL https://raw.githubusercontent.com/aniongithub/devcontainer-mcp/main/install.sh | bash - -# Or pick specific backends -curl -fsSL ... | bash -s -- --backends devpod,codespaces ``` +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? @@ -53,6 +52,16 @@ graph TD ## MCP Tools +### Auth (3 tools) + +| Tool | Description | +|------|-------------| +| `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 @@ -143,11 +152,11 @@ Add to your MCP settings: ## Prerequisites -At least one backend CLI must be installed: +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/) authenticated with `gh auth login` +- **Codespaces**: [GitHub CLI](https://cli.github.com/) — auth is handled by the `auth_login` tool ## Self-Healing Loop diff --git a/SKILL.md b/SKILL.md index 33016c4..717f541 100644 --- a/SKILL.md +++ b/SKILL.md @@ -2,6 +2,9 @@ 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 @@ -44,11 +47,39 @@ You have access to `devcontainer-mcp`, an MCP server that manages dev container **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.** +## Authentication + +**Before using Codespaces tools, you MUST obtain an auth handle.** + +### 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 +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 or the user prefers GitHub infrastructure +3. **Codespaces** — when you need cloud-hosted environments (requires GitHub auth) ## Workflow: DevPod @@ -91,19 +122,27 @@ devcontainer_stop(workspace_folder: "/path/to/project") ## Workflow: Codespaces -### 1. Create a codespace +### 1. Authenticate ``` -codespaces_create(repo: "owner/repo", machine: "basicLinux") +auth_status(provider: "github") +# If no accounts: auth_login(provider: "github", scopes: "codespace") +# If multiple: ask the user which account ``` -### 2. Execute commands +### 2. Create a codespace — ask user for machine type ``` -codespaces_ssh(codespace: "codespace-name", command: "npm test") +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) -### 3. Stop when done +### 3. Execute commands +``` +codespaces_ssh(auth: "github-USERNAME", codespace: "codespace-name", command: "npm test") +``` + +### 4. Stop when done ``` -codespaces_stop(codespace: "codespace-name") +codespaces_stop(auth: "github-USERNAME", codespace: "codespace-name") ``` ## Self-Healing @@ -119,5 +158,7 @@ If `devpod_up`, `devcontainer_up`, or `codespaces_create` returns errors: - ❌ 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/install.sh b/install.sh index 6396a0f..79a5a2c 100755 --- a/install.sh +++ b/install.sh @@ -2,31 +2,24 @@ set -euo pipefail # devcontainer-mcp installer -# Downloads the latest release binary and installs backend CLIs as needed. +# 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/devcontainer-mcp/main/install.sh | bash # curl -fsSL ... | bash -s -- --install-dir /usr/local/bin -# curl -fsSL ... | bash -s -- --backends devpod,codespaces REPO="aniongithub/devcontainer-mcp" INSTALL_DIR="${HOME}/.local/bin" -BACKENDS="devpod,devcontainer,codespaces" # Parse args while [[ $# -gt 0 ]]; do case "$1" in - --backends) BACKENDS="$2"; shift 2 ;; --install-dir) INSTALL_DIR="$2"; shift 2 ;; --help|-h) - echo "Usage: install.sh [--backends LIST] [--install-dir DIR]" - echo " --backends Comma-separated backends to set up (default: devpod,devcontainer,codespaces)" + echo "Usage: install.sh [--install-dir DIR]" echo " --install-dir Installation directory (default: ~/.local/bin)" - echo "" - echo "Backends:" - echo " devpod Install DevPod CLI if missing" - echo " devcontainer Install @devcontainers/cli via npm if missing" - echo " codespaces Verify gh CLI is installed and authenticated" exit 0 ;; *) echo "Unknown option: $1"; exit 1 ;; @@ -97,80 +90,6 @@ if ! echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then echo " export PATH=\"${INSTALL_DIR}:\$PATH\"" fi -# --------------------------------------------------------------------------- -# Backend CLIs -# --------------------------------------------------------------------------- - -BACKEND_STATUS=() - -# Helper: check if a backend is requested -has_backend() { - echo ",$BACKENDS," | grep -q ",$1," -} - -# --- DevPod --- -if has_backend "devpod"; then - if command -v devpod >/dev/null 2>&1; then - echo "" - echo "==> DevPod CLI already installed: $(devpod version)" - BACKEND_STATUS+=(" ✓ devpod") - else - 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" - BACKEND_STATUS+=(" ✓ devpod (just installed)") - fi -fi - -# --- devcontainer CLI --- -if has_backend "devcontainer"; then - if command -v devcontainer >/dev/null 2>&1; then - echo "" - echo "==> devcontainer CLI already installed: $(devcontainer --version)" - BACKEND_STATUS+=(" ✓ devcontainer") - elif command -v npm >/dev/null 2>&1; then - echo "" - echo "==> devcontainer CLI not found — installing via npm..." - npm install -g @devcontainers/cli 2>&1 | tail -3 - BACKEND_STATUS+=(" ✓ devcontainer (just installed)") - else - echo "" - echo "Warning: devcontainer CLI not found and npm is not available." - echo "Install Node.js first, then: npm install -g @devcontainers/cli" - BACKEND_STATUS+=(" ✗ devcontainer (npm not found)") - fi -fi - -# --- Codespaces (gh CLI) --- -if has_backend "codespaces"; then - if command -v gh >/dev/null 2>&1; then - if gh auth status >/dev/null 2>&1; then - echo "" - echo "==> gh CLI installed and authenticated" - BACKEND_STATUS+=(" ✓ codespaces") - else - echo "" - echo "Warning: gh CLI found but not authenticated. Run: gh auth login" - BACKEND_STATUS+=(" ✗ codespaces (not authenticated)") - fi - else - echo "" - echo "Warning: gh CLI not found. Install from: https://cli.github.com/" - BACKEND_STATUS+=(" ✗ codespaces (gh not found)") - fi -fi - # Install SKILL.md for agent discovery SKILL_URL="https://raw.githubusercontent.com/${REPO}/main/SKILL.md" SKILL_DIRS=( @@ -187,11 +106,12 @@ for dir in "${SKILL_DIRS[@]}"; do echo " ${dir}/SKILL.md" || true done +# Detect available backends echo "" -echo "Backend status:" -for status in "${BACKEND_STATUS[@]}"; do - echo "$status" -done +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:" From c47e16fd960bfbc723f9ff7f88af3e3639150b93 Mon Sep 17 00:00:00 2001 From: Ani Balasubramaniam Date: Tue, 21 Apr 2026 22:52:54 -0700 Subject: [PATCH 7/7] feat: add Azure, AWS, GCP, and Kubernetes auth providers - auth/azure.rs: az account list/login --use-device-code - auth/aws.rs: aws sts get-caller-identity, sso login, profile switching - auth/gcloud.rs: gcloud auth list/login --no-browser - auth/kubernetes.rs: kubectl config get-contexts/use-context - cli.rs: add Az, Aws, Gcloud, Kubectl binary variants - error.rs: add AzCliNotFound, AwsCliNotFound, GcloudCliNotFound, KubectlNotFound --- README.md | 18 +- crates/devcontainer-mcp-core/src/auth/aws.rs | 179 ++++++++++++++++++ .../devcontainer-mcp-core/src/auth/azure.rs | 134 +++++++++++++ .../devcontainer-mcp-core/src/auth/gcloud.rs | 120 ++++++++++++ .../src/auth/kubernetes.rs | 119 ++++++++++++ crates/devcontainer-mcp-core/src/auth/mod.rs | 12 +- crates/devcontainer-mcp-core/src/cli.rs | 16 ++ crates/devcontainer-mcp-core/src/error.rs | 12 ++ 8 files changed, 596 insertions(+), 14 deletions(-) create mode 100644 crates/devcontainer-mcp-core/src/auth/aws.rs create mode 100644 crates/devcontainer-mcp-core/src/auth/azure.rs create mode 100644 crates/devcontainer-mcp-core/src/auth/gcloud.rs create mode 100644 crates/devcontainer-mcp-core/src/auth/kubernetes.rs diff --git a/README.md b/README.md index a8c5772..af67a80 100644 --- a/README.md +++ b/README.md @@ -171,20 +171,18 @@ This makes the dev environment a **dynamic, agent-managed asset** rather than a ## 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 devcontainer-mcp --provider docker --open-ide=false +# 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 -# Build inside the workspace +# 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" - -# Run tests -devpod ssh devcontainer-mcp --command "cd /workspaces/devcontainer-mcp && cargo test --workspace" - -# Build release binary -devpod ssh devcontainer-mcp --command "cd /workspaces/devcontainer-mcp && cargo build --release -p devcontainer-mcp" ``` ### CI/CD 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/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 index b3c2116..bee38c5 100644 --- a/crates/devcontainer-mcp-core/src/auth/mod.rs +++ b/crates/devcontainer-mcp-core/src/auth/mod.rs @@ -1,4 +1,8 @@ +pub mod aws; +pub mod azure; +pub mod gcloud; pub mod github; +pub mod kubernetes; use async_trait::async_trait; use serde::Serialize; @@ -67,10 +71,10 @@ pub trait AuthProvider: Send + Sync { pub fn get_provider(name: &str) -> Option> { match name { "github" => Some(Box::new(github::GitHubAuth)), - // Future: "aws" => Some(Box::new(aws::AwsAuth)), - // Future: "azure" => Some(Box::new(azure::AzureAuth)), - // Future: "gcloud" => Some(Box::new(gcloud::GcloudAuth)), - // Future: "kubernetes" => Some(Box::new(kubernetes::K8sAuth)), + "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, } } diff --git a/crates/devcontainer-mcp-core/src/cli.rs b/crates/devcontainer-mcp-core/src/cli.rs index 27e81cf..cee82ec 100644 --- a/crates/devcontainer-mcp-core/src/cli.rs +++ b/crates/devcontainer-mcp-core/src/cli.rs @@ -21,6 +21,14 @@ pub enum CliBinary { Devcontainer, /// GitHub CLI — the actual binary is `gh`. Gh, + /// Azure CLI + Az, + /// AWS CLI + Aws, + /// Google Cloud CLI + Gcloud, + /// Kubernetes CLI + Kubectl, } impl CliBinary { @@ -29,6 +37,10 @@ impl CliBinary { CliBinary::DevPod => "devpod", CliBinary::Devcontainer => "devcontainer", CliBinary::Gh => "gh", + CliBinary::Az => "az", + CliBinary::Aws => "aws", + CliBinary::Gcloud => "gcloud", + CliBinary::Kubectl => "kubectl", } } @@ -37,6 +49,10 @@ impl CliBinary { 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, } } } diff --git a/crates/devcontainer-mcp-core/src/error.rs b/crates/devcontainer-mcp-core/src/error.rs index d43643e..549501c 100644 --- a/crates/devcontainer-mcp-core/src/error.rs +++ b/crates/devcontainer-mcp-core/src/error.rs @@ -15,6 +15,18 @@ pub enum Error { #[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 },