feat: add MCP server support with global library and per-project toggles
All checks were successful
Build App / build-macos (push) Successful in 2m20s
Build App / build-windows (push) Successful in 3m21s
Build App / build-linux (push) Successful in 5m8s
Build Container / build-container (push) Successful in 1m4s
Sync Release to GitHub / sync-release (release) Successful in 2s

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 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 08:57:12 -08:00
parent 2ddc705925
commit 625d48a6ed
22 changed files with 839 additions and 23 deletions

View File

@@ -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<Vec<McpServer>, String> {
Ok(state.mcp_store.list())
}
#[tauri::command]
pub async fn add_mcp_server(
name: String,
state: State<'_, AppState>,
) -> Result<McpServer, String> {
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<McpServer, String> {
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)
}

View File

@@ -1,4 +1,5 @@
pub mod docker_commands;
pub mod mcp_commands;
pub mod project_commands;
pub mod settings_commands;
pub mod terminal_commands;

View File

@@ -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<McpServer> = 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?;

View File

@@ -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<Option<String>, 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<String, String> {
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<Mount> = 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<bool, String> {
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)
}

View File

@@ -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,

View File

@@ -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<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
pub url: Option<String>,
#[serde(default)]
pub headers: HashMap<String, String>,
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,
}
}
}

View File

@@ -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::*;

View File

@@ -45,6 +45,8 @@ pub struct Project {
pub port_mappings: Vec<PortMapping>,
#[serde(default)]
pub claude_instructions: Option<String>,
#[serde(default)]
pub enabled_mcp_servers: Vec<String>,
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,
}

View File

@@ -0,0 +1,106 @@
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use crate::models::McpServer;
pub struct McpStore {
servers: Mutex<Vec<McpServer>>,
file_path: PathBuf,
}
impl McpStore {
pub fn new() -> Result<Self, String> {
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::<Vec<McpServer>>(&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<McpServer>> {
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<McpServer> {
self.lock().clone()
}
pub fn get(&self, id: &str) -> Option<McpServer> {
self.lock().iter().find(|s| s.id == id).cloned()
}
pub fn add(&self, server: McpServer) -> Result<McpServer, String> {
let mut servers = self.lock();
let cloned = server.clone();
servers.push(server);
self.save(&servers)?;
Ok(cloned)
}
pub fn update(&self, updated: McpServer) -> Result<McpServer, String> {
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(())
}
}

View File

@@ -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::*;