From 20a07c84f238b36e92980d9b3dac1ef4e4149b8b Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 4 Mar 2026 10:21:05 -0800 Subject: [PATCH] feat: upgrade MCP to Docker-based architecture (Beta) Each MCP server can now run as its own Docker container on a dedicated per-project bridge network, enabling proper isolation and lifecycle management. SSE transport is removed (deprecated per MCP spec) with backward-compatible serde alias. Docker socket access is auto-enabled when stdio+Docker MCP servers are configured. Co-Authored-By: Claude Opus 4.6 --- .../src/commands/project_commands.rs | 60 ++++- app/src-tauri/src/docker/container.rs | 248 ++++++++++++++++-- app/src-tauri/src/docker/mod.rs | 2 + app/src-tauri/src/docker/network.rs | 128 +++++++++ app/src-tauri/src/models/mcp_server.rs | 20 +- app/src/components/layout/Sidebar.tsx | 2 +- app/src/components/mcp/McpPanel.tsx | 5 +- app/src/components/mcp/McpServerCard.tsx | 73 +++++- app/src/components/projects/ProjectCard.tsx | 9 + app/src/lib/types.ts | 4 +- 10 files changed, 516 insertions(+), 35 deletions(-) create mode 100644 app/src-tauri/src/docker/network.rs diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index 3694f04..a857351 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -53,6 +53,19 @@ fn load_secrets_for_project(project: &mut Project) { } } +/// Resolve enabled MCP servers and filter to Docker-only ones. +fn resolve_mcp_servers(project: &Project, state: &AppState) -> (Vec, Vec) { + let all_mcp_servers = state.mcp_store.list(); + let enabled_mcp: Vec = project.enabled_mcp_servers.iter() + .filter_map(|id| all_mcp_servers.iter().find(|s| &s.id == id).cloned()) + .collect(); + let docker_mcp: Vec = enabled_mcp.iter() + .filter(|s| s.is_docker()) + .cloned() + .collect(); + (enabled_mcp, docker_mcp) +} + #[tauri::command] pub async fn list_projects(state: State<'_, AppState>) -> Result, String> { Ok(state.projects_store.list()) @@ -97,6 +110,18 @@ pub async fn remove_project( let _ = docker::stop_container(container_id).await; let _ = docker::remove_container(container_id).await; } + + // Remove MCP containers and network + let (_enabled_mcp, docker_mcp) = resolve_mcp_servers(project, &state); + if !docker_mcp.is_empty() { + if let Err(e) = docker::remove_mcp_containers(&docker_mcp).await { + log::warn!("Failed to remove MCP containers for project {}: {}", project_id, e); + } + } + if let Err(e) = docker::remove_project_network(&project.id).await { + log::warn!("Failed to remove project network for project {}: {}", project_id, e); + } + // Clean up the snapshot image + volumes if let Err(e) = docker::remove_snapshot_image(project).await { log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e); @@ -143,10 +168,7 @@ pub async fn start_project_container( let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name); // Resolve enabled MCP servers for this project - let all_mcp_servers = state.mcp_store.list(); - let enabled_mcp: Vec = project.enabled_mcp_servers.iter() - .filter_map(|id| all_mcp_servers.iter().find(|s| &s.id == id).cloned()) - .collect(); + let (enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state); // Validate auth mode requirements if project.auth_mode == AuthMode::Bedrock { @@ -178,6 +200,17 @@ pub async fn start_project_container( // AWS config path from global settings let aws_config_path = settings.global_aws.aws_config_path.clone(); + // Set up Docker network and MCP containers if needed + let network_name = if !docker_mcp.is_empty() { + emit_progress(&app_handle, &project_id, "Setting up MCP network..."); + let net = docker::ensure_project_network(&project.id).await?; + emit_progress(&app_handle, &project_id, "Starting MCP containers..."); + docker::start_mcp_containers(&docker_mcp, &net).await?; + Some(net) + } else { + None + }; + let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? { // Check if config changed — if so, snapshot + recreate let needs_recreate = docker::container_needs_recreation( @@ -218,6 +251,7 @@ pub async fn start_project_container( &settings.global_custom_env_vars, settings.timezone.as_deref(), &enabled_mcp, + network_name.as_deref(), ).await?; emit_progress(&app_handle, &project_id, "Starting container..."); docker::start_container(&new_id).await?; @@ -250,6 +284,7 @@ pub async fn start_project_container( &settings.global_custom_env_vars, settings.timezone.as_deref(), &enabled_mcp, + network_name.as_deref(), ).await?; emit_progress(&app_handle, &project_id, "Starting container..."); docker::start_container(&new_id).await?; @@ -299,6 +334,15 @@ pub async fn stop_project_container( } } + // Stop MCP containers (best-effort) + let (_enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state); + if !docker_mcp.is_empty() { + emit_progress(&app_handle, &project_id, "Stopping MCP containers..."); + if let Err(e) = docker::stop_mcp_containers(&docker_mcp).await { + log::warn!("Failed to stop MCP containers for project {}: {}", project_id, e); + } + } + state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?; Ok(()) } @@ -322,6 +366,14 @@ pub async fn rebuild_project_container( state.projects_store.set_container_id(&project_id, None)?; } + // Remove MCP containers before rebuild + let (_enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state); + if !docker_mcp.is_empty() { + if let Err(e) = docker::remove_mcp_containers(&docker_mcp).await { + log::warn!("Failed to remove MCP containers for project {}: {}", project_id, e); + } + } + // Remove snapshot image + volumes so Reset creates from the clean base image if let Err(e) = docker::remove_snapshot_image(&project).await { log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e); diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 04a6a34..7a2bed7 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -178,6 +178,12 @@ fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String { /// Build the JSON value for MCP servers config to be injected into ~/.claude.json. /// Produces `{"mcpServers": {"name": {"type": "stdio", ...}, ...}}`. +/// +/// Handles 4 modes: +/// - Stdio+Docker: `docker exec -i ...args` +/// - Stdio+Manual: ` ...args` (existing behavior) +/// - HTTP+Docker: `streamableHttp` URL pointing to `http://:/mcp` +/// - HTTP+Manual: `streamableHttp` with user-provided URL + headers fn build_mcp_servers_json(servers: &[McpServer]) -> String { let mut mcp_map = serde_json::Map::new(); for server in servers { @@ -185,32 +191,50 @@ fn build_mcp_servers_json(servers: &[McpServer]) -> String { match server.transport_type { McpTransportType::Stdio => { entry.insert("type".to_string(), serde_json::json!("stdio")); - if let Some(ref cmd) = server.command { - entry.insert("command".to_string(), serde_json::json!(cmd)); - } - if !server.args.is_empty() { - entry.insert("args".to_string(), serde_json::json!(server.args)); + if server.is_docker() { + // Stdio+Docker: use `docker exec` to communicate with MCP container + entry.insert("command".to_string(), serde_json::json!("docker")); + let mut args = vec![ + "exec".to_string(), + "-i".to_string(), + server.mcp_container_name(), + ]; + if let Some(ref cmd) = server.command { + args.push(cmd.clone()); + } + args.extend(server.args.iter().cloned()); + entry.insert("args".to_string(), serde_json::json!(args)); + } else { + // Stdio+Manual: existing behavior + if let Some(ref cmd) = server.command { + entry.insert("command".to_string(), serde_json::json!(cmd)); + } + if !server.args.is_empty() { + entry.insert("args".to_string(), serde_json::json!(server.args)); + } } if !server.env.is_empty() { entry.insert("env".to_string(), serde_json::json!(server.env)); } } McpTransportType::Http => { - entry.insert("type".to_string(), serde_json::json!("http")); - if let Some(ref url) = server.url { + entry.insert("type".to_string(), serde_json::json!("streamableHttp")); + if server.is_docker() { + // HTTP+Docker: point to MCP container by name on the shared network + let url = format!( + "http://{}:{}/mcp", + server.mcp_container_name(), + server.effective_container_port() + ); entry.insert("url".to_string(), serde_json::json!(url)); - } - if !server.headers.is_empty() { - entry.insert("headers".to_string(), serde_json::json!(server.headers)); - } - } - McpTransportType::Sse => { - entry.insert("type".to_string(), serde_json::json!("sse")); - if let Some(ref url) = server.url { - entry.insert("url".to_string(), serde_json::json!(url)); - } - if !server.headers.is_empty() { - entry.insert("headers".to_string(), serde_json::json!(server.headers)); + } else { + // HTTP+Manual: user-provided URL + headers + if let Some(ref url) = server.url { + entry.insert("url".to_string(), serde_json::json!(url)); + } + if !server.headers.is_empty() { + entry.insert("headers".to_string(), serde_json::json!(server.headers)); + } } } } @@ -271,6 +295,7 @@ pub async fn create_container( global_custom_env_vars: &[EnvVar], timezone: Option<&str>, mcp_servers: &[McpServer], + network_name: Option<&str>, ) -> Result { let docker = get_docker()?; let container_name = project.container_name(); @@ -492,8 +517,12 @@ pub async fn create_container( } } - // Docker socket (only if allowed) - if project.allow_docker_access { + // Docker socket (if allowed, or auto-enabled for stdio+Docker MCP servers) + let needs_docker_for_mcp = any_stdio_docker_mcp(mcp_servers); + if project.allow_docker_access || needs_docker_for_mcp { + if needs_docker_for_mcp && !project.allow_docker_access { + log::info!("Auto-enabling Docker socket access for stdio+Docker MCP servers"); + } // On Windows, the named pipe (//./pipe/docker_engine) cannot be // bind-mounted into a Linux container. Docker Desktop exposes the // daemon socket as /var/run/docker.sock for container mounts. @@ -542,6 +571,8 @@ pub async fn create_container( mounts: Some(mounts), port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) }, init: Some(true), + // Connect to project network if specified (for MCP container communication) + network_mode: network_name.map(|n| n.to_string()), ..Default::default() }; @@ -931,3 +962,178 @@ pub async fn list_sibling_containers() -> Result, String> Ok(siblings) } + +// ── MCP Container Lifecycle ───────────────────────────────────────────── + +/// Returns true if any MCP server uses stdio transport with Docker. +pub fn any_stdio_docker_mcp(servers: &[McpServer]) -> bool { + servers.iter().any(|s| s.is_docker() && s.transport_type == McpTransportType::Stdio) +} + +/// Returns true if any MCP server uses Docker. +pub fn any_docker_mcp(servers: &[McpServer]) -> bool { + servers.iter().any(|s| s.is_docker()) +} + +/// Find an existing MCP container by its expected name. +pub async fn find_mcp_container(server: &McpServer) -> Result, String> { + let docker = get_docker()?; + let container_name = server.mcp_container_name(); + + let filters: HashMap> = HashMap::from([ + ("name".to_string(), vec![container_name.clone()]), + ]); + + let containers: Vec = docker + .list_containers(Some(ListContainersOptions { + all: true, + filters, + ..Default::default() + })) + .await + .map_err(|e| format!("Failed to list MCP containers: {}", e))?; + + let expected = format!("/{}", container_name); + for c in &containers { + if let Some(names) = &c.names { + if names.iter().any(|n| n == &expected) { + return Ok(c.id.clone()); + } + } + } + + Ok(None) +} + +/// Create a Docker container for an MCP server. +pub async fn create_mcp_container( + server: &McpServer, + network_name: &str, +) -> Result { + let docker = get_docker()?; + let container_name = server.mcp_container_name(); + + let image = server + .docker_image + .as_ref() + .ok_or_else(|| format!("MCP server '{}' has no docker_image", server.name))?; + + let mut env_vars: Vec = Vec::new(); + for (k, v) in &server.env { + env_vars.push(format!("{}={}", k, v)); + } + + // Build command + args as Cmd + let mut cmd: Vec = Vec::new(); + if let Some(ref command) = server.command { + cmd.push(command.clone()); + } + cmd.extend(server.args.iter().cloned()); + + let mut labels = HashMap::new(); + labels.insert("triple-c.managed".to_string(), "true".to_string()); + labels.insert("triple-c.mcp-server".to_string(), server.id.clone()); + + let host_config = HostConfig { + network_mode: Some(network_name.to_string()), + ..Default::default() + }; + + let config = Config { + image: Some(image.clone()), + env: if env_vars.is_empty() { None } else { Some(env_vars) }, + cmd: if cmd.is_empty() { None } else { Some(cmd) }, + labels: Some(labels), + host_config: Some(host_config), + ..Default::default() + }; + + let options = CreateContainerOptions { + name: container_name.clone(), + ..Default::default() + }; + + let response = docker + .create_container(Some(options), config) + .await + .map_err(|e| format!("Failed to create MCP container '{}': {}", container_name, e))?; + + log::info!( + "Created MCP container {} (image: {}) on network {}", + container_name, + image, + network_name + ); + Ok(response.id) +} + +/// Start all Docker-based MCP server containers. Finds or creates each one. +pub async fn start_mcp_containers( + servers: &[McpServer], + network_name: &str, +) -> Result<(), String> { + for server in servers { + if !server.is_docker() { + continue; + } + + let container_id = if let Some(existing_id) = find_mcp_container(server).await? { + log::debug!("Found existing MCP container for '{}'", server.name); + existing_id + } else { + create_mcp_container(server, network_name).await? + }; + + // Start the container (ignore already-started errors) + if let Err(e) = start_container(&container_id).await { + let err_str = e.to_string(); + if err_str.contains("already started") || err_str.contains("304") { + log::debug!("MCP container '{}' already running", server.name); + } else { + return Err(format!( + "Failed to start MCP container '{}': {}", + server.name, e + )); + } + } + + log::info!("MCP container '{}' started", server.name); + } + + Ok(()) +} + +/// Stop all Docker-based MCP server containers (best-effort). +pub async fn stop_mcp_containers(servers: &[McpServer]) -> Result<(), String> { + for server in servers { + if !server.is_docker() { + continue; + } + if let Ok(Some(container_id)) = find_mcp_container(server).await { + if let Err(e) = stop_container(&container_id).await { + log::warn!("Failed to stop MCP container '{}': {}", server.name, e); + } else { + log::info!("Stopped MCP container '{}'", server.name); + } + } + } + Ok(()) +} + +/// Stop and remove all Docker-based MCP server containers (best-effort). +pub async fn remove_mcp_containers(servers: &[McpServer]) -> Result<(), String> { + for server in servers { + if !server.is_docker() { + continue; + } + if let Ok(Some(container_id)) = find_mcp_container(server).await { + let _ = stop_container(&container_id).await; + if let Err(e) = remove_container(&container_id).await { + log::warn!("Failed to remove MCP container '{}': {}", server.name, e); + } else { + log::info!("Removed MCP container '{}'", server.name); + } + } + } + Ok(()) +} diff --git a/app/src-tauri/src/docker/mod.rs b/app/src-tauri/src/docker/mod.rs index 6331958..a4ccbe9 100644 --- a/app/src-tauri/src/docker/mod.rs +++ b/app/src-tauri/src/docker/mod.rs @@ -2,8 +2,10 @@ pub mod client; pub mod container; pub mod image; pub mod exec; +pub mod network; pub use client::*; pub use container::*; pub use image::*; pub use exec::*; +pub use network::*; diff --git a/app/src-tauri/src/docker/network.rs b/app/src-tauri/src/docker/network.rs new file mode 100644 index 0000000..ca49608 --- /dev/null +++ b/app/src-tauri/src/docker/network.rs @@ -0,0 +1,128 @@ +use bollard::network::{CreateNetworkOptions, InspectNetworkOptions}; +use std::collections::HashMap; + +use super::client::get_docker; + +/// Network name for a project's MCP containers. +fn project_network_name(project_id: &str) -> String { + format!("triple-c-net-{}", project_id) +} + +/// Ensure a Docker bridge network exists for the project. +/// Returns the network name. +pub async fn ensure_project_network(project_id: &str) -> Result { + let docker = get_docker()?; + let network_name = project_network_name(project_id); + + // Check if network already exists + match docker + .inspect_network(&network_name, None::>) + .await + { + Ok(_) => { + log::debug!("Network {} already exists", network_name); + return Ok(network_name); + } + Err(_) => { + // Network doesn't exist, create it + } + } + + let options = CreateNetworkOptions { + name: network_name.clone(), + driver: "bridge".to_string(), + labels: HashMap::from([ + ("triple-c.managed".to_string(), "true".to_string()), + ("triple-c.project-id".to_string(), project_id.to_string()), + ]), + ..Default::default() + }; + + docker + .create_network(options) + .await + .map_err(|e| format!("Failed to create network {}: {}", network_name, e))?; + + log::info!("Created Docker network {}", network_name); + Ok(network_name) +} + +/// Connect a container to the project network. +pub async fn connect_container_to_network( + container_id: &str, + network_name: &str, +) -> Result<(), String> { + let docker = get_docker()?; + + let config = bollard::network::ConnectNetworkOptions { + container: container_id.to_string(), + ..Default::default() + }; + + docker + .connect_network(network_name, config) + .await + .map_err(|e| { + format!( + "Failed to connect container {} to network {}: {}", + container_id, network_name, e + ) + })?; + + log::debug!( + "Connected container {} to network {}", + container_id, + network_name + ); + Ok(()) +} + +/// Remove the project network (best-effort). Disconnects all containers first. +pub async fn remove_project_network(project_id: &str) -> Result<(), String> { + let docker = get_docker()?; + let network_name = project_network_name(project_id); + + // Inspect to get connected containers + let info = match docker + .inspect_network(&network_name, None::>) + .await + { + Ok(info) => info, + Err(_) => { + log::debug!( + "Network {} not found, nothing to remove", + network_name + ); + return Ok(()); + } + }; + + // Disconnect all containers + if let Some(containers) = info.containers { + for (container_id, _) in containers { + let disconnect_opts = bollard::network::DisconnectNetworkOptions { + container: container_id.clone(), + force: true, + }; + if let Err(e) = docker + .disconnect_network(&network_name, disconnect_opts) + .await + { + log::warn!( + "Failed to disconnect container {} from network {}: {}", + container_id, + network_name, + e + ); + } + } + } + + // Remove the network + match docker.remove_network(&network_name).await { + Ok(_) => log::info!("Removed Docker network {}", network_name), + Err(e) => log::warn!("Failed to remove network {}: {}", network_name, e), + } + + Ok(()) +} diff --git a/app/src-tauri/src/models/mcp_server.rs b/app/src-tauri/src/models/mcp_server.rs index 1b3b232..1fad1d8 100644 --- a/app/src-tauri/src/models/mcp_server.rs +++ b/app/src-tauri/src/models/mcp_server.rs @@ -5,8 +5,8 @@ use std::collections::HashMap; #[serde(rename_all = "snake_case")] pub enum McpTransportType { Stdio, + #[serde(alias = "sse")] Http, - Sse, } impl Default for McpTransportType { @@ -29,6 +29,10 @@ pub struct McpServer { pub url: Option, #[serde(default)] pub headers: HashMap, + #[serde(default)] + pub docker_image: Option, + #[serde(default)] + pub container_port: Option, pub created_at: String, pub updated_at: String, } @@ -45,8 +49,22 @@ impl McpServer { env: HashMap::new(), url: None, headers: HashMap::new(), + docker_image: None, + container_port: None, created_at: now.clone(), updated_at: now, } } + + pub fn is_docker(&self) -> bool { + self.docker_image.is_some() + } + + pub fn mcp_container_name(&self) -> String { + format!("triple-c-mcp-{}", self.id) + } + + pub fn effective_container_port(&self) -> u16 { + self.container_port.unwrap_or(3000) + } } diff --git a/app/src/components/layout/Sidebar.tsx b/app/src/components/layout/Sidebar.tsx index de06f53..2eb6ac6 100644 --- a/app/src/components/layout/Sidebar.tsx +++ b/app/src/components/layout/Sidebar.tsx @@ -24,7 +24,7 @@ export default function Sidebar() { Projects ))} + {/* Container Port (HTTP+Docker only) */} + {transportType === "http" && isDocker && ( +
+ + setContainerPort(e.target.value)} + onBlur={handleContainerPortBlur} + placeholder="3000" + className={inputCls} + /> +

