mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-04-02 18:12:05 +08:00
fix(mcp): improve upsert and import robustness
- Remove server from live config when app is disabled during upsert - Merge enabled flags instead of overwriting when importing from multiple apps - Normalize Gemini MCP type field (url-only → sse, command → stdio) - Use atomic write for Codex config updates - Add tests for disable-removal, multi-app merge, and Gemini SSE import
This commit is contained in:
@@ -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<std::collections::HashMap<String, Value>, AppError> {
|
||||
let path = user_config_path();
|
||||
if !path.exists() {
|
||||
@@ -65,8 +65,15 @@ pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>
|
||||
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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user