Compare commits

..

1 Commits

Author SHA1 Message Date
20a07c84f2 feat: upgrade MCP to Docker-based architecture (Beta)
All checks were successful
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m50s
Build App / build-linux (push) Successful in 5m28s
Sync Release to GitHub / sync-release (release) Successful in 2s
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 <noreply@anthropic.com>
2026-03-04 10:21:05 -08:00
10 changed files with 516 additions and 35 deletions

View File

@@ -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<McpServer>, Vec<McpServer>) {
let all_mcp_servers = state.mcp_store.list();
let enabled_mcp: Vec<McpServer> = project.enabled_mcp_servers.iter()
.filter_map(|id| all_mcp_servers.iter().find(|s| &s.id == id).cloned())
.collect();
let docker_mcp: Vec<McpServer> = enabled_mcp.iter()
.filter(|s| s.is_docker())
.cloned()
.collect();
(enabled_mcp, docker_mcp)
}
#[tauri::command] #[tauri::command]
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, String> { pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, String> {
Ok(state.projects_store.list()) Ok(state.projects_store.list())
@@ -97,6 +110,18 @@ pub async fn remove_project(
let _ = docker::stop_container(container_id).await; let _ = docker::stop_container(container_id).await;
let _ = docker::remove_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 // Clean up the snapshot image + volumes
if let Err(e) = docker::remove_snapshot_image(project).await { if let Err(e) = docker::remove_snapshot_image(project).await {
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e); 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); let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
// Resolve enabled MCP servers for this project // Resolve enabled MCP servers for this project
let all_mcp_servers = state.mcp_store.list(); let (enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state);
let enabled_mcp: Vec<McpServer> = project.enabled_mcp_servers.iter()
.filter_map(|id| all_mcp_servers.iter().find(|s| &s.id == id).cloned())
.collect();
// Validate auth mode requirements // Validate auth mode requirements
if project.auth_mode == AuthMode::Bedrock { if project.auth_mode == AuthMode::Bedrock {
@@ -178,6 +200,17 @@ pub async fn start_project_container(
// AWS config path from global settings // AWS config path from global settings
let aws_config_path = settings.global_aws.aws_config_path.clone(); 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? { let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
// Check if config changed — if so, snapshot + recreate // Check if config changed — if so, snapshot + recreate
let needs_recreate = docker::container_needs_recreation( let needs_recreate = docker::container_needs_recreation(
@@ -218,6 +251,7 @@ pub async fn start_project_container(
&settings.global_custom_env_vars, &settings.global_custom_env_vars,
settings.timezone.as_deref(), settings.timezone.as_deref(),
&enabled_mcp, &enabled_mcp,
network_name.as_deref(),
).await?; ).await?;
emit_progress(&app_handle, &project_id, "Starting container..."); emit_progress(&app_handle, &project_id, "Starting container...");
docker::start_container(&new_id).await?; docker::start_container(&new_id).await?;
@@ -250,6 +284,7 @@ pub async fn start_project_container(
&settings.global_custom_env_vars, &settings.global_custom_env_vars,
settings.timezone.as_deref(), settings.timezone.as_deref(),
&enabled_mcp, &enabled_mcp,
network_name.as_deref(),
).await?; ).await?;
emit_progress(&app_handle, &project_id, "Starting container..."); emit_progress(&app_handle, &project_id, "Starting container...");
docker::start_container(&new_id).await?; 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)?; state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
Ok(()) Ok(())
} }
@@ -322,6 +366,14 @@ pub async fn rebuild_project_container(
state.projects_store.set_container_id(&project_id, None)?; 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 // Remove snapshot image + volumes so Reset creates from the clean base image
if let Err(e) = docker::remove_snapshot_image(&project).await { if let Err(e) = docker::remove_snapshot_image(&project).await {
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e); log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);

View File

@@ -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. /// Build the JSON value for MCP servers config to be injected into ~/.claude.json.
/// Produces `{"mcpServers": {"name": {"type": "stdio", ...}, ...}}`. /// Produces `{"mcpServers": {"name": {"type": "stdio", ...}, ...}}`.
///
/// Handles 4 modes:
/// - Stdio+Docker: `docker exec -i <mcp-container-name> <command> ...args`
/// - Stdio+Manual: `<command> ...args` (existing behavior)
/// - HTTP+Docker: `streamableHttp` URL pointing to `http://<mcp-container-name>:<port>/mcp`
/// - HTTP+Manual: `streamableHttp` with user-provided URL + headers
fn build_mcp_servers_json(servers: &[McpServer]) -> String { fn build_mcp_servers_json(servers: &[McpServer]) -> String {
let mut mcp_map = serde_json::Map::new(); let mut mcp_map = serde_json::Map::new();
for server in servers { for server in servers {
@@ -185,18 +191,44 @@ fn build_mcp_servers_json(servers: &[McpServer]) -> String {
match server.transport_type { match server.transport_type {
McpTransportType::Stdio => { McpTransportType::Stdio => {
entry.insert("type".to_string(), serde_json::json!("stdio")); entry.insert("type".to_string(), serde_json::json!("stdio"));
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 { if let Some(ref cmd) = server.command {
entry.insert("command".to_string(), serde_json::json!(cmd)); entry.insert("command".to_string(), serde_json::json!(cmd));
} }
if !server.args.is_empty() { if !server.args.is_empty() {
entry.insert("args".to_string(), serde_json::json!(server.args)); entry.insert("args".to_string(), serde_json::json!(server.args));
} }
}
if !server.env.is_empty() { if !server.env.is_empty() {
entry.insert("env".to_string(), serde_json::json!(server.env)); entry.insert("env".to_string(), serde_json::json!(server.env));
} }
} }
McpTransportType::Http => { McpTransportType::Http => {
entry.insert("type".to_string(), serde_json::json!("http")); 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));
} else {
// HTTP+Manual: user-provided URL + headers
if let Some(ref url) = server.url { if let Some(ref url) = server.url {
entry.insert("url".to_string(), serde_json::json!(url)); entry.insert("url".to_string(), serde_json::json!(url));
} }
@@ -204,14 +236,6 @@ fn build_mcp_servers_json(servers: &[McpServer]) -> String {
entry.insert("headers".to_string(), serde_json::json!(server.headers)); 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));
}
} }
} }
mcp_map.insert(server.name.clone(), serde_json::Value::Object(entry)); mcp_map.insert(server.name.clone(), serde_json::Value::Object(entry));
@@ -271,6 +295,7 @@ pub async fn create_container(
global_custom_env_vars: &[EnvVar], global_custom_env_vars: &[EnvVar],
timezone: Option<&str>, timezone: Option<&str>,
mcp_servers: &[McpServer], mcp_servers: &[McpServer],
network_name: Option<&str>,
) -> Result<String, String> { ) -> Result<String, String> {
let docker = get_docker()?; let docker = get_docker()?;
let container_name = project.container_name(); let container_name = project.container_name();
@@ -492,8 +517,12 @@ pub async fn create_container(
} }
} }
// Docker socket (only if allowed) // Docker socket (if allowed, or auto-enabled for stdio+Docker MCP servers)
if project.allow_docker_access { 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 // On Windows, the named pipe (//./pipe/docker_engine) cannot be
// bind-mounted into a Linux container. Docker Desktop exposes the // bind-mounted into a Linux container. Docker Desktop exposes the
// daemon socket as /var/run/docker.sock for container mounts. // daemon socket as /var/run/docker.sock for container mounts.
@@ -542,6 +571,8 @@ pub async fn create_container(
mounts: Some(mounts), mounts: Some(mounts),
port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) }, port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) },
init: Some(true), init: Some(true),
// Connect to project network if specified (for MCP container communication)
network_mode: network_name.map(|n| n.to_string()),
..Default::default() ..Default::default()
}; };
@@ -931,3 +962,178 @@ pub async fn list_sibling_containers() -> Result<Vec<ContainerSummary>, String>
Ok(siblings) 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<Option<String>, String> {
let docker = get_docker()?;
let container_name = server.mcp_container_name();
let filters: HashMap<String, Vec<String>> = HashMap::from([
("name".to_string(), vec![container_name.clone()]),
]);
let containers: Vec<ContainerSummary> = 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<String, String> {
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<String> = Vec::new();
for (k, v) in &server.env {
env_vars.push(format!("{}={}", k, v));
}
// Build command + args as Cmd
let mut cmd: Vec<String> = 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(())
}

View File

@@ -2,8 +2,10 @@ pub mod client;
pub mod container; pub mod container;
pub mod image; pub mod image;
pub mod exec; pub mod exec;
pub mod network;
pub use client::*; pub use client::*;
pub use container::*; pub use container::*;
pub use image::*; pub use image::*;
pub use exec::*; pub use exec::*;
pub use network::*;

View File

@@ -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<String, String> {
let docker = get_docker()?;
let network_name = project_network_name(project_id);
// Check if network already exists
match docker
.inspect_network(&network_name, None::<InspectNetworkOptions<String>>)
.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::<InspectNetworkOptions<String>>)
.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(())
}

