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    Ok(ImportedConnection {
72        selector: Selector {
73            path,
74            entry: selector.entry,
75        },
76        connection,
77    })
78}
79
80#[derive(Debug, Default, Deserialize)]
81struct Document {
82    #[serde(default, rename = "mcpServers")]
83    mcp_servers: BTreeMap<String, Entry>,
84    #[serde(default)]
85    servers: BTreeMap<String, Entry>,
86}
87
88#[derive(Debug, Default, Deserialize)]
89struct Entry {
90    #[serde(rename = "type")]
91    kind: Option<String>,
92    transport: Option<String>,
93    command: Option<String>,
94    #[serde(default)]
95    args: Vec<String>,
96    #[serde(default)]
97    env: BTreeMap<String, String>,
98    cwd: Option<String>,
99    url: Option<String>,
100    #[serde(default)]
101    headers: BTreeMap<String, String>,
102}
103
104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105enum ImportedTransport {
106    Http,
107    Stdio,
108}
109
110fn parse_document(
111    source: &str,
112    path: &Path,
113    selected: &str,
114    lookup: &impl Fn(&str) -> Option<String>,
115) -> Result<Connection, String> {
116    let document: Document = serde_json::from_str(source)
117        .map_err(|error| format!("{}: invalid MCP JSON config: {error}", path.display()))?;
118    let mut entries = document.mcp_servers;
119    for (name, entry) in document.servers {
120        if entries.insert(name.clone(), entry).is_some() {
121            return Err(format!(
122                "{} defines server {name:?} in both `mcpServers` and `servers`",
123                path.display()
124            ));
125        }
126    }
127    let entry = entries.get(selected).ok_or_else(|| {
128        let available = entries.keys().cloned().collect::<Vec<_>>();
129        if available.is_empty() {
130            format!(
131                "{} has no entries under `mcpServers` or `servers`",
132                path.display()
133            )
134        } else {
135            format!(
136                "{} has no server named {selected:?}; available servers: {}",
137                path.display(),
138                available.join(", ")
139            )
140        }
141    })?;
142    resolve_entry(entry, path, selected, lookup)
143}
144
145fn resolve_entry(
146    entry: &Entry,
147    path: &Path,
148    name: &str,
149    lookup: &impl Fn(&str) -> Option<String>,
150) -> Result<Connection, String> {
151    let workspace = workspace_folder(path);
152    let declared = match (&entry.kind, &entry.transport) {
153        (Some(kind), Some(transport)) => {
154            let kind = parse_transport(kind)?;
155            let transport = parse_transport(transport)?;
156            if kind != transport {
157                return Err(format!(
158                    "server {name:?} has conflicting `type` and `transport` values"
159                ));
160            }
161            Some(kind)
162        }
163        (Some(kind), None) | (None, Some(kind)) => Some(parse_transport(kind)?),
164        (None, None) => None,
165    };
166    let has_command = entry.command.is_some();
167    let has_url = entry.url.is_some();
168    let transport = match (declared, has_command, has_url) {
169        (Some(transport), _, _) => transport,
170        (None, true, false) => ImportedTransport::Stdio,
171        (None, false, true) => ImportedTransport::Http,
172        (None, true, true) => {
173            return Err(format!(
174                "server {name:?} sets both `command` and `url`; add `type` to choose a transport"
175            ));
176        }
177        (None, false, false) => {
178            return Err(format!(
179                "server {name:?} has neither `command` nor `url`, so its transport cannot be inferred"
180            ));
181        }
182    };
183
184    match transport {
185        ImportedTransport::Stdio => {
186            if entry.url.is_some() || !entry.headers.is_empty() {
187                return Err(format!(
188                    "stdio server {name:?} also sets HTTP-only `url` or `headers`"
189                ));
190            }
191            let command = entry
192                .command
193                .as_deref()
194                .ok_or_else(|| format!("stdio server {name:?} has no `command`"))?;
195            let mut command_and_args = Vec::with_capacity(entry.args.len() + 1);
196            command_and_args.push(expand(command, &workspace, lookup)?);
197            for argument in &entry.args {
198                command_and_args.push(expand(argument, &workspace, lookup)?);
199            }
200            if command_and_args[0].is_empty() {
201                return Err(format!("stdio server {name:?} has an empty `command`"));
202            }
203            let env = entry
204                .env
205                .iter()
206                .map(|(key, value)| {
207                    expand(value, &workspace, lookup).map(|value| (key.clone(), value))
208                })
209                .collect::<Result<BTreeMap<_, _>, _>>()?;
210            let cwd = entry
211                .cwd
212                .as_deref()
213                .map(|cwd| expand(cwd, &workspace, lookup))
214                .transpose()?
215                .map(PathBuf::from)
216                .map(|cwd| {
217                    if cwd.is_absolute() {
218                        cwd
219                    } else {
220                        workspace.join(cwd)
221                    }
222                });
223            Ok(Connection::Stdio {
224                command: command_and_args,
225                env,
226                cwd,
227            })
228        }
229        ImportedTransport::Http => {
230            if entry.command.is_some()
231                || !entry.args.is_empty()
232                || !entry.env.is_empty()
233                || entry.cwd.is_some()
234            {
235                return Err(format!(
236                    "HTTP server {name:?} also sets stdio-only `command`, `args`, `env`, or `cwd`"
237                ));
238            }
239            let url = entry
240                .url
241                .as_deref()
242                .ok_or_else(|| format!("HTTP server {name:?} has no `url`"))?;
243            let headers = entry
244                .headers
245                .iter()
246                .map(|(key, value)| {
247                    expand(value, &workspace, lookup).map(|value| (key.clone(), value))
248                })
249                .collect::<Result<Vec<_>, _>>()?;
250            Ok(Connection::Http {
251                url: expand(url, &workspace, lookup)?,
252                bearer: None,
253                headers,
254                oauth: None,
255            })
256        }
257    }
258}
259
260fn parse_transport(value: &str) -> Result<ImportedTransport, String> {
261    match value.to_ascii_lowercase().replace(['-', '_'], "").as_str() {
262        "stdio" => Ok(ImportedTransport::Stdio),
263        "http" | "streamablehttp" => Ok(ImportedTransport::Http),
264        "sse" => Err(
265            "transport `sse` is not supported; mcp-repl requires Streamable HTTP (`http`)"
266                .to_string(),
267        ),
268        _ => Err(format!(
269            "unsupported imported transport {value:?}; expected `stdio` or `http`"
270        )),
271    }
272}
273
274fn workspace_folder(config_path: &Path) -> PathBuf {
275    let parent = config_path.parent().unwrap_or_else(|| Path::new("."));
276    if parent.file_name().is_some_and(|name| name == ".vscode") {
277        parent.parent().unwrap_or(parent).to_path_buf()
278    } else {
279        parent.to_path_buf()
280    }
281}
282
283fn expand(
284    input: &str,
285    workspace: &Path,
286    lookup: &impl Fn(&str) -> Option<String>,
287) -> Result<String, String> {
288    let mut rendered = String::new();
289    let mut rest = input;
290    while let Some(start) = rest.find("${") {
291        rendered.push_str(&rest[..start]);
292        let after_open = &rest[start + 2..];
293        let Some(end) = after_open.find('}') else {
294            return Err("unterminated `${...}` substitution in imported config".to_string());
295        };
296        let variable = &after_open[..end];
297        let replacement = match variable {
298            "workspaceFolder" => workspace.to_string_lossy().into_owned(),
299            "workspaceFolderBasename" => workspace
300                .file_name()
301                .map(|name| name.to_string_lossy().into_owned())
302                .unwrap_or_default(),
303            "userHome" => lookup("HOME")
304                .or_else(|| lookup("USERPROFILE"))
305                .ok_or_else(|| {
306                    "`${userHome}` requires the HOME or USERPROFILE environment variable"
307                        .to_string()
308                })?,
309            variable if variable.starts_with("input:") => {
310                return Err(format!(
311                    "`${{{variable}}}` requires interactive client input, which mcp-repl cannot import; use an environment variable instead"
312                ));
313            }
314            variable => {
315                let variable = variable.strip_prefix("env:").unwrap_or(variable);
316                if variable.is_empty() {
317                    return Err(
318                        "imported config contains an empty environment substitution".to_string()
319                    );
320                }
321                lookup(variable).ok_or_else(|| {
322                    format!("imported config requires environment variable {variable:?}, but it is unset")
323                })?
324            }
325        };
326        rendered.push_str(&replacement);
327        rest = &after_open[end + 1..];
328    }
329    rendered.push_str(rest);
330    Ok(rendered)
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
338        let values: BTreeMap<String, String> = pairs
339            .iter()
340            .map(|(key, value)| (key.to_string(), value.to_string()))
341            .collect();
342        move |key| values.get(key).cloned()
343    }
344
345    #[test]
346    fn parses_claude_stdio_shape_with_workspace_and_environment() {
347        let source = r#"{
348          "mcpServers": {
349            "local": {
350              "command": "${workspaceFolder}/bin/server",
351              "args": ["--repo", "${workspaceFolderBasename}"],
352              "env": {"API_TOKEN": "${env:HOST_TOKEN}"},
353              "cwd": "work"
354            }
355          }
356        }"#;
357        let resolved = parse_document(
358            source,
359            Path::new("/repo/.mcp.json"),
360            "local",
361            &env(&[("HOST_TOKEN", "secret")]),
362        )
363        .unwrap();
364        assert_eq!(
365            resolved,
366            Connection::Stdio {
367                command: vec![
368                    "/repo/bin/server".to_string(),
369                    "--repo".to_string(),
370                    "repo".to_string(),
371                ],
372                env: BTreeMap::from([("API_TOKEN".to_string(), "secret".to_string())]),
373                cwd: Some(PathBuf::from("/repo/work")),
374            }
375        );
376    }
377
378    #[test]
379    fn parses_vscode_http_shape_and_uses_workspace_parent() {
380        let source = r#"{
381          "servers": {
382            "remote": {
383              "type": "streamable-http",
384              "url": "${env:MCP_URL}",
385              "headers": {"Authorization": "Bearer ${TOKEN}"}
386            }
387          }
388        }"#;
389        let resolved = parse_document(
390            source,
391            Path::new("/repo/.vscode/mcp.json"),
392            "remote",
393            &env(&[("MCP_URL", "https://example/mcp"), ("TOKEN", "secret")]),
394        )
395        .unwrap();
396        assert_eq!(
397            resolved,
398            Connection::Http {
399                url: "https://example/mcp".to_string(),
400                bearer: None,
401                headers: vec![("Authorization".to_string(), "Bearer secret".to_string())],
402                oauth: None,
403            }
404        );
405    }
406
407    #[test]
408    fn missing_entry_lists_sorted_names() {
409        let error = parse_document(
410            r#"{"mcpServers":{"z":{"command":"z"},"a":{"command":"a"}}}"#,
411            Path::new("/repo/.mcp.json"),
412            "missing",
413            &env(&[]),
414        )
415        .unwrap_err();
416        assert!(error.contains("a, z"), "{error}");
417    }
418
419    #[test]
420    fn rejects_ambiguous_and_unsupported_transports() {
421        let ambiguous = parse_document(
422            r#"{"mcpServers":{"x":{"command":"x","url":"https://example"}}}"#,
423            Path::new("/repo/.mcp.json"),
424            "x",
425            &env(&[]),
426        )
427        .unwrap_err();
428        assert!(ambiguous.contains("both"), "{ambiguous}");
429
430        let sse = parse_document(
431            r#"{"servers":{"x":{"type":"sse","url":"https://example"}}}"#,
432            Path::new("/repo/mcp.json"),
433            "x",
434            &env(&[]),
435        )
436        .unwrap_err();
437        assert!(sse.contains("Streamable HTTP"), "{sse}");
438    }
439
440    #[test]
441    fn missing_substitutions_name_the_variable_without_leaking_values() {
442        let error = parse_document(
443            r#"{
444              "mcpServers": {
445                "x": {
446                  "command": "server",
447                  "args": ["${env:MISSING}"],
448                  "env": {"LITERAL_SECRET": "do-not-print-me"}
449                }
450              }
451            }"#,
452            Path::new("/repo/.mcp.json"),
453            "x",
454            &env(&[]),
455        )
456        .unwrap_err();
457        assert!(error.contains("MISSING"), "{error}");
458        assert!(!error.contains("do-not-print-me"), "{error}");
459    }
460
461    #[test]
462    fn interactive_input_substitutions_are_actionable_errors() {
463        let error = expand("${input:token}", Path::new("/repo"), &env(&[])).unwrap_err();
464        assert!(error.contains("interactive"), "{error}");
465        assert!(error.contains("environment variable"), "{error}");
466    }
467
468    #[test]
469    fn selector_recognition_does_not_steal_ordinary_commands() {
470        assert!(parse_selector("registry:serve").is_none());
471        assert_eq!(
472            parse_selector("path/to/.mcp.json:server").unwrap().unwrap(),
473            Selector {
474                path: PathBuf::from("path/to/.mcp.json"),
475                entry: "server".to_string(),
476            }
477        );
478    }
479}