1use anyhow::{Context, Result, bail};
12use sha2::{Digest, Sha256};
13use std::path::{Path, PathBuf};
14
15#[cfg(windows)]
17const MCP_SERVER_BIN: &str = "mur-mcp-server.exe";
18#[cfg(not(windows))]
19const MCP_SERVER_BIN: &str = "mur-mcp-server";
20
21pub fn bundled_mcp_server_path() -> PathBuf {
26 crate::trust::mur_home()
27 .join("mcp-servers")
28 .join(MCP_SERVER_BIN)
29}
30
31pub fn ensure_bundled_mcp_server() -> Result<PathBuf> {
44 let target = bundled_mcp_server_path();
45 match locate_mcp_server_source() {
46 Some(src) => {
47 install_if_stale(&src, &target)?;
48 Ok(target)
49 }
50 None if target.is_file() => Ok(target),
51 None => bail!(
52 "mur-mcp-server not found next to `mur` or on PATH, and no copy at {}",
53 target.display()
54 ),
55 }
56}
57
58fn locate_mcp_server_source() -> Option<PathBuf> {
60 if let Ok(exe) = std::env::current_exe()
61 && let Some(dir) = exe.parent()
62 {
63 let sibling = dir.join(MCP_SERVER_BIN);
64 if sibling.is_file() {
65 return sibling.canonicalize().ok();
66 }
67 }
68 resolve_command(MCP_SERVER_BIN).ok()
69}
70
71fn install_if_stale(src: &Path, target: &Path) -> Result<()> {
76 if target.is_file() && sha256_file(src)? == sha256_file(target)? {
77 return Ok(());
78 }
79 let dir = target
80 .parent()
81 .ok_or_else(|| anyhow::anyhow!("target {} has no parent", target.display()))?;
82 std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
83 let tmp = dir.join(format!(".{MCP_SERVER_BIN}.{}.tmp", std::process::id()));
85 std::fs::copy(src, &tmp)
86 .with_context(|| format!("copy {} -> {}", src.display(), tmp.display()))?;
87 #[cfg(unix)]
88 {
89 use std::os::unix::fs::PermissionsExt;
90 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))
91 .with_context(|| format!("chmod {}", tmp.display()))?;
92 }
93 std::fs::rename(&tmp, target)
94 .with_context(|| format!("rename {} -> {}", tmp.display(), target.display()))?;
95 Ok(())
96}
97
98fn sha256_file(path: &Path) -> Result<String> {
100 use std::io::Read;
101 let mut f = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
102 let mut hasher = Sha256::new();
103 let mut buf = [0u8; 65536];
104 loop {
105 let n = f
106 .read(&mut buf)
107 .with_context(|| format!("read {}", path.display()))?;
108 if n == 0 {
109 break;
110 }
111 hasher.update(&buf[..n]);
112 }
113 Ok(hex::encode(hasher.finalize()))
114}
115
116const INTERPRETERS: &[&str] = &[
119 "npx", "node", "bunx", "bun", "deno", "python", "python3", "uv", "uvx", "pipx", "ruby", "perl",
120 "sh", "bash", "zsh",
121];
122
123pub fn is_interpreter_command(command: &str) -> bool {
136 let first = if Path::new(command).is_file() {
141 command
142 } else {
143 command.split_whitespace().next().unwrap_or(command)
144 };
145 let stem = Path::new(first)
146 .file_stem() .and_then(|s| s.to_str())
148 .unwrap_or(first);
149 INTERPRETERS.contains(&stem.to_ascii_lowercase().as_str())
150}
151
152pub fn resolve_command(command: &str) -> Result<PathBuf> {
170 resolve_command_in(&augmented_path_var(), command)
171}
172
173pub fn resolve_mcp_launch_in(path_var: &std::ffi::OsStr, command: &str) -> Result<PathBuf> {
180 resolve_mcp_launch_with(&bundled_mcp_server_path(), path_var, command)
181}
182
183pub fn resolve_mcp_launch_with(
185 bundled: &Path,
186 path_var: &std::ffi::OsStr,
187 command: &str,
188) -> Result<PathBuf> {
189 if Path::new(command).file_name() == bundled.file_name() && bundled.is_file() {
190 return Ok(bundled.to_path_buf());
191 }
192 resolve_command_in(path_var, command)
193}
194
195pub fn resolve_command_in(path_var: &std::ffi::OsStr, command: &str) -> Result<PathBuf> {
200 let p = Path::new(command);
201 if p.is_absolute() || command.contains('/') || command.contains('\\') {
202 return p
203 .canonicalize()
204 .with_context(|| format!("canonicalize {command}"));
205 }
206 for dir in std::env::split_paths(path_var) {
207 let candidate = dir.join(command);
208 if candidate.is_file() {
209 return candidate
210 .canonicalize()
211 .with_context(|| format!("canonicalize {}", candidate.display()));
212 }
213 #[cfg(target_os = "windows")]
214 {
215 let with_exe = dir.join(format!("{command}.exe"));
216 if with_exe.is_file() {
217 return with_exe
218 .canonicalize()
219 .with_context(|| format!("canonicalize {}", with_exe.display()));
220 }
221 }
222 }
223 bail!(
224 "could not find `{command}` on PATH (searched: {})",
225 std::env::split_paths(path_var)
226 .map(|d| d.display().to_string())
227 .collect::<Vec<_>>()
228 .join(", ")
229 );
230}
231
232pub fn augmented_path_var() -> std::ffi::OsString {
244 let current = std::env::var_os("PATH").unwrap_or_default();
245 let mut dirs_list: Vec<PathBuf> = std::env::split_paths(¤t).collect();
246 let mut extras: Vec<PathBuf> = vec![
247 PathBuf::from("/opt/homebrew/bin"),
248 PathBuf::from("/usr/local/bin"),
249 ];
250 if let Some(home) = dirs::home_dir() {
251 extras.push(home.join(".local/bin"));
252 }
253 for e in extras {
254 if !dirs_list.contains(&e) {
255 dirs_list.push(e);
256 }
257 }
258 std::env::join_paths(dirs_list).unwrap_or(current)
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn interpreter_commands_are_recognised_including_paths_and_args() {
267 for c in [
268 "npx",
269 "node",
270 "python3",
271 "uvx",
272 "bunx",
273 "deno",
274 "sh",
275 "/opt/homebrew/bin/npx",
276 "npx @yawlabs/fetch-mcp",
277 "NPX",
278 "npx.cmd",
279 ] {
280 assert!(
281 is_interpreter_command(c),
282 "`{c}` should count as an interpreter"
283 );
284 }
285 }
286
287 #[test]
288 fn interpreter_path_with_spaces_is_judged_whole() {
289 let dir = tempfile::tempdir().unwrap();
290 let sub = dir.path().join("Application Support").join("bin");
291 std::fs::create_dir_all(&sub).unwrap();
292 let node = sub.join("node");
293 std::fs::write(&node, b"").unwrap();
294 let server = sub.join("my-server");
295 std::fs::write(&server, b"").unwrap();
296 assert!(is_interpreter_command(node.to_str().unwrap()));
297 assert!(!is_interpreter_command(server.to_str().unwrap()));
298 }
299
300 #[test]
301 fn real_server_binaries_are_not_interpreters() {
302 for c in [
303 "mur-mcp-server",
304 "/Users/x/.mur/mcp-servers/mur-mcp-server",
305 "agent-browser",
306 "mur-research-gateway",
307 "nodemon-ish",
308 ] {
309 assert!(!is_interpreter_command(c), "`{c}` is the server itself");
310 }
311 }
312
313 #[test]
314 fn errors_on_missing_binary() {
315 assert!(resolve_command("definitely-not-a-real-binary-xyz123").is_err());
316 }
317
318 #[cfg(unix)]
329 #[test]
330 fn install_time_resolve_finds_binaries_the_ambient_path_omits() {
331 use std::os::unix::fs::PermissionsExt;
332
333 let home = tempfile::tempdir().unwrap();
334 let local_bin = home.path().join(".local/bin");
335 std::fs::create_dir_all(&local_bin).unwrap();
336 let tool = local_bin.join("uvx-fixture");
337 std::fs::write(&tool, "#!/bin/sh\n").unwrap();
338 std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
339
340 let mut envg = crate::test_env::EnvGuard::hold();
344 envg.set_var("PATH", "/usr/bin:/bin");
345 envg.set_var("HOME", home.path());
346
347 assert!(
348 resolve_command_in(
349 &std::env::var_os("PATH").unwrap(),
350 tool.file_name().unwrap().to_str().unwrap()
351 )
352 .is_err(),
353 "fixture must be off the raw ambient PATH, or this proves nothing"
354 );
355
356 let resolved = resolve_command(tool.file_name().unwrap().to_str().unwrap())
357 .expect("install-time resolve must search ~/.local/bin like the runtime does");
358 assert_eq!(resolved, tool.canonicalize().unwrap());
359 }
360
361 #[cfg(unix)]
362 #[test]
363 fn resolves_bare_program_on_path_to_absolute() {
364 let resolved = resolve_command("sh").expect("sh is on PATH");
367 assert!(
368 resolved.is_absolute(),
369 "expected absolute, got {resolved:?}"
370 );
371 assert!(resolved.exists());
372 }
373
374 #[test]
375 fn absolute_path_is_canonicalized() {
376 let tmp = tempfile::NamedTempFile::new().unwrap();
377 let resolved = resolve_command(tmp.path().to_str().unwrap()).unwrap();
378 assert!(resolved.is_absolute());
379 }
380
381 #[test]
382 fn augmented_path_appends_standard_dirs_without_reordering_ambient() {
383 let ambient: Vec<PathBuf> =
386 std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect();
387 let aug: Vec<PathBuf> = std::env::split_paths(&augmented_path_var()).collect();
388 assert!(
389 aug.starts_with(&ambient),
390 "ambient PATH must keep priority: {aug:?}"
391 );
392 for d in ["/opt/homebrew/bin", "/usr/local/bin"] {
393 let d = PathBuf::from(d);
394 let in_ambient = ambient.iter().filter(|x| **x == d).count();
395 let in_aug = aug.iter().filter(|x| **x == d).count();
396 assert_eq!(
399 in_aug,
400 in_ambient.max(1),
401 "{d:?}: expected append-only-when-absent"
402 );
403 }
404 }
405
406 #[test]
407 fn mcp_launch_prefers_the_bundled_server_over_a_path_copy() {
408 let bundled_dir = tempfile::tempdir().unwrap();
409 let path_dir = tempfile::tempdir().unwrap();
410 let bundled = bundled_dir.path().join(MCP_SERVER_BIN);
411 let var = std::env::join_paths([path_dir.path()]).unwrap();
412 std::fs::write(path_dir.path().join(MCP_SERVER_BIN), b"x").unwrap();
413 let got = resolve_mcp_launch_with(&bundled, &var, MCP_SERVER_BIN).unwrap();
415 assert_eq!(
416 got,
417 path_dir.path().join(MCP_SERVER_BIN).canonicalize().unwrap()
418 );
419 std::fs::write(&bundled, b"x").unwrap();
420 assert_eq!(
421 resolve_mcp_launch_with(&bundled, &var, MCP_SERVER_BIN).unwrap(),
422 bundled
423 );
424 std::fs::write(path_dir.path().join("other"), b"x").unwrap();
426 assert_ne!(
427 resolve_mcp_launch_with(&bundled, &var, "other").unwrap(),
428 bundled
429 );
430 }
431
432 #[test]
433 fn resolve_command_in_uses_the_given_path_not_the_env() {
434 let dir = tempfile::tempdir().unwrap();
435 let exe = dir.path().join("fake-mcp");
436 std::fs::write(&exe, b"#!/bin/sh\n").unwrap();
437 let var = std::env::join_paths([dir.path().to_path_buf()]).unwrap();
438 let found = resolve_command_in(&var, "fake-mcp").unwrap();
439 assert_eq!(found, exe.canonicalize().unwrap());
440 assert!(
441 resolve_command_in(std::ffi::OsStr::new(""), "fake-mcp").is_err(),
442 "an empty path var must not fall back to the ambient PATH"
443 );
444 }
445
446 #[test]
447 fn install_if_stale_copies_then_is_idempotent_and_updates() {
448 let dir = tempfile::tempdir().unwrap();
449 let src = dir.path().join("src-bin");
450 let target = dir.path().join("mcp-servers/mur-mcp-server"); std::fs::write(&src, b"v1").unwrap();
452
453 install_if_stale(&src, &target).unwrap();
455 assert_eq!(std::fs::read(&target).unwrap(), b"v1");
456 #[cfg(unix)]
457 {
458 use std::os::unix::fs::PermissionsExt;
459 let mode = std::fs::metadata(&target).unwrap().permissions().mode();
460 assert_eq!(mode & 0o111, 0o111, "target must be executable");
461 }
462
463 install_if_stale(&src, &target).unwrap();
465 assert_eq!(std::fs::read(&target).unwrap(), b"v1");
466
467 std::fs::write(&src, b"v2-newer").unwrap();
469 install_if_stale(&src, &target).unwrap();
470 assert_eq!(std::fs::read(&target).unwrap(), b"v2-newer");
471 }
472}