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 = command.split_whitespace().next().unwrap_or(command);
137 let stem = Path::new(first)
138 .file_stem() .and_then(|s| s.to_str())
140 .unwrap_or(first);
141 INTERPRETERS.contains(&stem.to_ascii_lowercase().as_str())
142}
143
144pub fn resolve_command(command: &str) -> Result<PathBuf> {
162 resolve_command_in(&augmented_path_var(), command)
163}
164
165pub fn resolve_command_in(path_var: &std::ffi::OsStr, command: &str) -> Result<PathBuf> {
170 let p = Path::new(command);
171 if p.is_absolute() || command.contains('/') || command.contains('\\') {
172 return p
173 .canonicalize()
174 .with_context(|| format!("canonicalize {command}"));
175 }
176 for dir in std::env::split_paths(path_var) {
177 let candidate = dir.join(command);
178 if candidate.is_file() {
179 return candidate
180 .canonicalize()
181 .with_context(|| format!("canonicalize {}", candidate.display()));
182 }
183 #[cfg(target_os = "windows")]
184 {
185 let with_exe = dir.join(format!("{command}.exe"));
186 if with_exe.is_file() {
187 return with_exe
188 .canonicalize()
189 .with_context(|| format!("canonicalize {}", with_exe.display()));
190 }
191 }
192 }
193 bail!(
194 "could not find `{command}` on PATH (searched: {})",
195 std::env::split_paths(path_var)
196 .map(|d| d.display().to_string())
197 .collect::<Vec<_>>()
198 .join(", ")
199 );
200}
201
202pub fn augmented_path_var() -> std::ffi::OsString {
214 let current = std::env::var_os("PATH").unwrap_or_default();
215 let mut dirs_list: Vec<PathBuf> = std::env::split_paths(¤t).collect();
216 let mut extras: Vec<PathBuf> = vec![
217 PathBuf::from("/opt/homebrew/bin"),
218 PathBuf::from("/usr/local/bin"),
219 ];
220 if let Some(home) = dirs::home_dir() {
221 extras.push(home.join(".local/bin"));
222 }
223 for e in extras {
224 if !dirs_list.contains(&e) {
225 dirs_list.push(e);
226 }
227 }
228 std::env::join_paths(dirs_list).unwrap_or(current)
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
236 fn interpreter_commands_are_recognised_including_paths_and_args() {
237 for c in [
238 "npx",
239 "node",
240 "python3",
241 "uvx",
242 "bunx",
243 "deno",
244 "sh",
245 "/opt/homebrew/bin/npx",
246 "npx @yawlabs/fetch-mcp",
247 "NPX",
248 "npx.cmd",
249 ] {
250 assert!(
251 is_interpreter_command(c),
252 "`{c}` should count as an interpreter"
253 );
254 }
255 }
256
257 #[test]
258 fn real_server_binaries_are_not_interpreters() {
259 for c in [
260 "mur-mcp-server",
261 "/Users/x/.mur/mcp-servers/mur-mcp-server",
262 "agent-browser",
263 "mur-research-gateway",
264 "nodemon-ish",
265 ] {
266 assert!(!is_interpreter_command(c), "`{c}` is the server itself");
267 }
268 }
269
270 #[test]
271 fn errors_on_missing_binary() {
272 assert!(resolve_command("definitely-not-a-real-binary-xyz123").is_err());
273 }
274
275 #[cfg(unix)]
286 #[test]
287 fn install_time_resolve_finds_binaries_the_ambient_path_omits() {
288 use std::os::unix::fs::PermissionsExt;
289
290 let home = tempfile::tempdir().unwrap();
291 let local_bin = home.path().join(".local/bin");
292 std::fs::create_dir_all(&local_bin).unwrap();
293 let tool = local_bin.join("uvx-fixture");
294 std::fs::write(&tool, "#!/bin/sh\n").unwrap();
295 std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
296
297 let mut envg = crate::test_env::EnvGuard::hold();
301 envg.set_var("PATH", "/usr/bin:/bin");
302 envg.set_var("HOME", home.path());
303
304 assert!(
305 resolve_command_in(
306 &std::env::var_os("PATH").unwrap(),
307 tool.file_name().unwrap().to_str().unwrap()
308 )
309 .is_err(),
310 "fixture must be off the raw ambient PATH, or this proves nothing"
311 );
312
313 let resolved = resolve_command(tool.file_name().unwrap().to_str().unwrap())
314 .expect("install-time resolve must search ~/.local/bin like the runtime does");
315 assert_eq!(resolved, tool.canonicalize().unwrap());
316 }
317
318 #[cfg(unix)]
319 #[test]
320 fn resolves_bare_program_on_path_to_absolute() {
321 let resolved = resolve_command("sh").expect("sh is on PATH");
324 assert!(
325 resolved.is_absolute(),
326 "expected absolute, got {resolved:?}"
327 );
328 assert!(resolved.exists());
329 }
330
331 #[test]
332 fn absolute_path_is_canonicalized() {
333 let tmp = tempfile::NamedTempFile::new().unwrap();
334 let resolved = resolve_command(tmp.path().to_str().unwrap()).unwrap();
335 assert!(resolved.is_absolute());
336 }
337
338 #[test]
339 fn augmented_path_appends_standard_dirs_without_reordering_ambient() {
340 let ambient: Vec<PathBuf> =
343 std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect();
344 let aug: Vec<PathBuf> = std::env::split_paths(&augmented_path_var()).collect();
345 assert!(
346 aug.starts_with(&ambient),
347 "ambient PATH must keep priority: {aug:?}"
348 );
349 for d in ["/opt/homebrew/bin", "/usr/local/bin"] {
350 let d = PathBuf::from(d);
351 let in_ambient = ambient.iter().filter(|x| **x == d).count();
352 let in_aug = aug.iter().filter(|x| **x == d).count();
353 assert_eq!(
356 in_aug,
357 in_ambient.max(1),
358 "{d:?}: expected append-only-when-absent"
359 );
360 }
361 }
362
363 #[test]
364 fn resolve_command_in_uses_the_given_path_not_the_env() {
365 let dir = tempfile::tempdir().unwrap();
366 let exe = dir.path().join("fake-mcp");
367 std::fs::write(&exe, b"#!/bin/sh\n").unwrap();
368 let var = std::env::join_paths([dir.path().to_path_buf()]).unwrap();
369 let found = resolve_command_in(&var, "fake-mcp").unwrap();
370 assert_eq!(found, exe.canonicalize().unwrap());
371 assert!(
372 resolve_command_in(std::ffi::OsStr::new(""), "fake-mcp").is_err(),
373 "an empty path var must not fall back to the ambient PATH"
374 );
375 }
376
377 #[test]
378 fn install_if_stale_copies_then_is_idempotent_and_updates() {
379 let dir = tempfile::tempdir().unwrap();
380 let src = dir.path().join("src-bin");
381 let target = dir.path().join("mcp-servers/mur-mcp-server"); std::fs::write(&src, b"v1").unwrap();
383
384 install_if_stale(&src, &target).unwrap();
386 assert_eq!(std::fs::read(&target).unwrap(), b"v1");
387 #[cfg(unix)]
388 {
389 use std::os::unix::fs::PermissionsExt;
390 let mode = std::fs::metadata(&target).unwrap().permissions().mode();
391 assert_eq!(mode & 0o111, 0o111, "target must be executable");
392 }
393
394 install_if_stale(&src, &target).unwrap();
396 assert_eq!(std::fs::read(&target).unwrap(), b"v1");
397
398 std::fs::write(&src, b"v2-newer").unwrap();
400 install_if_stale(&src, &target).unwrap();
401 assert_eq!(std::fs::read(&target).unwrap(), b"v2-newer");
402 }
403}