1#[cfg(test)]
17#[path = "mcp_tests.rs"]
18mod tests;
19
20use anyhow::{Context, Result, bail};
21use serde::{Deserialize, Serialize};
22use std::collections::BTreeMap;
23use std::fs;
24use std::path::{Path, PathBuf};
25
26const MCP_PREFIX: &str = "zag-";
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct McpServer {
35 pub name: String,
37 #[serde(default)]
39 pub description: String,
40 #[serde(default = "default_transport")]
42 pub transport: String,
43
44 #[serde(default)]
47 pub command: Option<String>,
48 #[serde(default)]
50 pub args: Vec<String>,
51
52 #[serde(default)]
55 pub url: Option<String>,
56 #[serde(default)]
58 pub bearer_token_env_var: Option<String>,
59 #[serde(default)]
61 pub headers: BTreeMap<String, String>,
62
63 #[serde(default)]
66 pub env: BTreeMap<String, String>,
67}
68
69fn default_transport() -> String {
70 "stdio".to_string()
71}
72
73pub fn mcp_dir() -> PathBuf {
79 dirs::home_dir()
80 .unwrap_or_else(|| PathBuf::from("."))
81 .join(".zag")
82 .join("mcp")
83}
84
85pub fn project_mcp_dir(root: Option<&str>) -> Option<PathBuf> {
88 let base = dirs::home_dir()?.join(".zag");
89
90 let project_dir = if let Some(r) = root {
91 let sanitized = crate::config::Config::sanitize_path(r);
92 base.join("projects").join(sanitized)
93 } else {
94 let current_dir = std::env::current_dir().ok()?;
95 let git_root = find_git_root(¤t_dir)?;
96 let sanitized = crate::config::Config::sanitize_path(&git_root.to_string_lossy());
97 base.join("projects").join(sanitized)
98 };
99
100 Some(project_dir.join("mcp"))
101}
102
103fn find_git_root(start_dir: &Path) -> Option<PathBuf> {
104 let output = std::process::Command::new("git")
105 .arg("rev-parse")
106 .arg("--show-toplevel")
107 .current_dir(start_dir)
108 .output()
109 .ok()?;
110 if output.status.success() {
111 let root = String::from_utf8(output.stdout).ok()?;
112 Some(PathBuf::from(root.trim()))
113 } else {
114 None
115 }
116}
117
118pub fn provider_mcp_config_path(provider: &str) -> Option<PathBuf> {
125 let home = dirs::home_dir()?;
126 match provider {
127 "claude" => Some(home.join(".claude.json")),
128 "gemini" => Some(home.join(".gemini").join("settings.json")),
129 "copilot" => Some(home.join(".copilot").join("mcp-config.json")),
130 "codex" => Some(home.join(".codex").join("config.toml")),
131 _ => None,
132 }
133}
134
135pub const MCP_PROVIDERS: &[&str] = &["claude", "gemini", "copilot", "codex"];
137
138pub fn parse_server(path: &Path) -> Result<McpServer> {
144 let content =
145 fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
146 let server: McpServer = toml::from_str(&content)
147 .with_context(|| format!("Failed to parse MCP server config {}", path.display()))?;
148 Ok(server)
149}
150
151fn load_servers_from(dir: &Path) -> Result<Vec<McpServer>> {
153 if !dir.exists() {
154 return Ok(Vec::new());
155 }
156
157 let mut servers = Vec::new();
158 for entry in fs::read_dir(dir)
159 .with_context(|| format!("Failed to read MCP directory {}", dir.display()))?
160 {
161 let entry = entry?;
162 let path = entry.path();
163 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
164 continue;
165 }
166 match parse_server(&path) {
167 Ok(server) => servers.push(server),
168 Err(e) => {
169 log::warn!("Skipping MCP server at {}: {}", path.display(), e);
170 }
171 }
172 }
173 servers.sort_by(|a, b| a.name.cmp(&b.name));
174 Ok(servers)
175}
176
177pub fn load_global_servers() -> Result<Vec<McpServer>> {
179 load_servers_from(&mcp_dir())
180}
181
182pub fn load_project_servers(root: Option<&str>) -> Result<Vec<McpServer>> {
184 match project_mcp_dir(root) {
185 Some(dir) => load_servers_from(&dir),
186 None => Ok(Vec::new()),
187 }
188}
189
190pub fn load_all_servers(root: Option<&str>) -> Result<Vec<McpServer>> {
192 let mut by_name: BTreeMap<String, McpServer> = BTreeMap::new();
193
194 for server in load_global_servers()? {
195 by_name.insert(server.name.clone(), server);
196 }
197 for server in load_project_servers(root)? {
198 by_name.insert(server.name.clone(), server);
199 }
200
201 Ok(by_name.into_values().collect())
202}
203
204pub fn list_servers(root: Option<&str>) -> Result<Vec<McpServer>> {
206 load_all_servers(root)
207}
208
209pub fn get_server(name: &str, root: Option<&str>) -> Result<McpServer> {
211 if let Some(dir) = project_mcp_dir(root) {
213 let path = dir.join(format!("{}.toml", name));
214 if path.exists() {
215 return parse_server(&path);
216 }
217 }
218 let path = mcp_dir().join(format!("{}.toml", name));
220 if path.exists() {
221 return parse_server(&path);
222 }
223 bail!("MCP server '{}' not found", name);
224}
225
226pub fn add_server(server: &McpServer, project: bool, root: Option<&str>) -> Result<PathBuf> {
229 let dir = if project {
230 project_mcp_dir(root).context("Not in a project (no git root found)")?
231 } else {
232 mcp_dir()
233 };
234 fs::create_dir_all(&dir)
235 .with_context(|| format!("Failed to create MCP directory {}", dir.display()))?;
236
237 let path = dir.join(format!("{}.toml", server.name));
238 if path.exists() {
239 bail!(
240 "MCP server '{}' already exists at {}",
241 server.name,
242 path.display()
243 );
244 }
245
246 let content =
247 toml::to_string_pretty(server).context("Failed to serialize MCP server config")?;
248 fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display()))?;
249 Ok(path)
250}
251
252pub fn remove_server(name: &str, root: Option<&str>) -> Result<()> {
254 let mut found = false;
255
256 if let Some(dir) = project_mcp_dir(root) {
258 let path = dir.join(format!("{}.toml", name));
259 if path.exists() {
260 fs::remove_file(&path)
261 .with_context(|| format!("Failed to remove {}", path.display()))?;
262 found = true;
263 }
264 }
265
266 let path = mcp_dir().join(format!("{}.toml", name));
268 if path.exists() {
269 fs::remove_file(&path).with_context(|| format!("Failed to remove {}", path.display()))?;
270 found = true;
271 }
272
273 if !found {
274 bail!("MCP server '{}' not found", name);
275 }
276
277 for provider in MCP_PROVIDERS {
279 if let Err(e) = remove_server_from_provider(provider, name) {
280 log::warn!(
281 "Failed to clean up {} config for '{}': {}",
282 provider,
283 name,
284 e
285 );
286 }
287 }
288
289 Ok(())
290}
291
292fn server_to_json(server: &McpServer, provider: &str) -> serde_json::Value {
298 let mut entry = serde_json::Map::new();
299
300 if server.transport == "stdio" {
301 if let Some(ref cmd) = server.command {
302 match provider {
304 "copilot" => {
305 entry.insert("type".into(), serde_json::json!("local"));
306 }
307 "claude" => {
308 entry.insert("type".into(), serde_json::json!("stdio"));
309 }
310 _ => {}
311 }
312 entry.insert("command".into(), serde_json::json!(cmd));
313 if !server.args.is_empty() {
314 entry.insert("args".into(), serde_json::json!(server.args));
315 }
316 }
317 } else if server.transport == "http" {
318 if let Some(ref url) = server.url {
319 match provider {
320 "copilot" => {
321 entry.insert("type".into(), serde_json::json!("http"));
322 entry.insert("url".into(), serde_json::json!(url));
323 }
324 "gemini" => {
325 entry.insert("httpUrl".into(), serde_json::json!(url));
326 }
327 _ => {
328 entry.insert("type".into(), serde_json::json!("http"));
329 entry.insert("url".into(), serde_json::json!(url));
330 }
331 }
332 }
333 if !server.headers.is_empty() {
334 entry.insert("headers".into(), serde_json::json!(server.headers));
335 }
336 }
337
338 if !server.env.is_empty() {
339 entry.insert("env".into(), serde_json::json!(server.env));
340 }
341
342 serde_json::Value::Object(entry)
343}
344
345fn sync_json_provider(provider: &str, servers: &[McpServer]) -> Result<usize> {
348 let Some(config_path) = provider_mcp_config_path(provider) else {
349 return Ok(0);
350 };
351
352 let mut config: serde_json::Value = if config_path.exists() {
354 let content = fs::read_to_string(&config_path)
355 .with_context(|| format!("Failed to read {}", config_path.display()))?;
356 serde_json::from_str(&content)
357 .with_context(|| format!("Failed to parse {}", config_path.display()))?
358 } else {
359 serde_json::json!({})
360 };
361
362 let mcp_servers = config
364 .as_object_mut()
365 .context("Config is not a JSON object")?
366 .entry("mcpServers")
367 .or_insert_with(|| serde_json::json!({}));
368
369 let mcp_map = mcp_servers
370 .as_object_mut()
371 .context("mcpServers is not a JSON object")?;
372
373 let zag_keys: Vec<String> = mcp_map
375 .keys()
376 .filter(|k| k.starts_with(MCP_PREFIX))
377 .cloned()
378 .collect();
379 for key in &zag_keys {
380 mcp_map.remove(key);
381 }
382
383 let mut synced = 0;
385 for server in servers {
386 let key = format!("{}{}", MCP_PREFIX, server.name);
387 let value = server_to_json(server, provider);
388 mcp_map.insert(key, value);
389 synced += 1;
390 }
391
392 if let Some(parent) = config_path.parent() {
394 fs::create_dir_all(parent)?;
395 }
396
397 let content = serde_json::to_string_pretty(&config)?;
399 fs::write(&config_path, format!("{}\n", content))
400 .with_context(|| format!("Failed to write {}", config_path.display()))?;
401
402 log::debug!(
403 "Synced {} MCP server(s) to {} at {}",
404 synced,
405 provider,
406 config_path.display()
407 );
408
409 Ok(synced)
410}
411
412fn sync_codex_provider(servers: &[McpServer]) -> Result<usize> {
415 let Some(config_path) = provider_mcp_config_path("codex") else {
416 return Ok(0);
417 };
418
419 let mut config: toml::Table = if config_path.exists() {
421 let content = fs::read_to_string(&config_path)
422 .with_context(|| format!("Failed to read {}", config_path.display()))?;
423 content
424 .parse::<toml::Table>()
425 .with_context(|| format!("Failed to parse {}", config_path.display()))?
426 } else {
427 toml::Table::new()
428 };
429
430 let mcp_table = config
432 .entry("mcp_servers")
433 .or_insert_with(|| toml::Value::Table(toml::Table::new()))
434 .as_table_mut()
435 .context("mcp_servers is not a TOML table")?;
436
437 let zag_keys: Vec<String> = mcp_table
439 .keys()
440 .filter(|k| k.starts_with(MCP_PREFIX))
441 .cloned()
442 .collect();
443 for key in &zag_keys {
444 mcp_table.remove(key.as_str());
445 }
446
447 let mut synced = 0;
449 for server in servers {
450 let key = format!("{}{}", MCP_PREFIX, server.name);
451 let mut entry = toml::Table::new();
452
453 if server.transport == "stdio" {
454 if let Some(ref cmd) = server.command {
455 entry.insert("command".into(), toml::Value::String(cmd.clone()));
456 }
457 if !server.args.is_empty() {
458 let args: Vec<toml::Value> = server
459 .args
460 .iter()
461 .map(|a| toml::Value::String(a.clone()))
462 .collect();
463 entry.insert("args".into(), toml::Value::Array(args));
464 }
465 } else if server.transport == "http" {
466 if let Some(ref url) = server.url {
467 entry.insert("url".into(), toml::Value::String(url.clone()));
468 }
469 if let Some(ref token_var) = server.bearer_token_env_var {
470 entry.insert(
471 "bearer_token_env_var".into(),
472 toml::Value::String(token_var.clone()),
473 );
474 }
475 }
476
477 if !server.env.is_empty() {
478 let mut env_table = toml::Table::new();
479 for (k, v) in &server.env {
480 env_table.insert(k.clone(), toml::Value::String(v.clone()));
481 }
482 entry.insert("env".into(), toml::Value::Table(env_table));
483 }
484
485 mcp_table.insert(key, toml::Value::Table(entry));
486 synced += 1;
487 }
488
489 if let Some(parent) = config_path.parent() {
491 fs::create_dir_all(parent)?;
492 }
493
494 let content = toml::to_string_pretty(&config)?;
495 fs::write(&config_path, &content)
496 .with_context(|| format!("Failed to write {}", config_path.display()))?;
497
498 log::debug!(
499 "Synced {} MCP server(s) to codex at {}",
500 synced,
501 config_path.display()
502 );
503
504 Ok(synced)
505}
506
507pub fn sync_servers_for_provider(provider: &str, servers: &[McpServer]) -> Result<usize> {
509 match provider {
510 "claude" | "gemini" | "copilot" => sync_json_provider(provider, servers),
511 "codex" => sync_codex_provider(servers),
512 _ => {
513 log::debug!("Provider '{}' does not support MCP servers", provider);
514 Ok(0)
515 }
516 }
517}
518
519fn remove_server_from_provider(provider: &str, name: &str) -> Result<()> {
521 let Some(config_path) = provider_mcp_config_path(provider) else {
522 return Ok(());
523 };
524 if !config_path.exists() {
525 return Ok(());
526 }
527
528 let key = format!("{}{}", MCP_PREFIX, name);
529
530 if provider == "codex" {
531 let content = fs::read_to_string(&config_path)?;
532 let mut config: toml::Table = content.parse()?;
533 if let Some(mcp) = config.get_mut("mcp_servers").and_then(|v| v.as_table_mut()) {
534 mcp.remove(&key);
535 }
536 let content = toml::to_string_pretty(&config)?;
537 fs::write(&config_path, &content)?;
538 } else {
539 let content = fs::read_to_string(&config_path)?;
540 let mut config: serde_json::Value = serde_json::from_str(&content)?;
541 if let Some(mcp) = config
542 .as_object_mut()
543 .and_then(|o| o.get_mut("mcpServers"))
544 .and_then(|v| v.as_object_mut())
545 {
546 mcp.remove(&key);
547 }
548 let content = serde_json::to_string_pretty(&config)?;
549 fs::write(&config_path, format!("{}\n", content))?;
550 }
551
552 Ok(())
553}
554
555pub fn import_servers(from_provider: &str) -> Result<Vec<String>> {
563 let Some(config_path) = provider_mcp_config_path(from_provider) else {
564 bail!("Provider '{}' does not support MCP servers", from_provider);
565 };
566
567 if !config_path.exists() {
568 bail!(
569 "No MCP config found for '{}' at {}",
570 from_provider,
571 config_path.display()
572 );
573 }
574
575 if from_provider == "codex" {
576 import_from_codex_toml(&config_path)
577 } else {
578 import_from_json(&config_path, from_provider)
579 }
580}
581
582fn import_from_json(config_path: &Path, provider: &str) -> Result<Vec<String>> {
584 let content = fs::read_to_string(config_path)?;
585 let config: serde_json::Value = serde_json::from_str(&content)
586 .with_context(|| format!("Failed to parse {}", config_path.display()))?;
587
588 let mcp_servers = match config.get("mcpServers").and_then(|v| v.as_object()) {
589 Some(obj) => obj,
590 None => return Ok(Vec::new()),
591 };
592
593 let dest_dir = mcp_dir();
594 fs::create_dir_all(&dest_dir)?;
595
596 let mut imported = Vec::new();
597
598 for (name, value) in mcp_servers {
599 if name.starts_with(MCP_PREFIX) {
601 continue;
602 }
603
604 let dest = dest_dir.join(format!("{}.toml", name));
605 if dest.exists() {
606 log::debug!("Skipping '{}': already exists in ~/.zag/mcp/", name);
607 continue;
608 }
609
610 let server = json_entry_to_server(name, value, provider);
611 let content = toml::to_string_pretty(&server).context("Failed to serialize MCP server")?;
612 fs::write(&dest, content).with_context(|| format!("Failed to write {}", dest.display()))?;
613
614 imported.push(name.clone());
615 }
616
617 Ok(imported)
618}
619
620fn json_entry_to_server(name: &str, value: &serde_json::Value, provider: &str) -> McpServer {
622 let obj = value.as_object();
623
624 let transport = if obj.and_then(|o| o.get("url")).is_some()
626 || obj.and_then(|o| o.get("httpUrl")).is_some()
627 {
628 "http".to_string()
629 } else {
630 "stdio".to_string()
631 };
632
633 let command = obj
634 .and_then(|o| o.get("command"))
635 .and_then(|v| v.as_str())
636 .map(|s| s.to_string());
637
638 let args = obj
639 .and_then(|o| o.get("args"))
640 .and_then(|v| v.as_array())
641 .map(|arr| {
642 arr.iter()
643 .filter_map(|v| v.as_str().map(|s| s.to_string()))
644 .collect()
645 })
646 .unwrap_or_default();
647
648 let url = obj
649 .and_then(|o| o.get("url").or_else(|| o.get("httpUrl")))
650 .and_then(|v| v.as_str())
651 .map(|s| s.to_string());
652
653 let env = obj
654 .and_then(|o| o.get("env"))
655 .and_then(|v| v.as_object())
656 .map(|obj| {
657 obj.iter()
658 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
659 .collect()
660 })
661 .unwrap_or_default();
662
663 let headers = obj
664 .and_then(|o| o.get("headers"))
665 .and_then(|v| v.as_object())
666 .map(|obj| {
667 obj.iter()
668 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
669 .collect()
670 })
671 .unwrap_or_default();
672
673 let _ = provider; McpServer {
676 name: name.to_string(),
677 description: String::new(),
678 transport,
679 command,
680 args,
681 url,
682 bearer_token_env_var: None,
683 headers,
684 env,
685 }
686}
687
688fn import_from_codex_toml(config_path: &Path) -> Result<Vec<String>> {
690 let content = fs::read_to_string(config_path)?;
691 let config: toml::Table = content
692 .parse()
693 .with_context(|| format!("Failed to parse {}", config_path.display()))?;
694
695 let mcp_servers = match config.get("mcp_servers").and_then(|v| v.as_table()) {
696 Some(t) => t,
697 None => return Ok(Vec::new()),
698 };
699
700 let dest_dir = mcp_dir();
701 fs::create_dir_all(&dest_dir)?;
702
703 let mut imported = Vec::new();
704
705 for (name, value) in mcp_servers {
706 if name.starts_with(MCP_PREFIX) {
707 continue;
708 }
709
710 let dest = dest_dir.join(format!("{}.toml", name));
711 if dest.exists() {
712 log::debug!("Skipping '{}': already exists in ~/.zag/mcp/", name);
713 continue;
714 }
715
716 let server = toml_entry_to_server(name, value);
717 let content = toml::to_string_pretty(&server)?;
718 fs::write(&dest, content).with_context(|| format!("Failed to write {}", dest.display()))?;
719
720 imported.push(name.clone());
721 }
722
723 Ok(imported)
724}
725
726fn toml_entry_to_server(name: &str, value: &toml::Value) -> McpServer {
728 let table = value.as_table();
729
730 let transport = if table.and_then(|t| t.get("url")).is_some() {
731 "http".to_string()
732 } else {
733 "stdio".to_string()
734 };
735
736 let command = table
737 .and_then(|t| t.get("command"))
738 .and_then(|v| v.as_str())
739 .map(|s| s.to_string());
740
741 let args = table
742 .and_then(|t| t.get("args"))
743 .and_then(|v| v.as_array())
744 .map(|arr| {
745 arr.iter()
746 .filter_map(|v| v.as_str().map(|s| s.to_string()))
747 .collect()
748 })
749 .unwrap_or_default();
750
751 let url = table
752 .and_then(|t| t.get("url"))
753 .and_then(|v| v.as_str())
754 .map(|s| s.to_string());
755
756 let bearer_token_env_var = table
757 .and_then(|t| t.get("bearer_token_env_var"))
758 .and_then(|v| v.as_str())
759 .map(|s| s.to_string());
760
761 let env = table
762 .and_then(|t| t.get("env"))
763 .and_then(|v| v.as_table())
764 .map(|t| {
765 t.iter()
766 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
767 .collect()
768 })
769 .unwrap_or_default();
770
771 McpServer {
772 name: name.to_string(),
773 description: String::new(),
774 transport,
775 command,
776 args,
777 url,
778 bearer_token_env_var,
779 headers: BTreeMap::new(),
780 env,
781 }
782}
783
784pub fn setup_mcp(provider: &str, root: Option<&str>) -> Result<()> {
790 let servers = load_all_servers(root)?;
791 if servers.is_empty() {
792 return Ok(());
793 }
794
795 let synced = sync_servers_for_provider(provider, &servers)?;
796 if synced > 0 {
797 log::info!("Synced {} MCP server(s) for {}", synced, provider);
798 }
799
800 Ok(())
801}
802
803pub fn load_servers_from_dir(dir: &Path) -> Result<Vec<McpServer>> {
809 load_servers_from(dir)
810}
811
812pub fn sync_json_provider_to(
814 provider: &str,
815 servers: &[McpServer],
816 config_path: &Path,
817) -> Result<usize> {
818 let mut config: serde_json::Value = if config_path.exists() {
819 let content = fs::read_to_string(config_path)?;
820 serde_json::from_str(&content)?
821 } else {
822 serde_json::json!({})
823 };
824
825 let mcp_servers = config
826 .as_object_mut()
827 .context("Config is not a JSON object")?
828 .entry("mcpServers")
829 .or_insert_with(|| serde_json::json!({}));
830
831 let mcp_map = mcp_servers
832 .as_object_mut()
833 .context("mcpServers is not a JSON object")?;
834
835 let zag_keys: Vec<String> = mcp_map
837 .keys()
838 .filter(|k| k.starts_with(MCP_PREFIX))
839 .cloned()
840 .collect();
841 for key in &zag_keys {
842 mcp_map.remove(key);
843 }
844
845 let mut synced = 0;
846 for server in servers {
847 let key = format!("{}{}", MCP_PREFIX, server.name);
848 let value = server_to_json(server, provider);
849 mcp_map.insert(key, value);
850 synced += 1;
851 }
852
853 let content = serde_json::to_string_pretty(&config)?;
854 fs::write(config_path, format!("{}\n", content))?;
855 Ok(synced)
856}
857
858pub fn sync_codex_provider_to(servers: &[McpServer], config_path: &Path) -> Result<usize> {
860 let mut config: toml::Table = if config_path.exists() {
861 let content = fs::read_to_string(config_path)?;
862 content.parse()?
863 } else {
864 toml::Table::new()
865 };
866
867 let mcp_table = config
868 .entry("mcp_servers")
869 .or_insert_with(|| toml::Value::Table(toml::Table::new()))
870 .as_table_mut()
871 .context("mcp_servers is not a TOML table")?;
872
873 let zag_keys: Vec<String> = mcp_table
874 .keys()
875 .filter(|k| k.starts_with(MCP_PREFIX))
876 .cloned()
877 .collect();
878 for key in &zag_keys {
879 mcp_table.remove(key.as_str());
880 }
881
882 let mut synced = 0;
883 for server in servers {
884 let key = format!("{}{}", MCP_PREFIX, server.name);
885 let mut entry = toml::Table::new();
886
887 if server.transport == "stdio" {
888 if let Some(ref cmd) = server.command {
889 entry.insert("command".into(), toml::Value::String(cmd.clone()));
890 }
891 if !server.args.is_empty() {
892 let args: Vec<toml::Value> = server
893 .args
894 .iter()
895 .map(|a| toml::Value::String(a.clone()))
896 .collect();
897 entry.insert("args".into(), toml::Value::Array(args));
898 }
899 } else if server.transport == "http" {
900 if let Some(ref url) = server.url {
901 entry.insert("url".into(), toml::Value::String(url.clone()));
902 }
903 if let Some(ref token_var) = server.bearer_token_env_var {
904 entry.insert(
905 "bearer_token_env_var".into(),
906 toml::Value::String(token_var.clone()),
907 );
908 }
909 }
910
911 if !server.env.is_empty() {
912 let mut env_table = toml::Table::new();
913 for (k, v) in &server.env {
914 env_table.insert(k.clone(), toml::Value::String(v.clone()));
915 }
916 entry.insert("env".into(), toml::Value::Table(env_table));
917 }
918
919 mcp_table.insert(key, toml::Value::Table(entry));
920 synced += 1;
921 }
922
923 let content = toml::to_string_pretty(&config)?;
924 fs::write(config_path, &content)?;
925 Ok(synced)
926}
927
928pub fn import_from_json_to(
930 config_path: &Path,
931 provider: &str,
932 dest_dir: &Path,
933) -> Result<Vec<String>> {
934 let content = fs::read_to_string(config_path)?;
935 let config: serde_json::Value = serde_json::from_str(&content)?;
936
937 let mcp_servers = match config.get("mcpServers").and_then(|v| v.as_object()) {
938 Some(obj) => obj,
939 None => return Ok(Vec::new()),
940 };
941
942 fs::create_dir_all(dest_dir)?;
943 let mut imported = Vec::new();
944
945 for (name, value) in mcp_servers {
946 if name.starts_with(MCP_PREFIX) {
947 continue;
948 }
949 let dest = dest_dir.join(format!("{}.toml", name));
950 if dest.exists() {
951 continue;
952 }
953 let server = json_entry_to_server(name, value, provider);
954 let content = toml::to_string_pretty(&server)?;
955 fs::write(&dest, content)?;
956 imported.push(name.clone());
957 }
958
959 Ok(imported)
960}
961
962pub fn import_from_codex_to(config_path: &Path, dest_dir: &Path) -> Result<Vec<String>> {
964 let content = fs::read_to_string(config_path)?;
965 let config: toml::Table = content.parse()?;
966
967 let mcp_servers = match config.get("mcp_servers").and_then(|v| v.as_table()) {
968 Some(t) => t,
969 None => return Ok(Vec::new()),
970 };
971
972 fs::create_dir_all(dest_dir)?;
973 let mut imported = Vec::new();
974
975 for (name, value) in mcp_servers {
976 if name.starts_with(MCP_PREFIX) {
977 continue;
978 }
979 let dest = dest_dir.join(format!("{}.toml", name));
980 if dest.exists() {
981 continue;
982 }
983 let server = toml_entry_to_server(name, value);
984 let content = toml::to_string_pretty(&server)?;
985 fs::write(&dest, content)?;
986 imported.push(name.clone());
987 }
988
989 Ok(imported)
990}