Skip to main content

mcp_repl/
import_config.rs

1//! Import named servers from the common JSON configuration used by MCP
2//! clients such as Claude, Cursor, and VS Code.
3
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7use serde::Deserialize;
8
9use crate::config::Connection;
10
11/// An explicit `PATH:ENTRY` selector recognized by the CLI.
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct Selector {
14    pub path: PathBuf,
15    pub entry: String,
16}
17
18/// A resolved imported server plus its display label.
19#[derive(Debug, PartialEq, Eq)]
20pub struct ImportedConnection {
21    pub selector: Selector,
22    pub connection: Connection,
23}
24
25impl ImportedConnection {
26    pub fn label(&self) -> String {
27        format!("{}:{}", self.selector.path.display(), self.selector.entry)
28    }
29}
30
31/// Recognize an explicit JSON config selector without stealing ordinary
32/// executable names containing `:`. A `.json` suffix is explicit even when
33/// the file is missing, so the user gets a file error rather than attempting
34/// to spawn the whole selector as a command.
35pub fn parse_selector(value: &str) -> Option<Result<Selector, String>> {
36    let (path, entry) = value.rsplit_once(':')?;
37    let path = PathBuf::from(path);
38    let looks_like_json = path
39        .extension()
40        .and_then(|extension| extension.to_str())
41        .is_some_and(|extension| extension.eq_ignore_ascii_case("json"));
42    if !looks_like_json && !path.exists() {
43        return None;
44    }
45    if path.as_os_str().is_empty() {
46        return Some(Err("import selector has an empty file path".to_string()));
47    }
48    if entry.is_empty() {
49        return Some(Err(format!(
50            "import selector for {} has an empty entry name",
51            path.display()
52        )));
53    }
54    Some(Ok(Selector {
55        path,
56        entry: entry.to_string(),
57    }))
58}
59
60/// Read and resolve one selected server. Environment values are supplied by
61/// the caller so tests never mutate the process environment.
62pub fn load_with(
63    selector: Selector,
64    lookup: impl Fn(&str) -> Option<String>,
65) -> Result<ImportedConnection, String> {
66    let source = std::fs::read_to_string(&selector.path)
67        .map_err(|error| format!("{}: {error}", selector.path.display()))?;
68    let path = std::fs::canonicalize(&selector.path)
69        .map_err(|error| format!("{}: {error}", selector.path.display()))?;
70    let connection = parse_document(&source, &path, &selector.entry, &lookup)?;
71    tracing::debug!(
72        path = %path.display(),
73        entry = %selector.entry,
74        "resolved a client config entry"
75    );
76    Ok(ImportedConnection {
77        selector: Selector {
78            path,
79            entry: selector.entry,
80        },
81        connection,
82    })
83}
84
85/// Where MCP clients keep their server lists.
86///
87/// Project files first, since a scan is usually run inside a repository and
88/// that is the config the user means. Paths that do not exist are dropped by
89/// [`scan`], so this can list every location worth trying.
90pub fn candidate_paths(cwd: &Path, home: Option<&Path>) -> Vec<PathBuf> {
91    let mut paths = vec![
92        cwd.join(".mcp.json"),
93        cwd.join(".vscode").join("mcp.json"),
94        cwd.join(".cursor").join("mcp.json"),
95    ];
96    let Some(home) = home else {
97        return paths;
98    };
99    paths.push(home.join(".claude.json"));
100    paths.push(home.join(".cursor").join("mcp.json"));
101    // Claude Desktop, per platform.
102    #[cfg(target_os = "macos")]
103    paths.push(
104        home.join("Library")
105            .join("Application Support")
106            .join("Claude")
107            .join("claude_desktop_config.json"),
108    );
109    #[cfg(target_os = "linux")]
110    paths.push(
111        home.join(".config")
112            .join("Claude")
113            .join("claude_desktop_config.json"),
114    );
115    #[cfg(target_os = "windows")]
116    if let Some(appdata) = std::env::var_os("APPDATA") {
117        paths.push(
118            PathBuf::from(appdata)
119                .join("Claude")
120                .join("claude_desktop_config.json"),
121        );
122    }
123    paths
124}
125
126/// What one config file yielded.
127#[derive(Debug)]
128pub struct ScannedFile {
129    pub path: PathBuf,
130    /// The entries, or why the file could not be read. A malformed file is
131    /// reported rather than skipped: silence would read as "nothing here".
132    pub result: Result<Vec<DiscoveredEntry>, String>,
133}
134
135/// Describe every config that exists among `paths`.
136///
137/// Files that are absent are skipped; files that exist but cannot be parsed
138/// are included with their error.
139pub fn scan(paths: &[PathBuf]) -> Vec<ScannedFile> {
140    tracing::debug!(candidates = paths.len(), "scanning for client configs");
141    paths
142        .iter()
143        .filter(|path| path.is_file())
144        .map(|path| ScannedFile {
145            path: path.clone(),
146            result: list_entries(path),
147        })
148        .collect()
149}
150
151/// One entry found in a client config, described without being resolved.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct DiscoveredEntry {
154    /// The name to put after the colon in a `PATH:ENTRY` selector.
155    pub name: String,
156    /// `stdio` or `http`, or `?` when the entry declares neither.
157    pub transport: String,
158    /// The command line or URL, as written in the file.
159    pub summary: String,
160}
161
162/// List what a config file offers, without resolving it.
163///
164/// Deliberately not [`load_with`]: resolution expands `${env:NAME}` and fails
165/// on a variable that is not set, which would make a scan of somebody else's
166/// config report errors for entries that are perfectly fine to use once the
167/// variable is exported. Placeholders are shown as written.
168///
169/// Nothing here executes anything: it reads a file and describes it.
170pub fn list_entries(path: &Path) -> Result<Vec<DiscoveredEntry>, String> {
171    let source = std::fs::read_to_string(path).map_err(|error| plain_io_error(&error))?;
172    let document: Document =
173        serde_json::from_str(&source).map_err(|error| format!("not valid JSON: {error}"))?;
174    let mut found: Vec<DiscoveredEntry> = document
175        .mcp_servers
176        .iter()
177        .chain(document.servers.iter())
178        .map(|(name, entry)| DiscoveredEntry {
179            name: name.clone(),
180            transport: entry.declared_transport().to_string(),
181            summary: entry.summary(),
182        })
183        .collect();
184    found.sort_by(|a, b| a.name.cmp(&b.name));
185    found.dedup_by(|a, b| a.name == b.name);
186    Ok(found)
187}
188
189/// An io error without the path repeated: the caller already prints it.
190fn plain_io_error(error: &std::io::Error) -> String {
191    match error.kind() {
192        std::io::ErrorKind::NotFound => "no such file".to_string(),
193        std::io::ErrorKind::PermissionDenied => "permission denied".to_string(),
194        _ => error.to_string(),
195    }
196}
197
198impl Entry {
199    /// What the entry says it is, before any validation.
200    fn declared_transport(&self) -> &'static str {
201        let declared = self.kind.as_deref().or(self.transport.as_deref());
202        match declared {
203            Some(kind) if kind.eq_ignore_ascii_case("stdio") => "stdio",
204            Some(_) => "http",
205            None if self.command.is_some() => "stdio",
206            None if self.url.is_some() => "http",
207            None => "?",
208        }
209    }
210
211    /// The command line or URL as written, for a scan listing.
212    fn summary(&self) -> String {
213        if let Some(command) = &self.command {
214            let mut line = command.clone();
215            for arg in &self.args {
216                line.push(' ');
217                line.push_str(arg);
218            }
219            return line;
220        }
221        self.url
222            .clone()
223            .unwrap_or_else(|| "(no command or url)".to_string())
224    }
225}
226
227#[derive(Debug, Default, Deserialize)]
228struct Document {
229    #[serde(default, rename = "mcpServers")]
230    mcp_servers: BTreeMap<String, Entry>,
231    #[serde(default)]
232    servers: BTreeMap<String, Entry>,
233}
234
235#[derive(Debug, Default, Deserialize)]
236struct Entry {
237    #[serde(rename = "type")]
238    kind: Option<String>,
239    transport: Option<String>,
240    command: Option<String>,
241    #[serde(default)]
242    args: Vec<String>,
243    #[serde(default)]
244    env: BTreeMap<String, String>,
245    cwd: Option<String>,
246    url: Option<String>,
247    #[serde(default)]
248    headers: BTreeMap<String, String>,
249}
250
251#[derive(Clone, Copy, Debug, PartialEq, Eq)]
252enum ImportedTransport {
253    Http,
254    Stdio,
255}
256
257fn parse_document(
258    source: &str,
259    path: &Path,
260    selected: &str,
261    lookup: &impl Fn(&str) -> Option<String>,
262) -> Result<Connection, String> {
263    let document: Document = serde_json::from_str(source)
264        .map_err(|error| format!("{}: invalid MCP JSON config: {error}", path.display()))?;
265    let mut entries = document.mcp_servers;
266    for (name, entry) in document.servers {
267        if entries.insert(name.clone(), entry).is_some() {
268            return Err(format!(
269                "{} defines server {name:?} in both `mcpServers` and `servers`",
270                path.display()
271            ));
272        }
273    }
274    let entry = entries.get(selected).ok_or_else(|| {
275        let available = entries.keys().cloned().collect::<Vec<_>>();
276        if available.is_empty() {
277            format!(
278                "{} has no entries under `mcpServers` or `servers`",
279                path.display()
280            )
281        } else {
282            format!(
283                "{} has no server named {selected:?}; available servers: {}",
284                path.display(),
285                available.join(", ")
286            )
287        }
288    })?;
289    resolve_entry(entry, path, selected, lookup)
290}
291
292fn resolve_entry(
293    entry: &Entry,
294    path: &Path,
295    name: &str,
296    lookup: &impl Fn(&str) -> Option<String>,
297) -> Result<Connection, String> {
298    let workspace = workspace_folder(path);
299    let declared = match (&entry.kind, &entry.transport) {
300        (Some(kind), Some(transport)) => {
301            let kind = parse_transport(kind)?;
302            let transport = parse_transport(transport)?;
303            if kind != transport {
304                return Err(format!(
305                    "server {name:?} has conflicting `type` and `transport` values"
306                ));
307            }
308            Some(kind)
309        }
310        (Some(kind), None) | (None, Some(kind)) => Some(parse_transport(kind)?),
311        (None, None) => None,
312    };
313    let has_command = entry.command.is_some();
314    let has_url = entry.url.is_some();
315    let transport = match (declared, has_command, has_url) {
316        (Some(transport), _, _) => transport,
317        (None, true, false) => ImportedTransport::Stdio,
318        (None, false, true) => ImportedTransport::Http,
319        (None, true, true) => {
320            return Err(format!(
321                "server {name:?} sets both `command` and `url`; add `type` to choose a transport"
322            ));
323        }
324        (None, false, false) => {
325            return Err(format!(
326                "server {name:?} has neither `command` nor `url`, so its transport cannot be inferred"
327            ));
328        }
329    };
330
331    match transport {
332        ImportedTransport::Stdio => {
333            if entry.url.is_some() || !entry.headers.is_empty() {
334                return Err(format!(
335                    "stdio server {name:?} also sets HTTP-only `url` or `headers`"
336                ));
337            }
338            let command = entry
339                .command
340                .as_deref()
341                .ok_or_else(|| format!("stdio server {name:?} has no `command`"))?;
342            let mut command_and_args = Vec::with_capacity(entry.args.len() + 1);
343            command_and_args.push(expand(command, &workspace, lookup)?);
344            for argument in &entry.args {
345                command_and_args.push(expand(argument, &workspace, lookup)?);
346            }
347            if command_and_args[0].is_empty() {
348                return Err(format!("stdio server {name:?} has an empty `command`"));
349            }
350            let env = entry
351                .env
352                .iter()
353                .map(|(key, value)| {
354                    expand(value, &workspace, lookup).map(|value| (key.clone(), value))
355                })
356                .collect::<Result<BTreeMap<_, _>, _>>()?;
357            let cwd = entry
358                .cwd
359                .as_deref()
360                .map(|cwd| expand(cwd, &workspace, lookup))
361                .transpose()?
362                .map(PathBuf::from)
363                .map(|cwd| {
364                    if cwd.is_absolute() {
365                        cwd
366                    } else {
367                        workspace.join(cwd)
368                    }
369                });
370            Ok(Connection::Stdio {
371                command: command_and_args,
372                env,
373                cwd,
374            })
375        }
376        ImportedTransport::Http => {
377            if entry.command.is_some()
378                || !entry.args.is_empty()
379                || !entry.env.is_empty()
380                || entry.cwd.is_some()
381            {
382                return Err(format!(
383                    "HTTP server {name:?} also sets stdio-only `command`, `args`, `env`, or `cwd`"
384                ));
385            }
386            let url = entry
387                .url
388                .as_deref()
389                .ok_or_else(|| format!("HTTP server {name:?} has no `url`"))?;
390            let headers = entry
391                .headers
392                .iter()
393                .map(|(key, value)| {
394                    expand(value, &workspace, lookup).map(|value| (key.clone(), value))
395                })
396                .collect::<Result<Vec<_>, _>>()?;
397            Ok(Connection::Http {
398                url: expand(url, &workspace, lookup)?,
399                bearer: None,
400                headers,
401                oauth: None,
402            })
403        }
404    }
405}
406
407fn parse_transport(value: &str) -> Result<ImportedTransport, String> {
408    match value.to_ascii_lowercase().replace(['-', '_'], "").as_str() {
409        "stdio" => Ok(ImportedTransport::Stdio),
410        "http" | "streamablehttp" => Ok(ImportedTransport::Http),
411        "sse" => Err(
412            "transport `sse` is not supported; mcp-repl requires Streamable HTTP (`http`)"
413                .to_string(),
414        ),
415        _ => Err(format!(
416            "unsupported imported transport {value:?}; expected `stdio` or `http`"
417        )),
418    }
419}
420
421fn workspace_folder(config_path: &Path) -> PathBuf {
422    let parent = config_path.parent().unwrap_or_else(|| Path::new("."));
423    if parent.file_name().is_some_and(|name| name == ".vscode") {
424        parent.parent().unwrap_or(parent).to_path_buf()
425    } else {
426        parent.to_path_buf()
427    }
428}
429
430fn expand(
431    input: &str,
432    workspace: &Path,
433    lookup: &impl Fn(&str) -> Option<String>,
434) -> Result<String, String> {
435    let mut rendered = String::new();
436    let mut rest = input;
437    while let Some(start) = rest.find("${") {
438        rendered.push_str(&rest[..start]);
439        let after_open = &rest[start + 2..];
440        let Some(end) = after_open.find('}') else {
441            return Err("unterminated `${...}` substitution in imported config".to_string());
442        };
443        let variable = &after_open[..end];
444        let replacement = match variable {
445            "workspaceFolder" => workspace.to_string_lossy().into_owned(),
446            "workspaceFolderBasename" => workspace
447                .file_name()
448                .map(|name| name.to_string_lossy().into_owned())
449                .unwrap_or_default(),
450            "userHome" => lookup("HOME")
451                .or_else(|| lookup("USERPROFILE"))
452                .ok_or_else(|| {
453                    "`${userHome}` requires the HOME or USERPROFILE environment variable"
454                        .to_string()
455                })?,
456            variable if variable.starts_with("input:") => {
457                return Err(format!(
458                    "`${{{variable}}}` requires interactive client input, which mcp-repl cannot import; use an environment variable instead"
459                ));
460            }
461            variable => {
462                let variable = variable.strip_prefix("env:").unwrap_or(variable);
463                if variable.is_empty() {
464                    return Err(
465                        "imported config contains an empty environment substitution".to_string()
466                    );
467                }
468                lookup(variable).ok_or_else(|| {
469                    format!("imported config requires environment variable {variable:?}, but it is unset")
470                })?
471            }
472        };
473        rendered.push_str(&replacement);
474        rest = &after_open[end + 1..];
475    }
476    rendered.push_str(rest);
477    Ok(rendered)
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    fn write(dir: &std::path::Path, name: &str, body: &str) -> PathBuf {
485        let path = dir.join(name);
486        if let Some(parent) = path.parent() {
487            std::fs::create_dir_all(parent).unwrap();
488        }
489        std::fs::write(&path, body).unwrap();
490        path
491    }
492
493    #[test]
494    fn listing_describes_both_roots_and_both_transports() {
495        let dir = tempfile::tempdir().unwrap();
496        let path = write(
497            dir.path(),
498            ".mcp.json",
499            r#"{
500                "mcpServers": {
501                    "local": {"command": "node", "args": ["server.js", "--stdio"]}
502                },
503                "servers": {
504                    "remote": {"type": "http", "url": "https://example.com/mcp"}
505                }
506            }"#,
507        );
508        let entries = list_entries(&path).unwrap();
509        assert_eq!(entries.len(), 2);
510        assert_eq!(entries[0].name, "local");
511        assert_eq!(entries[0].transport, "stdio");
512        assert_eq!(entries[0].summary, "node server.js --stdio");
513        assert_eq!(entries[1].name, "remote");
514        assert_eq!(entries[1].transport, "http");
515        assert_eq!(entries[1].summary, "https://example.com/mcp");
516    }
517
518    #[test]
519    fn listing_does_not_resolve_placeholders() {
520        // Resolution fails on a variable that is not set. A scan of someone
521        // else's config must still describe the entry, since exporting the
522        // variable is all that is needed to use it.
523        let dir = tempfile::tempdir().unwrap();
524        let path = write(
525            dir.path(),
526            ".mcp.json",
527            r#"{"mcpServers": {"x": {"url": "${env:NEVER_SET_ANYWHERE}"}}}"#,
528        );
529        let entries = list_entries(&path).unwrap();
530        assert_eq!(entries[0].summary, "${env:NEVER_SET_ANYWHERE}");
531        // The resolving path is the one that objects.
532        assert!(
533            load_with(
534                Selector {
535                    path,
536                    entry: "x".to_string()
537                },
538                |_| None
539            )
540            .is_err()
541        );
542    }
543
544    #[test]
545    fn a_malformed_file_reports_rather_than_disappearing() {
546        let dir = tempfile::tempdir().unwrap();
547        let path = write(dir.path(), ".mcp.json", "not json {{{");
548        let error = list_entries(&path).unwrap_err();
549        assert!(error.contains("not valid JSON"), "{error}");
550
551        // And a scan includes it, so a broken config is visible instead of
552        // looking like an empty one.
553        let scanned = scan(std::slice::from_ref(&path));
554        assert_eq!(scanned.len(), 1);
555        assert!(scanned[0].result.is_err());
556    }
557
558    #[test]
559    fn scanning_skips_paths_that_do_not_exist() {
560        let dir = tempfile::tempdir().unwrap();
561        let real = write(
562            dir.path(),
563            ".mcp.json",
564            r#"{"mcpServers":{"a":{"url":"http://x"}}}"#,
565        );
566        let scanned = scan(&[dir.path().join("absent.json"), real]);
567        assert_eq!(scanned.len(), 1, "a missing file is not an error to report");
568    }
569
570    #[test]
571    fn candidates_cover_the_project_files_and_the_home_ones() {
572        let cwd = std::path::Path::new("/work/api");
573        let home = std::path::Path::new("/home/dev");
574        let paths = candidate_paths(cwd, Some(home));
575        for expected in [
576            cwd.join(".mcp.json"),
577            cwd.join(".vscode").join("mcp.json"),
578            cwd.join(".cursor").join("mcp.json"),
579            home.join(".claude.json"),
580        ] {
581            assert!(paths.contains(&expected), "missing {}", expected.display());
582        }
583        // Project files come first: a scan run in a repo means that repo.
584        assert!(paths[0].starts_with(cwd));
585        // Without a home directory it still offers the project files.
586        assert_eq!(candidate_paths(cwd, None).len(), 3);
587    }
588
589    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
590        let values: BTreeMap<String, String> = pairs
591            .iter()
592            .map(|(key, value)| (key.to_string(), value.to_string()))
593            .collect();
594        move |key| values.get(key).cloned()
595    }
596
597    #[test]
598    fn parses_claude_stdio_shape_with_workspace_and_environment() {
599        let source = r#"{
600          "mcpServers": {
601            "local": {
602              "command": "${workspaceFolder}/bin/server",
603              "args": ["--repo", "${workspaceFolderBasename}"],
604              "env": {"API_TOKEN": "${env:HOST_TOKEN}"},
605              "cwd": "work"
606            }
607          }
608        }"#;
609        let resolved = parse_document(
610            source,
611            Path::new("/repo/.mcp.json"),
612            "local",
613            &env(&[("HOST_TOKEN", "secret")]),
614        )
615        .unwrap();
616        assert_eq!(
617            resolved,
618            Connection::Stdio {
619                command: vec![
620                    "/repo/bin/server".to_string(),
621                    "--repo".to_string(),
622                    "repo".to_string(),
623                ],
624                env: BTreeMap::from([("API_TOKEN".to_string(), "secret".to_string())]),
625                cwd: Some(PathBuf::from("/repo/work")),
626            }
627        );
628    }
629
630    #[test]
631    fn parses_vscode_http_shape_and_uses_workspace_parent() {
632        let source = r#"{
633          "servers": {
634            "remote": {
635              "type": "streamable-http",
636              "url": "${env:MCP_URL}",
637              "headers": {"Authorization": "Bearer ${TOKEN}"}
638            }
639          }
640        }"#;
641        let resolved = parse_document(
642            source,
643            Path::new("/repo/.vscode/mcp.json"),
644            "remote",
645            &env(&[("MCP_URL", "https://example/mcp"), ("TOKEN", "secret")]),
646        )
647        .unwrap();
648        assert_eq!(
649            resolved,
650            Connection::Http {
651                url: "https://example/mcp".to_string(),
652                bearer: None,
653                headers: vec![("Authorization".to_string(), "Bearer secret".to_string())],
654                oauth: None,
655            }
656        );
657    }
658
659    #[test]
660    fn missing_entry_lists_sorted_names() {
661        let error = parse_document(
662            r#"{"mcpServers":{"z":{"command":"z"},"a":{"command":"a"}}}"#,
663            Path::new("/repo/.mcp.json"),
664            "missing",
665            &env(&[]),
666        )
667        .unwrap_err();
668        assert!(error.contains("a, z"), "{error}");
669    }
670
671    #[test]
672    fn rejects_ambiguous_and_unsupported_transports() {
673        let ambiguous = parse_document(
674            r#"{"mcpServers":{"x":{"command":"x","url":"https://example"}}}"#,
675            Path::new("/repo/.mcp.json"),
676            "x",
677            &env(&[]),
678        )
679        .unwrap_err();
680        assert!(ambiguous.contains("both"), "{ambiguous}");
681
682        let sse = parse_document(
683            r#"{"servers":{"x":{"type":"sse","url":"https://example"}}}"#,
684            Path::new("/repo/mcp.json"),
685            "x",
686            &env(&[]),
687        )
688        .unwrap_err();
689        assert!(sse.contains("Streamable HTTP"), "{sse}");
690    }
691
692    #[test]
693    fn missing_substitutions_name_the_variable_without_leaking_values() {
694        let error = parse_document(
695            r#"{
696              "mcpServers": {
697                "x": {
698                  "command": "server",
699                  "args": ["${env:MISSING}"],
700                  "env": {"LITERAL_SECRET": "do-not-print-me"}
701                }
702              }
703            }"#,
704            Path::new("/repo/.mcp.json"),
705            "x",
706            &env(&[]),
707        )
708        .unwrap_err();
709        assert!(error.contains("MISSING"), "{error}");
710        assert!(!error.contains("do-not-print-me"), "{error}");
711    }
712
713    #[test]
714    fn interactive_input_substitutions_are_actionable_errors() {
715        let error = expand("${input:token}", Path::new("/repo"), &env(&[])).unwrap_err();
716        assert!(error.contains("interactive"), "{error}");
717        assert!(error.contains("environment variable"), "{error}");
718    }
719
720    #[test]
721    fn selector_recognition_does_not_steal_ordinary_commands() {
722        assert!(parse_selector("registry:serve").is_none());
723        assert_eq!(
724            parse_selector("path/to/.mcp.json:server").unwrap().unwrap(),
725            Selector {
726                path: PathBuf::from("path/to/.mcp.json"),
727                entry: "server".to_string(),
728            }
729        );
730    }
731}