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