diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index 67bb2b66e..e9af2a83c 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -176,21 +176,36 @@ impl McpService { Ok(()) } - /// 手动同步所有启用的 MCP 服务器到对应的应用 + /// 手动同步所有启用的 MCP 服务器到对应的应用。 + /// + /// Best-effort:单个应用投影失败(如 ~/.claude.json 坏 JSON)不阻断 + /// 其余应用——各应用的 live 文件互相独立,一处损坏没有理由让其他 + /// 应用的 MCP 状态陈旧。全部跑完后若有失败,聚合成一个错误上报, + /// 保留调用方的可见性。 pub fn sync_all_enabled(state: &AppState) -> Result<(), AppError> { let servers = Self::get_all_servers(state)?; + let mut failures: Vec = Vec::new(); for app in AppType::all() { - Self::project_servers_to_app(state, &servers, &app)?; + if let Err(err) = Self::project_servers_to_app(state, &servers, &app) { + log::warn!("同步 MCP 到 {app:?} 失败: {err}"); + failures.push(format!("{}: {err}", app.as_str())); + } } - Ok(()) + if failures.is_empty() { + Ok(()) + } else { + Err(AppError::Message(format!( + "部分应用 MCP 同步失败: {}", + failures.join("; ") + ))) + } } /// 只把启用状态投影到单个应用。某个应用的 live 被整体重写后用它做 - /// 定向重投影:sync_all_enabled 按 AppType::all() 顺序逐应用短路, - /// 排在前面的无关应用 live 损坏(如 ~/.claude.json 坏 JSON)会阻断 - /// 后面应用的投影。 + /// 定向重投影,避免把无关应用的失败面(如 ~/.claude.json 坏 JSON) + /// 牵连进目标应用的关键路径。 pub fn sync_enabled_for_app(state: &AppState, app: &AppType) -> Result<(), AppError> { let servers = Self::get_all_servers(state)?; Self::project_servers_to_app(state, &servers, app) diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 4fe2c8cf4..b16024c5e 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -948,7 +948,10 @@ pub(crate) fn sync_current_provider_for_app_to_live( } } - McpService::sync_all_enabled(state)?; + // 本函数语义是"把这个应用同步到 live",MCP 重投影也只针对该应用; + // 全量 sync_all_enabled 会把无关应用的 live 损坏牵连进来。投影失败 + // 上抛(不降级):这里没有已变更的 DB 状态需要保护,调用方重试即可。 + McpService::sync_enabled_for_app(state, app_type)?; Ok(()) } @@ -1016,8 +1019,10 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> { } } - // MCP sync - McpService::sync_all_enabled(state)?; + // MCP sync(best-effort 逐应用投影,内部已聚合失败)。错误暂存到 + // Skill 同步之后再返回:MCP 的失败不该跳过 Skill 同步,但调用方 + //(配置导入 / 云同步恢复)需要知道结果不完整。 + let mcp_result = McpService::sync_all_enabled(state); // Skill sync for app_type in AppType::all() { @@ -1027,7 +1032,7 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> { } } - Ok(()) + mcp_result } /// Read current live settings for an app type diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 34ee51ead..8a0370a22 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -2134,8 +2134,16 @@ impl ProviderService { } } else { write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?; - // Sync MCP - McpService::sync_all_enabled(state)?; + // 重写 live 后只重投影本应用的 MCP:全量 sync_all_enabled 会把 + // 无关应用的 live 损坏(如 ~/.claude.json 坏 JSON)牵连进保存 + // 流程。走到这里 DB 与 live 都已按新配置落盘,保存事实上已 + // 成功;投影失败降级为警告,避免制造"保存失败"假象(MCP + // 投影可自愈:下次切换 / 任一 MCP 启停都会重新投影)。 + if let Err(err) = McpService::sync_enabled_for_app(state, &app_type) { + log::warn!( + "保存供应商后重投影 {app_type:?} MCP 失败(将在下次同步时自愈): {err}" + ); + } } } @@ -2509,8 +2517,16 @@ impl ProviderService { } } - // Sync MCP - McpService::sync_all_enabled(state)?; + // 切换重写了目标应用的 live,只重投影该应用的 MCP(Codex 的 + // [mcp_servers] 与 live 同文件,整体替换后必须补回;其余应用的 + // MCP 文件独立于 live,投影是幂等维护)。不用全量 sync_all_enabled: + // 无关应用的 live 损坏(如 ~/.claude.json 坏 JSON)不该阻断切换。 + // 走到这里 DB is_current 与 live 都已落盘,切换事实上已成功; + // 投影失败上抛会让前端报"切换失败"制造分裂假象,故降级为警告 + // (MCP 投影可自愈:下次切换 / 任一 MCP 启停都会重新投影)。 + if let Err(err) = McpService::sync_enabled_for_app(state, &app_type) { + log::warn!("切换供应商后重投影 {app_type:?} MCP 失败(将在下次同步时自愈): {err}"); + } Ok(result) } diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs index 111f49f06..dde2fe469 100644 --- a/src-tauri/tests/provider_service.rs +++ b/src-tauri/tests/provider_service.rs @@ -1044,7 +1044,8 @@ fn reapply_codex_official_live_projects_mcp_despite_broken_claude_json() { let state = create_test_state_with_config(&initial_config).expect("create test state"); - // 切换路径仍走 sync_all_enabled(会碰 claude),所以先切换、后破坏。 + // 先切换建立"当前=官方"的前置状态,再破坏 claude 文件, + // 让损坏只作用于被测的 reapply 路径。 ProviderService::switch(&state, AppType::Codex, "official-provider") .expect("switch to official provider"); @@ -1078,6 +1079,146 @@ fn reapply_codex_official_live_projects_mcp_despite_broken_claude_json() { ); } +/// 切换供应商与 reapply 是同一类场景:live 整体重写后只需重投影本应用 +/// 的 MCP。历史实现走全量 sync_all_enabled + `?`,损坏的 ~/.claude.json +/// 会让"切 Codex"直接报切换失败——而此时 DB is_current 与 live 都已 +/// 落盘,切换事实上成功,报错只制造分裂假象。 +#[test] +fn switch_codex_projects_mcp_despite_broken_claude_json() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + write_codex_live_atomic(&json!({ "OPENAI_API_KEY": "sk-old" }), Some("")) + .expect("seed codex live"); + + let mut config = MultiAppConfig::default(); + { + let manager = config + .get_manager_mut(&AppType::Codex) + .expect("codex manager"); + manager.providers.insert( + "p".to_string(), + Provider::with_id( + "p".to_string(), + "P".to_string(), + json!({ + "auth": { "OPENAI_API_KEY": "sk-p" }, + "config": "model = \"gpt-5.5\"\n" + }), + None, + ), + ); + } + let servers = config.mcp.servers.get_or_insert_with(Default::default); + servers.insert( + "echo-server".into(), + McpServer { + id: "echo-server".into(), + name: "Echo Server".into(), + server: json!({ + "type": "stdio", + "command": "echo" + }), + apps: McpApps { + claude: false, + codex: true, + gemini: false, + opencode: false, + hermes: false, + }, + description: None, + homepage: None, + docs: None, + tags: Vec::new(), + }, + ); + + let state = create_test_state_with_config(&config).expect("create test state"); + + // 坏 JSON 能通过 should_sync_claude_mcp 门控(文件存在即过), + // 但 read_mcp_servers_map 解析必然报错;codex-only 服务器也会 + // 触发 claude 的 remove 分支去读这个文件。 + let claude_json = cc_switch_lib::get_claude_mcp_path(); + std::fs::write(&claude_json, "{ not valid json").expect("seed broken claude json"); + + ProviderService::switch(&state, AppType::Codex, "p") + .expect("broken ~/.claude.json must not fail an unrelated codex switch"); + + let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path()) + .expect("read config.toml after switch"); + assert!( + live.contains("mcp_servers.echo-server"), + "switch must re-project codex MCP after the full live rewrite, got: {live}" + ); + + let claude_after = std::fs::read_to_string(&claude_json).expect("read claude json"); + assert_eq!( + claude_after, "{ not valid json", + "codex-only projection must not touch claude's live file" + ); +} + +/// sync_all_enabled 的全量语义(配置导入 / 云同步恢复):单个应用的 +/// live 损坏不阻断其余应用的投影,但失败必须聚合上报——调用方需要 +/// 知道结果不完整。历史实现按 AppType::all() 顺序 `?` 短路,Claude +/// 排在 Codex 前面,一份坏 ~/.claude.json 会让所有后续应用的 MCP +/// 状态永远陈旧。 +#[test] +fn sync_all_enabled_reports_broken_app_but_projects_the_rest() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let _home = ensure_test_home(); + + write_codex_live_atomic(&json!({ "OPENAI_API_KEY": "sk" }), Some("")).expect("seed codex live"); + + let mut config = MultiAppConfig::default(); + let servers = config.mcp.servers.get_or_insert_with(Default::default); + servers.insert( + "echo-server".into(), + McpServer { + id: "echo-server".into(), + name: "Echo Server".into(), + server: json!({ + "type": "stdio", + "command": "echo" + }), + apps: McpApps { + claude: false, + codex: true, + gemini: false, + opencode: false, + hermes: false, + }, + description: None, + homepage: None, + docs: None, + tags: Vec::new(), + }, + ); + + let state = create_test_state_with_config(&config).expect("create test state"); + + let claude_json = cc_switch_lib::get_claude_mcp_path(); + std::fs::write(&claude_json, "{ not valid json").expect("seed broken claude json"); + + let err = cc_switch_lib::McpService::sync_all_enabled(&state) + .expect_err("broken claude live must surface as an aggregated error"); + let message = err.to_string(); + assert!( + message.contains("claude"), + "aggregated error should name the failing app, got: {message}" + ); + + // Claude 的失败不能阻断 Codex:best-effort 必须继续投影其余应用。 + let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path()) + .expect("read config.toml after sync_all_enabled"); + assert!( + live.contains("mcp_servers.echo-server"), + "codex projection must proceed despite the broken claude file, got: {live}" + ); +} + #[test] fn provider_service_switch_codex_official_accounts_write_auth_json() { let _guard = test_mutex().lock().expect("acquire test mutex");