Files
CC-Switch/src-tauri/src/mcp.rs
T
Jason 3051743bd3 feat(mcp): support extended fields for Codex TOML conversion
## Problem
Previously, the Codex MCP configuration used a whitelist-only approach
for TOML conversion, which caused custom fields (like `timeout`,
`startup_timeout_ms`, etc.) to be silently dropped during JSON → TOML
conversion. Only Claude and Gemini (JSON format) could preserve
arbitrary fields.

## Solution
Implemented a three-tier field handling strategy:

1. **Core fields** (type, command, args, url, headers, env, cwd)
   - Strong-typed manual processing (existing behavior preserved)

2. **Extended fields** (19 common optional fields)
   - White-listed fields with automatic type conversion:
     - General: timeout, timeout_ms, startup_timeout_ms/sec,
       connection_timeout, read_timeout, debug, log_level, disabled
     - stdio: shell, encoding, working_dir, restart_on_exit,
       max_restart_count
     - http/sse: retry_count, max_retry_attempts, retry_delay,
       cache_tools_list, verify_ssl, insecure, proxy

3. **Custom fields** (generic converter)
   - Automatic type inference for:
     - String → TOML String
     - Number (i64/f64) → TOML Integer/Float
     - Boolean → TOML Boolean
     - Simple arrays → TOML Array
     - Shallow objects (string values only) → TOML Inline Table
   - Unsupported types (null, mixed arrays, nested objects) are
     gracefully skipped with debug logging

## Changes

### Core Implementation
- **json_value_to_toml_item()** (mcp.rs:843-947)
  Generic JSON → TOML value converter with smart type inference

- **json_server_to_toml_table()** (mcp.rs:949-1072)
  Refactored to use three-tier strategy, processes all fields beyond
  core whitelist

- **build_servers_table()** (mcp.rs:616-633)
  Now reuses the generic converter for consistency

- **import_from_codex()** (mcp.rs:432-605)
  Extended with generic TOML → JSON converter for bidirectional
  field preservation

### Logging
- DEBUG: Extended field conversions
- INFO: Custom field conversions
- WARN: Skipped unsupported types (with reason)

## Testing
-  All 24 integration tests pass
-  Compilation clean (zero errors)
-  Backward compatible with existing configs

