Skip to main content

leviath_cli/commands/setup/import/
formats.rs

1//! Turning another harness's config file *contents* into Leviath
2//! [`MCPServerConfig`]s.
3//!
4//! Everything here is a pure `&str -> Result<Vec<..>>` function. No path
5//! resolution, no filesystem, no `#[cfg]` - those live in the parent module -
6//! so every harness's format is unit-testable on every platform, including the
7//! ones whose config files could never exist there.
8//!
9//! ## The shapes
10//!
11//! Nine harnesses, four families:
12//!
13//! * **`mcpServers` object** - Claude Code (`~/.claude.json`, plus a nested
14//!   `mcpServers` per project), `.mcp.json`, Claude Desktop, Cursor, Windsurf,
15//!   Gemini CLI. Entries are `{command, args, env}` or `{url, headers}`, with
16//!   Gemini adding `httpUrl` and Windsurf `serverUrl`.
17//! * **`servers` object** - VS Code, same entry shape with an explicit `type`.
18//! * **`mcp` object** - OpenCode, whose entries are tagged `local`/`remote` and
19//!   whose `command` is an *array* (argv) rather than a string.
20//! * **`context_servers` object** - Zed, whose entry nests the launch under a
21//!   `command` object (`{path, args, env}`).
22//! * **`[mcp_servers]` table** - Codex, the one TOML source.
23//!
24//! Rather than nine near-identical structs, one tolerant entry parser accepts
25//! the union of field names and every wrapper normalises into it. Unknown
26//! fields are dropped rather than rejected: these files are written by other
27//! tools that add keys on their own schedule, and refusing to import a server
28//! because it carries a setting Leviath does not model would be useless
29//! strictness.
30
31use std::collections::HashMap;
32
33use leviath_mcp::{MCPServerConfig, MCPTransport};
34
35/// One server offered for import, with enough provenance to show the user
36/// where it came from and what it would drag along.
37#[derive(Debug, Clone)]
38pub struct Candidate {
39    /// The Leviath config entry this would become.
40    pub config: MCPServerConfig,
41    /// Sub-location within the file, when one file holds several scopes
42    /// (Claude Code keys servers per project). Empty for a flat file.
43    pub scope: String,
44    /// `env` / `headers` keys whose values look like a literal credential
45    /// rather than a `${VAR}` reference. Surfaced so importing a server does
46    /// not silently copy another tool's token into `~/.leviath/config.toml`.
47    pub inline_secrets: Vec<String>,
48}
49
50/// Env/header key fragments that mark a value as credential-shaped.
51const SECRET_HINTS: [&str; 7] = [
52    "token", "key", "secret", "password", "passwd", "auth", "bearer",
53];
54
55/// Whether `value` under `key` looks like a literal credential.
56///
57/// `${VAR}` and `$VAR` are references Leviath expands at connect time, so they
58/// are not secrets in the file; anything else under a credential-shaped key is.
59/// Deliberately conservative in one direction only - a false positive costs the
60/// user one glance at a flagged row, a false negative silently copies a live
61/// token into a second file on disk.
62fn looks_like_inline_secret(key: &str, value: &str) -> bool {
63    if value.is_empty() || value.starts_with("${") || value.starts_with('$') {
64        return false;
65    }
66    let lower = key.to_ascii_lowercase();
67    SECRET_HINTS.iter().any(|hint| lower.contains(hint))
68}
69
70/// Collect the credential-shaped keys of one candidate's `env` and `headers`.
71fn inline_secrets(config: &MCPServerConfig) -> Vec<String> {
72    let mut found: Vec<String> = config
73        .env
74        .iter()
75        .chain(config.headers.iter())
76        .filter(|(k, v)| looks_like_inline_secret(k, v))
77        .map(|(k, _)| k.clone())
78        .collect();
79    found.sort();
80    found
81}
82
83/// Read a JSON object of `string -> string`, skipping non-string values rather
84/// than failing: a harness that allows a number or `null` in `env` should cost
85/// that one variable, not the whole server.
86fn string_map(value: Option<&serde_json::Value>) -> HashMap<String, String> {
87    value
88        .and_then(|v| v.as_object())
89        .map(|obj| {
90            obj.iter()
91                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
92                .collect()
93        })
94        .unwrap_or_default()
95}
96
97/// Read a JSON array of strings, skipping non-string elements.
98fn string_list(value: Option<&serde_json::Value>) -> Vec<String> {
99    value
100        .and_then(|v| v.as_array())
101        .map(|arr| {
102            arr.iter()
103                .filter_map(|v| v.as_str().map(str::to_owned))
104                .collect()
105        })
106        .unwrap_or_default()
107}
108
109/// Whether an entry is switched off in its own harness. An explicitly disabled
110/// server should not be offered - the user already said no once.
111fn is_disabled(entry: &serde_json::Map<String, serde_json::Value>) -> bool {
112    entry.get("enabled").and_then(|v| v.as_bool()) == Some(false)
113        || entry.get("disabled").and_then(|v| v.as_bool()) == Some(true)
114}
115
116/// Parse one server entry from any of the JSON-shaped harnesses.
117///
118/// Returns `None` when the entry is disabled, malformed, or describes neither a
119/// command nor a URL - an entry Leviath cannot connect to is not a candidate.
120///
121/// Precedence is URL over command. Several harnesses carry both (a stdio
122/// fallback alongside a hosted endpoint), and [`MCPServerConfig::resolve`]
123/// rejects an entry that sets both without an explicit transport, so exactly
124/// one is kept and the transport is stated outright.
125pub fn parse_json_entry(name: &str, value: &serde_json::Value) -> Option<Candidate> {
126    let entry = value.as_object()?;
127    if is_disabled(entry) {
128        return None;
129    }
130
131    // Zed nests the launch under a `command` *object*; everyone else uses a
132    // string (or, for OpenCode, an argv array).
133    let nested = entry.get("command").and_then(|v| v.as_object());
134    let command_field = nested
135        .and_then(|c| c.get("path"))
136        .or_else(|| entry.get("command"));
137
138    let (command, mut args) = match command_field {
139        Some(serde_json::Value::String(s)) => (Some(s.clone()), Vec::new()),
140        // OpenCode: `command: ["npx", "-y", "pkg"]` - head is the program.
141        Some(serde_json::Value::Array(_)) => {
142            let argv = string_list(entry.get("command"));
143            let mut it = argv.into_iter();
144            (it.next(), it.collect())
145        }
146        _ => (None, Vec::new()),
147    };
148    let declared_args = string_list(nested.map_or_else(|| entry.get("args"), |c| c.get("args")));
149    if !declared_args.is_empty() {
150        args = declared_args;
151    }
152
153    let url = ["url", "httpUrl", "serverUrl", "endpoint"]
154        .iter()
155        .find_map(|k| entry.get(*k).and_then(|v| v.as_str()))
156        .map(str::to_owned);
157
158    let mut env = string_map(nested.map_or_else(|| entry.get("env"), |c| c.get("env")));
159    // OpenCode spells it `environment`.
160    env.extend(string_map(entry.get("environment")));
161    let headers = string_map(entry.get("headers"));
162
163    let config = match (url, command) {
164        (Some(url), _) => MCPServerConfig {
165            name: name.to_string(),
166            transport: Some(MCPTransport::Http),
167            url: Some(url),
168            headers,
169            ..MCPServerConfig::default()
170        },
171        (None, Some(command)) => MCPServerConfig {
172            name: name.to_string(),
173            transport: Some(MCPTransport::Stdio),
174            command: Some(command),
175            args,
176            env,
177            ..MCPServerConfig::default()
178        },
179        // Neither: nothing to connect to.
180        (None, None) => return None,
181    };
182
183    // A malformed `[[mcp_servers]]` entry is a hard error in `Config::load`, so
184    // importing one would brick the whole config file rather than costing one
185    // server. Both shapes above are valid by construction - each sets exactly
186    // one of `command`/`url` and states its transport outright, which is
187    // precisely what `validate` checks - so there is no runtime check here to
188    // reject something that cannot be built. `every_candidate_validates` holds
189    // the invariant instead, and would fail loudly if a future edit to this
190    // function broke it.
191
192    Some(Candidate {
193        inline_secrets: inline_secrets(&config),
194        config,
195        scope: String::new(),
196    })
197}
198
199/// Parse every entry of a JSON object of servers, sorted by name for a stable
200/// display order.
201fn parse_json_map(map: Option<&serde_json::Value>) -> Vec<Candidate> {
202    let Some(obj) = map.and_then(|v| v.as_object()) else {
203        return Vec::new();
204    };
205    let mut out: Vec<Candidate> = obj
206        .iter()
207        .filter_map(|(name, value)| parse_json_entry(name, value))
208        .collect();
209    out.sort_by(|a, b| a.config.name.cmp(&b.config.name));
210    out
211}
212
213/// A JSON file whose servers live under one top-level key.
214///
215/// Covers `.mcp.json`, Claude Desktop, Cursor, Windsurf, and Gemini CLI
216/// (`mcpServers`), VS Code (`servers`), OpenCode (`mcp`), and Zed
217/// (`context_servers`) - the key is the only thing that differs.
218pub fn parse_json_object(contents: &str, key: &str) -> anyhow::Result<Vec<Candidate>> {
219    let root: serde_json::Value = serde_json::from_str(contents)?;
220    Ok(parse_json_map(root.get(key)))
221}
222
223/// Claude Code's `~/.claude.json`: a global `mcpServers` object plus a
224/// per-project one under `projects.<absolute path>.mcpServers`.
225///
226/// Both are offered. A server configured for one repo is still a server the
227/// user set up and may want globally, and the project path rides along as the
228/// candidate's scope so the wizard can say where each came from.
229pub fn parse_claude_code(contents: &str) -> anyhow::Result<Vec<Candidate>> {
230    let root: serde_json::Value = serde_json::from_str(contents)?;
231    let mut out = parse_json_map(root.get("mcpServers"));
232
233    if let Some(projects) = root.get("projects").and_then(|v| v.as_object()) {
234        let mut paths: Vec<&String> = projects.keys().collect();
235        paths.sort();
236        for path in paths {
237            let scoped = parse_json_map(projects.get(path).and_then(|p| p.get("mcpServers")));
238            out.extend(scoped.into_iter().map(|mut c| {
239                c.scope = path.clone();
240                c
241            }));
242        }
243    }
244    Ok(out)
245}
246
247/// Codex's `~/.codex/config.toml`, whose servers live in an `[mcp_servers]`
248/// table. Reuses the JSON entry parser by converting the TOML table through
249/// `serde_json::Value` - the field names are identical, and one tolerant entry
250/// parser beats two that must be kept in step.
251pub fn parse_codex(contents: &str) -> anyhow::Result<Vec<Candidate>> {
252    // Deserialize the TOML straight into a `serde_json::Value` rather than
253    // parsing to `toml::Value` and converting: the conversion step's error arm
254    // cannot happen for anything TOML can produce, and an error arm that cannot
255    // be reached is worse than not having one.
256    let root: serde_json::Value = toml::from_str(contents)?;
257    Ok(parse_json_map(root.get("mcp_servers")))
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    fn by_name<'a>(cands: &'a [Candidate], name: &str) -> &'a Candidate {
265        cands
266            .iter()
267            .find(|c| c.config.name == name)
268            .expect("candidate is present")
269    }
270
271    // ─── stdio entries ──────────────────────────────────────────────────────
272
273    #[test]
274    fn parses_a_stdio_entry_with_args_and_env() {
275        let json = r#"{"mcpServers":{"fs":{"command":"npx","args":["-y","@mcp/fs"],
276                       "env":{"ROOT":"/tmp"}}}}"#;
277
278        let found = parse_json_object(json, "mcpServers").unwrap();
279
280        assert_eq!(found.len(), 1);
281        let c = &found[0].config;
282        assert_eq!(c.name, "fs");
283        assert_eq!(c.transport, Some(MCPTransport::Stdio));
284        assert_eq!(c.command.as_deref(), Some("npx"));
285        assert_eq!(c.args, vec!["-y", "@mcp/fs"]);
286        assert_eq!(c.env.get("ROOT").map(String::as_str), Some("/tmp"));
287        assert!(c.url.is_none());
288        assert!(found[0].inline_secrets.is_empty());
289    }
290
291    #[test]
292    fn parses_an_opencode_argv_command() {
293        // OpenCode gives the whole argv as an array under `command`, with the
294        // environment under `environment`.
295        let json = r#"{"mcp":{"fs":{"type":"local","command":["npx","-y","@mcp/fs"],
296                       "environment":{"ROOT":"/tmp"}}}}"#;
297
298        let found = parse_json_object(json, "mcp").unwrap();
299
300        let c = &by_name(&found, "fs").config;
301        assert_eq!(c.command.as_deref(), Some("npx"));
302        assert_eq!(c.args, vec!["-y", "@mcp/fs"]);
303        assert_eq!(c.env.get("ROOT").map(String::as_str), Some("/tmp"));
304    }
305
306    #[test]
307    fn parses_a_zed_nested_command_object() {
308        let json = r#"{"context_servers":{"fs":{"command":{"path":"npx",
309                       "args":["-y","@mcp/fs"],"env":{"ROOT":"/tmp"}}}}}"#;
310
311        let found = parse_json_object(json, "context_servers").unwrap();
312
313        let c = &by_name(&found, "fs").config;
314        assert_eq!(c.command.as_deref(), Some("npx"));
315        assert_eq!(c.args, vec!["-y", "@mcp/fs"]);
316        assert_eq!(c.env.get("ROOT").map(String::as_str), Some("/tmp"));
317    }
318
319    // ─── http entries ───────────────────────────────────────────────────────
320
321    #[test]
322    fn parses_http_entries_under_every_spelling_of_the_url_field() {
323        // Gemini CLI uses `httpUrl`, Windsurf `serverUrl`, everyone else `url`.
324        for key in ["url", "httpUrl", "serverUrl", "endpoint"] {
325            let json = format!(r#"{{"mcpServers":{{"api":{{"{key}":"https://x.test/mcp"}}}}}}"#);
326
327            let found = parse_json_object(&json, "mcpServers").unwrap();
328
329            let c = &by_name(&found, "api").config;
330            assert_eq!(c.transport, Some(MCPTransport::Http), "field {key}");
331            assert_eq!(c.url.as_deref(), Some("https://x.test/mcp"));
332            assert!(c.command.is_none());
333        }
334    }
335
336    #[test]
337    fn a_url_wins_over_a_command_so_the_entry_stays_resolvable() {
338        // `MCPServerConfig::resolve` rejects an entry carrying both without an
339        // explicit transport, so importing both fields would produce a config
340        // that fails to load.
341        let json = r#"{"mcpServers":{"both":{"command":"npx","url":"https://x.test/mcp"}}}"#;
342
343        let found = parse_json_object(json, "mcpServers").unwrap();
344
345        let c = &by_name(&found, "both").config;
346        assert_eq!(c.url.as_deref(), Some("https://x.test/mcp"));
347        assert!(c.command.is_none());
348        assert!(c.resolve().is_ok());
349    }
350
351    #[test]
352    fn every_candidate_validates() {
353        // The load-bearing invariant: a malformed `[[mcp_servers]]` entry is a
354        // hard error in `Config::load`, so importing one would brick the whole
355        // config file. `parse_json_entry` guarantees validity by construction
356        // rather than checking at runtime, and this is what holds it to that.
357        let shapes = [
358            r#"{"s":{"a":{"command":"x"}}}"#,
359            r#"{"s":{"a":{"command":"x","args":["1"],"env":{"K":"V"}}}}"#,
360            r#"{"s":{"a":{"command":["npx","-y","p"]}}}"#,
361            r#"{"s":{"a":{"command":{"path":"npx","args":["-y"]}}}}"#,
362            r#"{"s":{"a":{"url":"https://y.test"}}}"#,
363            r#"{"s":{"a":{"httpUrl":"https://y.test","headers":{"H":"V"}}}}"#,
364            r#"{"s":{"a":{"serverUrl":"https://y.test"}}}"#,
365            r#"{"s":{"a":{"endpoint":"https://y.test"}}}"#,
366            r#"{"s":{"a":{"command":"x","url":"https://y.test"}}}"#,
367        ];
368
369        for shape in shapes {
370            let found = parse_json_object(shape, "s").unwrap();
371            assert_eq!(found.len(), 1, "shape produced no candidate: {shape}");
372            assert!(
373                found[0].config.validate().is_ok(),
374                "shape produced an invalid entry: {shape}"
375            );
376            assert!(found[0].config.resolve().is_ok());
377        }
378    }
379
380    // ─── entries that are not candidates ────────────────────────────────────
381
382    #[test]
383    fn skips_entries_with_neither_a_command_nor_a_url() {
384        let json = r#"{"mcpServers":{"empty":{"description":"nothing to connect to"}}}"#;
385
386        assert!(parse_json_object(json, "mcpServers").unwrap().is_empty());
387    }
388
389    #[test]
390    fn skips_entries_the_other_harness_has_switched_off() {
391        // The user already said no to these once.
392        let json = r#"{"mcpServers":{"off":{"command":"x","enabled":false},
393                       "also-off":{"command":"y","disabled":true},
394                       "on":{"command":"z","enabled":true}}}"#;
395
396        let found = parse_json_object(json, "mcpServers").unwrap();
397
398        assert_eq!(found.len(), 1);
399        assert_eq!(found[0].config.name, "on");
400    }
401
402    #[test]
403    fn skips_non_object_entries() {
404        let json = r#"{"mcpServers":{"bogus":"just a string","ok":{"command":"x"}}}"#;
405
406        let found = parse_json_object(json, "mcpServers").unwrap();
407
408        assert_eq!(found.len(), 1);
409        assert_eq!(found[0].config.name, "ok");
410    }
411
412    #[test]
413    fn a_missing_or_non_object_key_yields_nothing() {
414        assert!(parse_json_object("{}", "mcpServers").unwrap().is_empty());
415        assert!(
416            parse_json_object(r#"{"mcpServers":[]}"#, "mcpServers")
417                .unwrap()
418                .is_empty()
419        );
420    }
421
422    #[test]
423    fn malformed_json_is_an_error_not_a_silent_empty_list() {
424        // VS Code and Zed allow comments in these files; serde_json does not.
425        // The caller shows the row as unreadable rather than pretending the
426        // harness configured nothing.
427        let err = parse_json_object("{ // a comment\n }", "servers");
428
429        assert!(err.is_err());
430    }
431
432    #[test]
433    fn non_string_values_inside_env_and_args_are_dropped_not_fatal() {
434        let json = r#"{"mcpServers":{"x":{"command":"c","args":["a",7,"b"],
435                       "env":{"GOOD":"1","BAD":2}}}}"#;
436
437        let found = parse_json_object(json, "mcpServers").unwrap();
438
439        let c = &found[0].config;
440        assert_eq!(c.args, vec!["a", "b"]);
441        assert_eq!(c.env.len(), 1);
442        assert_eq!(c.env.get("GOOD").map(String::as_str), Some("1"));
443    }
444
445    // ─── inline secrets ─────────────────────────────────────────────────────
446
447    #[test]
448    fn flags_credential_shaped_env_and_header_values() {
449        let json = r#"{"mcpServers":{
450            "a":{"command":"x","env":{"API_TOKEN":"sk-live-123","ROOT":"/tmp"}},
451            "b":{"url":"https://y.test","headers":{"Authorization":"Bearer abc"}}}}"#;
452
453        let found = parse_json_object(json, "mcpServers").unwrap();
454
455        assert_eq!(by_name(&found, "a").inline_secrets, vec!["API_TOKEN"]);
456        assert_eq!(by_name(&found, "b").inline_secrets, vec!["Authorization"]);
457    }
458
459    #[test]
460    fn does_not_flag_env_references_or_innocuous_keys() {
461        // `${VAR}` and `$VAR` are expanded at connect time, so nothing
462        // sensitive is in the file.
463        let json = r#"{"mcpServers":{"a":{"command":"x","env":{
464            "API_TOKEN":"${GITHUB_TOKEN}","OTHER_KEY":"$SOME_VAR",
465            "EMPTY_SECRET":"","ROOT":"/tmp"}}}}"#;
466
467        let found = parse_json_object(json, "mcpServers").unwrap();
468
469        assert!(found[0].inline_secrets.is_empty());
470    }
471
472    #[test]
473    fn secret_detection_covers_every_hint_and_is_case_insensitive() {
474        for hint in SECRET_HINTS {
475            assert!(
476                looks_like_inline_secret(&format!("MY_{}", hint.to_uppercase()), "literal"),
477                "{hint} should be treated as credential-shaped"
478            );
479        }
480        assert!(!looks_like_inline_secret("ROOT_DIR", "literal"));
481    }
482
483    // ─── Claude Code ────────────────────────────────────────────────────────
484
485    #[test]
486    fn claude_code_yields_global_and_per_project_servers_with_scopes() {
487        let json = r#"{
488            "mcpServers":{"global":{"url":"https://g.test/mcp"}},
489            "projects":{
490              "/repo/b":{"mcpServers":{"beta":{"command":"b"}}},
491              "/repo/a":{"mcpServers":{"alpha":{"command":"a"}}},
492              "/repo/c":{"other":"no servers here"}
493            }}"#;
494
495        let found = parse_claude_code(json).unwrap();
496
497        assert_eq!(found.len(), 3);
498        assert_eq!(found[0].config.name, "global");
499        assert!(found[0].scope.is_empty());
500        // Projects are visited in sorted path order, so the listing is stable.
501        assert_eq!(found[1].config.name, "alpha");
502        assert_eq!(found[1].scope, "/repo/a");
503        assert_eq!(found[2].config.name, "beta");
504        assert_eq!(found[2].scope, "/repo/b");
505    }
506
507    #[test]
508    fn claude_code_tolerates_a_file_with_no_servers_at_all() {
509        // The overwhelmingly common case: a big `~/.claude.json` full of
510        // unrelated state, with `projects` empty or absent entirely.
511        assert!(
512            parse_claude_code(r#"{"numStartups":12,"projects":{}}"#)
513                .unwrap()
514                .is_empty()
515        );
516        assert!(
517            parse_claude_code(r#"{"numStartups":12}"#)
518                .unwrap()
519                .is_empty()
520        );
521        assert!(
522            parse_claude_code(r#"{"projects":"not an object"}"#)
523                .unwrap()
524                .is_empty()
525        );
526    }
527
528    #[test]
529    fn claude_code_rejects_malformed_json() {
530        assert!(parse_claude_code("not json").is_err());
531    }
532
533    // ─── Codex ──────────────────────────────────────────────────────────────
534
535    #[test]
536    fn codex_parses_stdio_and_http_tables() {
537        let toml_src = r#"
538            [mcp_servers.fs]
539            command = "npx"
540            args = ["-y", "@mcp/fs"]
541
542            [mcp_servers.fs.env]
543            ROOT = "/tmp"
544
545            [mcp_servers.api]
546            url = "https://x.test/mcp"
547        "#;
548
549        let found = parse_codex(toml_src).unwrap();
550
551        assert_eq!(found.len(), 2);
552        let fs = &by_name(&found, "fs").config;
553        assert_eq!(fs.command.as_deref(), Some("npx"));
554        assert_eq!(fs.args, vec!["-y", "@mcp/fs"]);
555        assert_eq!(fs.env.get("ROOT").map(String::as_str), Some("/tmp"));
556        assert_eq!(
557            by_name(&found, "api").config.url.as_deref(),
558            Some("https://x.test/mcp")
559        );
560    }
561
562    #[test]
563    fn codex_without_an_mcp_servers_table_yields_nothing() {
564        assert!(parse_codex("model = \"gpt-5\"\n").unwrap().is_empty());
565    }
566
567    #[test]
568    fn codex_rejects_malformed_toml() {
569        assert!(parse_codex("[[[not toml").is_err());
570    }
571}