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    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
141/// The runner behind a command, for callers that already know it's one.
142pub 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    /// Rewriting the wrong argument would corrupt the launch command, so an
184    /// ambiguous shape must decline rather than guess.
185    #[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}