fix(mcp): migrate import functions to unified v3.7.0 structure

- Rewrite import_from_claude/codex/gemini to write directly to mcp.servers
- Implement skip-on-error strategy for fault tolerance (single invalid item no longer aborts entire batch)
- Smart merge logic: existing servers only enable corresponding app, preserve other configs
- Remove deprecated markers from service layer
- Export McpApps type for test usage
- Update mcp_commands tests to use unified structure

Fixes runtime import issue where data was written to legacy structure (mcp.claude/codex.servers)
but unified panel reads from new structure (mcp.servers), causing "imported but invisible" bug.
This commit is contained in:
Jason
2025-11-14 23:33:54 +08:00
parent 09f80d82bc
commit ea8f2095e2
4 changed files with 201 additions and 212 deletions

View File

@@ -18,7 +18,7 @@ mod settings;
mod store;
mod usage_script;
pub use app_config::{AppType, McpServer, MultiAppConfig};
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
pub use commands::*;
pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};

View File

@@ -324,92 +324,101 @@ pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> {
crate::claude_mcp::set_mcp_servers_map(&enabled)
}
/// 从 ~/.claude.json 导入 mcpServers 到 config.json设为 enabled=true
/// 已存在的项仅强制 enabled=true,不覆盖其他字段
/// 从 ~/.claude.json 导入 mcpServers 到统一结构v3.7.0+
/// 已存在的服务器将启用 Claude 应用,不覆盖其他字段和应用状态
pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError> {
use crate::app_config::{McpApps, McpServer};
let text_opt = crate::claude_mcp::read_mcp_json()?;
let Some(text) = text_opt else { return Ok(0) };
let mut changed = normalize_servers_for(config, &AppType::Claude);
let v: Value = serde_json::from_str(&text)
.map_err(|e| AppError::McpValidation(format!("解析 ~/.claude.json 失败: {e}")))?;
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
return Ok(changed);
return Ok(0);
};
// 确保新结构存在
if config.mcp.servers.is_none() {
config.mcp.servers = Some(HashMap::new());
}
let servers = config.mcp.servers.as_mut().unwrap();
let mut changed = 0;
let mut errors = Vec::new();
for (id, spec) in map.iter() {
// 校验目标 spec
validate_server_spec(spec)?;
// 校验:单项失败不中止,收集错误继续处理
if let Err(e) = validate_server_spec(spec) {
log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
errors.push(format!("{id}: {e}"));
continue;
}
let entry = config
.mcp_for_mut(&AppType::Claude)
.servers
.entry(id.clone());
use std::collections::hash_map::Entry;
match entry {
Entry::Vacant(vac) => {
let mut obj = serde_json::Map::new();
obj.insert(String::from("id"), json!(id));
obj.insert(String::from("name"), json!(id));
obj.insert(String::from("server"), spec.clone());
obj.insert(String::from("enabled"), json!(true));
vac.insert(Value::Object(obj));
if let Some(existing) = servers.get_mut(id) {
// 已存在:仅启用 Claude 应用
if !existing.apps.claude {
existing.apps.claude = true;
changed += 1;
log::info!("MCP 服务器 '{id}' 已启用 Claude 应用");
}
Entry::Occupied(mut occ) => {
let value = occ.get_mut();
let Some(existing) = value.as_object_mut() else {
log::warn!("MCP 条目 '{id}' 不是 JSON 对象,覆盖为导入数据");
let mut obj = serde_json::Map::new();
obj.insert(String::from("id"), json!(id));
obj.insert(String::from("name"), json!(id));
obj.insert(String::from("server"), spec.clone());
obj.insert(String::from("enabled"), json!(true));
occ.insert(Value::Object(obj));
changed += 1;
continue;
};
let mut modified = false;
let prev_enabled = existing
.get("enabled")
.and_then(|b| b.as_bool())
.unwrap_or(false);
if !prev_enabled {
existing.insert(String::from("enabled"), json!(true));
modified = true;
}
if existing.get("server").is_none() {
log::warn!("MCP 条目 '{id}' 缺少 server 字段,覆盖为导入数据");
existing.insert(String::from("server"), spec.clone());
modified = true;
}
if existing.get("id").is_none() {
log::warn!("MCP 条目 '{id}' 缺少 id 字段,自动填充");
existing.insert(String::from("id"), json!(id));
modified = true;
}
if modified {
changed += 1;
}
}
} else {
// 新建服务器:默认仅启用 Claude
servers.insert(
id.clone(),
McpServer {
id: id.clone(),
name: id.clone(),
server: spec.clone(),
apps: McpApps {
claude: true,
codex: false,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
changed += 1;
log::info!("导入新 MCP 服务器 '{id}'");
}
}
if !errors.is_empty() {
log::warn!(
"导入完成,但有 {} 项失败: {:?}",
errors.len(),
errors
);
}
Ok(changed)
}
/// 从 ~/.codex/config.toml 导入 MCP 到 config.jsonCodex 作用域),并将导入项设为 enabled=true。
/// 支持两种 schema[mcp.servers.<id>] 与 [mcp_servers.<id>]
/// 已存在的项仅强制 enabled=true,不覆盖其他字段
/// 从 ~/.codex/config.toml 导入 MCP 到统一结构v3.7.0+
/// 支持两种 schema[mcp.servers.<id>] 与 [mcp_servers.<id>]
/// 已存在的服务器将启用 Codex 应用,不覆盖其他字段和应用状态
pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError> {
use crate::app_config::{McpApps, McpServer};
let text = crate::codex_config::read_and_validate_codex_config_text()?;
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| AppError::McpValidation(format!("解析 ~/.codex/config.toml 失败: {e}")))?;
// 确保新结构存在
if config.mcp.servers.is_none() {
config.mcp.servers = Some(HashMap::new());
}
let servers = config.mcp.servers.as_mut().unwrap();
let mut changed_total = 0usize;
// helper处理一组 servers 表
let mut import_servers_tbl = |servers_tbl: &toml::value::Table| {
let mut changed = 0usize;
@@ -476,70 +485,48 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
}
}
}
_ => {}
_ => {
log::warn!("跳过未知类型 '{typ}' 的 Codex MCP 项 '{id}'");
return changed;
}
}
let spec_v = serde_json::Value::Object(spec);
// 校验
// 校验:单项失败继续处理
if let Err(e) = validate_server_spec(&spec_v) {
log::warn!("跳过无效 Codex MCP 项 '{id}': {e}");
continue;
}
// 合并:仅强制 enabled=true
use std::collections::hash_map::Entry;
let entry = config
.mcp_for_mut(&AppType::Codex)
.servers
.entry(id.clone());
match entry {
Entry::Vacant(vac) => {
let mut obj = serde_json::Map::new();
obj.insert(String::from("id"), json!(id));
obj.insert(String::from("name"), json!(id));
obj.insert(String::from("server"), spec_v.clone());
obj.insert(String::from("enabled"), json!(true));
vac.insert(serde_json::Value::Object(obj));
if let Some(existing) = servers.get_mut(id) {
// 已存在:仅启用 Codex 应用
if !existing.apps.codex {
existing.apps.codex = true;
changed += 1;
log::info!("MCP 服务器 '{id}' 已启用 Codex 应用");
}
Entry::Occupied(mut occ) => {
let value = occ.get_mut();
let Some(existing) = value.as_object_mut() else {
log::warn!("MCP 条目 '{id}' 不是 JSON 对象,覆盖为导入数据");
let mut obj = serde_json::Map::new();
obj.insert(String::from("id"), json!(id));
obj.insert(String::from("name"), json!(id));
obj.insert(String::from("server"), spec_v.clone());
obj.insert(String::from("enabled"), json!(true));
occ.insert(serde_json::Value::Object(obj));
changed += 1;
continue;
};
let mut modified = false;
let prev = existing
.get("enabled")
.and_then(|b| b.as_bool())
.unwrap_or(false);
if !prev {
existing.insert(String::from("enabled"), json!(true));
modified = true;
}
if existing.get("server").is_none() {
log::warn!("MCP 条目 '{id}' 缺少 server 字段,覆盖为导入数据");
existing.insert(String::from("server"), spec_v.clone());
modified = true;
}
if existing.get("id").is_none() {
log::warn!("MCP 条目 '{id}' 缺少 id 字段,自动填充");
existing.insert(String::from("id"), json!(id));
modified = true;
}
if modified {
changed += 1;
}
}
} else {
// 新建服务器:默认仅启用 Codex
servers.insert(
id.clone(),
McpServer {
id: id.clone(),
name: id.clone(),
server: spec_v,
apps: McpApps {
claude: false,
codex: true,
gemini: false,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
changed += 1;
log::info!("导入新 MCP 服务器 '{id}'");
}
}
changed
@@ -724,76 +711,76 @@ pub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> {
crate::gemini_mcp::set_mcp_servers_map(&enabled)
}
/// 从 ~/.gemini/settings.json 导入 mcpServers 到 config.json设为 enabled=true
/// 已存在的项仅强制 enabled=true,不覆盖其他字段
/// 从 ~/.gemini/settings.json 导入 mcpServers 到统一结构v3.7.0+
/// 已存在的服务器将启用 Gemini 应用,不覆盖其他字段和应用状态
pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError> {
use crate::app_config::{McpApps, McpServer};
let text_opt = crate::gemini_mcp::read_mcp_json()?;
let Some(text) = text_opt else { return Ok(0) };
let mut changed = normalize_servers_for(config, &AppType::Gemini);
let v: Value = serde_json::from_str(&text)
.map_err(|e| AppError::McpValidation(format!("解析 ~/.gemini/settings.json 失败: {e}")))?;
let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
return Ok(changed);
return Ok(0);
};
// 确保新结构存在
if config.mcp.servers.is_none() {
config.mcp.servers = Some(HashMap::new());
}
let servers = config.mcp.servers.as_mut().unwrap();
let mut changed = 0;
let mut errors = Vec::new();
for (id, spec) in map.iter() {
// 校验目标 spec
validate_server_spec(spec)?;
// 校验:单项失败不中止,收集错误继续处理
if let Err(e) = validate_server_spec(spec) {
log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
errors.push(format!("{id}: {e}"));
continue;
}
let entry = config
.mcp_for_mut(&AppType::Gemini)
.servers
.entry(id.clone());
use std::collections::hash_map::Entry;
match entry {
Entry::Vacant(vac) => {
let mut obj = serde_json::Map::new();
obj.insert(String::from("id"), json!(id));
obj.insert(String::from("name"), json!(id));
obj.insert(String::from("server"), spec.clone());
obj.insert(String::from("enabled"), json!(true));
vac.insert(Value::Object(obj));
if let Some(existing) = servers.get_mut(id) {
// 已存在:仅启用 Gemini 应用
if !existing.apps.gemini {
existing.apps.gemini = true;
changed += 1;
log::info!("MCP 服务器 '{id}' 已启用 Gemini 应用");
}
Entry::Occupied(mut occ) => {
let value = occ.get_mut();
let Some(existing) = value.as_object_mut() else {
log::warn!("MCP 条目 '{id}' 不是 JSON 对象,覆盖为导入数据");
let mut obj = serde_json::Map::new();
obj.insert(String::from("id"), json!(id));
obj.insert(String::from("name"), json!(id));
obj.insert(String::from("server"), spec.clone());
obj.insert(String::from("enabled"), json!(true));
occ.insert(Value::Object(obj));
changed += 1;
continue;
};
let mut modified = false;
let prev_enabled = existing
.get("enabled")
.and_then(|b| b.as_bool())
.unwrap_or(false);
if !prev_enabled {
existing.insert(String::from("enabled"), json!(true));
modified = true;
}
if existing.get("server").is_none() {
log::warn!("MCP 条目 '{id}' 缺少 server 字段,覆盖为导入数据");
existing.insert(String::from("server"), spec.clone());
modified = true;
}
if existing.get("id").is_none() {
log::warn!("MCP 条目 '{id}' 缺少 id 字段,自动填充");
existing.insert(String::from("id"), json!(id));
modified = true;
}
if modified {
changed += 1;
}
}
} else {
// 新建服务器:默认仅启用 Gemini
servers.insert(
id.clone(),
McpServer {
id: id.clone(),
name: id.clone(),
server: spec.clone(),
apps: McpApps {
claude: false,
codex: false,
gemini: true,
},
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
changed += 1;
log::info!("导入新 MCP 服务器 '{id}'");
}
}
if !errors.is_empty() {
log::warn!(
"导入完成,但有 {} 项失败: {:?}",
errors.len(),
errors
);
}
Ok(changed)
}

View File

@@ -231,11 +231,7 @@ impl McpService {
Ok(())
}
/// [已废弃] 从 Claude 导入 MCP兼容旧 API
#[deprecated(
since = "3.7.0",
note = "Import will be handled differently in unified structure"
)]
/// 从 Claude 导入 MCPv3.7.0 已更新为统一结构
pub fn import_from_claude(state: &AppState) -> Result<usize, AppError> {
let mut cfg = state.config.write()?;
let count = mcp::import_from_claude(&mut cfg)?;
@@ -244,11 +240,7 @@ impl McpService {
Ok(count)
}
/// [已废弃] 从 Codex 导入 MCP兼容旧 API
#[deprecated(
since = "3.7.0",
note = "Import will be handled differently in unified structure"
)]
/// 从 Codex 导入 MCPv3.7.0 已更新为统一结构
pub fn import_from_codex(state: &AppState) -> Result<usize, AppError> {
let mut cfg = state.config.write()?;
let count = mcp::import_from_codex(&mut cfg)?;
@@ -257,11 +249,7 @@ impl McpService {
Ok(count)
}
/// [已废弃] 从 Gemini 导入 MCP兼容旧 API
#[deprecated(
since = "3.7.0",
note = "Import will be handled differently in unified structure"
)]
/// 从 Gemini 导入 MCPv3.7.0 已更新为统一结构
pub fn import_from_gemini(state: &AppState) -> Result<usize, AppError> {
let mut cfg = state.config.write()?;
let count = mcp::import_from_gemini(&mut cfg)?;

View File

@@ -1,10 +1,10 @@
use std::{fs, sync::RwLock};
use std::{collections::HashMap, fs, sync::RwLock};
use serde_json::json;
use cc_switch_lib::{
get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, AppError,
AppState, AppType, McpService, MultiAppConfig,
AppState, AppType, McpApps, McpServer, McpService, MultiAppConfig,
};
#[path = "support.rs"]
@@ -126,16 +126,12 @@ fn import_mcp_from_claude_creates_config_and_enables_servers() {
);
let guard = state.config.read().expect("lock config");
let claude_servers = &guard.mcp.claude.servers;
let entry = claude_servers
.get("echo")
.expect("server imported into config.json");
// v3.7.0: 检查统一结构
let servers = guard.mcp.servers.as_ref().expect("unified servers should exist");
let entry = servers.get("echo").expect("server imported into unified structure");
assert!(
entry
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false),
"imported server should be marked enabled"
entry.apps.claude,
"imported server should have Claude app enabled"
);
drop(guard);
@@ -181,43 +177,61 @@ fn import_mcp_from_claude_invalid_json_preserves_state() {
fn set_mcp_enabled_for_codex_writes_live_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
ensure_test_home();
let home = ensure_test_home();
// 创建 Codex 配置目录和文件
let codex_dir = home.join(".codex");
fs::create_dir_all(&codex_dir).expect("create codex dir");
fs::write(codex_dir.join("auth.json"), r#"{"OPENAI_API_KEY":"test-key"}"#)
.expect("create auth.json");
fs::write(codex_dir.join("config.toml"), "")
.expect("create empty config.toml");
let mut config = MultiAppConfig::default();
config.ensure_app(&AppType::Codex);
config.mcp.codex.servers.insert(
// v3.7.0: 使用统一结构
config.mcp.servers = Some(HashMap::new());
config.mcp.servers.as_mut().unwrap().insert(
"codex-server".into(),
json!({
"id": "codex-server",
"name": "Codex Server",
"server": {
McpServer {
id: "codex-server".to_string(),
name: "Codex Server".to_string(),
server: json!({
"type": "stdio",
"command": "echo"
}),
apps: McpApps {
claude: false,
codex: false, // 初始未启用
gemini: false,
},
"enabled": false
}),
description: None,
homepage: None,
docs: None,
tags: Vec::new(),
},
);
let state = AppState {
config: RwLock::new(config),
};
McpService::set_enabled(&state, AppType::Codex, "codex-server", true)
.expect("set enabled should succeed");
// v3.7.0: 使用 toggle_app 替代 set_enabled
McpService::toggle_app(&state, "codex-server", AppType::Codex, true)
.expect("toggle_app should succeed");
let guard = state.config.read().expect("lock config");
let entry = guard
.mcp
.codex
.servers
.as_ref()
.unwrap()
.get("codex-server")
.expect("codex server exists");
assert!(
entry
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false),
"server should be marked enabled after command"
entry.apps.codex,
"server should have Codex app enabled after toggle"
);
drop(guard);