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 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
85pub 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 #[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#[derive(Debug)]
128pub struct ScannedFile {
129 pub path: PathBuf,
130 pub result: Result<Vec<DiscoveredEntry>, String>,
133}
134
135pub 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#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct DiscoveredEntry {
154 pub name: String,
156 pub transport: String,
158 pub summary: String,
160}
161
162pub 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
189fn 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 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 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 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 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 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 assert!(paths[0].starts_with(cwd));
585 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}