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