1use std::collections::{BTreeMap, BTreeSet};
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 pub http_trust: Option<ImportedHttpTrust>,
25}
26
27#[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
47pub 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
76pub 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
102pub 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#[derive(Debug)]
134pub struct ScannedFile {
135 pub path: PathBuf,
136 pub result: Result<Vec<DiscoveredEntry>, String>,
139}
140
141pub 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#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct DiscoveredEntry {
160 pub name: String,
162 pub transport: String,
164 pub summary: String,
166}
167
168pub 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
195fn 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 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 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 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 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 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 assert!(paths[0].starts_with(cwd));
690 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}