1use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7use serde::Deserialize;
8
9use crate::config::Connection;
10
11#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct Selector {
14 pub path: PathBuf,
15 pub entry: String,
16}
17
18#[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
31pub 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
60pub 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}