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.
This commit is contained in:
Jason
2025-10-12 16:21:32 +08:00
parent 036d41b774
commit e92d99b758
7 changed files with 222 additions and 69 deletions

View File

@@ -27,8 +27,8 @@ fn read_json_value(path: &Path) -> Result<Value, String> {
}
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<Option<String>, 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<bool, 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 对象存在
{
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<bool, String> {
/// 将给定的启用 MCP 服务器映射写入到用户级 ~/.claude.json 的 mcpServers 字段
/// 仅覆盖 mcpServers其他字段保持不变
pub fn set_mcp_servers_map(servers: &std::collections::HashMap<String, Value>) -> Result<(), String> {
pub fn set_mcp_servers_map(
servers: &std::collections::HashMap<String, Value>,
) -> 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<String, Value> = Map::new();

View File

@@ -63,9 +63,15 @@ pub fn write_claude_config() -> Result<bool, String> {
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;
}
}

View File

@@ -66,14 +66,18 @@ 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))?)
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))?)
let _old_config =
if config_path.exists() {
Some(fs::read(&config_path).map_err(|e| {
format!("读取旧 config.toml 失败: {}: {}", config_path.display(), e)
})?)
} else {
None
};

View File

@@ -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<Option<String>, String> {
claude_mcp::read_mcp_json()
}
/// 新增或更新一个 MCP 服务器条目
#[tauri::command]
pub async fn upsert_claude_mcp_server(id: String, spec: serde_json::Value) -> Result<bool, String> {
@@ -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<String>) -> Result<McpConfigResponse, String> {
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<String>,
) -> Result<McpConfigResponse, String> {
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<bool, String> {
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<bool, String> {
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)
}

View File

@@ -118,11 +118,10 @@ pub fn read_json_file<T: for<'a> Deserialize<'a>>(path: &Path) -> Result<T, Stri
return Err(format!("文件不存在: {}", path.display()));
}
let content = fs::read_to_string(path)
.map_err(|e| format!("读取文件失败: {}: {}", path.display(), e))?;
let content =
fs::read_to_string(path).map_err(|e| format!("读取文件失败: {}: {}", path.display(), e))?;
serde_json::from_str(&content)
.map_err(|e| format!("解析 JSON 失败: {}: {}", path.display(), e))
serde_json::from_str(&content).map_err(|e| format!("解析 JSON 失败: {}: {}", path.display(), e))
}
/// 写入 JSON 配置文件
@@ -192,14 +191,26 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> 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(())
}

View File

@@ -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::{

View File

@@ -68,6 +68,97 @@ fn validate_mcp_entry(entry: &Value) -> Result<(), String> {
Ok(())
}
fn normalize_server_keys(map: &mut HashMap<String, Value>) -> 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<Value, String> {
let obj = entry
.as_object()
@@ -106,7 +197,11 @@ fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
out
}
pub fn get_servers_snapshot_for(config: &MultiAppConfig, app: &AppType) -> HashMap<String, Value> {
pub fn get_servers_snapshot_for(
config: &mut MultiAppConfig,
app: &AppType,
) -> (HashMap<String, Value>, 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<bool, String> {
pub fn delete_in_config_for(
config: &mut MultiAppConfig,
app: &AppType,
id: &str,
) -> Result<bool, String> {
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<usize, String> {
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<usize, String> {
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 {