Skip to main content

cc_switch_lib/
mcp.rs

1use serde_json::{json, Value};
2use std::collections::HashMap;
3
4use crate::app_config::{AppType, McpConfig, MultiAppConfig};
5use crate::error::AppError;
6
7/// 基础校验:允许 stdio/http/sse;或省略 type(视为 stdio)。对应必填字段存在
8fn validate_server_spec(spec: &Value) -> Result<(), AppError> {
9    if !spec.is_object() {
10        return Err(AppError::McpValidation(
11            "MCP 服务器连接定义必须为 JSON 对象".into(),
12        ));
13    }
14    if let Some(type_val) = spec.get("type") {
15        if !type_val.is_string() {
16            return Err(AppError::McpValidation(
17                "MCP 服务器 type 必须是字符串".into(),
18            ));
19        }
20    }
21    let t_opt = spec.get("type").and_then(|x| x.as_str());
22    // 支持三种:stdio/http/sse;若缺省 type 则按 stdio 处理(与社区常见 .mcp.json 一致)
23    let is_stdio = t_opt.map(|t| t == "stdio").unwrap_or(true);
24    let is_http = t_opt.map(|t| t == "http").unwrap_or(false);
25    let is_sse = t_opt.map(|t| t == "sse").unwrap_or(false);
26
27    if !(is_stdio || is_http || is_sse) {
28        return Err(AppError::McpValidation(
29            "MCP 服务器 type 必须是 'stdio'、'http' 或 'sse'(或省略表示 stdio)".into(),
30        ));
31    }
32
33    if is_stdio {
34        let cmd = spec.get("command").and_then(|x| x.as_str()).unwrap_or("");
35        if cmd.trim().is_empty() {
36            return Err(AppError::McpValidation(
37                "stdio 类型的 MCP 服务器缺少 command 字段".into(),
38            ));
39        }
40    }
41    if is_http {
42        let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
43        if url.trim().is_empty() {
44            return Err(AppError::McpValidation(
45                "http 类型的 MCP 服务器缺少 url 字段".into(),
46            ));
47        }
48    }
49    if is_sse {
50        let url = spec.get("url").and_then(|x| x.as_str()).unwrap_or("");
51        if url.trim().is_empty() {
52            return Err(AppError::McpValidation(
53                "sse 类型的 MCP 服务器缺少 url 字段".into(),
54            ));
55        }
56    }
57    Ok(())
58}
59
60#[allow(dead_code)] // v3.7.0: 旧的验证逻辑,保留用于未来可能的迁移
61fn validate_mcp_entry(entry: &Value) -> Result<(), AppError> {
62    let obj = entry
63        .as_object()
64        .ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
65
66    let server = obj
67        .get("server")
68        .ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?;
69    validate_server_spec(server)?;
70
71    for key in ["name", "description", "homepage", "docs"] {
72        if let Some(val) = obj.get(key) {
73            if !val.is_string() {
74                return Err(AppError::McpValidation(format!(
75                    "MCP 服务器 {key} 必须为字符串"
76                )));
77            }
78        }
79    }
80
81    if let Some(tags) = obj.get("tags") {
82        let arr = tags
83            .as_array()
84            .ok_or_else(|| AppError::McpValidation("MCP 服务器 tags 必须为字符串数组".into()))?;
85        if !arr.iter().all(|item| item.is_string()) {
86            return Err(AppError::McpValidation(
87                "MCP 服务器 tags 必须为字符串数组".into(),
88            ));
89        }
90    }
91
92    if let Some(enabled) = obj.get("enabled") {
93        if !enabled.is_boolean() {
94            return Err(AppError::McpValidation(
95                "MCP 服务器 enabled 必须为布尔值".into(),
96            ));
97        }
98    }
99
100    Ok(())
101}
102
103fn normalize_server_keys(map: &mut HashMap<String, Value>) -> usize {
104    let mut change_count = 0usize;
105    let mut renames: Vec<(String, String)> = Vec::new();
106
107    for (key_ref, value) in map.iter_mut() {
108        let key = key_ref.clone();
109        let Some(obj) = value.as_object_mut() else {
110            continue;
111        };
112
113        let id_value = obj.get("id").cloned();
114
115        let target_id: String;
116
117        match id_value {
118            Some(id_val) => match id_val.as_str() {
119                Some(id_str) => {
120                    let trimmed = id_str.trim();
121                    if trimmed.is_empty() {
122                        obj.insert("id".into(), json!(key.clone()));
123                        change_count += 1;
124                        target_id = key.clone();
125                    } else {
126                        if trimmed != id_str {
127                            obj.insert("id".into(), json!(trimmed));
128                            change_count += 1;
129                        }
130                        target_id = trimmed.to_string();
131                    }
132                }
133                None => {
134                    obj.insert("id".into(), json!(key.clone()));
135                    change_count += 1;
136                    target_id = key.clone();
137                }
138            },
139            None => {
140                obj.insert("id".into(), json!(key.clone()));
141                change_count += 1;
142                target_id = key.clone();
143            }
144        }
145
146        if target_id != key {
147            renames.push((key, target_id));
148        }
149    }
150
151    for (old_key, new_key) in renames {
152        if old_key == new_key {
153            continue;
154        }
155        if map.contains_key(&new_key) {
156            log::warn!("MCP 条目 '{old_key}' 的内部 id '{new_key}' 与现有键冲突,回退为原键");
157            if let Some(value) = map.get_mut(&old_key) {
158                if let Some(obj) = value.as_object_mut() {
159                    if obj
160                        .get("id")
161                        .and_then(|v| v.as_str())
162                        .map(|s| s != old_key)
163                        .unwrap_or(true)
164                    {
165                        obj.insert("id".into(), json!(old_key.clone()));
166                        change_count += 1;
167                    }
168                }
169            }
170            continue;
171        }
172        if let Some(mut value) = map.remove(&old_key) {
173            if let Some(obj) = value.as_object_mut() {
174                obj.insert("id".into(), json!(new_key.clone()));
175            }
176            log::info!("MCP 条目键名已自动修复: '{old_key}' -> '{new_key}'");
177            map.insert(new_key, value);
178            change_count += 1;
179        }
180    }
181
182    change_count
183}
184
185pub fn normalize_servers_for(config: &mut MultiAppConfig, app: &AppType) -> usize {
186    let servers = &mut config.mcp_for_mut(app).servers;
187    normalize_server_keys(servers)
188}
189
190fn extract_server_spec(entry: &Value) -> Result<Value, AppError> {
191    let obj = entry
192        .as_object()
193        .ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
194    let server = obj
195        .get("server")
196        .ok_or_else(|| AppError::McpValidation("MCP 服务器条目缺少 server 字段".into()))?;
197
198    if !server.is_object() {
199        return Err(AppError::McpValidation(
200            "MCP 服务器 server 字段必须为 JSON 对象".into(),
201        ));
202    }
203
204    Ok(server.clone())
205}
206
207/// 返回已启用的 MCP 服务器(过滤 enabled==true)
208fn collect_enabled_servers(cfg: &McpConfig) -> HashMap<String, Value> {
209    let mut out = HashMap::new();
210    for (id, entry) in cfg.servers.iter() {
211        let enabled = entry
212            .get("enabled")
213            .and_then(|v| v.as_bool())
214            .unwrap_or(false);
215        if !enabled {
216            continue;
217        }
218        match extract_server_spec(entry) {
219            Ok(spec) => {
220                out.insert(id.clone(), spec);
221            }
222            Err(err) => {
223                log::warn!("跳过无效的 MCP 条目 '{id}': {err}");
224            }
225        }
226    }
227    out
228}
229
230#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
231pub fn get_servers_snapshot_for(
232    config: &mut MultiAppConfig,
233    app: &AppType,
234) -> (HashMap<String, Value>, usize) {
235    let normalized = normalize_servers_for(config, app);
236    let mut snapshot = config.mcp_for(app).servers.clone();
237    snapshot.retain(|id, value| {
238        let Some(obj) = value.as_object_mut() else {
239            log::warn!("跳过无效的 MCP 条目 '{id}': 必须为 JSON 对象");
240            return false;
241        };
242
243        obj.entry(String::from("id")).or_insert(json!(id));
244
245        match validate_mcp_entry(value) {
246            Ok(()) => true,
247            Err(err) => {
248                log::error!("config.json 中存在无效的 MCP 条目 '{id}': {err}");
249                false
250            }
251        }
252    });
253    (snapshot, normalized)
254}
255
256#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
257pub fn upsert_in_config_for(
258    config: &mut MultiAppConfig,
259    app: &AppType,
260    id: &str,
261    spec: Value,
262) -> Result<bool, AppError> {
263    if id.trim().is_empty() {
264        return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
265    }
266    normalize_servers_for(config, app);
267    validate_mcp_entry(&spec)?;
268
269    let mut entry_obj = spec
270        .as_object()
271        .cloned()
272        .ok_or_else(|| AppError::McpValidation("MCP 服务器条目必须为 JSON 对象".into()))?;
273    if let Some(existing_id) = entry_obj.get("id") {
274        let Some(existing_id_str) = existing_id.as_str() else {
275            return Err(AppError::McpValidation("MCP 服务器 id 必须为字符串".into()));
276        };
277        if existing_id_str != id {
278            return Err(AppError::McpValidation(format!(
279                "MCP 服务器条目中的 id '{existing_id_str}' 与参数 id '{id}' 不一致"
280            )));
281        }
282    } else {
283        entry_obj.insert(String::from("id"), json!(id));
284    }
285
286    let value = Value::Object(entry_obj);
287
288    let servers = &mut config.mcp_for_mut(app).servers;
289    let before = servers.get(id).cloned();
290    servers.insert(id.to_string(), value);
291
292    Ok(before.is_none())
293}
294
295#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
296pub fn delete_in_config_for(
297    config: &mut MultiAppConfig,
298    app: &AppType,
299    id: &str,
300) -> Result<bool, AppError> {
301    if id.trim().is_empty() {
302        return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
303    }
304    normalize_servers_for(config, app);
305    let existed = config.mcp_for_mut(app).servers.remove(id).is_some();
306    Ok(existed)
307}
308
309#[allow(dead_code)] // v3.7.0: 旧的分应用 API,保留用于未来可能的迁移
310/// 设置启用状态(不执行落盘或文件同步)
311pub fn set_enabled_flag_for(
312    config: &mut MultiAppConfig,
313    app: &AppType,
314    id: &str,
315    enabled: bool,
316) -> Result<bool, AppError> {
317    if id.trim().is_empty() {
318        return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
319    }
320    normalize_servers_for(config, app);
321    if let Some(spec) = config.mcp_for_mut(app).servers.get_mut(id) {
322        // 写入 enabled 字段
323        let mut obj = spec
324            .as_object()
325            .cloned()
326            .ok_or_else(|| AppError::McpValidation("MCP 服务器定义必须为 JSON 对象".into()))?;
327        obj.insert("enabled".into(), json!(enabled));
328        *spec = Value::Object(obj);
329    } else {
330        // 若不存在则直接返回 false
331        return Ok(false);
332    }
333
334    Ok(true)
335}
336
337/// 将 config.json 中 enabled==true 的项投影写入 ~/.claude.json
338pub fn sync_enabled_to_claude(config: &MultiAppConfig) -> Result<(), AppError> {
339    if !crate::sync_policy::should_sync_live(&AppType::Claude) {
340        return Ok(());
341    }
342    let enabled = collect_enabled_servers(&config.mcp.claude);
343    crate::claude_mcp::set_mcp_servers_map(&enabled)
344}
345
346/// 从 ~/.claude.json 导入 mcpServers 到统一结构(v3.7.0+)
347/// 已存在的服务器将启用 Claude 应用,不覆盖其他字段和应用状态
348pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError> {
349    use crate::app_config::{McpApps, McpServer};
350
351    let text_opt = crate::claude_mcp::read_mcp_json()?;
352    let Some(text) = text_opt else { return Ok(0) };
353
354    let v: Value = serde_json::from_str(&text)
355        .map_err(|e| AppError::McpValidation(format!("解析 ~/.claude.json 失败: {e}")))?;
356    let Some(map) = v.get("mcpServers").and_then(|x| x.as_object()) else {
357        return Ok(0);
358    };
359
360    // 确保新结构存在
361    if config.mcp.servers.is_none() {
362        config.mcp.servers = Some(HashMap::new());
363    }
364    let servers = config.mcp.servers.as_mut().unwrap();
365
366    let mut changed = 0;
367    let mut errors = Vec::new();
368
369    for (id, spec) in map.iter() {
370        // 校验:单项失败不中止,收集错误继续处理
371        if let Err(e) = validate_server_spec(spec) {
372            log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
373            errors.push(format!("{id}: {e}"));
374            continue;
375        }
376
377        if let Some(existing) = servers.get_mut(id.as_str()) {
378            // 已存在:仅启用 Claude 应用
379            if !existing.apps.claude {
380                existing.apps.claude = true;
381                changed += 1;
382                log::info!("MCP 服务器 '{id}' 已启用 Claude 应用");
383            }
384        } else {
385            // 新建服务器:默认仅启用 Claude
386            servers.insert(
387                id.clone(),
388                McpServer {
389                    id: id.clone(),
390                    name: id.clone(),
391                    server: spec.clone(),
392                    apps: McpApps {
393                        claude: true,
394                        codex: false,
395                        gemini: false,
396                        opencode: false,
397                        openclaw: false,
398                        hermes: false,
399                    },
400                    description: None,
401                    homepage: None,
402                    docs: None,
403                    tags: Vec::new(),
404                },
405            );
406            changed += 1;
407            log::info!("导入新 MCP 服务器 '{id}'");
408        }
409    }
410
411    if !errors.is_empty() {
412        log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
413    }
414
415    Ok(changed)
416}
417
418/// 从 ~/.codex/config.toml 导入 MCP 到统一结构(v3.7.0+)
419///
420/// 格式支持:
421/// - 正确格式:[mcp_servers.*](Codex 官方标准)
422/// - 错误格式:[mcp.servers.*](容错读取,用于迁移错误写入的配置)
423///
424/// 已存在的服务器将启用 Codex 应用,不覆盖其他字段和应用状态
425pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError> {
426    use crate::app_config::{McpApps, McpServer};
427
428    let text = crate::codex_config::read_and_validate_codex_config_text()?;
429    if text.trim().is_empty() {
430        return Ok(0);
431    }
432
433    let root: toml::Table = toml::from_str(&text)
434        .map_err(|e| AppError::McpValidation(format!("解析 ~/.codex/config.toml 失败: {e}")))?;
435
436    // 确保新结构存在
437    if config.mcp.servers.is_none() {
438        config.mcp.servers = Some(HashMap::new());
439    }
440    let servers = config.mcp.servers.as_mut().unwrap();
441
442    let mut changed_total = 0usize;
443
444    // helper:处理一组 servers 表
445    let mut import_servers_tbl = |servers_tbl: &toml::value::Table| {
446        let mut changed = 0usize;
447        for (id, entry_val) in servers_tbl.iter() {
448            let Some(entry_tbl) = entry_val.as_table() else {
449                continue;
450            };
451
452            let toml_to_json = |toml_val: &toml::Value| -> Option<serde_json::Value> {
453                match toml_val {
454                    toml::Value::String(s) => Some(json!(s)),
455                    toml::Value::Integer(i) => Some(json!(i)),
456                    toml::Value::Float(f) => Some(json!(f)),
457                    toml::Value::Boolean(b) => Some(json!(b)),
458                    toml::Value::Array(arr) => {
459                        let json_arr: Vec<serde_json::Value> = arr
460                            .iter()
461                            .filter_map(|item| match item {
462                                toml::Value::String(s) => Some(json!(s)),
463                                toml::Value::Integer(i) => Some(json!(i)),
464                                toml::Value::Float(f) => Some(json!(f)),
465                                toml::Value::Boolean(b) => Some(json!(b)),
466                                _ => None,
467                            })
468                            .collect();
469                        if json_arr.is_empty() {
470                            None
471                        } else {
472                            Some(serde_json::Value::Array(json_arr))
473                        }
474                    }
475                    toml::Value::Table(tbl) => {
476                        let mut json_obj = serde_json::Map::new();
477                        for (k, v) in tbl.iter() {
478                            if let Some(s) = v.as_str() {
479                                json_obj.insert(k.clone(), json!(s));
480                            }
481                        }
482                        if json_obj.is_empty() {
483                            None
484                        } else {
485                            Some(serde_json::Value::Object(json_obj))
486                        }
487                    }
488                    toml::Value::Datetime(_) => None,
489                }
490            };
491
492            // Codex 的远程 MCP 可以只写 `url`,不显式提供 `type`。
493            // 仅在 `type` 真正缺失时才推断为 HTTP,避免掩盖显式但非法的配置。
494            let typ = if entry_tbl.contains_key("type") {
495                entry_tbl.get("type").and_then(|v| v.as_str())
496            } else {
497                entry_tbl
498                    .get("url")
499                    .and_then(|v| v.as_str())
500                    .filter(|url| !url.trim().is_empty())
501                    .map(|_| "http")
502                    .or(Some("stdio"))
503            };
504
505            // 构建 JSON 规范
506            let mut spec = serde_json::Map::new();
507            if let Some(typ) = typ {
508                spec.insert("type".into(), json!(typ));
509            } else if let Some(type_val) = entry_tbl.get("type").and_then(toml_to_json) {
510                spec.insert("type".into(), type_val);
511            }
512
513            // 核心字段(需要手动处理的字段)
514            let core_fields = match typ {
515                Some("stdio") => vec!["type", "command", "args", "env", "cwd"],
516                Some("http") | Some("sse") => vec!["type", "url", "http_headers"],
517                _ => vec!["type"],
518            };
519
520            // 1. 处理核心字段(强类型)
521            match typ {
522                Some("stdio") => {
523                    if let Some(cmd) = entry_tbl.get("command").and_then(|v| v.as_str()) {
524                        spec.insert("command".into(), json!(cmd));
525                    }
526                    if let Some(args) = entry_tbl.get("args").and_then(|v| v.as_array()) {
527                        let arr = args
528                            .iter()
529                            .filter_map(|x| x.as_str())
530                            .map(|s| json!(s))
531                            .collect::<Vec<_>>();
532                        if !arr.is_empty() {
533                            spec.insert("args".into(), serde_json::Value::Array(arr));
534                        }
535                    }
536                    if let Some(cwd) = entry_tbl.get("cwd").and_then(|v| v.as_str()) {
537                        if !cwd.trim().is_empty() {
538                            spec.insert("cwd".into(), json!(cwd));
539                        }
540                    }
541                    if let Some(env_tbl) = entry_tbl.get("env").and_then(|v| v.as_table()) {
542                        let mut env_json = serde_json::Map::new();
543                        for (k, v) in env_tbl.iter() {
544                            if let Some(sv) = v.as_str() {
545                                env_json.insert(k.clone(), json!(sv));
546                            }
547                        }
548                        if !env_json.is_empty() {
549                            spec.insert("env".into(), serde_json::Value::Object(env_json));
550                        }
551                    }
552                }
553                Some("http") | Some("sse") => {
554                    if let Some(url) = entry_tbl.get("url").and_then(|v| v.as_str()) {
555                        spec.insert("url".into(), json!(url));
556                    }
557                    // Read from http_headers (correct Codex format) or headers (legacy) with priority to http_headers
558                    let headers_tbl = entry_tbl
559                        .get("http_headers")
560                        .and_then(|v| v.as_table())
561                        .or_else(|| entry_tbl.get("headers").and_then(|v| v.as_table()));
562
563                    if let Some(headers_tbl) = headers_tbl {
564                        let mut headers_json = serde_json::Map::new();
565                        for (k, v) in headers_tbl.iter() {
566                            if let Some(sv) = v.as_str() {
567                                headers_json.insert(k.clone(), json!(sv));
568                            }
569                        }
570                        if !headers_json.is_empty() {
571                            spec.insert("headers".into(), serde_json::Value::Object(headers_json));
572                        }
573                    }
574                }
575                _ => {}
576            }
577
578            // 2. 处理扩展字段和其他未知字段(通用 TOML → JSON 转换)
579            for (key, toml_val) in entry_tbl.iter() {
580                // 跳过已处理的核心字段
581                if core_fields.contains(&key.as_str()) {
582                    continue;
583                }
584
585                // 通用 TOML 值到 JSON 值转换
586                let json_val = toml_to_json(toml_val);
587
588                if let Some(val) = json_val {
589                    spec.insert(key.clone(), val);
590                    log::debug!("导入扩展字段 '{key}' = {toml_val:?}");
591                } else {
592                    log::debug!("跳过复杂字段 '{key}' (TOML → JSON)");
593                }
594            }
595
596            let spec_v = serde_json::Value::Object(spec);
597
598            // 校验:单项失败继续处理
599            if let Err(e) = validate_server_spec(&spec_v) {
600                log::warn!("跳过无效 Codex MCP 项 '{id}': {e}");
601                continue;
602            }
603
604            if let Some(existing) = servers.get_mut(id) {
605                // 已存在:仅启用 Codex 应用
606                if !existing.apps.codex {
607                    existing.apps.codex = true;
608                    changed += 1;
609                    log::info!("MCP 服务器 '{id}' 已启用 Codex 应用");
610                }
611            } else {
612                // 新建服务器:默认仅启用 Codex
613                servers.insert(
614                    id.clone(),
615                    McpServer {
616                        id: id.clone(),
617                        name: id.clone(),
618                        server: spec_v,
619                        apps: McpApps {
620                            claude: false,
621                            codex: true,
622                            gemini: false,
623                            opencode: false,
624                            openclaw: false,
625                            hermes: false,
626                        },
627                        description: None,
628                        homepage: None,
629                        docs: None,
630                        tags: Vec::new(),
631                    },
632                );
633                changed += 1;
634                log::info!("导入新 MCP 服务器 '{id}'");
635            }
636        }
637        changed
638    };
639
640    // 1) 处理 mcp.servers
641    if let Some(mcp_val) = root.get("mcp") {
642        if let Some(mcp_tbl) = mcp_val.as_table() {
643            if let Some(servers_val) = mcp_tbl.get("servers") {
644                if let Some(servers_tbl) = servers_val.as_table() {
645                    changed_total += import_servers_tbl(servers_tbl);
646                }
647            }
648        }
649    }
650
651    // 2) 处理 mcp_servers
652    if let Some(servers_val) = root.get("mcp_servers") {
653        if let Some(servers_tbl) = servers_val.as_table() {
654            changed_total += import_servers_tbl(servers_tbl);
655        }
656    }
657
658    Ok(changed_total)
659}
660
661/// 将 config.json 中 Codex 的 enabled==true 项以 TOML 形式写入 ~/.codex/config.toml
662///
663/// 格式策略:
664/// - 唯一正确格式:[mcp_servers] 顶层表(Codex 官方标准)
665/// - 自动清理错误格式:[mcp.servers](如果存在)
666/// - 读取现有 config.toml;若语法无效则报错,不尝试覆盖
667/// - 仅更新 `mcp_servers` 表,保留其它键
668/// - 仅写入启用项;无启用项时清理 mcp_servers 表
669pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
670    if !crate::sync_policy::should_sync_live(&AppType::Codex) {
671        return Ok(());
672    }
673    use toml_edit::{Item, Table};
674
675    // 1) 收集启用项(Codex 维度)
676    let enabled = collect_enabled_servers(&config.mcp.codex);
677
678    // 2) 读取现有 config.toml 文本;保持无效 TOML 的错误返回(不覆盖文件)
679    let base_text = crate::codex_config::read_and_validate_codex_config_text()?;
680
681    // 3) 使用 toml_edit 解析(允许空文件)
682    let mut doc = if base_text.trim().is_empty() {
683        toml_edit::DocumentMut::default()
684    } else {
685        base_text
686            .parse::<toml_edit::DocumentMut>()
687            .map_err(|e| AppError::McpValidation(format!("解析 config.toml 失败: {e}")))?
688    };
689
690    // 4) 清理可能存在的错误格式 [mcp.servers]
691    if let Some(mcp_item) = doc.get_mut("mcp") {
692        if let Some(tbl) = mcp_item.as_table_like_mut() {
693            if tbl.contains_key("servers") {
694                log::warn!("检测到错误的 MCP 格式 [mcp.servers],正在清理并迁移到 [mcp_servers]");
695                tbl.remove("servers");
696            }
697        }
698    }
699
700    // 5) 构造目标 servers 表(稳定的键顺序)
701    if enabled.is_empty() {
702        // 无启用项:移除 mcp_servers 表
703        doc.as_table_mut().remove("mcp_servers");
704    } else {
705        // 构建 servers 表
706        let mut servers_tbl = Table::new();
707        let mut ids: Vec<_> = enabled.keys().cloned().collect();
708        ids.sort();
709        for id in ids {
710            let spec = enabled.get(&id).expect("spec must exist");
711            // 复用通用转换函数(已包含扩展字段支持)
712            match json_server_to_toml_table(spec) {
713                Ok(table) => {
714                    servers_tbl[&id[..]] = Item::Table(table);
715                }
716                Err(err) => {
717                    log::error!("跳过无效的 MCP 服务器 '{id}': {err}");
718                }
719            }
720        }
721        // 使用唯一正确的格式:[mcp_servers]
722        doc["mcp_servers"] = Item::Table(servers_tbl);
723    }
724
725    // 6) 写回(仅改 TOML,不触碰 auth.json);toml_edit 会尽量保留未改区域的注释/空白/顺序
726    let new_text = doc.to_string();
727    let path = crate::codex_config::get_codex_config_path();
728    crate::config::write_text_file(&path, &new_text)?;
729    Ok(())
730}
731
732/// 将 config.json 中 enabled==true 的项投影写入 ~/.gemini/settings.json
733pub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> {
734    if !crate::sync_policy::should_sync_live(&AppType::Gemini) {
735        return Ok(());
736    }
737    let enabled = collect_enabled_servers(&config.mcp.gemini);
738    crate::gemini_mcp::set_mcp_servers_map(&enabled)
739}
740
741/// 从 ~/.gemini/settings.json 导入 mcpServers 到统一结构(v3.7.0+)
742/// 已存在的服务器将启用 Gemini 应用,不覆盖其他字段和应用状态
743pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError> {
744    use crate::app_config::{McpApps, McpServer};
745
746    let map = crate::gemini_mcp::read_mcp_servers_map()?;
747    if map.is_empty() {
748        return Ok(0);
749    }
750
751    // 确保新结构存在
752    if config.mcp.servers.is_none() {
753        config.mcp.servers = Some(HashMap::new());
754    }
755    let servers = config.mcp.servers.as_mut().unwrap();
756
757    let mut changed = 0;
758    let mut errors = Vec::new();
759
760    for (id, spec) in map.iter() {
761        // 校验:单项失败不中止,收集错误继续处理
762        if let Err(e) = validate_server_spec(spec) {
763            log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
764            errors.push(format!("{id}: {e}"));
765            continue;
766        }
767
768        if let Some(existing) = servers.get_mut(id) {
769            // 已存在:仅启用 Gemini 应用
770            if !existing.apps.gemini {
771                existing.apps.gemini = true;
772                changed += 1;
773                log::info!("MCP 服务器 '{id}' 已启用 Gemini 应用");
774            }
775        } else {
776            // 新建服务器:默认仅启用 Gemini
777            servers.insert(
778                id.clone(),
779                McpServer {
780                    id: id.clone(),
781                    name: id.clone(),
782                    server: spec.clone(),
783                    apps: McpApps {
784                        claude: false,
785                        codex: false,
786                        gemini: true,
787                        opencode: false,
788                        openclaw: false,
789                        hermes: false,
790                    },
791                    description: None,
792                    homepage: None,
793                    docs: None,
794                    tags: Vec::new(),
795                },
796            );
797            changed += 1;
798            log::info!("导入新 MCP 服务器 '{id}'");
799        }
800    }
801
802    if !errors.is_empty() {
803        log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
804    }
805
806    Ok(changed)
807}
808
809/// OpenCode MCP: CC Switch 统一格式 → OpenCode 格式
810fn convert_to_opencode_mcp_spec(spec: &Value) -> Result<Value, AppError> {
811    let obj = spec
812        .as_object()
813        .ok_or_else(|| AppError::McpValidation("MCP spec must be a JSON object".into()))?;
814
815    let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
816    let mut result = serde_json::Map::new();
817
818    match typ {
819        "stdio" => {
820            result.insert("type".into(), json!("local"));
821
822            let cmd = obj.get("command").and_then(|v| v.as_str()).unwrap_or("");
823            let mut command = vec![json!(cmd)];
824            if let Some(args) = obj.get("args").and_then(|v| v.as_array()) {
825                command.extend(args.iter().cloned());
826            }
827            result.insert("command".into(), Value::Array(command));
828
829            if let Some(env) = obj.get("env") {
830                if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
831                    result.insert("environment".into(), env.clone());
832                }
833            }
834            result.insert("enabled".into(), json!(true));
835        }
836        "sse" | "http" => {
837            result.insert("type".into(), json!("remote"));
838            if let Some(url) = obj.get("url") {
839                result.insert("url".into(), url.clone());
840            }
841            if let Some(headers) = obj.get("headers") {
842                if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
843                {
844                    result.insert("headers".into(), headers.clone());
845                }
846            }
847            result.insert("enabled".into(), json!(true));
848        }
849        other => {
850            return Err(AppError::McpValidation(format!(
851                "Unknown MCP type: {other}"
852            )));
853        }
854    }
855
856    Ok(Value::Object(result))
857}
858
859/// OpenCode MCP: OpenCode 格式 → CC Switch 统一格式
860fn convert_from_opencode_mcp_spec(spec: &Value) -> Result<Value, AppError> {
861    let obj = spec
862        .as_object()
863        .ok_or_else(|| AppError::McpValidation("OpenCode MCP spec must be a JSON object".into()))?;
864
865    let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("local");
866    let mut result = serde_json::Map::new();
867
868    match typ {
869        "local" => {
870            result.insert("type".into(), json!("stdio"));
871            if let Some(command) = obj.get("command").and_then(|v| v.as_array()) {
872                if let Some(cmd) = command.first().and_then(|v| v.as_str()) {
873                    result.insert("command".into(), json!(cmd));
874                }
875                if command.len() > 1 {
876                    result.insert("args".into(), Value::Array(command[1..].to_vec()));
877                }
878            }
879            if let Some(env) = obj.get("environment") {
880                if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
881                    result.insert("env".into(), env.clone());
882                }
883            }
884        }
885        "remote" => {
886            result.insert("type".into(), json!("sse"));
887            if let Some(url) = obj.get("url") {
888                result.insert("url".into(), url.clone());
889            }
890            if let Some(headers) = obj.get("headers") {
891                if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
892                {
893                    result.insert("headers".into(), headers.clone());
894                }
895            }
896        }
897        other => {
898            return Err(AppError::McpValidation(format!(
899                "Unknown OpenCode MCP type: {other}"
900            )));
901        }
902    }
903
904    Ok(Value::Object(result))
905}
906
907/// 从 ~/.config/opencode/opencode.json 导入 MCP 到统一结构
908pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppError> {
909    use crate::app_config::{McpApps, McpServer};
910
911    let map = crate::opencode_config::get_mcp_servers()?;
912    if map.is_empty() {
913        return Ok(0);
914    }
915
916    if config.mcp.servers.is_none() {
917        config.mcp.servers = Some(HashMap::new());
918    }
919    let servers = config.mcp.servers.as_mut().unwrap();
920
921    let mut changed = 0;
922    let mut errors = Vec::new();
923
924    for (id, spec) in map.iter() {
925        let unified = match convert_from_opencode_mcp_spec(spec) {
926            Ok(spec) => spec,
927            Err(err) => {
928                log::warn!("跳过无效 OpenCode MCP 服务器 '{id}': {err}");
929                errors.push(format!("{id}: {err}"));
930                continue;
931            }
932        };
933
934        if let Err(err) = validate_server_spec(&unified) {
935            log::warn!("跳过无效 MCP 服务器 '{id}': {err}");
936            errors.push(format!("{id}: {err}"));
937            continue;
938        }
939
940        if let Some(existing) = servers.get_mut(id) {
941            if !existing.apps.opencode {
942                existing.apps.opencode = true;
943                changed += 1;
944                log::info!("MCP 服务器 '{id}' 已启用 OpenCode 应用");
945            }
946        } else {
947            servers.insert(
948                id.clone(),
949                McpServer {
950                    id: id.clone(),
951                    name: id.clone(),
952                    server: unified,
953                    apps: McpApps {
954                        claude: false,
955                        codex: false,
956                        gemini: false,
957                        opencode: true,
958                        openclaw: false,
959                        hermes: false,
960                    },
961                    description: None,
962                    homepage: None,
963                    docs: None,
964                    tags: Vec::new(),
965                },
966            );
967            changed += 1;
968            log::info!("导入新 OpenCode MCP 服务器 '{id}'");
969        }
970    }
971
972    if !errors.is_empty() {
973        log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
974    }
975
976    Ok(changed)
977}
978
979// ============================================================================
980// v3.7.0 新增:单个服务器同步和删除函数
981// ============================================================================
982
983/// 将单个 MCP 服务器同步到 Claude live 配置
984pub fn sync_single_server_to_claude(
985    _config: &MultiAppConfig,
986    id: &str,
987    server_spec: &Value,
988) -> Result<(), AppError> {
989    if !crate::sync_policy::should_sync_live(&AppType::Claude) {
990        return Ok(());
991    }
992    // 读取现有的 MCP 配置
993    let current = crate::claude_mcp::read_mcp_servers_map()?;
994
995    // 创建新的 HashMap,包含现有的所有服务器 + 当前要同步的服务器
996    let mut updated = current;
997    updated.insert(id.to_string(), server_spec.clone());
998
999    // 写回
1000    crate::claude_mcp::set_mcp_servers_map(&updated)
1001}
1002
1003/// 从 Claude live 配置中移除单个 MCP 服务器
1004pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> {
1005    if !crate::sync_policy::should_sync_live(&AppType::Claude) {
1006        return Ok(());
1007    }
1008    // 读取现有的 MCP 配置
1009    let mut current = crate::claude_mcp::read_mcp_servers_map()?;
1010
1011    // 移除指定服务器
1012    current.remove(id);
1013
1014    // 写回
1015    crate::claude_mcp::set_mcp_servers_map(&current)
1016}
1017
1018/// 通用 JSON 值到 TOML 值转换器(支持简单类型和浅层嵌套)
1019///
1020/// 支持的类型转换:
1021/// - String → TOML String
1022/// - Number (i64) → TOML Integer
1023/// - Number (f64) → TOML Float
1024/// - Boolean → TOML Boolean
1025/// - Array[简单类型] → TOML Array
1026/// - Object → TOML Inline Table (仅字符串值)
1027///
1028/// 不支持的类型(返回 None):
1029/// - null
1030/// - 深度嵌套对象
1031/// - 混合类型数组
1032fn json_value_to_toml_item(value: &Value, field_name: &str) -> Option<toml_edit::Item> {
1033    use toml_edit::{Array, InlineTable, Item};
1034
1035    match value {
1036        Value::String(s) => Some(toml_edit::value(s.as_str())),
1037
1038        Value::Number(n) => {
1039            if let Some(i) = n.as_i64() {
1040                Some(toml_edit::value(i))
1041            } else if let Some(f) = n.as_f64() {
1042                Some(toml_edit::value(f))
1043            } else {
1044                log::warn!("跳过字段 '{field_name}': 无法转换的数字类型 {n}");
1045                None
1046            }
1047        }
1048
1049        Value::Bool(b) => Some(toml_edit::value(*b)),
1050
1051        Value::Array(arr) => {
1052            // 只支持简单类型的数组(字符串、数字、布尔)
1053            let mut toml_arr = Array::default();
1054            let mut all_same_type = true;
1055
1056            for item in arr {
1057                match item {
1058                    Value::String(s) => toml_arr.push(s.as_str()),
1059                    Value::Number(n) if n.is_i64() => toml_arr.push(n.as_i64().unwrap()),
1060                    Value::Number(n) if n.is_f64() => toml_arr.push(n.as_f64().unwrap()),
1061                    Value::Bool(b) => toml_arr.push(*b),
1062                    _ => {
1063                        all_same_type = false;
1064                        break;
1065                    }
1066                }
1067            }
1068
1069            if all_same_type && !toml_arr.is_empty() {
1070                Some(Item::Value(toml_edit::Value::Array(toml_arr)))
1071            } else {
1072                log::warn!("跳过字段 '{field_name}': 不支持的数组类型(混合类型或嵌套结构)");
1073                None
1074            }
1075        }
1076
1077        Value::Object(obj) => {
1078            // 只支持浅层对象(所有值都是字符串)→ TOML Inline Table
1079            let mut inline_table = InlineTable::new();
1080            let mut all_strings = true;
1081
1082            for (k, v) in obj {
1083                if let Some(s) = v.as_str() {
1084                    // InlineTable 需要 Value 类型,toml_edit::value() 返回 Item,需要提取内部的 Value
1085                    inline_table.insert(k, s.into());
1086                } else {
1087                    all_strings = false;
1088                    break;
1089                }
1090            }
1091
1092            if all_strings && !inline_table.is_empty() {
1093                Some(Item::Value(toml_edit::Value::InlineTable(inline_table)))
1094            } else {
1095                log::warn!("跳过字段 '{field_name}': 对象值包含非字符串类型,建议使用子表语法");
1096                None
1097            }
1098        }
1099
1100        Value::Null => {
1101            log::debug!("跳过字段 '{field_name}': TOML 不支持 null 值");
1102            None
1103        }
1104    }
1105}
1106
1107/// Helper: 将 JSON MCP 服务器规范转换为 toml_edit::Table
1108///
1109/// 策略:
1110/// 1. 核心字段(type, command, args, url, headers, env, cwd)使用强类型处理
1111/// 2. 扩展字段(timeout、retry 等)通过白名单列表自动转换
1112/// 3. 其他未知字段使用通用转换器尝试转换
1113fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError> {
1114    use toml_edit::{Array, Item, Table};
1115
1116    let mut t = Table::new();
1117    let typ = spec.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
1118    t["type"] = toml_edit::value(typ);
1119
1120    // 定义核心字段(已在下方处理,跳过通用转换)
1121    let core_fields = match typ {
1122        "stdio" => vec!["type", "command", "args", "env", "cwd"],
1123        "http" | "sse" => vec!["type", "url", "headers", "http_headers"],
1124        _ => vec!["type"],
1125    };
1126
1127    // 定义扩展字段白名单(Codex 常见可选字段)
1128    let extended_fields = [
1129        // 通用字段
1130        "timeout",
1131        "timeout_ms",
1132        "startup_timeout_ms",
1133        "startup_timeout_sec",
1134        "connection_timeout",
1135        "read_timeout",
1136        "debug",
1137        "log_level",
1138        "disabled",
1139        // stdio 特有
1140        "shell",
1141        "encoding",
1142        "working_dir",
1143        "restart_on_exit",
1144        "max_restart_count",
1145        // http/sse 特有
1146        "retry_count",
1147        "max_retry_attempts",
1148        "retry_delay",
1149        "cache_tools_list",
1150        "verify_ssl",
1151        "insecure",
1152        "proxy",
1153    ];
1154
1155    // 1. 处理核心字段(强类型)
1156    match typ {
1157        "stdio" => {
1158            let cmd = spec.get("command").and_then(|v| v.as_str()).unwrap_or("");
1159            t["command"] = toml_edit::value(cmd);
1160
1161            if let Some(args) = spec.get("args").and_then(|v| v.as_array()) {
1162                let mut arr_v = Array::default();
1163                for a in args.iter().filter_map(|x| x.as_str()) {
1164                    arr_v.push(a);
1165                }
1166                if !arr_v.is_empty() {
1167                    t["args"] = Item::Value(toml_edit::Value::Array(arr_v));
1168                }
1169            }
1170
1171            if let Some(cwd) = spec.get("cwd").and_then(|v| v.as_str()) {
1172                if !cwd.trim().is_empty() {
1173                    t["cwd"] = toml_edit::value(cwd);
1174                }
1175            }
1176
1177            if let Some(env) = spec.get("env").and_then(|v| v.as_object()) {
1178                let mut env_tbl = Table::new();
1179                for (k, v) in env.iter() {
1180                    if let Some(s) = v.as_str() {
1181                        env_tbl[&k[..]] = toml_edit::value(s);
1182                    }
1183                }
1184                if !env_tbl.is_empty() {
1185                    t["env"] = Item::Table(env_tbl);
1186                }
1187            }
1188        }
1189        "http" | "sse" => {
1190            let url = spec.get("url").and_then(|v| v.as_str()).unwrap_or("");
1191            t["url"] = toml_edit::value(url);
1192
1193            if let Some(headers) = spec.get("headers").and_then(|v| v.as_object()) {
1194                let mut h_tbl = Table::new();
1195                for (k, v) in headers.iter() {
1196                    if let Some(s) = v.as_str() {
1197                        h_tbl[&k[..]] = toml_edit::value(s);
1198                    }
1199                }
1200                if !h_tbl.is_empty() {
1201                    t["http_headers"] = Item::Table(h_tbl);
1202                }
1203            }
1204        }
1205        _ => {}
1206    }
1207
1208    // 2. 处理扩展字段和其他未知字段
1209    if let Some(obj) = spec.as_object() {
1210        for (key, value) in obj {
1211            // 跳过已处理的核心字段
1212            if core_fields.contains(&key.as_str()) {
1213                continue;
1214            }
1215
1216            // 尝试使用通用转换器
1217            if let Some(toml_item) = json_value_to_toml_item(value, key) {
1218                t[&key[..]] = toml_item;
1219
1220                // 记录扩展字段的处理
1221                if extended_fields.contains(&key.as_str()) {
1222                    log::debug!("已转换扩展字段 '{key}' = {value:?}");
1223                } else {
1224                    log::info!("已转换自定义字段 '{key}' = {value:?}");
1225                }
1226            }
1227        }
1228    }
1229
1230    Ok(t)
1231}
1232
1233/// 将单个 MCP 服务器同步到 Codex live 配置
1234/// 始终使用 Codex 官方格式 [mcp_servers],并清理可能存在的错误格式 [mcp.servers]
1235pub fn sync_single_server_to_codex(
1236    _config: &MultiAppConfig,
1237    id: &str,
1238    server_spec: &Value,
1239) -> Result<(), AppError> {
1240    if !crate::sync_policy::should_sync_live(&AppType::Codex) {
1241        return Ok(());
1242    }
1243    use toml_edit::Item;
1244
1245    // 读取现有的 config.toml
1246    let config_path = crate::codex_config::get_codex_config_path();
1247
1248    let mut doc = if config_path.exists() {
1249        let content =
1250            std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
1251        // 尝试解析现有配置,如果失败则创建新文档(容错处理)
1252        match content.parse::<toml_edit::DocumentMut>() {
1253            Ok(doc) => doc,
1254            Err(e) => {
1255                log::warn!("解析 Codex config.toml 失败: {e},将创建新配置");
1256                toml_edit::DocumentMut::new()
1257            }
1258        }
1259    } else {
1260        toml_edit::DocumentMut::new()
1261    };
1262
1263    // 清理可能存在的错误格式 [mcp.servers]
1264    if let Some(mcp_item) = doc.get_mut("mcp") {
1265        if let Some(tbl) = mcp_item.as_table_like_mut() {
1266            if tbl.contains_key("servers") {
1267                log::warn!("检测到错误的 MCP 格式 [mcp.servers],正在清理并迁移到 [mcp_servers]");
1268                tbl.remove("servers");
1269            }
1270        }
1271    }
1272
1273    // 确保 [mcp_servers] 表存在
1274    if !doc.contains_key("mcp_servers") {
1275        doc["mcp_servers"] = toml_edit::table();
1276    }
1277
1278    // 将 JSON 服务器规范转换为 TOML 表
1279    let toml_table = json_server_to_toml_table(server_spec)?;
1280
1281    // 使用唯一正确的格式:[mcp_servers]
1282    doc["mcp_servers"][id] = Item::Table(toml_table);
1283
1284    // 写回文件
1285    let new_text = doc.to_string();
1286    crate::config::write_text_file(&config_path, &new_text)?;
1287
1288    Ok(())
1289}
1290
1291/// 从 Codex live 配置中移除单个 MCP 服务器
1292/// 从正确的 [mcp_servers] 表中删除,同时清理可能存在于错误位置 [mcp.servers] 的数据
1293pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
1294    if !crate::sync_policy::should_sync_live(&AppType::Codex) {
1295        return Ok(());
1296    }
1297    let config_path = crate::codex_config::get_codex_config_path();
1298
1299    if !config_path.exists() {
1300        return Ok(()); // 文件不存在,无需删除
1301    }
1302
1303    let content =
1304        std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
1305
1306    // 尝试解析现有配置,如果失败则直接返回(无法删除不存在的内容)
1307    let mut doc = match content.parse::<toml_edit::DocumentMut>() {
1308        Ok(doc) => doc,
1309        Err(e) => {
1310            log::warn!("解析 Codex config.toml 失败: {e},跳过删除操作");
1311            return Ok(());
1312        }
1313    };
1314
1315    // 从正确的位置删除:[mcp_servers]
1316    if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
1317        mcp_servers.remove(id);
1318    }
1319
1320    // 同时清理可能存在于错误位置的数据:[mcp.servers](如果存在)
1321    if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) {
1322        if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) {
1323            if servers.remove(id).is_some() {
1324                log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
1325            }
1326        }
1327    }
1328
1329    // 写回文件
1330    let new_text = doc.to_string();
1331    crate::config::write_text_file(&config_path, &new_text)?;
1332
1333    Ok(())
1334}
1335
1336/// 将单个 MCP 服务器同步到 Gemini live 配置
1337pub fn sync_single_server_to_gemini(
1338    _config: &MultiAppConfig,
1339    id: &str,
1340    server_spec: &Value,
1341) -> Result<(), AppError> {
1342    if !crate::sync_policy::should_sync_live(&AppType::Gemini) {
1343        return Ok(());
1344    }
1345
1346    // 读取现有的 MCP 配置
1347    let current = crate::gemini_mcp::read_mcp_servers_map()?;
1348
1349    // 创建新的 HashMap,包含现有的所有服务器 + 当前要同步的服务器
1350    let mut updated = current;
1351    updated.insert(id.to_string(), server_spec.clone());
1352
1353    // 写回
1354    crate::gemini_mcp::set_mcp_servers_map(&updated)
1355}
1356
1357/// 从 Gemini live 配置中移除单个 MCP 服务器
1358pub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> {
1359    if !crate::sync_policy::should_sync_live(&AppType::Gemini) {
1360        return Ok(());
1361    }
1362
1363    // 读取现有的 MCP 配置
1364    let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
1365
1366    // 移除指定服务器
1367    current.remove(id);
1368
1369    // 写回
1370    crate::gemini_mcp::set_mcp_servers_map(&current)
1371}
1372
1373/// 将单个 MCP 服务器同步到 OpenCode live 配置
1374pub fn sync_single_server_to_opencode(
1375    _config: &MultiAppConfig,
1376    id: &str,
1377    server_spec: &Value,
1378) -> Result<(), AppError> {
1379    if !crate::sync_policy::should_sync_live(&AppType::OpenCode) {
1380        return Ok(());
1381    }
1382
1383    let spec = convert_to_opencode_mcp_spec(server_spec)?;
1384    crate::opencode_config::set_mcp_server(id, spec)
1385}
1386
1387/// 从 OpenCode live 配置中移除单个 MCP 服务器
1388pub fn remove_server_from_opencode(id: &str) -> Result<(), AppError> {
1389    if !crate::sync_policy::should_sync_live(&AppType::OpenCode) {
1390        return Ok(());
1391    }
1392
1393    crate::opencode_config::remove_mcp_server(id)
1394}
1395
1396// ============================================================================
1397// OpenClaw MCP: format conversion, sync, import
1398// ============================================================================
1399
1400fn convert_to_openclaw_mcp_spec(spec: &Value) -> Result<Value, AppError> {
1401    let obj = spec
1402        .as_object()
1403        .ok_or_else(|| AppError::McpValidation("MCP spec must be a JSON object".into()))?;
1404    let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
1405    let mut result = serde_json::Map::new();
1406
1407    match typ {
1408        "stdio" => {
1409            if let Some(command) = obj.get("command") {
1410                result.insert("command".into(), command.clone());
1411            }
1412            if let Some(args) = obj.get("args") {
1413                if args.is_array() && !args.as_array().map(|a| a.is_empty()).unwrap_or(true) {
1414                    result.insert("args".into(), args.clone());
1415                }
1416            }
1417            if let Some(env) = obj.get("env") {
1418                if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
1419                    result.insert("env".into(), env.clone());
1420                }
1421            }
1422            for key in ["cwd", "workingDirectory"] {
1423                if let Some(value) = obj.get(key) {
1424                    result.insert(key.to_string(), value.clone());
1425                }
1426            }
1427        }
1428        "sse" | "http" => {
1429            if let Some(url) = obj.get("url") {
1430                result.insert("url".into(), url.clone());
1431            }
1432            if typ == "http" {
1433                result.insert("transport".into(), json!("streamable-http"));
1434            } else if obj
1435                .get("transport")
1436                .and_then(|value| value.as_str())
1437                .is_some()
1438            {
1439                result.insert("transport".into(), json!("sse"));
1440            }
1441            if let Some(headers) = obj.get("headers") {
1442                if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
1443                {
1444                    result.insert("headers".into(), headers.clone());
1445                }
1446            }
1447            if let Some(timeout) = obj.get("connectionTimeoutMs") {
1448                result.insert("connectionTimeoutMs".into(), timeout.clone());
1449            }
1450        }
1451        _ => {
1452            return Err(AppError::McpValidation(format!("Unknown MCP type: {typ}")));
1453        }
1454    }
1455
1456    Ok(Value::Object(result))
1457}
1458
1459fn convert_from_openclaw_mcp_spec(id: &str, spec: &Value) -> Result<Value, AppError> {
1460    let obj = spec
1461        .as_object()
1462        .ok_or_else(|| AppError::McpValidation("OpenClaw MCP spec must be a JSON object".into()))?;
1463    let mut result = serde_json::Map::new();
1464
1465    if obj.contains_key("command") {
1466        result.insert("type".into(), json!("stdio"));
1467        for key in ["command", "args", "env", "cwd", "workingDirectory"] {
1468            if let Some(value) = obj.get(key) {
1469                result.insert(key.to_string(), value.clone());
1470            }
1471        }
1472    } else if obj.contains_key("url") {
1473        let transport = obj.get("transport").and_then(|value| value.as_str());
1474        let typ = match transport {
1475            Some("streamable-http") | Some("http") => "http",
1476            _ => "sse",
1477        };
1478        result.insert("type".into(), json!(typ));
1479        if let Some(url) = obj.get("url") {
1480            result.insert("url".into(), url.clone());
1481        }
1482        if let Some(headers) = obj.get("headers") {
1483            if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) {
1484                result.insert("headers".into(), headers.clone());
1485            }
1486        }
1487        if let Some(timeout) = obj.get("connectionTimeoutMs") {
1488            result.insert("connectionTimeoutMs".into(), timeout.clone());
1489        }
1490    } else {
1491        return Err(AppError::McpValidation(format!(
1492            "OpenClaw MCP server '{id}' has neither 'command' nor 'url' field"
1493        )));
1494    }
1495
1496    Ok(Value::Object(result))
1497}
1498
1499pub fn sync_single_server_to_openclaw(
1500    _config: &MultiAppConfig,
1501    id: &str,
1502    server_spec: &Value,
1503) -> Result<(), AppError> {
1504    if !crate::sync_policy::should_sync_live(&AppType::OpenClaw) {
1505        return Ok(());
1506    }
1507
1508    let spec = convert_to_openclaw_mcp_spec(server_spec)?;
1509    crate::openclaw_config::set_mcp_server(id, spec).map(|_| ())
1510}
1511
1512pub fn remove_server_from_openclaw(id: &str) -> Result<(), AppError> {
1513    if !crate::sync_policy::should_sync_live(&AppType::OpenClaw) {
1514        return Ok(());
1515    }
1516
1517    crate::openclaw_config::remove_mcp_server(id).map(|_| ())
1518}
1519
1520pub fn import_from_openclaw(config: &mut MultiAppConfig) -> Result<usize, AppError> {
1521    use crate::app_config::{McpApps, McpServer};
1522
1523    let map = crate::openclaw_config::get_mcp_servers()?;
1524    if map.is_empty() {
1525        return Ok(0);
1526    }
1527
1528    if config.mcp.servers.is_none() {
1529        config.mcp.servers = Some(HashMap::new());
1530    }
1531    let servers = config.mcp.servers.as_mut().unwrap();
1532
1533    let mut changed = 0;
1534    let mut errors = Vec::new();
1535
1536    for (id, spec) in map.iter() {
1537        let unified = match convert_from_openclaw_mcp_spec(id, spec) {
1538            Ok(spec) => spec,
1539            Err(err) => {
1540                log::warn!("Skip invalid OpenClaw MCP server '{id}': {err}");
1541                errors.push(format!("{id}: {err}"));
1542                continue;
1543            }
1544        };
1545
1546        if let Err(err) = validate_server_spec(&unified) {
1547            log::warn!("Skip invalid MCP server '{id}' after conversion: {err}");
1548            errors.push(format!("{id}: {err}"));
1549            continue;
1550        }
1551
1552        if let Some(existing) = servers.get_mut(id) {
1553            if !existing.apps.openclaw {
1554                existing.apps.openclaw = true;
1555                changed += 1;
1556                log::info!("MCP server '{id}' enabled for OpenClaw");
1557            }
1558        } else {
1559            servers.insert(
1560                id.clone(),
1561                McpServer {
1562                    id: id.clone(),
1563                    name: id.clone(),
1564                    server: unified,
1565                    apps: McpApps {
1566                        claude: false,
1567                        codex: false,
1568                        gemini: false,
1569                        opencode: false,
1570                        openclaw: true,
1571                        hermes: false,
1572                    },
1573                    description: None,
1574                    homepage: None,
1575                    docs: None,
1576                    tags: Vec::new(),
1577                },
1578            );
1579            changed += 1;
1580            log::info!("Imported new MCP server '{id}' from OpenClaw");
1581        }
1582    }
1583
1584    if !errors.is_empty() {
1585        log::warn!(
1586            "Import completed with {} failures: {:?}",
1587            errors.len(),
1588            errors
1589        );
1590    }
1591
1592    Ok(changed)
1593}
1594
1595// ============================================================================
1596// Hermes MCP: format conversion, sync, import
1597// ============================================================================
1598
1599/// Hermes-specific fields preserved on merge-on-write, stripped on import.
1600const HERMES_EXTRA_FIELDS: &[&str] = &[
1601    "enabled",
1602    "timeout",
1603    "connect_timeout",
1604    "tools",
1605    "sampling",
1606    "roots",
1607    "auth",
1608];
1609
1610fn should_sync_hermes_mcp() -> bool {
1611    crate::hermes_config::get_hermes_dir().exists()
1612}
1613
1614/// Convert CC Switch unified format to Hermes format
1615fn convert_to_hermes_format(spec: &Value) -> Result<Value, AppError> {
1616    let obj = spec
1617        .as_object()
1618        .ok_or_else(|| AppError::McpValidation("MCP spec must be a JSON object".into()))?;
1619
1620    let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
1621
1622    let mut result = serde_json::Map::new();
1623
1624    match typ {
1625        "stdio" => {
1626            if let Some(command) = obj.get("command") {
1627                result.insert("command".into(), command.clone());
1628            }
1629            if let Some(args) = obj.get("args") {
1630                if args.is_array() && !args.as_array().map(|a| a.is_empty()).unwrap_or(true) {
1631                    result.insert("args".into(), args.clone());
1632                }
1633            }
1634            if let Some(env) = obj.get("env") {
1635                if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
1636                    result.insert("env".into(), env.clone());
1637                }
1638            }
1639        }
1640        "sse" | "http" => {
1641            if let Some(url) = obj.get("url") {
1642                result.insert("url".into(), url.clone());
1643            }
1644            if let Some(headers) = obj.get("headers") {
1645                if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
1646                {
1647                    result.insert("headers".into(), headers.clone());
1648                }
1649            }
1650        }
1651        _ => {
1652            return Err(AppError::McpValidation(format!("Unknown MCP type: {typ}")));
1653        }
1654    }
1655
1656    result.insert("enabled".into(), json!(true));
1657
1658    Ok(Value::Object(result))
1659}
1660
1661/// Convert Hermes format to CC Switch unified format
1662fn convert_from_hermes_format(id: &str, spec: &Value) -> Result<Value, AppError> {
1663    let obj = spec
1664        .as_object()
1665        .ok_or_else(|| AppError::McpValidation("Hermes MCP spec must be a JSON object".into()))?;
1666
1667    let mut result = serde_json::Map::new();
1668
1669    if obj.contains_key("command") {
1670        result.insert("type".into(), json!("stdio"));
1671        if let Some(command) = obj.get("command") {
1672            result.insert("command".into(), command.clone());
1673        }
1674        if let Some(args) = obj.get("args") {
1675            if args.is_array() && !args.as_array().map(|a| a.is_empty()).unwrap_or(true) {
1676                result.insert("args".into(), args.clone());
1677            }
1678        }
1679        if let Some(env) = obj.get("env") {
1680            if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
1681                result.insert("env".into(), env.clone());
1682            }
1683        }
1684    } else if obj.contains_key("url") {
1685        result.insert("type".into(), json!("sse"));
1686        if let Some(url) = obj.get("url") {
1687            result.insert("url".into(), url.clone());
1688        }
1689        if let Some(headers) = obj.get("headers") {
1690            if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) {
1691                result.insert("headers".into(), headers.clone());
1692            }
1693        }
1694    } else {
1695        return Err(AppError::McpValidation(format!(
1696            "Hermes MCP server '{id}' has neither 'command' nor 'url' field"
1697        )));
1698    }
1699
1700    Ok(Value::Object(result))
1701}
1702
1703/// Merge new spec into existing Hermes spec, preserving Hermes-specific fields.
1704fn merge_hermes_spec(existing: &Value, new_spec: &Value) -> Value {
1705    let mut result = serde_json::Map::new();
1706
1707    if let Some(existing_obj) = existing.as_object() {
1708        for &field in HERMES_EXTRA_FIELDS {
1709            if let Some(val) = existing_obj.get(field) {
1710                result.insert(field.to_string(), val.clone());
1711            }
1712        }
1713    }
1714
1715    if let Some(new_obj) = new_spec.as_object() {
1716        for (key, val) in new_obj {
1717            if HERMES_EXTRA_FIELDS.contains(&key.as_str()) && result.contains_key(key) {
1718                continue;
1719            }
1720            result.insert(key.clone(), val.clone());
1721        }
1722    }
1723
1724    Value::Object(result)
1725}
1726
1727/// Sync a single MCP server to Hermes live config (merge-on-write)
1728pub fn sync_single_server_to_hermes(
1729    _config: &MultiAppConfig,
1730    id: &str,
1731    server_spec: &Value,
1732) -> Result<(), AppError> {
1733    if !should_sync_hermes_mcp() {
1734        return Ok(());
1735    }
1736
1737    let hermes_spec = convert_to_hermes_format(server_spec)?;
1738    let id_owned = id.to_string();
1739
1740    crate::hermes_config::update_mcp_servers_yaml(|servers| {
1741        let id_yaml = serde_yaml::Value::String(id_owned.clone());
1742
1743        let merged_json = if let Some(existing_yaml) = servers.get(&id_yaml) {
1744            let existing_json = crate::hermes_config::yaml_to_json(existing_yaml)?;
1745            merge_hermes_spec(&existing_json, &hermes_spec)
1746        } else {
1747            hermes_spec.clone()
1748        };
1749
1750        let merged_yaml_value = crate::hermes_config::json_to_yaml(&merged_json)?;
1751        servers.insert(id_yaml, merged_yaml_value);
1752        Ok(())
1753    })
1754}
1755
1756/// Remove a single MCP server from Hermes live config
1757pub fn remove_server_from_hermes(id: &str) -> Result<(), AppError> {
1758    if !should_sync_hermes_mcp() {
1759        return Ok(());
1760    }
1761
1762    let id_owned = id.to_string();
1763    crate::hermes_config::update_mcp_servers_yaml(|servers| {
1764        servers.remove(serde_yaml::Value::String(id_owned.clone()));
1765        Ok(())
1766    })
1767}
1768
1769/// Import MCP servers from Hermes config to unified structure
1770pub fn import_from_hermes(config: &mut MultiAppConfig) -> Result<usize, AppError> {
1771    use crate::app_config::{McpApps, McpServer};
1772
1773    let yaml_map = crate::hermes_config::get_mcp_servers_yaml()?;
1774    if yaml_map.is_empty() {
1775        return Ok(0);
1776    }
1777
1778    if config.mcp.servers.is_none() {
1779        config.mcp.servers = Some(HashMap::new());
1780    }
1781    let servers = config.mcp.servers.as_mut().unwrap();
1782
1783    let mut changed = 0;
1784    let mut errors = Vec::new();
1785
1786    for (key, spec_yaml) in &yaml_map {
1787        let id = match key.as_str() {
1788            Some(s) => s.to_string(),
1789            None => {
1790                log::warn!("Skip Hermes MCP server with non-string key");
1791                continue;
1792            }
1793        };
1794
1795        let spec_json = match crate::hermes_config::yaml_to_json(spec_yaml) {
1796            Ok(j) => j,
1797            Err(e) => {
1798                log::warn!("Skip Hermes MCP server '{id}': failed to convert YAML to JSON: {e}");
1799                errors.push(format!("{id}: {e}"));
1800                continue;
1801            }
1802        };
1803
1804        let unified_spec = match convert_from_hermes_format(&id, &spec_json) {
1805            Ok(s) => s,
1806            Err(e) => {
1807                log::warn!("Skip invalid Hermes MCP server '{id}': {e}");
1808                errors.push(format!("{id}: {e}"));
1809                continue;
1810            }
1811        };
1812
1813        if let Err(e) = validate_server_spec(&unified_spec) {
1814            log::warn!("Skip invalid MCP server '{id}' after conversion: {e}");
1815            errors.push(format!("{id}: {e}"));
1816            continue;
1817        }
1818
1819        if let Some(existing) = servers.get_mut(&id) {
1820            if !existing.apps.hermes {
1821                existing.apps.hermes = true;
1822                changed += 1;
1823                log::info!("MCP server '{id}' enabled for Hermes");
1824            }
1825        } else {
1826            servers.insert(
1827                id.clone(),
1828                McpServer {
1829                    id: id.clone(),
1830                    name: id.clone(),
1831                    server: unified_spec,
1832                    apps: McpApps {
1833                        claude: false,
1834                        codex: false,
1835                        gemini: false,
1836                        opencode: false,
1837                        openclaw: false,
1838                        hermes: true,
1839                    },
1840                    description: None,
1841                    homepage: None,
1842                    docs: None,
1843                    tags: Vec::new(),
1844                },
1845            );
1846            changed += 1;
1847            log::info!("Imported new MCP server '{id}' from Hermes");
1848        }
1849    }
1850
1851    if !errors.is_empty() {
1852        log::warn!(
1853            "Import completed with {} failures: {:?}",
1854            errors.len(),
1855            errors
1856        );
1857    }
1858
1859    Ok(changed)
1860}