1use 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
30pub 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
44pub 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
69fn 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
82pub 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
143pub 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
236pub 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
243pub 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
318pub 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
325pub 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
332pub 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
339pub 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}