fix(mcp): correct Codex MCP configuration format to [mcp_servers]

BREAKING CHANGE: The [mcp.servers] format was completely incorrect and not
any official Codex format. The only correct format is [mcp_servers] at the
top level of config.toml.

Changes:
- Remove incorrect [mcp.servers] nested table support
- Always use [mcp_servers] top-level table (official Codex format)
- Auto-migrate and cleanup erroneous [mcp.servers] entries on write
- Preserve error-tolerant import for migrating old incorrect configs
- Simplify sync logic by removing format selection branches (~60 lines)
- Update all documentation and tests to reflect correct format
- Add warning logs when detecting and cleaning incorrect format

Backend (Rust):
- mcp.rs: Simplify sync_enabled_to_codex by removing Target enum
- mcp.rs: sync_single_server_to_codex now always uses [mcp_servers]
- mcp.rs: remove_server_from_codex cleans both locations
- mcp.rs: Update import_from_codex comments to clarify format status
- tests: Rename test to sync_enabled_to_codex_migrates_erroneous_*
- tests: Update assertions to verify migration behavior

Frontend (TypeScript):
- tomlUtils.ts: Prioritize [mcp_servers] format in parsing
- tomlUtils.ts: Update error messages to guide correct format

Documentation:
- README.md: Correct MCP format reference to [mcp_servers]
- CLAUDE.md: Add comprehensive format specification with examples

All 79 tests pass. This ensures backward compatibility while enforcing
the correct Codex official standard going forward.

Refs: https://github.com/openai/codex/issues/3441
This commit is contained in:
Jason
2025-11-17 22:57:04 +08:00
parent 3051743bd3
commit 67bd8f5c11
10 changed files with 147 additions and 167 deletions

View File

