From 341df09a21e418ca22373d516e996effa68fccf3 Mon Sep 17 00:00:00 2001 From: aniongithub Date: Fri, 8 May 2026 16:47:24 -0700 Subject: [PATCH] refactor: composable tools + WSL support + templated SKILL.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 — Upgrade rmcp and split tools: - Upgrade rmcp from 0.1 to 1.6 (composable #[tool_router] + #[tool_handler]) - Migrate all tools from #[tool(param)] to Parameters pattern - Split 1200-line monolithic tools.rs into ~40 focused files: tools/devpod/ - 12 files tools/devcontainer/ - 8 files tools/codespaces/ - 8 files tools/auth/ - 4 files - Each tool gets its own #[tool_router] impl block - Backend routers combined hierarchically with + operator Phase 2 — Templated SKILL.md: - Split SKILL.md into skills/ fragments (10 markdown files + 4 tool lists) - build.rs assembles YAML frontmatter + body at build time - Platform-specific sections (WSL) conditionally included on Windows Phase 3 — WSL support (Windows-only): - Add cfg-gated wsl.rs to core crate with list/exec/terminate/shutdown/ set_default + file operations - Add cfg-gated tools/wsl/ with 6 tool files (9 MCP tools total) - WSL skill docs + tool names auto-included on Windows builds via build.rs - Add Windows x64 build job to release.yml All code gated with #[cfg(target_os = "windows")] — zero dead code on Linux/macOS. WSL tools only ship in Windows binaries. --- .github/workflows/release.yml | 23 + Cargo.lock | 94 +- Cargo.toml | 2 +- SKILL.md | 44 +- crates/devcontainer-mcp-core/src/cli.rs | 7 + .../devcontainer-mcp-core/src/devcontainer.rs | 16 +- crates/devcontainer-mcp-core/src/devpod.rs | 12 +- crates/devcontainer-mcp-core/src/error.rs | 4 + crates/devcontainer-mcp-core/src/lib.rs | 2 + crates/devcontainer-mcp-core/src/wsl.rs | 182 +++ crates/devcontainer-mcp/Cargo.toml | 1 + crates/devcontainer-mcp/build.rs | 106 ++ crates/devcontainer-mcp/src/main.rs | 4 +- crates/devcontainer-mcp/src/tools.rs | 1178 ----------------- .../devcontainer-mcp/src/tools/auth/login.rs | 32 + .../devcontainer-mcp/src/tools/auth/logout.rs | 31 + crates/devcontainer-mcp/src/tools/auth/mod.rs | 17 + .../devcontainer-mcp/src/tools/auth/select.rs | 32 + .../devcontainer-mcp/src/tools/auth/status.rs | 30 + .../src/tools/codespaces/create.rs | 59 + .../src/tools/codespaces/delete.rs | 37 + .../src/tools/codespaces/files.rs | 173 +++ .../src/tools/codespaces/list.rs | 35 + .../src/tools/codespaces/mod.rs | 25 + .../src/tools/codespaces/ports.rs | 35 + .../src/tools/codespaces/ssh.rs | 34 + .../src/tools/codespaces/stop.rs | 35 + .../src/tools/codespaces/view.rs | 35 + crates/devcontainer-mcp/src/tools/common.rs | 12 + .../src/tools/devcontainer/build.rs | 38 + .../src/tools/devcontainer/config.rs | 33 + .../src/tools/devcontainer/exec.rs | 38 + .../src/tools/devcontainer/files.rs | 150 +++ .../src/tools/devcontainer/mod.rs | 25 + .../src/tools/devcontainer/remove.rs | 30 + .../src/tools/devcontainer/status.rs | 31 + .../src/tools/devcontainer/stop.rs | 28 + .../src/tools/devcontainer/up.rs | 42 + .../src/tools/devpod/build.rs | 28 + .../src/tools/devpod/container.rs | 58 + .../src/tools/devpod/context.rs | 39 + .../src/tools/devpod/delete.rs | 28 + .../src/tools/devpod/files.rs | 170 +++ .../devcontainer-mcp/src/tools/devpod/list.rs | 18 + .../devcontainer-mcp/src/tools/devpod/logs.rs | 25 + .../devcontainer-mcp/src/tools/devpod/mod.rs | 33 + .../src/tools/devpod/provider.rs | 64 + .../devcontainer-mcp/src/tools/devpod/ssh.rs | 40 + .../src/tools/devpod/status.rs | 28 + .../devcontainer-mcp/src/tools/devpod/stop.rs | 22 + .../devcontainer-mcp/src/tools/devpod/up.rs | 28 + crates/devcontainer-mcp/src/tools/mod.rs | 47 + crates/devcontainer-mcp/src/tools/wsl/exec.rs | 27 + .../devcontainer-mcp/src/tools/wsl/files.rs | 138 ++ crates/devcontainer-mcp/src/tools/wsl/list.rs | 18 + crates/devcontainer-mcp/src/tools/wsl/mod.rs | 21 + .../src/tools/wsl/set_default.rs | 25 + .../src/tools/wsl/shutdown.rs | 18 + crates/devcontainer-mcp/src/tools/wsl/stop.rs | 25 + skills/_tools/auth.txt | 4 + skills/_tools/codespaces.txt | 11 + skills/_tools/devcontainer.txt | 11 + skills/_tools/devpod.txt | 19 + skills/_tools/wsl.txt | 9 + skills/auth.md | 27 + skills/choosing-backend.md | 5 + skills/codespaces.md | 24 + skills/core-rule.md | 3 + skills/devcontainer.md | 16 + skills/devpod.md | 21 + skills/file-ops.md | 42 + skills/footer.md | 9 + skills/header.md | 6 + skills/self-healing.md | 7 + skills/wsl.md | 37 + 75 files changed, 2604 insertions(+), 1259 deletions(-) create mode 100644 crates/devcontainer-mcp-core/src/wsl.rs create mode 100644 crates/devcontainer-mcp/build.rs delete mode 100644 crates/devcontainer-mcp/src/tools.rs create mode 100644 crates/devcontainer-mcp/src/tools/auth/login.rs create mode 100644 crates/devcontainer-mcp/src/tools/auth/logout.rs create mode 100644 crates/devcontainer-mcp/src/tools/auth/mod.rs create mode 100644 crates/devcontainer-mcp/src/tools/auth/select.rs create mode 100644 crates/devcontainer-mcp/src/tools/auth/status.rs create mode 100644 crates/devcontainer-mcp/src/tools/codespaces/create.rs create mode 100644 crates/devcontainer-mcp/src/tools/codespaces/delete.rs create mode 100644 crates/devcontainer-mcp/src/tools/codespaces/files.rs create mode 100644 crates/devcontainer-mcp/src/tools/codespaces/list.rs create mode 100644 crates/devcontainer-mcp/src/tools/codespaces/mod.rs create mode 100644 crates/devcontainer-mcp/src/tools/codespaces/ports.rs create mode 100644 crates/devcontainer-mcp/src/tools/codespaces/ssh.rs create mode 100644 crates/devcontainer-mcp/src/tools/codespaces/stop.rs create mode 100644 crates/devcontainer-mcp/src/tools/codespaces/view.rs create mode 100644 crates/devcontainer-mcp/src/tools/common.rs create mode 100644 crates/devcontainer-mcp/src/tools/devcontainer/build.rs create mode 100644 crates/devcontainer-mcp/src/tools/devcontainer/config.rs create mode 100644 crates/devcontainer-mcp/src/tools/devcontainer/exec.rs create mode 100644 crates/devcontainer-mcp/src/tools/devcontainer/files.rs create mode 100644 crates/devcontainer-mcp/src/tools/devcontainer/mod.rs create mode 100644 crates/devcontainer-mcp/src/tools/devcontainer/remove.rs create mode 100644 crates/devcontainer-mcp/src/tools/devcontainer/status.rs create mode 100644 crates/devcontainer-mcp/src/tools/devcontainer/stop.rs create mode 100644 crates/devcontainer-mcp/src/tools/devcontainer/up.rs create mode 100644 crates/devcontainer-mcp/src/tools/devpod/build.rs create mode 100644 crates/devcontainer-mcp/src/tools/devpod/container.rs create mode 100644 crates/devcontainer-mcp/src/tools/devpod/context.rs create mode 100644 crates/devcontainer-mcp/src/tools/devpod/delete.rs create mode 100644 crates/devcontainer-mcp/src/tools/devpod/files.rs create mode 100644 crates/devcontainer-mcp/src/tools/devpod/list.rs create mode 100644 crates/devcontainer-mcp/src/tools/devpod/logs.rs create mode 100644 crates/devcontainer-mcp/src/tools/devpod/mod.rs create mode 100644 crates/devcontainer-mcp/src/tools/devpod/provider.rs create mode 100644 crates/devcontainer-mcp/src/tools/devpod/ssh.rs create mode 100644 crates/devcontainer-mcp/src/tools/devpod/status.rs create mode 100644 crates/devcontainer-mcp/src/tools/devpod/stop.rs create mode 100644 crates/devcontainer-mcp/src/tools/devpod/up.rs create mode 100644 crates/devcontainer-mcp/src/tools/mod.rs create mode 100644 crates/devcontainer-mcp/src/tools/wsl/exec.rs create mode 100644 crates/devcontainer-mcp/src/tools/wsl/files.rs create mode 100644 crates/devcontainer-mcp/src/tools/wsl/list.rs create mode 100644 crates/devcontainer-mcp/src/tools/wsl/mod.rs create mode 100644 crates/devcontainer-mcp/src/tools/wsl/set_default.rs create mode 100644 crates/devcontainer-mcp/src/tools/wsl/shutdown.rs create mode 100644 crates/devcontainer-mcp/src/tools/wsl/stop.rs create mode 100644 skills/_tools/auth.txt create mode 100644 skills/_tools/codespaces.txt create mode 100644 skills/_tools/devcontainer.txt create mode 100644 skills/_tools/devpod.txt create mode 100644 skills/_tools/wsl.txt create mode 100644 skills/auth.md create mode 100644 skills/choosing-backend.md create mode 100644 skills/codespaces.md create mode 100644 skills/core-rule.md create mode 100644 skills/devcontainer.md create mode 100644 skills/devpod.md create mode 100644 skills/file-ops.md create mode 100644 skills/footer.md create mode 100644 skills/header.md create mode 100644 skills/self-healing.md create mode 100644 skills/wsl.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a4eea12..d90e382 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,3 +88,26 @@ jobs: files: | install.sh install.ps1 + + build-windows: + name: Build devcontainer-mcp-windows-x64 + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + - uses: Swatinem/rust-cache@v2 + with: + key: x86_64-pc-windows-msvc + + - name: Build + run: cargo build --release --target x86_64-pc-windows-msvc -p devcontainer-mcp + + - name: Package binary + run: Compress-Archive -Path target/x86_64-pc-windows-msvc/release/devcontainer-mcp.exe -DestinationPath devcontainer-mcp-windows-x64.zip + + - name: Upload release asset + uses: softprops/action-gh-release@v3 + with: + files: devcontainer-mcp-windows-x64.zip diff --git a/Cargo.lock b/Cargo.lock index 35e53c2..a60602a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,12 +99,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" @@ -123,7 +117,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97ccca1260af6a459d75994ad5acc1651bcabcbdbc41467cc9786519ab854c30" dependencies = [ - "base64 0.22.1", + "base64", "bollard-stubs", "bytes", "futures-core", @@ -255,6 +249,40 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "deranged" version = "0.5.8" @@ -273,6 +301,7 @@ dependencies = [ "clap", "devcontainer-mcp-core", "rmcp", + "schemars 1.2.1", "serde", "serde_json", "tokio", @@ -285,7 +314,7 @@ name = "devcontainer-mcp-core" version = "0.1.0" dependencies = [ "async-trait", - "base64 0.22.1", + "base64", "bollard", "futures-util", "serde", @@ -677,6 +706,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -862,10 +897,10 @@ dependencies = [ ] [[package]] -name = "paste" -version = "1.0.15" +name = "pastey" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a" [[package]] name = "percent-encoding" @@ -960,17 +995,18 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "rmcp" -version = "0.1.5" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33a0110d28bd076f39e14bfd5b0340216dd18effeb5d02b43215944cc3e5c751" +checksum = "e12ca9067b5ebfbd5b3fcdc4acfceb81aa7d5ab2a879dff7cb75d22434276aad" dependencies = [ - "base64 0.21.7", + "async-trait", + "base64", "chrono", "futures", - "paste", + "pastey", "pin-project-lite", "rmcp-macros", - "schemars 0.8.22", + "schemars 1.2.1", "serde", "serde_json", "thiserror", @@ -981,12 +1017,14 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.1.5" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6e2b2fd7497540489fa2db285edd43b7ed14c49157157438664278da6e42a7a" +checksum = "7caa6743cc0888e433105fe1bc551a7f607940b126a37bc97b478e86064627eb" dependencies = [ + "darling", "proc-macro2", "quote", + "serde_json", "syn", ] @@ -1002,18 +1040,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "schemars" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" -dependencies = [ - "dyn-clone", - "schemars_derive", - "serde", - "serde_json", -] - [[package]] name = "schemars" version = "0.9.0" @@ -1032,17 +1058,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ + "chrono", "dyn-clone", "ref-cast", + "schemars_derive", "serde", "serde_json", ] [[package]] name = "schemars_derive" -version = "0.8.22" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" dependencies = [ "proc-macro2", "quote", @@ -1139,7 +1167,7 @@ version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "hex", "indexmap 1.9.3", diff --git a/Cargo.toml b/Cargo.toml index 8d12a05..5633bb9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,6 @@ thiserror = "2" anyhow = "1" bollard = "0.18" clap = { version = "4", features = ["derive"] } -rmcp = { version = "0.1", features = ["server", "transport-io"] } +rmcp = { version = "1.6", features = ["server", "transport-io", "schemars"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/SKILL.md b/SKILL.md index 9d7e29e..9745d46 100644 --- a/SKILL.md +++ b/SKILL.md @@ -6,6 +6,28 @@ tools: - auth_login - auth_select - auth_logout + - codespaces_create + - codespaces_list + - codespaces_ssh + - codespaces_stop + - codespaces_delete + - codespaces_view + - codespaces_ports + - codespaces_file_read + - codespaces_file_write + - codespaces_file_edit + - codespaces_file_list + - devcontainer_up + - devcontainer_exec + - devcontainer_build + - devcontainer_read_config + - devcontainer_stop + - devcontainer_remove + - devcontainer_status + - devcontainer_file_read + - devcontainer_file_write + - devcontainer_file_edit + - devcontainer_file_list - devpod_up - devpod_stop - devpod_delete @@ -21,32 +43,10 @@ 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_file_read - devpod_file_write - devpod_file_edit - devpod_file_list - - devcontainer_file_read - - devcontainer_file_write - - devcontainer_file_edit - - devcontainer_file_list - - codespaces_file_read - - codespaces_file_write - - codespaces_file_edit - - codespaces_file_list --- # DevContainer MCP Skill diff --git a/crates/devcontainer-mcp-core/src/cli.rs b/crates/devcontainer-mcp-core/src/cli.rs index cee82ec..13b1457 100644 --- a/crates/devcontainer-mcp-core/src/cli.rs +++ b/crates/devcontainer-mcp-core/src/cli.rs @@ -29,6 +29,9 @@ pub enum CliBinary { Gcloud, /// Kubernetes CLI Kubectl, + #[cfg(target_os = "windows")] + /// Windows Subsystem for Linux + Wsl, } impl CliBinary { @@ -41,6 +44,8 @@ impl CliBinary { CliBinary::Aws => "aws", CliBinary::Gcloud => "gcloud", CliBinary::Kubectl => "kubectl", + #[cfg(target_os = "windows")] + CliBinary::Wsl => "wsl", } } @@ -53,6 +58,8 @@ impl CliBinary { CliBinary::Aws => Error::AwsCliNotFound, CliBinary::Gcloud => Error::GcloudCliNotFound, CliBinary::Kubectl => Error::KubectlNotFound, + #[cfg(target_os = "windows")] + CliBinary::Wsl => Error::WslNotFound, } } } diff --git a/crates/devcontainer-mcp-core/src/devcontainer.rs b/crates/devcontainer-mcp-core/src/devcontainer.rs index 5a586aa..167fc0d 100644 --- a/crates/devcontainer-mcp-core/src/devcontainer.rs +++ b/crates/devcontainer-mcp-core/src/devcontainer.rs @@ -100,20 +100,13 @@ pub async fn status(workspace_folder: &str) -> Result Result { +pub async fn file_read(workspace_folder: &str, path: &str) -> Result { let cmd = crate::file_ops::read_file_command(path); exec(workspace_folder, "sh", &["-c", &cmd]).await } /// Write (create or overwrite) a file in a dev container. -pub async fn file_write( - workspace_folder: &str, - path: &str, - content: &str, -) -> Result { +pub async fn file_write(workspace_folder: &str, path: &str, content: &str) -> Result { let cmd = crate::file_ops::write_file_command(path, content); exec(workspace_folder, "sh", &["-c", &cmd]).await } @@ -147,10 +140,7 @@ pub async fn file_edit( } /// List directory contents in a dev container. -pub async fn file_list( - workspace_folder: &str, - path: &str, -) -> Result { +pub async fn file_list(workspace_folder: &str, path: &str) -> Result { let cmd = crate::file_ops::list_dir_command(path); exec(workspace_folder, "sh", &["-c", &cmd]).await } diff --git a/crates/devcontainer-mcp-core/src/devpod.rs b/crates/devcontainer-mcp-core/src/devpod.rs index 2a53fc6..20667d2 100644 --- a/crates/devcontainer-mcp-core/src/devpod.rs +++ b/crates/devcontainer-mcp-core/src/devpod.rs @@ -173,11 +173,7 @@ pub async fn export(workspace: &str) -> Result { // --------------------------------------------------------------------------- /// Read a file from a DevPod workspace. -pub async fn file_read( - workspace: &str, - path: &str, - user: Option<&str>, -) -> Result { +pub async fn file_read(workspace: &str, path: &str, user: Option<&str>) -> Result { let cmd = crate::file_ops::read_file_command(path); ssh_exec(workspace, &cmd, user, None).await } @@ -223,11 +219,7 @@ pub async fn file_edit( } /// List directory contents in a DevPod workspace. -pub async fn file_list( - workspace: &str, - path: &str, - user: Option<&str>, -) -> Result { +pub async fn file_list(workspace: &str, path: &str, user: Option<&str>) -> Result { let cmd = crate::file_ops::list_dir_command(path); ssh_exec(workspace, &cmd, user, None).await } diff --git a/crates/devcontainer-mcp-core/src/error.rs b/crates/devcontainer-mcp-core/src/error.rs index ec3105c..549b840 100644 --- a/crates/devcontainer-mcp-core/src/error.rs +++ b/crates/devcontainer-mcp-core/src/error.rs @@ -27,6 +27,10 @@ pub enum Error { #[error("kubectl not found. Install from: https://kubernetes.io/docs/tasks/tools/")] KubectlNotFound, + #[cfg(target_os = "windows")] + #[error("WSL (wsl.exe) not found. WSL must be installed: https://learn.microsoft.com/en-us/windows/wsl/install")] + WslNotFound, + #[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 index 3189d28..0177a10 100644 --- a/crates/devcontainer-mcp-core/src/lib.rs +++ b/crates/devcontainer-mcp-core/src/lib.rs @@ -6,3 +6,5 @@ pub mod devpod; pub mod docker; pub mod error; pub mod file_ops; +#[cfg(target_os = "windows")] +pub mod wsl; diff --git a/crates/devcontainer-mcp-core/src/wsl.rs b/crates/devcontainer-mcp-core/src/wsl.rs new file mode 100644 index 0000000..735a051 --- /dev/null +++ b/crates/devcontainer-mcp-core/src/wsl.rs @@ -0,0 +1,182 @@ +//! WSL (Windows Subsystem for Linux) backend. +//! +//! Wraps the `wsl.exe` CLI to manage WSL distributions, execute commands, +//! and perform file operations inside Linux distros. + +use serde::Serialize; + +use crate::cli::{run_cli, CliBinary, CliOutput}; +use crate::error::{Error, Result}; + +/// A WSL distribution parsed from `wsl --list --verbose`. +#[derive(Debug, Clone, Serialize)] +pub struct WslDistro { + pub name: String, + pub state: String, + pub version: u8, + pub is_default: bool, +} + +/// Run a WSL CLI command with the given args. +async fn run_wsl(args: &[&str], parse_json: bool) -> Result { + run_cli(&CliBinary::Wsl, args, parse_json).await +} + +// --------------------------------------------------------------------------- +// Distribution management +// --------------------------------------------------------------------------- + +/// `wsl --list --verbose` — list installed distributions with state and version. +/// +/// Parses the tabular output into structured [`WslDistro`] entries and returns +/// them as a JSON array in `CliOutput::json`. +pub async fn list() -> Result { + let mut output = run_wsl(&["--list", "--verbose"], false).await?; + + if output.exit_code == 0 { + let distros = parse_list_output(&output.stdout); + output.json = Some(serde_json::to_value(&distros).unwrap_or_default()); + } + + Ok(output) +} + +/// Parse the tabular output of `wsl --list --verbose`. +/// +/// Example output: +/// ```text +/// NAME STATE VERSION +/// * Ubuntu Running 2 +/// Debian Stopped 2 +/// ``` +fn parse_list_output(stdout: &str) -> Vec { + let mut distros = Vec::new(); + + for line in stdout.lines().skip(1) { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let is_default = trimmed.starts_with('*'); + let clean = trimmed.trim_start_matches('*').trim(); + + let parts: Vec<&str> = clean.split_whitespace().collect(); + if parts.len() >= 3 { + if let Ok(version) = parts[parts.len() - 1].parse::() { + let state = parts[parts.len() - 2].to_string(); + let name = parts[..parts.len() - 2].join(" "); + distros.push(WslDistro { + name, + state, + version, + is_default, + }); + } + } + } + + distros +} + +/// `wsl --set-default ` — set the default WSL distribution. +pub async fn set_default(distro: &str) -> Result { + run_wsl(&["--set-default", distro], false).await +} + +/// `wsl --terminate ` — stop a running distribution. +pub async fn terminate(distro: &str) -> Result { + run_wsl(&["--terminate", distro], false).await +} + +/// `wsl --shutdown` — shut down all running WSL distributions. +pub async fn shutdown() -> Result { + run_wsl(&["--shutdown"], false).await +} + +// --------------------------------------------------------------------------- +// Command execution +// --------------------------------------------------------------------------- + +/// `wsl -d -- sh -c ` — execute a command inside a distro. +pub async fn exec(distro: &str, command: &str) -> Result { + run_wsl(&["-d", distro, "--", "sh", "-c", command], false).await +} + +// --------------------------------------------------------------------------- +// File operations +// --------------------------------------------------------------------------- + +/// Read a file from a WSL distro. +pub async fn file_read(distro: &str, path: &str) -> Result { + let cmd = crate::file_ops::read_file_command(path); + exec(distro, &cmd).await +} + +/// Write (create or overwrite) a file in a WSL distro. +pub async fn file_write(distro: &str, path: &str, content: &str) -> Result { + let cmd = crate::file_ops::write_file_command(path, content); + exec(distro, &cmd).await +} + +/// Surgical edit: replace exactly one occurrence of `old_str` with `new_str`. +pub async fn file_edit(distro: &str, path: &str, old_str: &str, new_str: &str) -> Result { + let read_output = file_read(distro, path).await?; + if read_output.exit_code != 0 { + return Err(Error::FileRead(format!( + "Failed to read {path}: {}", + read_output.stderr.trim() + ))); + } + + let modified = crate::file_ops::apply_edit(&read_output.stdout, old_str, new_str)?; + + let write_output = file_write(distro, path, &modified).await?; + if write_output.exit_code != 0 { + return Err(Error::FileEdit(format!( + "Failed to write {path}: {}", + write_output.stderr.trim() + ))); + } + + Ok(format!("Edit applied to {path}")) +} + +/// List directory contents in a WSL distro. +pub async fn file_list(distro: &str, path: &str) -> Result { + let cmd = crate::file_ops::list_dir_command(path); + exec(distro, &cmd).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_list_output_typical() { + let output = "\ + NAME STATE VERSION +* Ubuntu Running 2 + Debian Stopped 2 +"; + let distros = parse_list_output(output); + assert_eq!(distros.len(), 2); + + assert_eq!(distros[0].name, "Ubuntu"); + assert_eq!(distros[0].state, "Running"); + assert_eq!(distros[0].version, 2); + assert!(distros[0].is_default); + + assert_eq!(distros[1].name, "Debian"); + assert_eq!(distros[1].state, "Stopped"); + assert_eq!(distros[1].version, 2); + assert!(!distros[1].is_default); + } + + #[test] + fn test_parse_list_output_empty() { + let output = " NAME STATE VERSION\n"; + let distros = parse_list_output(output); + assert!(distros.is_empty()); + } +} diff --git a/crates/devcontainer-mcp/Cargo.toml b/crates/devcontainer-mcp/Cargo.toml index d511325..48e283b 100644 --- a/crates/devcontainer-mcp/Cargo.toml +++ b/crates/devcontainer-mcp/Cargo.toml @@ -16,3 +16,4 @@ clap = { workspace = true } rmcp = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +schemars = "1" diff --git a/crates/devcontainer-mcp/build.rs b/crates/devcontainer-mcp/build.rs new file mode 100644 index 0000000..9313903 --- /dev/null +++ b/crates/devcontainer-mcp/build.rs @@ -0,0 +1,106 @@ +use std::fs; +use std::path::PathBuf; + +const FRONTMATTER_NAME: &str = "devcontainer-mcp"; +const FRONTMATTER_DESC: &str = + "Manage dev container environments via MCP tools (DevPod, devcontainer CLI, Codespaces)"; + +/// Fragment order for the assembled SKILL.md body. +const FRAGMENTS: &[&str] = &[ + "header.md", + "core-rule.md", + "auth.md", + "choosing-backend.md", + "devpod.md", + "devcontainer.md", + "codespaces.md", + // WSL fragment inserted here on Windows builds + "self-healing.md", + "footer.md", + "file-ops.md", +]; + +fn main() { + let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let workspace_root = manifest_dir + .parent() + .and_then(|p| p.parent()) + .expect("could not resolve workspace root from CARGO_MANIFEST_DIR"); + + let skills_dir = workspace_root.join("skills"); + let tools_dir = skills_dir.join("_tools"); + let output_path = workspace_root.join("SKILL.md"); + + // Use CARGO_CFG_TARGET_OS (the *target* platform, not the host) so that + // cross-compiling for Windows from Linux still includes WSL content. + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let is_windows_target = target_os == "windows"; + + // --- Collect tool names from _tools/*.txt ----------------------------------- + let mut tools: Vec = Vec::new(); + let mut tool_files: Vec = fs::read_dir(&tools_dir) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", tools_dir.display())) + .filter_map(Result::ok) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|ext| ext == "txt")) + .collect(); + tool_files.sort(); + + // On non-Windows targets, skip wsl.txt + for path in &tool_files { + let is_wsl = path + .file_stem() + .is_some_and(|s| s.to_str().is_some_and(|s| s == "wsl")); + if is_wsl && !is_windows_target { + continue; + } + let content = fs::read_to_string(path) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())); + for line in content.lines() { + let trimmed = line.trim(); + if !trimmed.is_empty() { + tools.push(trimmed.to_string()); + } + } + } + + // --- Build YAML frontmatter ------------------------------------------------- + let mut output = String::from("---\n"); + output.push_str(&format!("name: {FRONTMATTER_NAME}\n")); + output.push_str(&format!("description: {FRONTMATTER_DESC}\n")); + output.push_str("tools:\n"); + for tool in &tools { + output.push_str(&format!(" - {tool}\n")); + } + output.push_str("---\n"); + + // --- Assemble markdown body ------------------------------------------------- + let insert_wsl_after = "codespaces.md"; + + for &fragment_name in FRAGMENTS { + let path = skills_dir.join(fragment_name); + let content = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())); + output.push('\n'); + output.push_str(&content); + + // On Windows targets, insert WSL section right after codespaces + if is_windows_target && fragment_name == insert_wsl_after { + let wsl_path = skills_dir.join("wsl.md"); + if wsl_path.exists() { + let wsl_content = fs::read_to_string(&wsl_path) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", wsl_path.display())); + output.push('\n'); + output.push_str(&wsl_content); + } + } + } + + // --- Write output ----------------------------------------------------------- + fs::write(&output_path, &output) + .unwrap_or_else(|e| panic!("cannot write {}: {e}", output_path.display())); + + // --- Incremental build support ---------------------------------------------- + println!("cargo:rerun-if-changed={}", skills_dir.display()); + println!("cargo:rerun-if-env-changed=CARGO_CFG_TARGET_OS"); +} diff --git a/crates/devcontainer-mcp/src/main.rs b/crates/devcontainer-mcp/src/main.rs index 65c8871..62b9d69 100644 --- a/crates/devcontainer-mcp/src/main.rs +++ b/crates/devcontainer-mcp/src/main.rs @@ -1,8 +1,8 @@ mod tools; use clap::{Parser, Subcommand}; +use rmcp::transport::stdio; use rmcp::ServiceExt; -use tokio::io::{stdin, stdout}; #[derive(Parser)] #[command(name = "devcontainer-mcp")] @@ -32,7 +32,7 @@ async fn main() -> anyhow::Result<()> { Commands::Serve => { tracing::info!("Starting devcontainer-mcp MCP server over stdio"); let service = tools::DevContainerMcp::new(); - let server = service.serve((stdin(), stdout())).await?; + let server = service.serve(stdio()).await?; server.waiting().await?; } } diff --git a/crates/devcontainer-mcp/src/tools.rs b/crates/devcontainer-mcp/src/tools.rs deleted file mode 100644 index 3a46ac1..0000000 --- a/crates/devcontainer-mcp/src/tools.rs +++ /dev/null @@ -1,1178 +0,0 @@ -use rmcp::model::ServerInfo; -use rmcp::{tool, ServerHandler}; - -use devcontainer_mcp_core::{auth, cli::CliOutput, codespaces, devcontainer, devpod, docker, file_ops}; - -#[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}"), - } - } - - // ----------------------------------------------------------------------- - // DevPod file operations - // ----------------------------------------------------------------------- - - #[tool( - name = "devpod_file_read", - description = "Read file content from a DevPod workspace. Returns content with line numbers. Supports optional line range." - )] - async fn devpod_file_read( - &self, - #[tool(param)] - #[schemars(description = "Workspace name or ID")] - workspace: String, - #[tool(param)] - #[schemars(description = "Path to the file inside the workspace")] - path: String, - #[tool(param)] - #[schemars(description = "Start line number (1-based, inclusive)")] - start_line: Option, - #[tool(param)] - #[schemars(description = "End line number (1-based, inclusive). Use -1 or omit for end of file")] - end_line: Option, - #[tool(param)] - #[schemars(description = "User to run the command as")] - user: Option, - ) -> String { - match devpod::file_read(&workspace, &path, user.as_deref()).await { - Ok(output) => { - if output.exit_code != 0 { - return format!("Error (exit {}): {}", output.exit_code, output.stderr.trim()); - } - let end = end_line.and_then(|e| if e < 0 { None } else { Some(e as usize) }); - file_ops::format_with_line_numbers(&output.stdout, start_line, end) - } - Err(e) => format!("Error: {e}"), - } - } - - #[tool( - name = "devpod_file_write", - description = "Create or overwrite a file in a DevPod workspace. Creates parent directories automatically." - )] - async fn devpod_file_write( - &self, - #[tool(param)] - #[schemars(description = "Workspace name or ID")] - workspace: String, - #[tool(param)] - #[schemars(description = "Path to the file inside the workspace")] - path: String, - #[tool(param)] - #[schemars(description = "File content to write")] - content: String, - #[tool(param)] - #[schemars(description = "User to run the command as")] - user: Option, - ) -> String { - match devpod::file_write(&workspace, &path, &content, user.as_deref()).await { - Ok(output) => { - if output.exit_code != 0 { - format!("Error (exit {}): {}", output.exit_code, output.stderr.trim()) - } else { - format!("File written: {path}") - } - } - Err(e) => format!("Error: {e}"), - } - } - - #[tool( - name = "devpod_file_edit", - description = "Make a surgical edit to a file in a DevPod workspace. Replaces exactly one occurrence of old_str with new_str. The old_str must match exactly one location in the file — include enough surrounding context to make it unique." - )] - async fn devpod_file_edit( - &self, - #[tool(param)] - #[schemars(description = "Workspace name or ID")] - workspace: String, - #[tool(param)] - #[schemars(description = "Path to the file inside the workspace")] - path: String, - #[tool(param)] - #[schemars(description = "The exact string in the file to replace. Must match exactly once.")] - old_str: String, - #[tool(param)] - #[schemars(description = "The new string to replace old_str with")] - new_str: String, - #[tool(param)] - #[schemars(description = "User to run the command as")] - user: Option, - ) -> String { - match devpod::file_edit(&workspace, &path, &old_str, &new_str, user.as_deref()).await { - Ok(msg) => msg, - Err(e) => format!("Error: {e}"), - } - } - - #[tool( - name = "devpod_file_list", - description = "List directory contents in a DevPod workspace. Shows non-hidden files up to 2 levels deep." - )] - async fn devpod_file_list( - &self, - #[tool(param)] - #[schemars(description = "Workspace name or ID")] - workspace: String, - #[tool(param)] - #[schemars(description = "Path to the directory inside the workspace (defaults to '.')")] - path: Option, - #[tool(param)] - #[schemars(description = "User to run the command as")] - user: Option, - ) -> String { - let dir = path.as_deref().unwrap_or("."); - match devpod::file_list(&workspace, dir, user.as_deref()).await { - Ok(output) => { - if output.exit_code != 0 { - format!("Error (exit {}): {}", output.exit_code, output.stderr.trim()) - } else { - output.stdout - } - } - 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}"), - } - } - - // ----------------------------------------------------------------------- - // devcontainer file operations - // ----------------------------------------------------------------------- - - #[tool( - name = "devcontainer_file_read", - description = "Read file content from a local dev container. Returns content with line numbers. Supports optional line range." - )] - async fn devcontainer_file_read( - &self, - #[tool(param)] - #[schemars(description = "Path to the workspace folder")] - workspace_folder: String, - #[tool(param)] - #[schemars(description = "Path to the file inside the container")] - path: String, - #[tool(param)] - #[schemars(description = "Start line number (1-based, inclusive)")] - start_line: Option, - #[tool(param)] - #[schemars(description = "End line number (1-based, inclusive). Use -1 or omit for end of file")] - end_line: Option, - ) -> String { - match devcontainer::file_read(&workspace_folder, &path).await { - Ok(output) => { - if output.exit_code != 0 { - return format!("Error (exit {}): {}", output.exit_code, output.stderr.trim()); - } - let end = end_line.and_then(|e| if e < 0 { None } else { Some(e as usize) }); - file_ops::format_with_line_numbers(&output.stdout, start_line, end) - } - Err(e) => format!("Error: {e}"), - } - } - - #[tool( - name = "devcontainer_file_write", - description = "Create or overwrite a file in a local dev container. Creates parent directories automatically." - )] - async fn devcontainer_file_write( - &self, - #[tool(param)] - #[schemars(description = "Path to the workspace folder")] - workspace_folder: String, - #[tool(param)] - #[schemars(description = "Path to the file inside the container")] - path: String, - #[tool(param)] - #[schemars(description = "File content to write")] - content: String, - ) -> String { - match devcontainer::file_write(&workspace_folder, &path, &content).await { - Ok(output) => { - if output.exit_code != 0 { - format!("Error (exit {}): {}", output.exit_code, output.stderr.trim()) - } else { - format!("File written: {path}") - } - } - Err(e) => format!("Error: {e}"), - } - } - - #[tool( - name = "devcontainer_file_edit", - description = "Make a surgical edit to a file in a local dev container. Replaces exactly one occurrence of old_str with new_str. The old_str must match exactly one location in the file — include enough surrounding context to make it unique." - )] - async fn devcontainer_file_edit( - &self, - #[tool(param)] - #[schemars(description = "Path to the workspace folder")] - workspace_folder: String, - #[tool(param)] - #[schemars(description = "Path to the file inside the container")] - path: String, - #[tool(param)] - #[schemars(description = "The exact string in the file to replace. Must match exactly once.")] - old_str: String, - #[tool(param)] - #[schemars(description = "The new string to replace old_str with")] - new_str: String, - ) -> String { - match devcontainer::file_edit(&workspace_folder, &path, &old_str, &new_str).await { - Ok(msg) => msg, - Err(e) => format!("Error: {e}"), - } - } - - #[tool( - name = "devcontainer_file_list", - description = "List directory contents in a local dev container. Shows non-hidden files up to 2 levels deep." - )] - async fn devcontainer_file_list( - &self, - #[tool(param)] - #[schemars(description = "Path to the workspace folder")] - workspace_folder: String, - #[tool(param)] - #[schemars(description = "Path to the directory inside the container (defaults to '.')")] - path: Option, - ) -> String { - let dir = path.as_deref().unwrap_or("."); - match devcontainer::file_list(&workspace_folder, dir).await { - Ok(output) => { - if output.exit_code != 0 { - format!("Error (exit {}): {}", output.exit_code, output.stderr.trim()) - } else { - output.stdout - } - } - Err(e) => format!("Error: {e}"), - } - } - - // ======================================================================= - // Auth tools - // ======================================================================= - - #[tool( - name = "auth_status", - description = "Check authentication status for a provider. Returns available auth handles and account info. Providers: 'github', 'aws', 'azure', 'gcloud', 'kubernetes'." - )] - async fn auth_status( - &self, - #[tool(param)] - #[schemars(description = "Auth provider name (e.g. 'github', 'aws', 'azure', 'gcloud')")] - provider: String, - ) -> String { - match auth::get_provider(&provider) { - Some(p) => match p.status().await { - Ok(status) => { - serde_json::to_string(&status).unwrap_or_else(|e| format!("Error: {e}")) - } - Err(e) => format!("Error: {e}"), - }, - None => format!("Unknown auth provider: {provider}"), - } - } - - #[tool( - name = "auth_login", - description = "Initiate authentication for a provider. Opens browser, copies device code to clipboard, and waits for approval. Returns an auth handle on success." - )] - async fn auth_login( - &self, - #[tool(param)] - #[schemars(description = "Auth provider name (e.g. 'github')")] - provider: String, - #[tool(param)] - #[schemars( - description = "Additional OAuth scopes to request (e.g. 'codespace' for GitHub)" - )] - scopes: Option, - ) -> String { - match auth::get_provider(&provider) { - Some(p) => match p.login(scopes.as_deref()).await { - Ok(result) => { - serde_json::to_string(&result).unwrap_or_else(|e| format!("Error: {e}")) - } - Err(e) => format!("Error: {e}"), - }, - None => format!("Unknown auth provider: {provider}"), - } - } - - #[tool( - name = "auth_select", - description = "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 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.select(&id).await { - Ok(Some(account)) => { - serde_json::to_string(&account).unwrap_or_else(|e| format!("Error: {e}")) - } - 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}"), - } - } - - // ======================================================================= - // GitHub Codespaces tools - // ======================================================================= - - #[tool( - name = "codespaces_create", - description = "Create a new GitHub Codespace for a repository. Requires a GitHub auth handle (get one via auth_status or auth_login)." - )] - #[allow(clippy::too_many_arguments)] - async fn codespaces_create( - &self, - #[tool(param)] - #[schemars( - description = "GitHub auth handle from auth_status/auth_login (e.g. 'github-aniongithub')" - )] - auth: String, - #[tool(param)] - #[schemars(description = "Repository in owner/repo format")] - repo: String, - #[tool(param)] - #[schemars(description = "Branch to create the codespace from")] - branch: Option, - #[tool(param)] - #[schemars( - description = "Machine type — ask the user. Options: 'basicLinux32gb' (2 cores, 8 GB RAM), 'standardLinux32gb' (4 cores, 16 GB RAM), 'premiumLinux' (8 cores, 32 GB RAM), 'largePremiumLinux' (16 cores, 64 GB RAM)" - )] - machine: Option, - #[tool(param)] - #[schemars(description = "Path to devcontainer.json within the repo")] - devcontainer_path: Option, - #[tool(param)] - #[schemars(description = "Display name for the codespace (max 48 chars)")] - display_name: Option, - #[tool(param)] - #[schemars(description = "Idle timeout before auto-stop, e.g. '10m', '1h'")] - idle_timeout: Option, - ) -> String { - let env = match auth::resolve_handle_env(&auth).await { - Ok(e) => e, - Err(e) => return format!("Auth error: {e}"), - }; - match codespaces::create( - &env, - &repo, - branch.as_deref(), - machine.as_deref(), - devcontainer_path.as_deref(), - display_name.as_deref(), - idle_timeout.as_deref(), - ) - .await - { - Ok(output) => format_output(&output), - Err(e) => format!("Error: {e}"), - } - } - - #[tool( - name = "codespaces_list", - description = "List your GitHub Codespaces. Requires a GitHub auth handle." - )] - async fn codespaces_list( - &self, - #[tool(param)] - #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] - auth: String, - #[tool(param)] - #[schemars(description = "Filter by repository (owner/repo format)")] - repo: Option, - ) -> String { - let env = match auth::resolve_handle_env(&auth).await { - Ok(e) => e, - Err(e) => return format!("Auth error: {e}"), - }; - match codespaces::list(&env, repo.as_deref()).await { - Ok(output) => format_output(&output), - Err(e) => format!("Error: {e}"), - } - } - - #[tool( - name = "codespaces_ssh", - description = "Execute a command inside a GitHub Codespace via SSH. Requires a GitHub auth handle." - )] - async fn codespaces_ssh( - &self, - #[tool(param)] - #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] - auth: String, - #[tool(param)] - #[schemars(description = "Codespace name (from codespaces_list or codespaces_create)")] - codespace: String, - #[tool(param)] - #[schemars(description = "Command to execute inside the codespace")] - command: String, - ) -> String { - let env = match auth::resolve_handle_env(&auth).await { - Ok(e) => e, - Err(e) => return format!("Auth error: {e}"), - }; - match codespaces::ssh_exec(&env, &codespace, &command).await { - Ok(output) => format_output(&output), - Err(e) => format!("Error: {e}"), - } - } - - #[tool( - name = "codespaces_stop", - description = "Stop a running GitHub Codespace. Requires a GitHub auth handle." - )] - async fn codespaces_stop( - &self, - #[tool(param)] - #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] - auth: String, - #[tool(param)] - #[schemars(description = "Codespace name")] - codespace: String, - ) -> String { - let env = match auth::resolve_handle_env(&auth).await { - Ok(e) => e, - Err(e) => return format!("Auth error: {e}"), - }; - match codespaces::stop(&env, &codespace).await { - Ok(output) => format_output(&output), - Err(e) => format!("Error: {e}"), - } - } - - #[tool( - name = "codespaces_delete", - description = "Delete a GitHub Codespace. Requires a GitHub auth handle." - )] - async fn codespaces_delete( - &self, - #[tool(param)] - #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] - auth: String, - #[tool(param)] - #[schemars(description = "Codespace name")] - codespace: String, - #[tool(param)] - #[schemars(description = "Force delete even with unsaved changes")] - force: Option, - ) -> String { - let env = match auth::resolve_handle_env(&auth).await { - Ok(e) => e, - Err(e) => return format!("Auth error: {e}"), - }; - match codespaces::delete(&env, &codespace, force.unwrap_or(false)).await { - Ok(output) => format_output(&output), - Err(e) => format!("Error: {e}"), - } - } - - #[tool( - name = "codespaces_view", - description = "View detailed information about a GitHub Codespace. Requires a GitHub auth handle." - )] - async fn codespaces_view( - &self, - #[tool(param)] - #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] - auth: String, - #[tool(param)] - #[schemars(description = "Codespace name")] - codespace: String, - ) -> String { - let env = match auth::resolve_handle_env(&auth).await { - Ok(e) => e, - Err(e) => return format!("Auth error: {e}"), - }; - match codespaces::view(&env, &codespace).await { - Ok(output) => format_output(&output), - Err(e) => format!("Error: {e}"), - } - } - - #[tool( - name = "codespaces_ports", - description = "List forwarded ports for a GitHub Codespace. Requires a GitHub auth handle." - )] - async fn codespaces_ports( - &self, - #[tool(param)] - #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] - auth: String, - #[tool(param)] - #[schemars(description = "Codespace name")] - codespace: String, - ) -> String { - let env = match auth::resolve_handle_env(&auth).await { - Ok(e) => e, - Err(e) => return format!("Auth error: {e}"), - }; - match codespaces::ports(&env, &codespace).await { - Ok(output) => format_output(&output), - Err(e) => format!("Error: {e}"), - } - } - - // ----------------------------------------------------------------------- - // Codespaces file operations - // ----------------------------------------------------------------------- - - #[tool( - name = "codespaces_file_read", - description = "Read file content from a GitHub Codespace. Returns content with line numbers. Supports optional line range. Requires a GitHub auth handle." - )] - async fn codespaces_file_read( - &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 = "Path to the file inside the codespace")] - path: String, - #[tool(param)] - #[schemars(description = "Start line number (1-based, inclusive)")] - start_line: Option, - #[tool(param)] - #[schemars(description = "End line number (1-based, inclusive). Use -1 or omit for end of file")] - end_line: Option, - ) -> String { - let env = match auth::resolve_handle_env(&auth).await { - Ok(e) => e, - Err(e) => return format!("Auth error: {e}"), - }; - match codespaces::file_read(&env, &codespace, &path).await { - Ok(output) => { - if output.exit_code != 0 { - return format!("Error (exit {}): {}", output.exit_code, output.stderr.trim()); - } - let end = end_line.and_then(|e| if e < 0 { None } else { Some(e as usize) }); - file_ops::format_with_line_numbers(&output.stdout, start_line, end) - } - Err(e) => format!("Error: {e}"), - } - } - - #[tool( - name = "codespaces_file_write", - description = "Create or overwrite a file in a GitHub Codespace. Creates parent directories automatically. Requires a GitHub auth handle." - )] - async fn codespaces_file_write( - &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 = "Path to the file inside the codespace")] - path: String, - #[tool(param)] - #[schemars(description = "File content to write")] - content: String, - ) -> String { - let env = match auth::resolve_handle_env(&auth).await { - Ok(e) => e, - Err(e) => return format!("Auth error: {e}"), - }; - match codespaces::file_write(&env, &codespace, &path, &content).await { - Ok(output) => { - if output.exit_code != 0 { - format!("Error (exit {}): {}", output.exit_code, output.stderr.trim()) - } else { - format!("File written: {path}") - } - } - Err(e) => format!("Error: {e}"), - } - } - - #[tool( - name = "codespaces_file_edit", - description = "Make a surgical edit to a file in a GitHub Codespace. Replaces exactly one occurrence of old_str with new_str. The old_str must match exactly one location in the file — include enough surrounding context to make it unique. Requires a GitHub auth handle." - )] - async fn codespaces_file_edit( - &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 = "Path to the file inside the codespace")] - path: String, - #[tool(param)] - #[schemars(description = "The exact string in the file to replace. Must match exactly once.")] - old_str: String, - #[tool(param)] - #[schemars(description = "The new string to replace old_str with")] - new_str: String, - ) -> String { - let env = match auth::resolve_handle_env(&auth).await { - Ok(e) => e, - Err(e) => return format!("Auth error: {e}"), - }; - match codespaces::file_edit(&env, &codespace, &path, &old_str, &new_str).await { - Ok(msg) => msg, - Err(e) => format!("Error: {e}"), - } - } - - #[tool( - name = "codespaces_file_list", - description = "List directory contents in a GitHub Codespace. Shows non-hidden files up to 2 levels deep. Requires a GitHub auth handle." - )] - async fn codespaces_file_list( - &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 = "Path to the directory inside the codespace (defaults to '.')")] - path: Option, - ) -> String { - let env = match auth::resolve_handle_env(&auth).await { - Ok(e) => e, - Err(e) => return format!("Auth error: {e}"), - }; - let dir = path.as_deref().unwrap_or("."); - match codespaces::file_list(&env, &codespace, dir).await { - Ok(output) => { - if output.exit_code != 0 { - format!("Error (exit {}): {}", output.exit_code, output.stderr.trim()) - } else { - output.stdout - } - } - 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/devcontainer-mcp/src/tools/auth/login.rs b/crates/devcontainer-mcp/src/tools/auth/login.rs new file mode 100644 index 0000000..09df55e --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/auth/login.rs @@ -0,0 +1,32 @@ +use devcontainer_mcp_core::auth; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +use crate::tools::DevContainerMcp; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct AuthLoginParams { + #[schemars(description = "Auth provider name (e.g. 'github')")] + provider: String, + #[schemars(description = "Additional OAuth scopes to request (e.g. 'codespace' for GitHub)")] + scopes: Option, +} + +#[tool_router(router = auth_login_router, vis = "pub(super)")] +impl DevContainerMcp { + #[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, Parameters(params): Parameters) -> String { + match auth::get_provider(¶ms.provider) { + Some(p) => match p.login(params.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: {}", params.provider), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/auth/logout.rs b/crates/devcontainer-mcp/src/tools/auth/logout.rs new file mode 100644 index 0000000..4635382 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/auth/logout.rs @@ -0,0 +1,31 @@ +use devcontainer_mcp_core::auth; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +use crate::tools::DevContainerMcp; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct AuthLogoutParams { + #[schemars( + description = "Auth handle to logout (e.g. 'github-aniongithub', 'azure-')" + )] + id: String, +} + +#[tool_router(router = auth_logout_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "auth_logout", + description = "Logout / revoke an authenticated account. Removes credentials from the provider's keyring." + )] + async fn auth_logout(&self, Parameters(params): Parameters) -> String { + let provider_name = auth::provider_from_handle(¶ms.id).unwrap_or("unknown"); + match auth::get_provider(provider_name) { + Some(p) => match p.logout(¶ms.id).await { + Ok(msg) => msg, + Err(e) => format!("Error: {e}"), + }, + None => format!("Unknown auth provider in handle: {}", params.id), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/auth/mod.rs b/crates/devcontainer-mcp/src/tools/auth/mod.rs new file mode 100644 index 0000000..2217d32 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/auth/mod.rs @@ -0,0 +1,17 @@ +mod login; +mod logout; +mod select; +mod status; + +use rmcp::handler::server::router::tool::ToolRouter; + +use super::DevContainerMcp; + +impl DevContainerMcp { + pub(super) fn auth_router() -> ToolRouter { + Self::auth_status_router() + + Self::auth_login_router() + + Self::auth_select_router() + + Self::auth_logout_router() + } +} diff --git a/crates/devcontainer-mcp/src/tools/auth/select.rs b/crates/devcontainer-mcp/src/tools/auth/select.rs new file mode 100644 index 0000000..1c78bc9 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/auth/select.rs @@ -0,0 +1,32 @@ +use devcontainer_mcp_core::auth; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +use crate::tools::DevContainerMcp; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct AuthSelectParams { + #[schemars(description = "Auth handle to switch to (e.g. 'github-aniongithub', 'aws-prod')")] + id: String, +} + +#[tool_router(router = auth_select_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "auth_select", + description = "Switch the active account for a provider. Returns account info if successful, null if the handle is invalid." + )] + async fn auth_select(&self, Parameters(params): Parameters) -> String { + let provider_name = auth::provider_from_handle(¶ms.id).unwrap_or("unknown"); + match auth::get_provider(provider_name) { + Some(p) => match p.select(¶ms.id).await { + Ok(Some(account)) => { + serde_json::to_string(&account).unwrap_or_else(|e| format!("Error: {e}")) + } + Ok(None) => format!("Failed to switch to: {}", params.id), + Err(e) => format!("Error: {e}"), + }, + None => format!("Unknown auth provider in handle: {}", params.id), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/auth/status.rs b/crates/devcontainer-mcp/src/tools/auth/status.rs new file mode 100644 index 0000000..b25f1ba --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/auth/status.rs @@ -0,0 +1,30 @@ +use devcontainer_mcp_core::auth; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +use crate::tools::DevContainerMcp; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct AuthStatusParams { + #[schemars(description = "Auth provider name (e.g. 'github', 'aws', 'azure', 'gcloud')")] + provider: String, +} + +#[tool_router(router = auth_status_router, vis = "pub(super)")] +impl DevContainerMcp { + #[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, Parameters(params): Parameters) -> String { + match auth::get_provider(¶ms.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: {}", params.provider), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/codespaces/create.rs b/crates/devcontainer-mcp/src/tools/codespaces/create.rs new file mode 100644 index 0000000..5d94fc6 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/codespaces/create.rs @@ -0,0 +1,59 @@ +use devcontainer_mcp_core::{auth, codespaces}; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct CodespacesCreateParams { + #[schemars( + description = "GitHub auth handle from auth_status/auth_login (e.g. 'github-aniongithub')" + )] + auth: String, + #[schemars(description = "Repository in owner/repo format")] + repo: String, + #[schemars(description = "Branch to create the codespace from")] + branch: Option, + #[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, + #[schemars(description = "Path to devcontainer.json within the repo")] + devcontainer_path: Option, + #[schemars(description = "Display name for the codespace (max 48 chars)")] + display_name: Option, + #[schemars(description = "Idle timeout before auto-stop, e.g. '10m', '1h'")] + idle_timeout: Option, +} + +#[tool_router(router = codespaces_create_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "codespaces_create", + description = "Create a new GitHub Codespace for a repository. Requires a GitHub auth handle (get one via auth_status or auth_login)." + )] + async fn codespaces_create( + &self, + Parameters(params): Parameters, + ) -> String { + let env = match auth::resolve_handle_env(¶ms.auth).await { + Ok(e) => e, + Err(e) => return format!("Auth error: {e}"), + }; + match codespaces::create( + &env, + ¶ms.repo, + params.branch.as_deref(), + params.machine.as_deref(), + params.devcontainer_path.as_deref(), + params.display_name.as_deref(), + params.idle_timeout.as_deref(), + ) + .await + { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/codespaces/delete.rs b/crates/devcontainer-mcp/src/tools/codespaces/delete.rs new file mode 100644 index 0000000..f3855b0 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/codespaces/delete.rs @@ -0,0 +1,37 @@ +use devcontainer_mcp_core::{auth, codespaces}; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct CodespacesDeleteParams { + #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] + auth: String, + #[schemars(description = "Codespace name")] + codespace: String, + #[schemars(description = "Force delete even with unsaved changes")] + force: Option, +} + +#[tool_router(router = codespaces_delete_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "codespaces_delete", + description = "Delete a GitHub Codespace. Requires a GitHub auth handle." + )] + async fn codespaces_delete( + &self, + Parameters(params): Parameters, + ) -> String { + let env = match auth::resolve_handle_env(¶ms.auth).await { + Ok(e) => e, + Err(e) => return format!("Auth error: {e}"), + }; + match codespaces::delete(&env, ¶ms.codespace, params.force.unwrap_or(false)).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/codespaces/files.rs b/crates/devcontainer-mcp/src/tools/codespaces/files.rs new file mode 100644 index 0000000..64cca19 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/codespaces/files.rs @@ -0,0 +1,173 @@ +use devcontainer_mcp_core::{auth, codespaces, file_ops}; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +use crate::tools::DevContainerMcp; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct CodespacesFileReadParams { + #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] + auth: String, + #[schemars(description = "Codespace name")] + codespace: String, + #[schemars(description = "Path to the file inside the codespace")] + path: String, + #[schemars(description = "Start line number (1-based, inclusive)")] + start_line: Option, + #[schemars( + description = "End line number (1-based, inclusive). Use -1 or omit for end of file" + )] + end_line: Option, +} + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct CodespacesFileWriteParams { + #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] + auth: String, + #[schemars(description = "Codespace name")] + codespace: String, + #[schemars(description = "Path to the file inside the codespace")] + path: String, + #[schemars(description = "File content to write")] + content: String, +} + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct CodespacesFileEditParams { + #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] + auth: String, + #[schemars(description = "Codespace name")] + codespace: String, + #[schemars(description = "Path to the file inside the codespace")] + path: String, + #[schemars(description = "The exact string in the file to replace. Must match exactly once.")] + old_str: String, + #[schemars(description = "The new string to replace old_str with")] + new_str: String, +} + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct CodespacesFileListParams { + #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] + auth: String, + #[schemars(description = "Codespace name")] + codespace: String, + #[schemars(description = "Path to the directory inside the codespace (defaults to '.')")] + path: Option, +} + +#[tool_router(router = codespaces_files_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "codespaces_file_read", + description = "Read file content from a GitHub Codespace. Returns content with line numbers. Supports optional line range. Requires a GitHub auth handle." + )] + async fn codespaces_file_read( + &self, + Parameters(params): Parameters, + ) -> String { + let env = match auth::resolve_handle_env(¶ms.auth).await { + Ok(e) => e, + Err(e) => return format!("Auth error: {e}"), + }; + match codespaces::file_read(&env, ¶ms.codespace, ¶ms.path).await { + Ok(output) => { + if output.exit_code != 0 { + return format!( + "Error (exit {}): {}", + output.exit_code, + output.stderr.trim() + ); + } + let end = params + .end_line + .and_then(|e| if e < 0 { None } else { Some(e as usize) }); + file_ops::format_with_line_numbers(&output.stdout, params.start_line, end) + } + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "codespaces_file_write", + description = "Create or overwrite a file in a GitHub Codespace. Creates parent directories automatically. Requires a GitHub auth handle." + )] + async fn codespaces_file_write( + &self, + Parameters(params): Parameters, + ) -> String { + let env = match auth::resolve_handle_env(¶ms.auth).await { + Ok(e) => e, + Err(e) => return format!("Auth error: {e}"), + }; + match codespaces::file_write(&env, ¶ms.codespace, ¶ms.path, ¶ms.content).await { + Ok(output) => { + if output.exit_code != 0 { + format!( + "Error (exit {}): {}", + output.exit_code, + output.stderr.trim() + ) + } else { + format!("File written: {}", params.path) + } + } + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "codespaces_file_edit", + description = "Make a surgical edit to a file in a GitHub Codespace. Replaces exactly one occurrence of old_str with new_str. The old_str must match exactly one location in the file — include enough surrounding context to make it unique. Requires a GitHub auth handle." + )] + async fn codespaces_file_edit( + &self, + Parameters(params): Parameters, + ) -> String { + let env = match auth::resolve_handle_env(¶ms.auth).await { + Ok(e) => e, + Err(e) => return format!("Auth error: {e}"), + }; + match codespaces::file_edit( + &env, + ¶ms.codespace, + ¶ms.path, + ¶ms.old_str, + ¶ms.new_str, + ) + .await + { + Ok(msg) => msg, + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "codespaces_file_list", + description = "List directory contents in a GitHub Codespace. Shows non-hidden files up to 2 levels deep. Requires a GitHub auth handle." + )] + async fn codespaces_file_list( + &self, + Parameters(params): Parameters, + ) -> String { + let env = match auth::resolve_handle_env(¶ms.auth).await { + Ok(e) => e, + Err(e) => return format!("Auth error: {e}"), + }; + let dir = params.path.as_deref().unwrap_or("."); + match codespaces::file_list(&env, ¶ms.codespace, dir).await { + Ok(output) => { + if output.exit_code != 0 { + format!( + "Error (exit {}): {}", + output.exit_code, + output.stderr.trim() + ) + } else { + output.stdout + } + } + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/codespaces/list.rs b/crates/devcontainer-mcp/src/tools/codespaces/list.rs new file mode 100644 index 0000000..2e38799 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/codespaces/list.rs @@ -0,0 +1,35 @@ +use devcontainer_mcp_core::{auth, codespaces}; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct CodespacesListParams { + #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] + auth: String, + #[schemars(description = "Filter by repository (owner/repo format)")] + repo: Option, +} + +#[tool_router(router = codespaces_list_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "codespaces_list", + description = "List your GitHub Codespaces. Requires a GitHub auth handle." + )] + async fn codespaces_list( + &self, + Parameters(params): Parameters, + ) -> String { + let env = match auth::resolve_handle_env(¶ms.auth).await { + Ok(e) => e, + Err(e) => return format!("Auth error: {e}"), + }; + match codespaces::list(&env, params.repo.as_deref()).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/codespaces/mod.rs b/crates/devcontainer-mcp/src/tools/codespaces/mod.rs new file mode 100644 index 0000000..9fc2009 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/codespaces/mod.rs @@ -0,0 +1,25 @@ +mod create; +mod delete; +mod files; +mod list; +mod ports; +mod ssh; +mod stop; +mod view; + +use rmcp::handler::server::router::tool::ToolRouter; + +use super::DevContainerMcp; + +impl DevContainerMcp { + pub(super) fn codespaces_router() -> ToolRouter { + Self::codespaces_create_router() + + Self::codespaces_list_router() + + Self::codespaces_ssh_router() + + Self::codespaces_stop_router() + + Self::codespaces_delete_router() + + Self::codespaces_view_router() + + Self::codespaces_ports_router() + + Self::codespaces_files_router() + } +} diff --git a/crates/devcontainer-mcp/src/tools/codespaces/ports.rs b/crates/devcontainer-mcp/src/tools/codespaces/ports.rs new file mode 100644 index 0000000..2eff2a2 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/codespaces/ports.rs @@ -0,0 +1,35 @@ +use devcontainer_mcp_core::{auth, codespaces}; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct CodespacesPortsParams { + #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] + auth: String, + #[schemars(description = "Codespace name")] + codespace: String, +} + +#[tool_router(router = codespaces_ports_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "codespaces_ports", + description = "List forwarded ports for a GitHub Codespace. Requires a GitHub auth handle." + )] + async fn codespaces_ports( + &self, + Parameters(params): Parameters, + ) -> String { + let env = match auth::resolve_handle_env(¶ms.auth).await { + Ok(e) => e, + Err(e) => return format!("Auth error: {e}"), + }; + match codespaces::ports(&env, ¶ms.codespace).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/codespaces/ssh.rs b/crates/devcontainer-mcp/src/tools/codespaces/ssh.rs new file mode 100644 index 0000000..6ff581f --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/codespaces/ssh.rs @@ -0,0 +1,34 @@ +use devcontainer_mcp_core::{auth, codespaces}; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct CodespacesSshParams { + #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] + auth: String, + #[schemars(description = "Codespace name (from codespaces_list or codespaces_create)")] + codespace: String, + #[schemars(description = "Command to execute inside the codespace")] + command: String, +} + +#[tool_router(router = codespaces_ssh_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "codespaces_ssh", + description = "Execute a command inside a GitHub Codespace via SSH. Requires a GitHub auth handle." + )] + async fn codespaces_ssh(&self, Parameters(params): Parameters) -> String { + let env = match auth::resolve_handle_env(¶ms.auth).await { + Ok(e) => e, + Err(e) => return format!("Auth error: {e}"), + }; + match codespaces::ssh_exec(&env, ¶ms.codespace, ¶ms.command).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/codespaces/stop.rs b/crates/devcontainer-mcp/src/tools/codespaces/stop.rs new file mode 100644 index 0000000..d41c1a6 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/codespaces/stop.rs @@ -0,0 +1,35 @@ +use devcontainer_mcp_core::{auth, codespaces}; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct CodespacesStopParams { + #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] + auth: String, + #[schemars(description = "Codespace name")] + codespace: String, +} + +#[tool_router(router = codespaces_stop_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "codespaces_stop", + description = "Stop a running GitHub Codespace. Requires a GitHub auth handle." + )] + async fn codespaces_stop( + &self, + Parameters(params): Parameters, + ) -> String { + let env = match auth::resolve_handle_env(¶ms.auth).await { + Ok(e) => e, + Err(e) => return format!("Auth error: {e}"), + }; + match codespaces::stop(&env, ¶ms.codespace).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/codespaces/view.rs b/crates/devcontainer-mcp/src/tools/codespaces/view.rs new file mode 100644 index 0000000..897be0c --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/codespaces/view.rs @@ -0,0 +1,35 @@ +use devcontainer_mcp_core::{auth, codespaces}; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct CodespacesViewParams { + #[schemars(description = "GitHub auth handle (e.g. 'github-aniongithub')")] + auth: String, + #[schemars(description = "Codespace name")] + codespace: String, +} + +#[tool_router(router = codespaces_view_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "codespaces_view", + description = "View detailed information about a GitHub Codespace. Requires a GitHub auth handle." + )] + async fn codespaces_view( + &self, + Parameters(params): Parameters, + ) -> String { + let env = match auth::resolve_handle_env(¶ms.auth).await { + Ok(e) => e, + Err(e) => return format!("Auth error: {e}"), + }; + match codespaces::view(&env, ¶ms.codespace).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/common.rs b/crates/devcontainer-mcp/src/tools/common.rs new file mode 100644 index 0000000..9ef4902 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/common.rs @@ -0,0 +1,12 @@ +use devcontainer_mcp_core::cli::CliOutput; + +/// Format a CliOutput as a JSON string for MCP responses. +pub 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() +} diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/build.rs b/crates/devcontainer-mcp/src/tools/devcontainer/build.rs new file mode 100644 index 0000000..caea2c3 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devcontainer/build.rs @@ -0,0 +1,38 @@ +use devcontainer_mcp_core::devcontainer; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevcontainerBuildParams { + #[schemars(description = "Path to the workspace folder")] + workspace_folder: String, + #[schemars( + description = "Additional flags as space-separated args, e.g. '--no-cache --image-name my-image'" + )] + extra_args: Option, +} + +#[tool_router(router = devcontainer_build_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "devcontainer_build", + description = "Build a dev container image without starting it." + )] + async fn devcontainer_build( + &self, + Parameters(params): Parameters, + ) -> String { + let extra: Vec<&str> = params + .extra_args + .as_deref() + .map(|a| a.split_whitespace().collect()) + .unwrap_or_default(); + match devcontainer::build(¶ms.workspace_folder, &extra).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/config.rs b/crates/devcontainer-mcp/src/tools/devcontainer/config.rs new file mode 100644 index 0000000..667faf1 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devcontainer/config.rs @@ -0,0 +1,33 @@ +use devcontainer_mcp_core::devcontainer; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevcontainerReadConfigParams { + #[schemars(description = "Path to the workspace folder")] + workspace_folder: String, + #[schemars(description = "Path to a specific devcontainer.json")] + config: Option, +} + +#[tool_router(router = devcontainer_config_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "devcontainer_read_config", + description = "Read and return the merged devcontainer configuration as JSON." + )] + async fn devcontainer_read_config( + &self, + Parameters(params): Parameters, + ) -> String { + match devcontainer::read_configuration(¶ms.workspace_folder, params.config.as_deref()) + .await + { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/exec.rs b/crates/devcontainer-mcp/src/tools/devcontainer/exec.rs new file mode 100644 index 0000000..e97a521 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devcontainer/exec.rs @@ -0,0 +1,38 @@ +use devcontainer_mcp_core::devcontainer; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevcontainerExecParams { + #[schemars(description = "Path to the workspace folder")] + workspace_folder: String, + #[schemars(description = "Command to execute inside the container")] + command: String, + #[schemars(description = "Arguments for the command as a space-separated string")] + args: Option, +} + +#[tool_router(router = devcontainer_exec_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "devcontainer_exec", + description = "Execute a command inside a running local dev container." + )] + async fn devcontainer_exec( + &self, + Parameters(params): Parameters, + ) -> String { + let cmd_args: Vec<&str> = params + .args + .as_deref() + .map(|a| a.split_whitespace().collect()) + .unwrap_or_default(); + match devcontainer::exec(¶ms.workspace_folder, ¶ms.command, &cmd_args).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/files.rs b/crates/devcontainer-mcp/src/tools/devcontainer/files.rs new file mode 100644 index 0000000..d22ba25 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devcontainer/files.rs @@ -0,0 +1,150 @@ +use devcontainer_mcp_core::{devcontainer, file_ops}; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +use crate::tools::DevContainerMcp; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevcontainerFileReadParams { + #[schemars(description = "Path to the workspace folder")] + workspace_folder: String, + #[schemars(description = "Path to the file inside the container")] + path: String, + #[schemars(description = "Start line number (1-based, inclusive)")] + start_line: Option, + #[schemars( + description = "End line number (1-based, inclusive). Use -1 or omit for end of file" + )] + end_line: Option, +} + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevcontainerFileWriteParams { + #[schemars(description = "Path to the workspace folder")] + workspace_folder: String, + #[schemars(description = "Path to the file inside the container")] + path: String, + #[schemars(description = "File content to write")] + content: String, +} + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevcontainerFileEditParams { + #[schemars(description = "Path to the workspace folder")] + workspace_folder: String, + #[schemars(description = "Path to the file inside the container")] + path: String, + #[schemars(description = "The exact string in the file to replace. Must match exactly once.")] + old_str: String, + #[schemars(description = "The new string to replace old_str with")] + new_str: String, +} + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevcontainerFileListParams { + #[schemars(description = "Path to the workspace folder")] + workspace_folder: String, + #[schemars(description = "Path to the directory inside the container (defaults to '.')")] + path: Option, +} + +#[tool_router(router = devcontainer_files_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "devcontainer_file_read", + description = "Read file content from a local dev container. Returns content with line numbers. Supports optional line range." + )] + async fn devcontainer_file_read( + &self, + Parameters(params): Parameters, + ) -> String { + match devcontainer::file_read(¶ms.workspace_folder, ¶ms.path).await { + Ok(output) => { + if output.exit_code != 0 { + return format!( + "Error (exit {}): {}", + output.exit_code, + output.stderr.trim() + ); + } + let end = params + .end_line + .and_then(|e| if e < 0 { None } else { Some(e as usize) }); + file_ops::format_with_line_numbers(&output.stdout, params.start_line, end) + } + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devcontainer_file_write", + description = "Create or overwrite a file in a local dev container. Creates parent directories automatically." + )] + async fn devcontainer_file_write( + &self, + Parameters(params): Parameters, + ) -> String { + match devcontainer::file_write(¶ms.workspace_folder, ¶ms.path, ¶ms.content) + .await + { + Ok(output) => { + if output.exit_code != 0 { + format!( + "Error (exit {}): {}", + output.exit_code, + output.stderr.trim() + ) + } else { + format!("File written: {}", params.path) + } + } + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devcontainer_file_edit", + description = "Make a surgical edit to a file in a local dev container. Replaces exactly one occurrence of old_str with new_str. The old_str must match exactly one location in the file — include enough surrounding context to make it unique." + )] + async fn devcontainer_file_edit( + &self, + Parameters(params): Parameters, + ) -> String { + match devcontainer::file_edit( + ¶ms.workspace_folder, + ¶ms.path, + ¶ms.old_str, + ¶ms.new_str, + ) + .await + { + Ok(msg) => msg, + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devcontainer_file_list", + description = "List directory contents in a local dev container. Shows non-hidden files up to 2 levels deep." + )] + async fn devcontainer_file_list( + &self, + Parameters(params): Parameters, + ) -> String { + let dir = params.path.as_deref().unwrap_or("."); + match devcontainer::file_list(¶ms.workspace_folder, dir).await { + Ok(output) => { + if output.exit_code != 0 { + format!( + "Error (exit {}): {}", + output.exit_code, + output.stderr.trim() + ) + } else { + output.stdout + } + } + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/mod.rs b/crates/devcontainer-mcp/src/tools/devcontainer/mod.rs new file mode 100644 index 0000000..08de29b --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devcontainer/mod.rs @@ -0,0 +1,25 @@ +mod build; +mod config; +mod exec; +mod files; +mod remove; +mod status; +mod stop; +mod up; + +use rmcp::handler::server::router::tool::ToolRouter; + +use super::DevContainerMcp; + +impl DevContainerMcp { + pub(super) fn devcontainer_router() -> ToolRouter { + Self::devcontainer_up_router() + + Self::devcontainer_exec_router() + + Self::devcontainer_build_router() + + Self::devcontainer_config_router() + + Self::devcontainer_stop_router() + + Self::devcontainer_remove_router() + + Self::devcontainer_status_router() + + Self::devcontainer_files_router() + } +} diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/remove.rs b/crates/devcontainer-mcp/src/tools/devcontainer/remove.rs new file mode 100644 index 0000000..dabfe53 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devcontainer/remove.rs @@ -0,0 +1,30 @@ +use devcontainer_mcp_core::devcontainer; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +use crate::tools::DevContainerMcp; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevcontainerRemoveParams { + #[schemars(description = "Path to the workspace folder (used to find the container by label)")] + workspace_folder: String, + #[schemars(description = "Force removal even if the container is running")] + force: Option, +} + +#[tool_router(router = devcontainer_remove_router, vis = "pub(super)")] +impl DevContainerMcp { + #[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, + Parameters(params): Parameters, + ) -> String { + match devcontainer::remove(¶ms.workspace_folder, params.force.unwrap_or(false)).await { + Ok(msg) => msg, + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/status.rs b/crates/devcontainer-mcp/src/tools/devcontainer/status.rs new file mode 100644 index 0000000..c8de498 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devcontainer/status.rs @@ -0,0 +1,31 @@ +use devcontainer_mcp_core::devcontainer; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +use crate::tools::DevContainerMcp; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevcontainerStatusParams { + #[schemars(description = "Path to the workspace folder")] + workspace_folder: String, +} + +#[tool_router(router = devcontainer_status_router, vis = "pub(super)")] +impl DevContainerMcp { + #[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, + Parameters(params): Parameters, + ) -> String { + match devcontainer::status(¶ms.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}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/stop.rs b/crates/devcontainer-mcp/src/tools/devcontainer/stop.rs new file mode 100644 index 0000000..38acf79 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devcontainer/stop.rs @@ -0,0 +1,28 @@ +use devcontainer_mcp_core::devcontainer; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +use crate::tools::DevContainerMcp; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevcontainerStopParams { + #[schemars(description = "Path to the workspace folder (used to find the container by label)")] + workspace_folder: String, +} + +#[tool_router(router = devcontainer_stop_router, vis = "pub(super)")] +impl DevContainerMcp { + #[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, + Parameters(params): Parameters, + ) -> String { + match devcontainer::stop(¶ms.workspace_folder).await { + Ok(msg) => msg, + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/up.rs b/crates/devcontainer-mcp/src/tools/devcontainer/up.rs new file mode 100644 index 0000000..7c9955d --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devcontainer/up.rs @@ -0,0 +1,42 @@ +use devcontainer_mcp_core::devcontainer; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevcontainerUpParams { + #[schemars( + description = "Path to the workspace folder containing .devcontainer/devcontainer.json" + )] + workspace_folder: String, + #[schemars(description = "Path to a specific devcontainer.json (overrides auto-detection)")] + config: Option, + #[schemars( + description = "Additional flags as space-separated args, e.g. '--remove-existing-container --build-no-cache'" + )] + extra_args: Option, +} + +#[tool_router(router = devcontainer_up_router, vis = "pub(super)")] +impl DevContainerMcp { + #[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, + Parameters(params): Parameters, + ) -> String { + let extra: Vec<&str> = params + .extra_args + .as_deref() + .map(|a| a.split_whitespace().collect()) + .unwrap_or_default(); + match devcontainer::up(¶ms.workspace_folder, params.config.as_deref(), &extra).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/devpod/build.rs b/crates/devcontainer-mcp/src/tools/devpod/build.rs new file mode 100644 index 0000000..6ce4642 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devpod/build.rs @@ -0,0 +1,28 @@ +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; +use devcontainer_mcp_core::devpod; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevpodBuildParams { + #[schemars( + description = "All arguments for 'devpod build', e.g. 'my-workspace --provider docker'" + )] + args: String, +} + +#[tool_router(router = devpod_build_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "devpod_build", + description = "Build a DevPod workspace image without starting it." + )] + async fn devpod_build(&self, Parameters(params): Parameters) -> String { + let parts: Vec<&str> = params.args.split_whitespace().collect(); + match devpod::build(&parts).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/devpod/container.rs b/crates/devcontainer-mcp/src/tools/devpod/container.rs new file mode 100644 index 0000000..f36057f --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devpod/container.rs @@ -0,0 +1,58 @@ +use crate::tools::DevContainerMcp; +use devcontainer_mcp_core::docker; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevpodContainerInspectParams { + #[schemars(description = "Container name or ID")] + container: String, +} + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevpodContainerLogsParams { + #[schemars(description = "Container name or ID")] + container: String, + #[serde(default)] + #[schemars(description = "Number of lines from the end to return (0 = all)")] + tail: Option, +} + +#[tool_router(router = devpod_container_router, vis = "pub(super)")] +impl DevContainerMcp { + #[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 devpod_container_inspect( + &self, + Parameters(params): Parameters, + ) -> String { + let client = match docker::connect() { + Ok(c) => c, + Err(e) => return format!("Error connecting to Docker: {e}"), + }; + match docker::inspect_container(&client, ¶ms.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 devpod_container_logs( + &self, + Parameters(params): Parameters, + ) -> String { + let client = match docker::connect() { + Ok(c) => c, + Err(e) => return format!("Error connecting to Docker: {e}"), + }; + match docker::container_logs(&client, ¶ms.container, params.tail.unwrap_or(100)).await { + Ok(logs) => logs, + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/devpod/context.rs b/crates/devcontainer-mcp/src/tools/devpod/context.rs new file mode 100644 index 0000000..919b72b --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devpod/context.rs @@ -0,0 +1,39 @@ +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; +use devcontainer_mcp_core::devpod; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevpodContextUseParams { + #[schemars(description = "Context name to switch to")] + context: String, +} + +#[tool_router(router = devpod_context_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "devpod_context_list", + description = "List all DevPod contexts." + )] + async fn devpod_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 devpod_context_use( + &self, + Parameters(params): Parameters, + ) -> String { + match devpod::context_use(¶ms.context).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/devpod/delete.rs b/crates/devcontainer-mcp/src/tools/devpod/delete.rs new file mode 100644 index 0000000..b6a785e --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devpod/delete.rs @@ -0,0 +1,28 @@ +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; +use devcontainer_mcp_core::devpod; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevpodDeleteParams { + #[schemars(description = "Workspace name or ID")] + workspace: String, + #[serde(default)] + #[schemars(description = "Force delete even if workspace is not found remotely")] + force: Option, +} + +#[tool_router(router = devpod_delete_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "devpod_delete", + description = "Delete a DevPod workspace. Stops and removes all associated resources." + )] + async fn devpod_delete(&self, Parameters(params): Parameters) -> String { + match devpod::delete(¶ms.workspace, params.force.unwrap_or(false)).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/devpod/files.rs b/crates/devcontainer-mcp/src/tools/devpod/files.rs new file mode 100644 index 0000000..5bb4094 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devpod/files.rs @@ -0,0 +1,170 @@ +use crate::tools::DevContainerMcp; +use devcontainer_mcp_core::{devpod, file_ops}; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevpodFileReadParams { + #[schemars(description = "Workspace name or ID")] + workspace: String, + #[schemars(description = "Path to the file inside the workspace")] + path: String, + #[serde(default)] + #[schemars(description = "Start line number (1-based, inclusive)")] + start_line: Option, + #[serde(default)] + #[schemars( + description = "End line number (1-based, inclusive). Use -1 or omit for end of file" + )] + end_line: Option, + #[serde(default)] + #[schemars(description = "User to run the command as")] + user: Option, +} + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevpodFileWriteParams { + #[schemars(description = "Workspace name or ID")] + workspace: String, + #[schemars(description = "Path to the file inside the workspace")] + path: String, + #[schemars(description = "File content to write")] + content: String, + #[serde(default)] + #[schemars(description = "User to run the command as")] + user: Option, +} + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevpodFileEditParams { + #[schemars(description = "Workspace name or ID")] + workspace: String, + #[schemars(description = "Path to the file inside the workspace")] + path: String, + #[schemars(description = "The exact string in the file to replace. Must match exactly once.")] + old_str: String, + #[schemars(description = "The new string to replace old_str with")] + new_str: String, + #[serde(default)] + #[schemars(description = "User to run the command as")] + user: Option, +} + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevpodFileListParams { + #[schemars(description = "Workspace name or ID")] + workspace: String, + #[serde(default)] + #[schemars(description = "Path to the directory inside the workspace (defaults to '.')")] + path: Option, + #[serde(default)] + #[schemars(description = "User to run the command as")] + user: Option, +} + +#[tool_router(router = devpod_files_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "devpod_file_read", + description = "Read file content from a DevPod workspace. Returns content with line numbers. Supports optional line range." + )] + async fn devpod_file_read( + &self, + Parameters(params): Parameters, + ) -> String { + match devpod::file_read(¶ms.workspace, ¶ms.path, params.user.as_deref()).await { + Ok(output) => { + if output.exit_code != 0 { + return format!( + "Error (exit {}): {}", + output.exit_code, + output.stderr.trim() + ); + } + let end = params + .end_line + .and_then(|e| if e < 0 { None } else { Some(e as usize) }); + file_ops::format_with_line_numbers(&output.stdout, params.start_line, end) + } + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devpod_file_write", + description = "Create or overwrite a file in a DevPod workspace. Creates parent directories automatically." + )] + async fn devpod_file_write( + &self, + Parameters(params): Parameters, + ) -> String { + match devpod::file_write( + ¶ms.workspace, + ¶ms.path, + ¶ms.content, + params.user.as_deref(), + ) + .await + { + Ok(output) => { + if output.exit_code != 0 { + format!( + "Error (exit {}): {}", + output.exit_code, + output.stderr.trim() + ) + } else { + format!("File written: {}", params.path) + } + } + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devpod_file_edit", + description = "Make a surgical edit to a file in a DevPod workspace. Replaces exactly one occurrence of old_str with new_str. The old_str must match exactly one location in the file — include enough surrounding context to make it unique." + )] + async fn devpod_file_edit( + &self, + Parameters(params): Parameters, + ) -> String { + match devpod::file_edit( + ¶ms.workspace, + ¶ms.path, + ¶ms.old_str, + ¶ms.new_str, + params.user.as_deref(), + ) + .await + { + Ok(msg) => msg, + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "devpod_file_list", + description = "List directory contents in a DevPod workspace. Shows non-hidden files up to 2 levels deep." + )] + async fn devpod_file_list( + &self, + Parameters(params): Parameters, + ) -> String { + let dir = params.path.as_deref().unwrap_or("."); + match devpod::file_list(¶ms.workspace, dir, params.user.as_deref()).await { + Ok(output) => { + if output.exit_code != 0 { + format!( + "Error (exit {}): {}", + output.exit_code, + output.stderr.trim() + ) + } else { + output.stdout + } + } + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/devpod/list.rs b/crates/devcontainer-mcp/src/tools/devpod/list.rs new file mode 100644 index 0000000..0650804 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devpod/list.rs @@ -0,0 +1,18 @@ +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; +use devcontainer_mcp_core::devpod; +use rmcp::{tool, tool_router}; + +#[tool_router(router = devpod_list_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "devpod_list", + description = "List all DevPod workspaces. Returns JSON array with workspace IDs, sources, providers, and status." + )] + async fn devpod_list(&self) -> String { + match devpod::list().await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/devpod/logs.rs b/crates/devcontainer-mcp/src/tools/devpod/logs.rs new file mode 100644 index 0000000..e4f5cf2 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devpod/logs.rs @@ -0,0 +1,25 @@ +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; +use devcontainer_mcp_core::devpod; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevpodLogsParams { + #[schemars(description = "Workspace name or ID")] + workspace: String, +} + +#[tool_router(router = devpod_logs_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "devpod_logs", + description = "Get logs from a DevPod workspace." + )] + async fn devpod_logs(&self, Parameters(params): Parameters) -> String { + match devpod::logs(¶ms.workspace).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/devpod/mod.rs b/crates/devcontainer-mcp/src/tools/devpod/mod.rs new file mode 100644 index 0000000..ebe0cf5 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devpod/mod.rs @@ -0,0 +1,33 @@ +mod build; +mod container; +mod context; +mod delete; +mod files; +mod list; +mod logs; +mod provider; +mod ssh; +mod status; +mod stop; +mod up; + +use rmcp::handler::server::router::tool::ToolRouter; + +use super::DevContainerMcp; + +impl DevContainerMcp { + pub(super) fn devpod_router() -> ToolRouter { + Self::devpod_up_router() + + Self::devpod_stop_router() + + Self::devpod_delete_router() + + Self::devpod_build_router() + + Self::devpod_status_router() + + Self::devpod_list_router() + + Self::devpod_ssh_router() + + Self::devpod_logs_router() + + Self::devpod_provider_router() + + Self::devpod_context_router() + + Self::devpod_container_router() + + Self::devpod_files_router() + } +} diff --git a/crates/devcontainer-mcp/src/tools/devpod/provider.rs b/crates/devcontainer-mcp/src/tools/devpod/provider.rs new file mode 100644 index 0000000..24ead82 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devpod/provider.rs @@ -0,0 +1,64 @@ +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; +use devcontainer_mcp_core::devpod; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevpodProviderAddParams { + #[schemars(description = "Provider name or URL to add")] + provider: String, + #[serde(default)] + #[schemars(description = "Additional options as space-separated KEY=VALUE pairs")] + options: Option, +} + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevpodProviderDeleteParams { + #[schemars(description = "Provider name to delete")] + provider: String, +} + +#[tool_router(router = devpod_provider_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "devpod_provider_list", + description = "List all configured DevPod providers." + )] + async fn devpod_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 devpod_provider_add( + &self, + Parameters(params): Parameters, + ) -> String { + let opt_parts: Vec<&str> = params + .options + .as_deref() + .map(|o| o.split_whitespace().collect()) + .unwrap_or_default(); + match devpod::provider_add(¶ms.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 devpod_provider_delete( + &self, + Parameters(params): Parameters, + ) -> String { + match devpod::provider_delete(¶ms.provider).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/devpod/ssh.rs b/crates/devcontainer-mcp/src/tools/devpod/ssh.rs new file mode 100644 index 0000000..f1cf55e --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devpod/ssh.rs @@ -0,0 +1,40 @@ +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; +use devcontainer_mcp_core::devpod; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevpodSshParams { + #[schemars(description = "Workspace name or ID")] + workspace: String, + #[schemars(description = "Command to execute inside the workspace")] + command: String, + #[serde(default)] + #[schemars(description = "User to run the command as")] + user: Option, + #[serde(default)] + #[schemars(description = "Working directory inside the workspace")] + workdir: Option, +} + +#[tool_router(router = devpod_ssh_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "devpod_ssh", + description = "Execute a command inside a DevPod workspace via SSH. Returns stdout, stderr, and exit code." + )] + async fn devpod_ssh(&self, Parameters(params): Parameters) -> String { + match devpod::ssh_exec( + ¶ms.workspace, + ¶ms.command, + params.user.as_deref(), + params.workdir.as_deref(), + ) + .await + { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/devpod/status.rs b/crates/devcontainer-mcp/src/tools/devpod/status.rs new file mode 100644 index 0000000..7e7958c --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devpod/status.rs @@ -0,0 +1,28 @@ +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; +use devcontainer_mcp_core::devpod; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevpodStatusParams { + #[schemars(description = "Workspace name or ID")] + workspace: String, + #[serde(default)] + #[schemars(description = "Timeout for status check, e.g. '30s'")] + timeout: Option, +} + +#[tool_router(router = devpod_status_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "devpod_status", + description = "Get the status of a DevPod workspace. Returns structured JSON with state (Running, Stopped, Busy, NotFound)." + )] + async fn devpod_status(&self, Parameters(params): Parameters) -> String { + match devpod::status(¶ms.workspace, params.timeout.as_deref()).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/devpod/stop.rs b/crates/devcontainer-mcp/src/tools/devpod/stop.rs new file mode 100644 index 0000000..1303a7d --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devpod/stop.rs @@ -0,0 +1,22 @@ +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; +use devcontainer_mcp_core::devpod; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevpodStopParams { + #[schemars(description = "Workspace name or ID")] + workspace: String, +} + +#[tool_router(router = devpod_stop_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool(name = "devpod_stop", description = "Stop a running DevPod workspace.")] + async fn devpod_stop(&self, Parameters(params): Parameters) -> String { + match devpod::stop(¶ms.workspace).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/devpod/up.rs b/crates/devcontainer-mcp/src/tools/devpod/up.rs new file mode 100644 index 0000000..b0366c5 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devpod/up.rs @@ -0,0 +1,28 @@ +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; +use devcontainer_mcp_core::devpod; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevpodUpParams { + #[schemars( + description = "All arguments for 'devpod up', e.g. 'https://github.com/org/repo --provider docker --id my-ws'" + )] + args: String, +} + +#[tool_router(router = devpod_up_router, vis = "pub(super)")] +impl DevContainerMcp { + #[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 devpod_up(&self, Parameters(params): Parameters) -> String { + let parts: Vec<&str> = params.args.split_whitespace().collect(); + match devpod::up(&parts).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/mod.rs b/crates/devcontainer-mcp/src/tools/mod.rs new file mode 100644 index 0000000..4e6f4f1 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/mod.rs @@ -0,0 +1,47 @@ +mod auth; +mod codespaces; +pub mod common; +mod devcontainer; +mod devpod; +#[cfg(target_os = "windows")] +mod wsl; + +use rmcp::handler::server::router::tool::ToolRouter; +use rmcp::model::{ServerCapabilities, ServerInfo}; +use rmcp::{tool_handler, ServerHandler}; + +#[derive(Debug, Clone)] +pub struct DevContainerMcp; + +impl DevContainerMcp { + pub fn new() -> Self { + Self + } + + fn combined_router() -> ToolRouter { + let r = Self::devpod_router() + + Self::devcontainer_router() + + Self::codespaces_router() + + Self::auth_router(); + #[cfg(target_os = "windows")] + let r = r + Self::wsl_router(); + r + } +} + +#[tool_handler(router = Self::combined_router())] +impl ServerHandler for DevContainerMcp { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(rmcp::model::Implementation::new( + "devcontainer-mcp", + env!("CARGO_PKG_VERSION"), + )) + .with_instructions( + "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.", + ) + } +} diff --git a/crates/devcontainer-mcp/src/tools/wsl/exec.rs b/crates/devcontainer-mcp/src/tools/wsl/exec.rs new file mode 100644 index 0000000..92f3a32 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/wsl/exec.rs @@ -0,0 +1,27 @@ +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; +use devcontainer_mcp_core::wsl; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct WslExecParams { + #[schemars(description = "WSL distribution name")] + distro: String, + #[schemars(description = "Command to execute")] + command: String, +} + +#[tool_router(router = wsl_exec_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "wsl_exec", + description = "Execute a command inside a WSL distribution. Returns stdout, stderr, and exit code." + )] + async fn wsl_exec(&self, Parameters(params): Parameters) -> String { + match wsl::exec(¶ms.distro, ¶ms.command).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/wsl/files.rs b/crates/devcontainer-mcp/src/tools/wsl/files.rs new file mode 100644 index 0000000..25376fa --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/wsl/files.rs @@ -0,0 +1,138 @@ +use crate::tools::DevContainerMcp; +use devcontainer_mcp_core::{file_ops, wsl}; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct WslFileReadParams { + #[schemars(description = "WSL distribution name")] + distro: String, + #[schemars(description = "Path to the file inside the distribution")] + path: String, + #[serde(default)] + #[schemars(description = "Start line number (1-based, inclusive)")] + start_line: Option, + #[serde(default)] + #[schemars( + description = "End line number (1-based, inclusive). Use -1 or omit for end of file" + )] + end_line: Option, +} + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct WslFileWriteParams { + #[schemars(description = "WSL distribution name")] + distro: String, + #[schemars(description = "Path to the file inside the distribution")] + path: String, + #[schemars(description = "File content to write")] + content: String, +} + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct WslFileEditParams { + #[schemars(description = "WSL distribution name")] + distro: String, + #[schemars(description = "Path to the file inside the distribution")] + path: String, + #[schemars(description = "The exact string in the file to replace. Must match exactly once.")] + old_str: String, + #[schemars(description = "The new string to replace old_str with")] + new_str: String, +} + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct WslFileListParams { + #[schemars(description = "WSL distribution name")] + distro: String, + #[serde(default)] + #[schemars(description = "Path to the directory inside the distribution (defaults to '.')")] + path: Option, +} + +#[tool_router(router = wsl_files_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "wsl_file_read", + description = "Read file content from a WSL distribution. Returns content with line numbers. Supports optional line range." + )] + async fn wsl_file_read(&self, Parameters(params): Parameters) -> String { + match wsl::file_read(¶ms.distro, ¶ms.path).await { + Ok(output) => { + if output.exit_code != 0 { + return format!( + "Error (exit {}): {}", + output.exit_code, + output.stderr.trim() + ); + } + let end = params + .end_line + .and_then(|e| if e < 0 { None } else { Some(e as usize) }); + file_ops::format_with_line_numbers(&output.stdout, params.start_line, end) + } + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "wsl_file_write", + description = "Create or overwrite a file in a WSL distribution. Creates parent directories automatically." + )] + async fn wsl_file_write(&self, Parameters(params): Parameters) -> String { + match wsl::file_write(¶ms.distro, ¶ms.path, ¶ms.content).await { + Ok(output) => { + if output.exit_code != 0 { + format!( + "Error (exit {}): {}", + output.exit_code, + output.stderr.trim() + ) + } else { + format!("File written: {}", params.path) + } + } + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "wsl_file_edit", + description = "Make a surgical edit to a file in a WSL distribution. Replaces exactly one occurrence of old_str with new_str. The old_str must match exactly one location in the file — include enough surrounding context to make it unique." + )] + async fn wsl_file_edit(&self, Parameters(params): Parameters) -> String { + match wsl::file_edit( + ¶ms.distro, + ¶ms.path, + ¶ms.old_str, + ¶ms.new_str, + ) + .await + { + Ok(msg) => msg, + Err(e) => format!("Error: {e}"), + } + } + + #[tool( + name = "wsl_file_list", + description = "List directory contents in a WSL distribution. Shows non-hidden files up to 2 levels deep." + )] + async fn wsl_file_list(&self, Parameters(params): Parameters) -> String { + let dir = params.path.as_deref().unwrap_or("."); + match wsl::file_list(¶ms.distro, dir).await { + Ok(output) => { + if output.exit_code != 0 { + format!( + "Error (exit {}): {}", + output.exit_code, + output.stderr.trim() + ) + } else { + output.stdout + } + } + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/wsl/list.rs b/crates/devcontainer-mcp/src/tools/wsl/list.rs new file mode 100644 index 0000000..c7a28d3 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/wsl/list.rs @@ -0,0 +1,18 @@ +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; +use devcontainer_mcp_core::wsl; +use rmcp::{tool, tool_router}; + +#[tool_router(router = wsl_list_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "wsl_list", + description = "List WSL distributions with their state and version." + )] + async fn wsl_list(&self) -> String { + match wsl::list().await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/wsl/mod.rs b/crates/devcontainer-mcp/src/tools/wsl/mod.rs new file mode 100644 index 0000000..c3f3f60 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/wsl/mod.rs @@ -0,0 +1,21 @@ +mod exec; +mod files; +mod list; +mod set_default; +mod shutdown; +mod stop; + +use rmcp::handler::server::router::tool::ToolRouter; + +use super::DevContainerMcp; + +impl DevContainerMcp { + pub(super) fn wsl_router() -> ToolRouter { + Self::wsl_list_router() + + Self::wsl_exec_router() + + Self::wsl_stop_router() + + Self::wsl_shutdown_router() + + Self::wsl_set_default_router() + + Self::wsl_files_router() + } +} diff --git a/crates/devcontainer-mcp/src/tools/wsl/set_default.rs b/crates/devcontainer-mcp/src/tools/wsl/set_default.rs new file mode 100644 index 0000000..e407643 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/wsl/set_default.rs @@ -0,0 +1,25 @@ +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; +use devcontainer_mcp_core::wsl; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct WslSetDefaultParams { + #[schemars(description = "WSL distribution name to set as default")] + distro: String, +} + +#[tool_router(router = wsl_set_default_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "wsl_set_default", + description = "Set the default WSL distribution." + )] + async fn wsl_set_default(&self, Parameters(params): Parameters) -> String { + match wsl::set_default(¶ms.distro).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/wsl/shutdown.rs b/crates/devcontainer-mcp/src/tools/wsl/shutdown.rs new file mode 100644 index 0000000..5266870 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/wsl/shutdown.rs @@ -0,0 +1,18 @@ +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; +use devcontainer_mcp_core::wsl; +use rmcp::{tool, tool_router}; + +#[tool_router(router = wsl_shutdown_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "wsl_shutdown", + description = "Shut down all running WSL distributions." + )] + async fn wsl_shutdown(&self) -> String { + match wsl::shutdown().await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/crates/devcontainer-mcp/src/tools/wsl/stop.rs b/crates/devcontainer-mcp/src/tools/wsl/stop.rs new file mode 100644 index 0000000..f275f55 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/wsl/stop.rs @@ -0,0 +1,25 @@ +use crate::tools::common::format_output; +use crate::tools::DevContainerMcp; +use devcontainer_mcp_core::wsl; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct WslTerminateParams { + #[schemars(description = "WSL distribution name to terminate")] + distro: String, +} + +#[tool_router(router = wsl_stop_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "wsl_terminate", + description = "Terminate (stop) a running WSL distribution." + )] + async fn wsl_terminate(&self, Parameters(params): Parameters) -> String { + match wsl::terminate(¶ms.distro).await { + Ok(output) => format_output(&output), + Err(e) => format!("Error: {e}"), + } + } +} diff --git a/skills/_tools/auth.txt b/skills/_tools/auth.txt new file mode 100644 index 0000000..e3a6656 --- /dev/null +++ b/skills/_tools/auth.txt @@ -0,0 +1,4 @@ +auth_status +auth_login +auth_select +auth_logout diff --git a/skills/_tools/codespaces.txt b/skills/_tools/codespaces.txt new file mode 100644 index 0000000..79b1033 --- /dev/null +++ b/skills/_tools/codespaces.txt @@ -0,0 +1,11 @@ +codespaces_create +codespaces_list +codespaces_ssh +codespaces_stop +codespaces_delete +codespaces_view +codespaces_ports +codespaces_file_read +codespaces_file_write +codespaces_file_edit +codespaces_file_list diff --git a/skills/_tools/devcontainer.txt b/skills/_tools/devcontainer.txt new file mode 100644 index 0000000..dfebd4c --- /dev/null +++ b/skills/_tools/devcontainer.txt @@ -0,0 +1,11 @@ +devcontainer_up +devcontainer_exec +devcontainer_build +devcontainer_read_config +devcontainer_stop +devcontainer_remove +devcontainer_status +devcontainer_file_read +devcontainer_file_write +devcontainer_file_edit +devcontainer_file_list diff --git a/skills/_tools/devpod.txt b/skills/_tools/devpod.txt new file mode 100644 index 0000000..224de7a --- /dev/null +++ b/skills/_tools/devpod.txt @@ -0,0 +1,19 @@ +devpod_up +devpod_stop +devpod_delete +devpod_build +devpod_status +devpod_list +devpod_ssh +devpod_logs +devpod_provider_list +devpod_provider_add +devpod_provider_delete +devpod_context_list +devpod_context_use +devpod_container_inspect +devpod_container_logs +devpod_file_read +devpod_file_write +devpod_file_edit +devpod_file_list diff --git a/skills/_tools/wsl.txt b/skills/_tools/wsl.txt new file mode 100644 index 0000000..b86751b --- /dev/null +++ b/skills/_tools/wsl.txt @@ -0,0 +1,9 @@ +wsl_list +wsl_exec +wsl_terminate +wsl_shutdown +wsl_set_default +wsl_file_read +wsl_file_write +wsl_file_edit +wsl_file_list diff --git a/skills/auth.md b/skills/auth.md new file mode 100644 index 0000000..dbeef14 --- /dev/null +++ b/skills/auth.md @@ -0,0 +1,27 @@ +## 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` diff --git a/skills/choosing-backend.md b/skills/choosing-backend.md new file mode 100644 index 0000000..8d3e4f9 --- /dev/null +++ b/skills/choosing-backend.md @@ -0,0 +1,5 @@ +## Choosing a Backend + +1. **Local Docker + devcontainer CLI** — simplest for local development, no auth needed +2. **DevPod** — when you need multi-provider support or the project uses DevPod +3. **Codespaces** — when you need cloud-hosted environments (requires GitHub auth) diff --git a/skills/codespaces.md b/skills/codespaces.md new file mode 100644 index 0000000..e904fd4 --- /dev/null +++ b/skills/codespaces.md @@ -0,0 +1,24 @@ +## Workflow: Codespaces + +### 1. Authenticate +``` +auth_status(provider: "github") +# If no accounts: auth_login(provider: "github", scopes: "codespace") +# If multiple: ask the user which account +``` + +### 2. Create a codespace — ask user for machine type +``` +codespaces_create(auth: "github-USERNAME", repo: "owner/repo", machine: "basicLinux32gb") +``` +Machine types: `basicLinux32gb` (2 cores, 8 GB), `standardLinux32gb` (4 cores, 16 GB), `premiumLinux` (8 cores, 32 GB), `largePremiumLinux` (16 cores, 64 GB) + +### 3. Execute commands +``` +codespaces_ssh(auth: "github-USERNAME", codespace: "codespace-name", command: "npm test") +``` + +### 4. Stop when done +``` +codespaces_stop(auth: "github-USERNAME", codespace: "codespace-name") +``` diff --git a/skills/core-rule.md b/skills/core-rule.md new file mode 100644 index 0000000..6e7d6d0 --- /dev/null +++ b/skills/core-rule.md @@ -0,0 +1,3 @@ +## Core Rule + +**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.** diff --git a/skills/devcontainer.md b/skills/devcontainer.md new file mode 100644 index 0000000..ff99de3 --- /dev/null +++ b/skills/devcontainer.md @@ -0,0 +1,16 @@ +## Workflow: devcontainer CLI + +### 1. Start the dev container +``` +devcontainer_up(workspace_folder: "/path/to/project") +``` + +### 2. Execute commands +``` +devcontainer_exec(workspace_folder: "/path/to/project", command: "npm test") +``` + +### 3. Stop when done +``` +devcontainer_stop(workspace_folder: "/path/to/project") +``` diff --git a/skills/devpod.md b/skills/devpod.md new file mode 100644 index 0000000..318df3f --- /dev/null +++ b/skills/devpod.md @@ -0,0 +1,21 @@ +## Workflow: DevPod + +### 1. Create or start the workspace +``` +devpod_up(args: "/path/to/project --id my-project --provider docker") +``` + +### 2. Verify the workspace is running +``` +devpod_status(workspace: "my-project") +``` + +### 3. Execute commands inside the workspace +``` +devpod_ssh(workspace: "my-project", command: "cargo build") +``` + +### 4. Stop when done +``` +devpod_stop(workspace: "my-project") +``` diff --git a/skills/file-ops.md b/skills/file-ops.md new file mode 100644 index 0000000..cb10ab3 --- /dev/null +++ b/skills/file-ops.md @@ -0,0 +1,42 @@ +## File Operations + +**All backends support built-in file operations — no need to construct shell commands.** + +These tools mirror familiar editing tools (read, write, edit, list) and handle escaping, encoding, and directory creation automatically. + +### Reading files +``` +devpod_file_read(workspace: "my-ws", path: "/workspaces/project/src/main.rs") +devcontainer_file_read(workspace_folder: "/path/to/project", path: "/workspaces/project/src/main.rs") +codespaces_file_read(auth: "github-user", codespace: "name", path: "src/main.rs") +``` +Supports optional `start_line` and `end_line` for reading specific ranges. + +### Writing files +``` +devpod_file_write(workspace: "my-ws", path: "/workspaces/project/new_file.rs", content: "fn main() {}") +devcontainer_file_write(workspace_folder: "/path/to/project", path: "new_file.rs", content: "fn main() {}") +codespaces_file_write(auth: "github-user", codespace: "name", path: "src/new.rs", content: "...") +``` +Creates parent directories automatically. + +### Editing files (surgical replacement) +``` +devpod_file_edit(workspace: "my-ws", path: "src/main.rs", old_str: "fn old()", new_str: "fn new()") +devcontainer_file_edit(workspace_folder: "/path/to/project", path: "src/lib.rs", old_str: "v1", new_str: "v2") +codespaces_file_edit(auth: "github-user", codespace: "name", path: "src/lib.rs", old_str: "TODO", new_str: "DONE") +``` +`old_str` must match exactly once in the file. Include surrounding context to make it unique. + +### Listing directories +``` +devpod_file_list(workspace: "my-ws", path: "/workspaces/project/src") +devcontainer_file_list(workspace_folder: "/path/to/project", path: "src") +codespaces_file_list(auth: "github-user", codespace: "name", path: ".") +``` +Shows non-hidden files up to 2 levels deep. + +### When to use file tools vs exec/ssh +- ✅ **Use file tools** for reading, writing, and editing source files +- ✅ **Use exec/ssh** for running builds, tests, and commands +- ❌ **Don't** construct `sed`, `cat`, or `echo` commands via exec for file editing diff --git a/skills/footer.md b/skills/footer.md new file mode 100644 index 0000000..a307b85 --- /dev/null +++ b/skills/footer.md @@ -0,0 +1,9 @@ +## What NOT to do + +- ❌ Do NOT install packages on the host +- ❌ Do NOT run builds on the host +- ❌ Do NOT modify the host's global config +- ✅ DO authenticate before using codespaces tools +- ✅ DO ask the user which account/machine type to use +- ✅ DO use `devpod_ssh`, `devcontainer_exec`, or `codespaces_ssh` for everything +- ✅ DO check `.devcontainer/devcontainer.json` first diff --git a/skills/header.md b/skills/header.md new file mode 100644 index 0000000..de02373 --- /dev/null +++ b/skills/header.md @@ -0,0 +1,6 @@ +# DevContainer MCP Skill + +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 diff --git a/skills/self-healing.md b/skills/self-healing.md new file mode 100644 index 0000000..746241e --- /dev/null +++ b/skills/self-healing.md @@ -0,0 +1,7 @@ +## Self-Healing + +If `devpod_up`, `devcontainer_up`, or `codespaces_create` returns errors: +1. Read the error output carefully +2. Fix the `Dockerfile` or `devcontainer.json` +3. Call the up/create command again +4. Repeat until successful diff --git a/skills/wsl.md b/skills/wsl.md new file mode 100644 index 0000000..50f61ea --- /dev/null +++ b/skills/wsl.md @@ -0,0 +1,37 @@ +## Workflow: WSL (Windows only) + +WSL tools are available when running on Windows. They let you manage and interact with WSL distributions directly. + +### 1. List available distributions +``` +wsl_list() +``` + +### 2. Execute commands inside a distribution +``` +wsl_exec(distro: "Ubuntu", command: "apt list --installed") +``` + +### 3. Set the default distribution +``` +wsl_set_default(distro: "Ubuntu") +``` + +### 4. Stop a distribution +``` +wsl_terminate(distro: "Ubuntu") +``` + +### 5. Shut down all WSL distributions +``` +wsl_shutdown() +``` + +### File operations in WSL +``` +wsl_file_read(distro: "Ubuntu", path: "/home/user/project/src/main.rs") +wsl_file_write(distro: "Ubuntu", path: "/home/user/file.txt", content: "hello") +wsl_file_edit(distro: "Ubuntu", path: "/home/user/file.txt", old_str: "hello", new_str: "world") +wsl_file_list(distro: "Ubuntu", path: "/home/user/project") +``` +