Skip to main content

zag_agent/
mcp.rs

1/// Provider-agnostic MCP (Model Context Protocol) server management.
2///
3/// MCP server configs are stored as individual TOML files:
4/// - Global: `~/.zag/mcp/<server-name>.toml`
5/// - Project-scoped: `~/.zag/projects/<sanitized-path>/mcp/<server-name>.toml`
6///
7/// During sync, servers are injected into each provider's native config format
8/// with a `zag-` prefix to avoid collisions with user-managed servers.
9///
10/// Supported providers:
11/// - Claude: `~/.claude.json` under `mcpServers` (JSON)
12/// - Gemini: `~/.gemini/settings.json` under `mcpServers` (JSON)
13/// - Copilot: `~/.copilot/mcp-config.json` under `mcpServers` (JSON)
14/// - Codex: `~/.codex/config.toml` under `[mcp_servers]` (TOML)
15/// - Ollama: No native MCP support
16#[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// ---------------------------------------------------------------------------
29// Data structures
30// ---------------------------------------------------------------------------
31
32/// An MCP server configuration (one per TOML file).
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct McpServer {
35    /// Human-readable name (also the filename stem).
36    pub name: String,
37    /// Optional description.
38    #[serde(default)]
39    pub description: String,
40    /// Transport type: "stdio" or "http".
41    #[serde(default = "default_transport")]
42    pub transport: String,
43
44    // -- stdio fields --
45    /// Command to start the server (stdio transport).
46    #[serde(default)]
47    pub command: Option<String>,
48    /// Arguments for the command.
49    #[serde(default)]
50    pub args: Vec<String>,
51
52    // -- http fields --
53    /// URL endpoint (http transport).
54    #[serde(default)]
55    pub url: Option<String>,
56    /// Environment variable name containing a bearer token (http transport).
57    #[serde(default)]
58    pub bearer_token_env_var: Option<String>,
59    /// HTTP headers for http transport.
60    #[serde(default)]
61    pub headers: BTreeMap<String, String>,
62
63    // -- shared --
64    /// Environment variables forwarded to the server process.
65    #[serde(default)]
66    pub env: BTreeMap<String, String>,
67}
68
69fn default_transport() -> String {
70    "stdio".to_string()
71}
72
73// ---------------------------------------------------------------------------
74// Directory helpers
75// ---------------------------------------------------------------------------
76
77/// Returns `~/.zag/mcp/`.
78pub fn mcp_dir() -> PathBuf {
79    dirs::home_dir()
80        .unwrap_or_else(|| PathBuf::from("."))
81        .join(".zag")
82        .join("mcp")
83}
84
85/// Returns the project-scoped MCP directory for the given root.
86/// Uses the same sanitization as config: `~/.zag/projects/<sanitized-path>/mcp/`.
87pub 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(&current_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
118/// Returns the provider's native MCP config file path, or `None` if unsupported.
119///
120/// - Claude: `~/.claude.json`
121/// - Gemini: `~/.gemini/settings.json`
122/// - Copilot: `~/.copilot/mcp-config.json`
123/// - Codex: `~/.codex/config.toml`
124pub 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
135/// List of providers that support MCP.
136pub const MCP_PROVIDERS: &[&str] = &["claude", "gemini", "copilot", "codex"];
137
138// ---------------------------------------------------------------------------
139// Loading / saving individual servers
140// ---------------------------------------------------------------------------
141
142/// Parse an MCP server from a TOML file.
143pub 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
151/// Load all MCP servers from a directory. Silently skips invalid files.
152fn 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
177/// Load all global MCP servers from `~/.zag/mcp/`.
178pub fn load_global_servers() -> Result<Vec<McpServer>> {
179    load_servers_from(&mcp_dir())
180}
181
182/// Load project-scoped MCP servers. Returns empty vec if not in a project.
183pub 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
190/// Load all MCP servers (global + project-scoped, project overrides global).
191pub 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
204/// List all MCP servers (alias for load_all_servers).
205pub fn list_servers(root: Option<&str>) -> Result<Vec<McpServer>> {
206    load_all_servers(root)
207}
208
209/// Get a single MCP server by name. Checks project-scoped first, then global.
210pub fn get_server(name: &str, root: Option<&str>) -> Result<McpServer> {
211    // Check project-scoped first
212    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    // Check global
219    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
226/// Create a new MCP server config file. Returns the path to the new file.
227/// If `project` is true, creates in project-scoped dir; otherwise global.
228pub 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
252/// Remove an MCP server config file and clean up provider configs.
253pub fn remove_server(name: &str, root: Option<&str>) -> Result<()> {
254    let mut found = false;
255
256    // Try project-scoped first
257    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    // Try global
267    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    // Remove from all provider configs
278    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
292// ---------------------------------------------------------------------------
293// Provider sync: convert McpServer → provider-native format
294// ---------------------------------------------------------------------------
295
296/// Convert an MCP server to a Claude/Gemini/Copilot JSON entry (serde_json::Value).
297fn 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            // Copilot uses "type": "local", Claude uses "type": "stdio", Gemini omits type
303            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
345/// Sync all MCP servers into a JSON-based provider's config file.
346/// Only touches entries with the `zag-` prefix.
347fn 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    // Read existing config (or start with empty object)
353    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    // Ensure mcpServers object exists
363    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    // Remove all existing zag- entries
374    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    // Add current servers with zag- prefix
384    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    // Ensure parent directory exists
393    if let Some(parent) = config_path.parent() {
394        fs::create_dir_all(parent)?;
395    }
396
397    // Write back with pretty printing
398    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
412/// Sync all MCP servers into Codex's TOML config.
413/// Only touches entries under `[mcp_servers]` with the `zag-` prefix.
414fn 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    // Read existing config as a TOML table
420    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    // Get or create mcp_servers table
431    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    // Remove all existing zag- entries
438    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    // Add current servers with zag- prefix
448    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    // Ensure parent directory exists
490    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
507/// Sync MCP servers for a specific provider.
508pub 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
519/// Remove a single zag-managed server from a provider's config.
520fn 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
555// ---------------------------------------------------------------------------
556// Import from provider configs
557// ---------------------------------------------------------------------------
558
559/// Import MCP servers from a provider's native config into `~/.zag/mcp/`.
560/// Skips entries prefixed with `zag-` (our own entries).
561/// Returns names of imported servers.
562pub 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
582/// Import MCP servers from a JSON config (Claude, Gemini, Copilot).
583fn 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        // Skip our own entries
600        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
620/// Convert a JSON mcpServers entry to our McpServer struct.
621fn json_entry_to_server(name: &str, value: &serde_json::Value, provider: &str) -> McpServer {
622    let obj = value.as_object();
623
624    // Detect transport
625    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; // reserved for provider-specific quirks
674
675    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
688/// Import MCP servers from Codex TOML config.
689fn 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
726/// Convert a Codex TOML mcp_servers entry to our McpServer struct.
727fn 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
784// ---------------------------------------------------------------------------
785// Orchestration (called automatically before agent sessions)
786// ---------------------------------------------------------------------------
787
788/// Set up MCP servers for the given provider. Called before each agent session.
789pub 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
803// ---------------------------------------------------------------------------
804// Testable variants (accept custom directories)
805// ---------------------------------------------------------------------------
806
807/// Load servers from a custom base directory (for testing).
808pub fn load_servers_from_dir(dir: &Path) -> Result<Vec<McpServer>> {
809    load_servers_from(dir)
810}
811
812/// Sync MCP servers to a JSON-format provider config at a custom path (for testing).
813pub 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    // Remove existing zag- entries
836    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
858/// Sync MCP servers to a Codex TOML config at a custom path (for testing).
859pub 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
928/// Import MCP servers from a JSON config file to a custom destination (for testing).
929pub 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
962/// Import MCP servers from a Codex TOML config file to a custom destination (for testing).
963pub 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}