+ Port inside the MCP container (default: 3000) +

+
+ )} + {/* Stdio fields */} {transportType === "stdio" && ( <> @@ -146,7 +197,7 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) { value={command} onChange={(e) => setCommand(e.target.value)} onBlur={handleCommandBlur} - placeholder="npx" + placeholder={isDocker ? "Command inside container" : "npx"} className={inputCls} /> @@ -169,8 +220,8 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) { )} - {/* HTTP/SSE fields */} - {(transportType === "http" || transportType === "sse") && ( + {/* HTTP fields (only for manual mode — Docker mode auto-generates URL) */} + {transportType === "http" && !isDocker && ( <>
@@ -190,6 +241,16 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) { /> )} + + {/* Environment variables for HTTP+Docker */} + {transportType === "http" && isDocker && ( + { setEnvPairs(pairs); }} + onSave={saveEnv} + /> + )}
)} diff --git a/app/src/components/projects/ProjectCard.tsx b/app/src/components/projects/ProjectCard.tsx index c3150b7..cb108ce 100644 --- a/app/src/components/projects/ProjectCard.tsx +++ b/app/src/components/projects/ProjectCard.tsx @@ -622,6 +622,7 @@ export default function ProjectCard({ project }: Props) {
{mcpServers.map((server) => { const enabled = project.enabled_mcp_servers.includes(server.id); + const isDocker = !!server.docker_image; return ( ); })}
+ {mcpServers.some((s) => s.docker_image && s.transport_type === "stdio" && project.enabled_mcp_servers.includes(s.id)) && ( +

+ Docker access will be auto-enabled for stdio+Docker MCP servers. +

+ )} )} diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 9f6d8a9..593e055 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -117,7 +117,7 @@ export interface ReleaseAsset { size: number; } -export type McpTransportType = "stdio" | "http" | "sse"; +export type McpTransportType = "stdio" | "http"; export interface McpServer { id: string; @@ -128,6 +128,8 @@ export interface McpServer { env: Record; url: string | null; headers: Record; + docker_image: string | null; + container_port: number | null; created_at: string; updated_at: string; }