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 p = Path::new(command);
154 if p.is_absolute() || command.contains('/') || command.contains('\\') {
155 return p
156 .canonicalize()
157 .with_context(|| format!("canonicalize {command}"));
158 }
159 let path_var = std::env::var_os("PATH")
160 .ok_or_else(|| anyhow::anyhow!("PATH env var unset; cannot resolve `{command}`"))?;
161 for dir in std::env::split_paths(&path_var) {
162 let candidate = dir.join(command);
163 if candidate.is_file() {
164 return candidate
165 .canonicalize()
166 .with_context(|| format!("canonicalize {}", candidate.display()));
167 }
168 #[cfg(target_os = "windows")]
169 {
170 let with_exe = dir.join(format!("{command}.exe"));
171 if with_exe.is_file() {
172 return with_exe
173 .canonicalize()
174 .with_context(|| format!("canonicalize {}", with_exe.display()));
175 }
176 }
177 }
178 bail!("could not find `{command}` on PATH");
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184
185 #[test]
186 fn interpreter_commands_are_recognised_including_paths_and_args() {
187 for c in [
188 "npx",
189 "node",
190 "python3",
191 "uvx",
192 "bunx",
193 "deno",
194 "sh",
195 "/opt/homebrew/bin/npx",
196 "npx @yawlabs/fetch-mcp",
197 "NPX",
198 "npx.cmd",
199 ] {
200 assert!(
201 is_interpreter_command(c),
202 "`{c}` should count as an interpreter"
203 );
204 }
205 }
206
207 #[test]
208 fn real_server_binaries_are_not_interpreters() {
209 for c in [
210 "mur-mcp-server",
211 "/Users/x/.mur/mcp-servers/mur-mcp-server",
212 "agent-browser",
213 "mur-research-gateway",
214 "nodemon-ish",
215 ] {
216 assert!(!is_interpreter_command(c), "`{c}` is the server itself");
217 }
218 }
219
220 #[test]
221 fn errors_on_missing_binary() {
222 assert!(resolve_command("definitely-not-a-real-binary-xyz123").is_err());
223 }
224
225 #[cfg(unix)]
226 #[test]
227 fn resolves_bare_program_on_path_to_absolute() {
228 let resolved = resolve_command("sh").expect("sh is on PATH");
231 assert!(
232 resolved.is_absolute(),
233 "expected absolute, got {resolved:?}"
234 );
235 assert!(resolved.exists());
236 }
237
238 #[test]
239 fn absolute_path_is_canonicalized() {
240 let tmp = tempfile::NamedTempFile::new().unwrap();
241 let resolved = resolve_command(tmp.path().to_str().unwrap()).unwrap();
242 assert!(resolved.is_absolute());
243 }
244
245 #[test]
246 fn install_if_stale_copies_then_is_idempotent_and_updates() {
247 let dir = tempfile::tempdir().unwrap();
248 let src = dir.path().join("src-bin");
249 let target = dir.path().join("mcp-servers/mur-mcp-server"); std::fs::write(&src, b"v1").unwrap();
251
252 install_if_stale(&src, &target).unwrap();
254 assert_eq!(std::fs::read(&target).unwrap(), b"v1");
255 #[cfg(unix)]
256 {
257 use std::os::unix::fs::PermissionsExt;
258 let mode = std::fs::metadata(&target).unwrap().permissions().mode();
259 assert_eq!(mode & 0o111, 0o111, "target must be executable");
260 }
261
262 install_if_stale(&src, &target).unwrap();
264 assert_eq!(std::fs::read(&target).unwrap(), b"v1");
265
266 std::fs::write(&src, b"v2-newer").unwrap();
268 install_if_stale(&src, &target).unwrap();
269 assert_eq!(std::fs::read(&target).unwrap(), b"v2-newer");
270 }
271}