Skip to main content

atman_runtime/
mcp_config.rs

1//! MCP server config file management — single source of truth.
2//!
3//! Reads and writes `mcp_servers.json` and `config.toml [[mcp]]` blocks.
4//! All operations go through [`McpServerConfig`]:
5//!
6//! - [`load`] → `Vec<McpServerConfig>` (from JSON + TOML + Claude Desktop)
7//! - [`save`] → writes to `mcp_servers.json`
8//! - [`toggle_disabled`] = load → toggle → save
9//! - [`remove`] = load → filter → save
10
11use std::path::{Path, PathBuf};
12
13use crate::mcp::{McpServerConfig, TransportKind};
14use crate::tool::Tier;
15
16pub fn json_path() -> Result<PathBuf, String> {
17    crate::config_hub::ConfigHub::global()
18        .map(|hub| hub.mcp_json_path())
19        .map_err(|error| error.to_string())
20}
21
22pub fn json_path_in(config_dir: &Path) -> PathBuf {
23    crate::config_hub::ConfigHub::from_config_dir(config_dir).mcp_json_path()
24}
25
26fn toml_path_in(config_dir: &Path) -> PathBuf {
27    crate::config_hub::ConfigHub::from_config_dir(config_dir).config_toml_path()
28}
29
30/// Load all MCP server configs from all sources: `config.toml` [[mcp]]
31/// blocks, `mcp_servers.json`, and Claude Desktop's config (read-only).
32/// Later entries override earlier ones by name.
33pub fn load(config_dir: Option<&Path>) -> Vec<McpServerConfig> {
34    let hub = match config_dir {
35        Some(dir) => crate::config_hub::ConfigHub::from_config_dir(dir),
36        None => match crate::config_hub::ConfigHub::global() {
37            Ok(hub) => hub,
38            Err(_) => return Vec::new(),
39        },
40    };
41    hub.load_mcp()
42}
43
44/// Load from a specific config directory only (no Claude Desktop discovery).
45pub fn load_in(config_dir: &Path) -> Vec<McpServerConfig> {
46    crate::config_hub::ConfigHub::from_config_dir(config_dir).load_local_mcp()
47}
48
49pub(crate) fn load_from_dir(config_dir: &Path, discover_claude: bool) -> Vec<McpServerConfig> {
50    let mut configs = Vec::new();
51    if let Ok(text) = std::fs::read_to_string(toml_path_in(config_dir)) {
52        configs.extend(parse_mcp_toml(&text));
53    }
54    if let Ok(text) = std::fs::read_to_string(json_path_in(config_dir)) {
55        configs.extend(parse_mcp_json(&text));
56    }
57    if discover_claude
58        && let Ok(home) = std::env::var("HOME")
59        && let Ok(text) = std::fs::read_to_string(
60            PathBuf::from(home)
61                .join("Library/Application Support/Claude/claude_desktop_config.json"),
62        )
63    {
64        configs.extend(parse_mcp_json(&text));
65    }
66    dedup_keep_last(configs)
67}
68
69/// Deduplicate by name, keeping the LAST occurrence (later sources override).
70fn dedup_keep_last(configs: Vec<McpServerConfig>) -> Vec<McpServerConfig> {
71    let mut seen = std::collections::HashSet::new();
72    let mut result: Vec<McpServerConfig> = Vec::with_capacity(configs.len());
73    for cfg in configs.into_iter().rev() {
74        if seen.insert(cfg.name.clone()) {
75            result.push(cfg);
76        }
77    }
78    result.reverse();
79    result
80}
81
82/// Parse standard `mcpServers` JSON format.
83pub fn parse_mcp_json(text: &str) -> Vec<McpServerConfig> {
84    #[derive(serde::Deserialize)]
85    struct JsonConfigFile {
86        #[serde(default, rename = "mcpServers")]
87        mcp_servers: std::collections::HashMap<String, JsonServerConfig>,
88    }
89
90    #[derive(serde::Deserialize)]
91    struct JsonServerConfig {
92        #[serde(default)]
93        r#type: Option<String>,
94        #[serde(default)]
95        command: Option<String>,
96        #[serde(default)]
97        args: Vec<String>,
98        #[serde(default)]
99        env: std::collections::HashMap<String, String>,
100        #[serde(default)]
101        url: Option<String>,
102        #[serde(default, rename = "authToken")]
103        auth_token: Option<String>,
104        #[serde(default)]
105        headers: std::collections::HashMap<String, String>,
106        #[serde(default)]
107        disabled: bool,
108        #[serde(default)]
109        tier: Option<String>,
110        #[serde(default)]
111        timeout_ms: Option<u64>,
112    }
113
114    let file: JsonConfigFile = match serde_json::from_str(text) {
115        Ok(f) => f,
116        Err(_) => return Vec::new(),
117    };
118    file.mcp_servers
119        .into_iter()
120        .map(|(name, raw)| {
121            let (transport, command, url) = match raw.r#type.as_deref() {
122                Some("sse") => (TransportKind::Sse, String::new(), raw.url),
123                Some("http") => (TransportKind::Http, String::new(), raw.url),
124                _ => (TransportKind::Stdio, raw.command.unwrap_or_default(), None),
125            };
126            McpServerConfig {
127                name,
128                transport,
129                command,
130                args: raw.args,
131                env: raw.env.into_iter().collect(),
132                url,
133                auth_token: raw.auth_token,
134                headers: raw.headers.into_iter().collect(),
135                tier: parse_tier_str(raw.tier.as_deref()),
136                timeout_ms: raw.timeout_ms.unwrap_or(30_000),
137                disabled: raw.disabled,
138            }
139        })
140        .collect()
141}
142
143/// Parse `config.toml` `[[mcp]]` blocks.
144pub fn parse_mcp_toml(text: &str) -> Vec<McpServerConfig> {
145    #[derive(Debug, serde::Deserialize)]
146    struct RawMcpConfigFile {
147        #[serde(default)]
148        mcp: Vec<RawMcpConfig>,
149    }
150
151    #[derive(Debug, serde::Deserialize)]
152    struct RawMcpConfig {
153        name: String,
154        #[serde(default)]
155        transport: Option<String>,
156        #[serde(default)]
157        command: Option<String>,
158        #[serde(default)]
159        args: Vec<String>,
160        #[serde(default)]
161        env: std::collections::HashMap<String, String>,
162        #[serde(default)]
163        url: Option<String>,
164        #[serde(default)]
165        auth_token: Option<String>,
166        #[serde(default)]
167        headers: std::collections::HashMap<String, String>,
168        #[serde(default)]
169        tier: Option<u8>,
170        #[serde(default)]
171        timeout_ms: Option<u64>,
172        #[serde(default)]
173        disabled: bool,
174    }
175
176    let file: RawMcpConfigFile = match toml::from_str(text) {
177        Ok(f) => f,
178        Err(_) => return Vec::new(),
179    };
180    file.mcp
181        .into_iter()
182        .map(|raw| {
183            let transport = match raw.transport.as_deref() {
184                Some("http") => TransportKind::Http,
185                Some("sse") => TransportKind::Sse,
186                _ => TransportKind::Stdio,
187            };
188            McpServerConfig {
189                name: raw.name,
190                transport,
191                command: raw.command.unwrap_or_default(),
192                args: raw.args,
193                env: raw.env.into_iter().collect(),
194                url: raw.url,
195                auth_token: raw.auth_token,
196                headers: raw.headers.into_iter().collect(),
197                tier: tier_from_int(raw.tier.unwrap_or(3)),
198                timeout_ms: raw.timeout_ms.unwrap_or(30_000),
199                disabled: raw.disabled,
200            }
201        })
202        .collect()
203}
204
205fn parse_tier_str(s: Option<&str>) -> Tier {
206    match s {
207        Some("Zero") | Some("zero") | Some("0") => Tier::Zero,
208        Some("One") | Some("one") | Some("1") => Tier::One,
209        Some("Two") | Some("two") | Some("2") => Tier::Two,
210        Some("Three") | Some("three") | Some("3") => Tier::Three,
211        Some("Four") | Some("four") | Some("4") => Tier::Four,
212        _ => Tier::Three,
213    }
214}
215
216fn tier_from_int(n: u8) -> Tier {
217    match n {
218        0 => Tier::Zero,
219        1 => Tier::One,
220        2 => Tier::Two,
221        3 => Tier::Three,
222        _ => Tier::Four,
223    }
224}
225
226fn tier_to_str(t: Tier) -> &'static str {
227    match t {
228        Tier::Zero => "Zero",
229        Tier::One => "One",
230        Tier::Two => "Two",
231        Tier::Three => "Three",
232        Tier::Four => "Four",
233    }
234}
235
236/// Save configs to `mcp_servers.json`. Overwrites the file entirely.
237pub fn save(configs: &[McpServerConfig]) -> Result<(), String> {
238    crate::config_hub::ConfigHub::global()
239        .and_then(|hub| hub.save_mcp(configs))
240        .map_err(|error| error.to_string())
241}
242
243/// Save configs to `mcp_servers.json` in an explicit config directory.
244pub fn save_in(config_dir: &Path, configs: &[McpServerConfig]) -> Result<(), String> {
245    crate::config_hub::ConfigHub::from_config_dir(config_dir)
246        .save_mcp(configs)
247        .map_err(|error| error.to_string())
248}
249
250pub(crate) fn serialize(configs: &[McpServerConfig]) -> Result<String, serde_json::Error> {
251    serde_json::to_string_pretty(&configs_to_json(configs)).map(|json| json + "\n")
252}
253
254fn configs_to_json(configs: &[McpServerConfig]) -> serde_json::Value {
255    let mut servers = serde_json::Map::new();
256    for cfg in configs {
257        servers.insert(cfg.name.clone(), config_to_json_value(cfg));
258    }
259    serde_json::json!({ "mcpServers": serde_json::Value::Object(servers) })
260}
261
262fn config_to_json_value(cfg: &McpServerConfig) -> serde_json::Value {
263    let mut obj = serde_json::Map::new();
264    match cfg.transport {
265        TransportKind::Sse => {
266            obj.insert("type".into(), "sse".into());
267        }
268        TransportKind::Http => {
269            obj.insert("type".into(), "http".into());
270        }
271        TransportKind::Stdio => {}
272    }
273    if !cfg.command.is_empty() {
274        obj.insert("command".into(), cfg.command.clone().into());
275    }
276    if !cfg.args.is_empty() {
277        obj.insert(
278            "args".into(),
279            serde_json::Value::Array(cfg.args.iter().map(|a| a.clone().into()).collect()),
280        );
281    }
282    if !cfg.env.is_empty() {
283        obj.insert(
284            "env".into(),
285            serde_json::Value::Object(
286                cfg.env
287                    .iter()
288                    .map(|(k, v)| (k.clone(), v.clone().into()))
289                    .collect(),
290            ),
291        );
292    }
293    if let Some(url) = &cfg.url {
294        obj.insert("url".into(), url.clone().into());
295    }
296    if let Some(token) = &cfg.auth_token {
297        obj.insert("authToken".into(), token.clone().into());
298    }
299    if !cfg.headers.is_empty() {
300        obj.insert(
301            "headers".into(),
302            serde_json::Value::Object(
303                cfg.headers
304                    .iter()
305                    .map(|(k, v)| (k.clone(), v.clone().into()))
306                    .collect(),
307            ),
308        );
309    }
310    obj.insert("tier".into(), tier_to_str(cfg.tier).into());
311    obj.insert("timeout_ms".into(), cfg.timeout_ms.into());
312    if cfg.disabled {
313        obj.insert("disabled".into(), true.into());
314    }
315    serde_json::Value::Object(obj)
316}
317
318/// Toggle the `disabled` flag on a server. Returns the new disabled value.
319pub fn toggle_disabled(name: &str) -> Result<bool, String> {
320    crate::config_hub::ConfigHub::global()
321        .and_then(|hub| hub.toggle_mcp(name))
322        .map_err(|error| error.to_string())
323}
324
325/// Toggle `disabled` in an explicit config directory.
326pub fn toggle_disabled_in(config_dir: &Path, name: &str) -> Result<bool, String> {
327    crate::config_hub::ConfigHub::from_config_dir(config_dir)
328        .toggle_mcp(name)
329        .map_err(|error| error.to_string())
330}
331
332/// Remove a server from the config.
333pub fn remove(name: &str) -> Result<(), String> {
334    crate::config_hub::ConfigHub::global()
335        .and_then(|hub| hub.remove_mcp(name))
336        .map_err(|error| error.to_string())
337}
338
339/// Remove a server from an explicit config directory.
340pub fn remove_in(config_dir: &Path, name: &str) -> Result<(), String> {
341    crate::config_hub::ConfigHub::from_config_dir(config_dir)
342        .remove_mcp(name)
343        .map_err(|error| error.to_string())
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn load_empty_when_no_files() {
352        let dir = tempfile::tempdir().unwrap();
353        let configs = load_in(dir.path());
354        assert!(configs.is_empty());
355    }
356
357    #[test]
358    fn save_then_load_roundtrip() {
359        let dir = tempfile::tempdir().unwrap();
360        let original = vec![
361            McpServerConfig::stdio("srv-a", "echo", vec!["hello".into()], Tier::Two, 30_000),
362            McpServerConfig::http(
363                "srv-b",
364                "https://api.example.com",
365                Some("secret".into()),
366                Tier::Three,
367                30_000,
368            ),
369        ];
370        save_in(dir.path(), &original).unwrap();
371        let loaded = load_in(dir.path());
372        assert_eq!(loaded.len(), 2);
373        let a = loaded.iter().find(|c| c.name == "srv-a").unwrap();
374        assert_eq!(a.command, "echo");
375        assert_eq!(a.transport, TransportKind::Stdio);
376        let b = loaded.iter().find(|c| c.name == "srv-b").unwrap();
377        assert_eq!(b.transport, TransportKind::Http);
378        assert_eq!(b.url.as_deref(), Some("https://api.example.com"));
379    }
380
381    #[test]
382    fn toggle_disabled_load_modify_save() {
383        let dir = tempfile::tempdir().unwrap();
384        save_in(
385            dir.path(),
386            &[McpServerConfig::stdio(
387                "srv",
388                "echo",
389                vec![],
390                Tier::Two,
391                30_000,
392            )],
393        )
394        .unwrap();
395
396        assert!(toggle_disabled_in(dir.path(), "srv").unwrap());
397        let loaded = load_in(dir.path());
398        assert!(loaded[0].disabled);
399
400        assert!(!toggle_disabled_in(dir.path(), "srv").unwrap());
401        let loaded = load_in(dir.path());
402        assert!(!loaded[0].disabled);
403    }
404
405    #[test]
406    fn toggle_disabled_on_toml_config() {
407        let dir = tempfile::tempdir().unwrap();
408        std::fs::write(
409            toml_path_in(dir.path()),
410            "[[mcp]]\nname = \"exa\"\ncommand = \"exa-mcp-server\"\ntimeout_ms = 30000\n",
411        )
412        .unwrap();
413
414        assert!(toggle_disabled_in(dir.path(), "exa").unwrap());
415        let loaded = load_in(dir.path());
416        assert_eq!(loaded.len(), 1);
417        assert!(loaded[0].disabled);
418        assert!(json_path_in(dir.path()).exists());
419    }
420
421    #[test]
422    fn remove_server() {
423        let dir = tempfile::tempdir().unwrap();
424        save_in(
425            dir.path(),
426            &[
427                McpServerConfig::stdio("a", "echo", vec![], Tier::Two, 30_000),
428                McpServerConfig::stdio("b", "ls", vec![], Tier::Two, 30_000),
429            ],
430        )
431        .unwrap();
432
433        remove_in(dir.path(), "a").unwrap();
434        let loaded = load_in(dir.path());
435        assert_eq!(loaded.len(), 1);
436        assert_eq!(loaded[0].name, "b");
437    }
438
439    #[test]
440    fn remove_missing_errors() {
441        let dir = tempfile::tempdir().unwrap();
442        save_in(
443            dir.path(),
444            &[McpServerConfig::stdio(
445                "a",
446                "echo",
447                vec![],
448                Tier::Two,
449                30_000,
450            )],
451        )
452        .unwrap();
453        assert!(remove_in(dir.path(), "nonexistent").is_err());
454    }
455
456    #[test]
457    fn toggle_missing_errors() {
458        let dir = tempfile::tempdir().unwrap();
459        save_in(
460            dir.path(),
461            &[McpServerConfig::stdio(
462                "a",
463                "echo",
464                vec![],
465                Tier::Two,
466                30_000,
467            )],
468        )
469        .unwrap();
470        assert!(toggle_disabled_in(dir.path(), "nonexistent").is_err());
471    }
472
473    #[test]
474    fn dedup_keeps_last() {
475        let configs = vec![
476            McpServerConfig::stdio("a", "from-toml", vec![], Tier::Two, 30_000),
477            McpServerConfig::stdio("a", "from-json", vec![], Tier::Two, 30_000),
478        ];
479        let deduped = dedup_keep_last(configs);
480        assert_eq!(deduped.len(), 1);
481        assert_eq!(deduped[0].command, "from-json");
482    }
483}