## Design Decision: No Auto Field Mapping
Explicitly NOT implementing automatic field mapping (e.g., Claude's
`timeout` → Codex's `startup_timeout_ms`) due to:
- Unit ambiguity (seconds vs milliseconds)
- Semantic ambiguity (same field name, different meanings)
- Risk of data corruption (30s → 30ms causes immediate timeout)
- Breaks user expectation of "what you see is what you get"

Recommendation: Use application-specific field names as documented.

## Example
User adds `timeout: 30` in MCP panel:

**Claude/Gemini** (~/.claude.json):
```json
{"mcpServers": {"srv": {"timeout": 30}}}
```

**Codex** (~/.codex/config.toml):
```toml
[mcp.servers.srv]
timeout = 30  #  Now preserved!
```

Fixes the whitelist limitation reported in user feedback.
2025-11-17 17:23:09 +08:00

1178 lines
41 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use serde_json::{json, Value};
use std::collections::HashMap;
use crate::app_config::{AppType, McpConfig, MultiAppConfig};
use crate::error::AppError;
/// 基础校验:允许 stdio/http/sse;或省略 type(视为 stdio)。对应必填字段存在
fn validate_server_spec(spec: &Value) -> Result<(), AppError> {
if !spec.is_object() {
return Err(AppError::McpValidation(
"MCP 服务器连接定义必须为 JSON 对象".into(),
));
}
let t_opt = spec.get("type").and_then(|x| x.as_str());
// 支持三种:stdio/http/sse;若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致)
let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true);
let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false);
if !(is_stdio || is_http || is_sse) {
return Err(AppError::McpValidation(
"MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'(或省略表示 stdio".into(),
));
}
if is_stdio {
let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
if cmd.trim().is_empty() {
return Err(AppError::McpValidation(
"stdio 类型的 MCP 服务器缺少 command 字段".into(),
));
}
}
if is_http {
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
if url.trim().is_empty() {
return Err(AppError::McpValidation(
"http 类型的 MCP 服务器缺少 url 字段".into(),
));
}
}
if is_sse {
let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
if url.trim().is_empty() {
return Err(AppError::McpValidation(
"sse 类型的 MCP 服务器缺少 url 字段".into(),
));
}
}
Ok(())
}
#[allow(dead_code)] // v3.7.0: 旧的验证逻辑,保留用于未来可能的迁移
fn validate_mcp_entry(entry: &Value) -> Result<(), AppError> {
let obj = entry
.as_object()
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
let server = obj
.get("server")
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?;
validate_server_spec(server)?;
for key in ["name", "description", "homepage", "docs"] {
if let Some(val) = obj.get(key) {
if !val.is_string() {
return Err(AppError::McpValidation(format!(
"MCP 服务器 {key} 必须为字符串"
)));
}
}
}
if let Some(tags) = obj.get("tags") {
let arr = tags
.as_array()
.ok_or_else(|| AppError::McpValidation("MCP 服务器 tags 必须为字符串数组".into()))?;
if !arr.iter().all(|item| item.is_string()) {
return Err(AppError::McpValidation(
"MCP 服务器 tags 必须为字符串数组".into(),
));
}
}
if let Some(enabled) = obj.get("enabled") {
if !enabled.is_boolean() {
return Err(AppError::McpValidation(
"MCP 服务器 enabled 必须为布尔值".into(),
));
}
}
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 条目 '{old_key}' 的内部 id '{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, AppError> {
let obj = entry
.as_object()
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
let server = obj
.get("server")
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?;
if !server.is_object() {
return Err(AppError::McpValidation(
"MCP 服务器 server 字段必须为 JSON 对象".into(),
));
}
Ok(server.clone())
}
/// 返回已启用的 MCP 服务器(过滤 enabled==true
fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
let mut out = HashMap::new();
for (id, entry) in cfg.servers.iter() {
let enabled = entry
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !enabled {
continue;
}
match extract_server_spec(entry) {
Ok(spec) => {
out.insert(id.clone(), spec);
}
Err(err) => {
log::warn!("跳过无效的 MCP 条目 '{id}': {err}");
}
}
}
out
}
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
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 {
log::warn!("跳过无效的 MCP 条目 '{id}': 必须为 JSON 对象");
return false;
};
obj.entry(String::from("id")).or_insert(json!(id));
match validate_mcp_entry(value) {
Ok(()) => true,
Err(err) => {
log::error!("config.json 中存在无效的 MCP 条目 '{id}': {err}");
false
}
}
});
(snapshot, normalized)
}
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
pub fn upsert_in_config_for(
config: &mut MultiAppConfig,
app: &AppType,
id: &str,
spec: Value,
) -> Result<bool, AppError> {
if id.trim().is_empty() {
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
}
normalize_servers_for(config, app);
validate_mcp_entry(&spec)?;
let mut entry_obj = spec
.as_object()
.cloned()
.ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
if let Some(existing_id) = entry_obj.get("id") {
let Some(existing_id_str) = existing_id.as_str() else {
return Err(AppError::McpValidation("MCP 服务器 id 必须为字符串".into()));
};
if existing_id_str != id {
return Err(AppError::McpValidation(format!(
"MCP 服务器条目中的 id '{existing_id_str}' 与参数 id '{id}' 不一致"
)));
}
} else {
entry_obj.insert(String::from("id"), json!(id));
}
let value = Value::Object(entry_obj);
let servers = &mut config.mcp_for_mut(app).servers;
let before = servers.get(id).cloned();
servers.insert(id.to_string(), value);
Ok(before.is_none())
}
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
pub fn delete_in_config_for(
config: &mut MultiAppConfig,
app: &AppType,
id: &str,
) -> Result<bool, AppError> {
if id.trim().is_empty() {
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
}
normalize_servers_for(config, app);
let existed = config.mcp_for_mut(app).servers.remove(id).is_some();
Ok(existed)
}
#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
/// 设置启用状态(不执行落盘或文件同步)
pub fn set_enabled_flag_for(
config: &mut MultiAppConfig,
app: &AppType,
id: &str,
enabled: bool,
) -> Result<bool, AppError> {
if id.trim().is_empty() {
return Err(AppError::InvalidInput("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(|| AppError::McpValidation("MCP 服务器定义必须为 JSON 对象".into()))?;
obj.insert("enabled".into(), json!(enabled));
*spec = Value::Object(obj);
} else {
// 若不存在则直接返回 false
return Ok(false);
}
Ok(true)
}
/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json
pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> {
let enabled = collect_enabled_servers(&config.mcp.claude);
crate::claude_mcp::set_mcp_servers_map(&enabled)
}
/// 从 ~/.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 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(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() {
// 校验:单项失败不中止,收集错误继续处理
if let Err(e) = validate_server_spec(spec) {
log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
errors.push(format!("{id}: {e}"));
continue;
}
if let Some(existing) = servers.get_mut(id) {
// 已存在:仅启用 Claude 应用
if !existing.apps.claude {
existing.apps.claude = true;
changed += 1;
log::info!("MCP 服务器 '{id}' 已启用 Claude 应用");
}
} 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 到统一结构(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 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;
for (id, entry_val) in servers_tbl.iter() {
let Some(entry_tbl) = entry_val.as_table() else {
continue;
};
// type 缺省为 stdio
let typ = entry_tbl
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("stdio");
// 构建 JSON 规范
let mut spec = serde_json::Map::new();
spec.insert("type".into(), json!(typ));
// 核心字段(需要手动处理的字段)
let core_fields = match typ {
"stdio" => vec!["type", "command", "args", "env", "cwd"],
"http" | "sse" => vec!["type", "url", "headers"],
_ => vec!["type"],
};
// 1. 处理核心字段(强类型)
match typ {
"stdio" => {
if let Some(cmd) = entry_tbl.get("command").and_then(|v| v.as_str()) {
spec.insert("command".into(), json!(cmd));
}
if let Some(args) = entry_tbl.get("args").and_then(|v| v.as_array()) {
let arr = args
.iter()
.filter_map(|x| x.as_str())
.map(|s| json!(s))
.collect::<Vec<_>>();
if !arr.is_empty() {
spec.insert("args".into(), serde_json::Value::Array(arr));
}
}
if let Some(cwd) = entry_tbl.get("cwd").and_then(|v| v.as_str()) {
if !cwd.trim().is_empty() {
spec.insert("cwd".into(), json!(cwd));
}
}
if let Some(env_tbl) = entry_tbl.get("env").and_then(|v| v.as_table()) {
let mut env_json = serde_json::Map::new();
for (k, v) in env_tbl.iter() {
if let Some(sv) = v.as_str() {
env_json.insert(k.clone(), json!(sv));
}
}
if !env_json.is_empty() {
spec.insert("env".into(), serde_json::Value::Object(env_json));
}
}
}
"http" | "sse" => {
if let Some(url) = entry_tbl.get("url").and_then(|v| v.as_str()) {
spec.insert("url".into(), json!(url));
}
if let Some(headers_tbl) = entry_tbl.get("headers").and_then(|v| v.as_table()) {
let mut headers_json = serde_json::Map::new();
for (k, v) in headers_tbl.iter() {
if let Some(sv) = v.as_str() {
headers_json.insert(k.clone(), json!(sv));
}
}
if !headers_json.is_empty() {
spec.insert("headers".into(), serde_json::Value::Object(headers_json));
}
}
}
_ => {
log::warn!("跳过未知类型 '{typ}' 的 Codex MCP 项 '{id}'");
return changed;
}
}
// 2. 处理扩展字段和其他未知字段(通用 TOML → JSON 转换)
for (key, toml_val) in entry_tbl.iter() {
// 跳过已处理的核心字段
if core_fields.contains(&key.as_str()) {
continue;
}
// 通用 TOML 值到 JSON 值转换
let json_val = match toml_val {
toml::Value::String(s) => Some(json!(s)),
toml::Value::Integer(i) => Some(json!(i)),
toml::Value::Float(f) => Some(json!(f)),
toml::Value::Boolean(b) => Some(json!(b)),
toml::Value::Array(arr) => {
// 只支持简单类型数组
let json_arr: Vec<serde_json::Value> = arr
.iter()
.filter_map(|item| match item {
toml::Value::String(s) => Some(json!(s)),
toml::Value::Integer(i) => Some(json!(i)),
toml::Value::Float(f) => Some(json!(f)),
toml::Value::Boolean(b) => Some(json!(b)),
_ => None,
})
.collect();
if !json_arr.is_empty() {
Some(serde_json::Value::Array(json_arr))
} else {
log::debug!("跳过复杂数组字段 '{}' (TOML → JSON)", key);
None
}
}
toml::Value::Table(tbl) => {
// 浅层表转为 JSON 对象(仅支持字符串值)
let mut json_obj = serde_json::Map::new();
for (k, v) in tbl.iter() {
if let Some(s) = v.as_str() {
json_obj.insert(k.clone(), json!(s));
}
}
if !json_obj.is_empty() {
Some(serde_json::Value::Object(json_obj))
} else {
log::debug!("跳过复杂对象字段 '{}' (TOML → JSON)", key);
None
}
}
toml::Value::Datetime(_) => {
log::debug!("跳过日期时间字段 '{}' (TOML → JSON)", key);
None
}
};
if let Some(val) = json_val {
spec.insert(key.clone(), val);
log::debug!("导入扩展字段 '{}' = {:?}", key, toml_val);
}
}
let spec_v = serde_json::Value::Object(spec);
// 校验:单项失败继续处理
if let Err(e) = validate_server_spec(&spec_v) {
log::warn!("跳过无效 Codex MCP 项 '{id}': {e}");
continue;
}
if let Some(existing) = servers.get_mut(id) {
// 已存在:仅启用 Codex 应用
if !existing.apps.codex {
existing.apps.codex = true;
changed += 1;
log::info!("MCP 服务器 '{id}' 已启用 Codex 应用");
}
} 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
};
// 1) 处理 mcp.servers
if let Some(mcp_val) = root.get("mcp") {
if let Some(mcp_tbl) = mcp_val.as_table() {
if let Some(servers_val) = mcp_tbl.get("servers") {
if let Some(servers_tbl) = servers_val.as_table() {
changed_total += import_servers_tbl(servers_tbl);
}
}
}
}
// 2) 处理 mcp_servers
if let Some(servers_val) = root.get("mcp_servers") {
if let Some(servers_tbl) = servers_val.as_table() {
changed_total += import_servers_tbl(servers_tbl);
}
}
Ok(changed_total)
}
/// 将 config.json 中 Codex 的 enabled==true 项以 TOML 形式写入 ~/.codex/config.toml 的 [mcp.servers]
/// 策略:
/// - 读取现有 config.toml;若语法无效则报错,不尝试覆盖
/// - 仅更新 `mcp.servers` 或 `mcp_servers` 子表,保留 `mcp` 其它键
/// - 仅写入启用项;无启用项时清理对应子表
pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
use toml_edit::{DocumentMut, Item, Table};
// 1) 收集启用项(Codex 维度)
let enabled = collect_enabled_servers(&config.mcp.codex);
// 2) 读取现有 config.toml 文本;保持无效 TOML 的错误返回(不覆盖文件)
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()
} else {
base_text
.parse::<DocumentMut>()
.map_err(|e| AppError::McpValidation(format!("解析 config.toml 失败: {e}")))?
};
enum Target {
McpServers, // 顶层 mcp_servers
McpDotServers, // mcp.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
} else {
Target::McpServers
};
// 构造目标 servers 表(稳定的键顺序)
let build_servers_table = || -> Table {
let mut servers = Table::new();
let mut ids: Vec<_> = enabled.keys().cloned().collect();
ids.sort();
for id in ids {
let spec = enabled.get(&id).expect("spec must exist");
// 复用通用转换函数(已包含扩展字段支持)
match json_server_to_toml_table(spec) {
Ok(table) => {
servers[&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");
}
}
}
}
}
// 6) 写回(仅改 TOML,不触碰 auth.json);toml_edit 会尽量保留未改区域的注释/空白/顺序
let new_text = doc.to_string();
let path = crate::codex_config::get_codex_config_path();
crate::config::write_text_file(&path, &new_text)?;
Ok(())
}
/// 将 config.json 中 enabled==true 的项投影写入 ~/.gemini/settings.json
pub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> {
let enabled = collect_enabled_servers(&config.mcp.gemini);
crate::gemini_mcp::set_mcp_servers_map(&enabled)
}
/// 从 ~/.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 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(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() {
// 校验:单项失败不中止,收集错误继续处理
if let Err(e) = validate_server_spec(spec) {
log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
errors.push(format!("{id}: {e}"));
continue;
}
if let Some(existing) = servers.get_mut(id) {
// 已存在:仅启用 Gemini 应用
if !existing.apps.gemini {
existing.apps.gemini = true;
changed += 1;
log::info!("MCP 服务器 '{id}' 已启用 Gemini 应用");
}
} 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)
}
// ============================================================================
// v3.7.0 新增:单个服务器同步和删除函数
// ============================================================================
/// 将单个 MCP 服务器同步到 Claude live 配置
pub fn sync_single_server_to_claude(
_config: &MultiAppConfig,
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
// 读取现有的 MCP 配置
let current = crate::claude_mcp::read_mcp_servers_map()?;
// 创建新的 HashMap,包含现有的所有服务器 + 当前要同步的服务器
let mut updated = current;
updated.insert(id.to_string(), server_spec.clone());
// 写回
crate::claude_mcp::set_mcp_servers_map(&updated)
}
/// 从 Claude live 配置中移除单个 MCP 服务器
pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> {
// 读取现有的 MCP 配置
let mut current = crate::claude_mcp::read_mcp_servers_map()?;
// 移除指定服务器
current.remove(id);
// 写回
crate::claude_mcp::set_mcp_servers_map(&current)
}
/// 通用 JSON 值到 TOML 值转换器(支持简单类型和浅层嵌套)
///
/// 支持的类型转换:
/// - String → TOML String
/// - Number (i64) → TOML Integer
/// - Number (f64) → TOML Float
/// - Boolean → TOML Boolean
/// - Array[简单类型] → TOML Array
/// - Object → TOML Inline Table (仅字符串值)
///
/// 不支持的类型(返回 None):
/// - null
/// - 深度嵌套对象
/// - 混合类型数组
fn json_value_to_toml_item(value: &Value, field_name: &str) -> Option<toml_edit::Item> {
use toml_edit::{Array, InlineTable, Item};
match value {
Value::String(s) => Some(toml_edit::value(s.as_str())),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
Some(toml_edit::value(i))
} else if let Some(f) = n.as_f64() {
Some(toml_edit::value(f))
} else {
log::warn!(
"跳过字段 '{field_name}': 无法转换的数字类型 {}",
n
);
None
}
}
Value::Bool(b) => Some(toml_edit::value(*b)),
Value::Array(arr) => {
// 只支持简单类型的数组(字符串、数字、布尔)
let mut toml_arr = Array::default();
let mut all_same_type = true;
for item in arr {
match item {
Value::String(s) => toml_arr.push(s.as_str()),
Value::Number(n) if n.is_i64() => toml_arr.push(n.as_i64().unwrap()),
Value::Number(n) if n.is_f64() => toml_arr.push(n.as_f64().unwrap()),
Value::Bool(b) => toml_arr.push(*b),
_ => {
all_same_type = false;
break;
}
}
}
if all_same_type && !toml_arr.is_empty() {
Some(Item::Value(toml_edit::Value::Array(toml_arr)))
} else {
log::warn!(
"跳过字段 '{field_name}': 不支持的数组类型(混合类型或嵌套结构)"
);
None
}
}
Value::Object(obj) => {
// 只支持浅层对象(所有值都是字符串)→ TOML Inline Table
let mut inline_table = InlineTable::new();
let mut all_strings = true;
for (k, v) in obj {
if let Some(s) = v.as_str() {
// InlineTable 需要 Value 类型,toml_edit::value() 返回 Item,需要提取内部的 Value
inline_table.insert(k, s.into());
} else {
all_strings = false;
break;
}
}
if all_strings && !inline_table.is_empty() {
Some(Item::Value(toml_edit::Value::InlineTable(inline_table)))
} else {
log::warn!(
"跳过字段 '{field_name}': 对象值包含非字符串类型,建议使用子表语法"
);
None
}
}
Value::Null => {
log::debug!("跳过字段 '{field_name}': TOML 不支持 null 值");
None
}
}
}
/// Helper: 将 JSON MCP 服务器规范转换为 toml_edit::Table
///
/// 策略:
/// 1. 核心字段(type, command, args, url, headers, env, cwd)使用强类型处理
/// 2. 扩展字段(timeout、retry 等)通过白名单列表自动转换
/// 3. 其他未知字段使用通用转换器尝试转换
fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError> {
use toml_edit::{Array, Item, Table};
let mut t = Table::new();
let typ = spec.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
t["type"] = toml_edit::value(typ);
// 定义核心字段(已在下方处理,跳过通用转换)
let core_fields = match typ {
"stdio" => vec!["type", "command", "args", "env", "cwd"],
"http" | "sse" => vec!["type", "url", "headers"],
_ => vec!["type"],
};
// 定义扩展字段白名单(Codex 常见可选字段)
let extended_fields = [
// 通用字段
"timeout",
"timeout_ms",
"startup_timeout_ms",
"startup_timeout_sec",
"connection_timeout",
"read_timeout",
"debug",
"log_level",
"disabled",
// stdio 特有
"shell",
"encoding",
"working_dir",
"restart_on_exit",
"max_restart_count",
// http/sse 特有
"retry_count",
"max_retry_attempts",
"retry_delay",
"cache_tools_list",
"verify_ssl",
"insecure",
"proxy",
];
// 1. 处理核心字段(强类型)
match typ {
"stdio" => {
let cmd = spec.get("command").and_then(|v| v.as_str()).unwrap_or("");
t["command"] = toml_edit::value(cmd);
if let Some(args) = spec.get("args").and_then(|v| v.as_array()) {
let mut arr_v = Array::default();
for a in args.iter().filter_map(|x| x.as_str()) {
arr_v.push(a);
}
if !arr_v.is_empty() {
t["args"] = Item::Value(toml_edit::Value::Array(arr_v));
}
}
if let Some(cwd) = spec.get("cwd").and_then(|v| v.as_str()) {
if !cwd.trim().is_empty() {
t["cwd"] = toml_edit::value(cwd);
}
}
if let Some(env) = spec.get("env").and_then(|v| v.as_object()) {
let mut env_tbl = Table::new();
for (k, v) in env.iter() {
if let Some(s) = v.as_str() {
env_tbl[&k[..]] = toml_edit::value(s);
}
}
if !env_tbl.is_empty() {
t["env"] = Item::Table(env_tbl);
}
}
}
"http" | "sse" => {
let url = spec.get("url").and_then(|v| v.as_str()).unwrap_or("");
t["url"] = toml_edit::value(url);
if let Some(headers) = spec.get("headers").and_then(|v| v.as_object()) {
let mut h_tbl = Table::new();
for (k, v) in headers.iter() {
if let Some(s) = v.as_str() {
h_tbl[&k[..]] = toml_edit::value(s);
}
}
if !h_tbl.is_empty() {
t["headers"] = Item::Table(h_tbl);
}
}
}
_ => {}
}
// 2. 处理扩展字段和其他未知字段
if let Some(obj) = spec.as_object() {
for (key, value) in obj {
// 跳过已处理的核心字段
if core_fields.contains(&key.as_str()) {
continue;
}
// 尝试使用通用转换器
if let Some(toml_item) = json_value_to_toml_item(value, key) {
t[&key[..]] = toml_item;
// 记录扩展字段的处理
if extended_fields.contains(&key.as_str()) {
log::debug!("已转换扩展字段 '{}' = {:?}", key, value);
} else {
log::info!("已转换自定义字段 '{}' = {:?}", key, value);
}
}
}
}
Ok(t)
}
/// 将单个 MCP 服务器同步到 Codex live 配置
pub fn sync_single_server_to_codex(
_config: &MultiAppConfig,
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
use toml_edit::Item;
// 读取现有的 config.toml
let config_path = crate::codex_config::get_codex_config_path();
let mut doc = if config_path.exists() {
let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
content
.parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?
} else {
toml_edit::DocumentMut::new()
};
// 确保 [mcp] 表存在
if !doc.contains_key("mcp") {
doc["mcp"] = toml_edit::table();
}
// 确保 [mcp.servers] 子表存在
if doc["mcp"]
.as_table()
.and_then(|t| t.get("servers"))
.is_none()
{
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);
// 写回文件
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
Ok(())
}
/// 从 Codex live 配置中移除单个 MCP 服务器
pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
let config_path = crate::codex_config::get_codex_config_path();
if !config_path.exists() {
return Ok(()); // 文件不存在,无需删除
}
let content =
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
let mut doc = content
.parse::<toml_edit::DocumentMut>()
.map_err(|e| AppError::McpValidation(format!("解析 Codex config.toml 失败: {e}")))?;
// 从 [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);
}
}
// 写回文件
std::fs::write(&config_path, doc.to_string()).map_err(|e| AppError::io(&config_path, e))?;
Ok(())
}
/// 将单个 MCP 服务器同步到 Gemini live 配置
pub fn sync_single_server_to_gemini(
_config: &MultiAppConfig,
id: &str,
server_spec: &Value,
) -> Result<(), AppError> {
// 读取现有的 MCP 配置
let current = crate::gemini_mcp::read_mcp_servers_map()?;
// 创建新的 HashMap,包含现有的所有服务器 + 当前要同步的服务器
let mut updated = current;
updated.insert(id.to_string(), server_spec.clone());
// 写回
crate::gemini_mcp::set_mcp_servers_map(&updated)
}
/// 从 Gemini live 配置中移除单个 MCP 服务器
pub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> {
// 读取现有的 MCP 配置
let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
// 移除指定服务器
current.remove(id);
// 写回
crate::gemini_mcp::set_mcp_servers_map(&current)
}