Skip to main content

leviath_cli/commands/setup/import/
mod.rs

1//! Discovering MCP servers already configured in other agent harnesses.
2//!
3//! Someone installing Leviath has usually already wired up MCP servers
4//! somewhere else - Claude Code, Cursor, Codex, Zed. Making them retype each
5//! one is busywork, and the entries are close enough in shape to convert
6//! mechanically, so `lev setup` offers to import them.
7//!
8//! ## Layering
9//!
10//! Two halves, deliberately separated:
11//!
12//! * [`formats`] turns file *contents* into candidates. Pure, no filesystem, no
13//!   `#[cfg]` - every harness's format is testable on every platform, including
14//!   ones whose files could never exist there.
15//! * This module knows *where* those files live - the only platform-dependent
16//!   part. It takes its roots as an injected [`Roots`] rather than reading the
17//!   environment, so the whole table is testable against tempdirs, and the one
18//!   genuine per-OS branch is `#[cfg]`-gated in a single place, per the rule
19//!   `daemon_service` follows.
20//!
21//! Nothing here is prescribed to Leviath as a whole. Scanning a user's home
22//! directory for other tools' config is a desktop-shaped idea; a future mobile
23//! host would simply have no sources and offer no import step, with everything
24//! downstream unchanged.
25//!
26//! ## What is deliberately *not* done
27//!
28//! Discovery never connects to a server, never runs a command, and never reads
29//! anything outside the specific files listed below.
30
31pub mod formats;
32
33use std::path::{Path, PathBuf};
34
35pub use formats::Candidate;
36
37/// How a source file's servers are laid out.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Layout {
40    /// A JSON object of servers under one top-level key.
41    JsonObject(&'static str),
42    /// Claude Code's `~/.claude.json`: global plus per-project scopes.
43    ClaudeCode,
44    /// Codex's `[mcp_servers]` TOML table.
45    CodexToml,
46}
47
48/// One harness Leviath knows how to read.
49#[derive(Debug, Clone)]
50pub struct Source {
51    /// Stable id, used in messages and as a dedup key.
52    pub id: &'static str,
53    /// Name to show the user.
54    pub display: &'static str,
55    /// The file to read.
56    pub path: PathBuf,
57    /// How to parse it.
58    pub layout: Layout,
59    /// Whether this file's format tolerates comments (JSONC). `serde_json`
60    /// does not, so a parse failure here is expected rather than alarming and
61    /// is reported as such.
62    pub allows_comments: bool,
63}
64
65/// The result of reading one source.
66#[derive(Debug, Clone)]
67pub struct Scan {
68    /// Which harness was scanned.
69    pub source: Source,
70    /// Servers found, or the reason the file could not be read.
71    pub result: Result<Vec<Candidate>, String>,
72}
73
74impl Scan {
75    /// Candidates found, or an empty slice when the file could not be read.
76    pub fn candidates(&self) -> &[Candidate] {
77        self.result.as_deref().unwrap_or(&[])
78    }
79}
80
81/// Where to look. Everything is derived from injected roots rather than read
82/// from the environment, so the whole table is testable against tempdirs.
83///
84/// Two config roots, not one, because the harnesses genuinely disagree. Claude
85/// Desktop and VS Code follow the OS convention (`~/Library/Application Support`
86/// on macOS, `%APPDATA%` on Windows, `~/.config` on Linux) - that is
87/// [`Self::os_config`]. Zed and OpenCode use an XDG-style `~/.config` on macOS
88/// *as well as* Linux - that is [`Self::xdg_config`]. Collapsing them would
89/// silently look in the wrong place for half the table on macOS.
90#[derive(Debug, Clone)]
91pub struct Roots {
92    /// The user's home directory.
93    pub home: PathBuf,
94    /// The OS config directory (`dirs::config_dir`).
95    pub os_config: PathBuf,
96    /// The XDG-style config directory: `$XDG_CONFIG_HOME`, else `~/.config`.
97    pub xdg_config: PathBuf,
98    /// The current working directory, for project-scoped files.
99    pub cwd: PathBuf,
100}
101
102impl Roots {
103    /// Resolve both config roots from a home directory and the OS convention.
104    ///
105    /// `$XDG_CONFIG_HOME` is honoured when set, since a user who sets it means
106    /// it. On Windows there is no `~/.config` convention at all, so the XDG
107    /// root falls back to the OS one.
108    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
118/// The XDG-style config root: `$XDG_CONFIG_HOME` if set, else the
119/// platform default.
120fn 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/// Platform default for the XDG-style root, with no `$XDG_CONFIG_HOME` set.
128///
129/// macOS and Linux both use `~/.config` here - that is the whole reason this
130/// root is separate from `dirs::config_dir()`, which on macOS points at
131/// Application Support. Windows has no such convention, so tools that would use
132/// it there fall back to the OS config directory.
133#[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
143/// Every harness config file Leviath knows about, whether or not it exists.
144///
145/// Ordering is the order the wizard shows them in: the harnesses most likely to
146/// be present first.
147pub 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
251// ── Platform-specific layouts ────────────────────────────────────────────────
252//
253// The per-OS differences in *where* these files live turn out to be entirely
254// absorbed by `dirs::config_dir()`, which the caller supplies as `Roots.config`
255// - `~/Library/Application Support` on macOS, `%APPDATA%` on Windows,
256// `~/.config` on Linux. Both vendors below put their file at the same relative
257// path under it on all three, so no `#[cfg]` is needed here. Writing out three
258// identical arms would only claim a difference that does not exist; if one
259// vendor ever diverges, *that* is when this grows a `cfg`.
260
261/// Claude Desktop's config file: `<config>/Claude/claude_desktop_config.json`.
262fn claude_desktop_path(config: &Path) -> PathBuf {
263    config.join("Claude").join("claude_desktop_config.json")
264}
265
266/// VS Code's user-level `mcp.json`, beside `settings.json` in the per-user
267/// profile directory: `<config>/Code/User/mcp.json`.
268fn vscode_user_path(config: &Path) -> PathBuf {
269    config.join("Code").join("User").join("mcp.json")
270}
271
272// ── Scanning ─────────────────────────────────────────────────────────────────
273
274/// Parse one source's contents according to its layout.
275fn 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
283/// Read and parse every known source that exists.
284///
285/// Sources whose path does not exist are omitted entirely - on a clean machine
286/// this returns nothing and the wizard's import step is one line of text.
287///
288/// Anything that *does* exist is kept even when it cannot be read or parsed,
289/// carrying its error, so the user is told "Zed: couldn't read this" rather
290/// than being quietly shown nothing and concluding Zed had no servers. The
291/// filter is `exists`, not `is_file`, for exactly that reason: a directory
292/// sitting where a config file belongs is a situation worth reporting, not one
293/// to silently pretend is an absent harness.
294pub 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
309/// Turn a parse failure into something worth showing a user.
310///
311/// VS Code and Zed both allow comments and trailing commas in these files and
312/// `serde_json` rejects both, so that failure is expected rather than a sign
313/// anything is wrong. Saying so beats a raw `expected value at line 3 column 1`.
314fn 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
322/// Whether `name` is already configured in Leviath.
323pub fn already_configured(existing: &[leviath_mcp::MCPServerConfig], name: &str) -> bool {
324    existing.iter().any(|s| s.name == name)
325}
326
327/// A name that does not collide with anything already configured, by appending
328/// `-2`, `-3`, … Used when the user chooses to keep both.
329pub 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    // ─── known_sources ──────────────────────────────────────────────────────
358
359    #[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        // Nothing may reach past the roots it was handed - that is what keeps
388        // the scan testable and keeps it from wandering the real home dir.
389        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        // On macOS these two live under `~/.config`, while `dirs::config_dir()`
407        // points at `~/Library/Application Support`. Reading them from the OS
408        // root would silently find nothing on the most common desktop.
409        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        // An empty value counts as unset - an exported-but-blank variable is
450        // not a directory anyone meant to point at.
451        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    // ─── scan ───────────────────────────────────────────────────────────────
497
498    #[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        // The Claude Code project scope rides along.
534        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        // Showing nothing would read as "this harness had no servers", which is
545        // a different and wrong claim.
546        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        // Not silently skipped: something is at that path, and "we could not
579        // read your Claude Code config" is true where "you have none" is not.
580        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    // ─── name collisions ────────────────────────────────────────────────────
593
594    #[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}