From 98fceadb744fdd73aee5d2a7e0f26aa1a6b6c7d3 Mon Sep 17 00:00:00 2001 From: aniongithub Date: Wed, 27 May 2026 11:36:29 -0700 Subject: [PATCH] Add multi-container devcontainer workspace support Support the VS Code multi-container pattern where a workspace has multiple .devcontainer//devcontainer.json files referencing shared services in a single docker-compose.yml. Core changes: - New devcontainer_config module: discovers all configs in a workspace (root + .devcontainer/devcontainer.json + .devcontainer/*/devcontainer.json), parses them via jsonc-parser (handles comments + trailing commas per the devcontainer.json spec), and resolves abs paths + compose service + dockerComposeFile paths relative to each config's directory. - Reliable container lookup in docker.rs: matches compose configs via com.docker.compose.service + com.docker.compose.project.config_files rather than the devcontainer.local_folder label, which the CLI only stamps on the first container of a compose stack. The no-config path unions devcontainer.local_folder and com.docker.compose.project.working_dir matches so sibling containers are visible. Canonicalizes paths to survive macOS /private/var/... aliasing. - All devcontainer tools (up, exec, build, stop, remove, status, read_config, file_*) now accept an optional config parameter. Single- container workflows are unchanged. - exec passes the resolved container id to the CLI via --container-id, which lets it reach sibling compose containers that the CLI's own workspace-folder lookup can't see. - Ambiguity is surfaced as structured output: status returns {state:'Ambiguous',candidates:[...],hint:'...'} and lookup-style tools return errors listing every candidate so the agent can retry with the right config. - New devcontainer_list_configs MCP tool exposes the discovery output. Drive-by: narrow the cfg gate on process_tree.rs signal_all, any_alive, and libc_signal from cfg(unix) to cfg(target_os = 'linux') to match their only call site (reap_linux). The cfg(not(unix)) stubs were unreachable and silenced spurious dead_code warnings on macOS. README: new Multi-container workspaces section + tool count 45->46. --- Cargo.lock | 10 + README.md | 14 +- crates/devcontainer-mcp-core/Cargo.toml | 1 + .../devcontainer-mcp-core/src/devcontainer.rs | 273 ++++++++--- .../src/devcontainer_config.rs | 445 ++++++++++++++++++ crates/devcontainer-mcp-core/src/docker.rs | 184 +++++++- crates/devcontainer-mcp-core/src/lib.rs | 1 + .../devcontainer-mcp-core/src/process_tree.rs | 13 +- .../src/tools/devcontainer/build.rs | 13 +- .../src/tools/devcontainer/exec.rs | 5 + .../src/tools/devcontainer/files.rs | 37 +- .../src/tools/devcontainer/list_configs.rs | 30 ++ .../src/tools/devcontainer/mod.rs | 2 + .../src/tools/devcontainer/remove.rs | 12 +- .../src/tools/devcontainer/status.rs | 40 +- .../src/tools/devcontainer/stop.rs | 6 +- .../src/tools/devcontainer/up.rs | 4 +- 17 files changed, 982 insertions(+), 108 deletions(-) create mode 100644 crates/devcontainer-mcp-core/src/devcontainer_config.rs create mode 100644 crates/devcontainer-mcp/src/tools/devcontainer/list_configs.rs diff --git a/Cargo.lock b/Cargo.lock index 3d2552c..06dd48c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -320,6 +320,7 @@ dependencies = [ "base64", "bollard", "futures-util", + "jsonc-parser", "libc", "serde", "serde_json", @@ -825,6 +826,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonc-parser" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6d80e6d70e7911a29f3cf3f44f452df85d06f73572b494ca99a2cad3fcf8f4" +dependencies = [ + "serde_json", +] + [[package]] name = "lazy_static" version = "1.5.0" diff --git a/README.md b/README.md index 07ab0b4..e920864 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ Codespaces tools require an auth handle (e.g. `"github-aniongithub"`). The MCP s Supported providers: **GitHub**, **AWS**, **Azure**, **GCP**, **Kubernetes** -## MCP Tools (45 total) +## MCP Tools (46 total) ### Auth (4 tools) @@ -149,7 +149,7 @@ Supported providers: **GitHub**, **AWS**, **Azure**, **GCP**, **Kubernetes** | `devpod_file_edit` | Surgical string replacement — old_str → new_str | | `devpod_file_list` | List directory contents (non-hidden, 2 levels deep) | -### devcontainer CLI (11 tools) +### devcontainer CLI (12 tools) | Tool | Description | |------|-------------| @@ -157,6 +157,7 @@ Supported providers: **GitHub**, **AWS**, **Azure**, **GCP**, **Kubernetes** | `devcontainer_exec` | Execute a command inside a running dev container | | `devcontainer_build` | Build a dev container image | | `devcontainer_read_config` | Read merged devcontainer configuration as JSON | +| `devcontainer_list_configs` | Discover all devcontainer.json files in a workspace (single + multi-container) | | `devcontainer_stop` | Stop a dev container (via Docker API) | | `devcontainer_remove` | Remove a dev container and its resources | | `devcontainer_status` | Get dev container state by workspace folder | @@ -221,6 +222,15 @@ Install backend CLIs as needed — the MCP server detects them at runtime and re When `devcontainer_up`, `devpod_up`, or `codespaces_create` fails, the full build output (including errors) is returned to the agent. The agent can read the error, fix the `Dockerfile` or `devcontainer.json`, and retry — making the dev environment a **dynamic, agent-managed asset** rather than a static prerequisite. +## Multi-container workspaces + +The devcontainer spec supports [connecting to multiple containers](https://code.visualstudio.com/remote/advancedcontainers/connect-multiple-containers) in one workspace by placing per-service configs at `.devcontainer//devcontainer.json`, each pointing at a shared `docker-compose.yml`. `devcontainer-mcp` supports this pattern end-to-end: + +- **Discovery** — `devcontainer_list_configs` returns every config it finds (root `.devcontainer.json`, `.devcontainer/devcontainer.json`, and each `.devcontainer/*/devcontainer.json`) with its kind (`image` / `dockerfile` / `compose`), service name, and absolute path. +- **Targeting** — Every devcontainer tool (`up`, `exec`, `build`, `stop`, `remove`, `status`, `read_config`, `file_*`) accepts an optional `config` parameter pointing at a specific `devcontainer.json`. Single-container workflows continue to work unchanged — `config` defaults to whatever the devcontainer CLI auto-detects. +- **Ambiguity handling** — When a workspace has multiple configs and no `config` is provided, lookup-style tools (`status`, `exec`, `stop`, `remove`, `file_*`) return a structured `Ambiguous` result listing every matching container so the agent can pick the right one. `status` reports this as `{"state":"Ambiguous","candidates":[...],"hint":"..."}`. +- **Robust container matching** — Sibling compose containers are identified via `com.docker.compose.service` + `com.docker.compose.project.config_files` (not the unreliable `devcontainer.local_folder` label, which is only stamped on the first container). + ## Development This project eats its own dogfood — development happens inside its own devcontainer. diff --git a/crates/devcontainer-mcp-core/Cargo.toml b/crates/devcontainer-mcp-core/Cargo.toml index 9a02857..18c6f2e 100644 --- a/crates/devcontainer-mcp-core/Cargo.toml +++ b/crates/devcontainer-mcp-core/Cargo.toml @@ -18,6 +18,7 @@ futures-util = "0.3" async-trait = "0.1" base64 = "0.22" shlex = "1" +jsonc-parser = { version = "0.26", features = ["serde"] } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/devcontainer-mcp-core/src/devcontainer.rs b/crates/devcontainer-mcp-core/src/devcontainer.rs index 34e07fc..ca037c9 100644 --- a/crates/devcontainer-mcp-core/src/devcontainer.rs +++ b/crates/devcontainer-mcp-core/src/devcontainer.rs @@ -1,7 +1,8 @@ use crate::cli::{ run_cli, run_cli_streaming, run_with_shim, ChunkSink, CliBinary, CliOutput, RemoteKiller, }; -use crate::docker; +use crate::devcontainer_config::{resolve_config, ResolvedConfig}; +use crate::docker::{self, DevcontainerLookup}; use crate::error::Result; use std::sync::Arc; use tokio_util::sync::CancellationToken; @@ -11,6 +12,63 @@ async fn run_devcontainer(args: &[&str], parse_json: bool) -> Result run_cli(&CliBinary::Devcontainer, args, parse_json).await } +/// Best-effort resolve of `config` against `workspace_folder`. Returns +/// `None` if `config` is `None` *or* if parsing fails (we log + fall back +/// to no-config matching so a malformed config file never blocks lookups +/// the agent could otherwise satisfy). +fn try_resolve(workspace_folder: &str, config: Option<&str>) -> Option { + let cfg = config?; + match resolve_config(workspace_folder, cfg) { + Ok(r) => Some(r), + Err(e) => { + tracing::warn!(%e, config = %cfg, "failed to resolve devcontainer config; lookup will fall back to no-config matching"); + None + } + } +} + +/// Make `config` absolute against `workspace_folder` so the devcontainer +/// CLI resolves it correctly regardless of the MCP server's CWD. If +/// already absolute, returned unchanged. Falls back to the input when +/// canonicalize fails (e.g. file doesn't exist yet). +fn abs_config(workspace_folder: &str, config: &str) -> String { + let p = std::path::Path::new(config); + let joined = if p.is_absolute() { + p.to_path_buf() + } else { + std::path::Path::new(workspace_folder).join(p) + }; + std::fs::canonicalize(&joined) + .map(|c| c.to_string_lossy().into_owned()) + .unwrap_or_else(|_| joined.to_string_lossy().into_owned()) +} + +/// Build a uniform "ambiguous match" error string for stop/remove/status +/// callers when multiple containers match `workspace_folder` and no +/// `config` was supplied. Lists each candidate's id, name, compose service, +/// and devcontainer.config_file label so the agent has everything it needs +/// to retry with the right `config`. +fn ambiguous_error(workspace_folder: &str, candidates: &[docker::ContainerInfo]) -> String { + let lines: Vec = candidates + .iter() + .map(|c| { + let svc = c.compose_service().unwrap_or("(none)"); + let cfg = c.devcontainer_config_file().unwrap_or("(unlabeled)"); + format!( + " - id={short} name={name} service={svc} config_file={cfg}", + short = c.id.chars().take(12).collect::(), + name = c.name, + ) + }) + .collect(); + format!( + "Multiple containers match workspace `{workspace_folder}`. \ + Re-run with `config` set to the devcontainer.json path of the one you want. \ + Use `devcontainer_list_configs` to enumerate. Candidates:\n{}", + lines.join("\n") + ) +} + // --------------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------------- @@ -22,7 +80,8 @@ pub async fn up( extra_args: &[&str], ) -> Result { let mut args = vec!["up", "--workspace-folder", workspace_folder]; - if let Some(c) = config { + let cfg_abs = config.map(|c| abs_config(workspace_folder, c)); + if let Some(c) = cfg_abs.as_deref() { args.push("--config"); args.push(c); } @@ -46,7 +105,8 @@ pub async fn up_streaming( on_chunk: Option>, ) -> Result { let mut args = vec!["up", "--workspace-folder", workspace_folder]; - if let Some(c) = config { + let cfg_abs = config.map(|c| abs_config(workspace_folder, c)); + if let Some(c) = cfg_abs.as_deref() { args.push("--config"); args.push(c); } @@ -63,12 +123,32 @@ pub async fn up_streaming( } /// `devcontainer exec` — execute a command in a running dev container. +/// +/// Resolves the target container via our reliable label-based lookup and +/// passes its id to the CLI via `--container-id`. This works for sibling +/// compose containers that the devcontainer CLI's own workspace-folder +/// lookup can't see (because only the first container in a compose stack +/// gets stamped with `devcontainer.*` labels). pub async fn exec( workspace_folder: &str, + config: Option<&str>, command: &str, command_args: &[&str], ) -> Result { - let mut args = vec!["exec", "--workspace-folder", workspace_folder, command]; + let container = lookup_one_or_err(workspace_folder, config).await?; + let mut args = vec![ + "exec", + "--container-id", + &container.id, + "--workspace-folder", + workspace_folder, + ]; + let cfg_abs = config.map(|c| abs_config(workspace_folder, c)); + if let Some(c) = cfg_abs.as_deref() { + args.push("--config"); + args.push(c); + } + args.push(command); args.extend_from_slice(command_args); run_devcontainer(&args, false).await } @@ -90,6 +170,7 @@ pub async fn exec( /// and use the captured PGID to reap them on cancel. pub async fn exec_streaming( workspace_folder: &str, + config: Option<&str>, command: &str, command_args: &[&str], cancel: &CancellationToken, @@ -120,21 +201,27 @@ pub async fn exec_streaming( // (DevPod, Codespaces) that can't pass remote env vars. let remote_env_arg = format!("{}={}", crate::exec_shim::USER_CMD_ENV, user_cmd); - let args: [&str; 7] = [ + // Resolve target container up-front (see `exec` docs for rationale). + let container = lookup_one_or_err(workspace_folder, config).await?; + + let mut all_args: Vec<&str> = vec![ "exec", + "--container-id", + &container.id, "--workspace-folder", workspace_folder, - "--remote-env", - &remote_env_arg, - "sh", - "-c", ]; - // run_with_shim takes a slice of &str; build a Vec to add the wrapped tail. - let mut all_args: Vec<&str> = args.to_vec(); + let cfg_abs = config.map(|c| abs_config(workspace_folder, c)); + if let Some(c) = cfg_abs.as_deref() { + all_args.push("--config"); + all_args.push(c); + } + all_args.extend_from_slice(&["--remote-env", &remote_env_arg, "sh", "-c"]); all_args.push(&wrapped); let killer: Arc = Arc::new(DevcontainerKiller { workspace_folder: workspace_folder.to_string(), + config: config.map(str::to_string), }); run_with_shim( @@ -149,9 +236,12 @@ pub async fn exec_streaming( } /// Delivers `kill - -` inside the devcontainer associated -/// with a workspace folder, using bollard's exec API. +/// with a workspace folder, using bollard's exec API. When `config` is +/// provided, the target container is disambiguated using the same +/// resolution as the rest of the lifecycle tools. struct DevcontainerKiller { workspace_folder: String, + config: Option, } #[async_trait::async_trait] @@ -166,20 +256,34 @@ impl RemoteKiller for DevcontainerKiller { return; } }; - let container = match docker::find_container_by_local_folder(&client, &self.workspace_folder).await { - Ok(Some(c)) => c, - Ok(None) => { - tracing::warn!( - workspace = %self.workspace_folder, - "no devcontainer found for in-container kill" - ); - return; - } - Err(e) => { - tracing::warn!(%e, "container lookup failed during in-container kill"); - return; - } - }; + let resolved = try_resolve(&self.workspace_folder, self.config.as_deref()); + let container = + match docker::find_devcontainer(&client, &self.workspace_folder, resolved.as_ref()) + .await + { + Ok(DevcontainerLookup::One(c)) => c, + Ok(DevcontainerLookup::Many(candidates)) => { + // Best effort: pick the first; we may be racing the + // user's own teardown, so an imperfect kill is fine. + tracing::warn!( + workspace = %self.workspace_folder, + count = candidates.len(), + "ambiguous container lookup during in-container kill; picking first" + ); + candidates.into_iter().next().unwrap() + } + Ok(DevcontainerLookup::None) => { + tracing::warn!( + workspace = %self.workspace_folder, + "no devcontainer found for in-container kill" + ); + return; + } + Err(e) => { + tracing::warn!(%e, "container lookup failed during in-container kill"); + return; + } + }; // `kill - -` signals every process in the group. // The POSIX `--` argument separator is deliberately omitted @@ -213,8 +317,17 @@ impl RemoteKiller for DevcontainerKiller { } /// `devcontainer build` — build a dev container image. -pub async fn build(workspace_folder: &str, extra_args: &[&str]) -> Result { +pub async fn build( + workspace_folder: &str, + config: Option<&str>, + extra_args: &[&str], +) -> Result { let mut args = vec!["build", "--workspace-folder", workspace_folder]; + let cfg_abs = config.map(|c| abs_config(workspace_folder, c)); + if let Some(c) = cfg_abs.as_deref() { + args.push("--config"); + args.push(c); + } args.extend_from_slice(extra_args); run_devcontainer(&args, true).await } @@ -223,11 +336,17 @@ pub async fn build(workspace_folder: &str, extra_args: &[&str]) -> Result, extra_args: &[&str], cancel: &CancellationToken, on_chunk: Option>, ) -> Result { let mut args = vec!["build", "--workspace-folder", workspace_folder]; + let cfg_abs = config.map(|c| abs_config(workspace_folder, c)); + if let Some(c) = cfg_abs.as_deref() { + args.push("--config"); + args.push(c); + } args.extend_from_slice(extra_args); run_cli_streaming( &CliBinary::Devcontainer, @@ -243,7 +362,8 @@ pub async fn build_streaming( /// `devcontainer read-configuration` — read devcontainer config as JSON. pub async fn read_configuration(workspace_folder: &str, config: Option<&str>) -> Result { let mut args = vec!["read-configuration", "--workspace-folder", workspace_folder]; - if let Some(c) = config { + let cfg_abs = config.map(|c| abs_config(workspace_folder, c)); + if let Some(c) = cfg_abs.as_deref() { args.push("--config"); args.push(c); } @@ -255,40 +375,65 @@ pub async fn read_configuration(workspace_folder: &str, config: Option<&str>) -> // Lifecycle via bollard (devcontainer CLI has no stop/down) // --------------------------------------------------------------------------- +/// Look up the single container for `workspace_folder` + `config`, +/// returning a structured error for `None` / `Many`. Used by stop/remove, +/// which refuse to act on ambiguous matches. +async fn lookup_one_or_err( + workspace_folder: &str, + config: Option<&str>, +) -> Result { + let client = docker::connect()?; + let resolved = try_resolve(workspace_folder, config); + match docker::find_devcontainer(&client, workspace_folder, resolved.as_ref()).await? { + DevcontainerLookup::One(c) => Ok(c), + DevcontainerLookup::None => Err(crate::error::Error::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("No devcontainer found for workspace: {workspace_folder}"), + ))), + DevcontainerLookup::Many(candidates) => Err(crate::error::Error::Io( + std::io::Error::other(ambiguous_error(workspace_folder, &candidates)), + )), + } +} + /// Stop a dev container found by its workspace folder label. -pub async fn stop(workspace_folder: &str) -> Result { +pub async fn stop(workspace_folder: &str, config: Option<&str>) -> Result { let client = docker::connect()?; - let container = docker::find_container_by_local_folder(&client, workspace_folder) - .await? - .ok_or_else(|| { - crate::error::Error::Io(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("No devcontainer found for workspace: {workspace_folder}"), - )) - })?; + let container = lookup_one_or_err(workspace_folder, config).await?; docker::stop_container(&client, &container.id).await?; Ok(format!("Stopped container {}", container.name)) } /// Remove a dev container found by its workspace folder label. -pub async fn remove(workspace_folder: &str, force: bool) -> Result { +pub async fn remove(workspace_folder: &str, config: Option<&str>, force: bool) -> Result { let client = docker::connect()?; - let container = docker::find_container_by_local_folder(&client, workspace_folder) - .await? - .ok_or_else(|| { - crate::error::Error::Io(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("No devcontainer found for workspace: {workspace_folder}"), - )) - })?; + let container = lookup_one_or_err(workspace_folder, config).await?; docker::remove_container(&client, &container.id, force).await?; Ok(format!("Removed container {}", container.name)) } -/// Get status of a dev container by workspace folder label. -pub async fn status(workspace_folder: &str) -> Result> { +/// Status outcome for a devcontainer lookup. `Ambiguous` is the +/// multi-container case where no `config` was supplied to disambiguate; +/// callers should surface the candidates and point the agent at +/// `devcontainer_list_configs`. +#[derive(Debug, Clone)] +pub enum StatusOutcome { + NotFound, + Found(docker::ContainerInfo), + Ambiguous(Vec), +} + +/// Get status of a dev container by workspace folder + optional config. +pub async fn status(workspace_folder: &str, config: Option<&str>) -> Result { let client = docker::connect()?; - docker::find_container_by_local_folder(&client, workspace_folder).await + let resolved = try_resolve(workspace_folder, config); + Ok( + match docker::find_devcontainer(&client, workspace_folder, resolved.as_ref()).await? { + DevcontainerLookup::None => StatusOutcome::NotFound, + DevcontainerLookup::One(c) => StatusOutcome::Found(c), + DevcontainerLookup::Many(v) => StatusOutcome::Ambiguous(v), + }, + ) } // --------------------------------------------------------------------------- @@ -296,25 +441,35 @@ pub async fn status(workspace_folder: &str) -> Result Result { +pub async fn file_read( + workspace_folder: &str, + config: Option<&str>, + path: &str, +) -> Result { let cmd = crate::file_ops::read_file_command(path); - exec(workspace_folder, "sh", &["-c", &cmd]).await + exec(workspace_folder, config, "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, + config: Option<&str>, + path: &str, + content: &str, +) -> Result { let cmd = crate::file_ops::write_file_command(path, content); - exec(workspace_folder, "sh", &["-c", &cmd]).await + exec(workspace_folder, config, "sh", &["-c", &cmd]).await } /// Surgical edit: replace exactly one occurrence of `old_str` with `new_str`. pub async fn file_edit( workspace_folder: &str, + config: Option<&str>, path: &str, old_str: &str, new_str: &str, ) -> Result { - let read_output = file_read(workspace_folder, path).await?; + let read_output = file_read(workspace_folder, config, path).await?; if read_output.exit_code != 0 { return Err(crate::error::Error::FileRead(format!( "Failed to read {path}: {}", @@ -324,7 +479,7 @@ pub async fn file_edit( let modified = crate::file_ops::apply_edit(&read_output.stdout, old_str, new_str)?; - let write_output = file_write(workspace_folder, path, &modified).await?; + let write_output = file_write(workspace_folder, config, path, &modified).await?; if write_output.exit_code != 0 { return Err(crate::error::Error::FileEdit(format!( "Failed to write {path}: {}", @@ -336,7 +491,11 @@ 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, + config: Option<&str>, + path: &str, +) -> Result { let cmd = crate::file_ops::list_dir_command(path); - exec(workspace_folder, "sh", &["-c", &cmd]).await + exec(workspace_folder, config, "sh", &["-c", &cmd]).await } diff --git a/crates/devcontainer-mcp-core/src/devcontainer_config.rs b/crates/devcontainer-mcp-core/src/devcontainer_config.rs new file mode 100644 index 0000000..e7b8a7d --- /dev/null +++ b/crates/devcontainer-mcp-core/src/devcontainer_config.rs @@ -0,0 +1,445 @@ +//! Discovery and parsing of `devcontainer.json` files within a workspace. +//! +//! Implements the VS Code multi-container workspace pattern: a single repo +//! can contain several `.devcontainer//devcontainer.json` files, +//! typically referencing different services of a shared `docker-compose.yml`. +//! +//! Used by the `devcontainer_list_configs` MCP tool and as the basis for +//! disambiguating Docker container lookups when an operation targets a +//! specific config in a multi-container workspace. + +use crate::error::Result; +use jsonc_parser::ParseOptions; +use serde::Serialize; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// One field of a `dockerComposeFile` — either a single path or an array. +fn json_to_string_list(value: &serde_json::Value) -> Vec { + match value { + serde_json::Value::String(s) => vec![s.clone()], + serde_json::Value::Array(items) => items + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect(), + _ => Vec::new(), + } +} + +/// Classification of a parsed devcontainer.json based on which top-level +/// fields are present. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ConfigKind { + /// `dockerComposeFile` + `service` — multi-container compose project. + Compose, + /// `build.dockerfile` or top-level `dockerFile` — image is built locally. + Dockerfile, + /// `image` — pre-built image is used directly. + Image, + /// None of the above could be determined. + Unknown, +} + +/// One entry returned by [`list_configs`]: a discovered devcontainer.json +/// with a best-effort summary of its top-level fields. `path` is always +/// **relative** to the workspace folder so it can be fed straight back +/// into another tool's `config` parameter. +#[derive(Debug, Clone, Serialize)] +pub struct DiscoveredConfig { + pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub service: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub docker_compose_file: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_folder: Option, + pub kind: ConfigKind, + /// When parsing fails, the entry still appears with the error message + /// here and all other fields empty — so an agent can see *that* the + /// config exists even if it can't be parsed. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// Resolved view of a devcontainer.json used by the Docker container lookup +/// to disambiguate multi-container workspaces. Paths are absolute and +/// canonicalized so substring comparisons against container labels are +/// reliable across `/var/folders/...` ↔ `/private/var/folders/...` aliases +/// (macOS) and similar. +#[derive(Debug, Clone)] +pub struct ResolvedConfig { + pub abs_path: PathBuf, + pub service: Option, + /// Absolute, canonicalized paths of every `dockerComposeFile` entry. + pub compose_files_abs: Vec, + pub kind: ConfigKind, +} + +/// Parse `text` as JSONC (JSON with comments + trailing commas, per the +/// devcontainer spec) into a `serde_json::Value`. +fn parse_jsonc(text: &str) -> std::result::Result { + let parsed = jsonc_parser::parse_to_serde_value(text, &ParseOptions::default()) + .map_err(|e| e.to_string())? + .ok_or_else(|| "empty document".to_string())?; + Ok(parsed) +} + +/// Determine the search paths for devcontainer configs under `workspace`. +/// Returns absolute paths in priority order: root `.devcontainer.json`, +/// `.devcontainer/devcontainer.json`, then every +/// `.devcontainer/*/devcontainer.json`. +fn candidate_paths(workspace: &Path) -> Vec { + let mut out = Vec::new(); + let root_dotfile = workspace.join(".devcontainer.json"); + if root_dotfile.is_file() { + out.push(root_dotfile); + } + let dc_dir = workspace.join(".devcontainer"); + let root_in_dir = dc_dir.join("devcontainer.json"); + if root_in_dir.is_file() { + out.push(root_in_dir); + } + if let Ok(entries) = std::fs::read_dir(&dc_dir) { + // Sort sub-folder configs deterministically so the agent sees a + // stable order across calls. + let mut subdirs: Vec = entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.is_dir()) + .collect(); + subdirs.sort(); + for sub in subdirs { + let candidate = sub.join("devcontainer.json"); + if candidate.is_file() { + out.push(candidate); + } + } + } + out +} + +/// Convert `abs` to a path relative to `base`, falling back to the absolute +/// path's string form if it isn't actually inside `base`. +fn rel_to(abs: &Path, base: &Path) -> String { + abs.strip_prefix(base) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| abs.to_string_lossy().into_owned()) +} + +/// Best-effort canonicalization that falls back to the input on error. +/// Used because compose files referenced from a not-yet-built devcontainer +/// might not exist at scan time, but we still want a stable absolute path. +fn canonicalize_or_keep(p: &Path) -> PathBuf { + std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()) +} + +fn classify(obj: &serde_json::Map) -> ConfigKind { + if obj.get("dockerComposeFile").is_some() { + return ConfigKind::Compose; + } + let has_dockerfile = obj.get("dockerFile").is_some() + || obj + .get("build") + .and_then(|b| b.as_object()) + .is_some_and(|b| b.contains_key("dockerfile") || b.contains_key("dockerFile")); + if has_dockerfile { + return ConfigKind::Dockerfile; + } + if obj.get("image").is_some() { + return ConfigKind::Image; + } + ConfigKind::Unknown +} + +/// Build a [`DiscoveredConfig`] from a parsed JSON value. Top-level keys are +/// best-effort: missing or wrong-typed fields are skipped, not errors. +fn build_discovered(rel_path: String, value: &serde_json::Value) -> DiscoveredConfig { + let Some(obj) = value.as_object() else { + return DiscoveredConfig { + path: rel_path, + error: Some("top-level value is not an object".into()), + kind: ConfigKind::Unknown, + name: None, + image: None, + service: None, + docker_compose_file: Vec::new(), + workspace_folder: None, + }; + }; + DiscoveredConfig { + path: rel_path, + name: obj.get("name").and_then(|v| v.as_str()).map(str::to_string), + image: obj + .get("image") + .and_then(|v| v.as_str()) + .map(str::to_string), + service: obj + .get("service") + .and_then(|v| v.as_str()) + .map(str::to_string), + docker_compose_file: obj + .get("dockerComposeFile") + .map(json_to_string_list) + .unwrap_or_default(), + workspace_folder: obj + .get("workspaceFolder") + .and_then(|v| v.as_str()) + .map(str::to_string), + kind: classify(obj), + error: None, + } +} + +/// Discover every devcontainer.json under `workspace_folder` and return a +/// best-effort summary of each. Parse errors are reported per-entry; the +/// call itself only fails on IO problems with `workspace_folder`. +pub fn list_configs(workspace_folder: &str) -> Result> { + let workspace = PathBuf::from(workspace_folder); + let workspace_abs = canonicalize_or_keep(&workspace); + let mut out = Vec::new(); + for abs_path in candidate_paths(&workspace_abs) { + let rel = rel_to(&abs_path, &workspace_abs); + let entry = match std::fs::read_to_string(&abs_path) { + Ok(text) => match parse_jsonc(&text) { + Ok(v) => build_discovered(rel, &v), + Err(e) => DiscoveredConfig { + path: rel, + error: Some(format!("JSONC parse error: {e}")), + kind: ConfigKind::Unknown, + name: None, + image: None, + service: None, + docker_compose_file: Vec::new(), + workspace_folder: None, + }, + }, + Err(e) => DiscoveredConfig { + path: rel, + error: Some(format!("read error: {e}")), + kind: ConfigKind::Unknown, + name: None, + image: None, + service: None, + docker_compose_file: Vec::new(), + workspace_folder: None, + }, + }; + out.push(entry); + } + Ok(out) +} + +/// Resolve `config` (a path to a devcontainer.json, either absolute or +/// relative to `workspace_folder`) into a [`ResolvedConfig`] used by the +/// Docker container lookup. +/// +/// `dockerComposeFile` entries are resolved against the *config file's* +/// directory (matching the devcontainer CLI's behavior) and canonicalized. +pub fn resolve_config(workspace_folder: &str, config: &str) -> Result { + let workspace = canonicalize_or_keep(&PathBuf::from(workspace_folder)); + let config_path = { + let p = PathBuf::from(config); + if p.is_absolute() { + p + } else { + workspace.join(p) + } + }; + let abs_path = canonicalize_or_keep(&config_path); + let text = std::fs::read_to_string(&abs_path)?; + let value = parse_jsonc(&text).map_err(|e| { + crate::error::Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("JSONC parse error in {}: {e}", abs_path.display()), + )) + })?; + let obj = value.as_object().ok_or_else(|| { + crate::error::Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("top-level value is not an object in {}", abs_path.display()), + )) + })?; + + let kind = classify(obj); + let service = obj + .get("service") + .and_then(|v| v.as_str()) + .map(str::to_string); + + // `dockerComposeFile` paths are resolved against the directory + // containing the devcontainer.json, per the devcontainer spec. + let config_dir = abs_path.parent().unwrap_or(&workspace).to_path_buf(); + let compose_files_abs: Vec = obj + .get("dockerComposeFile") + .map(json_to_string_list) + .unwrap_or_default() + .into_iter() + .map(|entry| { + let p = PathBuf::from(&entry); + let abs = if p.is_absolute() { + p + } else { + config_dir.join(p) + }; + canonicalize_or_keep(&abs) + }) + .collect(); + + Ok(ResolvedConfig { + abs_path, + service, + compose_files_abs, + kind, + }) +} + +/// Helper for callers that want a `BTreeMap` of common +/// label filters derived from a [`ResolvedConfig`]. Not used directly by +/// the docker layer (which builds its own filters), but handy for tests +/// and tracing. +pub fn expected_labels(resolved: &ResolvedConfig) -> BTreeMap { + let mut out = BTreeMap::new(); + if let Some(svc) = &resolved.service { + out.insert("com.docker.compose.service".into(), svc.clone()); + } + out.insert( + "devcontainer.config_file".into(), + resolved.abs_path.to_string_lossy().into_owned(), + ); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn write(p: &Path, contents: &str) { + if let Some(parent) = p.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(p, contents).unwrap(); + } + + #[test] + fn discovers_root_devcontainer_in_subdir() { + let dir = tempdir().unwrap(); + write( + &dir.path().join(".devcontainer/devcontainer.json"), + r#"{ "name": "Root", "image": "alpine:3.20" }"#, + ); + let out = list_configs(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(out.len(), 1); + assert_eq!(out[0].path, ".devcontainer/devcontainer.json"); + assert_eq!(out[0].name.as_deref(), Some("Root")); + assert_eq!(out[0].kind, ConfigKind::Image); + } + + #[test] + fn discovers_root_dotfile() { + let dir = tempdir().unwrap(); + write( + &dir.path().join(".devcontainer.json"), + r#"{ "image": "alpine:3.20" }"#, + ); + let out = list_configs(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(out.len(), 1); + assert_eq!(out[0].path, ".devcontainer.json"); + } + + #[test] + fn discovers_multi_subfolder_configs_sorted() { + let dir = tempdir().unwrap(); + write( + &dir.path().join(".devcontainer/python/devcontainer.json"), + r#"// python config + { + "name": "Python", + "dockerComposeFile": ["../../docker-compose.yml"], + "service": "python-api", + "workspaceFolder": "/workspace/py", + }"#, + ); + write( + &dir.path().join(".devcontainer/node/devcontainer.json"), + r#"{ + "name": "Node", + "dockerComposeFile": "../../docker-compose.yml", + "service": "node-app" + }"#, + ); + let out = list_configs(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(out.len(), 2); + // Sorted by directory name: node, python + assert_eq!(out[0].service.as_deref(), Some("node-app")); + assert_eq!(out[1].service.as_deref(), Some("python-api")); + assert_eq!(out[0].kind, ConfigKind::Compose); + assert_eq!(out[1].kind, ConfigKind::Compose); + // Single-string dockerComposeFile is normalized to a one-element list. + assert_eq!(out[0].docker_compose_file.len(), 1); + // Trailing comma + comments parsed by JSONC. + assert_eq!(out[1].workspace_folder.as_deref(), Some("/workspace/py")); + } + + #[test] + fn malformed_config_reports_per_entry_error() { + let dir = tempdir().unwrap(); + write( + &dir.path().join(".devcontainer/broken/devcontainer.json"), + r#"{ "name": "broken" "#, + ); + let out = list_configs(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(out.len(), 1); + assert!(out[0].error.is_some()); + } + + #[test] + fn classifies_dockerfile_build() { + let dir = tempdir().unwrap(); + write( + &dir.path().join(".devcontainer/devcontainer.json"), + r#"{ "build": { "dockerfile": "Dockerfile" } }"#, + ); + let out = list_configs(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(out[0].kind, ConfigKind::Dockerfile); + } + + #[test] + fn resolve_config_canonicalizes_compose_files() { + let dir = tempdir().unwrap(); + write( + &dir.path().join("docker-compose.yml"), + "services:\n a:\n image: alpine\n", + ); + write( + &dir.path().join(".devcontainer/a/devcontainer.json"), + r#"{ + "dockerComposeFile": ["../../docker-compose.yml"], + "service": "a" + }"#, + ); + let resolved = resolve_config( + dir.path().to_str().unwrap(), + ".devcontainer/a/devcontainer.json", + ) + .unwrap(); + assert_eq!(resolved.service.as_deref(), Some("a")); + assert_eq!(resolved.kind, ConfigKind::Compose); + assert_eq!(resolved.compose_files_abs.len(), 1); + let compose = &resolved.compose_files_abs[0]; + assert!(compose.is_absolute()); + assert!(compose.ends_with("docker-compose.yml")); + } + + #[test] + fn list_returns_empty_when_no_configs() { + let dir = tempdir().unwrap(); + let out = list_configs(dir.path().to_str().unwrap()).unwrap(); + assert!(out.is_empty()); + } +} diff --git a/crates/devcontainer-mcp-core/src/docker.rs b/crates/devcontainer-mcp-core/src/docker.rs index 48fc878..34ca335 100644 --- a/crates/devcontainer-mcp-core/src/docker.rs +++ b/crates/devcontainer-mcp-core/src/docker.rs @@ -6,6 +6,7 @@ use futures_util::StreamExt; use serde::Serialize; use std::collections::HashMap; +use crate::devcontainer_config::{ConfigKind, ResolvedConfig}; use crate::error::Result; /// Summary of a container's state. @@ -18,45 +19,178 @@ pub struct ContainerInfo { pub labels: HashMap, } +impl ContainerInfo { + /// `com.docker.compose.service` label, if present — used for ambiguity + /// diagnostics in multi-container workspaces. + pub fn compose_service(&self) -> Option<&str> { + self.labels + .get("com.docker.compose.service") + .map(String::as_str) + } + + /// `devcontainer.config_file` label, if present. + pub fn devcontainer_config_file(&self) -> Option<&str> { + self.labels + .get("devcontainer.config_file") + .map(String::as_str) + } +} + +/// Outcome of [`find_devcontainer`]. `Many` is surfaced to callers so they +/// can either pick deterministically (e.g. status returning the first + +/// flagging `multipleMatches`) or refuse and ask for a `config` (e.g. stop +/// / remove). +#[derive(Debug, Clone)] +pub enum DevcontainerLookup { + None, + One(ContainerInfo), + Many(Vec), +} + +impl DevcontainerLookup { + pub fn into_one(self) -> Option { + match self { + DevcontainerLookup::One(c) => Some(c), + _ => None, + } + } + + /// Convenience: collapse `One`/`Many` to "any of them" for diagnostics. + pub fn candidates(&self) -> &[ContainerInfo] { + match self { + DevcontainerLookup::None => &[], + DevcontainerLookup::One(c) => std::slice::from_ref(c), + DevcontainerLookup::Many(v) => v.as_slice(), + } + } +} + /// Create a Docker client connected to the local socket. pub fn connect() -> Result { Ok(Docker::connect_with_local_defaults()?) } -/// Find a container by the standard `devcontainer.local_folder` label. -pub async fn find_container_by_local_folder( - docker: &Docker, - local_folder: &str, -) -> Result> { +/// Internal helper: list containers matching one label filter. +async fn list_by_label(docker: &Docker, label_eq: &str) -> Result> { let mut filters = HashMap::new(); - filters.insert( - "label".to_string(), - vec![format!("devcontainer.local_folder={local_folder}")], - ); - + filters.insert("label".to_string(), vec![label_eq.to_string()]); let options = ListContainersOptions { all: true, filters, ..Default::default() }; - let containers = docker.list_containers(Some(options)).await?; + Ok(containers.into_iter().map(container_to_info).collect()) +} - Ok(containers.into_iter().next().map(|c| { - let labels = c.labels.unwrap_or_default(); - ContainerInfo { - id: c.id.unwrap_or_default(), - name: c - .names - .and_then(|n| n.first().cloned()) - .unwrap_or_default() - .trim_start_matches('/') - .to_string(), - image: c.image.unwrap_or_default(), - state: c.state.unwrap_or_default(), - labels, +fn container_to_info(c: bollard::models::ContainerSummary) -> ContainerInfo { + let labels = c.labels.unwrap_or_default(); + ContainerInfo { + id: c.id.unwrap_or_default(), + name: c + .names + .and_then(|n| n.first().cloned()) + .unwrap_or_default() + .trim_start_matches('/') + .to_string(), + image: c.image.unwrap_or_default(), + state: c.state.unwrap_or_default(), + labels, + } +} + +/// Find devcontainer-managed container(s) for `workspace_folder`, optionally +/// scoped to a specific resolved config. +/// +/// Strategy: +/// +/// 1. **Compose config provided** — filter by `com.docker.compose.service` +/// and verify the container's `com.docker.compose.project.config_files` +/// label contains one of the resolved compose-file paths. This is the +/// only reliable path for sibling containers in a multi-service +/// workspace, because the devcontainer CLI only stamps `devcontainer.*` +/// labels on the first container of the compose project. +/// 2. **Image / Dockerfile config provided** — filter by +/// `devcontainer.config_file=`. The CLI labels these +/// consistently. +/// 3. **No config** — start with `devcontainer.local_folder=`. +/// If that misses, fall back to +/// `com.docker.compose.project.working_dir=` to catch the +/// multi-container case where sibling containers lack devcontainer.* +/// labels. +pub async fn find_devcontainer( + docker: &Docker, + workspace_folder: &str, + config: Option<&ResolvedConfig>, +) -> Result { + // Canonicalize the workspace path the same way the CLI does so + // label substring/equality checks survive macOS `/private/var/...` + // aliasing and trailing-slash variations. + let workspace_abs = std::fs::canonicalize(workspace_folder) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| workspace_folder.to_string()); + + let candidates: Vec = match config { + Some(cfg) if cfg.kind == ConfigKind::Compose => { + if let Some(service) = cfg.service.as_deref() { + let service_filter = format!("com.docker.compose.service={service}"); + let by_service = list_by_label(docker, &service_filter).await?; + let expected: Vec = cfg + .compose_files_abs + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + by_service + .into_iter() + .filter(|c| { + let Some(cfg_files) = + c.labels.get("com.docker.compose.project.config_files") + else { + return false; + }; + expected.iter().any(|exp| cfg_files.contains(exp.as_str())) + }) + .collect() + } else { + // Compose config without a service field — fall back to + // the no-config matching logic inline (avoids async + // recursion). + lookup_without_config(docker, &workspace_abs).await? + } } - })) + Some(cfg) => { + let cfg_str = cfg.abs_path.to_string_lossy(); + let filter = format!("devcontainer.config_file={cfg_str}"); + list_by_label(docker, &filter).await? + } + None => lookup_without_config(docker, &workspace_abs).await?, + }; + + Ok(match candidates.len() { + 0 => DevcontainerLookup::None, + 1 => DevcontainerLookup::One(candidates.into_iter().next().unwrap()), + _ => DevcontainerLookup::Many(candidates), + }) +} + +/// No-config matching: union of `devcontainer.local_folder` and +/// `com.docker.compose.project.working_dir` matches (deduped by container +/// id). Single-container workspaces yield exactly the primary container; +/// multi-container compose workspaces yield every sibling, even those +/// that don't carry `devcontainer.*` labels (the CLI only stamps them on +/// the first container of the compose project). +async fn lookup_without_config(docker: &Docker, workspace_abs: &str) -> Result> { + let primary = format!("devcontainer.local_folder={workspace_abs}"); + let mut hits = list_by_label(docker, &primary).await?; + let fallback = format!("com.docker.compose.project.working_dir={workspace_abs}"); + let fallback_hits = list_by_label(docker, &fallback).await?; + let seen: std::collections::HashSet = hits.iter().map(|c| c.id.clone()).collect(); + for c in fallback_hits { + if !seen.contains(&c.id) { + hits.push(c); + } + } + Ok(hits) } /// Inspect a container by name or ID. diff --git a/crates/devcontainer-mcp-core/src/lib.rs b/crates/devcontainer-mcp-core/src/lib.rs index 7c7bcf1..a54d566 100644 --- a/crates/devcontainer-mcp-core/src/lib.rs +++ b/crates/devcontainer-mcp-core/src/lib.rs @@ -2,6 +2,7 @@ pub mod auth; pub mod cli; pub mod codespaces; pub mod devcontainer; +pub mod devcontainer_config; pub mod devpod; pub mod docker; pub mod error; diff --git a/crates/devcontainer-mcp-core/src/process_tree.rs b/crates/devcontainer-mcp-core/src/process_tree.rs index 0b46507..50f43d2 100644 --- a/crates/devcontainer-mcp-core/src/process_tree.rs +++ b/crates/devcontainer-mcp-core/src/process_tree.rs @@ -134,7 +134,7 @@ fn read_ppid(pid: u32) -> Option { None } -#[cfg(unix)] +#[cfg(target_os = "linux")] fn signal_all(pids: &[u32], sig: i32) { for &pid in pids { // kill(2) can fail with ESRCH if the process already exited; @@ -146,9 +146,6 @@ fn signal_all(pids: &[u32], sig: i32) { } } -#[cfg(not(unix))] -fn signal_all(_: &[u32], _: i32) {} - #[cfg(target_os = "linux")] fn any_alive(pids: &[u32]) -> bool { pids.iter().any(|&pid| { @@ -158,18 +155,12 @@ fn any_alive(pids: &[u32]) -> bool { }) } -#[cfg(unix)] +#[cfg(target_os = "linux")] mod libc_signal { pub const SIGTERM: i32 = libc::SIGTERM; pub const SIGKILL: i32 = libc::SIGKILL; } -#[cfg(not(unix))] -mod libc_signal { - pub const SIGTERM: i32 = 15; - pub const SIGKILL: i32 = 9; -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/build.rs b/crates/devcontainer-mcp/src/tools/devcontainer/build.rs index 82b3bcd..790fdf3 100644 --- a/crates/devcontainer-mcp/src/tools/devcontainer/build.rs +++ b/crates/devcontainer-mcp/src/tools/devcontainer/build.rs @@ -12,6 +12,10 @@ use crate::tools::DevContainerMcp; struct DevcontainerBuildParams { #[schemars(description = "Path to the workspace folder")] workspace_folder: String, + #[schemars( + description = "Path to a specific devcontainer.json (use to disambiguate multi-container workspaces)" + )] + config: Option, #[schemars( description = "Additional flags as space-separated args, e.g. '--no-cache --image-name my-image'" )] @@ -38,7 +42,14 @@ impl DevContainerMcp { .unwrap_or_default(); let extra_refs: Vec<&str> = extra.iter().map(|s| s.as_str()).collect(); let sink = progress_sink_from_meta(&meta, &peer); - match devcontainer::build_streaming(¶ms.workspace_folder, &extra_refs, &ct, sink).await + match devcontainer::build_streaming( + ¶ms.workspace_folder, + params.config.as_deref(), + &extra_refs, + &ct, + sink, + ) + .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 index 0a4cb86..54775ee 100644 --- a/crates/devcontainer-mcp/src/tools/devcontainer/exec.rs +++ b/crates/devcontainer-mcp/src/tools/devcontainer/exec.rs @@ -12,6 +12,10 @@ use crate::tools::DevContainerMcp; struct DevcontainerExecParams { #[schemars(description = "Path to the workspace folder")] workspace_folder: String, + #[schemars( + description = "Path to a specific devcontainer.json (use to disambiguate multi-container workspaces)" + )] + config: Option, #[schemars(description = "Command to execute inside the container")] command: String, #[schemars(description = "Arguments for the command as a space-separated string")] @@ -40,6 +44,7 @@ impl DevContainerMcp { match devcontainer::exec_streaming( ¶ms.workspace_folder, + params.config.as_deref(), "sh", &["-c", &full_cmd], &ct, diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/files.rs b/crates/devcontainer-mcp/src/tools/devcontainer/files.rs index d22ba25..fc98e1b 100644 --- a/crates/devcontainer-mcp/src/tools/devcontainer/files.rs +++ b/crates/devcontainer-mcp/src/tools/devcontainer/files.rs @@ -8,6 +8,10 @@ use crate::tools::DevContainerMcp; struct DevcontainerFileReadParams { #[schemars(description = "Path to the workspace folder")] workspace_folder: String, + #[schemars( + description = "Path to a specific devcontainer.json (use to disambiguate multi-container workspaces)" + )] + config: Option, #[schemars(description = "Path to the file inside the container")] path: String, #[schemars(description = "Start line number (1-based, inclusive)")] @@ -22,6 +26,10 @@ struct DevcontainerFileReadParams { struct DevcontainerFileWriteParams { #[schemars(description = "Path to the workspace folder")] workspace_folder: String, + #[schemars( + description = "Path to a specific devcontainer.json (use to disambiguate multi-container workspaces)" + )] + config: Option, #[schemars(description = "Path to the file inside the container")] path: String, #[schemars(description = "File content to write")] @@ -32,6 +40,10 @@ struct DevcontainerFileWriteParams { struct DevcontainerFileEditParams { #[schemars(description = "Path to the workspace folder")] workspace_folder: String, + #[schemars( + description = "Path to a specific devcontainer.json (use to disambiguate multi-container workspaces)" + )] + config: Option, #[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.")] @@ -44,6 +56,10 @@ struct DevcontainerFileEditParams { struct DevcontainerFileListParams { #[schemars(description = "Path to the workspace folder")] workspace_folder: String, + #[schemars( + description = "Path to a specific devcontainer.json (use to disambiguate multi-container workspaces)" + )] + config: Option, #[schemars(description = "Path to the directory inside the container (defaults to '.')")] path: Option, } @@ -58,7 +74,13 @@ impl DevContainerMcp { &self, Parameters(params): Parameters, ) -> String { - match devcontainer::file_read(¶ms.workspace_folder, ¶ms.path).await { + match devcontainer::file_read( + ¶ms.workspace_folder, + params.config.as_deref(), + ¶ms.path, + ) + .await + { Ok(output) => { if output.exit_code != 0 { return format!( @@ -84,8 +106,13 @@ impl DevContainerMcp { &self, Parameters(params): Parameters, ) -> String { - match devcontainer::file_write(¶ms.workspace_folder, ¶ms.path, ¶ms.content) - .await + match devcontainer::file_write( + ¶ms.workspace_folder, + params.config.as_deref(), + ¶ms.path, + ¶ms.content, + ) + .await { Ok(output) => { if output.exit_code != 0 { @@ -112,6 +139,7 @@ impl DevContainerMcp { ) -> String { match devcontainer::file_edit( ¶ms.workspace_folder, + params.config.as_deref(), ¶ms.path, ¶ms.old_str, ¶ms.new_str, @@ -132,7 +160,8 @@ impl DevContainerMcp { Parameters(params): Parameters, ) -> String { let dir = params.path.as_deref().unwrap_or("."); - match devcontainer::file_list(¶ms.workspace_folder, dir).await { + match devcontainer::file_list(¶ms.workspace_folder, params.config.as_deref(), dir).await + { Ok(output) => { if output.exit_code != 0 { format!( diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/list_configs.rs b/crates/devcontainer-mcp/src/tools/devcontainer/list_configs.rs new file mode 100644 index 0000000..5f22358 --- /dev/null +++ b/crates/devcontainer-mcp/src/tools/devcontainer/list_configs.rs @@ -0,0 +1,30 @@ +use devcontainer_mcp_core::devcontainer_config; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::{tool, tool_router}; + +use crate::tools::DevContainerMcp; + +#[derive(serde::Deserialize, schemars::JsonSchema)] +struct DevcontainerListConfigsParams { + #[schemars(description = "Path to the workspace folder to scan for devcontainer.json files")] + workspace_folder: String, +} + +#[tool_router(router = devcontainer_list_configs_router, vis = "pub(super)")] +impl DevContainerMcp { + #[tool( + name = "devcontainer_list_configs", + description = "Enumerate devcontainer.json files in a workspace. Returns each discovered config (root `.devcontainer.json`, `.devcontainer/devcontainer.json`, and `.devcontainer/*/devcontainer.json`) with its parsed name, image, service, dockerComposeFile, workspaceFolder, and kind (compose | image | dockerfile | unknown). Use the returned `path` directly as the `config` parameter to other devcontainer tools to target a specific container in a multi-container workspace." + )] + async fn devcontainer_list_configs( + &self, + Parameters(params): Parameters, + ) -> String { + match devcontainer_config::list_configs(¶ms.workspace_folder) { + Ok(entries) => { + serde_json::to_string(&entries).unwrap_or_else(|e| format!("Error: {e}")) + } + 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 index 08de29b..3ba0406 100644 --- a/crates/devcontainer-mcp/src/tools/devcontainer/mod.rs +++ b/crates/devcontainer-mcp/src/tools/devcontainer/mod.rs @@ -2,6 +2,7 @@ mod build; mod config; mod exec; mod files; +mod list_configs; mod remove; mod status; mod stop; @@ -21,5 +22,6 @@ impl DevContainerMcp { + Self::devcontainer_remove_router() + Self::devcontainer_status_router() + Self::devcontainer_files_router() + + Self::devcontainer_list_configs_router() } } diff --git a/crates/devcontainer-mcp/src/tools/devcontainer/remove.rs b/crates/devcontainer-mcp/src/tools/devcontainer/remove.rs index dabfe53..72638dc 100644 --- a/crates/devcontainer-mcp/src/tools/devcontainer/remove.rs +++ b/crates/devcontainer-mcp/src/tools/devcontainer/remove.rs @@ -8,6 +8,10 @@ use crate::tools::DevContainerMcp; struct DevcontainerRemoveParams { #[schemars(description = "Path to the workspace folder (used to find the container by label)")] workspace_folder: String, + #[schemars( + description = "Path to a specific devcontainer.json (use to disambiguate multi-container workspaces)" + )] + config: Option, #[schemars(description = "Force removal even if the container is running")] force: Option, } @@ -22,7 +26,13 @@ impl DevContainerMcp { &self, Parameters(params): Parameters, ) -> String { - match devcontainer::remove(¶ms.workspace_folder, params.force.unwrap_or(false)).await { + match devcontainer::remove( + ¶ms.workspace_folder, + params.config.as_deref(), + 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 index c8de498..c35e530 100644 --- a/crates/devcontainer-mcp/src/tools/devcontainer/status.rs +++ b/crates/devcontainer-mcp/src/tools/devcontainer/status.rs @@ -1,6 +1,7 @@ -use devcontainer_mcp_core::devcontainer; +use devcontainer_mcp_core::devcontainer::{self, StatusOutcome}; use rmcp::handler::server::wrapper::Parameters; use rmcp::{tool, tool_router}; +use serde_json::json; use crate::tools::DevContainerMcp; @@ -8,23 +9,52 @@ use crate::tools::DevContainerMcp; struct DevcontainerStatusParams { #[schemars(description = "Path to the workspace folder")] workspace_folder: String, + #[schemars( + description = "Path to a specific devcontainer.json (use to disambiguate multi-container workspaces)" + )] + config: Option, } #[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." + description = "Get the status of a local dev container. Returns container info (state, image, labels) for a single match, `{\"state\":\"NotFound\"}` if nothing matches, or `{\"state\":\"Ambiguous\",\"candidates\":[...]}` if multiple containers match the workspace and no `config` was supplied to disambiguate." )] async fn devcontainer_status( &self, Parameters(params): Parameters, ) -> String { - match devcontainer::status(¶ms.workspace_folder).await { - Ok(Some(info)) => { + match devcontainer::status(¶ms.workspace_folder, params.config.as_deref()).await { + Ok(StatusOutcome::Found(info)) => { serde_json::to_string(&info).unwrap_or_else(|e| format!("Error: {e}")) } - Ok(None) => r#"{"state":"NotFound"}"#.to_string(), + Ok(StatusOutcome::NotFound) => r#"{"state":"NotFound"}"#.to_string(), + Ok(StatusOutcome::Ambiguous(candidates)) => { + // Surface every candidate's identity, service, and + // config-file label so the agent has enough info to + // pick the right `config` and retry without further + // calls. + let entries: Vec<_> = candidates + .iter() + .map(|c| { + json!({ + "id": c.id, + "name": c.name, + "image": c.image, + "state": c.state, + "composeService": c.compose_service(), + "configFile": c.devcontainer_config_file(), + }) + }) + .collect(); + json!({ + "state": "Ambiguous", + "candidates": entries, + "hint": "Multiple containers match this workspace. Call devcontainer_list_configs and re-run with the `config` parameter set to the desired devcontainer.json.", + }) + .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 index 38acf79..78c30c0 100644 --- a/crates/devcontainer-mcp/src/tools/devcontainer/stop.rs +++ b/crates/devcontainer-mcp/src/tools/devcontainer/stop.rs @@ -8,6 +8,10 @@ use crate::tools::DevContainerMcp; struct DevcontainerStopParams { #[schemars(description = "Path to the workspace folder (used to find the container by label)")] workspace_folder: String, + #[schemars( + description = "Path to a specific devcontainer.json (use to disambiguate multi-container workspaces)" + )] + config: Option, } #[tool_router(router = devcontainer_stop_router, vis = "pub(super)")] @@ -20,7 +24,7 @@ impl DevContainerMcp { &self, Parameters(params): Parameters, ) -> String { - match devcontainer::stop(¶ms.workspace_folder).await { + match devcontainer::stop(¶ms.workspace_folder, params.config.as_deref()).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 index 9924a0c..9c7dd79 100644 --- a/crates/devcontainer-mcp/src/tools/devcontainer/up.rs +++ b/crates/devcontainer-mcp/src/tools/devcontainer/up.rs @@ -14,7 +14,9 @@ struct DevcontainerUpParams { description = "Path to the workspace folder containing .devcontainer/devcontainer.json" )] workspace_folder: String, - #[schemars(description = "Path to a specific devcontainer.json (overrides auto-detection)")] + #[schemars( + description = "Path to a specific devcontainer.json (use to disambiguate multi-container workspaces)" + )] config: Option, #[schemars( description = "Additional flags as space-separated args, e.g. '--remove-existing-container --build-no-cache'"