Skip to main content

mur_common/
mcp_package.rs

1//! Package specs for interpreter-launched MCP servers.
2//!
3//! `command: npx, args: ["@yawlabs/fetch-mcp"]` runs whatever the registry
4//! serves at spawn time. The binary pin cannot help here — it hashes `npx`
5//! (see [`crate::exec::is_interpreter_command`]) — so the first thing that can
6//! is knowing *which release* the user approved.
7//!
8//! This module only parses and resolves the spec. Verifying the bytes that
9//! actually get executed needs package-manager cache introspection or a
10//! MUR-owned install, which is tracked separately.
11
12use anyhow::{Result, bail};
13
14/// A package spec found in an interpreter entry's args.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct PackageSpec {
17    /// Index into `args` where the spec sits, so a caller can rewrite it.
18    pub arg_index: usize,
19    /// Package name, including any `@scope/` prefix.
20    pub name: String,
21    /// Version suffix, if the spec carries one. `None` means the spec floats:
22    /// the package manager resolves it fresh on every start.
23    pub version: Option<String>,
24}
25
26impl PackageSpec {
27    /// `true` when no version is recorded — the resolved code can change
28    /// between two starts with no user action and no signal.
29    pub fn floats(&self) -> bool {
30        self.version.is_none()
31    }
32
33    /// The spec as it appears (and would be written) in args.
34    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
42/// Package runners whose first non-flag argument is a package spec.
43fn 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
63/// Find the package spec an interpreter entry will run.
64///
65/// Returns `None` for anything whose shape isn't unambiguous — `node
66/// server.js`, `python -m pkg`, or a runner invoked with a flag that takes a
67/// separate value (`npx -p a b`). Guessing wrong here would mean rewriting the
68/// wrong argument, so an unrecognised shape is left alone.
69pub 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        // A flag that takes a separate value makes the position of the spec
75        // ambiguous — bail rather than rewrite the wrong argument.
76        if matches!(a.as_str(), "-p" | "--package" | "-c" | "--call") {
77            return None;
78        }
79        if a.starts_with('-') {
80            idx += 1; // valueless flag (-y, --yes, --quiet, --package=x)
81            continue;
82        }
83        return Some(split_spec(idx, a));
84    }
85    None
86}
87
88/// Split `name[@version]`, keeping a leading `@scope/` intact.
89fn split_spec(arg_index: usize, spec: &str) -> PackageSpec {
90    // A leading '@' is a scope, not a version separator; look after it.
91    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
109/// Ask the package manager which version it would resolve right now.
110///
111/// This records what the user is approving at pin time; it is not a trust
112/// decision about the registry.
113pub fn resolve_current_version(runner: Runner, name: &str) -> Result<String> {
114    match runner {
115        Runner::Npm => {
116            let out = std::process::Command::new("npm")
117                .args(["view", name, "version"])
118                .output()
119                .map_err(|e| anyhow::anyhow!("run `npm view {name} version`: {e}"))?;
120            if !out.status.success() {
121                bail!(
122                    "`npm view {name} version` failed: {}",
123                    String::from_utf8_lossy(&out.stderr).trim(),
124                );
125            }
126            let v = String::from_utf8_lossy(&out.stdout).trim().to_string();
127            if v.is_empty() {
128                bail!("`npm view {name} version` returned nothing");
129            }
130            Ok(v)
131        }
132        // uv has no "what version would you pick" query, so resolve the way an
133        // install would and read the answer back out of the resolution. Using
134        // the resolver rather than a registry query means the recorded version
135        // is the one uv would actually install, including any yanked-release
136        // or requires-python filtering it applies.
137        Runner::Python => {
138            let dir = tempfile::tempdir().map_err(|e| anyhow::anyhow!("temp dir: {e}"))?;
139            let req_in = dir.path().join("req.in");
140            std::fs::write(&req_in, format!("{name}\n"))
141                .map_err(|e| anyhow::anyhow!("write {}: {e}", req_in.display()))?;
142            let out = std::process::Command::new("uv")
143                .args(["pip", "compile", "req.in", "-o", "req.lock"])
144                .current_dir(dir.path())
145                .output()
146                .map_err(|e| anyhow::anyhow!("run uv pip compile: {e} (is uv on PATH?)"))?;
147            if !out.status.success() {
148                bail!(
149                    "`uv pip compile` could not resolve `{name}`: {}",
150                    String::from_utf8_lossy(&out.stderr).trim(),
151                );
152            }
153            let body = std::fs::read_to_string(dir.path().join("req.lock"))
154                .map_err(|e| anyhow::anyhow!("read resolved lockfile: {e}"))?;
155            pinned_version_of(&body, name)
156                .ok_or_else(|| anyhow::anyhow!("`{name}` did not appear in uv's resolution"))
157        }
158    }
159}
160
161/// Find `name==version` for `name` in a `uv pip compile` lockfile.
162///
163/// Distribution names normalise loosely — `Foo.Bar` and `foo-bar` are the same
164/// project — so matching has to normalise too, or a package would resolve fine
165/// and then look absent.
166pub fn pinned_version_of(lockfile: &str, name: &str) -> Option<String> {
167    let want = normalize_dist_name(name);
168    for line in lockfile.lines() {
169        let line = line.trim();
170        // A lockfile is mostly not pins: blank lines, `# via` comments, and
171        // `--hash=` continuations. Each has to be skipped, not treated as the
172        // end of the search — `?` here would abandon the whole file at the
173        // first one.
174        if line.is_empty() || line.starts_with('#') || line.starts_with("--") {
175            continue;
176        }
177        let Some(spec) = line.split_whitespace().next() else {
178            continue;
179        };
180        let Some((pkg, version)) = spec.split_once("==") else {
181            continue;
182        };
183        if normalize_dist_name(pkg) == want {
184            return Some(version.trim_end_matches('\\').trim().to_string());
185        }
186    }
187    None
188}
189
190/// PEP 503 name normalisation: lowercase, runs of `-_.` collapse to `-`.
191fn normalize_dist_name(name: &str) -> String {
192    let mut out = String::with_capacity(name.len());
193    let mut last_dash = false;
194    for c in name.chars() {
195        if matches!(c, '-' | '_' | '.') {
196            if !last_dash {
197                out.push('-');
198                last_dash = true;
199            }
200        } else {
201            out.extend(c.to_lowercase());
202            last_dash = false;
203        }
204    }
205    out
206}
207
208/// The runner behind a command, for callers that already know it's one.
209pub fn runner_for(command: &str) -> Option<Runner> {
210    runner_kind(command)
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    fn args(v: &[&str]) -> Vec<String> {
218        v.iter().map(|s| s.to_string()).collect()
219    }
220
221    #[test]
222    fn finds_a_floating_scoped_package() {
223        let s = parse_spec("npx", &args(&["@yawlabs/fetch-mcp"])).unwrap();
224        assert_eq!(s.name, "@yawlabs/fetch-mcp");
225        assert_eq!(s.version, None);
226        assert!(s.floats(), "no version means npx resolves it every start");
227        assert_eq!(s.arg_index, 0);
228    }
229
230    #[test]
231    fn a_scope_prefix_is_not_a_version_separator() {
232        let s = parse_spec("npx", &args(&["@scope/pkg@1.2.3"])).unwrap();
233        assert_eq!(s.name, "@scope/pkg");
234        assert_eq!(s.version.as_deref(), Some("1.2.3"));
235        assert!(!s.floats());
236        assert_eq!(s.to_arg(), "@scope/pkg@1.2.3");
237    }
238
239    #[test]
240    fn handles_unscoped_and_valueless_flags() {
241        let s = parse_spec("npx", &args(&["-y", "--quiet", "some-mcp@0.4.0"])).unwrap();
242        assert_eq!(s.name, "some-mcp");
243        assert_eq!(s.version.as_deref(), Some("0.4.0"));
244        assert_eq!(
245            s.arg_index, 2,
246            "index must point at the spec, not the flags"
247        );
248    }
249
250    /// Rewriting the wrong argument would corrupt the launch command, so an
251    /// ambiguous shape must decline rather than guess.
252    #[test]
253    fn declines_ambiguous_and_non_runner_shapes() {
254        assert!(parse_spec("npx", &args(&["-p", "typescript", "tsc"])).is_none());
255        assert!(parse_spec("npx", &args(&["--package", "a", "b"])).is_none());
256        assert!(parse_spec("node", &args(&["server.js"])).is_none());
257        assert!(parse_spec("python3", &args(&["-m", "pkg"])).is_none());
258        assert!(parse_spec("mur-mcp-server", &args(&[])).is_none());
259        assert!(parse_spec("npx", &args(&["-y"])).is_none(), "flags only");
260        assert!(parse_spec("npx", &args(&[])).is_none());
261    }
262
263    #[test]
264    fn recognises_runners_by_path_and_case() {
265        assert_eq!(runner_for("/opt/homebrew/bin/npx"), Some(Runner::Npm));
266        assert_eq!(runner_for("BUNX"), Some(Runner::Npm));
267        assert_eq!(runner_for("uvx"), Some(Runner::Python));
268        assert_eq!(runner_for("node"), None);
269    }
270
271    #[test]
272    fn to_arg_round_trips_what_was_parsed() {
273        for raw in ["@scope/pkg@1.2.3", "@scope/pkg", "pkg@2.0.0-beta.1", "pkg"] {
274            let s = split_spec(0, raw);
275            assert_eq!(s.to_arg(), raw);
276        }
277    }
278
279    // ── uv resolution ───────────────────────────────────────────────────────
280
281    /// Real `uv pip compile --generate-hashes` output: continuation
282    /// backslashes, hash lines, and `# via` comments around the spec.
283    const UV_LOCK: &str = r#"
284# This file was autogenerated by uv via the following command:
285#    uv pip compile req.in --generate-hashes -o req.lock
286annotated-types==0.8.0 \
287    --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7
288    # via pydantic
289mcp-server-time==0.6.2 \
290    --hash=sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b
291    # via -r req.in
292"#;
293
294    #[test]
295    fn reads_the_pinned_version_out_of_a_uv_lockfile() {
296        assert_eq!(
297            pinned_version_of(UV_LOCK, "mcp-server-time").as_deref(),
298            Some("0.6.2"),
299        );
300        assert_eq!(
301            pinned_version_of(UV_LOCK, "annotated-types").as_deref(),
302            Some("0.8.0"),
303            "transitive deps are pinned in the same file",
304        );
305        assert_eq!(pinned_version_of(UV_LOCK, "absent-pkg"), None);
306    }
307
308    /// PEP 503: `Foo.Bar`, `foo_bar` and `foo-bar` are one project. Without
309    /// normalising, a package would resolve fine and then read as missing.
310    #[test]
311    fn distribution_names_match_across_spelling() {
312        for spelling in ["mcp_server_time", "MCP-Server-Time", "mcp.server.time"] {
313            assert_eq!(
314                pinned_version_of(UV_LOCK, spelling).as_deref(),
315                Some("0.6.2"),
316                "`{spelling}` names the same project",
317            );
318        }
319    }
320
321    #[test]
322    fn comments_are_never_mistaken_for_a_pin() {
323        let lock = "# uv pip compile foo==1.0.0\nbar==2.0.0\n";
324        assert_eq!(pinned_version_of(lock, "foo"), None, "that was a comment");
325        assert_eq!(pinned_version_of(lock, "bar").as_deref(), Some("2.0.0"));
326    }
327}