From 625d48a6ed218ec4983d79c415356cc8ccd65fd9 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 4 Mar 2026 08:57:12 -0800 Subject: [PATCH] feat: add MCP server support with global library and per-project toggles Add Model Context Protocol (MCP) server configuration support. Users can define MCP servers globally (new sidebar tab) and enable them per-project. Enabled servers are injected into containers as MCP_SERVERS_JSON env var and merged into ~/.claude.json by the entrypoint. Backend: McpServer model, McpStore (JSON + atomic writes), 4 CRUD commands, container injection with fingerprint-based recreation detection. Frontend: MCP sidebar tab, McpPanel/McpServerCard components, useMcpServers hook, per-project MCP checkboxes in ProjectCard config. Co-Authored-By: Claude Opus 4.6 --- app/src-tauri/src/commands/mcp_commands.rs | 38 +++ app/src-tauri/src/commands/mod.rs | 1 + .../src/commands/project_commands.rs | 11 +- app/src-tauri/src/docker/container.rs | 74 ++++- app/src-tauri/src/lib.rs | 15 + app/src-tauri/src/models/mcp_server.rs | 52 ++++ app/src-tauri/src/models/mod.rs | 2 + app/src-tauri/src/models/project.rs | 3 + app/src-tauri/src/storage/mcp_store.rs | 106 +++++++ app/src-tauri/src/storage/mod.rs | 2 + app/src/App.tsx | 3 + app/src/components/layout/Sidebar.test.tsx | 3 + app/src/components/layout/Sidebar.tsx | 37 +-- app/src/components/mcp/McpPanel.tsx | 76 +++++ app/src/components/mcp/McpServerCard.tsx | 262 ++++++++++++++++++ .../components/projects/ProjectCard.test.tsx | 12 + app/src/components/projects/ProjectCard.tsx | 36 +++ app/src/hooks/useMcpServers.ts | 55 ++++ app/src/lib/tauri-commands.ts | 11 +- app/src/lib/types.ts | 16 ++ app/src/store/appState.ts | 26 +- container/entrypoint.sh | 21 ++ 22 files changed, 839 insertions(+), 23 deletions(-) create mode 100644 app/src-tauri/src/commands/mcp_commands.rs create mode 100644 app/src-tauri/src/models/mcp_server.rs create mode 100644 app/src-tauri/src/storage/mcp_store.rs create mode 100644 app/src/components/mcp/McpPanel.tsx create mode 100644 app/src/components/mcp/McpServerCard.tsx create mode 100644 app/src/hooks/useMcpServers.ts diff --git a/app/src-tauri/src/commands/mcp_commands.rs b/app/src-tauri/src/commands/mcp_commands.rs new file mode 100644 index 0000000..771a227 --- /dev/null +++ b/app/src-tauri/src/commands/mcp_commands.rs @@ -0,0 +1,38 @@ +use tauri::State; + +use crate::models::McpServer; +use crate::AppState; + +#[tauri::command] +pub async fn list_mcp_servers(state: State<'_, AppState>) -> Result, String> { + Ok(state.mcp_store.list()) +} + +#[tauri::command] +pub async fn add_mcp_server( + name: String, + state: State<'_, AppState>, +) -> Result { + let name = name.trim().to_string(); + if name.is_empty() { + return Err("MCP server name cannot be empty.".to_string()); + } + let server = McpServer::new(name); + state.mcp_store.add(server) +} + +#[tauri::command] +pub async fn update_mcp_server( + server: McpServer, + state: State<'_, AppState>, +) -> Result { + state.mcp_store.update(server) +} + +#[tauri::command] +pub async fn remove_mcp_server( + server_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + state.mcp_store.remove(&server_id) +} diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index 2c0a311..6dd8ec6 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod docker_commands; +pub mod mcp_commands; pub mod project_commands; pub mod settings_commands; pub mod terminal_commands; diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index cdcf1ce..3694f04 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -1,7 +1,7 @@ use tauri::{Emitter, State}; use crate::docker; -use crate::models::{container_config, AuthMode, Project, ProjectPath, ProjectStatus}; +use crate::models::{container_config, AuthMode, McpServer, Project, ProjectPath, ProjectStatus}; use crate::storage::secure; use crate::AppState; @@ -142,6 +142,12 @@ pub async fn start_project_container( let settings = state.settings_store.get(); 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(); + // Validate auth mode requirements if project.auth_mode == AuthMode::Bedrock { let bedrock = project.bedrock_config.as_ref() @@ -180,6 +186,7 @@ pub async fn start_project_container( settings.global_claude_instructions.as_deref(), &settings.global_custom_env_vars, settings.timezone.as_deref(), + &enabled_mcp, ).await.unwrap_or(false); if needs_recreate { @@ -210,6 +217,7 @@ pub async fn start_project_container( settings.global_claude_instructions.as_deref(), &settings.global_custom_env_vars, settings.timezone.as_deref(), + &enabled_mcp, ).await?; emit_progress(&app_handle, &project_id, "Starting container..."); docker::start_container(&new_id).await?; @@ -241,6 +249,7 @@ pub async fn start_project_container( settings.global_claude_instructions.as_deref(), &settings.global_custom_env_vars, settings.timezone.as_deref(), + &enabled_mcp, ).await?; emit_progress(&app_handle, &project_id, "Starting container..."); docker::start_container(&new_id).await?; diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index e9a108a..04a6a34 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -9,7 +9,7 @@ use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use super::client::get_docker; -use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, PortMapping, Project, ProjectPath}; +use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath}; const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks @@ -176,6 +176,61 @@ fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String { format!("{:x}", hasher.finish()) } +/// Build the JSON value for MCP servers config to be injected into ~/.claude.json. +/// Produces `{"mcpServers": {"name": {"type": "stdio", ...}, ...}}`. +fn build_mcp_servers_json(servers: &[McpServer]) -> String { + let mut mcp_map = serde_json::Map::new(); + for server in servers { + let mut entry = serde_json::Map::new(); + 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.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("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)); + } + } + } + mcp_map.insert(server.name.clone(), serde_json::Value::Object(entry)); + } + let wrapper = serde_json::json!({ "mcpServers": mcp_map }); + serde_json::to_string(&wrapper).unwrap_or_default() +} + +/// Compute a fingerprint for MCP server configuration so we can detect changes. +fn compute_mcp_fingerprint(servers: &[McpServer]) -> String { + if servers.is_empty() { + return String::new(); + } + let json = build_mcp_servers_json(servers); + let mut hasher = DefaultHasher::new(); + json.hash(&mut hasher); + format!("{:x}", hasher.finish()) +} + pub async fn find_existing_container(project: &Project) -> Result, String> { let docker = get_docker()?; let container_name = project.container_name(); @@ -215,6 +270,7 @@ pub async fn create_container( global_claude_instructions: Option<&str>, global_custom_env_vars: &[EnvVar], timezone: Option<&str>, + mcp_servers: &[McpServer], ) -> Result { let docker = get_docker()?; let container_name = project.container_name(); @@ -355,6 +411,12 @@ pub async fn create_container( env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions)); } + // MCP servers config + if !mcp_servers.is_empty() { + let mcp_json = build_mcp_servers_json(mcp_servers); + env_vars.push(format!("MCP_SERVERS_JSON={}", mcp_json)); + } + let mut mounts: Vec = Vec::new(); // Project directories -> /workspace/{mount_name} @@ -474,6 +536,7 @@ pub async fn create_container( labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings)); labels.insert("triple-c.image".to_string(), image_name.to_string()); labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string()); + labels.insert("triple-c.mcp-fingerprint".to_string(), compute_mcp_fingerprint(mcp_servers)); let host_config = HostConfig { mounts: Some(mounts), @@ -637,6 +700,7 @@ pub async fn container_needs_recreation( global_claude_instructions: Option<&str>, global_custom_env_vars: &[EnvVar], timezone: Option<&str>, + mcp_servers: &[McpServer], ) -> Result { let docker = get_docker()?; let info = docker @@ -801,6 +865,14 @@ pub async fn container_needs_recreation( return Ok(true); } + // ── MCP servers fingerprint ───────────────────────────────────────── + let expected_mcp_fp = compute_mcp_fingerprint(mcp_servers); + let container_mcp_fp = get_label("triple-c.mcp-fingerprint").unwrap_or_default(); + if container_mcp_fp != expected_mcp_fp { + log::info!("MCP servers fingerprint mismatch (container={:?}, expected={:?})", container_mcp_fp, expected_mcp_fp); + return Ok(true); + } + Ok(false) } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 3507b58..a508792 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -7,11 +7,13 @@ mod storage; use docker::exec::ExecSessionManager; use storage::projects_store::ProjectsStore; use storage::settings_store::SettingsStore; +use storage::mcp_store::McpStore; use tauri::Manager; pub struct AppState { pub projects_store: ProjectsStore, pub settings_store: SettingsStore, + pub mcp_store: McpStore, pub exec_manager: ExecSessionManager, } @@ -32,6 +34,13 @@ pub fn run() { panic!("Failed to initialize settings store: {}", e); } }; + let mcp_store = match McpStore::new() { + Ok(s) => s, + Err(e) => { + log::error!("Failed to initialize MCP store: {}", e); + panic!("Failed to initialize MCP store: {}", e); + } + }; tauri::Builder::default() .plugin(tauri_plugin_store::Builder::default().build()) @@ -40,6 +49,7 @@ pub fn run() { .manage(AppState { projects_store, settings_store, + mcp_store, exec_manager: ExecSessionManager::new(), }) .setup(|app| { @@ -91,6 +101,11 @@ pub fn run() { commands::terminal_commands::terminal_resize, commands::terminal_commands::close_terminal_session, commands::terminal_commands::paste_image_to_terminal, + // MCP + commands::mcp_commands::list_mcp_servers, + commands::mcp_commands::add_mcp_server, + commands::mcp_commands::update_mcp_server, + commands::mcp_commands::remove_mcp_server, // Updates commands::update_commands::get_app_version, commands::update_commands::check_for_updates, diff --git a/app/src-tauri/src/models/mcp_server.rs b/app/src-tauri/src/models/mcp_server.rs new file mode 100644 index 0000000..1b3b232 --- /dev/null +++ b/app/src-tauri/src/models/mcp_server.rs @@ -0,0 +1,52 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum McpTransportType { + Stdio, + Http, + Sse, +} + +impl Default for McpTransportType { + fn default() -> Self { + Self::Stdio + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpServer { + pub id: String, + pub name: String, + #[serde(default)] + pub transport_type: McpTransportType, + pub command: Option, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub env: HashMap, + pub url: Option, + #[serde(default)] + pub headers: HashMap, + pub created_at: String, + pub updated_at: String, +} + +impl McpServer { + pub fn new(name: String) -> Self { + let now = chrono::Utc::now().to_rfc3339(); + Self { + id: uuid::Uuid::new_v4().to_string(), + name, + transport_type: McpTransportType::default(), + command: None, + args: Vec::new(), + env: HashMap::new(), + url: None, + headers: HashMap::new(), + created_at: now.clone(), + updated_at: now, + } + } +} diff --git a/app/src-tauri/src/models/mod.rs b/app/src-tauri/src/models/mod.rs index 5abbf24..66cae6f 100644 --- a/app/src-tauri/src/models/mod.rs +++ b/app/src-tauri/src/models/mod.rs @@ -2,8 +2,10 @@ pub mod project; pub mod container_config; pub mod app_settings; pub mod update_info; +pub mod mcp_server; pub use project::*; pub use container_config::*; pub use app_settings::*; pub use update_info::*; +pub use mcp_server::*; diff --git a/app/src-tauri/src/models/project.rs b/app/src-tauri/src/models/project.rs index a2dd912..180e4ee 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -45,6 +45,8 @@ pub struct Project { pub port_mappings: Vec, #[serde(default)] pub claude_instructions: Option, + #[serde(default)] + pub enabled_mcp_servers: Vec, pub created_at: String, pub updated_at: String, } @@ -130,6 +132,7 @@ impl Project { custom_env_vars: Vec::new(), port_mappings: Vec::new(), claude_instructions: None, + enabled_mcp_servers: Vec::new(), created_at: now.clone(), updated_at: now, } diff --git a/app/src-tauri/src/storage/mcp_store.rs b/app/src-tauri/src/storage/mcp_store.rs new file mode 100644 index 0000000..b28c99b --- /dev/null +++ b/app/src-tauri/src/storage/mcp_store.rs @@ -0,0 +1,106 @@ +use std::fs; +use std::path::PathBuf; +use std::sync::Mutex; + +use crate::models::McpServer; + +pub struct McpStore { + servers: Mutex>, + file_path: PathBuf, +} + +impl McpStore { + pub fn new() -> Result { + let data_dir = dirs::data_dir() + .ok_or_else(|| "Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string())? + .join("triple-c"); + + fs::create_dir_all(&data_dir).ok(); + + let file_path = data_dir.join("mcp_servers.json"); + + let servers = if file_path.exists() { + match fs::read_to_string(&file_path) { + Ok(data) => { + match serde_json::from_str::>(&data) { + Ok(parsed) => parsed, + Err(e) => { + log::error!("Failed to parse mcp_servers.json: {}. Starting with empty list.", e); + let backup = file_path.with_extension("json.bak"); + if let Err(be) = fs::copy(&file_path, &backup) { + log::error!("Failed to back up corrupted mcp_servers.json: {}", be); + } + Vec::new() + } + } + } + Err(e) => { + log::error!("Failed to read mcp_servers.json: {}", e); + Vec::new() + } + } + } else { + Vec::new() + }; + + Ok(Self { + servers: Mutex::new(servers), + file_path, + }) + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Vec> { + self.servers.lock().unwrap_or_else(|e| e.into_inner()) + } + + fn save(&self, servers: &[McpServer]) -> Result<(), String> { + let data = serde_json::to_string_pretty(servers) + .map_err(|e| format!("Failed to serialize MCP servers: {}", e))?; + + // Atomic write: write to temp file, then rename + let tmp_path = self.file_path.with_extension("json.tmp"); + fs::write(&tmp_path, data) + .map_err(|e| format!("Failed to write temp MCP servers file: {}", e))?; + fs::rename(&tmp_path, &self.file_path) + .map_err(|e| format!("Failed to rename MCP servers file: {}", e))?; + Ok(()) + } + + pub fn list(&self) -> Vec { + self.lock().clone() + } + + pub fn get(&self, id: &str) -> Option { + self.lock().iter().find(|s| s.id == id).cloned() + } + + pub fn add(&self, server: McpServer) -> Result { + let mut servers = self.lock(); + let cloned = server.clone(); + servers.push(server); + self.save(&servers)?; + Ok(cloned) + } + + pub fn update(&self, updated: McpServer) -> Result { + let mut servers = self.lock(); + if let Some(s) = servers.iter_mut().find(|s| s.id == updated.id) { + *s = updated.clone(); + self.save(&servers)?; + Ok(updated) + } else { + Err(format!("MCP server {} not found", updated.id)) + } + } + + pub fn remove(&self, id: &str) -> Result<(), String> { + let mut servers = self.lock(); + let initial_len = servers.len(); + servers.retain(|s| s.id != id); + if servers.len() == initial_len { + return Err(format!("MCP server {} not found", id)); + } + self.save(&servers)?; + Ok(()) + } +} diff --git a/app/src-tauri/src/storage/mod.rs b/app/src-tauri/src/storage/mod.rs index 8fe3915..b258619 100644 --- a/app/src-tauri/src/storage/mod.rs +++ b/app/src-tauri/src/storage/mod.rs @@ -1,7 +1,9 @@ pub mod projects_store; pub mod secure; pub mod settings_store; +pub mod mcp_store; pub use projects_store::*; pub use secure::*; pub use settings_store::*; +pub use mcp_store::*; diff --git a/app/src/App.tsx b/app/src/App.tsx index 127236a..6c9584f 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -7,6 +7,7 @@ import TerminalView from "./components/terminal/TerminalView"; import { useDocker } from "./hooks/useDocker"; import { useSettings } from "./hooks/useSettings"; import { useProjects } from "./hooks/useProjects"; +import { useMcpServers } from "./hooks/useMcpServers"; import { useUpdates } from "./hooks/useUpdates"; import { useAppState } from "./store/appState"; @@ -14,6 +15,7 @@ export default function App() { const { checkDocker, checkImage, startDockerPolling } = useDocker(); const { loadSettings } = useSettings(); const { refresh } = useProjects(); + const { refresh: refreshMcp } = useMcpServers(); const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates(); const { sessions, activeSessionId } = useAppState( useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId })) @@ -31,6 +33,7 @@ export default function App() { } }); refresh(); + refreshMcp(); // Update detection loadVersion(); diff --git a/app/src/components/layout/Sidebar.test.tsx b/app/src/components/layout/Sidebar.test.tsx index 1b9cdab..5800c91 100644 --- a/app/src/components/layout/Sidebar.test.tsx +++ b/app/src/components/layout/Sidebar.test.tsx @@ -19,6 +19,9 @@ vi.mock("../projects/ProjectList", () => ({ vi.mock("../settings/SettingsPanel", () => ({ default: () =>
SettingsPanel
, })); +vi.mock("../mcp/McpPanel", () => ({ + default: () =>
McpPanel
, +})); describe("Sidebar", () => { beforeEach(() => { diff --git a/app/src/components/layout/Sidebar.tsx b/app/src/components/layout/Sidebar.tsx index dc0c4ca..de06f53 100644 --- a/app/src/components/layout/Sidebar.tsx +++ b/app/src/components/layout/Sidebar.tsx @@ -1,6 +1,7 @@ import { useShallow } from "zustand/react/shallow"; import { useAppState } from "../../store/appState"; import ProjectList from "../projects/ProjectList"; +import McpPanel from "../mcp/McpPanel"; import SettingsPanel from "../settings/SettingsPanel"; export default function Sidebar() { @@ -8,35 +9,37 @@ export default function Sidebar() { useShallow(s => ({ sidebarView: s.sidebarView, setSidebarView: s.setSidebarView })) ); + const tabCls = (view: typeof sidebarView) => + `flex-1 px-3 py-2 text-sm font-medium transition-colors ${ + sidebarView === view + ? "text-[var(--accent)] border-b-2 border-[var(--accent)]" + : "text-[var(--text-secondary)] hover:text-[var(--text-primary)]" + }`; + return (
{/* Nav tabs */}
- - +
{/* Content */}
- {sidebarView === "projects" ? : } + {sidebarView === "projects" ? ( + + ) : sidebarView === "mcp" ? ( + + ) : ( + + )}
); diff --git a/app/src/components/mcp/McpPanel.tsx b/app/src/components/mcp/McpPanel.tsx new file mode 100644 index 0000000..7bbc6ef --- /dev/null +++ b/app/src/components/mcp/McpPanel.tsx @@ -0,0 +1,76 @@ +import { useState, useEffect } from "react"; +import { useMcpServers } from "../../hooks/useMcpServers"; +import McpServerCard from "./McpServerCard"; + +export default function McpPanel() { + const { mcpServers, refresh, add, update, remove } = useMcpServers(); + const [newName, setNewName] = useState(""); + const [error, setError] = useState(null); + + useEffect(() => { + refresh(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const handleAdd = async () => { + const name = newName.trim(); + if (!name) return; + setError(null); + try { + await add(name); + setNewName(""); + } catch (e) { + setError(String(e)); + } + }; + + return ( +
+
+

MCP Servers

+

+ Define MCP servers globally, then enable them per-project. +

+
+ + {/* Add new server */} +
+ setNewName(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleAdd(); }} + placeholder="Server name..." + className="flex-1 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)]" + /> + +
+ + {error && ( +
{error}
+ )} + + {/* Server list */} +
+ {mcpServers.length === 0 ? ( +

+ No MCP servers configured. +

+ ) : ( + mcpServers.map((server) => ( + + )) + )} +
+
+ ); +} diff --git a/app/src/components/mcp/McpServerCard.tsx b/app/src/components/mcp/McpServerCard.tsx new file mode 100644 index 0000000..904dfe0 --- /dev/null +++ b/app/src/components/mcp/McpServerCard.tsx @@ -0,0 +1,262 @@ +import { useState, useEffect } from "react"; +import type { McpServer, McpTransportType } from "../../lib/types"; + +interface Props { + server: McpServer; + onUpdate: (server: McpServer) => Promise; + onRemove: (id: string) => Promise; +} + +export default function McpServerCard({ server, onUpdate, onRemove }: Props) { + const [expanded, setExpanded] = useState(false); + const [name, setName] = useState(server.name); + const [transportType, setTransportType] = useState(server.transport_type); + const [command, setCommand] = useState(server.command ?? ""); + const [args, setArgs] = useState(server.args.join(" ")); + const [envPairs, setEnvPairs] = useState<[string, string][]>(Object.entries(server.env)); + const [url, setUrl] = useState(server.url ?? ""); + const [headerPairs, setHeaderPairs] = useState<[string, string][]>(Object.entries(server.headers)); + + useEffect(() => { + setName(server.name); + setTransportType(server.transport_type); + setCommand(server.command ?? ""); + setArgs(server.args.join(" ")); + setEnvPairs(Object.entries(server.env)); + setUrl(server.url ?? ""); + setHeaderPairs(Object.entries(server.headers)); + }, [server]); + + const saveServer = async (patch: Partial) => { + try { + await onUpdate({ ...server, ...patch }); + } catch (err) { + console.error("Failed to update MCP server:", err); + } + }; + + const handleNameBlur = () => { + if (name !== server.name) saveServer({ name }); + }; + + const handleTransportChange = (t: McpTransportType) => { + setTransportType(t); + saveServer({ transport_type: t }); + }; + + const handleCommandBlur = () => { + saveServer({ command: command || null }); + }; + + const handleArgsBlur = () => { + const parsed = args.trim() ? args.trim().split(/\s+/) : []; + saveServer({ args: parsed }); + }; + + const handleUrlBlur = () => { + saveServer({ url: url || null }); + }; + + const saveEnv = (pairs: [string, string][]) => { + const env: Record = {}; + for (const [k, v] of pairs) { + if (k.trim()) env[k.trim()] = v; + } + saveServer({ env }); + }; + + const saveHeaders = (pairs: [string, string][]) => { + const headers: Record = {}; + for (const [k, v] of pairs) { + if (k.trim()) headers[k.trim()] = v; + } + saveServer({ headers }); + }; + + 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 transportBadge = { + stdio: "Stdio", + http: "HTTP", + sse: "SSE", + }[transportType]; + + return ( +
+ {/* Header */} +
+ + +
+ + {/* Expanded config */} + {expanded && ( +
+ {/* Name */} +
+ + setName(e.target.value)} + onBlur={handleNameBlur} + className={inputCls} + /> +
+ + {/* Transport type */} +
+ +
+ {(["stdio", "http", "sse"] as McpTransportType[]).map((t) => ( + + ))} +
+
+ + {/* Stdio fields */} + {transportType === "stdio" && ( + <> +
+ + setCommand(e.target.value)} + onBlur={handleCommandBlur} + placeholder="npx" + className={inputCls} + /> +
+
+ + setArgs(e.target.value)} + onBlur={handleArgsBlur} + placeholder="-y @modelcontextprotocol/server-filesystem /path" + className={inputCls} + /> +
+ { setEnvPairs(pairs); }} + onSave={saveEnv} + /> + + )} + + {/* HTTP/SSE fields */} + {(transportType === "http" || transportType === "sse") && ( + <> +
+ + setUrl(e.target.value)} + onBlur={handleUrlBlur} + placeholder="http://localhost:3000/mcp" + className={inputCls} + /> +
+ { setHeaderPairs(pairs); }} + onSave={saveHeaders} + /> + + )} +
+ )} +
+ ); +} + +function KeyValueEditor({ + label, + pairs, + onChange, + onSave, +}: { + label: string; + pairs: [string, string][]; + onChange: (pairs: [string, string][]) => void; + onSave: (pairs: [string, string][]) => void; +}) { + const inputCls = "flex-1 min-w-0 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)]"; + + return ( +
+ + {pairs.map(([key, value], i) => ( +
+ { + const updated = [...pairs] as [string, string][]; + updated[i] = [e.target.value, value]; + onChange(updated); + }} + onBlur={() => onSave(pairs)} + placeholder="KEY" + className={inputCls} + /> + = + { + const updated = [...pairs] as [string, string][]; + updated[i] = [key, e.target.value]; + onChange(updated); + }} + onBlur={() => onSave(pairs)} + placeholder="value" + className={inputCls} + /> + +
+ ))} + +
+ ); +} diff --git a/app/src/components/projects/ProjectCard.test.tsx b/app/src/components/projects/ProjectCard.test.tsx index ec38c66..2b2f658 100644 --- a/app/src/components/projects/ProjectCard.test.tsx +++ b/app/src/components/projects/ProjectCard.test.tsx @@ -31,6 +31,16 @@ vi.mock("../../hooks/useTerminal", () => ({ }), })); +vi.mock("../../hooks/useMcpServers", () => ({ + useMcpServers: () => ({ + mcpServers: [], + refresh: vi.fn(), + add: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + }), +})); + let mockSelectedProjectId: string | null = null; vi.mock("../../store/appState", () => ({ useAppState: vi.fn((selector) => @@ -55,7 +65,9 @@ const mockProject: Project = { git_user_name: null, git_user_email: null, custom_env_vars: [], + port_mappings: [], claude_instructions: null, + enabled_mcp_servers: [], created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", }; diff --git a/app/src/components/projects/ProjectCard.tsx b/app/src/components/projects/ProjectCard.tsx index 50ce254..c3150b7 100644 --- a/app/src/components/projects/ProjectCard.tsx +++ b/app/src/components/projects/ProjectCard.tsx @@ -3,6 +3,7 @@ import { open } from "@tauri-apps/plugin-dialog"; import { listen } from "@tauri-apps/api/event"; import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types"; import { useProjects } from "../../hooks/useProjects"; +import { useMcpServers } from "../../hooks/useMcpServers"; import { useTerminal } from "../../hooks/useTerminal"; import { useAppState } from "../../store/appState"; import EnvVarsModal from "./EnvVarsModal"; @@ -18,6 +19,7 @@ export default function ProjectCard({ project }: Props) { const selectedProjectId = useAppState(s => s.selectedProjectId); const setSelectedProject = useAppState(s => s.setSelectedProject); const { start, stop, rebuild, remove, update } = useProjects(); + const { mcpServers } = useMcpServers(); const { open: openTerminal } = useTerminal(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -613,6 +615,40 @@ export default function ProjectCard({ project }: Props) { + {/* MCP Servers */} + {mcpServers.length > 0 && ( +
+ +
+ {mcpServers.map((server) => { + const enabled = project.enabled_mcp_servers.includes(server.id); + return ( + + ); + })} +
+
+ )} + {/* Bedrock config */} {project.auth_mode === "bedrock" && (() => { const bc = project.bedrock_config ?? defaultBedrockConfig; diff --git a/app/src/hooks/useMcpServers.ts b/app/src/hooks/useMcpServers.ts new file mode 100644 index 0000000..8ce32f8 --- /dev/null +++ b/app/src/hooks/useMcpServers.ts @@ -0,0 +1,55 @@ +import { useCallback } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useAppState } from "../store/appState"; +import * as commands from "../lib/tauri-commands"; +import type { McpServer } from "../lib/types"; + +export function useMcpServers() { + const { + mcpServers, + setMcpServers, + updateMcpServerInList, + removeMcpServerFromList, + } = useAppState( + useShallow(s => ({ + mcpServers: s.mcpServers, + setMcpServers: s.setMcpServers, + updateMcpServerInList: s.updateMcpServerInList, + removeMcpServerFromList: s.removeMcpServerFromList, + })) + ); + + const refresh = useCallback(async () => { + const list = await commands.listMcpServers(); + setMcpServers(list); + }, [setMcpServers]); + + const add = useCallback( + async (name: string) => { + const server = await commands.addMcpServer(name); + const list = await commands.listMcpServers(); + setMcpServers(list); + return server; + }, + [setMcpServers], + ); + + const update = useCallback( + async (server: McpServer) => { + const updated = await commands.updateMcpServer(server); + updateMcpServerInList(updated); + return updated; + }, + [updateMcpServerInList], + ); + + const remove = useCallback( + async (id: string) => { + await commands.removeMcpServer(id); + removeMcpServerFromList(id); + }, + [removeMcpServerFromList], + ); + + return { mcpServers, refresh, add, update, remove }; +} diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 6197a50..a8c3ff3 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo } from "./types"; +import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, McpServer } from "./types"; // Docker export const checkDocker = () => invoke("check_docker"); @@ -50,6 +50,15 @@ export const closeTerminalSession = (sessionId: string) => export const pasteImageToTerminal = (sessionId: string, imageData: number[]) => invoke("paste_image_to_terminal", { sessionId, imageData }); +// MCP Servers +export const listMcpServers = () => invoke("list_mcp_servers"); +export const addMcpServer = (name: string) => + invoke("add_mcp_server", { name }); +export const updateMcpServer = (server: McpServer) => + invoke("update_mcp_server", { server }); +export const removeMcpServer = (serverId: string) => + invoke("remove_mcp_server", { serverId }); + // Updates export const getAppVersion = () => invoke("get_app_version"); export const checkForUpdates = () => diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index db094bd..9f6d8a9 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -30,6 +30,7 @@ export interface Project { custom_env_vars: EnvVar[]; port_mappings: PortMapping[]; claude_instructions: string | null; + enabled_mcp_servers: string[]; created_at: string; updated_at: string; } @@ -115,3 +116,18 @@ export interface ReleaseAsset { browser_download_url: string; size: number; } + +export type McpTransportType = "stdio" | "http" | "sse"; + +export interface McpServer { + id: string; + name: string; + transport_type: McpTransportType; + command: string | null; + args: string[]; + env: Record; + url: string | null; + headers: Record; + created_at: string; + updated_at: string; +} diff --git a/app/src/store/appState.ts b/app/src/store/appState.ts index 5f80e29..8b52fd3 100644 --- a/app/src/store/appState.ts +++ b/app/src/store/appState.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import type { Project, TerminalSession, AppSettings, UpdateInfo } from "../lib/types"; +import type { Project, TerminalSession, AppSettings, UpdateInfo, McpServer } from "../lib/types"; interface AppState { // Projects @@ -17,9 +17,15 @@ interface AppState { removeSession: (id: string) => void; setActiveSession: (id: string | null) => void; + // MCP servers + mcpServers: McpServer[]; + setMcpServers: (servers: McpServer[]) => void; + updateMcpServerInList: (server: McpServer) => void; + removeMcpServerFromList: (id: string) => void; + // UI state - sidebarView: "projects" | "settings"; - setSidebarView: (view: "projects" | "settings") => void; + sidebarView: "projects" | "mcp" | "settings"; + setSidebarView: (view: "projects" | "mcp" | "settings") => void; dockerAvailable: boolean | null; setDockerAvailable: (available: boolean | null) => void; imageExists: boolean | null; @@ -75,6 +81,20 @@ export const useAppState = create((set) => ({ }), setActiveSession: (id) => set({ activeSessionId: id }), + // MCP servers + mcpServers: [], + setMcpServers: (servers) => set({ mcpServers: servers }), + updateMcpServerInList: (server) => + set((state) => ({ + mcpServers: state.mcpServers.map((s) => + s.id === server.id ? server : s, + ), + })), + removeMcpServerFromList: (id) => + set((state) => ({ + mcpServers: state.mcpServers.filter((s) => s.id !== id), + })), + // UI state sidebarView: "projects", setSidebarView: (view) => set({ sidebarView: view }), diff --git a/container/entrypoint.sh b/container/entrypoint.sh index 98646cc..f8c03cb 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -103,6 +103,27 @@ if [ -n "$CLAUDE_INSTRUCTIONS" ]; then unset CLAUDE_INSTRUCTIONS fi +# ── MCP server configuration ──────────────────────────────────────────────── +# Merge MCP server config into ~/.claude.json (preserves existing keys like +# OAuth tokens). Creates the file if it doesn't exist. +if [ -n "$MCP_SERVERS_JSON" ]; then + CLAUDE_JSON="/home/claude/.claude.json" + if [ -f "$CLAUDE_JSON" ]; then + # Merge: existing config + MCP config (MCP keys override on conflict) + MERGED=$(jq -s '.[0] * .[1]' "$CLAUDE_JSON" <(printf '%s' "$MCP_SERVERS_JSON") 2>/dev/null) + if [ -n "$MERGED" ]; then + printf '%s\n' "$MERGED" > "$CLAUDE_JSON" + else + echo "entrypoint: warning — failed to merge MCP config into $CLAUDE_JSON" + fi + else + printf '%s\n' "$MCP_SERVERS_JSON" > "$CLAUDE_JSON" + fi + chown claude:claude "$CLAUDE_JSON" + chmod 600 "$CLAUDE_JSON" + unset MCP_SERVERS_JSON +fi + # ── Docker socket permissions ──────────────────────────────────────────────── if [ -S /var/run/docker.sock ]; then DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)