1use 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#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
27#[serde(rename_all = "snake_case")]
28pub enum ProvisionStatus {
29 Ready,
31 RuntimeMissing,
33 FetchFailed,
35 Skipped,
37}
38
39#[derive(Serialize, Deserialize, Debug, Clone)]
41pub struct ProvisionResult {
42 pub name: String,
44 pub status: ProvisionStatus,
46 pub message: String,
48}
49
50pub 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
67async 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
88async 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 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 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 Err(_elapsed) => ProvisionResult {
130 name: entry.name.clone(),
131 status: ProvisionStatus::Ready,
132 message: format!("npm package '{pkg}' pre-fetched (download completed)."),
133 },
134 Ok(Ok(_)) => ProvisionResult {
136 name: entry.name.clone(),
137 status: ProvisionStatus::Ready,
138 message: format!("npm package '{pkg}' is ready."),
139 },
140 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
149async 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 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 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 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
235async 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
256async 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 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#[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 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}