From 3b18c7ab3ef3df312b5e517a6318c8b131f9ac13 Mon Sep 17 00:00:00 2001 From: Ani Balasubramaniam Date: Wed, 22 Apr 2026 10:22:04 -0700 Subject: [PATCH] feat: auth_logout, fix auth_select, clean up env handling, improve docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth_logout: new tool + trait method for all 5 providers - auth_select: now actually switches active account (was just verify) - GitHub auth: explicit GH_TOKEN='' via run_cli_with_env, removed run_cli_clean_auth - GitHub login: auto-detects existing accounts, uses gh auth refresh - README: complete rewrite — clearer value prop, problem/solution framing, updated architecture diagram, auth broker docs, 33 tools - SKILL.md: added auth_logout to tool list --- README.md | 155 +++++++++--------- SKILL.md | 1 + crates/devcontainer-mcp-core/src/auth/aws.rs | 21 ++- .../devcontainer-mcp-core/src/auth/azure.rs | 25 ++- .../devcontainer-mcp-core/src/auth/gcloud.rs | 21 ++- .../devcontainer-mcp-core/src/auth/github.rs | 130 ++++++++++++--- .../src/auth/kubernetes.rs | 31 +++- crates/devcontainer-mcp-core/src/auth/mod.rs | 7 +- crates/devcontainer-mcp/src/tools.rs | 32 +++- 9 files changed, 304 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index af67a80..6050419 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,47 @@ [![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. +**Give your AI agent its own dev environment — not yours.** + +`devcontainer-mcp` is an MCP server that lets AI coding agents create, manage, and work inside [dev containers](https://containers.dev/) across three backends: local Docker, [DevPod](https://devpod.sh/), and [GitHub Codespaces](https://github.com/features/codespaces). The agent builds, tests, and ships code in an isolated container — your laptop stays clean. + +## The Problem + +When AI agents write code, they need to run it somewhere. Today that means your host machine: + +- 🔴 **Host contamination** — agents install packages, modify PATH, leave behind build artifacts +- 🔴 **"Works on my machine"** — agents assume your local toolchain matches production +- 🔴 **No isolation** — one project's dependencies break another +- 🔴 **Security risk** — agents run arbitrary commands with your user privileges + +## The Solution + +The [devcontainer spec](https://containers.dev/) already defines reproducible, container-based dev environments. Every major project ships a `.devcontainer/devcontainer.json`. But AI agents can't use them — until now. + +`devcontainer-mcp` exposes **33 MCP tools** that let any AI agent: + +1. **Spin up** a dev container from any repo — locally, on a cloud VM, or in Codespaces +2. **Run commands** inside the container — builds, tests, linting, anything +3. **Manage the lifecycle** — stop, restart, delete when done +4. **Authenticate** against cloud providers — GitHub, AWS, Azure, GCP — without ever seeing a raw token + +``` +Agent: "Let me build this project..." + → auth_status("github") → picks account + → codespaces_create(auth: "github-you", repo: "your/repo") + → codespaces_ssh(auth: "github-you", codespace: "...", command: "cargo build") + → ✅ Built in the cloud. Your laptop did nothing. +``` ## Quick Install ```bash -# Install the MCP server binary curl -fsSL https://raw.githubusercontent.com/aniongithub/devcontainer-mcp/main/install.sh | bash ``` -Backend CLIs (`devpod`, `devcontainer`, `gh`) are detected at runtime — if one is missing, the MCP server returns a helpful error telling you how to install it. +Backend CLIs (`devpod`, `devcontainer`, `gh`) are detected at runtime — if one is missing, the MCP server returns a helpful error with install instructions. -Binaries are available for **linux-x64**, **linux-arm64**, **darwin-x64**, and **darwin-arm64**. - -## Why? - -AI coding agents suffer from **Host Contamination** and **Context Drift**. They install packages on the host, assume local dependencies exist, and produce code that works "on my machine" but fails in production. - -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. +Binaries available for **linux-x64**, **linux-arm64**, **darwin-x64**, and **darwin-arm64**. ## Architecture @@ -28,43 +51,56 @@ graph TD A[AI Agent / MCP Client] -->|stdio JSON-RPC| B[devcontainer-mcp] subgraph "devcontainer-mcp" - B --> C[29 MCP Tools] - C --> D[devcontainer-mcp-core] + B --> C[33 MCP Tools] + C --> D[Auth Broker] + C --> E[devcontainer-mcp-core] end - D -->|subprocess| E[DevPod CLI] - D -->|subprocess| F[devcontainer CLI] - D -->|subprocess| G[gh CLI] - D -->|bollard API| H[Docker Engine] + D -->|opaque handles| C + E -->|subprocess| F[DevPod CLI] + E -->|subprocess| G[devcontainer CLI] + E -->|subprocess| H[gh CLI] + E -->|bollard API| I[Docker Engine] - E --> I[Docker / K8s / Cloud VMs] - F --> J[Local Docker] - G --> K[GitHub Codespaces] + F --> J[Docker / K8s / Cloud VMs] + G --> K[Local Docker] + H --> L[GitHub Codespaces] ``` -## Backends +## Three Backends, One Interface + +| Backend | Best for | Requires | Auth needed? | +|---------|----------|----------|:---:| +| **devcontainer CLI** (`devcontainer_*`) | Local Docker — fast, simple | [@devcontainers/cli](https://github.com/devcontainers/cli) + Docker | No | +| **DevPod** (`devpod_*`) | Multi-cloud: Docker, K8s, AWS, Azure, GCP | [DevPod CLI](https://devpod.sh) | Optional (cloud providers) | +| **Codespaces** (`codespaces_*`) | GitHub-hosted cloud environments | [gh CLI](https://cli.github.com/) | Yes (`auth` handle) | + +## Auth Broker + +The agent never sees raw tokens. Instead: -| 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 | +1. **`auth_status(provider)`** — list available accounts and scopes +2. **`auth_login(provider, scopes?)`** — initiate login, opens browser, handles device codes +3. **`auth_select(id)`** — switch the active account +4. **`auth_logout(id)`** — revoke credentials -## MCP Tools +Codespaces tools require an auth handle (e.g. `"github-aniongithub"`). The MCP server resolves it to the real token on each call via the CLI's native keyring. -### Auth (3 tools) +Supported providers: **GitHub**, **AWS**, **Azure**, **GCP**, **Kubernetes** + +## MCP Tools (33 total) + +### Auth (4 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. +| `auth_status` | Check auth for a provider — returns handles, accounts, scopes | +| `auth_login` | Initiate login or refresh scopes — browser + device code flow | +| `auth_select` | Switch the active account for a provider | +| `auth_logout` | Revoke credentials for an account | ### DevPod (15 tools) -#### Workspace Lifecycle | Tool | Description | |------|-------------| | `devpod_up` | Create and start a workspace from a git URL, local path, or image | @@ -73,37 +109,21 @@ Codespaces tools require a GitHub auth handle (e.g. `"github-aniongithub"`). Get | `devpod_build` | Build a workspace image without starting it | | `devpod_status` | Get workspace state (`Running`, `Stopped`, `Busy`, `NotFound`) | | `devpod_list` | List all workspaces with IDs, sources, providers, and status | - -#### Command Execution -| Tool | Description | -|------|-------------| | `devpod_ssh` | Execute a command inside a workspace via SSH | - -#### Provider Management -| Tool | Description | -|------|-------------| +| `devpod_logs` | Get workspace logs | | `devpod_provider_list` | List all configured providers | | `devpod_provider_add` | Add a new provider | | `devpod_provider_delete` | Remove a provider | - -#### Context Management -| Tool | Description | -|------|-------------| | `devpod_context_list` | List all contexts | | `devpod_context_use` | Switch to a different context | - -#### Logs & Docker -| Tool | Description | -|------|-------------| -| `devpod_logs` | Get workspace logs | -| `devpod_container_inspect` | Direct Docker inspect for labels, ports, mounts | +| `devpod_container_inspect` | Docker inspect — labels, ports, mounts, state | | `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_up` | Create and start a local dev container | | `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 | @@ -111,7 +131,7 @@ Codespaces tools require a GitHub auth handle (e.g. `"github-aniongithub"`). Get | `devcontainer_remove` | Remove a dev container and its resources | | `devcontainer_status` | Get dev container state by workspace folder | -### GitHub Codespaces (7 tools) +### GitHub Codespaces (7 tools) — require `auth` handle | Tool | Description | |------|-------------| @@ -125,7 +145,7 @@ Codespaces tools require a GitHub auth handle (e.g. `"github-aniongithub"`). Get ## MCP Server Configuration -### Claude Desktop +### Claude Desktop / Copilot / Cursor ```json { @@ -138,36 +158,17 @@ Codespaces tools require a GitHub auth handle (e.g. `"github-aniongithub"`). Get } ``` -### Cursor - -Add to your MCP settings: -```json -{ - "devcontainer-mcp": { - "command": "devcontainer-mcp", - "args": ["serve"] - } -} -``` - ## Prerequisites 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/) +- **DevPod**: [DevPod CLI](https://devpod.sh/docs/getting-started/install) + Docker (or another provider) - **Codespaces**: [GitHub CLI](https://cli.github.com/) — auth is handled by the `auth_login` tool -## Self-Healing Loop - -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 the up command again -4. Repeat until the environment builds successfully +## Self-Healing -This makes the dev environment a **dynamic, agent-managed asset** rather than a static prerequisite. +When `devcontainer_up`, `devpod_up`, or `codespaces_create` fails, the full build output (including errors) is returned to the agent. The agent can read the error, fix the `Dockerfile` or `devcontainer.json`, and retry — making the dev environment a **dynamic, agent-managed asset** rather than a static prerequisite. ## Development @@ -188,7 +189,7 @@ devpod ssh devcontainer-mcp --command "cd /workspaces/devcontainer-mcp && cargo ### CI/CD - **Pull Requests** — `cargo check`, `cargo test`, `cargo clippy`, `cargo fmt` run automatically -- **Releases** — Creating a GitHub release builds binaries for all 4 platforms and uploads them as release assets +- **Releases** — Creating a GitHub release builds binaries for all 4 platforms ## License diff --git a/SKILL.md b/SKILL.md index 717f541..d8101d0 100644 --- a/SKILL.md +++ b/SKILL.md @@ -5,6 +5,7 @@ tools: - auth_status - auth_login - auth_select + - auth_logout - devpod_up - devpod_stop - devpod_delete diff --git a/crates/devcontainer-mcp-core/src/auth/aws.rs b/crates/devcontainer-mcp-core/src/auth/aws.rs index d751e38..d336905 100644 --- a/crates/devcontainer-mcp-core/src/auth/aws.rs +++ b/crates/devcontainer-mcp-core/src/auth/aws.rs @@ -136,7 +136,9 @@ impl AuthProvider for AwsAuth { } } - async fn verify(&self, handle: &str) -> Result> { + async fn select(&self, handle: &str) -> Result> { + // AWS doesn't have an "active" account concept — profiles are selected per-call. + // Select validates the profile works by calling sts get-caller-identity. let profile = handle.strip_prefix("aws-").unwrap_or(handle); let output = run_cli( &CliBinary::Aws, @@ -162,7 +164,7 @@ impl AuthProvider for AwsAuth { return Ok(Some(AuthAccount { id: handle.to_string(), login: arn, - active: profile == "default", + active: true, metadata: parsed, })); } @@ -176,4 +178,19 @@ impl AuthProvider for AwsAuth { env.insert("AWS_PROFILE".into(), profile.to_string()); Ok(env) } + + async fn logout(&self, handle: &str) -> Result { + let profile = handle.strip_prefix("aws-").unwrap_or(handle); + let output = run_cli( + &CliBinary::Aws, + &["sso", "logout", "--profile", profile], + false, + ) + .await?; + if output.exit_code == 0 { + Ok(format!("Logged out AWS profile: {profile}")) + } else { + Ok(format!("AWS logout failed: {}", output.stderr.trim())) + } + } } diff --git a/crates/devcontainer-mcp-core/src/auth/azure.rs b/crates/devcontainer-mcp-core/src/auth/azure.rs index c2c4c69..817603c 100644 --- a/crates/devcontainer-mcp-core/src/auth/azure.rs +++ b/crates/devcontainer-mcp-core/src/auth/azure.rs @@ -116,13 +116,19 @@ impl AuthProvider for AzureAuth { } } - async fn verify(&self, handle: &str) -> Result> { + async fn select(&self, handle: &str) -> Result> { let sub_id = handle.strip_prefix("azure-").unwrap_or(handle); + let output = run_cli( + &CliBinary::Az, + &["account", "set", "--subscription", sub_id], + false, + ) + .await?; + if output.exit_code != 0 { + return Ok(None); + } 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) - })) + Ok(status.accounts.into_iter().find(|a| a.active)) } async fn resolve_env(&self, handle: &str) -> Result> { @@ -131,4 +137,13 @@ impl AuthProvider for AzureAuth { env.insert("AZURE_SUBSCRIPTION_ID".into(), sub_id.to_string()); Ok(env) } + + async fn logout(&self, _handle: &str) -> Result { + let output = run_cli(&CliBinary::Az, &["logout"], false).await?; + if output.exit_code == 0 { + Ok("Logged out of Azure.".into()) + } else { + Ok(format!("Azure logout failed: {}", output.stderr.trim())) + } + } } diff --git a/crates/devcontainer-mcp-core/src/auth/gcloud.rs b/crates/devcontainer-mcp-core/src/auth/gcloud.rs index 1691c90..71a44fa 100644 --- a/crates/devcontainer-mcp-core/src/auth/gcloud.rs +++ b/crates/devcontainer-mcp-core/src/auth/gcloud.rs @@ -105,8 +105,17 @@ impl AuthProvider for GcloudAuth { } } - async fn verify(&self, handle: &str) -> Result> { + async fn select(&self, handle: &str) -> Result> { let account = handle.strip_prefix("gcloud-").unwrap_or(handle); + let output = run_cli( + &CliBinary::Gcloud, + &["config", "set", "account", account], + false, + ) + .await?; + if output.exit_code != 0 { + return Ok(None); + } let status = self.status().await?; Ok(status.accounts.into_iter().find(|a| a.login == account)) } @@ -117,4 +126,14 @@ impl AuthProvider for GcloudAuth { env.insert("CLOUDSDK_CORE_ACCOUNT".into(), account.to_string()); Ok(env) } + + async fn logout(&self, handle: &str) -> Result { + let account = handle.strip_prefix("gcloud-").unwrap_or(handle); + let output = run_cli(&CliBinary::Gcloud, &["auth", "revoke", account], false).await?; + if output.exit_code == 0 { + Ok(format!("Revoked Google Cloud account: {account}")) + } else { + Ok(format!("GCloud logout failed: {}", output.stderr.trim())) + } + } } diff --git a/crates/devcontainer-mcp-core/src/auth/github.rs b/crates/devcontainer-mcp-core/src/auth/github.rs index 3a8a3d3..ae017d9 100644 --- a/crates/devcontainer-mcp-core/src/auth/github.rs +++ b/crates/devcontainer-mcp-core/src/auth/github.rs @@ -5,9 +5,16 @@ use async_trait::async_trait; use tokio::process::Command; use super::{AuthAccount, AuthLoginResult, AuthProvider, AuthStatus}; -use crate::cli::{run_cli, CliBinary}; +use crate::cli::{run_cli_with_env, CliBinary}; use crate::error::Result; +/// Auth env that clears inherited tokens so gh uses its keyring. +fn no_token_env() -> HashMap { + let mut env = HashMap::new(); + env.insert("GH_TOKEN".into(), String::new()); + env +} + pub struct GitHubAuth; #[async_trait] @@ -17,11 +24,12 @@ impl AuthProvider for GitHubAuth { } async fn status(&self) -> Result { - // Check if gh is installed - let output = run_cli( + let env = no_token_env(); + let output = run_cli_with_env( &CliBinary::Gh, &["auth", "status", "--json", "hosts"], false, + Some(&env), ) .await; let output = match output { @@ -76,22 +84,52 @@ impl AuthProvider for GitHubAuth { } 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); - } + let status = self.status().await?; + let existing = status.accounts.iter().find(|a| a.active); - // 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 scope_str; + let (child_result, is_refresh) = if let Some(account) = existing { + let mut args = vec![ + "auth", + "refresh", + "-h", + "github.com", + "--user", + &account.login, + ]; + if let Some(s) = scopes { + scope_str = s.to_string(); + args.push("-s"); + args.push(&scope_str); + } + ( + Command::new("gh") + .args(&args) + .env("GH_TOKEN", "") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(), + true, + ) + } else { + let mut args = vec!["auth", "login", "-h", "github.com", "-p", "https", "-w"]; + if let Some(s) = scopes { + scope_str = s.to_string(); + args.push("-s"); + args.push(&scope_str); + } + ( + Command::new("gh") + .args(&args) + .env("GH_TOKEN", "") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(), + false, + ) + }; + let child = child_result.map_err(|_| crate::error::Error::GhCliNotFound)?; let output = child .wait_with_output() .await @@ -102,15 +140,22 @@ impl AuthProvider for GitHubAuth { 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(), + action: if is_refresh { + "refreshed".into() + } else { + "success".into() + }, + message: if is_refresh { + "Scopes refreshed.".into() + } else { + "Authentication complete.".into() + }, browser_opened: true, code_copied: combined.contains("copied"), }) @@ -125,18 +170,35 @@ impl AuthProvider for GitHubAuth { } } - async fn verify(&self, handle: &str) -> Result> { + async fn select(&self, handle: &str) -> Result> { let login = handle.strip_prefix("github-").unwrap_or(handle); + let env = no_token_env(); + // Switch the active account + let output = run_cli_with_env( + &CliBinary::Gh, + &["auth", "switch", "--user", login], + false, + Some(&env), + ) + .await?; + + if output.exit_code != 0 { + return Ok(None); + } + + // Return the now-active account info 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( + let env = no_token_env(); + let output = run_cli_with_env( &CliBinary::Gh, &["auth", "token", "-h", "github.com", "--user", login], false, + Some(&env), ) .await?; @@ -148,8 +210,26 @@ impl AuthProvider for GitHubAuth { ))); } - let mut env = HashMap::new(); - env.insert("GH_TOKEN".into(), token); - Ok(env) + let mut result = HashMap::new(); + result.insert("GH_TOKEN".into(), token); + Ok(result) + } + + async fn logout(&self, handle: &str) -> Result { + let login = handle.strip_prefix("github-").unwrap_or(handle); + let env = no_token_env(); + let output = run_cli_with_env( + &CliBinary::Gh, + &["auth", "logout", "-h", "github.com", "--user", login], + false, + Some(&env), + ) + .await?; + + if output.exit_code == 0 { + Ok(format!("Logged out GitHub account: {login}")) + } else { + Ok(format!("Logout failed: {}", output.stderr.trim())) + } } } diff --git a/crates/devcontainer-mcp-core/src/auth/kubernetes.rs b/crates/devcontainer-mcp-core/src/auth/kubernetes.rs index 13253a1..3de9b96 100644 --- a/crates/devcontainer-mcp-core/src/auth/kubernetes.rs +++ b/crates/devcontainer-mcp-core/src/auth/kubernetes.rs @@ -102,8 +102,17 @@ impl AuthProvider for KubernetesAuth { }) } - async fn verify(&self, handle: &str) -> Result> { + async fn select(&self, handle: &str) -> Result> { let context = handle.strip_prefix("k8s-").unwrap_or(handle); + let output = run_cli( + &CliBinary::Kubectl, + &["config", "use-context", context], + false, + ) + .await?; + if output.exit_code != 0 { + return Ok(None); + } let status = self.status().await?; Ok(status.accounts.into_iter().find(|a| a.login == context)) } @@ -111,9 +120,25 @@ impl AuthProvider for KubernetesAuth { 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) } + + async fn logout(&self, handle: &str) -> Result { + let context = handle.strip_prefix("k8s-").unwrap_or(handle); + let output = run_cli( + &CliBinary::Kubectl, + &["config", "delete-context", context], + false, + ) + .await?; + if output.exit_code == 0 { + Ok(format!("Deleted Kubernetes context: {context}")) + } else { + Ok(format!( + "Failed to delete context: {}", + output.stderr.trim() + )) + } + } } diff --git a/crates/devcontainer-mcp-core/src/auth/mod.rs b/crates/devcontainer-mcp-core/src/auth/mod.rs index bee38c5..63d35ce 100644 --- a/crates/devcontainer-mcp-core/src/auth/mod.rs +++ b/crates/devcontainer-mcp-core/src/auth/mod.rs @@ -56,8 +56,8 @@ pub trait AuthProvider: Send + Sync { /// `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>; + /// Switch the active account for this provider. Returns account info if valid. + async fn select(&self, handle: &str) -> crate::error::Result>; /// Resolve a handle to the environment variables needed by the subprocess. /// e.g. github → { "GH_TOKEN": "" } @@ -65,6 +65,9 @@ pub trait AuthProvider: Send + Sync { &self, handle: &str, ) -> crate::error::Result>; + + /// Logout / revoke an account by handle. + async fn logout(&self, handle: &str) -> crate::error::Result; } /// Get a provider by name. diff --git a/crates/devcontainer-mcp/src/tools.rs b/crates/devcontainer-mcp/src/tools.rs index 20f2850..e81730b 100644 --- a/crates/devcontainer-mcp/src/tools.rs +++ b/crates/devcontainer-mcp/src/tools.rs @@ -528,21 +528,45 @@ impl DevContainerMcp { #[tool( name = "auth_select", - description = "Verify that an auth handle is still valid. Returns account info if valid, null if expired/invalid." + description = "Switch the active account for a provider. Returns account info if successful, null if the handle is invalid." )] async fn auth_select( &self, #[tool(param)] - #[schemars(description = "Auth handle to verify (e.g. 'github-aniongithub', 'aws-prod')")] + #[schemars( + description = "Auth handle to switch to (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 { + Some(p) => match p.select(&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}"), + Ok(None) => format!("Failed to switch to: {id}"), + Err(e) => format!("Error: {e}"), + }, + None => format!("Unknown auth provider in handle: {id}"), + } + } + + #[tool( + name = "auth_logout", + description = "Logout / revoke an authenticated account. Removes credentials from the provider's keyring." + )] + async fn auth_logout( + &self, + #[tool(param)] + #[schemars( + description = "Auth handle to logout (e.g. 'github-aniongithub', 'azure-')" + )] + id: String, + ) -> String { + let provider_name = auth::provider_from_handle(&id).unwrap_or("unknown"); + match auth::get_provider(provider_name) { + Some(p) => match p.logout(&id).await { + Ok(msg) => msg, Err(e) => format!("Error: {e}"), }, None => format!("Unknown auth provider in handle: {id}"),