From e92d99b758e4898770b4208a169c699a6dd1462e Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 12 Oct 2025 16:21:32 +0800 Subject: [PATCH] feat(mcp): add automatic key normalization for server entries - Add normalize_server_keys() to ensure MCP server map keys match internal id fields - Auto-normalize on all read/write operations (get, upsert, delete, import, sync) - Handle edge cases: empty/whitespace ids, key renaming, conflict resolution - Auto-save config when normalization detects changes - Apply cargo fmt for code formatting consistency This enhancement improves data integrity by automatically fixing inconsistencies between server entry keys and their id fields, especially after manual config edits. --- src-tauri/src/claude_mcp.rs | 35 +++++--- src-tauri/src/claude_plugin.rs | 10 ++- src-tauri/src/codex_config.rs | 20 +++-- src-tauri/src/commands.rs | 50 ++++++++---- src-tauri/src/config.rs | 27 +++++-- src-tauri/src/lib.rs | 6 +- src-tauri/src/mcp.rs | 143 ++++++++++++++++++++++++++++----- 7 files changed, 222 insertions(+), 69 deletions(-) diff --git a/src-tauri/src/claude_mcp.rs b/src-tauri/src/claude_mcp.rs index 8f46820..d53c604 100644 --- a/src-tauri/src/claude_mcp.rs +++ b/src-tauri/src/claude_mcp.rs @@ -27,8 +27,8 @@ fn read_json_value(path: &Path) -> Result { } let content = fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}: {}", path.display(), e))?; - let value: Value = - serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {}: {}", path.display(), e))?; + let value: Value = serde_json::from_str(&content) + .map_err(|e| format!("解析 JSON 失败: {}: {}", path.display(), e))?; Ok(value) } @@ -37,7 +37,8 @@ fn write_json_value(path: &Path, value: &Value) -> Result<(), String> { fs::create_dir_all(parent) .map_err(|e| format!("创建目录失败: {}: {}", parent.display(), e))?; } - let json = serde_json::to_string_pretty(value).map_err(|e| format!("序列化 JSON 失败: {}", e))?; + let json = + serde_json::to_string_pretty(value).map_err(|e| format!("序列化 JSON 失败: {}", e))?; atomic_write(path, json.as_bytes()) } @@ -63,8 +64,7 @@ pub fn read_mcp_json() -> Result, String> { if !path.exists() { return Ok(None); } - let content = - fs::read_to_string(&path).map_err(|e| format!("读取 MCP 配置失败: {}", e))?; + let content = fs::read_to_string(&path).map_err(|e| format!("读取 MCP 配置失败: {}", e))?; Ok(Some(content)) } @@ -100,21 +100,24 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result { } let path = user_config_path(); - let mut root = if path.exists() { read_json_value(&path)? } else { serde_json::json!({}) }; + let mut root = if path.exists() { + read_json_value(&path)? + } else { + serde_json::json!({}) + }; // 确保 mcpServers 对象存在 { - let obj = root.as_object_mut().ok_or_else(|| "mcp.json 根必须是对象".to_string())?; + let obj = root + .as_object_mut() + .ok_or_else(|| "mcp.json 根必须是对象".to_string())?; if !obj.contains_key("mcpServers") { obj.insert("mcpServers".into(), serde_json::json!({})); } } let before = root.clone(); - if let Some(servers) = root - .get_mut("mcpServers") - .and_then(|v| v.as_object_mut()) - { + if let Some(servers) = root.get_mut("mcpServers").and_then(|v| v.as_object_mut()) { servers.insert(id.to_string(), spec); } @@ -185,9 +188,15 @@ pub fn validate_command_in_path(cmd: &str) -> Result { /// 将给定的启用 MCP 服务器映射写入到用户级 ~/.claude.json 的 mcpServers 字段 /// 仅覆盖 mcpServers,其他字段保持不变 -pub fn set_mcp_servers_map(servers: &std::collections::HashMap) -> Result<(), String> { +pub fn set_mcp_servers_map( + servers: &std::collections::HashMap, +) -> Result<(), String> { let path = user_config_path(); - let mut root = if path.exists() { read_json_value(&path)? } else { serde_json::json!({}) }; + let mut root = if path.exists() { + read_json_value(&path)? + } else { + serde_json::json!({}) + }; // 构建 mcpServers 对象:移除 UI 辅助字段(enabled/source),仅保留实际 MCP 规范 let mut out: Map = Map::new(); diff --git a/src-tauri/src/claude_plugin.rs b/src-tauri/src/claude_plugin.rs index e6797ea..a4773b4 100644 --- a/src-tauri/src/claude_plugin.rs +++ b/src-tauri/src/claude_plugin.rs @@ -63,9 +63,15 @@ pub fn write_claude_config() -> Result { let mut changed = false; if let Some(map) = obj.as_object_mut() { - let cur = map.get("primaryApiKey").and_then(|v| v.as_str()).unwrap_or(""); + let cur = map + .get("primaryApiKey") + .and_then(|v| v.as_str()) + .unwrap_or(""); if cur != "any" { - map.insert("primaryApiKey".to_string(), serde_json::Value::String("any".to_string())); + map.insert( + "primaryApiKey".to_string(), + serde_json::Value::String("any".to_string()), + ); changed = true; } } diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 220cb46..42afb93 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -66,17 +66,21 @@ pub fn write_codex_live_atomic(auth: &Value, config_text_opt: Option<&str>) -> R // 读取旧内容用于回滚 let old_auth = if auth_path.exists() { - Some(fs::read(&auth_path) - .map_err(|e| format!("读取旧 auth.json 失败: {}: {}", auth_path.display(), e))?) - } else { - None - }; - let _old_config = if config_path.exists() { - Some(fs::read(&config_path) - .map_err(|e| format!("读取旧 config.toml 失败: {}: {}", config_path.display(), e))?) + Some( + fs::read(&auth_path) + .map_err(|e| format!("读取旧 auth.json 失败: {}: {}", auth_path.display(), e))?, + ) } else { None }; + let _old_config = + if config_path.exists() { + Some(fs::read(&config_path).map_err(|e| { + format!("读取旧 config.toml 失败: {}: {}", config_path.display(), e) + })?) + } else { + None + }; // 准备写入内容 let cfg_text = match config_text_opt { diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 732615e..66ca39b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -6,13 +6,13 @@ use tauri_plugin_dialog::DialogExt; use tauri_plugin_opener::OpenerExt; use crate::app_config::AppType; -use crate::claude_plugin; use crate::claude_mcp; -use crate::store::AppState; +use crate::claude_plugin; use crate::codex_config; use crate::config::{self, get_claude_settings_path, ConfigStatus}; use crate::provider::{Provider, ProviderMeta}; use crate::speedtest; +use crate::store::AppState; fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), String> { match app_type { @@ -381,11 +381,7 @@ pub async fn switch_provider( let auth: Value = crate::config::read_json_file(&auth_path)?; let config_str = if config_path.exists() { std::fs::read_to_string(&config_path).map_err(|e| { - format!( - "读取 config.toml 失败: {}: {}", - config_path.display(), - e - ) + format!("读取 config.toml 失败: {}: {}", config_path.display(), e) })? } else { String::new() @@ -784,7 +780,6 @@ pub async fn read_claude_mcp_config() -> Result, String> { claude_mcp::read_mcp_json() } - /// 新增或更新一个 MCP 服务器条目 #[tauri::command] pub async fn upsert_claude_mcp_server(id: String, spec: serde_json::Value) -> Result { @@ -815,15 +810,28 @@ pub struct McpConfigResponse { /// 获取 MCP 配置(来自 ~/.cc-switch/config.json) #[tauri::command] -pub async fn get_mcp_config(state: State<'_, AppState>, app: Option) -> Result { - let config_path = crate::config::get_app_config_path().to_string_lossy().to_string(); - let cfg = state +pub async fn get_mcp_config( + state: State<'_, AppState>, + app: Option, +) -> Result { + let config_path = crate::config::get_app_config_path() + .to_string_lossy() + .to_string(); + let mut cfg = state .config .lock() .map_err(|e| format!("获取锁失败: {}", e))?; let app_ty = crate::app_config::AppType::from(app.as_deref().unwrap_or("claude")); - let servers = crate::mcp::get_servers_snapshot_for(&cfg, &app_ty); - Ok(McpConfigResponse { config_path, servers }) + let (servers, normalized) = crate::mcp::get_servers_snapshot_for(&mut cfg, &app_ty); + let need_save = normalized > 0; + drop(cfg); + if need_save { + state.save()?; + } + Ok(McpConfigResponse { + config_path, + servers, + }) } /// 在 config.json 中新增或更新一个 MCP 服务器定义 @@ -894,22 +902,34 @@ pub async fn set_mcp_enabled( /// 手动同步:将启用的 MCP 投影到 ~/.claude.json(不更改 config.json) #[tauri::command] pub async fn sync_enabled_mcp_to_claude(state: State<'_, AppState>) -> Result { - let cfg = state + let mut cfg = state .config .lock() .map_err(|e| format!("获取锁失败: {}", e))?; + let normalized = crate::mcp::normalize_servers_for(&mut cfg, &AppType::Claude); crate::mcp::sync_enabled_to_claude(&cfg)?; + let need_save = normalized > 0; + drop(cfg); + if need_save { + state.save()?; + } Ok(true) } /// 手动同步:将启用的 MCP 投影到 ~/.codex/config.toml(不更改 config.json) #[tauri::command] pub async fn sync_enabled_mcp_to_codex(state: State<'_, AppState>) -> Result { - let cfg = state + let mut cfg = state .config .lock() .map_err(|e| format!("获取锁失败: {}", e))?; + let normalized = crate::mcp::normalize_servers_for(&mut cfg, &AppType::Codex); crate::mcp::sync_enabled_to_codex(&cfg)?; + let need_save = normalized > 0; + drop(cfg); + if need_save { + state.save()?; + } Ok(true) } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 1b9e896..3d7bc69 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -118,11 +118,10 @@ pub fn read_json_file Deserialize<'a>>(path: &Path) -> Result Result<(), String> { if path.exists() { let _ = fs::remove_file(path); } - fs::rename(&tmp, path) - .map_err(|e| format!("原子替换失败: {} -> {}: {}", tmp.display(), path.display(), e))?; + fs::rename(&tmp, path).map_err(|e| { + format!( + "原子替换失败: {} -> {}: {}", + tmp.display(), + path.display(), + e + ) + })?; } #[cfg(not(windows))] { - fs::rename(&tmp, path) - .map_err(|e| format!("原子替换失败: {} -> {}: {}", tmp.display(), path.display(), e))?; + fs::rename(&tmp, path).map_err(|e| { + format!( + "原子替换失败: {} -> {}: {}", + tmp.display(), + path.display(), + e + ) + })?; } Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e89ecb7..772fa7c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,16 +1,16 @@ mod app_config; -mod claude_plugin; mod claude_mcp; -mod mcp; +mod claude_plugin; mod codex_config; mod commands; mod config; mod import_export; +mod mcp; mod migration; mod provider; mod settings; -mod store; mod speedtest; +mod store; use store::AppState; use tauri::{ diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 55b449a..05bb056 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -68,6 +68,97 @@ fn validate_mcp_entry(entry: &Value) -> Result<(), String> { Ok(()) } +fn normalize_server_keys(map: &mut HashMap) -> usize { + let mut change_count = 0usize; + let mut renames: Vec<(String, String)> = Vec::new(); + + for (key_ref, value) in map.iter_mut() { + let key = key_ref.clone(); + let Some(obj) = value.as_object_mut() else { + continue; + }; + + let id_value = obj.get("id").cloned(); + + let target_id: String; + + match id_value { + Some(id_val) => match id_val.as_str() { + Some(id_str) => { + let trimmed = id_str.trim(); + if trimmed.is_empty() { + obj.insert("id".into(), json!(key.clone())); + change_count += 1; + target_id = key.clone(); + } else { + if trimmed != id_str { + obj.insert("id".into(), json!(trimmed)); + change_count += 1; + } + target_id = trimmed.to_string(); + } + } + None => { + obj.insert("id".into(), json!(key.clone())); + change_count += 1; + target_id = key.clone(); + } + }, + None => { + obj.insert("id".into(), json!(key.clone())); + change_count += 1; + target_id = key.clone(); + } + } + + if target_id != key { + renames.push((key, target_id)); + } + } + + for (old_key, new_key) in renames { + if old_key == new_key { + continue; + } + if map.contains_key(&new_key) { + log::warn!( + "MCP 条目 '{}' 的内部 id '{}' 与现有键冲突,回退为原键", + old_key, + new_key + ); + if let Some(value) = map.get_mut(&old_key) { + if let Some(obj) = value.as_object_mut() { + if obj + .get("id") + .and_then(|v| v.as_str()) + .map(|s| s != old_key) + .unwrap_or(true) + { + obj.insert("id".into(), json!(old_key.clone())); + change_count += 1; + } + } + } + continue; + } + if let Some(mut value) = map.remove(&old_key) { + if let Some(obj) = value.as_object_mut() { + obj.insert("id".into(), json!(new_key.clone())); + } + log::info!("MCP 条目键名已自动修复: '{}' -> '{}'", old_key, new_key); + map.insert(new_key, value); + change_count += 1; + } + } + + change_count +} + +pub fn normalize_servers_for(config: &mut MultiAppConfig, app: &AppType) -> usize { + let servers = &mut config.mcp_for_mut(app).servers; + normalize_server_keys(servers) +} + fn extract_server_spec(entry: &Value) -> Result { let obj = entry .as_object() @@ -106,7 +197,11 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap { out } -pub fn get_servers_snapshot_for(config: &MultiAppConfig, app: &AppType) -> HashMap { +pub fn get_servers_snapshot_for( + config: &mut MultiAppConfig, + app: &AppType, +) -> (HashMap, usize) { + let normalized = normalize_servers_for(config, app); let mut snapshot = config.mcp_for(app).servers.clone(); snapshot.retain(|id, value| { let Some(obj) = value.as_object_mut() else { @@ -124,7 +219,7 @@ pub fn get_servers_snapshot_for(config: &MultiAppConfig, app: &AppType) -> HashM } } }); - snapshot + (snapshot, normalized) } pub fn upsert_in_config_for( @@ -136,6 +231,7 @@ pub fn upsert_in_config_for( if id.trim().is_empty() { return Err("MCP 服务器 ID 不能为空".into()); } + normalize_servers_for(config, app); validate_mcp_entry(&spec)?; let mut entry_obj = spec @@ -165,10 +261,15 @@ pub fn upsert_in_config_for( Ok(before.is_none()) } -pub fn delete_in_config_for(config: &mut MultiAppConfig, app: &AppType, id: &str) -> Result { +pub fn delete_in_config_for( + config: &mut MultiAppConfig, + app: &AppType, + id: &str, +) -> Result { if id.trim().is_empty() { return Err("MCP 服务器 ID 不能为空".into()); } + normalize_servers_for(config, app); let existed = config.mcp_for_mut(app).servers.remove(id).is_some(); Ok(existed) } @@ -183,9 +284,13 @@ pub fn set_enabled_and_sync_for( if id.trim().is_empty() { return Err("MCP 服务器 ID 不能为空".into()); } + normalize_servers_for(config, app); if let Some(spec) = config.mcp_for_mut(app).servers.get_mut(id) { // 写入 enabled 字段 - let mut obj = spec.as_object().cloned().ok_or_else(|| "MCP 服务器定义必须为 JSON 对象".to_string())?; + let mut obj = spec + .as_object() + .cloned() + .ok_or_else(|| "MCP 服务器定义必须为 JSON 对象".to_string())?; obj.insert("enabled".into(), json!(enabled)); *spec = Value::Object(obj); } else { @@ -218,10 +323,13 @@ pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), String> { pub fn import_from_claude(config: &mut MultiAppConfig) -> Result { let text_opt = crate::claude_mcp::read_mcp_json()?; let Some(text) = text_opt else { return Ok(0) }; - let v: Value = serde_json::from_str(&text).map_err(|e| format!("解析 ~/.claude.json 失败: {}", e))?; - let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else { return Ok(0) }; + let mut changed = normalize_servers_for(config, &AppType::Claude); + let v: Value = + serde_json::from_str(&text).map_err(|e| format!("解析 ~/.claude.json 失败: {}", e))?; + let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else { + return Ok(changed); + }; - let mut changed = 0usize; for (id, spec) in map.iter() { // 校验目标 spec validate_server_spec(spec)?; @@ -291,17 +399,18 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result { if text.trim().is_empty() { return Ok(0); } + let mut changed_total = normalize_servers_for(config, &AppType::Codex); - let root: toml::Table = toml::from_str(&text) - .map_err(|e| format!("解析 ~/.codex/config.toml 失败: {}", e))?; - - let mut changed_total = 0usize; + let root: toml::Table = + toml::from_str(&text).map_err(|e| format!("解析 ~/.codex/config.toml 失败: {}", e))?; // helper:处理一组 servers 表 let mut import_servers_tbl = |servers_tbl: &toml::value::Table| { let mut changed = 0usize; for (id, entry_val) in servers_tbl.iter() { - let Some(entry_tbl) = entry_val.as_table() else { continue }; + let Some(entry_tbl) = entry_val.as_table() else { + continue; + }; // type 缺省为 stdio let typ = entry_tbl @@ -472,10 +581,7 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), String> { }; // 3) 写入 servers 表(支持 mcp.servers 与 mcp_servers;优先沿用已有风格,默认 mcp_servers) - let prefer_mcp_servers = root - .get("mcp_servers") - .is_some() - || root.get("mcp").is_none(); + let prefer_mcp_servers = root.get("mcp_servers").is_some() || root.get("mcp").is_none(); if enabled.is_empty() { // 无启用项:移除两种节点 // 清除 mcp.servers,但保留其他 mcp 字段 @@ -502,10 +608,7 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), String> { let mut s = TomlTable::new(); // 类型(缺省视为 stdio) - let typ = spec - .get("type") - .and_then(|v| v.as_str()) - .unwrap_or("stdio"); + let typ = spec.get("type").and_then(|v| v.as_str()).unwrap_or("stdio"); s.insert("type".into(), TomlValue::String(typ.to_string())); match typ {