mur_common/
mcp_package.rs1use anyhow::{Result, bail};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct PackageSpec {
17 pub arg_index: usize,
19 pub name: String,
21 pub version: Option<String>,
24}
25
26impl PackageSpec {
27 pub fn floats(&self) -> bool {
30 self.version.is_none()
31 }
32
33 pub fn to_arg(&self) -> String {
35 match &self.version {
36 Some(v) => format!("{}@{v}", self.name),
37 None => self.name.clone(),
38 }
39 }
40}
41
42fn runner_kind(command: &str) -> Option<Runner> {
44 let first = command.split_whitespace().next().unwrap_or(command);
45 let stem = std::path::Path::new(first)
46 .file_stem()
47 .and_then(|s| s.to_str())
48 .unwrap_or(first)
49 .to_ascii_lowercase();
50 match stem.as_str() {
51 "npx" | "bunx" => Some(Runner::Npm),
52 "uvx" => Some(Runner::Python),
53 _ => None,
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum Runner {
59 Npm,
60 Python,
61}
62
63pub fn parse_spec(command: &str, args: &[String]) -> Option<PackageSpec> {
70 runner_kind(command)?;
71 let mut idx = 0usize;
72 while idx < args.len() {
73 let a = &args[idx];
74 if matches!(a.as_str(), "-p" | "--package" | "-c" | "--call") {
77 return None;
78 }
79 if a.starts_with('-') {
80 idx += 1; continue;
82 }
83 return Some(split_spec(idx, a));
84 }
85 None
86}
87
88fn split_spec(arg_index: usize, spec: &str) -> PackageSpec {
90 let search_from = usize::from(spec.starts_with('@'));
92 match spec[search_from..].rfind('@') {
93 Some(rel) => {
94 let at = search_from + rel;
95 PackageSpec {
96 arg_index,
97 name: spec[..at].to_string(),
98 version: Some(spec[at + 1..].to_string()),
99 }
100 }
101 None => PackageSpec {
102 arg_index,
103 name: spec.to_string(),
104 version: None,
105 },
106 }
107}
108
109pub fn resolve_current_version(runner: Runner, name: &str) -> Result<String> {
114 let (program, args) = match runner {
115 Runner::Npm => (
116 "npm",
117 vec!["view".to_string(), name.to_string(), "version".to_string()],
118 ),
119 Runner::Python => bail!(
120 "resolving a current version for uvx packages is not implemented; \
121 pass an explicit `{name}==<version>` in args"
122 ),
123 };
124 let out = std::process::Command::new(program)
125 .args(&args)
126 .output()
127 .map_err(|e| anyhow::anyhow!("run `{program} view {name} version`: {e}"))?;
128 if !out.status.success() {
129 bail!(
130 "`{program} view {name} version` failed: {}",
131 String::from_utf8_lossy(&out.stderr).trim(),
132 );
133 }
134 let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
135 if v.is_empty() {
136 bail!("`{program} view {name} version` returned nothing");
137 }
138 Ok(v)
139}
140
141pub fn runner_for(command: &str) -> Option<Runner> {
143 runner_kind(command)
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 fn args(v: &[&str]) -> Vec<String> {
151 v.iter().map(|s| s.to_string()).collect()
152 }
153
154 #[test]
155 fn finds_a_floating_scoped_package() {
156 let s = parse_spec("npx", &args(&["@yawlabs/fetch-mcp"])).unwrap();
157 assert_eq!(s.name, "@yawlabs/fetch-mcp");
158 assert_eq!(s.version, None);
159 assert!(s.floats(), "no version means npx resolves it every start");
160 assert_eq!(s.arg_index, 0);
161 }
162
163 #[test]
164 fn a_scope_prefix_is_not_a_version_separator() {
165 let s = parse_spec("npx", &args(&["@scope/pkg@1.2.3"])).unwrap();
166 assert_eq!(s.name, "@scope/pkg");
167 assert_eq!(s.version.as_deref(), Some("1.2.3"));
168 assert!(!s.floats());
169 assert_eq!(s.to_arg(), "@scope/pkg@1.2.3");
170 }
171
172 #[test]
173 fn handles_unscoped_and_valueless_flags() {
174 let s = parse_spec("npx", &args(&["-y", "--quiet", "some-mcp@0.4.0"])).unwrap();
175 assert_eq!(s.name, "some-mcp");
176 assert_eq!(s.version.as_deref(), Some("0.4.0"));
177 assert_eq!(
178 s.arg_index, 2,
179 "index must point at the spec, not the flags"
180 );
181 }
182
183 #[test]
186 fn declines_ambiguous_and_non_runner_shapes() {
187 assert!(parse_spec("npx", &args(&["-p", "typescript", "tsc"])).is_none());
188 assert!(parse_spec("npx", &args(&["--package", "a", "b"])).is_none());
189 assert!(parse_spec("node", &args(&["server.js"])).is_none());
190 assert!(parse_spec("python3", &args(&["-m", "pkg"])).is_none());
191 assert!(parse_spec("mur-mcp-server", &args(&[])).is_none());
192 assert!(parse_spec("npx", &args(&["-y"])).is_none(), "flags only");
193 assert!(parse_spec("npx", &args(&[])).is_none());
194 }
195
196 #[test]
197 fn recognises_runners_by_path_and_case() {
198 assert_eq!(runner_for("/opt/homebrew/bin/npx"), Some(Runner::Npm));
199 assert_eq!(runner_for("BUNX"), Some(Runner::Npm));
200 assert_eq!(runner_for("uvx"), Some(Runner::Python));
201 assert_eq!(runner_for("node"), None);
202 }
203
204 #[test]
205 fn to_arg_round_trips_what_was_parsed() {
206 for raw in ["@scope/pkg@1.2.3", "@scope/pkg", "pkg@2.0.0-beta.1", "pkg"] {
207 let s = split_spec(0, raw);
208 assert_eq!(s.to_arg(), raw);
209 }
210 }
211}