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> {
153 let path_var = std::env::var_os("PATH")
154 .ok_or_else(|| anyhow::anyhow!("PATH env var unset; cannot resolve `{command}`"))?;
155 resolve_command_in(&path_var, command)
156}
157
158pub fn resolve_command_in(path_var: &std::ffi::OsStr, command: &str) -> Result<PathBuf> {
163 let p = Path::new(command);
164 if p.is_absolute() || command.contains('/') || command.contains('\\') {
165 return p
166 .canonicalize()
167 .with_context(|| format!("canonicalize {command}"));
168 }
169 for dir in std::env::split_paths(path_var) {
170 let candidate = dir.join(command);
171 if candidate.is_file() {
172 return candidate
173 .canonicalize()
174 .with_context(|| format!("canonicalize {}", candidate.display()));
175 }
176 #[cfg(target_os = "windows")]
177 {
178 let with_exe = dir.join(format!("{command}.exe"));
179 if with_exe.is_file() {
180 return with_exe
181 .canonicalize()
182 .with_context(|| format!("canonicalize {}", with_exe.display()));
183 }
184 }
185 }
186 bail!("could not find `{command}` on PATH");
187}
188
189pub fn augmented_path_var() -> std::ffi::OsString {
201 let current = std::env::var_os("PATH").unwrap_or_default();
202 let mut dirs_list: Vec<PathBuf> = std::env::split_paths(¤t).collect();
203 let mut extras: Vec<PathBuf> = vec![
204 PathBuf::from("/opt/homebrew/bin"),
205 PathBuf::from("/usr/local/bin"),
206 ];
207 if let Some(home) = dirs::home_dir() {
208 extras.push(home.join(".local/bin"));
209 }
210 for e in extras {
211 if !dirs_list.contains(&e) {
212 dirs_list.push(e);
213 }
214 }
215 std::env::join_paths(dirs_list).unwrap_or(current)
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
223 fn interpreter_commands_are_recognised_including_paths_and_args() {
224 for c in [
225 "npx",
226 "node",
227 "python3",
228 "uvx",
229 "bunx",
230 "deno",
231 "sh",
232 "/opt/homebrew/bin/npx",
233 "npx @yawlabs/fetch-mcp",
234 "NPX",
235 "npx.cmd",
236 ] {
237 assert!(
238 is_interpreter_command(c),
239 "`{c}` should count as an interpreter"
240 );
241 }
242 }
243
244 #[test]
245 fn real_server_binaries_are_not_interpreters() {
246 for c in [
247 "mur-mcp-server",
248 "/Users/x/.mur/mcp-servers/mur-mcp-server",
249 "agent-browser",
250 "mur-research-gateway",
251 "nodemon-ish",
252 ] {
253 assert!(!is_interpreter_command(c), "`{c}` is the server itself");
254 }
255 }
256
257 #[test]
258 fn errors_on_missing_binary() {
259 assert!(resolve_command("definitely-not-a-real-binary-xyz123").is_err());
260 }
261
262 #[cfg(unix)]
263 #[test]
264 fn resolves_bare_program_on_path_to_absolute() {
265 let resolved = resolve_command("sh").expect("sh is on PATH");
268 assert!(
269 resolved.is_absolute(),
270 "expected absolute, got {resolved:?}"
271 );
272 assert!(resolved.exists());
273 }
274
275 #[test]
276 fn absolute_path_is_canonicalized() {
277 let tmp = tempfile::NamedTempFile::new().unwrap();
278 let resolved = resolve_command(tmp.path().to_str().unwrap()).unwrap();
279 assert!(resolved.is_absolute());
280 }
281
282 #[test]
283 fn augmented_path_appends_standard_dirs_without_reordering_ambient() {
284 let ambient: Vec<PathBuf> =
287 std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).collect();
288 let aug: Vec<PathBuf> = std::env::split_paths(&augmented_path_var()).collect();
289 assert!(
290 aug.starts_with(&ambient),
291 "ambient PATH must keep priority: {aug:?}"
292 );
293 for d in ["/opt/homebrew/bin", "/usr/local/bin"] {
294 let d = PathBuf::from(d);
295 let in_ambient = ambient.iter().filter(|x| **x == d).count();
296 let in_aug = aug.iter().filter(|x| **x == d).count();
297 assert_eq!(
300 in_aug,
301 in_ambient.max(1),
302 "{d:?}: expected append-only-when-absent"
303 );
304 }
305 }
306
307 #[test]
308 fn resolve_command_in_uses_the_given_path_not_the_env() {
309 let dir = tempfile::tempdir().unwrap();
310 let exe = dir.path().join("fake-mcp");
311 std::fs::write(&exe, b"#!/bin/sh\n").unwrap();
312 let var = std::env::join_paths([dir.path().to_path_buf()]).unwrap();
313 let found = resolve_command_in(&var, "fake-mcp").unwrap();
314 assert_eq!(found, exe.canonicalize().unwrap());
315 assert!(
316 resolve_command_in(std::ffi::OsStr::new(""), "fake-mcp").is_err(),
317 "an empty path var must not fall back to the ambient PATH"
318 );
319 }
320
321 #[test]
322 fn install_if_stale_copies_then_is_idempotent_and_updates() {
323 let dir = tempfile::tempdir().unwrap();
324 let src = dir.path().join("src-bin");
325 let target = dir.path().join("mcp-servers/mur-mcp-server"); std::fs::write(&src, b"v1").unwrap();
327
328 install_if_stale(&src, &target).unwrap();
330 assert_eq!(std::fs::read(&target).unwrap(), b"v1");
331 #[cfg(unix)]
332 {
333 use std::os::unix::fs::PermissionsExt;
334 let mode = std::fs::metadata(&target).unwrap().permissions().mode();
335 assert_eq!(mode & 0o111, 0o111, "target must be executable");
336 }
337
338 install_if_stale(&src, &target).unwrap();
340 assert_eq!(std::fs::read(&target).unwrap(), b"v1");
341
342 std::fs::write(&src, b"v2-newer").unwrap();
344 install_if_stale(&src, &target).unwrap();
345 assert_eq!(std::fs::read(&target).unwrap(), b"v2-newer");
346 }
347}