@@ -137,7 +137,7 @@ Download the latest `CC-Switch-v{version}-Linux.deb` package or `CC-Switch-v{ver
- Live config: `~/.codex/auth.json` (required) + `config.toml` (optional) - Live config: `~/.codex/auth.json` (required) + `config.toml` (optional)
- API key field: `OPENAI_API_KEY` in `auth.json` - API key field: `OPENAI_API_KEY` in `auth.json`
- MCP servers: `~/.codex/config.toml``[mcp.servers]` - MCP servers: `~/.codex/config.toml``[mcp_servers]` tables
**Gemini** **Gemini**

View File

@@ -137,7 +137,7 @@ brew upgrade --cask cc-switch
- Live 配置:`~/.codex/auth.json`(必需)+ `config.toml`(可选) - Live 配置:`~/.codex/auth.json`(必需)+ `config.toml`(可选)
- API key 字段:`auth.json` 中的 `OPENAI_API_KEY` - API key 字段:`auth.json` 中的 `OPENAI_API_KEY`
- MCP 服务器:`~/.codex/config.toml``[mcp.servers]` - MCP 服务器:`~/.codex/config.toml``[mcp_servers]`
**Gemini** **Gemini**

View File

@@ -317,7 +317,9 @@ impl MultiAppConfig {
// 迁移通用配置片段claude_common_config_snippet → common_config_snippets.claude // 迁移通用配置片段claude_common_config_snippet → common_config_snippets.claude
if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() { if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() {
log::info!("迁移通用配置claude_common_config_snippet → common_config_snippets.claude"); log::info!(
"迁移通用配置claude_common_config_snippet → common_config_snippets.claude"
);
config.common_config_snippets.claude = Some(old_claude_snippet); config.common_config_snippets.claude = Some(old_claude_snippet);
updated = true; updated = true;
} }
@@ -414,9 +416,7 @@ impl MultiAppConfig {
return Ok(false); return Ok(false);
} }
log::info!( log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
"检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入"
);
let mut imported = false; let mut imported = false;
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] { for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {

View File

@@ -139,13 +139,11 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
if is_http || is_sse { if is_http || is_sse {
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or(""); let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
if url.is_empty() { if url.is_empty() {
return Err(AppError::McpValidation( return Err(AppError::McpValidation(if is_http {
if is_http { "http 类型的 MCP 服务器缺少 url 字段".into()
"http 类型的 MCP 服务器缺少 url 字段".into() } else {
} else { "sse 类型的 MCP 服务器缺少 url 字段".into()
"sse 类型的 MCP 服务器缺少 url 字段".into() }));
},
));
} }
} }

View File

@@ -184,8 +184,7 @@ pub async fn get_common_config_snippet(
use crate::app_config::AppType; use crate::app_config::AppType;
use std::str::FromStr; use std::str::FromStr;
let app = AppType::from_str(&app_type) let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {}", e))?;
.map_err(|e| format!("无效的应用类型: {}", e))?;
let guard = state let guard = state
.config .config
@@ -205,8 +204,7 @@ pub async fn set_common_config_snippet(
use crate::app_config::AppType; use crate::app_config::AppType;
use std::str::FromStr; use std::str::FromStr;
let app = AppType::from_str(&app_type) let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {}", e))?;
.map_err(|e| format!("无效的应用类型: {}", e))?;
let mut guard = state let mut guard = state
.config .config

View File

@@ -48,8 +48,6 @@ pub fn read_mcp_json() -> Result<Option<String>, AppError> {
Ok(Some(content)) Ok(Some(content))
} }
/// 读取 Gemini settings.json 中的 mcpServers 映射 /// 读取 Gemini settings.json 中的 mcpServers 映射
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> { pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
let path = user_config_path(); let path = user_config_path();

View File

@@ -396,18 +396,18 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
} }
if !errors.is_empty() { if !errors.is_empty() {
log::warn!( log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
"导入完成,但有 {} 项失败: {:?}",
errors.len(),
errors
);
} }
Ok(changed) Ok(changed)
} }
/// 从 ~/.codex/config.toml 导入 MCP 到统一结构v3.7.0+ /// 从 ~/.codex/config.toml 导入 MCP 到统一结构v3.7.0+
/// 支持两种 schema[mcp.servers.<id>] 与 [mcp_servers.<id>] ///
/// 格式支持:
/// - 正确格式:[mcp_servers.*]Codex 官方标准)
/// - 错误格式:[mcp.servers.*](容错读取,用于迁移错误写入的配置)
///
/// 已存在的服务器将启用 Codex 应用,不覆盖其他字段和应用状态 /// 已存在的服务器将启用 Codex 应用,不覆盖其他字段和应用状态
pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError> { pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError> {
use crate::app_config::{McpApps, McpServer}; use crate::app_config::{McpApps, McpServer};
@@ -629,13 +629,16 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
Ok(changed_total) Ok(changed_total)
} }
/// 将 config.json 中 Codex 的 enabled==true 项以 TOML 形式写入 ~/.codex/config.toml 的 [mcp.servers] /// 将 config.json 中 Codex 的 enabled==true 项以 TOML 形式写入 ~/.codex/config.toml
/// 策略: ///
/// 格式策略:
/// - 唯一正确格式:[mcp_servers] 顶层表Codex 官方标准)
/// - 自动清理错误格式:[mcp.servers](如果存在)
/// - 读取现有 config.toml若语法无效则报错不尝试覆盖 /// - 读取现有 config.toml若语法无效则报错不尝试覆盖
/// - 仅更新 `mcp.servers` 或 `mcp_servers` 表,保留 `mcp` 其它键 /// - 仅更新 `mcp_servers` 表,保留其它键
/// - 仅写入启用项;无启用项时清理对应子 /// - 仅写入启用项;无启用项时清理 mcp_servers
pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> { pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
use toml_edit::{DocumentMut, Item, Table}; use toml_edit::{Item, Table};
// 1) 收集启用项Codex 维度) // 1) 收集启用项Codex 维度)
let enabled = collect_enabled_servers(&config.mcp.codex); let enabled = collect_enabled_servers(&config.mcp.codex);
@@ -644,44 +647,31 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
let base_text = crate::codex_config::read_and_validate_codex_config_text()?; let base_text = crate::codex_config::read_and_validate_codex_config_text()?;
// 3) 使用 toml_edit 解析(允许空文件) // 3) 使用 toml_edit 解析(允许空文件)
let mut doc: DocumentMut = if base_text.trim().is_empty() { let mut doc = if base_text.trim().is_empty() {
DocumentMut::default() toml_edit::DocumentMut::default()
} else { } else {
base_text base_text
.parse::<DocumentMut>() .parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::McpValidation(format!("解析 config.toml 失败: {e}")))? .map_err(|e| AppError::McpValidation(format!("解析 config.toml 失败: {e}")))?
}; };
enum Target { // 4) 清理可能存在的错误格式 [mcp.servers]
McpServers, // 顶层 mcp_servers if let Some(mcp_item) = doc.get_mut("mcp") {
McpDotServers, // mcp.servers if let Some(tbl) = mcp_item.as_table_like_mut() {
if tbl.contains_key("servers") {
log::warn!("检测到错误的 MCP 格式 [mcp.servers],正在清理并迁移到 [mcp_servers]");
tbl.remove("servers");
}
}
} }
// 4) 选择目标风格:优先沿用既有子表;其次在 mcp 表下新建;最后退回顶层 mcp_servers // 5) 构造目标 servers 表(稳定的键顺序)
let has_mcp_dot_servers = doc if enabled.is_empty() {
.get("mcp") // 无启用项:移除 mcp_servers 表
.and_then(|m| m.get("servers")) doc.as_table_mut().remove("mcp_servers");
.and_then(|s| s.as_table_like())
.is_some();
let has_mcp_servers = doc
.get("mcp_servers")
.and_then(|s| s.as_table_like())
.is_some();
let mcp_is_table = doc.get("mcp").and_then(|m| m.as_table_like()).is_some();
let target = if has_mcp_dot_servers {
Target::McpDotServers
} else if has_mcp_servers {
Target::McpServers
} else if mcp_is_table {
Target::McpDotServers
} else { } else {
Target::McpServers // 构建 servers
}; let mut servers_tbl = Table::new();
// 构造目标 servers 表(稳定的键顺序)
let build_servers_table = || -> Table {
let mut servers = Table::new();
let mut ids: Vec<_> = enabled.keys().cloned().collect(); let mut ids: Vec<_> = enabled.keys().cloned().collect();
ids.sort(); ids.sort();
for id in ids { for id in ids {
@@ -689,47 +679,15 @@ pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
// 复用通用转换函数(已包含扩展字段支持) // 复用通用转换函数(已包含扩展字段支持)
match json_server_to_toml_table(spec) { match json_server_to_toml_table(spec) {
Ok(table) => { Ok(table) => {
servers[&id[..]] = Item::Table(table); servers_tbl[&id[..]] = Item::Table(table);
} }
Err(err) => { Err(err) => {
log::error!("跳过无效的 MCP 服务器 '{id}': {err}"); log::error!("跳过无效的 MCP 服务器 '{id}': {err}");
} }
} }
} }
servers // 使用唯一正确的格式:[mcp_servers]
}; doc["mcp_servers"] = Item::Table(servers_tbl);
// 5) 应用更新:仅就地更新目标子表;避免改动其它键/注释/空白
if enabled.is_empty() {
// 无启用项:移除两种 servers 表(如果存在),但保留 mcp 其它字段
if let Some(mcp_item) = doc.get_mut("mcp") {
if let Some(tbl) = mcp_item.as_table_like_mut() {
tbl.remove("servers");
}
}
doc.as_table_mut().remove("mcp_servers");
} else {
let servers_tbl = build_servers_table();
match target {
Target::McpDotServers => {
// 确保 mcp 为表
if doc.get("mcp").and_then(|m| m.as_table_like()).is_none() {
doc["mcp"] = Item::Table(Table::new());
}
doc["mcp"]["servers"] = Item::Table(servers_tbl);
// 去重:若存在顶层 mcp_servers则移除以避免重复定义
doc.as_table_mut().remove("mcp_servers");
}
Target::McpServers => {
doc["mcp_servers"] = Item::Table(servers_tbl);
// 去重:若存在 mcp.servers则移除该子表保留 mcp 其它键
if let Some(mcp_item) = doc.get_mut("mcp") {
if let Some(tbl) = mcp_item.as_table_like_mut() {
tbl.remove("servers");
}
}
}
}
} }
// 6) 写回(仅改 TOML不触碰 auth.jsontoml_edit 会尽量保留未改区域的注释/空白/顺序 // 6) 写回(仅改 TOML不触碰 auth.jsontoml_edit 会尽量保留未改区域的注释/空白/顺序
@@ -808,11 +766,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
} }
if !errors.is_empty() { if !errors.is_empty() {
log::warn!( log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
"导入完成,但有 {} 项失败: {:?}",
errors.len(),
errors
);
} }
Ok(changed) Ok(changed)
@@ -877,10 +831,7 @@ fn json_value_to_toml_item(value: &Value, field_name: &str) -> Option<toml_edit:
} else if let Some(f) = n.as_f64() { } else if let Some(f) = n.as_f64() {
Some(toml_edit::value(f)) Some(toml_edit::value(f))
} else { } else {
log::warn!( log::warn!("跳过字段 '{field_name}': 无法转换的数字类型 {}", n);
"跳过字段 '{field_name}': 无法转换的数字类型 {}",
n
);
None None
} }
} }
@@ -908,9 +859,7 @@ fn json_value_to_toml_item(value: &Value, field_name: &str) -> Option<toml_edit:
if all_same_type && !toml_arr.is_empty() { if all_same_type && !toml_arr.is_empty() {
Some(Item::Value(toml_edit::Value::Array(toml_arr))) Some(Item::Value(toml_edit::Value::Array(toml_arr)))
} else { } else {
log::warn!( log::warn!("跳过字段 '{field_name}': 不支持的数组类型(混合类型或嵌套结构)");
"跳过字段 '{field_name}': 不支持的数组类型(混合类型或嵌套结构)"
);
None None
} }
} }
@@ -933,9 +882,7 @@ fn json_value_to_toml_item(value: &Value, field_name: &str) -> Option<toml_edit:
if all_strings && !inline_table.is_empty() { if all_strings && !inline_table.is_empty() {
Some(Item::Value(toml_edit::Value::InlineTable(inline_table))) Some(Item::Value(toml_edit::Value::InlineTable(inline_table)))
} else { } else {
log::warn!( log::warn!("跳过字段 '{field_name}': 对象值包含非字符串类型,建议使用子表语法");
"跳过字段 '{field_name}': 对象值包含非字符串类型,建议使用子表语法"
);
None None
} }
} }
@@ -1074,6 +1021,7 @@ fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError>
} }
/// 将单个 MCP 服务器同步到 Codex live 配置 /// 将单个 MCP 服务器同步到 Codex live 配置
/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers]
pub fn sync_single_server_to_codex( pub fn sync_single_server_to_codex(
_config: &MultiAppConfig, _config: &MultiAppConfig,
id: &str, id: &str,
@@ -1094,24 +1042,26 @@ pub fn sync_single_server_to_codex(
toml_edit::DocumentMut::new() toml_edit::DocumentMut::new()
}; };
// 确保 [mcp] 表存在 // 清理可能存在的错误格式 [mcp.servers]
if !doc.contains_key("mcp") { if let Some(mcp_item) = doc.get_mut("mcp") {
doc["mcp"] = toml_edit::table(); if let Some(tbl) = mcp_item.as_table_like_mut() {
if tbl.contains_key("servers") {
log::warn!("检测到错误的 MCP 格式 [mcp.servers],正在清理并迁移到 [mcp_servers]");
tbl.remove("servers");
}
}
} }
// 确保 [mcp.servers] 表存在 // 确保 [mcp_servers] 表存在
if doc["mcp"] if !doc.contains_key("mcp_servers") {
.as_table() doc["mcp_servers"] = toml_edit::table();
.and_then(|t| t.get("servers"))
.is_none()
{
doc["mcp"]["servers"] = toml_edit::table();
} }
// 将 JSON 服务器规范转换为 TOML 表 // 将 JSON 服务器规范转换为 TOML 表
let toml_table = json_server_to_toml_table(server_spec)?; let toml_table = json_server_to_toml_table(server_spec)?;
doc["mcp"]["servers"][id] = Item::Table(toml_table); // 使用唯一正确的格式:[mcp_servers]
doc["mcp_servers"][id] = Item::Table(toml_table);
// 写回文件 // 写回文件
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?; std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
@@ -1120,6 +1070,7 @@ pub fn sync_single_server_to_codex(
} }
/// 从 Codex live 配置中移除单个 MCP 服务器 /// 从 Codex live 配置中移除单个 MCP 服务器
/// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据
pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> { pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
let config_path = crate::codex_config::get_codex_config_path(); let config_path = crate::codex_config::get_codex_config_path();
@@ -1134,10 +1085,17 @@ pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
.parse::<toml_edit::DocumentMut>() .parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?; .map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?;
// 从 [mcp.servers] 中删除 // 从正确的位置删除:[mcp_servers]
if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
mcp_servers.remove(id);
}
// 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在)
if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) { if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) {
if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) { if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) {
servers.remove(id); if servers.remove(id).is_some() {
log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{}'", id);
}
} }
} }

View File

@@ -222,10 +222,13 @@ mode = "dev"
text.contains("[profile]"), text.contains("[profile]"),
"non-MCP table should be preserved" "non-MCP table should be preserved"
); );
// 新增的 mcp_servers/或 mcp.servers 应存在并包含 echo
assert!( assert!(
text.contains("mcp_servers") || text.contains("[mcp.servers]"), text.contains("mcp_servers"),
"one server table style should be present" "mcp_servers table should be present"
);
assert!(
!text.contains("[mcp.servers]"),
"invalid [mcp.servers] table should not appear"
); );
assert!( assert!(
text.contains("echo") && text.contains("command = \"echo\""), text.contains("echo") && text.contains("command = \"echo\""),
@@ -234,14 +237,14 @@ mode = "dev"
} }
#[test] #[test]
fn sync_enabled_to_codex_keeps_existing_style_mcp_dot_servers() { fn sync_enabled_to_codex_migrates_erroneous_mcp_dot_servers_to_mcp_servers() {
let _guard = test_mutex().lock().expect("acquire test mutex"); let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs(); reset_test_fs();
let path = cc_switch_lib::get_codex_config_path(); let path = cc_switch_lib::get_codex_config_path();
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create codex dir"); fs::create_dir_all(parent).expect("create codex dir");
} }
// 预置 mcp.servers 风格 // 预置错误的 mcp.servers 风格(应迁移为顶层 mcp_servers
let seed = r#"[mcp] let seed = r#"[mcp]
other = "keep" other = "keep"
[mcp.servers] [mcp.servers]
@@ -260,14 +263,14 @@ fn sync_enabled_to_codex_keeps_existing_style_mcp_dot_servers() {
cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex"); cc_switch_lib::sync_enabled_to_codex(&config).expect("sync codex");
let text = fs::read_to_string(&path).expect("read config.toml"); let text = fs::read_to_string(&path).expect("read config.toml");
// 仍应采用 mcp.servers 风格 // 应迁移到顶层 mcp_servers并移除错误的 mcp.servers
assert!( assert!(
text.contains("[mcp.servers]"), text.contains("mcp_servers"),
"should keep mcp.servers style" "should migrate to mcp_servers table"
); );
assert!( assert!(
!text.contains("mcp_servers"), !text.contains("[mcp.servers]"),
"should not switch to mcp_servers" "invalid [mcp.servers] table should be removed"
); );
} }
@@ -488,10 +491,17 @@ url = "https://example.com"
assert!(changed >= 2, "should import both servers"); assert!(changed >= 2, "should import both servers");
// v3.7.0: 检查统一结构 // v3.7.0: 检查统一结构
let servers = config.mcp.servers.as_ref().expect("unified servers should exist"); let servers = config
.mcp
.servers
.as_ref()
.expect("unified servers should exist");
let echo = servers.get("echo_server").expect("echo server"); let echo = servers.get("echo_server").expect("echo server");
assert_eq!(echo.apps.codex, true, "Codex app should be enabled for echo_server"); assert_eq!(
echo.apps.codex, true,
"Codex app should be enabled for echo_server"
);
let server_spec = echo.server.as_object().expect("server spec"); let server_spec = echo.server.as_object().expect("server spec");
assert_eq!( assert_eq!(
server_spec server_spec
@@ -502,7 +512,10 @@ url = "https://example.com"
); );
let http = servers.get("http_server").expect("http server"); let http = servers.get("http_server").expect("http server");
assert_eq!(http.apps.codex, true, "Codex app should be enabled for http_server"); assert_eq!(
http.apps.codex, true,
"Codex app should be enabled for http_server"
);
let http_spec = http.server.as_object().expect("http spec"); let http_spec = http.server.as_object().expect("http spec");
assert_eq!( assert_eq!(
http_spec.get("url").and_then(|v| v.as_str()).unwrap_or(""), http_spec.get("url").and_then(|v| v.as_str()).unwrap_or(""),
@@ -541,7 +554,7 @@ command = "echo"
}), }),
apps: cc_switch_lib::McpApps { apps: cc_switch_lib::McpApps {
claude: false, claude: false,
codex: false, // 初始未启用 codex: false, // 初始未启用
gemini: false, gemini: false,
}, },
description: None, description: None,
@@ -564,7 +577,10 @@ command = "echo"
.expect("existing entry"); .expect("existing entry");
// 验证 Codex 应用已启用 // 验证 Codex 应用已启用
assert_eq!(entry.apps.codex, true, "Codex app should be enabled after import"); assert_eq!(
entry.apps.codex, true,
"Codex app should be enabled after import"
);
// 验证现有配置被保留server 不应被覆盖) // 验证现有配置被保留server 不应被覆盖)
let spec = entry.server.as_object().expect("server spec"); let spec = entry.server.as_object().expect("server spec");
@@ -662,7 +678,7 @@ fn import_from_claude_merges_into_config() {
"command": "prev" "command": "prev"
}), }),
apps: cc_switch_lib::McpApps { apps: cc_switch_lib::McpApps {
claude: false, // 初始未启用 claude: false, // 初始未启用
codex: false, codex: false,
gemini: false, gemini: false,
}, },
@@ -686,7 +702,10 @@ fn import_from_claude_merges_into_config() {
.expect("entry exists"); .expect("entry exists");
// 验证 Claude 应用已启用 // 验证 Claude 应用已启用
assert_eq!(entry.apps.claude, true, "Claude app should be enabled after import"); assert_eq!(
entry.apps.claude, true,
"Claude app should be enabled after import"
);
// 验证现有配置被保留server 不应被覆盖) // 验证现有配置被保留server 不应被覆盖)
let server = entry.server.as_object().expect("server obj"); let server = entry.server.as_object().expect("server obj");

View File

@@ -127,8 +127,14 @@ fn import_mcp_from_claude_creates_config_and_enables_servers() {
let guard = state.config.read().expect("lock config"); let guard = state.config.read().expect("lock config");
// v3.7.0: 检查统一结构 // v3.7.0: 检查统一结构
let servers = guard.mcp.servers.as_ref().expect("unified servers should exist"); let servers = guard
let entry = servers.get("echo").expect("server imported into unified structure"); .mcp
.servers
.as_ref()
.expect("unified servers should exist");
let entry = servers
.get("echo")
.expect("server imported into unified structure");
assert!( assert!(
entry.apps.claude, entry.apps.claude,
"imported server should have Claude app enabled" "imported server should have Claude app enabled"
@@ -182,10 +188,12 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
// 创建 Codex 配置目录和文件 // 创建 Codex 配置目录和文件
let codex_dir = home.join(".codex"); let codex_dir = home.join(".codex");
fs::create_dir_all(&codex_dir).expect("create codex dir"); fs::create_dir_all(&codex_dir).expect("create codex dir");
fs::write(codex_dir.join("auth.json"), r#"{"OPENAI_API_KEY":"test-key"}"#) fs::write(
.expect("create auth.json"); codex_dir.join("auth.json"),
fs::write(codex_dir.join("config.toml"), "") r#"{"OPENAI_API_KEY":"test-key"}"#,
.expect("create empty config.toml"); )
.expect("create auth.json");
fs::write(codex_dir.join("config.toml"), "").expect("create empty config.toml");
let mut config = MultiAppConfig::default(); let mut config = MultiAppConfig::default();
config.ensure_app(&AppType::Codex); config.ensure_app(&AppType::Codex);
@@ -203,7 +211,7 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
}), }),
apps: McpApps { apps: McpApps {
claude: false, claude: false,
codex: false, // 初始未启用 codex: false, // 初始未启用
gemini: false, gemini: false,
}, },
description: None, description: None,

View File

@@ -44,7 +44,8 @@ export const mcpServerToToml = (server: McpServerSpec): string => {
* 将 TOML 文本转换为 McpServerSpec 对象(单个服务器配置) * 将 TOML 文本转换为 McpServerSpec 对象(单个服务器配置)
* 支持两种格式: * 支持两种格式:
* 1. 直接的服务器配置type, command, args 等) * 1. 直接的服务器配置type, command, args 等)
* 2. [mcp.servers.<id>] 或 [mcp_servers.<id>] 格式(取第一个服务器) * 2. [mcp_servers.<id>] 格式(推荐,取第一个服务器)
* 3. [mcp.servers.<id>] 错误格式(容错解析,同样取第一个服务器)
* @param tomlText TOML 文本 * @param tomlText TOML 文本
* @returns McpServer 对象 * @returns McpServer 对象
* @throws 解析或转换失败时抛出错误 * @throws 解析或转换失败时抛出错误
@@ -67,7 +68,16 @@ export const tomlToMcpServer = (tomlText: string): McpServerSpec => {
return normalizeServerConfig(parsed); return normalizeServerConfig(parsed);
} }
// 情况 2: [mcp.servers.<id>] 格式 // 情况 2: [mcp_servers.<id>] 格式(推荐)
if (parsed.mcp_servers && typeof parsed.mcp_servers === "object") {
const serverIds = Object.keys(parsed.mcp_servers);
if (serverIds.length > 0) {
const firstServer = (parsed.mcp_servers as any)[serverIds[0]];
return normalizeServerConfig(firstServer);
}
}
// 情况 3: [mcp.servers.<id>] 错误格式(容错解析)
if (parsed.mcp && typeof parsed.mcp === "object") { if (parsed.mcp && typeof parsed.mcp === "object") {
const mcpObj = parsed.mcp as any; const mcpObj = parsed.mcp as any;
if (mcpObj.servers && typeof mcpObj.servers === "object") { if (mcpObj.servers && typeof mcpObj.servers === "object") {
@@ -79,17 +89,8 @@ export const tomlToMcpServer = (tomlText: string): McpServerSpec => {
} }
} }
// 情况 3: [mcp_servers.<id>] 格式
if (parsed.mcp_servers && typeof parsed.mcp_servers === "object") {
const serverIds = Object.keys(parsed.mcp_servers);
if (serverIds.length > 0) {
const firstServer = (parsed.mcp_servers as any)[serverIds[0]];
return normalizeServerConfig(firstServer);
}
}
throw new Error( throw new Error(
"无法识别的 TOML 格式。请提供单个 MCP 服务器配置,或使用 [mcp.servers.<id>] 格式", "无法识别的 TOML 格式。请提供单个 MCP 服务器配置,或使用 [mcp_servers.<id>] 格式",
); );
}; };
@@ -189,7 +190,14 @@ export const extractIdFromToml = (tomlText: string): string => {
try { try {
const parsed = parseToml(normalizeTomlText(tomlText)); const parsed = parseToml(normalizeTomlText(tomlText));
// 尝试从 [mcp.servers.<id>] 或 [mcp_servers.<id>] 中提取 ID // 尝试从 [mcp_servers.<id>] 或 [mcp.servers.<id>] 中提取 ID
if (parsed.mcp_servers && typeof parsed.mcp_servers === "object") {
const serverIds = Object.keys(parsed.mcp_servers);
if (serverIds.length > 0) {
return serverIds[0];
}
}
if (parsed.mcp && typeof parsed.mcp === "object") { if (parsed.mcp && typeof parsed.mcp === "object") {
const mcpObj = parsed.mcp as any; const mcpObj = parsed.mcp as any;
if (mcpObj.servers && typeof mcpObj.servers === "object") { if (mcpObj.servers && typeof mcpObj.servers === "object") {
@@ -200,13 +208,6 @@ export const extractIdFromToml = (tomlText: string): string => {
} }
} }
if (parsed.mcp_servers && typeof parsed.mcp_servers === "object") {
const serverIds = Object.keys(parsed.mcp_servers);
if (serverIds.length > 0) {
return serverIds[0];
}
}
// 尝试从 command 中推断 // 尝试从 command 中推断
if (parsed.command && typeof parsed.command === "string") { if (parsed.command && typeof parsed.command === "string") {
const cmd = parsed.command.split(/[\\/]/).pop() || ""; const cmd = parsed.command.split(/[\\/]/).pop() || "";