1use serde_json::{json, Value};
2use std::collections::HashMap;
3
4use crate::app_config::{AppType, McpConfig, MultiAppConfig};
5use crate::error::AppError;
6
7fn 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 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)] fn 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
207fn 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)] pub 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)] pub 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)] pub 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)] pub 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 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 return Ok(false);
332 }
333
334 Ok(true)
335}
336
337pub 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
346pub 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 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 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 if !existing.apps.claude {
380 existing.apps.claude = true;
381 changed += 1;
382 log::info!("MCP 服务器 '{id}' 已启用 Claude 应用");
383 }
384 } else {
385 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 hermes: false,
398 },
399 description: None,
400 homepage: None,
401 docs: None,
402 tags: Vec::new(),
403 },
404 );
405 changed += 1;
406 log::info!("导入新 MCP 服务器 '{id}'");
407 }
408 }
409
410 if !errors.is_empty() {
411 log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
412 }
413
414 Ok(changed)
415}
416
417pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError> {
425 use crate::app_config::{McpApps, McpServer};
426
427 let text = crate::codex_config::read_and_validate_codex_config_text()?;
428 if text.trim().is_empty() {
429 return Ok(0);
430 }
431
432 let root: toml::Table = toml::from_str(&text)
433 .map_err(|e| AppError::McpValidation(format!("解析 ~/.codex/config.toml 失败: {e}")))?;
434
435 if config.mcp.servers.is_none() {
437 config.mcp.servers = Some(HashMap::new());
438 }
439 let servers = config.mcp.servers.as_mut().unwrap();
440
441 let mut changed_total = 0usize;
442
443 let mut import_servers_tbl = |servers_tbl: &toml::value::Table| {
445 let mut changed = 0usize;
446 for (id, entry_val) in servers_tbl.iter() {
447 let Some(entry_tbl) = entry_val.as_table() else {
448 continue;
449 };
450
451 let toml_to_json = |toml_val: &toml::Value| -> Option<serde_json::Value> {
452 match toml_val {
453 toml::Value::String(s) => Some(json!(s)),
454 toml::Value::Integer(i) => Some(json!(i)),
455 toml::Value::Float(f) => Some(json!(f)),
456 toml::Value::Boolean(b) => Some(json!(b)),
457 toml::Value::Array(arr) => {
458 let json_arr: Vec<serde_json::Value> = arr
459 .iter()
460 .filter_map(|item| match item {
461 toml::Value::String(s) => Some(json!(s)),
462 toml::Value::Integer(i) => Some(json!(i)),
463 toml::Value::Float(f) => Some(json!(f)),
464 toml::Value::Boolean(b) => Some(json!(b)),
465 _ => None,
466 })
467 .collect();
468 if json_arr.is_empty() {
469 None
470 } else {
471 Some(serde_json::Value::Array(json_arr))
472 }
473 }
474 toml::Value::Table(tbl) => {
475 let mut json_obj = serde_json::Map::new();
476 for (k, v) in tbl.iter() {
477 if let Some(s) = v.as_str() {
478 json_obj.insert(k.clone(), json!(s));
479 }
480 }
481 if json_obj.is_empty() {
482 None
483 } else {
484 Some(serde_json::Value::Object(json_obj))
485 }
486 }
487 toml::Value::Datetime(_) => None,
488 }
489 };
490
491 let typ = if entry_tbl.contains_key("type") {
494 entry_tbl.get("type").and_then(|v| v.as_str())
495 } else {
496 entry_tbl
497 .get("url")
498 .and_then(|v| v.as_str())
499 .filter(|url| !url.trim().is_empty())
500 .map(|_| "http")
501 .or(Some("stdio"))
502 };
503
504 let mut spec = serde_json::Map::new();
506 if let Some(typ) = typ {
507 spec.insert("type".into(), json!(typ));
508 } else if let Some(type_val) = entry_tbl.get("type").and_then(toml_to_json) {
509 spec.insert("type".into(), type_val);
510 }
511
512 let core_fields = match typ {
514 Some("stdio") => vec!["type", "command", "args", "env", "cwd"],
515 Some("http") | Some("sse") => vec!["type", "url", "http_headers"],
516 _ => vec!["type"],
517 };
518
519 match typ {
521 Some("stdio") => {
522 if let Some(cmd) = entry_tbl.get("command").and_then(|v| v.as_str()) {
523 spec.insert("command".into(), json!(cmd));
524 }
525 if let Some(args) = entry_tbl.get("args").and_then(|v| v.as_array()) {
526 let arr = args
527 .iter()
528 .filter_map(|x| x.as_str())
529 .map(|s| json!(s))
530 .collect::<Vec<_>>();
531 if !arr.is_empty() {
532 spec.insert("args".into(), serde_json::Value::Array(arr));
533 }
534 }
535 if let Some(cwd) = entry_tbl.get("cwd").and_then(|v| v.as_str()) {
536 if !cwd.trim().is_empty() {
537 spec.insert("cwd".into(), json!(cwd));
538 }
539 }
540 if let Some(env_tbl) = entry_tbl.get("env").and_then(|v| v.as_table()) {
541 let mut env_json = serde_json::Map::new();
542 for (k, v) in env_tbl.iter() {
543 if let Some(sv) = v.as_str() {
544 env_json.insert(k.clone(), json!(sv));
545 }
546 }
547 if !env_json.is_empty() {
548 spec.insert("env".into(), serde_json::Value::Object(env_json));
549 }
550 }
551 }
552 Some("http") | Some("sse") => {
553 if let Some(url) = entry_tbl.get("url").and_then(|v| v.as_str()) {
554 spec.insert("url".into(), json!(url));
555 }
556 let headers_tbl = entry_tbl
558 .get("http_headers")
559 .and_then(|v| v.as_table())
560 .or_else(|| entry_tbl.get("headers").and_then(|v| v.as_table()));
561
562 if let Some(headers_tbl) = headers_tbl {
563 let mut headers_json = serde_json::Map::new();
564 for (k, v) in headers_tbl.iter() {
565 if let Some(sv) = v.as_str() {
566 headers_json.insert(k.clone(), json!(sv));
567 }
568 }
569 if !headers_json.is_empty() {
570 spec.insert("headers".into(), serde_json::Value::Object(headers_json));
571 }
572 }
573 }
574 _ => {}
575 }
576
577 for (key, toml_val) in entry_tbl.iter() {
579 if core_fields.contains(&key.as_str()) {
581 continue;
582 }
583
584 let json_val = toml_to_json(toml_val);
586
587 if let Some(val) = json_val {
588 spec.insert(key.clone(), val);
589 log::debug!("导入扩展字段 '{key}' = {toml_val:?}");
590 } else {
591 log::debug!("跳过复杂字段 '{key}' (TOML → JSON)");
592 }
593 }
594
595 let spec_v = serde_json::Value::Object(spec);
596
597 if let Err(e) = validate_server_spec(&spec_v) {
599 log::warn!("跳过无效 Codex MCP 项 '{id}': {e}");
600 continue;
601 }
602
603 if let Some(existing) = servers.get_mut(id) {
604 if !existing.apps.codex {
606 existing.apps.codex = true;
607 changed += 1;
608 log::info!("MCP 服务器 '{id}' 已启用 Codex 应用");
609 }
610 } else {
611 servers.insert(
613 id.clone(),
614 McpServer {
615 id: id.clone(),
616 name: id.clone(),
617 server: spec_v,
618 apps: McpApps {
619 claude: false,
620 codex: true,
621 gemini: false,
622 opencode: false,
623 hermes: false,
624 },
625 description: None,
626 homepage: None,
627 docs: None,
628 tags: Vec::new(),
629 },
630 );
631 changed += 1;
632 log::info!("导入新 MCP 服务器 '{id}'");
633 }
634 }
635 changed
636 };
637
638 if let Some(mcp_val) = root.get("mcp") {
640 if let Some(mcp_tbl) = mcp_val.as_table() {
641 if let Some(servers_val) = mcp_tbl.get("servers") {
642 if let Some(servers_tbl) = servers_val.as_table() {
643 changed_total += import_servers_tbl(servers_tbl);
644 }
645 }
646 }
647 }
648
649 if let Some(servers_val) = root.get("mcp_servers") {
651 if let Some(servers_tbl) = servers_val.as_table() {
652 changed_total += import_servers_tbl(servers_tbl);
653 }
654 }
655
656 Ok(changed_total)
657}
658
659pub fn sync_enabled_to_codex(config: &MultiAppConfig) -> Result<(), AppError> {
668 if !crate::sync_policy::should_sync_live(&AppType::Codex) {
669 return Ok(());
670 }
671 use toml_edit::{Item, Table};
672
673 let enabled = collect_enabled_servers(&config.mcp.codex);
675
676 let base_text = crate::codex_config::read_and_validate_codex_config_text()?;
678
679 let mut doc = if base_text.trim().is_empty() {
681 toml_edit::DocumentMut::default()
682 } else {
683 base_text
684 .parse::<toml_edit::DocumentMut>()
685 .map_err(|e| AppError::McpValidation(format!("解析 config.toml 失败: {e}")))?
686 };
687
688 if let Some(mcp_item) = doc.get_mut("mcp") {
690 if let Some(tbl) = mcp_item.as_table_like_mut() {
691 if tbl.contains_key("servers") {
692 log::warn!("检测到错误的 MCP 格式 [mcp.servers],正在清理并迁移到 [mcp_servers]");
693 tbl.remove("servers");
694 }
695 }
696 }
697
698 if enabled.is_empty() {
700 doc.as_table_mut().remove("mcp_servers");
702 } else {
703 let mut servers_tbl = Table::new();
705 let mut ids: Vec<_> = enabled.keys().cloned().collect();
706 ids.sort();
707 for id in ids {
708 let spec = enabled.get(&id).expect("spec must exist");
709 match json_server_to_toml_table(spec) {
711 Ok(table) => {
712 servers_tbl[&id[..]] = Item::Table(table);
713 }
714 Err(err) => {
715 log::error!("跳过无效的 MCP 服务器 '{id}': {err}");
716 }
717 }
718 }
719 doc["mcp_servers"] = Item::Table(servers_tbl);
721 }
722
723 let new_text = doc.to_string();
725 let path = crate::codex_config::get_codex_config_path();
726 crate::config::write_text_file(&path, &new_text)?;
727 Ok(())
728}
729
730pub fn sync_enabled_to_gemini(config: &MultiAppConfig) -> Result<(), AppError> {
732 if !crate::sync_policy::should_sync_live(&AppType::Gemini) {
733 return Ok(());
734 }
735 let enabled = collect_enabled_servers(&config.mcp.gemini);
736 crate::gemini_mcp::set_mcp_servers_map(&enabled)
737}
738
739pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError> {
742 use crate::app_config::{McpApps, McpServer};
743
744 let map = crate::gemini_mcp::read_mcp_servers_map()?;
745 if map.is_empty() {
746 return Ok(0);
747 }
748
749 if config.mcp.servers.is_none() {
751 config.mcp.servers = Some(HashMap::new());
752 }
753 let servers = config.mcp.servers.as_mut().unwrap();
754
755 let mut changed = 0;
756 let mut errors = Vec::new();
757
758 for (id, spec) in map.iter() {
759 if let Err(e) = validate_server_spec(spec) {
761 log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
762 errors.push(format!("{id}: {e}"));
763 continue;
764 }
765
766 if let Some(existing) = servers.get_mut(id) {
767 if !existing.apps.gemini {
769 existing.apps.gemini = true;
770 changed += 1;
771 log::info!("MCP 服务器 '{id}' 已启用 Gemini 应用");
772 }
773 } else {
774 servers.insert(
776 id.clone(),
777 McpServer {
778 id: id.clone(),
779 name: id.clone(),
780 server: spec.clone(),
781 apps: McpApps {
782 claude: false,
783 codex: false,
784 gemini: true,
785 opencode: false,
786 hermes: false,
787 },
788 description: None,
789 homepage: None,
790 docs: None,
791 tags: Vec::new(),
792 },
793 );
794 changed += 1;
795 log::info!("导入新 MCP 服务器 '{id}'");
796 }
797 }
798
799 if !errors.is_empty() {
800 log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
801 }
802
803 Ok(changed)
804}
805
806fn convert_to_opencode_mcp_spec(spec: &Value) -> Result<Value, AppError> {
808 let obj = spec
809 .as_object()
810 .ok_or_else(|| AppError::McpValidation("MCP spec must be a JSON object".into()))?;
811
812 let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
813 let mut result = serde_json::Map::new();
814
815 match typ {
816 "stdio" => {
817 result.insert("type".into(), json!("local"));
818
819 let cmd = obj.get("command").and_then(|v| v.as_str()).unwrap_or("");
820 let mut command = vec![json!(cmd)];
821 if let Some(args) = obj.get("args").and_then(|v| v.as_array()) {
822 command.extend(args.iter().cloned());
823 }
824 result.insert("command".into(), Value::Array(command));
825
826 if let Some(env) = obj.get("env") {
827 if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
828 result.insert("environment".into(), env.clone());
829 }
830 }
831 result.insert("enabled".into(), json!(true));
832 }
833 "sse" | "http" => {
834 result.insert("type".into(), json!("remote"));
835 if let Some(url) = obj.get("url") {
836 result.insert("url".into(), url.clone());
837 }
838 if let Some(headers) = obj.get("headers") {
839 if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
840 {
841 result.insert("headers".into(), headers.clone());
842 }
843 }
844 result.insert("enabled".into(), json!(true));
845 }
846 other => {
847 return Err(AppError::McpValidation(format!(
848 "Unknown MCP type: {other}"
849 )));
850 }
851 }
852
853 Ok(Value::Object(result))
854}
855
856fn convert_from_opencode_mcp_spec(spec: &Value) -> Result<Value, AppError> {
858 let obj = spec
859 .as_object()
860 .ok_or_else(|| AppError::McpValidation("OpenCode MCP spec must be a JSON object".into()))?;
861
862 let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("local");
863 let mut result = serde_json::Map::new();
864
865 match typ {
866 "local" => {
867 result.insert("type".into(), json!("stdio"));
868 if let Some(command) = obj.get("command").and_then(|v| v.as_array()) {
869 if let Some(cmd) = command.first().and_then(|v| v.as_str()) {
870 result.insert("command".into(), json!(cmd));
871 }
872 if command.len() > 1 {
873 result.insert("args".into(), Value::Array(command[1..].to_vec()));
874 }
875 }
876 if let Some(env) = obj.get("environment") {
877 if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
878 result.insert("env".into(), env.clone());
879 }
880 }
881 }
882 "remote" => {
883 result.insert("type".into(), json!("sse"));
884 if let Some(url) = obj.get("url") {
885 result.insert("url".into(), url.clone());
886 }
887 if let Some(headers) = obj.get("headers") {
888 if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true)
889 {
890 result.insert("headers".into(), headers.clone());
891 }
892 }
893 }
894 other => {
895 return Err(AppError::McpValidation(format!(
896 "Unknown OpenCode MCP type: {other}"
897 )));
898 }
899 }
900
901 Ok(Value::Object(result))
902}
903
904pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppError> {
906 use crate::app_config::{McpApps, McpServer};
907
908 let map = crate::opencode_config::get_mcp_servers()?;
909 if map.is_empty() {
910 return Ok(0);
911 }
912
913 if config.mcp.servers.is_none() {
914 config.mcp.servers = Some(HashMap::new());
915 }
916 let servers = config.mcp.servers.as_mut().unwrap();
917
918 let mut changed = 0;
919 let mut errors = Vec::new();
920
921 for (id, spec) in map.iter() {
922 let unified = match convert_from_opencode_mcp_spec(spec) {
923 Ok(spec) => spec,
924 Err(err) => {
925 log::warn!("跳过无效 OpenCode MCP 服务器 '{id}': {err}");
926 errors.push(format!("{id}: {err}"));
927 continue;
928 }
929 };
930
931 if let Err(err) = validate_server_spec(&unified) {
932 log::warn!("跳过无效 MCP 服务器 '{id}': {err}");
933 errors.push(format!("{id}: {err}"));
934 continue;
935 }
936
937 if let Some(existing) = servers.get_mut(id) {
938 if !existing.apps.opencode {
939 existing.apps.opencode = true;
940 changed += 1;
941 log::info!("MCP 服务器 '{id}' 已启用 OpenCode 应用");
942 }
943 } else {
944 servers.insert(
945 id.clone(),
946 McpServer {
947 id: id.clone(),
948 name: id.clone(),
949 server: unified,
950 apps: McpApps {
951 claude: false,
952 codex: false,
953 gemini: false,
954 opencode: true,
955 hermes: false,
956 },
957 description: None,
958 homepage: None,
959 docs: None,
960 tags: Vec::new(),
961 },
962 );
963 changed += 1;
964 log::info!("导入新 OpenCode MCP 服务器 '{id}'");
965 }
966 }
967
968 if !errors.is_empty() {
969 log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
970 }
971
972 Ok(changed)
973}
974
975pub fn sync_single_server_to_claude(
981 _config: &MultiAppConfig,
982 id: &str,
983 server_spec: &Value,
984) -> Result<(), AppError> {
985 if !crate::sync_policy::should_sync_live(&AppType::Claude) {
986 return Ok(());
987 }
988 let current = crate::claude_mcp::read_mcp_servers_map()?;
990
991 let mut updated = current;
993 updated.insert(id.to_string(), server_spec.clone());
994
995 crate::claude_mcp::set_mcp_servers_map(&updated)
997}
998
999pub fn remove_server_from_claude(id: &str) -> Result<(), AppError> {
1001 if !crate::sync_policy::should_sync_live(&AppType::Claude) {
1002 return Ok(());
1003 }
1004 let mut current = crate::claude_mcp::read_mcp_servers_map()?;
1006
1007 current.remove(id);
1009
1010 crate::claude_mcp::set_mcp_servers_map(¤t)
1012}
1013
1014fn json_value_to_toml_item(value: &Value, field_name: &str) -> Option<toml_edit::Item> {
1029 use toml_edit::{Array, InlineTable, Item};
1030
1031 match value {
1032 Value::String(s) => Some(toml_edit::value(s.as_str())),
1033
1034 Value::Number(n) => {
1035 if let Some(i) = n.as_i64() {
1036 Some(toml_edit::value(i))
1037 } else if let Some(f) = n.as_f64() {
1038 Some(toml_edit::value(f))
1039 } else {
1040 log::warn!("跳过字段 '{field_name}': 无法转换的数字类型 {n}");
1041 None
1042 }
1043 }
1044
1045 Value::Bool(b) => Some(toml_edit::value(*b)),
1046
1047 Value::Array(arr) => {
1048 let mut toml_arr = Array::default();
1050 let mut all_same_type = true;
1051
1052 for item in arr {
1053 match item {
1054 Value::String(s) => toml_arr.push(s.as_str()),
1055 Value::Number(n) if n.is_i64() => toml_arr.push(n.as_i64().unwrap()),
1056 Value::Number(n) if n.is_f64() => toml_arr.push(n.as_f64().unwrap()),
1057 Value::Bool(b) => toml_arr.push(*b),
1058 _ => {
1059 all_same_type = false;
1060 break;
1061 }
1062 }
1063 }
1064
1065 if all_same_type && !toml_arr.is_empty() {
1066 Some(Item::Value(toml_edit::Value::Array(toml_arr)))
1067 } else {
1068 log::warn!("跳过字段 '{field_name}': 不支持的数组类型(混合类型或嵌套结构)");
1069 None
1070 }
1071 }
1072
1073 Value::Object(obj) => {
1074 let mut inline_table = InlineTable::new();
1076 let mut all_strings = true;
1077
1078 for (k, v) in obj {
1079 if let Some(s) = v.as_str() {
1080 inline_table.insert(k, s.into());
1082 } else {
1083 all_strings = false;
1084 break;
1085 }
1086 }
1087
1088 if all_strings && !inline_table.is_empty() {
1089 Some(Item::Value(toml_edit::Value::InlineTable(inline_table)))
1090 } else {
1091 log::warn!("跳过字段 '{field_name}': 对象值包含非字符串类型,建议使用子表语法");
1092 None
1093 }
1094 }
1095
1096 Value::Null => {
1097 log::debug!("跳过字段 '{field_name}': TOML 不支持 null 值");
1098 None
1099 }
1100 }
1101}
1102
1103fn json_server_to_toml_table(spec: &Value) -> Result<toml_edit::Table, AppError> {
1110 use toml_edit::{Array, Item, Table};
1111
1112 let mut t = Table::new();
1113 let typ = spec.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
1114 t["type"] = toml_edit::value(typ);
1115
1116 let core_fields = match typ {
1118 "stdio" => vec!["type", "command", "args", "env", "cwd"],
1119 "http" | "sse" => vec!["type", "url", "headers", "http_headers"],
1120 _ => vec!["type"],
1121 };
1122
1123 let extended_fields = [
1125 "timeout",
1127 "timeout_ms",
1128 "startup_timeout_ms",
1129 "startup_timeout_sec",
1130 "connection_timeout",
1131 "read_timeout",
1132 "debug",
1133 "log_level",
1134 "disabled",
1135 "shell",
1137 "encoding",
1138 "working_dir",
1139 "restart_on_exit",
1140 "max_restart_count",
1141 "retry_count",
1143 "max_retry_attempts",
1144 "retry_delay",
1145 "cache_tools_list",
1146 "verify_ssl",
1147 "insecure",
1148 "proxy",
1149 ];
1150
1151 match typ {
1153 "stdio" => {
1154 let cmd = spec.get("command").and_then(|v| v.as_str()).unwrap_or("");
1155 t["command"] = toml_edit::value(cmd);
1156
1157 if let Some(args) = spec.get("args").and_then(|v| v.as_array()) {
1158 let mut arr_v = Array::default();
1159 for a in args.iter().filter_map(|x| x.as_str()) {
1160 arr_v.push(a);
1161 }
1162 if !arr_v.is_empty() {
1163 t["args"] = Item::Value(toml_edit::Value::Array(arr_v));
1164 }
1165 }
1166
1167 if let Some(cwd) = spec.get("cwd").and_then(|v| v.as_str()) {
1168 if !cwd.trim().is_empty() {
1169 t["cwd"] = toml_edit::value(cwd);
1170 }
1171 }
1172
1173 if let Some(env) = spec.get("env").and_then(|v| v.as_object()) {
1174 let mut env_tbl = Table::new();
1175 for (k, v) in env.iter() {
1176 if let Some(s) = v.as_str() {
1177 env_tbl[&k[..]] = toml_edit::value(s);
1178 }
1179 }
1180 if !env_tbl.is_empty() {
1181 t["env"] = Item::Table(env_tbl);
1182 }
1183 }
1184 }
1185 "http" | "sse" => {
1186 let url = spec.get("url").and_then(|v| v.as_str()).unwrap_or("");
1187 t["url"] = toml_edit::value(url);
1188
1189 if let Some(headers) = spec.get("headers").and_then(|v| v.as_object()) {
1190 let mut h_tbl = Table::new();
1191 for (k, v) in headers.iter() {
1192 if let Some(s) = v.as_str() {
1193 h_tbl[&k[..]] = toml_edit::value(s);
1194 }
1195 }
1196 if !h_tbl.is_empty() {
1197 t["http_headers"] = Item::Table(h_tbl);
1198 }
1199 }
1200 }
1201 _ => {}
1202 }
1203
1204 if let Some(obj) = spec.as_object() {
1206 for (key, value) in obj {
1207 if core_fields.contains(&key.as_str()) {
1209 continue;
1210 }
1211
1212 if let Some(toml_item) = json_value_to_toml_item(value, key) {
1214 t[&key[..]] = toml_item;
1215
1216 if extended_fields.contains(&key.as_str()) {
1218 log::debug!("已转换扩展字段 '{key}' = {value:?}");
1219 } else {
1220 log::info!("已转换自定义字段 '{key}' = {value:?}");
1221 }
1222 }
1223 }
1224 }
1225
1226 Ok(t)
1227}
1228
1229pub fn sync_single_server_to_codex(
1232 _config: &MultiAppConfig,
1233 id: &str,
1234 server_spec: &Value,
1235) -> Result<(), AppError> {
1236 if !crate::sync_policy::should_sync_live(&AppType::Codex) {
1237 return Ok(());
1238 }
1239 use toml_edit::Item;
1240
1241 let config_path = crate::codex_config::get_codex_config_path();
1243
1244 let mut doc = if config_path.exists() {
1245 let content =
1246 std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
1247 match content.parse::<toml_edit::DocumentMut>() {
1249 Ok(doc) => doc,
1250 Err(e) => {
1251 log::warn!("解析 Codex config.toml 失败: {e},将创建新配置");
1252 toml_edit::DocumentMut::new()
1253 }
1254 }
1255 } else {
1256 toml_edit::DocumentMut::new()
1257 };
1258
1259 if let Some(mcp_item) = doc.get_mut("mcp") {
1261 if let Some(tbl) = mcp_item.as_table_like_mut() {
1262 if tbl.contains_key("servers") {
1263 log::warn!("检测到错误的 MCP 格式 [mcp.servers],正在清理并迁移到 [mcp_servers]");
1264 tbl.remove("servers");
1265 }
1266 }
1267 }
1268
1269 if !doc.contains_key("mcp_servers") {
1271 doc["mcp_servers"] = toml_edit::table();
1272 }
1273
1274 let toml_table = json_server_to_toml_table(server_spec)?;
1276
1277 doc["mcp_servers"][id] = Item::Table(toml_table);
1279
1280 let new_text = doc.to_string();
1282 crate::config::write_text_file(&config_path, &new_text)?;
1283
1284 Ok(())
1285}
1286
1287pub fn remove_server_from_codex(id: &str) -> Result<(), AppError> {
1290 if !crate::sync_policy::should_sync_live(&AppType::Codex) {
1291 return Ok(());
1292 }
1293 let config_path = crate::codex_config::get_codex_config_path();
1294
1295 if !config_path.exists() {
1296 return Ok(()); }
1298
1299 let content =
1300 std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
1301
1302 let mut doc = match content.parse::<toml_edit::DocumentMut>() {
1304 Ok(doc) => doc,
1305 Err(e) => {
1306 log::warn!("解析 Codex config.toml 失败: {e},跳过删除操作");
1307 return Ok(());
1308 }
1309 };
1310
1311 if let Some(mcp_servers) = doc.get_mut("mcp_servers").and_then(|s| s.as_table_mut()) {
1313 mcp_servers.remove(id);
1314 }
1315
1316 if let Some(mcp_table) = doc.get_mut("mcp").and_then(|t| t.as_table_mut()) {
1318 if let Some(servers) = mcp_table.get_mut("servers").and_then(|s| s.as_table_mut()) {
1319 if servers.remove(id).is_some() {
1320 log::warn!("从错误的 MCP 格式 [mcp.servers] 中清理了服务器 '{id}'");
1321 }
1322 }
1323 }
1324
1325 let new_text = doc.to_string();
1327 crate::config::write_text_file(&config_path, &new_text)?;
1328
1329 Ok(())
1330}
1331
1332pub fn sync_single_server_to_gemini(
1334 _config: &MultiAppConfig,
1335 id: &str,
1336 server_spec: &Value,
1337) -> Result<(), AppError> {
1338 if !crate::sync_policy::should_sync_live(&AppType::Gemini) {
1339 return Ok(());
1340 }
1341
1342 let current = crate::gemini_mcp::read_mcp_servers_map()?;
1344
1345 let mut updated = current;
1347 updated.insert(id.to_string(), server_spec.clone());
1348
1349 crate::gemini_mcp::set_mcp_servers_map(&updated)
1351}
1352
1353pub fn remove_server_from_gemini(id: &str) -> Result<(), AppError> {
1355 if !crate::sync_policy::should_sync_live(&AppType::Gemini) {
1356 return Ok(());
1357 }
1358
1359 let mut current = crate::gemini_mcp::read_mcp_servers_map()?;
1361
1362 current.remove(id);
1364
1365 crate::gemini_mcp::set_mcp_servers_map(¤t)
1367}
1368
1369pub fn sync_single_server_to_opencode(
1371 _config: &MultiAppConfig,
1372 id: &str,
1373 server_spec: &Value,
1374) -> Result<(), AppError> {
1375 if !crate::sync_policy::should_sync_live(&AppType::OpenCode) {
1376 return Ok(());
1377 }
1378
1379 let spec = convert_to_opencode_mcp_spec(server_spec)?;
1380 crate::opencode_config::set_mcp_server(id, spec)
1381}
1382
1383pub fn remove_server_from_opencode(id: &str) -> Result<(), AppError> {
1385 if !crate::sync_policy::should_sync_live(&AppType::OpenCode) {
1386 return Ok(());
1387 }
1388
1389 crate::opencode_config::remove_mcp_server(id)
1390}
1391
1392const HERMES_EXTRA_FIELDS: &[&str] = &[
1398 "enabled",
1399 "timeout",
1400 "connect_timeout",
1401 "tools",
1402 "sampling",
1403 "roots",
1404 "auth",
1405];
1406
1407fn should_sync_hermes_mcp() -> bool {
1408 crate::hermes_config::get_hermes_dir().exists()
1409}
1410
1411fn convert_to_hermes_format(spec: &Value) -> Result<Value, AppError> {
1413 let obj = spec
1414 .as_object()
1415 .ok_or_else(|| AppError::McpValidation("MCP spec must be a JSON object".into()))?;
1416
1417 let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
1418
1419 let mut result = serde_json::Map::new();
1420
1421 match typ {
1422 "stdio" => {
1423 if let Some(command) = obj.get("command") {
1424 result.insert("command".into(), command.clone());
1425 }
1426 if let Some(args) = obj.get("args") {
1427 if args.is_array() && !args.as_array().map(|a| a.is_empty()).unwrap_or(true) {
1428 result.insert("args".into(), args.clone());
1429 }
1430 }
1431 if let Some(env) = obj.get("env") {
1432 if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
1433 result.insert("env".into(), env.clone());
1434 }
1435 }
1436 }
1437 "sse" | "http" => {
1438 if let Some(url) = obj.get("url") {
1439 result.insert("url".into(), url.clone());
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 }
1448 _ => {
1449 return Err(AppError::McpValidation(format!("Unknown MCP type: {typ}")));
1450 }
1451 }
1452
1453 result.insert("enabled".into(), json!(true));
1454
1455 Ok(Value::Object(result))
1456}
1457
1458fn convert_from_hermes_format(id: &str, spec: &Value) -> Result<Value, AppError> {
1460 let obj = spec
1461 .as_object()
1462 .ok_or_else(|| AppError::McpValidation("Hermes MCP spec must be a JSON object".into()))?;
1463
1464 let mut result = serde_json::Map::new();
1465
1466 if obj.contains_key("command") {
1467 result.insert("type".into(), json!("stdio"));
1468 if let Some(command) = obj.get("command") {
1469 result.insert("command".into(), command.clone());
1470 }
1471 if let Some(args) = obj.get("args") {
1472 if args.is_array() && !args.as_array().map(|a| a.is_empty()).unwrap_or(true) {
1473 result.insert("args".into(), args.clone());
1474 }
1475 }
1476 if let Some(env) = obj.get("env") {
1477 if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) {
1478 result.insert("env".into(), env.clone());
1479 }
1480 }
1481 } else if obj.contains_key("url") {
1482 result.insert("type".into(), json!("sse"));
1483 if let Some(url) = obj.get("url") {
1484 result.insert("url".into(), url.clone());
1485 }
1486 if let Some(headers) = obj.get("headers") {
1487 if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) {
1488 result.insert("headers".into(), headers.clone());
1489 }
1490 }
1491 } else {
1492 return Err(AppError::McpValidation(format!(
1493 "Hermes MCP server '{id}' has neither 'command' nor 'url' field"
1494 )));
1495 }
1496
1497 Ok(Value::Object(result))
1498}
1499
1500fn merge_hermes_spec(existing: &Value, new_spec: &Value) -> Value {
1502 let mut result = serde_json::Map::new();
1503
1504 if let Some(existing_obj) = existing.as_object() {
1505 for &field in HERMES_EXTRA_FIELDS {
1506 if let Some(val) = existing_obj.get(field) {
1507 result.insert(field.to_string(), val.clone());
1508 }
1509 }
1510 }
1511
1512 if let Some(new_obj) = new_spec.as_object() {
1513 for (key, val) in new_obj {
1514 if HERMES_EXTRA_FIELDS.contains(&key.as_str()) && result.contains_key(key) {
1515 continue;
1516 }
1517 result.insert(key.clone(), val.clone());
1518 }
1519 }
1520
1521 Value::Object(result)
1522}
1523
1524pub fn sync_single_server_to_hermes(
1526 _config: &MultiAppConfig,
1527 id: &str,
1528 server_spec: &Value,
1529) -> Result<(), AppError> {
1530 if !should_sync_hermes_mcp() {
1531 return Ok(());
1532 }
1533
1534 let hermes_spec = convert_to_hermes_format(server_spec)?;
1535 let id_owned = id.to_string();
1536
1537 crate::hermes_config::update_mcp_servers_yaml(|servers| {
1538 let id_yaml = serde_yaml::Value::String(id_owned.clone());
1539
1540 let merged_json = if let Some(existing_yaml) = servers.get(&id_yaml) {
1541 let existing_json = crate::hermes_config::yaml_to_json(existing_yaml)?;
1542 merge_hermes_spec(&existing_json, &hermes_spec)
1543 } else {
1544 hermes_spec.clone()
1545 };
1546
1547 let merged_yaml_value = crate::hermes_config::json_to_yaml(&merged_json)?;
1548 servers.insert(id_yaml, merged_yaml_value);
1549 Ok(())
1550 })
1551}
1552
1553pub fn remove_server_from_hermes(id: &str) -> Result<(), AppError> {
1555 if !should_sync_hermes_mcp() {
1556 return Ok(());
1557 }
1558
1559 let id_owned = id.to_string();
1560 crate::hermes_config::update_mcp_servers_yaml(|servers| {
1561 servers.remove(serde_yaml::Value::String(id_owned.clone()));
1562 Ok(())
1563 })
1564}
1565
1566pub fn import_from_hermes(config: &mut MultiAppConfig) -> Result<usize, AppError> {
1568 use crate::app_config::{McpApps, McpServer};
1569
1570 let yaml_map = crate::hermes_config::get_mcp_servers_yaml()?;
1571 if yaml_map.is_empty() {
1572 return Ok(0);
1573 }
1574
1575 if config.mcp.servers.is_none() {
1576 config.mcp.servers = Some(HashMap::new());
1577 }
1578 let servers = config.mcp.servers.as_mut().unwrap();
1579
1580 let mut changed = 0;
1581 let mut errors = Vec::new();
1582
1583 for (key, spec_yaml) in &yaml_map {
1584 let id = match key.as_str() {
1585 Some(s) => s.to_string(),
1586 None => {
1587 log::warn!("Skip Hermes MCP server with non-string key");
1588 continue;
1589 }
1590 };
1591
1592 let spec_json = match crate::hermes_config::yaml_to_json(spec_yaml) {
1593 Ok(j) => j,
1594 Err(e) => {
1595 log::warn!("Skip Hermes MCP server '{id}': failed to convert YAML to JSON: {e}");
1596 errors.push(format!("{id}: {e}"));
1597 continue;
1598 }
1599 };
1600
1601 let unified_spec = match convert_from_hermes_format(&id, &spec_json) {
1602 Ok(s) => s,
1603 Err(e) => {
1604 log::warn!("Skip invalid Hermes MCP server '{id}': {e}");
1605 errors.push(format!("{id}: {e}"));
1606 continue;
1607 }
1608 };
1609
1610 if let Err(e) = validate_server_spec(&unified_spec) {
1611 log::warn!("Skip invalid MCP server '{id}' after conversion: {e}");
1612 errors.push(format!("{id}: {e}"));
1613 continue;
1614 }
1615
1616 if let Some(existing) = servers.get_mut(&id) {
1617 if !existing.apps.hermes {
1618 existing.apps.hermes = true;
1619 changed += 1;
1620 log::info!("MCP server '{id}' enabled for Hermes");
1621 }
1622 } else {
1623 servers.insert(
1624 id.clone(),
1625 McpServer {
1626 id: id.clone(),
1627 name: id.clone(),
1628 server: unified_spec,
1629 apps: McpApps {
1630 claude: false,
1631 codex: false,
1632 gemini: false,
1633 opencode: false,
1634 hermes: true,
1635 },
1636 description: None,
1637 homepage: None,
1638 docs: None,
1639 tags: Vec::new(),
1640 },
1641 );
1642 changed += 1;
1643 log::info!("Imported new MCP server '{id}' from Hermes");
1644 }
1645 }
1646
1647 if !errors.is_empty() {
1648 log::warn!(
1649 "Import completed with {} failures: {:?}",
1650 errors.len(),
1651 errors
1652 );
1653 }
1654
1655 Ok(changed)
1656}