1pub mod formats;
32
33use std::path::{Path, PathBuf};
34
35pub use formats::Candidate;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Layout {
40 JsonObject(&'static str),
42 ClaudeCode,
44 CodexToml,
46}
47
48#[derive(Debug, Clone)]
50pub struct Source {
51 pub id: &'static str,
53 pub display: &'static str,
55 pub path: PathBuf,
57 pub layout: Layout,
59 pub allows_comments: bool,
63}
64
65#[derive(Debug, Clone)]
67pub struct Scan {
68 pub source: Source,
70 pub result: Result<Vec<Candidate>, String>,
72}
73
74impl Scan {
75 pub fn candidates(&self) -> &[Candidate] {
77 self.result.as_deref().unwrap_or(&[])
78 }
79}
80
81#[derive(Debug, Clone)]
91pub struct Roots {
92 pub home: PathBuf,
94 pub os_config: PathBuf,
96 pub xdg_config: PathBuf,
98 pub cwd: PathBuf,
100}
101
102impl Roots {
103 pub fn new(home: PathBuf, os_config: PathBuf, cwd: PathBuf) -> Self {
109 Self {
110 xdg_config: xdg_config_root(&home, &os_config),
111 home,
112 os_config,
113 cwd,
114 }
115 }
116}
117
118fn xdg_config_root(home: &Path, os_config: &Path) -> PathBuf {
121 match std::env::var_os("XDG_CONFIG_HOME") {
122 Some(dir) if !dir.is_empty() => PathBuf::from(dir),
123 _ => default_xdg_config_root(home, os_config),
124 }
125}
126
127#[cfg(not(windows))]
134fn default_xdg_config_root(home: &Path, _os_config: &Path) -> PathBuf {
135 home.join(".config")
136}
137
138#[cfg(windows)]
139fn default_xdg_config_root(_home: &Path, os_config: &Path) -> PathBuf {
140 os_config.to_path_buf()
141}
142
143pub fn known_sources(roots: &Roots) -> Vec<Source> {
148 let home = &roots.home;
149 let os_config = &roots.os_config;
150 let xdg = &roots.xdg_config;
151 let cwd = &roots.cwd;
152
153 vec![
154 Source {
155 id: "claude-code",
156 display: "Claude Code",
157 path: home.join(".claude.json"),
158 layout: Layout::ClaudeCode,
159 allows_comments: false,
160 },
161 Source {
162 id: "claude-code-project",
163 display: "Claude Code (this directory)",
164 path: cwd.join(".mcp.json"),
165 layout: Layout::JsonObject("mcpServers"),
166 allows_comments: false,
167 },
168 Source {
169 id: "claude-desktop",
170 display: "Claude Desktop",
171 path: claude_desktop_path(os_config),
172 layout: Layout::JsonObject("mcpServers"),
173 allows_comments: false,
174 },
175 Source {
176 id: "codex",
177 display: "Codex",
178 path: home.join(".codex").join("config.toml"),
179 layout: Layout::CodexToml,
180 allows_comments: false,
181 },
182 Source {
183 id: "opencode",
184 display: "OpenCode",
185 path: xdg.join("opencode").join("opencode.json"),
186 layout: Layout::JsonObject("mcp"),
187 allows_comments: false,
188 },
189 Source {
190 id: "opencode-home",
191 display: "OpenCode (home)",
192 path: home.join(".opencode.json"),
193 layout: Layout::JsonObject("mcp"),
194 allows_comments: false,
195 },
196 Source {
197 id: "gemini-cli",
198 display: "Gemini CLI",
199 path: home.join(".gemini").join("settings.json"),
200 layout: Layout::JsonObject("mcpServers"),
201 allows_comments: false,
202 },
203 Source {
204 id: "cursor",
205 display: "Cursor",
206 path: home.join(".cursor").join("mcp.json"),
207 layout: Layout::JsonObject("mcpServers"),
208 allows_comments: false,
209 },
210 Source {
211 id: "cursor-project",
212 display: "Cursor (this directory)",
213 path: cwd.join(".cursor").join("mcp.json"),
214 layout: Layout::JsonObject("mcpServers"),
215 allows_comments: false,
216 },
217 Source {
218 id: "vscode-project",
219 display: "VS Code (this directory)",
220 path: cwd.join(".vscode").join("mcp.json"),
221 layout: Layout::JsonObject("servers"),
222 allows_comments: true,
223 },
224 Source {
225 id: "vscode",
226 display: "VS Code",
227 path: vscode_user_path(os_config),
228 layout: Layout::JsonObject("servers"),
229 allows_comments: true,
230 },
231 Source {
232 id: "windsurf",
233 display: "Windsurf",
234 path: home
235 .join(".codeium")
236 .join("windsurf")
237 .join("mcp_config.json"),
238 layout: Layout::JsonObject("mcpServers"),
239 allows_comments: false,
240 },
241 Source {
242 id: "zed",
243 display: "Zed",
244 path: xdg.join("zed").join("settings.json"),
245 layout: Layout::JsonObject("context_servers"),
246 allows_comments: true,
247 },
248 ]
249}
250
251fn claude_desktop_path(config: &Path) -> PathBuf {
263 config.join("Claude").join("claude_desktop_config.json")
264}
265
266fn vscode_user_path(config: &Path) -> PathBuf {
269 config.join("Code").join("User").join("mcp.json")
270}
271
272fn parse(layout: Layout, contents: &str) -> anyhow::Result<Vec<Candidate>> {
276 match layout {
277 Layout::JsonObject(key) => formats::parse_json_object(contents, key),
278 Layout::ClaudeCode => formats::parse_claude_code(contents),
279 Layout::CodexToml => formats::parse_codex(contents),
280 }
281}
282
283pub fn scan(roots: &Roots) -> Vec<Scan> {
295 known_sources(roots)
296 .into_iter()
297 .filter(|s| s.path.exists())
298 .map(|source| {
299 let result = std::fs::read_to_string(&source.path)
300 .map_err(|e| e.to_string())
301 .and_then(|contents| {
302 parse(source.layout, &contents).map_err(|e| describe_parse_error(&source, &e))
303 });
304 Scan { source, result }
305 })
306 .collect()
307}
308
309fn describe_parse_error(source: &Source, error: &anyhow::Error) -> String {
315 if source.allows_comments {
316 format!("{error} - this file may use comments, which aren't supported")
317 } else {
318 error.to_string()
319 }
320}
321
322pub fn already_configured(existing: &[leviath_mcp::MCPServerConfig], name: &str) -> bool {
324 existing.iter().any(|s| s.name == name)
325}
326
327pub fn dedup_name(existing: &[leviath_mcp::MCPServerConfig], name: &str) -> String {
330 let mut candidate = name.to_string();
331 let mut suffix = 1;
332 while already_configured(existing, &candidate) {
333 suffix += 1;
334 candidate = format!("{name}-{suffix}");
335 }
336 candidate
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 fn roots_in(dir: &Path) -> Roots {
344 Roots {
345 home: dir.join("home"),
346 os_config: dir.join("os-config"),
347 xdg_config: dir.join("home").join(".config"),
348 cwd: dir.join("cwd"),
349 }
350 }
351
352 fn write(path: &Path, contents: &str) {
353 std::fs::create_dir_all(path.parent().expect("test paths have a parent")).unwrap();
354 std::fs::write(path, contents).unwrap();
355 }
356
357 #[test]
360 fn every_known_source_has_a_distinct_id_and_a_nonempty_path() {
361 let dir = tempfile::tempdir().unwrap();
362 let sources = known_sources(&roots_in(dir.path()));
363
364 assert!(sources.len() >= 9, "expected the full harness table");
365 let mut ids: Vec<&str> = sources.iter().map(|s| s.id).collect();
366 ids.sort_unstable();
367 let total = ids.len();
368 ids.dedup();
369 assert_eq!(total, ids.len(), "duplicate source ids");
370
371 for source in &sources {
372 assert!(
373 !source.display.is_empty(),
374 "source {} has no label",
375 source.id
376 );
377 assert!(
378 source.path.is_absolute(),
379 "source {} has a relative path",
380 source.id
381 );
382 }
383 }
384
385 #[test]
386 fn known_sources_are_rooted_in_the_injected_directories() {
387 let dir = tempfile::tempdir().unwrap();
390 let roots = roots_in(dir.path());
391
392 for source in known_sources(&roots) {
393 assert!(
394 source.path.starts_with(&roots.home)
395 || source.path.starts_with(&roots.os_config)
396 || source.path.starts_with(&roots.xdg_config)
397 || source.path.starts_with(&roots.cwd),
398 "source {} escaped the injected roots",
399 source.id
400 );
401 }
402 }
403
404 #[test]
405 fn zed_and_opencode_use_the_xdg_root_not_the_os_one() {
406 let dir = tempfile::tempdir().unwrap();
410 let roots = roots_in(dir.path());
411 let sources = known_sources(&roots);
412
413 for id in ["zed", "opencode"] {
414 let source = sources
415 .iter()
416 .find(|s| s.id == id)
417 .expect("source is in the table");
418 assert!(
419 source.path.starts_with(&roots.xdg_config),
420 "{id} should read from the XDG root"
421 );
422 }
423 for id in ["claude-desktop", "vscode"] {
424 let source = sources
425 .iter()
426 .find(|s| s.id == id)
427 .expect("source is in the table");
428 assert!(
429 source.path.starts_with(&roots.os_config),
430 "{id} should read from the OS config root"
431 );
432 }
433 }
434
435 #[test]
436 fn roots_new_honours_xdg_config_home_when_set() {
437 temp_env::with_var("XDG_CONFIG_HOME", Some("/custom/xdg"), || {
438 let roots = Roots::new(
439 PathBuf::from("/home/u"),
440 PathBuf::from("/home/u/os-config"),
441 PathBuf::from("/work"),
442 );
443 assert_eq!(roots.xdg_config, PathBuf::from("/custom/xdg"));
444 });
445 }
446
447 #[test]
448 fn roots_new_falls_back_to_the_platform_default_without_xdg_config_home() {
449 for value in [None, Some("")] {
452 temp_env::with_var("XDG_CONFIG_HOME", value, || {
453 let roots = Roots::new(
454 PathBuf::from("/home/u"),
455 PathBuf::from("/home/u/os-config"),
456 PathBuf::from("/work"),
457 );
458 assert_eq!(
459 roots.xdg_config,
460 default_xdg_config_root(Path::new("/home/u"), Path::new("/home/u/os-config"))
461 );
462 });
463 }
464 }
465
466 #[test]
467 fn the_table_covers_every_layout() {
468 let dir = tempfile::tempdir().unwrap();
469 let sources = known_sources(&roots_in(dir.path()));
470
471 assert!(sources.iter().any(|s| s.layout == Layout::ClaudeCode));
472 assert!(sources.iter().any(|s| s.layout == Layout::CodexToml));
473 assert!(
474 sources
475 .iter()
476 .any(|s| matches!(s.layout, Layout::JsonObject("mcpServers")))
477 );
478 assert!(
479 sources
480 .iter()
481 .any(|s| matches!(s.layout, Layout::JsonObject("servers")))
482 );
483 assert!(
484 sources
485 .iter()
486 .any(|s| matches!(s.layout, Layout::JsonObject("mcp")))
487 );
488 assert!(
489 sources
490 .iter()
491 .any(|s| matches!(s.layout, Layout::JsonObject("context_servers")))
492 );
493 assert!(sources.iter().any(|s| s.allows_comments));
494 }
495
496 #[test]
499 fn scanning_a_clean_machine_finds_nothing() {
500 let dir = tempfile::tempdir().unwrap();
501
502 assert!(scan(&roots_in(dir.path())).is_empty());
503 }
504
505 #[test]
506 fn scan_reads_each_layout_it_finds() {
507 let dir = tempfile::tempdir().unwrap();
508 let roots = roots_in(dir.path());
509 write(
510 &roots.home.join(".claude.json"),
511 r#"{"projects":{"/repo":{"mcpServers":{"cc":{"url":"https://cc.test"}}}}}"#,
512 );
513 write(
514 &roots.home.join(".codex").join("config.toml"),
515 "[mcp_servers.cx]\ncommand = \"cx\"\n",
516 );
517 write(
518 &roots.cwd.join(".mcp.json"),
519 r#"{"mcpServers":{"proj":{"command":"p"}}}"#,
520 );
521
522 let scans = scan(&roots);
523
524 assert_eq!(scans.len(), 3);
525 let names: Vec<&str> = scans
526 .iter()
527 .flat_map(|s| s.candidates())
528 .map(|c| c.config.name.as_str())
529 .collect();
530 assert!(names.contains(&"cc"));
531 assert!(names.contains(&"cx"));
532 assert!(names.contains(&"proj"));
533 let cc = scans
535 .iter()
536 .flat_map(|s| s.candidates())
537 .find(|c| c.config.name == "cc")
538 .expect("cc was found");
539 assert_eq!(cc.scope, "/repo");
540 }
541
542 #[test]
543 fn an_unreadable_source_is_reported_rather_than_silently_dropped() {
544 let dir = tempfile::tempdir().unwrap();
547 let roots = roots_in(dir.path());
548 write(&roots.home.join(".claude.json"), "not json at all");
549
550 let scans = scan(&roots);
551
552 assert_eq!(scans.len(), 1);
553 assert!(scans[0].result.is_err());
554 assert!(scans[0].candidates().is_empty());
555 }
556
557 #[test]
558 fn a_jsonc_parse_failure_explains_the_comment_limitation() {
559 let dir = tempfile::tempdir().unwrap();
560 let roots = roots_in(dir.path());
561 write(
562 &roots.cwd.join(".vscode").join("mcp.json"),
563 "{\n // a comment VS Code allows\n \"servers\": {}\n}",
564 );
565
566 let scans = scan(&roots);
567
568 let message = scans[0]
569 .result
570 .as_ref()
571 .expect_err("JSONC does not parse as JSON")
572 .clone();
573 assert!(message.contains("comments"), "unhelpful message: {message}");
574 }
575
576 #[test]
577 fn a_directory_where_a_config_file_belongs_is_reported_as_unreadable() {
578 let dir = tempfile::tempdir().unwrap();
581 let roots = roots_in(dir.path());
582 std::fs::create_dir_all(roots.home.join(".claude.json")).unwrap();
583
584 let scans = scan(&roots);
585
586 assert_eq!(scans.len(), 1);
587 assert_eq!(scans[0].source.id, "claude-code");
588 assert!(scans[0].result.is_err());
589 assert!(scans[0].candidates().is_empty());
590 }
591
592 #[test]
595 fn already_configured_matches_by_name() {
596 let existing = vec![leviath_mcp::MCPServerConfig::stdio("fs", "npx", vec![])];
597
598 assert!(already_configured(&existing, "fs"));
599 assert!(!already_configured(&existing, "other"));
600 }
601
602 #[test]
603 fn dedup_name_leaves_a_free_name_alone() {
604 let existing = vec![leviath_mcp::MCPServerConfig::stdio("fs", "npx", vec![])];
605
606 assert_eq!(dedup_name(&existing, "other"), "other");
607 }
608
609 #[test]
610 fn dedup_name_walks_past_every_taken_suffix() {
611 let existing = vec![
612 leviath_mcp::MCPServerConfig::stdio("fs", "npx", vec![]),
613 leviath_mcp::MCPServerConfig::stdio("fs-2", "npx", vec![]),
614 leviath_mcp::MCPServerConfig::stdio("fs-3", "npx", vec![]),
615 ];
616
617 assert_eq!(dedup_name(&existing, "fs"), "fs-4");
618 }
619}