View File

@@ -5,8 +5,8 @@ use std::collections::HashMap;
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum McpTransportType { pub enum McpTransportType {
Stdio, Stdio,
#[serde(alias = "sse")]
Http, Http,
Sse,
} }
impl Default for McpTransportType { impl Default for McpTransportType {
@@ -29,6 +29,10 @@ pub struct McpServer {
pub url: Option<String>, pub url: Option<String>,
#[serde(default)] #[serde(default)]
pub headers: HashMap<String, String>, pub headers: HashMap<String, String>,
#[serde(default)]
pub docker_image: Option<String>,
#[serde(default)]
pub container_port: Option<u16>,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
@@ -45,8 +49,22 @@ impl McpServer {
env: HashMap::new(), env: HashMap::new(),
url: None, url: None,
headers: HashMap::new(), headers: HashMap::new(),
docker_image: None,
container_port: None,
created_at: now.clone(), created_at: now.clone(),
updated_at: now, 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)
}
} }

View File

@@ -24,7 +24,7 @@ export default function Sidebar() {
Projects Projects
</button> </button>
<button onClick={() => setSidebarView("mcp")} className={tabCls("mcp")}> <button onClick={() => setSidebarView("mcp")} className={tabCls("mcp")}>
MCP MCP <span className="text-[0.6rem] px-1 py-0.5 rounded bg-yellow-500/20 text-yellow-400 ml-0.5">Beta</span>
</button> </button>
<button onClick={() => setSidebarView("settings")} className={tabCls("settings")}> <button onClick={() => setSidebarView("settings")} className={tabCls("settings")}>
Settings Settings

View File

@@ -26,7 +26,10 @@ export default function McpPanel() {
return ( return (
<div className="space-y-3 p-2"> <div className="space-y-3 p-2">
<div> <div>
<h2 className="text-sm font-semibold text-[var(--text-primary)]">MCP Servers</h2> <h2 className="text-sm font-semibold text-[var(--text-primary)]">
MCP Servers{" "}
<span className="text-xs px-1.5 py-0.5 rounded bg-yellow-500/20 text-yellow-400">Beta</span>
</h2>
<p className="text-xs text-[var(--text-secondary)] mt-0.5"> <p className="text-xs text-[var(--text-secondary)] mt-0.5">
Define MCP servers globally, then enable them per-project. Define MCP servers globally, then enable them per-project.
</p> </p>

View File

@@ -16,6 +16,8 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
const [envPairs, setEnvPairs] = useState<[string, string][]>(Object.entries(server.env)); const [envPairs, setEnvPairs] = useState<[string, string][]>(Object.entries(server.env));
const [url, setUrl] = useState(server.url ?? ""); const [url, setUrl] = useState(server.url ?? "");
const [headerPairs, setHeaderPairs] = useState<[string, string][]>(Object.entries(server.headers)); const [headerPairs, setHeaderPairs] = useState<[string, string][]>(Object.entries(server.headers));
const [dockerImage, setDockerImage] = useState(server.docker_image ?? "");
const [containerPort, setContainerPort] = useState(server.container_port?.toString() ?? "3000");
useEffect(() => { useEffect(() => {
setName(server.name); setName(server.name);
@@ -25,6 +27,8 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
setEnvPairs(Object.entries(server.env)); setEnvPairs(Object.entries(server.env));
setUrl(server.url ?? ""); setUrl(server.url ?? "");
setHeaderPairs(Object.entries(server.headers)); setHeaderPairs(Object.entries(server.headers));
setDockerImage(server.docker_image ?? "");
setContainerPort(server.container_port?.toString() ?? "3000");
}, [server]); }, [server]);
const saveServer = async (patch: Partial<McpServer>) => { const saveServer = async (patch: Partial<McpServer>) => {
@@ -57,6 +61,15 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
saveServer({ url: url || null }); saveServer({ url: url || null });
}; };
const handleDockerImageBlur = () => {
saveServer({ docker_image: dockerImage || null });
};
const handleContainerPortBlur = () => {
const port = parseInt(containerPort, 10);
saveServer({ container_port: isNaN(port) ? null : port });
};
const saveEnv = (pairs: [string, string][]) => { const saveEnv = (pairs: [string, string][]) => {
const env: Record<string, string> = {}; const env: Record<string, string> = {};
for (const [k, v] of pairs) { for (const [k, v] of pairs) {
@@ -75,12 +88,15 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"; const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]";
const isDocker = !!dockerImage;
const transportBadge = { const transportBadge = {
stdio: "Stdio", stdio: "Stdio",
http: "HTTP", http: "HTTP",
sse: "SSE",
}[transportType]; }[transportType];
const modeBadge = isDocker ? "Docker" : "Manual";
return ( return (
<div className="border border-[var(--border-color)] rounded bg-[var(--bg-primary)]"> <div className="border border-[var(--border-color)] rounded bg-[var(--bg-primary)]">
{/* Header */} {/* Header */}
@@ -94,6 +110,9 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--bg-secondary)] text-[var(--text-secondary)]"> <span className="text-xs px-1.5 py-0.5 rounded bg-[var(--bg-secondary)] text-[var(--text-secondary)]">
{transportBadge} {transportBadge}
</span> </span>
<span className={`text-xs px-1.5 py-0.5 rounded ${isDocker ? "bg-blue-500/20 text-blue-400" : "bg-[var(--bg-secondary)] text-[var(--text-secondary)]"}`}>
{modeBadge}
</span>
</button> </button>
<button <button
onClick={() => { if (confirm(`Remove MCP server "${server.name}"?`)) onRemove(server.id); }} onClick={() => { if (confirm(`Remove MCP server "${server.name}"?`)) onRemove(server.id); }}
@@ -117,11 +136,26 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
/> />
</div> </div>
{/* Docker Image (primary field — determines Docker vs Manual mode) */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Docker Image</label>
<input
value={dockerImage}
onChange={(e) => setDockerImage(e.target.value)}
onBlur={handleDockerImageBlur}
placeholder="e.g. mcp/filesystem:latest (leave empty for manual mode)"
className={inputCls}
/>
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-60">
Set a Docker image to run this MCP server as a container. Leave empty for manual mode.
</p>
</div>
{/* Transport type */} {/* Transport type */}
<div> <div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Transport</label> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">Transport</label>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{(["stdio", "http", "sse"] as McpTransportType[]).map((t) => ( {(["stdio", "http"] as McpTransportType[]).map((t) => (
<button <button
key={t} key={t}
onClick={() => handleTransportChange(t)} onClick={() => handleTransportChange(t)}
@@ -131,12 +165,29 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]" : "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]"
}`} }`}
> >
{t === "stdio" ? "Stdio" : t === "http" ? "HTTP" : "SSE"} {t === "stdio" ? "Stdio" : "HTTP"}
</button> </button>
))} ))}
</div> </div>
</div> </div>
{/* Container Port (HTTP+Docker only) */}
{transportType === "http" && isDocker && (
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Container Port</label>
<input
value={containerPort}
onChange={(e) => setContainerPort(e.target.value)}
onBlur={handleContainerPortBlur}
placeholder="3000"
className={inputCls}
/>
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-60">
Port inside the MCP container (default: 3000)
</p>
</div>
)}
{/* Stdio fields */} {/* Stdio fields */}
{transportType === "stdio" && ( {transportType === "stdio" && (
<> <>
@@ -146,7 +197,7 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
value={command} value={command}
onChange={(e) => setCommand(e.target.value)} onChange={(e) => setCommand(e.target.value)}
onBlur={handleCommandBlur} onBlur={handleCommandBlur}
placeholder="npx" placeholder={isDocker ? "Command inside container" : "npx"}
className={inputCls} className={inputCls}
/> />
</div> </div>
@@ -169,8 +220,8 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
</> </>
)} )}
{/* HTTP/SSE fields */} {/* HTTP fields (only for manual mode — Docker mode auto-generates URL) */}
{(transportType === "http" || transportType === "sse") && ( {transportType === "http" && !isDocker && (
<> <>
<div> <div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">URL</label> <label className="block text-xs text-[var(--text-secondary)] mb-0.5">URL</label>
@@ -190,6 +241,16 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
/> />
</> </>
)} )}
{/* Environment variables for HTTP+Docker */}
{transportType === "http" && isDocker && (
<KeyValueEditor
label="Environment Variables"
pairs={envPairs}
onChange={(pairs) => { setEnvPairs(pairs); }}
onSave={saveEnv}
/>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -622,6 +622,7 @@ export default function ProjectCard({ project }: Props) {
<div className="space-y-1"> <div className="space-y-1">
{mcpServers.map((server) => { {mcpServers.map((server) => {
const enabled = project.enabled_mcp_servers.includes(server.id); const enabled = project.enabled_mcp_servers.includes(server.id);
const isDocker = !!server.docker_image;
return ( return (
<label key={server.id} className="flex items-center gap-2 cursor-pointer"> <label key={server.id} className="flex items-center gap-2 cursor-pointer">
<input <input
@@ -642,10 +643,18 @@ export default function ProjectCard({ project }: Props) {
/> />
<span className="text-xs text-[var(--text-primary)]">{server.name}</span> <span className="text-xs text-[var(--text-primary)]">{server.name}</span>
<span className="text-xs text-[var(--text-secondary)]">({server.transport_type})</span> <span className="text-xs text-[var(--text-secondary)]">({server.transport_type})</span>
<span className={`text-xs px-1 py-0.5 rounded ${isDocker ? "bg-blue-500/20 text-blue-400" : "bg-[var(--bg-secondary)] text-[var(--text-secondary)]"}`}>
{isDocker ? "Docker" : "Manual"}
</span>
</label> </label>
); );
})} })}
</div> </div>
{mcpServers.some((s) => s.docker_image && s.transport_type === "stdio" && project.enabled_mcp_servers.includes(s.id)) && (
<p className="text-xs text-[var(--text-secondary)] mt-1 opacity-70">
Docker access will be auto-enabled for stdio+Docker MCP servers.
</p>
)}
</div> </div>
)} )}

View File

@@ -117,7 +117,7 @@ export interface ReleaseAsset {
size: number; size: number;
} }
export type McpTransportType = "stdio" | "http" | "sse"; export type McpTransportType = "stdio" | "http";
export interface McpServer { export interface McpServer {
id: string; id: string;
@@ -128,6 +128,8 @@ export interface McpServer {
env: Record<string, string>; env: Record<string, string>;
url: string | null; url: string | null;
headers: Record<string, string>; headers: Record<string, string>;
docker_image: string | null;
container_port: number | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }