gestura_core_mcp/
provision.rs

1//! MCP server provisioning — runtime availability checks and package pre-installation.
2//!
3//! Called immediately after a server is added via the registry browser so that
4//! the required runtime (Node/npx or uv/uvx) is verified and the package is
5//! pre-fetched before the first connection attempt.
6//!
7//! # Transport handling
8//!
9//! | Transport | Strategy |
10//! |-----------|----------|
11//! | Stdio — `npx` | Verify `npx` is on PATH; pre-warm npx cache with 60 s timeout |
12//! | Stdio — `uvx` | Verify `uv` is on PATH; run `uv tool install` (idempotent, 120 s) |
13//! | Stdio — other | Verify the command binary is on PATH; no install step |
14//! | HTTP / SSE | Skip — remote server, nothing to install |
15
16use crate::config::{McpServerEntry, McpTransportType};
17use serde::{Deserialize, Serialize};
18use std::process::Stdio;
19use std::time::Duration;
20use tokio::process::Command;
21use tokio::time::timeout;
22
23// ── Public types ──────────────────────────────────────────────────────────────
24
25/// Outcome of a provisioning attempt.
26#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
27#[serde(rename_all = "snake_case")]
28pub enum ProvisionStatus {
29    /// Runtime present and package downloaded/installed.
30    Ready,
31    /// Required runtime binary not found on PATH.
32    RuntimeMissing,
33    /// Runtime present but package fetch/install failed.
34    FetchFailed,
35    /// No installation needed (HTTP/SSE remote, or no command configured).
36    Skipped,
37}
38
39/// Result returned to the frontend after a provisioning attempt.
40#[derive(Serialize, Deserialize, Debug, Clone)]
41pub struct ProvisionResult {
42    /// Server name (echoed back for UI correlation).
43    pub name: String,
44    /// High-level outcome.
45    pub status: ProvisionStatus,
46    /// Human-readable explanation suitable for display in the config panel.
47    pub message: String,
48}
49
50// ── Entry point ───────────────────────────────────────────────────────────────
51
52/// Check runtime availability and pre-install/fetch the package for `entry`.
53///
54/// This function never panics and always returns a [`ProvisionResult`]; errors
55/// are captured in `status` + `message` rather than propagated.
56pub async fn provision_mcp_server(entry: &McpServerEntry) -> ProvisionResult {
57    match entry.transport {
58        McpTransportType::Http | McpTransportType::Sse => ProvisionResult {
59            name: entry.name.clone(),
60            status: ProvisionStatus::Skipped,
61            message: "Remote HTTP/SSE server — no local installation required.".to_string(),
62        },
63        McpTransportType::Stdio => provision_stdio(entry).await,
64    }
65}
66
67// ── Stdio dispatch ────────────────────────────────────────────────────────────
68
69async fn provision_stdio(entry: &McpServerEntry) -> ProvisionResult {
70    let cmd = match entry.command.as_deref().filter(|s| !s.is_empty()) {
71        Some(c) => c,
72        None => {
73            return ProvisionResult {
74                name: entry.name.clone(),
75                status: ProvisionStatus::Skipped,
76                message: "No command configured for stdio server.".to_string(),
77            };
78        }
79    };
80
81    match cmd {
82        "npx" => provision_npm(entry).await,
83        "uvx" => provision_pypi(entry).await,
84        other => provision_generic(entry, other).await,
85    }
86}
87
88// ── npm / npx ─────────────────────────────────────────────────────────────────
89
90async fn provision_npm(entry: &McpServerEntry) -> ProvisionResult {
91    let npx_cmd = crate::cmd_utils::resolve_mcp_command("npx");
92    if !runtime_available(&npx_cmd).await {
93        return ProvisionResult {
94            name: entry.name.clone(),
95            status: ProvisionStatus::RuntimeMissing,
96            message: "npx not found. Install Node.js from https://nodejs.org to use npm-based MCP servers.".to_string(),
97        };
98    }
99
100    // args[0] = "-y", args[1] = "pkg@version"  (built by build_mcp_entry_for_package)
101    let pkg = entry.args.get(1).map(String::as_str).unwrap_or_default();
102    if pkg.is_empty() {
103        return ProvisionResult {
104            name: entry.name.clone(),
105            status: ProvisionStatus::Skipped,
106            message: "Package identifier not set — skipping pre-fetch.".to_string(),
107        };
108    }
109
110    let mut envs = entry.env.clone();
111    crate::cmd_utils::inject_enriched_path(&mut envs);
112
113    // Run `npx --yes <pkg>` with stdin closed.  MCP servers exit immediately on EOF,
114    // so the cache is populated even if the process exits non-zero or times out.
115    let result = timeout(Duration::from_secs(60), async {
116        let mut child = Command::new(&npx_cmd)
117            .args(["--yes", pkg])
118            .envs(&envs)
119            .stdin(Stdio::null())
120            .stdout(Stdio::null())
121            .stderr(Stdio::null())
122            .spawn()?;
123        child.wait().await
124    })
125    .await;
126
127    match result {
128        // Timed-out: download already happened; treat as ready.
129        Err(_elapsed) => ProvisionResult {
130            name: entry.name.clone(),
131            status: ProvisionStatus::Ready,
132            message: format!("npm package '{pkg}' pre-fetched (download completed)."),
133        },
134        // Process ran to completion (any exit code is fine — cache is warm).
135        Ok(Ok(_)) => ProvisionResult {
136            name: entry.name.clone(),
137            status: ProvisionStatus::Ready,
138            message: format!("npm package '{pkg}' is ready."),
139        },
140        // spawn() or wait() failed (e.g. npx binary disappeared after the PATH check).
141        Ok(Err(e)) => ProvisionResult {
142            name: entry.name.clone(),
143            status: ProvisionStatus::FetchFailed,
144            message: format!("npx pre-fetch failed for '{pkg}': {e}"),
145        },
146    }
147}
148
149// ── pypi / uvx ────────────────────────────────────────────────────────────────
150
151async fn provision_pypi(entry: &McpServerEntry) -> ProvisionResult {
152    let uv_cmd = crate::cmd_utils::resolve_mcp_command("uv");
153    if !runtime_available(&uv_cmd).await {
154        return ProvisionResult {
155            name: entry.name.clone(),
156            status: ProvisionStatus::RuntimeMissing,
157            message: "uv not found. Install from https://docs.astral.sh/uv/ to use pypi-based MCP servers.".to_string(),
158        };
159    }
160
161    // args[0] = "pkg==version"  (built by build_mcp_entry_for_package for pypi)
162    let pkg_version = entry.args.first().map(String::as_str).unwrap_or_default();
163    if pkg_version.is_empty() {
164        return ProvisionResult {
165            name: entry.name.clone(),
166            status: ProvisionStatus::Skipped,
167            message: "Package identifier not set — skipping installation.".to_string(),
168        };
169    }
170
171    let mut envs = entry.env.clone();
172    crate::cmd_utils::inject_enriched_path(&mut envs);
173
174    // `uv tool install <pkg>==<version>` is idempotent:
175    //   - exits 0 on fresh install
176    //   - exits 1 with "already installed" on stderr when already present
177    let result = timeout(
178        Duration::from_secs(120),
179        Command::new(&uv_cmd)
180            .args(["tool", "install", pkg_version])
181            .envs(&envs)
182            .stdin(Stdio::null())
183            .stdout(Stdio::null())
184            .stderr(Stdio::piped())
185            .output(),
186    )
187    .await;
188
189    match result {
190        Err(_elapsed) => ProvisionResult {
191            name: entry.name.clone(),
192            status: ProvisionStatus::FetchFailed,
193            message: format!(
194                "uv tool install timed out for '{pkg_version}'. Try running manually: uv tool install {pkg_version}"
195            ),
196        },
197        Ok(Ok(output)) => {
198            if output.status.success() {
199                ProvisionResult {
200                    name: entry.name.clone(),
201                    status: ProvisionStatus::Ready,
202                    message: format!("Python package '{pkg_version}' installed successfully."),
203                }
204            } else {
205                let stderr = String::from_utf8_lossy(&output.stderr);
206                // uv exits 1 with "already installed" when already present — treat as ready.
207                if stderr.contains("already installed") {
208                    ProvisionResult {
209                        name: entry.name.clone(),
210                        status: ProvisionStatus::Ready,
211                        message: format!(
212                            "Python package '{pkg_version}' is already installed and ready."
213                        ),
214                    }
215                } else {
216                    ProvisionResult {
217                        name: entry.name.clone(),
218                        status: ProvisionStatus::FetchFailed,
219                        message: format!(
220                            "uv tool install failed for '{pkg_version}': {}",
221                            stderr.trim()
222                        ),
223                    }
224                }
225            }
226        }
227        Ok(Err(e)) => ProvisionResult {
228            name: entry.name.clone(),
229            status: ProvisionStatus::FetchFailed,
230            message: format!("Failed to launch uv for '{pkg_version}': {e}"),
231        },
232    }
233}
234
235// ── Generic stdio ─────────────────────────────────────────────────────────────
236
237/// For stdio servers that use neither npx nor uvx — just verify the binary exists.
238async fn provision_generic(entry: &McpServerEntry, cmd: &str) -> ProvisionResult {
239    if runtime_available(cmd).await {
240        ProvisionResult {
241            name: entry.name.clone(),
242            status: ProvisionStatus::Ready,
243            message: format!("Runtime '{cmd}' is available."),
244        }
245    } else {
246        ProvisionResult {
247            name: entry.name.clone(),
248            status: ProvisionStatus::RuntimeMissing,
249            message: format!(
250                "Command '{cmd}' not found on PATH. Install it before enabling this server."
251            ),
252        }
253    }
254}
255
256// ── Runtime availability check ────────────────────────────────────────────────
257
258/// Return `true` if `cmd` is found on PATH.
259///
260/// Uses `which` on Unix and `where` on Windows. Avoids running arbitrary
261/// version flags that could trigger unexpected side effects.
262async fn runtime_available(cmd: &str) -> bool {
263    #[cfg(target_os = "windows")]
264    let checker = "where";
265    #[cfg(not(target_os = "windows"))]
266    let checker = "which";
267
268    // Inject the same PATH that the tool will run with
269    let mut envs: std::collections::HashMap<String, String> = std::collections::HashMap::new();
270    crate::cmd_utils::inject_enriched_path(&mut envs);
271
272    Command::new(checker)
273        .arg(cmd)
274        .envs(&envs)
275        .stdin(Stdio::null())
276        .stdout(Stdio::null())
277        .stderr(Stdio::null())
278        .status()
279        .await
280        .map(|s| s.success())
281        .unwrap_or(false)
282}
283
284// ── Tests ─────────────────────────────────────────────────────────────────────
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::config::{McpScope, McpTransportType};
290    use std::collections::HashMap;
291
292    fn make_entry(
293        name: &str,
294        transport: McpTransportType,
295        command: Option<&str>,
296        args: Vec<&str>,
297        url: Option<&str>,
298    ) -> McpServerEntry {
299        McpServerEntry {
300            name: name.to_string(),
301            transport,
302            enabled: true,
303            command: command.map(str::to_string),
304            args: args.into_iter().map(str::to_string).collect(),
305            env: HashMap::new(),
306            url: url.map(str::to_string),
307            headers: HashMap::new(),
308            scope: McpScope::User,
309            timeout_secs: 30,
310            auto_reconnect: true,
311            session_default_enabled: true,
312        }
313    }
314
315    #[tokio::test]
316    async fn http_server_is_skipped() {
317        let entry = make_entry(
318            "my-http-server",
319            McpTransportType::Http,
320            None,
321            vec![],
322            Some("https://example.com/mcp"),
323        );
324        let result = provision_mcp_server(&entry).await;
325        assert_eq!(result.status, ProvisionStatus::Skipped);
326        assert_eq!(result.name, "my-http-server");
327    }
328
329    #[tokio::test]
330    async fn sse_server_is_skipped() {
331        let entry = make_entry(
332            "my-sse-server",
333            McpTransportType::Sse,
334            None,
335            vec![],
336            Some("https://example.com/sse"),
337        );
338        let result = provision_mcp_server(&entry).await;
339        assert_eq!(result.status, ProvisionStatus::Skipped);
340    }
341
342    #[tokio::test]
343    async fn stdio_no_command_is_skipped() {
344        let entry = make_entry("no-cmd", McpTransportType::Stdio, None, vec![], None);
345        let result = provision_mcp_server(&entry).await;
346        assert_eq!(result.status, ProvisionStatus::Skipped);
347    }
348
349    #[tokio::test]
350    async fn generic_command_found_returns_ready() {
351        // "echo" is universally available on all platforms.
352        let entry = make_entry(
353            "echo-srv",
354            McpTransportType::Stdio,
355            Some("echo"),
356            vec![],
357            None,
358        );
359        let result = provision_mcp_server(&entry).await;
360        assert_eq!(
361            result.status,
362            ProvisionStatus::Ready,
363            "echo must be on PATH"
364        );
365    }
366
367    #[tokio::test]
368    async fn generic_command_missing_returns_runtime_missing() {
369        let entry = make_entry(
370            "nonexistent-srv",
371            McpTransportType::Stdio,
372            Some("__gestura_nonexistent_cmd_xyz__"),
373            vec![],
374            None,
375        );
376        let result = provision_mcp_server(&entry).await;
377        assert_eq!(result.status, ProvisionStatus::RuntimeMissing);
378    }
379}