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