diff --git a/src-tauri/src/gemini_mcp.rs b/src-tauri/src/gemini_mcp.rs index c87a16c9c..62a959c18 100644 --- a/src-tauri/src/gemini_mcp.rs +++ b/src-tauri/src/gemini_mcp.rs @@ -42,8 +42,8 @@ fn write_json_value(path: &Path, value: &Value) -> Result<(), AppError> { /// /// 执行反向格式转换以保持与统一 MCP 结构的兼容性: /// - httpUrl → url + type: "http" -/// - 仅有 url 字段 → 保持不变(SSE 类型) -/// - 仅有 command 字段 → 保持不变(stdio 类型) +/// - 仅有 url 字段 → 补齐 type: "sse"(Gemini 以字段名推断传输类型) +/// - 仅有 command 字段 → 补齐 type: "stdio" pub fn read_mcp_servers_map() -> Result, AppError> { let path = user_config_path(); if !path.exists() { @@ -65,8 +65,15 @@ pub fn read_mcp_servers_map() -> Result obj.insert("url".to_string(), http_url); obj.insert("type".to_string(), Value::String("http".to_string())); } - // 如果有 url 但没有 type,不添加 type(默认为 SSE) - // 如果有 command 但没有 type,不添加 type(默认为 stdio) + + // Gemini CLI 不使用 type 字段:这里补齐成统一结构,便于校验与导入 + if obj.get("type").is_none() { + if obj.contains_key("command") { + obj.insert("type".to_string(), Value::String("stdio".to_string())); + } else if obj.contains_key("url") { + obj.insert("type".to_string(), Value::String("sse".to_string())); + } + } } } diff --git a/src-tauri/src/mcp/codex.rs b/src-tauri/src/mcp/codex.rs index a82ff75bf..437614128 100644 --- a/src-tauri/src/mcp/codex.rs +++ b/src-tauri/src/mcp/codex.rs @@ -376,7 +376,8 @@ pub fn sync_single_server_to_codex( doc["mcp_servers"][id] = Item::Table(toml_table); // 写回文件 - std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?; + let new_text = doc.to_string(); + crate::config::write_text_file(&config_path, &new_text)?; Ok(()) } @@ -412,7 +413,8 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> { } // 写回文件 - std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?; + let new_text = doc.to_string(); + crate::config::write_text_file(&config_path, &new_text)?; Ok(()) } diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index 4f95c950a..29f821d57 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -17,8 +17,27 @@ impl McpService { /// 添加或更新 MCP 服务器 pub fn upsert_server(state: &AppState, server: McpServer) -> Result<(), AppError> { + // 读取旧状态:用于处理“编辑时取消勾选某个应用”的场景(需要从对应 live 配置中移除) + let prev_apps = state + .db + .get_all_mcp_servers()? + .get(&server.id) + .map(|s| s.apps.clone()) + .unwrap_or_default(); + state.db.save_mcp_server(&server)?; + // 处理禁用:若旧版本启用但新版本取消,则需要从该应用的 live 配置移除 + if prev_apps.claude && !server.apps.claude { + Self::remove_server_from_app(state, &server.id, &AppType::Claude)?; + } + if prev_apps.codex && !server.apps.codex { + Self::remove_server_from_app(state, &server.id, &AppType::Codex)?; + } + if prev_apps.gemini && !server.apps.gemini { + Self::remove_server_from_app(state, &server.id, &AppType::Gemini)?; + } + // 同步到各个启用的应用 Self::sync_server_to_apps(state, &server)?; @@ -190,10 +209,22 @@ impl McpService { // 如果有导入的服务器,保存到数据库 if count > 0 { if let Some(servers) = &temp_config.mcp.servers { + let mut existing = state.db.get_all_mcp_servers()?; for server in servers.values() { - state.db.save_mcp_server(server)?; - // 同步到 Claude live 配置 - Self::sync_server_to_apps(state, server)?; + // 已存在:仅启用 Claude,不覆盖其他字段(与导入模块语义保持一致) + let to_save = if let Some(existing_server) = existing.get(&server.id) { + let mut merged = existing_server.clone(); + merged.apps.claude = true; + merged + } else { + server.clone() + }; + + state.db.save_mcp_server(&to_save)?; + existing.insert(to_save.id.clone(), to_save.clone()); + + // 同步到对应应用 live 配置 + Self::sync_server_to_apps(state, &to_save)?; } } } @@ -212,10 +243,22 @@ impl McpService { // 如果有导入的服务器,保存到数据库 if count > 0 { if let Some(servers) = &temp_config.mcp.servers { + let mut existing = state.db.get_all_mcp_servers()?; for server in servers.values() { - state.db.save_mcp_server(server)?; - // 同步到 Codex live 配置 - Self::sync_server_to_apps(state, server)?; + // 已存在:仅启用 Codex,不覆盖其他字段(与导入模块语义保持一致) + let to_save = if let Some(existing_server) = existing.get(&server.id) { + let mut merged = existing_server.clone(); + merged.apps.codex = true; + merged + } else { + server.clone() + }; + + state.db.save_mcp_server(&to_save)?; + existing.insert(to_save.id.clone(), to_save.clone()); + + // 同步到对应应用 live 配置 + Self::sync_server_to_apps(state, &to_save)?; } } } @@ -234,10 +277,22 @@ impl McpService { // 如果有导入的服务器,保存到数据库 if count > 0 { if let Some(servers) = &temp_config.mcp.servers { + let mut existing = state.db.get_all_mcp_servers()?; for server in servers.values() { - state.db.save_mcp_server(server)?; - // 同步到 Gemini live 配置 - Self::sync_server_to_apps(state, server)?; + // 已存在:仅启用 Gemini,不覆盖其他字段(与导入模块语义保持一致) + let to_save = if let Some(existing_server) = existing.get(&server.id) { + let mut merged = existing_server.clone(); + merged.apps.gemini = true; + merged + } else { + server.clone() + }; + + state.db.save_mcp_server(&to_save)?; + existing.insert(to_save.id.clone(), to_save.clone()); + + // 同步到对应应用 live 配置 + Self::sync_server_to_apps(state, &to_save)?; } } } diff --git a/src-tauri/tests/mcp_commands.rs b/src-tauri/tests/mcp_commands.rs index 2474d03ed..50508a1a8 100644 --- a/src-tauri/tests/mcp_commands.rs +++ b/src-tauri/tests/mcp_commands.rs @@ -246,3 +246,157 @@ fn set_mcp_enabled_for_codex_writes_live_config() { "codex config should include the enabled server definition" ); } + +#[test] +fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + ensure_test_home(); + + // 先创建一个启用 Claude 的 MCP 服务器 + let state = support::create_test_state().expect("create test state"); + McpService::upsert_server( + &state, + McpServer { + id: "echo".to_string(), + name: "echo".to_string(), + server: json!({ + "type": "stdio", + "command": "echo" + }), + apps: McpApps { + claude: true, + codex: false, + gemini: false, + }, + description: None, + homepage: None, + docs: None, + tags: Vec::new(), + }, + ) + .expect("upsert should sync to Claude live config"); + + // 确认已写入 ~/.claude.json + let mcp_path = get_claude_mcp_path(); + let text = fs::read_to_string(&mcp_path).expect("read ~/.claude.json"); + let v: serde_json::Value = serde_json::from_str(&text).expect("parse ~/.claude.json"); + assert!( + v.pointer("/mcpServers/echo").is_some(), + "echo should exist in Claude live config after enabling" + ); + + // 再次 upsert:取消勾选 Claude(apps.claude=false),应从 Claude live 配置中移除 + McpService::upsert_server( + &state, + McpServer { + id: "echo".to_string(), + name: "echo".to_string(), + server: json!({ + "type": "stdio", + "command": "echo" + }), + apps: McpApps { + claude: false, + codex: false, + gemini: false, + }, + description: None, + homepage: None, + docs: None, + tags: Vec::new(), + }, + ) + .expect("upsert disabling app should remove from Claude live config"); + + let text = fs::read_to_string(&mcp_path).expect("read ~/.claude.json after disable"); + let v: serde_json::Value = serde_json::from_str(&text).expect("parse ~/.claude.json"); + assert!( + v.pointer("/mcpServers/echo").is_none(), + "echo should be removed from Claude live config after disabling" + ); +} + +#[test] +fn import_mcp_from_multiple_apps_merges_enabled_flags() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + // 1) Claude: ~/.claude.json + let mcp_path = get_claude_mcp_path(); + let claude_json = json!({ + "mcpServers": { + "shared": { + "type": "stdio", + "command": "echo" + } + } + }); + fs::write( + &mcp_path, + serde_json::to_string_pretty(&claude_json).expect("serialize claude mcp"), + ) + .expect("seed ~/.claude.json"); + + // 2) Codex: ~/.codex/config.toml + let codex_dir = home.join(".codex"); + fs::create_dir_all(&codex_dir).expect("create codex dir"); + fs::write( + codex_dir.join("config.toml"), + r#"[mcp_servers.shared] +type = "stdio" +command = "echo" +"#, + ) + .expect("seed ~/.codex/config.toml"); + + let state = support::create_test_state().expect("create test state"); + + McpService::import_from_claude(&state).expect("import from claude"); + McpService::import_from_codex(&state).expect("import from codex"); + + let servers = state.db.get_all_mcp_servers().expect("get all mcp servers"); + let entry = servers.get("shared").expect("shared server exists"); + assert!(entry.apps.claude, "shared should enable Claude"); + assert!(entry.apps.codex, "shared should enable Codex"); +} + +#[test] +fn import_mcp_from_gemini_sse_url_only_is_valid() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + // Gemini MCP 位于 ~/.gemini/settings.json + let gemini_dir = home.join(".gemini"); + fs::create_dir_all(&gemini_dir).expect("create gemini dir"); + let settings_path = gemini_dir.join("settings.json"); + + // Gemini SSE:只包含 url(Gemini 不使用 type 字段) + let gemini_settings = json!({ + "mcpServers": { + "sse-server": { + "url": "https://example.com/sse" + } + } + }); + fs::write( + &settings_path, + serde_json::to_string_pretty(&gemini_settings).expect("serialize gemini settings"), + ) + .expect("seed ~/.gemini/settings.json"); + + let state = support::create_test_state().expect("create test state"); + let changed = McpService::import_from_gemini(&state).expect("import from gemini"); + assert!(changed > 0, "should import at least 1 server"); + + let servers = state.db.get_all_mcp_servers().expect("get all mcp servers"); + let entry = servers.get("sse-server").expect("sse-server exists"); + assert!(entry.apps.gemini, "imported server should enable Gemini"); + assert_eq!( + entry.server.get("type").and_then(|v| v.as_str()), + Some("sse"), + "Gemini url-only server should be normalized to type=sse in unified structure" + ); +}