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    pub source: Source,
69    /// Servers found, or the reason the file could not be read.
70    pub result: Result<Vec<Candidate>, String>,
71}
72
73impl Scan {
74    /// Candidates found, or an empty slice when the file could not be read.
75    pub fn candidates(&self) -> &[Candidate] {
76        self.result.as_deref().unwrap_or(&[])
77    }
78}
79
80/// Where to look. Everything is derived from injected roots rather than read
81/// from the environment, so the whole table is testable against tempdirs.
82///
83/// Two config roots, not one, because the harnesses genuinely disagree. Claude
84/// Desktop and VS Code follow the OS convention (`~/Library/Application Support`
85/// on macOS, `%APPDATA%` on Windows, `~/.config` on Linux) - that is
86/// [`Self::os_config`]. Zed and OpenCode use an XDG-style `~/.config` on macOS
87/// *as well as* Linux - that is [`Self::xdg_config`]. Collapsing them would
88/// silently look in the wrong place for half the table on macOS.
89#[derive(Debug, Clone)]
90pub struct Roots {
91    /// The user's home directory.
92    pub home: PathBuf,
93    /// The OS config directory (`dirs::config_dir`).
94    pub os_config: PathBuf,
95    /// The XDG-style config directory: `$XDG_CONFIG_HOME`, else `~/.config`.
96    pub xdg_config: PathBuf,
97    /// The current working directory, for project-scoped files.
98    pub cwd: PathBuf,
99}
100
101impl Roots {
102    /// Resolve both config roots from a home directory and the OS convention.
103    ///
104    /// `$XDG_CONFIG_HOME` is honoured when set, since a user who sets it means
105    /// it. On Windows there is no `~/.config` convention at all, so the XDG
106    /// root falls back to the OS one.
107    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
117/// The XDG-style config root: `$XDG_CONFIG_HOME` if set, else the
118/// platform default.
119fn 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/// Platform default for the XDG-style root, with no `$XDG_CONFIG_HOME` set.
127///
128/// macOS and Linux both use `~/.config` here - that is the whole reason this
129/// root is separate from `dirs::config_dir()`, which on macOS points at
130/// Application Support. Windows has no such convention, so tools that would use
131/// it there fall back to the OS config directory.
132#[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
142/// Every harness config file Leviath knows about, whether or not it exists.
143///
144/// Ordering is the order the wizard shows them in: the harnesses most likely to
145/// be present first.
146pub 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
250// ── Platform-specific layouts ────────────────────────────────────────────────
251//
252// The per-OS differences in *where* these files live turn out to be entirely
253// absorbed by `dirs::config_dir()`, which the caller supplies as `Roots.config`
254// - `~/Library/Application Support` on macOS, `%APPDATA%` on Windows,
255// `~/.config` on Linux. Both vendors below put their file at the same relative
256// path under it on all three, so no `#[cfg]` is needed here. Writing out three
257// identical arms would only claim a difference that does not exist; if one
258// vendor ever diverges, *that* is when this grows a `cfg`.
259
260/// Claude Desktop's config file: `<config>/Claude/claude_desktop_config.json`.
261fn claude_desktop_path(config: &Path) -> PathBuf {
262    config.join("Claude").join("claude_desktop_config.json")
263}
264
265/// VS Code's user-level `mcp.json`, beside `settings.json` in the per-user
266/// profile directory: `<config>/Code/User/mcp.json`.
267fn vscode_user_path(config: &Path) -> PathBuf {
268    config.join("Code").join("User").join("mcp.json")
269}
270
271// ── Scanning ─────────────────────────────────────────────────────────────────
272
273/// Parse one source's contents according to its layout.
274fn 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
282/// Read and parse every known source that exists.
283///
284/// Sources whose path does not exist are omitted entirely - on a clean machine
285/// this returns nothing and the wizard's import step is one line of text.
286///
287/// Anything that *does* exist is kept even when it cannot be read or parsed,
288/// carrying its error, so the user is told "Zed: couldn't read this" rather
289/// than being quietly shown nothing and concluding Zed had no servers. The
290/// filter is `exists`, not `is_file`, for exactly that reason: a directory
291/// sitting where a config file belongs is a situation worth reporting, not one
292/// to silently pretend is an absent harness.
293pub 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
308/// Turn a parse failure into something worth showing a user.
309///
310/// VS Code and Zed both allow comments and trailing commas in these files and
311/// `serde_json` rejects both, so that failure is expected rather than a sign
312/// anything is wrong. Saying so beats a raw `expected value at line 3 column 1`.
313fn 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
321/// Whether `name` is already configured in Leviath.
322pub fn already_configured(existing: &[leviath_mcp::MCPServerConfig], name: &str) -> bool {
323    existing.iter().any(|s| s.name == name)
324}
325
326/// A name that does not collide with anything already configured, by appending
327/// `-2`, `-3`, … Used when the user chooses to keep both.
328pub 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    // ─── known_sources ──────────────────────────────────────────────────────
357
358    #[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        // Nothing may reach past the roots it was handed - that is what keeps
387        // the scan testable and keeps it from wandering the real home dir.
388        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        // On macOS these two live under `~/.config`, while `dirs::config_dir()`
406        // points at `~/Library/Application Support`. Reading them from the OS
407        // root would silently find nothing on the most common desktop.
408        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        // An empty value counts as unset - an exported-but-blank variable is
449        // not a directory anyone meant to point at.
450        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    // ─── scan ───────────────────────────────────────────────────────────────
496
497    #[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        // The Claude Code project scope rides along.
533        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        // Showing nothing would read as "this harness had no servers", which is
544        // a different and wrong claim.
545        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        // Not silently skipped: something is at that path, and "we could not
578        // read your Claude Code config" is true where "you have none" is not.
579        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    // ─── name collisions ────────────────────────────────────────────────────
592
593    #[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}