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
116pub fn resolve_command(command: &str) -> Result<PathBuf> {
125 let p = Path::new(command);
126 if p.is_absolute() || command.contains('/') || command.contains('\\') {
127 return p
128 .canonicalize()
129 .with_context(|| format!("canonicalize {command}"));
130 }
131 let path_var = std::env::var_os("PATH")
132 .ok_or_else(|| anyhow::anyhow!("PATH env var unset; cannot resolve `{command}`"))?;
133 for dir in std::env::split_paths(&path_var) {
134 let candidate = dir.join(command);
135 if candidate.is_file() {
136 return candidate
137 .canonicalize()
138 .with_context(|| format!("canonicalize {}", candidate.display()));
139 }
140 #[cfg(target_os = "windows")]
141 {
142 let with_exe = dir.join(format!("{command}.exe"));
143 if with_exe.is_file() {
144 return with_exe
145 .canonicalize()
146 .with_context(|| format!("canonicalize {}", with_exe.display()));
147 }
148 }
149 }
150 bail!("could not find `{command}` on PATH");
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn errors_on_missing_binary() {
159 assert!(resolve_command("definitely-not-a-real-binary-xyz123").is_err());
160 }
161
162 #[cfg(unix)]
163 #[test]
164 fn resolves_bare_program_on_path_to_absolute() {
165 let resolved = resolve_command("sh").expect("sh is on PATH");
168 assert!(
169 resolved.is_absolute(),
170 "expected absolute, got {resolved:?}"
171 );
172 assert!(resolved.exists());
173 }
174
175 #[test]
176 fn absolute_path_is_canonicalized() {
177 let tmp = tempfile::NamedTempFile::new().unwrap();
178 let resolved = resolve_command(tmp.path().to_str().unwrap()).unwrap();
179 assert!(resolved.is_absolute());
180 }
181
182 #[test]
183 fn install_if_stale_copies_then_is_idempotent_and_updates() {
184 let dir = tempfile::tempdir().unwrap();
185 let src = dir.path().join("src-bin");
186 let target = dir.path().join("mcp-servers/mur-mcp-server"); std::fs::write(&src, b"v1").unwrap();
188
189 install_if_stale(&src, &target).unwrap();
191 assert_eq!(std::fs::read(&target).unwrap(), b"v1");
192 #[cfg(unix)]
193 {
194 use std::os::unix::fs::PermissionsExt;
195 let mode = std::fs::metadata(&target).unwrap().permissions().mode();
196 assert_eq!(mode & 0o111, 0o111, "target must be executable");
197 }
198
199 install_if_stale(&src, &target).unwrap();
201 assert_eq!(std::fs::read(&target).unwrap(), b"v1");
202
203 std::fs::write(&src, b"v2-newer").unwrap();
205 install_if_stale(&src, &target).unwrap();
206 assert_eq!(std::fs::read(&target).unwrap(), b"v2-newer");
207 }
208}