diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 5d2558e91..f2af78585 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -197,11 +197,5 @@ pub async fn toggle_mcp_app( /// 从所有应用导入 MCP 服务器(复用已有的导入逻辑) #[tauri::command] pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result { - let mut total = 0; - total += McpService::import_from_claude(&state).unwrap_or(0); - total += McpService::import_from_codex(&state).unwrap_or(0); - total += McpService::import_from_gemini(&state).unwrap_or(0); - total += McpService::import_from_opencode(&state).unwrap_or(0); - total += McpService::import_from_hermes(&state).unwrap_or(0); - Ok(total) + McpService::import_from_all_apps(&state).map_err(|e| e.to_string()) } diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index e9af2a83c..db330e5f8 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -468,4 +468,41 @@ impl McpService { Ok(new_count) } + + /// 从所有支持 MCP 的应用导入服务器,返回新导入的数量。 + /// + /// Best-effort:单个应用导入失败(如坏 config.toml)不阻断其余应用; + /// 全部跑完后若有失败,聚合成一个错误上报——历史实现逐应用 + /// `unwrap_or(0)` 吞错,坏文件只会表现为"导入成功 0 个",用户 + /// 无从得知哪个应用出了问题。 + pub fn import_from_all_apps(state: &AppState) -> Result { + let mut total = 0; + let mut failures: Vec = Vec::new(); + + let results: [(&str, Result); 5] = [ + ("claude", Self::import_from_claude(state)), + ("codex", Self::import_from_codex(state)), + ("gemini", Self::import_from_gemini(state)), + ("opencode", Self::import_from_opencode(state)), + ("hermes", Self::import_from_hermes(state)), + ]; + for (app, result) in results { + match result { + Ok(count) => total += count, + Err(err) => { + log::warn!("从 {app} 导入 MCP 失败: {err}"); + failures.push(format!("{app}: {err}")); + } + } + } + + if failures.is_empty() { + Ok(total) + } else { + Err(AppError::Message(format!( + "已导入 {total} 个,部分应用导入失败: {}", + failures.join("; ") + ))) + } + } } diff --git a/src-tauri/tests/mcp_commands.rs b/src-tauri/tests/mcp_commands.rs index 3bcf29e6a..15a30221b 100644 --- a/src-tauri/tests/mcp_commands.rs +++ b/src-tauri/tests/mcp_commands.rs @@ -287,6 +287,56 @@ fn import_mcp_from_claude_invalid_json_preserves_state() { ); } +/// "从应用导入"是 best-effort:单个应用的坏配置文件不阻断其余应用的 +/// 导入,但失败必须聚合上报——历史实现逐应用 `unwrap_or(0)` 吞错, +/// 坏 config.toml 只会表现为"导入成功 0 个",用户无从得知出了什么问题。 +#[test] +fn import_from_all_apps_reports_broken_app_but_imports_the_rest() { + use support::create_test_state; + + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + // 好的 ~/.claude.json:应正常导入 + let claude_json = json!({ + "mcpServers": { + "alpha": { "type": "stdio", "command": "echo" } + } + }); + fs::write( + get_claude_mcp_path(), + serde_json::to_string_pretty(&claude_json).expect("serialize claude mcp"), + ) + .expect("seed ~/.claude.json"); + + // 坏的 ~/.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"), "not = = valid toml") + .expect("seed broken codex config"); + + let state = create_test_state().expect("create test state"); + + let err = McpService::import_from_all_apps(&state) + .expect_err("broken codex config must surface, not be swallowed as zero imports"); + let message = err.to_string(); + assert!( + message.contains("codex"), + "aggregated error should name the failing app, got: {message}" + ); + + // Codex 的失败不阻断 Claude:alpha 应已入库并启用 Claude + let servers = state.db.get_all_mcp_servers().expect("get all mcp servers"); + let entry = servers + .get("alpha") + .expect("claude server imported despite codex failure"); + assert!( + entry.apps.claude, + "imported server should have Claude app enabled" + ); +} + #[test] fn set_mcp_enabled_for_codex_writes_live_config() { let _guard = test_mutex().lock().expect("acquire test mutex"); diff --git a/src/hooks/useMcp.ts b/src/hooks/useMcp.ts index ff9f505fd..d41e7a61d 100644 --- a/src/hooks/useMcp.ts +++ b/src/hooks/useMcp.ts @@ -67,7 +67,9 @@ export function useImportMcpFromApps() { const queryClient = useQueryClient(); return useMutation({ mutationFn: () => mcpApi.importFromApps(), - onSuccess: () => { + // 后端是 best-effort 导入:部分应用失败会返回错误,但其余应用的 + // 服务器已经入库,失败时也要刷新列表。 + onSettled: () => { queryClient.invalidateQueries({ queryKey: ["mcp", "all"] }); }, });