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:
@@ -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)
|
||||
- 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**
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ brew upgrade --cask cc-switch
|
||||
|
||||
- Live 配置:`~/.codex/auth.json`(必需)+ `config.toml`(可选)
|
||||
- API key 字段:`auth.json` 中的 `OPENAI_API_KEY`
|
||||
- MCP 服务器:`~/.codex/config.toml` → `[mcp.servers]`
|
||||
- MCP 服务器:`~/.codex/config.toml` → `[mcp_servers]` 表
|
||||
|
||||
**Gemini**
|
||||
|
||||
|
||||
@@ -317,7 +317,9 @@ impl MultiAppConfig {
|
||||
|
||||
// 迁移通用配置片段:claude_common_config_snippet → common_config_snippets.claude
|
||||
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);
|
||||
updated = true;
|
||||
}
|
||||
@@ -414,9 +416,7 @@ impl MultiAppConfig {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入"
|
||||
);
|
||||
log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");
|
||||
|
||||
let mut imported = false;
|
||||
for app in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||
|
||||
@@ -139,13 +139,11 @@ pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
|
||||
if is_http || is_sse {
|
||||
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
|
||||
if url.is_empty() {
|
||||
return Err(AppError::McpValidation(
|
||||
if is_http {
|
||||
"http 类型的 MCP 服务器缺少 url 字段".into()
|
||||
} else {
|
||||
"sse 类型的 MCP 服务器缺少 url 字段".into()
|
||||
},
|
||||
));
|
||||
return Err(AppError::McpValidation(if is_http {
|
||||
"http 类型的 MCP 服务器缺少 url 字段".into()
|
||||
} else {
|
||||
"sse 类型的 MCP 服务器缺少 url 字段".into()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -184,8 +184,7 @@ pub async fn get_common_config_snippet(
|
||||
use crate::app_config::AppType;
|
||||
use std::str::FromStr;
|
||||
|
||||
let app = AppType::from_str(&app_type)
|
||||
.map_err(|e| format!("无效的应用类型: {}", e))?;
|
||||
let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {}", e))?;
|
||||
|
||||
let guard = state
|
||||
.config
|
||||
@@ -205,8 +204,7 @@ pub async fn set_common_config_snippet(
|
||||
use crate::app_config::AppType;
|
||||
use std::str::FromStr;
|
||||
|
||||
let app = AppType::from_str(&app_type)
|
||||
.map_err(|e| format!("无效的应用类型: {}", e))?;
|
||||
let app = AppType::from_str(&app_type).map_err(|e| format!("无效的应用类型: {}", e))?;
|
||||
|
||||
let mut guard = state
|
||||
.config
|
||||
|
||||
@@ -48,8 +48,6 @@ pub fn read_mcp_json() -> Result<Option<String>, AppError> {
|
||||
Ok(Some(content))
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// 读取 Gemini settings.json 中的 mcpServers 映射
|
||||
pub fn read_mcp_servers_map() -> Result<std::collections::HashMap<String, Value>, AppError> {
|
||||
let path = user_config_path();
|
||||
|
||||
@@ -396,18 +396,18 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
log::warn!(
|
||||
"导入完成,但有 {} 项失败: {:?}",
|
||||
errors.len(),
|
||||
errors
|
||||
);
|
||||
log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// 从 ~/.codex/config.toml 导入 MCP 到统一结构(v3.7.0+)
|
||||
/// 支持两种 schema:[mcp.servers.<id>] 与 [mcp_servers.<id>]
|
||||
///
|
||||
/// 格式支持:
|
||||
/// - 正确格式:[mcp_servers.*](Codex 官方标准)
|
||||
/// - 错误格式:[mcp.servers.*](容错读取,用于迁移错误写入的配置)
|
||||
///
|
||||
/// 已存在的服务器将启用 Codex 应用,不覆盖其他字段和应用状态
|
||||
pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError> {
|
||||
use crate::app_config::{McpApps, McpServer};
|
||||
@@ -629,13 +629,16 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
|
||||
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;若语法无效则报错,不尝试覆盖
|
||||
/// - 仅更新 `mcp.servers` 或 `mcp_servers` 子表,保留 `mcp` 其它键
|
||||
/// - 仅写入启用项;无启用项时清理对应子表
|
||||
/// - 仅更新 `mcp_servers` 表,保留其它键
|
||||
/// - 仅写入启用项;无启用项时清理 mcp_servers 表
|
||||
pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
|
||||
use toml_edit::{DocumentMut, Item, Table};
|
||||
use toml_edit::{Item, Table};
|
||||
|
||||
// 1) 收集启用项(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()?;
|
||||
|
||||
// 3) 使用 toml_edit 解析(允许空文件)
|
||||
let mut doc: DocumentMut = if base_text.trim().is_empty() {
|
||||
DocumentMut::default()
|
||||
let mut doc = if base_text.trim().is_empty() {
|
||||
toml_edit::DocumentMut::default()
|
||||
} else {
|
||||
base_text
|
||||
.parse::<DocumentMut>()
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(|e| AppError::McpValidation(format!("解析 config.toml 失败: {e}")))?
|
||||
};
|
||||
|
||||
enum Target {
|
||||
McpServers, // 顶层 mcp_servers
|
||||
McpDotServers, // mcp.servers
|
||||
// 4) 清理可能存在的错误格式 [mcp.servers]
|
||||
if let Some(mcp_item) = doc.get_mut("mcp") {
|
||||
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
|
||||
let has_mcp_dot_servers = doc
|
||||
.get("mcp")
|
||||
.and_then(|m| m.get("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
|
||||
// 5) 构造目标 servers 表(稳定的键顺序)
|
||||
if enabled.is_empty() {
|
||||
// 无启用项:移除 mcp_servers 表
|
||||
doc.as_table_mut().remove("mcp_servers");
|
||||
} else {
|
||||
Target::McpServers
|
||||
};
|
||||
|
||||
// 构造目标 servers 表(稳定的键顺序)
|
||||
let build_servers_table = || -> Table {
|
||||
let mut servers = Table::new();
|
||||
// 构建 servers 表
|
||||
let mut servers_tbl = Table::new();
|
||||
let mut ids: Vec<_> = enabled.keys().cloned().collect();
|
||||
ids.sort();
|
||||
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) {
|
||||
Ok(table) => {
|
||||
servers[&id[..]] = Item::Table(table);
|
||||
servers_tbl[&id[..]] = Item::Table(table);
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("跳过无效的 MCP 服务器 '{id}': {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
servers
|
||||
};
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 使用唯一正确的格式:[mcp_servers]
|
||||
doc["mcp_servers"] = Item::Table(servers_tbl);
|
||||
}
|
||||
|
||||
// 6) 写回(仅改 TOML,不触碰 auth.json);toml_edit 会尽量保留未改区域的注释/空白/顺序
|
||||
@@ -808,11 +766,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
log::warn!(
|
||||
"导入完成,但有 {} 项失败: {:?}",
|
||||
errors.len(),
|
||||
errors
|
||||
);
|
||||
log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
|
||||
}
|
||||
|
||||
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() {
|
||||
Some(toml_edit::value(f))
|
||||
} else {
|
||||
log::warn!(
|
||||
"跳过字段 '{field_name}': 无法转换的数字类型 {}",
|
||||
n
|
||||
);
|
||||
log::warn!("跳过字段 '{field_name}': 无法转换的数字类型 {}", n);
|
||||
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() {
|
||||
Some(Item::Value(toml_edit::Value::Array(toml_arr)))
|
||||
} else {
|
||||
log::warn!(
|
||||
"跳过字段 '{field_name}': 不支持的数组类型(混合类型或嵌套结构)"
|
||||
);
|
||||
log::warn!("跳过字段 '{field_name}': 不支持的数组类型(混合类型或嵌套结构)");
|
||||
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() {
|
||||
Some(Item::Value(toml_edit::Value::InlineTable(inline_table)))
|
||||
} else {
|
||||
log::warn!(
|
||||
"跳过字段 '{field_name}': 对象值包含非字符串类型,建议使用子表语法"
|
||||
);
|
||||
log::warn!("跳过字段 '{field_name}': 对象值包含非字符串类型,建议使用子表语法");
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1074,6 +1021,7 @@ fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError>
|
||||
}
|
||||
|
||||
/// 将单个 MCP 服务器同步到 Codex live 配置
|
||||
/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers]
|
||||
pub fn sync_single_server_to_codex(
|
||||
_config: &MultiAppConfig,
|
||||
id: &str,
|
||||
@@ -1094,24 +1042,26 @@ pub fn sync_single_server_to_codex(
|
||||
toml_edit::DocumentMut::new()
|
||||
};
|
||||
|
||||
// 确保 [mcp] 表存在
|
||||
if !doc.contains_key("mcp") {
|
||||
doc["mcp"] = toml_edit::table();
|
||||
// 清理可能存在的错误格式 [mcp.servers]
|
||||
if let Some(mcp_item) = doc.get_mut("mcp") {
|
||||
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] 子表存在
|
||||
if doc["mcp"]
|
||||
.as_table()
|
||||
.and_then(|t| t.get("servers"))
|
||||
.is_none()
|
||||
{
|
||||
doc["mcp"]["servers"] = toml_edit::table();
|
||||
// 确保 [mcp_servers] 表存在
|
||||
if !doc.contains_key("mcp_servers") {
|
||||
doc["mcp_servers"] = toml_edit::table();
|
||||
}
|
||||
|
||||
// 将 JSON 服务器规范转换为 TOML 表
|
||||
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))?;
|
||||
@@ -1120,6 +1070,7 @@ pub fn sync_single_server_to_codex(
|
||||
}
|
||||
|
||||
/// 从 Codex live 配置中移除单个 MCP 服务器
|
||||
/// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据
|
||||
pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
|
||||
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>()
|
||||
.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(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -222,10 +222,13 @@ mode = "dev"
|
||||
text.contains("[profile]"),
|
||||
"non-MCP table should be preserved"
|
||||
);
|
||||
// 新增的 mcp_servers/或 mcp.servers 应存在并包含 echo
|
||||
assert!(
|
||||
text.contains("mcp_servers") || text.contains("[mcp.servers]"),
|
||||
"one server table style should be present"
|
||||
text.contains("mcp_servers"),
|
||||
"mcp_servers table should be present"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("[mcp.servers]"),
|
||||
"invalid [mcp.servers] table should not appear"
|
||||
);
|
||||
assert!(
|
||||
text.contains("echo") && text.contains("command = \"echo\""),
|
||||
@@ -234,14 +237,14 @@ mode = "dev"
|
||||
}
|
||||
|
||||
#[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");
|
||||
reset_test_fs();
|
||||
let path = cc_switch_lib::get_codex_config_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).expect("create codex dir");
|
||||
}
|
||||
// 预置 mcp.servers 风格
|
||||
// 预置错误的 mcp.servers 风格(应迁移为顶层 mcp_servers)
|
||||
let seed = r#"[mcp]
|
||||
other = "keep"
|
||||
[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");
|
||||
let text = fs::read_to_string(&path).expect("read config.toml");
|
||||
// 仍应采用 mcp.servers 风格
|
||||
// 应迁移到顶层 mcp_servers,并移除错误的 mcp.servers 表
|
||||
assert!(
|
||||
text.contains("[mcp.servers]"),
|
||||
"should keep mcp.servers style"
|
||||
text.contains("mcp_servers"),
|
||||
"should migrate to mcp_servers table"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("mcp_servers"),
|
||||
"should not switch to mcp_servers"
|
||||
!text.contains("[mcp.servers]"),
|
||||
"invalid [mcp.servers] table should be removed"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -488,10 +491,17 @@ url = "https://example.com"
|
||||
assert!(changed >= 2, "should import both servers");
|
||||
|
||||
// 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");
|
||||
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");
|
||||
assert_eq!(
|
||||
server_spec
|
||||
@@ -502,7 +512,10 @@ url = "https://example.com"
|
||||
);
|
||||
|
||||
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");
|
||||
assert_eq!(
|
||||
http_spec.get("url").and_then(|v| v.as_str()).unwrap_or(""),
|
||||
@@ -541,7 +554,7 @@ command = "echo"
|
||||
}),
|
||||
apps: cc_switch_lib::McpApps {
|
||||
claude: false,
|
||||
codex: false, // 初始未启用
|
||||
codex: false, // 初始未启用
|
||||
gemini: false,
|
||||
},
|
||||
description: None,
|
||||
@@ -564,7 +577,10 @@ command = "echo"
|
||||
.expect("existing entry");
|
||||
|
||||
// 验证 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 不应被覆盖)
|
||||
let spec = entry.server.as_object().expect("server spec");
|
||||
@@ -662,7 +678,7 @@ fn import_from_claude_merges_into_config() {
|
||||
"command": "prev"
|
||||
}),
|
||||
apps: cc_switch_lib::McpApps {
|
||||
claude: false, // 初始未启用
|
||||
claude: false, // 初始未启用
|
||||
codex: false,
|
||||
gemini: false,
|
||||
},
|
||||
@@ -686,7 +702,10 @@ fn import_from_claude_merges_into_config() {
|
||||
.expect("entry exists");
|
||||
|
||||
// 验证 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 不应被覆盖)
|
||||
let server = entry.server.as_object().expect("server obj");
|
||||
|
||||
@@ -127,8 +127,14 @@ fn import_mcp_from_claude_creates_config_and_enables_servers() {
|
||||
|
||||
let guard = state.config.read().expect("lock config");
|
||||
// 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");
|
||||
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.apps.claude,
|
||||
"imported server should have Claude app enabled"
|
||||
@@ -182,10 +188,12 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
|
||||
// 创建 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");
|
||||
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);
|
||||
@@ -203,7 +211,7 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
|
||||
}),
|
||||
apps: McpApps {
|
||||
claude: false,
|
||||
codex: false, // 初始未启用
|
||||
codex: false, // 初始未启用
|
||||
gemini: false,
|
||||
},
|
||||
description: None,
|
||||
|
||||
@@ -44,7 +44,8 @@ export const mcpServerToToml = (server: McpServerSpec): string => {
|
||||
* 将 TOML 文本转换为 McpServerSpec 对象(单个服务器配置)
|
||||
* 支持两种格式:
|
||||
* 1. 直接的服务器配置(type, command, args 等)
|
||||
* 2. [mcp.servers.<id>] 或 [mcp_servers.<id>] 格式(取第一个服务器)
|
||||
* 2. [mcp_servers.<id>] 格式(推荐,取第一个服务器)
|
||||
* 3. [mcp.servers.<id>] 错误格式(容错解析,同样取第一个服务器)
|
||||
* @param tomlText TOML 文本
|
||||
* @returns McpServer 对象
|
||||
* @throws 解析或转换失败时抛出错误
|
||||
@@ -67,7 +68,16 @@ export const tomlToMcpServer = (tomlText: string): McpServerSpec => {
|
||||
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") {
|
||||
const mcpObj = parsed.mcp as any;
|
||||
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(
|
||||
"无法识别的 TOML 格式。请提供单个 MCP 服务器配置,或使用 [mcp.servers.<id>] 格式",
|
||||
"无法识别的 TOML 格式。请提供单个 MCP 服务器配置,或使用 [mcp_servers.<id>] 格式",
|
||||
);
|
||||
};
|
||||
|
||||
@@ -189,7 +190,14 @@ export const extractIdFromToml = (tomlText: string): string => {
|
||||
try {
|
||||
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") {
|
||||
const mcpObj = parsed.mcp as any;
|
||||
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 中推断
|
||||
if (parsed.command && typeof parsed.command === "string") {
|
||||
const cmd = parsed.command.split(/[\\/]/).pop() || "";
|
||||
|
||||
Reference in New Issue
Block a user