Remove MCP backend, entrypoint injection, and docs; add migration shim
Completes the removal begun in the previous commit. Backend: deletes models/mcp_server.rs, storage/mcp_store.rs and commands/mcp_commands.rs, the McpStore on AppState, the four IPC handlers, Project::enabled_mcp_servers, build_mcp_servers_json(), compute_mcp_fingerprint(), the MCP_SERVERS_JSON env injection, the mcp-fingerprint label, and the whole MCP container lifecycle. create_container() and container_needs_recreation() lose their mcp_servers/network_name parameters. Container: entrypoint.sh no longer merges MCP_SERVERS_JSON into ~/.claude.json. MCP_SERVERS_JSON stays in the reserved env blocklist. Security: the Docker socket is no longer auto-mounted for stdio+Docker MCP servers — it now mounts only when allow_docker_access is set. Migration: old containers were created with network_mode=triple-c-net-<projectId> and refuse to start once that network is gone. docker/network.rs becomes docker/legacy_cleanup.rs with label-driven, best-effort removal of leftover MCP containers and the per-project network, called on both delete and recreate. container_needs_recreation() now forces a rebuild for any container carrying a non-empty triple-c.mcp-fingerprint label or attached to a triple-c-net-* network, moving it onto the default bridge. Both can be dropped a release later. Docs: drops the MCP sections from README/HOW-TO-USE/TECHNICAL and adds a short note pointing at Claude Code's native `claude mcp` / `/mcp` / .mcp.json instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -157,9 +157,10 @@ pub async fn download_container_file(
|
||||
/// - the workspace (default /workspace), minus regenerable build artifacts
|
||||
/// (node_modules, target), under `workspace/`, and
|
||||
/// - a sanitized copy of the home config under `home-claude/`: ~/.claude.json
|
||||
/// with secret-bearing keys removed (mcpServers/settings kept) and ~/.claude/
|
||||
/// minus the OAuth `.credentials.json`, so MCP servers, settings and skills
|
||||
/// set up via Claude Code survive a Reset.
|
||||
/// with secret-bearing keys removed (`mcpServers` — Claude Code's own native
|
||||
/// MCP config — and `settings` are kept) and ~/.claude/ minus the OAuth
|
||||
/// `.credentials.json`, so settings and skills set up via Claude Code
|
||||
/// survive a Reset.
|
||||
/// `.git` is kept in full so the backup faithfully preserves git history,
|
||||
/// including unpushed commits. Build + gzip happen inside the container so a
|
||||
/// large workspace isn't streamed in full. The container must be RUNNING (the
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ pub mod docker_commands;
|
||||
pub mod file_commands;
|
||||
pub mod help_commands;
|
||||
pub mod install_helper_commands;
|
||||
pub mod mcp_commands;
|
||||
pub mod project_commands;
|
||||
pub mod settings_commands;
|
||||
pub mod stt_commands;
|
||||
|
||||
@@ -2,7 +2,7 @@ use tauri::{Emitter, State};
|
||||
|
||||
use crate::commands::aws_commands;
|
||||
use crate::docker;
|
||||
use crate::models::{container_config, Backend, BedrockAuthMethod, McpServer, Project, ProjectPath, ProjectStatus};
|
||||
use crate::models::{container_config, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectStatus};
|
||||
use crate::storage::secure;
|
||||
use crate::AppState;
|
||||
|
||||
@@ -63,19 +63,6 @@ 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]
|
||||
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, String> {
|
||||
Ok(state.projects_store.list())
|
||||
@@ -121,16 +108,10 @@ pub async fn remove_project(
|
||||
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);
|
||||
}
|
||||
// Legacy MCP cleanup (pre-MCP-removal installs): drop any leftover MCP
|
||||
// containers first, then the per-project network they were attached to.
|
||||
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||
docker::remove_legacy_project_network(&project.id).await;
|
||||
|
||||
// Clean up the snapshot image + volumes
|
||||
if let Err(e) = docker::remove_snapshot_image(project).await {
|
||||
@@ -177,9 +158,6 @@ 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 (enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state);
|
||||
|
||||
// Validate backend requirements
|
||||
if project.backend == Backend::Bedrock {
|
||||
let bedrock = project.bedrock_config.as_ref()
|
||||
@@ -300,39 +278,6 @@ 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() {
|
||||
// Pull any missing MCP Docker images before starting containers
|
||||
for server in &docker_mcp {
|
||||
if let Some(ref image) = server.docker_image {
|
||||
if !docker::image_exists(image).await.unwrap_or(false) {
|
||||
emit_progress(
|
||||
&app_handle,
|
||||
&project_id,
|
||||
&format!("Pulling MCP image for '{}'...", server.name),
|
||||
);
|
||||
let image_clone = image.clone();
|
||||
let app_clone = app_handle.clone();
|
||||
let pid_clone = project_id.clone();
|
||||
let sname = server.name.clone();
|
||||
docker::pull_image(&image_clone, move |msg| {
|
||||
emit_progress(&app_clone, &pid_clone, &format!("[{}] {}", sname, msg));
|
||||
}).await.map_err(|e| {
|
||||
format!("Failed to pull MCP image '{}' for '{}': {}", image, server.name, e)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -344,7 +289,6 @@ pub async fn start_project_container(
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
&enabled_mcp,
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
@@ -362,6 +306,12 @@ pub async fn start_project_container(
|
||||
let _ = docker::stop_container(&existing_id).await;
|
||||
docker::remove_container(&existing_id).await?;
|
||||
|
||||
// Legacy MCP cleanup: the old container may have been attached to
|
||||
// `triple-c-net-<projectId>`. Tear down leftover MCP containers and
|
||||
// that network now, before the replacement is created without it.
|
||||
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||
docker::remove_legacy_project_network(&project.id).await;
|
||||
|
||||
// Create from snapshot image (preserves system-level changes)
|
||||
let snapshot_image = docker::get_snapshot_image_name(&project);
|
||||
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
@@ -381,8 +331,6 @@ pub async fn start_project_container(
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
&enabled_mcp,
|
||||
network_name.as_deref(),
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
@@ -420,8 +368,6 @@ pub async fn start_project_container(
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
&enabled_mcp,
|
||||
network_name.as_deref(),
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
@@ -482,15 +428,6 @@ 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(())
|
||||
}
|
||||
@@ -514,14 +451,6 @@ 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);
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::collections::HashMap;
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
use super::client::get_docker;
|
||||
use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath};
|
||||
use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, PortMapping, Project, ProjectPath};
|
||||
|
||||
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
|
||||
|
||||
@@ -175,6 +175,8 @@ fn build_claude_instructions(
|
||||
/// Sorted alphabetically so order changes do not cause spurious recreation.
|
||||
fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
|
||||
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
|
||||
// MCP_SERVERS_JSON is reserved for legacy reasons: the built-in MCP feature was
|
||||
// removed, but the name stays blocked so users cannot hand-set it.
|
||||
let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED"];
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
for env_var in custom_env_vars {
|
||||
@@ -476,83 +478,6 @@ fn build_claude_code_settings_json(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 <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 {
|
||||
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 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!("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 {
|
||||
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);
|
||||
sha256_hex(&json)
|
||||
}
|
||||
|
||||
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
|
||||
let docker = get_docker()?;
|
||||
let container_name = project.container_name();
|
||||
@@ -594,8 +519,6 @@ pub async fn create_container(
|
||||
global_claude_instructions: Option<&str>,
|
||||
global_custom_env_vars: &[EnvVar],
|
||||
timezone: Option<&str>,
|
||||
mcp_servers: &[McpServer],
|
||||
network_name: Option<&str>,
|
||||
global_claude_code_settings: Option<&ClaudeCodeSettings>,
|
||||
default_ssh_key_path: Option<&str>,
|
||||
default_git_user_name: Option<&str>,
|
||||
@@ -795,6 +718,8 @@ pub async fn create_container(
|
||||
// Custom environment variables (global + per-project, project overrides global for same key)
|
||||
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
||||
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
|
||||
// MCP_SERVERS_JSON is reserved for legacy reasons: the built-in MCP feature was
|
||||
// removed, but the name stays blocked so users cannot hand-set it.
|
||||
let reserved_exact = ["CLAUDE_INSTRUCTIONS", "MCP_SERVERS_JSON", "CLAUDE_CODE_SETTINGS_JSON", "MISSION_CONTROL_ENABLED"];
|
||||
for env_var in &merged_env {
|
||||
let key = env_var.key.trim();
|
||||
@@ -838,12 +763,6 @@ 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));
|
||||
}
|
||||
|
||||
// Claude Code settings (global + per-project merged)
|
||||
let merged_cc_settings = merge_claude_code_settings(
|
||||
global_claude_code_settings,
|
||||
@@ -964,12 +883,8 @@ pub async fn create_container(
|
||||
}
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
// Docker socket (if allowed)
|
||||
if project.allow_docker_access {
|
||||
// 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.
|
||||
@@ -1014,7 +929,6 @@ 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));
|
||||
labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string());
|
||||
labels.insert("triple-c.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone());
|
||||
labels.insert("triple-c.claude-code-settings-fingerprint".to_string(),
|
||||
@@ -1030,8 +944,6 @@ 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()
|
||||
};
|
||||
|
||||
@@ -1307,7 +1219,6 @@ pub async fn container_needs_recreation(
|
||||
global_claude_instructions: Option<&str>,
|
||||
global_custom_env_vars: &[EnvVar],
|
||||
timezone: Option<&str>,
|
||||
mcp_servers: &[McpServer],
|
||||
global_claude_code_settings: Option<&ClaudeCodeSettings>,
|
||||
default_ssh_key_path: Option<&str>,
|
||||
default_git_user_name: Option<&str>,
|
||||
@@ -1514,11 +1425,29 @@ 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);
|
||||
// ── Legacy MCP migration shim ───────────────────────────────────────
|
||||
// One-release migration for containers created before the built-in MCP
|
||||
// feature was removed. Such containers carry a `triple-c.mcp-fingerprint`
|
||||
// label and/or are attached to the per-project `triple-c-net-<id>` network.
|
||||
// That user-defined network is deleted during cleanup, and a container
|
||||
// whose NetworkMode points at a missing network refuses to start — so force
|
||||
// a recreation to move them onto the default bridge. Containers created by
|
||||
// the current code never carry the label or the network, so this is a no-op
|
||||
// for them and can be dropped a release later.
|
||||
if let Some(fp) = get_label("triple-c.mcp-fingerprint") {
|
||||
if !fp.is_empty() {
|
||||
log::info!("Legacy container carries triple-c.mcp-fingerprint label — recreating without MCP");
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
let legacy_network = info
|
||||
.host_config
|
||||
.as_ref()
|
||||
.and_then(|hc| hc.network_mode.as_deref())
|
||||
.map(|nm| nm.starts_with("triple-c-net-"))
|
||||
.unwrap_or(false);
|
||||
if legacy_network {
|
||||
log::info!("Legacy container attached to a triple-c-net-* network — recreating without MCP");
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
@@ -1590,173 +1519,3 @@ pub async fn list_sibling_containers() -> Result<Vec<ContainerSummary>, 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)
|
||||
}
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! One-release migration shim for the removed built-in MCP feature.
|
||||
//!
|
||||
//! Older releases created a per-project user-defined bridge network
|
||||
//! (`triple-c-net-<projectId>`) plus one container per Docker-backed MCP
|
||||
//! server, and attached the project container to that network. Now that MCP
|
||||
//! support is gone, those leftovers have to be torn down — a container whose
|
||||
//! `NetworkMode` names a network that no longer exists refuses to start, so
|
||||
//! the cleanup is paired with a forced container recreation (see
|
||||
//! `container_needs_recreation`).
|
||||
//!
|
||||
//! Everything here is best-effort: failures are logged and never abort the
|
||||
//! caller, and absent resources are a silent no-op. This module can be deleted
|
||||
//! a release after all users have migrated.
|
||||
|
||||
use bollard::container::{ListContainersOptions, RemoveContainerOptions};
|
||||
use bollard::network::InspectNetworkOptions;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::client::get_docker;
|
||||
|
||||
/// Network name used by the old MCP implementation for a project.
|
||||
fn legacy_network_name(project_id: &str) -> String {
|
||||
format!("triple-c-net-{}", project_id)
|
||||
}
|
||||
|
||||
/// Force-remove every leftover MCP server container.
|
||||
///
|
||||
/// Matched by the `triple-c.mcp-server` label rather than by name, so
|
||||
/// containers survive even if the MCP server definitions they came from are
|
||||
/// already gone from storage. Best-effort: errors are logged and skipped.
|
||||
pub async fn remove_legacy_mcp_containers(project_id: &str) {
|
||||
let docker = match get_docker() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"Skipping legacy MCP container cleanup for project {}: {}",
|
||||
project_id,
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||
"label".to_string(),
|
||||
vec!["triple-c.mcp-server".to_string()],
|
||||
)]);
|
||||
|
||||
let containers = match docker
|
||||
.list_containers(Some(ListContainersOptions {
|
||||
all: true,
|
||||
filters,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to list legacy MCP containers: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for container in containers {
|
||||
let Some(id) = container.id else { continue };
|
||||
match docker
|
||||
.remove_container(
|
||||
&id,
|
||||
Some(RemoveContainerOptions {
|
||||
force: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => log::info!("Removed legacy MCP container {}", id),
|
||||
Err(e) => log::warn!("Failed to remove legacy MCP container {}: {}", id, e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the old per-project Docker network, disconnecting any remaining
|
||||
/// members first (a network with attached endpoints cannot be deleted).
|
||||
///
|
||||
/// Silent no-op when the network does not exist. Best-effort: errors are
|
||||
/// logged and never propagated.
|
||||
pub async fn remove_legacy_project_network(project_id: &str) {
|
||||
let docker = match get_docker() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"Skipping legacy network cleanup for project {}: {}",
|
||||
project_id,
|
||||
e
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let network_name = legacy_network_name(project_id);
|
||||
|
||||
// Inspect to discover connected containers; absence means nothing to do.
|
||||
let info = match docker
|
||||
.inspect_network(&network_name, None::<InspectNetworkOptions<String>>)
|
||||
.await
|
||||
{
|
||||
Ok(info) => info,
|
||||
Err(_) => {
|
||||
log::debug!("Legacy network {} not present, nothing to do", network_name);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(containers) = info.containers {
|
||||
for container_id in containers.into_keys() {
|
||||
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 legacy network {}: {}",
|
||||
container_id,
|
||||
network_name,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match docker.remove_network(&network_name).await {
|
||||
Ok(_) => log::info!("Removed legacy Docker network {}", network_name),
|
||||
Err(e) => log::warn!("Failed to remove legacy network {}: {}", network_name, e),
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ pub mod client;
|
||||
pub mod container;
|
||||
pub mod image;
|
||||
pub mod exec;
|
||||
pub mod network;
|
||||
pub mod legacy_cleanup;
|
||||
pub mod stt;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
@@ -16,4 +16,4 @@ pub use image::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use exec::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use network::*;
|
||||
pub use legacy_cleanup::*;
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
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.
|
||||
#[allow(dead_code)]
|
||||
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(())
|
||||
}
|
||||
@@ -11,14 +11,12 @@ use std::sync::Arc;
|
||||
use docker::exec::ExecSessionManager;
|
||||
use storage::projects_store::ProjectsStore;
|
||||
use storage::settings_store::SettingsStore;
|
||||
use storage::mcp_store::McpStore;
|
||||
use tauri::Manager;
|
||||
use web_terminal::WebTerminalServer;
|
||||
|
||||
pub struct AppState {
|
||||
pub projects_store: Arc<ProjectsStore>,
|
||||
pub settings_store: Arc<SettingsStore>,
|
||||
pub mcp_store: Arc<McpStore>,
|
||||
pub exec_manager: Arc<ExecSessionManager>,
|
||||
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
|
||||
}
|
||||
@@ -40,13 +38,6 @@ pub fn run() {
|
||||
panic!("Failed to initialize settings store: {}", e);
|
||||
}
|
||||
});
|
||||
let mcp_store = Arc::new(match McpStore::new() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::error!("Failed to initialize MCP store: {}", e);
|
||||
panic!("Failed to initialize MCP store: {}", e);
|
||||
}
|
||||
});
|
||||
let exec_manager = Arc::new(ExecSessionManager::new());
|
||||
|
||||
// Clone Arcs for the setup closure (web terminal auto-start)
|
||||
@@ -61,7 +52,6 @@ pub fn run() {
|
||||
.manage(AppState {
|
||||
projects_store,
|
||||
settings_store,
|
||||
mcp_store,
|
||||
exec_manager,
|
||||
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
})
|
||||
@@ -187,11 +177,6 @@ pub fn run() {
|
||||
commands::file_commands::download_container_file,
|
||||
commands::file_commands::download_container_backup,
|
||||
commands::file_commands::upload_file_to_container,
|
||||
// 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,
|
||||
// AWS
|
||||
commands::aws_commands::aws_sso_refresh,
|
||||
// Updates
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum McpTransportType {
|
||||
Stdio,
|
||||
#[serde(alias = "sse")]
|
||||
Http,
|
||||
}
|
||||
|
||||
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>,
|
||||
#[serde(default)]
|
||||
pub docker_image: Option<String>,
|
||||
#[serde(default)]
|
||||
pub container_port: Option<u16>,
|
||||
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(),
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,8 @@ 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::*;
|
||||
|
||||
@@ -92,8 +92,6 @@ pub struct Project {
|
||||
#[serde(default)]
|
||||
pub claude_instructions: Option<String>,
|
||||
#[serde(default)]
|
||||
pub enabled_mcp_servers: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub claude_code_settings: Option<ClaudeCodeSettings>,
|
||||
/// User-defined display names for terminal tabs, keyed by session id.
|
||||
#[serde(default)]
|
||||
@@ -220,7 +218,6 @@ impl Project {
|
||||
custom_env_vars: Vec::new(),
|
||||
port_mappings: Vec::new(),
|
||||
claude_instructions: None,
|
||||
enabled_mcp_servers: Vec::new(),
|
||||
claude_code_settings: None,
|
||||
renamed_session_names: HashMap::new(),
|
||||
created_at: now.clone(),
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
pub mod projects_store;
|
||||
pub mod secure;
|
||||
pub mod settings_store;
|
||||
pub mod mcp_store;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use projects_store::*;
|
||||
@@ -9,5 +8,3 @@ pub use projects_store::*;
|
||||
pub use secure::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use settings_store::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use mcp_store::*;
|
||||
|
||||
Reference in New Issue
Block a user