mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
fix(mcp): stop cross-app failures from blocking MCP re-projection
sync_all_enabled iterated AppType::all() with `?`, so one app's corrupt live file (e.g. a broken ~/.claude.json, which passes the existence gate but fails to parse) blocked every app behind it in the iteration order -- and bubbled the error into whatever operation triggered the sync: - switch/save had just rewritten only the target app's live file, yet a broken unrelated file failed the whole operation after DB and live were already updated, reporting a false "switch failed" to the user. Both now project only the target app (sync_enabled_for_app) and degrade projection failure to a warning: the primary operation has already taken effect, and the projection self-heals on the next switch or MCP toggle. - sync_current_provider_for_app_to_live syncs a single app; it now projects that app only, keeping failures (which can only concern the target app) as errors. - sync_current_to_live (config import / cloud-sync restore) keeps the all-apps sweep, but sync_all_enabled is now best-effort: it projects every app, collects failures, and reports them aggregated. Its error is held until after skill sync so an MCP failure no longer skips it.
This commit is contained in:
@@ -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<String> = 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user