fix(mcp): surface per-app failures when importing MCP servers from apps

import_mcp_from_apps swallowed every importer error with unwrap_or(0),
so a corrupt config.toml surfaced as "imported 0 servers" with no hint
that anything went wrong.

Move the aggregation into McpService::import_from_all_apps: each app
imports best-effort (one bad file doesn't block the rest), and failures
are collected into a single error naming the failing apps alongside the
count that did import. The frontend now refreshes the server list on
settle rather than success, since a partial failure still means new
servers were persisted.
This commit is contained in:
Jason
2026-07-08 22:21:37 +08:00
parent 11c173c730
commit 94fc1cc064
4 changed files with 91 additions and 8 deletions
+1 -7
View File
@@ -197,11 +197,5 @@ pub async fn toggle_mcp_app(
/// 从所有应用导入 MCP 服务器(复用已有的导入逻辑)
#[tauri::command]
pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result<usize, String> {
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())
}
+37
View File
@@ -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<usize, AppError> {
let mut total = 0;
let mut failures: Vec<String> = Vec::new();
let results: [(&str, Result<usize, AppError>); 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("; ")
)))
}
}
}
+50
View File
@@ -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 的失败不阻断 Claudealpha 应已入库并启用 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");
+3 -1
View File
@@ -67,7 +67,9 @@ export function useImportMcpFromApps() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => mcpApi.importFromApps(),
onSuccess: () => {
// 后端是 best-effort 导入:部分应用失败会返回错误,但其余应用的
// 服务器已经入库,失败时也要刷新列表。
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["mcp", "all"] });
},
});