1use crate::error::{AppError, Result};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::path::PathBuf;
14
15const REGISTRY_BASE: &str = "https://registry.modelcontextprotocol.io/v0";
18
19#[derive(Debug, Deserialize)]
23struct RegistryListResponse {
24 servers: Vec<RegistryEntry>,
25 metadata: Option<RegistryMetadata>,
26}
27
28#[derive(Debug, Deserialize)]
29struct RegistryMetadata {
30 #[serde(rename = "nextCursor")]
31 next_cursor: Option<String>,
32 #[allow(dead_code)]
33 count: Option<u64>,
34}
35
36#[derive(Debug, Deserialize)]
38struct RegistryEntry {
39 server: RegistryServer,
40 #[serde(rename = "_meta")]
41 meta: Option<RegistryEntryMeta>,
42}
43
44#[derive(Debug, Deserialize, Default)]
45struct RegistryEntryMeta {
46 #[serde(rename = "io.modelcontextprotocol.registry/official")]
47 official: Option<RegistryOfficialMeta>,
48}
49
50#[derive(Debug, Deserialize)]
51struct RegistryOfficialMeta {
52 status: Option<String>,
53 #[serde(rename = "publishedAt")]
54 published_at: Option<String>,
55}
56
57#[derive(Debug, Deserialize)]
59pub struct RegistryServer {
60 pub name: String,
61 pub title: Option<String>,
62 pub description: Option<String>,
63 pub version: Option<String>,
64 #[serde(rename = "websiteUrl")]
65 pub website_url: Option<String>,
66 pub packages: Option<Vec<RegistryPackage>>,
67 pub remotes: Option<Vec<RegistryRemote>>,
68 pub repository: Option<RegistryRepository>,
69}
70
71#[derive(Debug, Deserialize)]
72pub struct RegistryPackage {
73 #[serde(rename = "registryType")]
74 pub registry_type: String, pub identifier: Option<String>,
76 pub version: Option<String>,
77 #[serde(rename = "runtimeHint")]
78 pub runtime_hint: Option<String>,
79 pub transport: Option<RegistryTransport>,
80 #[serde(rename = "environmentVariables")]
81 pub environment_variables: Option<Vec<RegistryEnvVar>>,
82 #[serde(rename = "packageArguments")]
83 pub package_arguments: Option<Vec<RegistryPackageArg>>,
84}
85
86#[derive(Debug, Deserialize)]
87pub struct RegistryRemote {
88 #[serde(rename = "type")]
89 pub transport_type: String, pub url: Option<String>,
91}
92
93#[derive(Debug, Deserialize)]
94pub struct RegistryTransport {
95 #[serde(rename = "type")]
96 pub transport_type: String,
97 pub url: Option<String>,
98}
99
100#[derive(Debug, Deserialize)]
101pub struct RegistryEnvVar {
102 pub name: String,
103 pub description: Option<String>,
104 #[serde(rename = "isRequired")]
105 pub is_required: Option<bool>,
106 #[serde(rename = "isSecret")]
107 pub is_secret: Option<bool>,
108 pub value: Option<String>,
109}
110
111#[derive(Debug, Deserialize)]
112pub struct RegistryPackageArg {
113 pub name: String,
114 pub description: Option<String>,
115 #[serde(rename = "isRequired")]
116 pub is_required: Option<bool>,
117 pub value: Option<String>,
118 pub default: Option<serde_json::Value>,
119}
120
121#[derive(Debug, Deserialize)]
122pub struct RegistryRepository {
123 pub url: Option<String>,
124 pub source: Option<String>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct McpConfigEntry {
134 #[serde(rename = "type")]
136 pub transport: String,
137 #[serde(skip_serializing_if = "Option::is_none")]
139 pub command: Option<String>,
140 #[serde(skip_serializing_if = "Vec::is_empty", default)]
142 pub args: Vec<String>,
143 #[serde(skip_serializing_if = "HashMap::is_empty", default)]
145 pub env: HashMap<String, String>,
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub url: Option<String>,
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub enabled: Option<bool>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, Default)]
156pub struct McpJsonConfig {
157 #[serde(rename = "mcpServers", default)]
158 pub mcp_servers: HashMap<String, McpConfigEntry>,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct McpManagerOutput {
168 pub operation: String,
169 pub success: bool,
170 pub message: String,
171 #[serde(skip_serializing_if = "Option::is_none")]
172 pub servers: Option<Vec<serde_json::Value>>,
173 #[serde(skip_serializing_if = "Option::is_none")]
174 pub config_path: Option<String>,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 pub workflow_guidance: Option<String>,
177 #[serde(skip_serializing_if = "Option::is_none")]
178 pub next_steps: Option<Vec<String>>,
179 #[serde(skip_serializing_if = "Option::is_none")]
180 pub error: Option<String>,
181}
182
183impl McpManagerOutput {
184 fn err(operation: &str, message: impl Into<String>, detail: impl Into<String>) -> Self {
185 Self {
186 operation: operation.to_string(),
187 success: false,
188 message: message.into(),
189 servers: None,
190 config_path: None,
191 workflow_guidance: None,
192 next_steps: None,
193 error: Some(detail.into()),
194 }
195 }
196}
197
198const SEARCH_GUIDANCE: &str = "\
201SEARCH RESULTS WORKFLOW:
2021. Review the server list above. Each entry shows: name, description, transport type, and package manager.
2032. If results look relevant, use operation=evaluate with the server name (e.g. \"io.github.org/repo\") to get full details including all env vars and install instructions.
2043. If no good matches, try a different search query — the registry searches by name prefix. Shorter or more general keywords work better.
2054. Once you find the right server, use operation=install to add it to the user's .mcp.json.
2065. After install, use operation=enable if the server is disabled, then instruct the user to restart Gestura (or reload MCP connections).
207TIPS:
208- Prefer servers with 'streamable-http' transport (no local install needed) for quick setup.
209- For stdio servers, check required env vars before installing — ask the user for any secrets.
210- The server name (e.g. 'io.github.modelcontextprotocol/server-filesystem') is the ID for evaluate/install.";
211
212const EVALUATE_GUIDANCE: &str = "\
213EVALUATE RESULTS WORKFLOW:
2141. Review the full server details above — pay special attention to:
215 - Transport type (stdio = local process, streamable-http = remote, sse = legacy remote)
216 - Required environment variables marked isRequired=true (especially secrets like API keys)
217 - Package type (npm → needs Node.js/npx, pypi → needs Python/uvx, oci → needs Docker)
2182. If the server looks suitable, ask the user for any missing required env vars (especially secrets).
2193. Use operation=install with the server name, optional alias, and any env vars the user provides.
2204. The install step writes the entry to .mcp.json — no packages are downloaded at this stage.
2215. The MCP client will launch/connect the server on next use.
222DECISION GUIDE:
223- streamable-http server → install with type=http, url=<remote_url>, no command needed.
224- npm stdio server → install with command='npx', args=['-y', '<package>'], type=stdio.
225- pypi stdio server → install with command='uvx', args=['<package>'], type=stdio.
226- oci stdio server → install with command='docker', args=['run', '-i', '--rm', '<image>'], type=stdio.";
227
228const INSTALL_GUIDANCE: &str = "\
229INSTALL COMPLETE — NEXT STEPS:
2301. The server entry has been written to .mcp.json shown above.
2312. If the server requires env vars (API keys, tokens, paths), verify they are set in the entry or exported in the shell environment.
2323. Use 'gestura mcp connect <name>' or restart Gestura to activate the new server.
2334. Use 'gestura mcp tools <name>' to see the tools provided by the server.
2345. Use operation=list to review all configured servers.
2356. If something is wrong, use operation=remove to delete the entry and try again.
236REMINDER: stdio servers require the runtime to be installed (npx needs Node.js, uvx needs Python+uv, docker needs Docker Desktop running).";
237
238const LIST_GUIDANCE: &str = "\
239CONFIGURED SERVERS:
240- 'enabled: true/null' = server is active and will be connected on startup.
241- 'enabled: false' = server is present but disabled (use operation=enable to re-activate).
242- Use operation=evaluate with a registry server name to discover new servers to add.
243- Use operation=install to add a new server.
244- Use operation=remove to delete a server entry.
245- Use 'gestura mcp connect <name>' to connect a server in the current session without restarting.";
246
247fn resolve_config_path(scope: &str) -> PathBuf {
254 match scope {
255 "user" => dirs::home_dir()
256 .unwrap_or_else(|| PathBuf::from("."))
257 .join(".mcp.json"),
258 "project" | "" => PathBuf::from(".mcp.json"),
259 other => PathBuf::from(other),
260 }
261}
262
263fn load_config(path: &PathBuf) -> Result<McpJsonConfig> {
264 if !path.exists() {
265 return Ok(McpJsonConfig::default());
266 }
267 let text = std::fs::read_to_string(path).map_err(AppError::Io)?;
268 serde_json::from_str(&text).map_err(|e| {
269 AppError::Io(std::io::Error::other(format!(
270 "Failed to parse {}: {e}",
271 path.display()
272 )))
273 })
274}
275
276fn save_config(path: &PathBuf, config: &McpJsonConfig) -> Result<()> {
277 if let Some(parent) = path.parent()
278 && !parent.as_os_str().is_empty()
279 {
280 std::fs::create_dir_all(parent).map_err(AppError::Io)?;
281 }
282 let json = serde_json::to_string_pretty(config)
283 .map_err(|e| AppError::Io(std::io::Error::other(format!("Serialization error: {e}"))))?;
284 std::fs::write(path, json).map_err(AppError::Io)
285}
286
287async fn run_blocking_config_op<T, F>(label: &'static str, op: F) -> Result<T>
288where
289 T: Send + 'static,
290 F: FnOnce() -> Result<T> + Send + 'static,
291{
292 tokio::task::spawn_blocking(op).await.map_err(|error| {
293 AppError::Io(std::io::Error::other(format!(
294 "{label} blocking task failed: {error}"
295 )))
296 })?
297}
298
299fn build_http_client() -> Result<reqwest::Client> {
302 reqwest::Client::builder()
303 .user_agent("gestura-mcp-manager/1.0")
304 .timeout(std::time::Duration::from_secs(15))
305 .build()
306 .map_err(|e| AppError::Io(std::io::Error::other(format!("HTTP client error: {e}"))))
307}
308
309fn server_to_value(entry: &RegistryEntry) -> serde_json::Value {
312 let s = &entry.server;
313 let transport_summary = describe_transport(s);
314 let status = entry
315 .meta
316 .as_ref()
317 .and_then(|m| m.official.as_ref())
318 .and_then(|o| o.status.as_deref())
319 .unwrap_or("unknown")
320 .to_string();
321 let published_at = entry
322 .meta
323 .as_ref()
324 .and_then(|m| m.official.as_ref())
325 .and_then(|o| o.published_at.as_deref())
326 .unwrap_or("")
327 .to_string();
328
329 serde_json::json!({
330 "id": s.name,
331 "title": s.title.as_deref().unwrap_or(&s.name),
332 "description": s.description.as_deref().unwrap_or(""),
333 "version": s.version.as_deref().unwrap_or(""),
334 "transport": transport_summary,
335 "status": status,
336 "published_at": published_at,
337 "website": s.website_url.as_deref().unwrap_or(""),
338 "repository": s.repository.as_ref().and_then(|r| r.url.as_deref()).unwrap_or(""),
339 })
340}
341
342fn describe_transport(s: &RegistryServer) -> String {
343 let mut parts: Vec<String> = Vec::new();
344 if let Some(remotes) = &s.remotes {
345 for r in remotes {
346 parts.push(format!(
347 "{} ({})",
348 r.transport_type,
349 r.url.as_deref().unwrap_or("")
350 ));
351 }
352 }
353 if let Some(pkgs) = &s.packages {
354 for p in pkgs {
355 let hint = p.runtime_hint.as_deref().unwrap_or(&p.registry_type);
356 let transport = p
357 .transport
358 .as_ref()
359 .map(|t| t.transport_type.as_str())
360 .unwrap_or("stdio");
361 parts.push(format!("{} via {} ({})", transport, hint, p.registry_type));
362 }
363 }
364 if parts.is_empty() {
365 "unknown".to_string()
366 } else {
367 parts.join("; ")
368 }
369}
370
371pub async fn search(query: &str, limit: usize) -> Result<McpManagerOutput> {
375 let client = build_http_client()?;
376 let url = format!(
377 "{}/servers?limit={}&search={}",
378 REGISTRY_BASE,
379 limit.min(50),
380 urlencoding::encode(query)
381 );
382 tracing::debug!("MCP registry search: {url}");
383
384 let resp = client.get(&url).send().await.map_err(|e| {
385 AppError::Io(std::io::Error::other(format!(
386 "Registry request failed: {e}"
387 )))
388 })?;
389
390 if !resp.status().is_success() {
391 let status = resp.status();
392 return Ok(McpManagerOutput::err(
393 "search",
394 format!("Registry returned HTTP {status}"),
395 format!("GET {url} → {status}"),
396 ));
397 }
398
399 let data: RegistryListResponse = resp
400 .json()
401 .await
402 .map_err(|e| AppError::Io(std::io::Error::other(format!("Registry parse error: {e}"))))?;
403
404 let count = data.servers.len();
405 let next_cursor = data
406 .metadata
407 .as_ref()
408 .and_then(|m| m.next_cursor.as_deref())
409 .unwrap_or("")
410 .to_string();
411 let servers: Vec<serde_json::Value> = data.servers.iter().map(server_to_value).collect();
412
413 let message = if count == 0 {
414 format!("No servers found matching '{query}'. Try a shorter or different keyword.")
415 } else {
416 format!("Found {count} server(s) matching '{query}'.")
417 };
418
419 let mut next_steps = vec![
420 "Use operation=evaluate with a server 'id' field to get full details and install instructions.".to_string(),
421 "Use operation=install once you have confirmed a server with the user.".to_string(),
422 ];
423 if !next_cursor.is_empty() {
424 next_steps.push(format!(
425 "More results available — search again with cursor='{next_cursor}' to see the next page."
426 ));
427 }
428
429 Ok(McpManagerOutput {
430 operation: "search".to_string(),
431 success: true,
432 message,
433 servers: Some(servers),
434 config_path: None,
435 workflow_guidance: Some(SEARCH_GUIDANCE.to_string()),
436 next_steps: Some(next_steps),
437 error: None,
438 })
439}
440
441pub async fn evaluate(server_id: &str) -> Result<McpManagerOutput> {
444 let client = build_http_client()?;
445 let encoded = urlencoding::encode(server_id);
447 let url = format!("{}/servers/{}", REGISTRY_BASE, encoded);
448 tracing::debug!("MCP registry evaluate: {url}");
449
450 let resp = client.get(&url).send().await.map_err(|e| {
451 AppError::Io(std::io::Error::other(format!(
452 "Registry request failed: {e}"
453 )))
454 })?;
455
456 if resp.status() == reqwest::StatusCode::NOT_FOUND {
457 return Ok(McpManagerOutput::err(
458 "evaluate",
459 format!("Server '{server_id}' not found in registry."),
460 "HTTP 404 — check the exact server name (use operation=search to find it).".to_string(),
461 ));
462 }
463 if !resp.status().is_success() {
464 let status = resp.status();
465 return Ok(McpManagerOutput::err(
466 "evaluate",
467 format!("Registry returned HTTP {status}"),
468 format!("GET {url} → {status}"),
469 ));
470 }
471
472 let entry: RegistryEntry = resp
473 .json()
474 .await
475 .map_err(|e| AppError::Io(std::io::Error::other(format!("Registry parse error: {e}"))))?;
476
477 let s = &entry.server;
478 let install_rec = build_install_recommendation(s);
479 let required_env = collect_required_env(s);
480 let detail = serde_json::json!({
481 "id": s.name,
482 "title": s.title.as_deref().unwrap_or(&s.name),
483 "description": s.description.as_deref().unwrap_or(""),
484 "version": s.version.as_deref().unwrap_or(""),
485 "website": s.website_url.as_deref().unwrap_or(""),
486 "repository": s.repository.as_ref().and_then(|r| r.url.as_deref()).unwrap_or(""),
487 "install_recommendation": install_rec,
488 "required_env_vars": required_env,
489 "packages": s.packages.as_ref().map(|pkgs| pkgs.iter().map(|p| serde_json::json!({
490 "type": p.registry_type,
491 "identifier": p.identifier.as_deref().unwrap_or(""),
492 "version": p.version.as_deref().unwrap_or(""),
493 "runtime_hint": p.runtime_hint.as_deref().unwrap_or(""),
494 "transport": p.transport.as_ref().map(|t| &t.transport_type).map(|s| s.as_str()).unwrap_or("stdio"),
495 "env_vars": p.environment_variables.as_ref().map(|ev| ev.iter().map(|v| serde_json::json!({
496 "name": v.name,
497 "description": v.description.as_deref().unwrap_or(""),
498 "required": v.is_required.unwrap_or(false),
499 "secret": v.is_secret.unwrap_or(false),
500 })).collect::<Vec<_>>()).unwrap_or_default(),
501 })).collect::<Vec<_>>()),
502 "remotes": s.remotes.as_ref().map(|rs| rs.iter().map(|r| serde_json::json!({
503 "type": r.transport_type,
504 "url": r.url.as_deref().unwrap_or(""),
505 })).collect::<Vec<_>>()),
506 });
507
508 let next_steps = build_evaluate_next_steps(s, server_id);
509
510 Ok(McpManagerOutput {
511 operation: "evaluate".to_string(),
512 success: true,
513 message: format!(
514 "Server '{}': {}",
515 s.title.as_deref().unwrap_or(&s.name),
516 s.description.as_deref().unwrap_or("no description")
517 ),
518 servers: Some(vec![detail]),
519 config_path: None,
520 workflow_guidance: Some(EVALUATE_GUIDANCE.to_string()),
521 next_steps: Some(next_steps),
522 error: None,
523 })
524}
525
526pub async fn info(server_id: &str) -> Result<McpManagerOutput> {
528 evaluate(server_id).await
529}
530
531fn collect_required_env(s: &RegistryServer) -> Vec<serde_json::Value> {
532 let mut vars: Vec<serde_json::Value> = Vec::new();
533 if let Some(pkgs) = &s.packages {
534 for p in pkgs {
535 if let Some(env_vars) = &p.environment_variables {
536 for v in env_vars {
537 vars.push(serde_json::json!({
538 "name": v.name,
539 "description": v.description.as_deref().unwrap_or(""),
540 "required": v.is_required.unwrap_or(false),
541 "secret": v.is_secret.unwrap_or(false),
542 }));
543 }
544 }
545 }
546 }
547 vars
548}
549
550fn build_install_recommendation(s: &RegistryServer) -> serde_json::Value {
551 if let Some(remotes) = &s.remotes
553 && let Some(remote) = remotes
554 .iter()
555 .find(|r| r.transport_type == "streamable-http")
556 .or_else(|| remotes.first())
557 {
558 return serde_json::json!({
559 "transport": "http",
560 "url": remote.url.as_deref().unwrap_or(""),
561 "note": "Remote HTTP server — no local runtime needed.",
562 });
563 }
564 if let Some(pkgs) = &s.packages
566 && let Some(pkg) = pkgs.first()
567 {
568 return build_package_recommendation(pkg);
569 }
570 serde_json::json!({"note": "No clear install path found. Review the server repository manually."})
571}
572
573fn build_package_recommendation(p: &RegistryPackage) -> serde_json::Value {
574 let id = p.identifier.as_deref().unwrap_or("");
575 match p.registry_type.as_str() {
576 "npm" => {
577 let hint = p.runtime_hint.as_deref().unwrap_or("npx");
578 serde_json::json!({
579 "transport": "stdio",
580 "command": hint,
581 "args": ["-y", id],
582 "note": format!("npm package — requires Node.js. Run: {hint} -y {id}"),
583 })
584 }
585 "pypi" => {
586 let hint = p.runtime_hint.as_deref().unwrap_or("uvx");
587 serde_json::json!({
588 "transport": "stdio",
589 "command": hint,
590 "args": [id],
591 "note": format!("PyPI package — requires Python + uv. Run: {hint} {id}"),
592 })
593 }
594 "oci" => serde_json::json!({
595 "transport": "stdio",
596 "command": "docker",
597 "args": ["run", "-i", "--rm", id],
598 "note": format!("OCI image — requires Docker Desktop running. Image: {id}"),
599 }),
600 other => serde_json::json!({
601 "transport": "stdio",
602 "note": format!("Unknown package type '{other}' — install manually from identifier: {id}"),
603 }),
604 }
605}
606
607fn build_evaluate_next_steps(s: &RegistryServer, server_id: &str) -> Vec<String> {
608 let mut steps = Vec::new();
609 let has_required_env = s.packages.as_ref().is_some_and(|pkgs| {
610 pkgs.iter().any(|p| {
611 p.environment_variables
612 .as_ref()
613 .is_some_and(|ev| ev.iter().any(|v| v.is_required.unwrap_or(false)))
614 })
615 });
616 if has_required_env {
617 steps.push("This server requires environment variables — ask the user to provide them before installing.".to_string());
618 }
619 steps.push(format!(
620 "To install: use operation=install with server_id=\"{server_id}\" and any required env vars."
621 ));
622 steps.push("Confirm the install plan with the user before proceeding.".to_string());
623 steps
624}
625
626#[allow(clippy::too_many_arguments)]
639pub async fn install(
640 server_id: &str,
641 name: Option<&str>,
642 scope: &str,
643 transport_override: Option<&str>,
644 command_override: Option<&str>,
645 args_override: Option<Vec<String>>,
646 url_override: Option<&str>,
647 env_vars: HashMap<String, String>,
648) -> Result<McpManagerOutput> {
649 let client = build_http_client()?;
651 let encoded = urlencoding::encode(server_id);
652 let url = format!("{}/servers/{}", REGISTRY_BASE, encoded);
653 let resp = client.get(&url).send().await.map_err(|e| {
654 AppError::Io(std::io::Error::other(format!(
655 "Registry request failed: {e}"
656 )))
657 })?;
658
659 if resp.status() == reqwest::StatusCode::NOT_FOUND {
660 return Ok(McpManagerOutput::err(
661 "install",
662 format!("Server '{server_id}' not found in registry."),
663 "Use operation=search to find the correct server name.".to_string(),
664 ));
665 }
666 if !resp.status().is_success() {
667 let status = resp.status();
668 return Ok(McpManagerOutput::err(
669 "install",
670 format!("Registry returned HTTP {status}"),
671 format!("GET {url} → {status}"),
672 ));
673 }
674
675 let entry: RegistryEntry = resp
676 .json()
677 .await
678 .map_err(|e| AppError::Io(std::io::Error::other(format!("Registry parse error: {e}"))))?;
679 let s = &entry.server;
680
681 let config_entry = derive_config_entry(
683 s,
684 transport_override,
685 command_override,
686 args_override,
687 url_override,
688 env_vars,
689 )?;
690
691 let local_name = name.map(|n| n.to_string()).unwrap_or_else(|| {
693 server_id
694 .rsplit('/')
695 .next()
696 .unwrap_or(server_id)
697 .to_string()
698 });
699
700 let config_path = resolve_config_path(scope);
702 let mut config = run_blocking_config_op("mcp install load_config", {
703 let config_path = config_path.clone();
704 move || load_config(&config_path)
705 })
706 .await?;
707 config.mcp_servers.insert(local_name.clone(), config_entry);
708 run_blocking_config_op("mcp install save_config", {
709 let config_path = config_path.clone();
710 let config = config.clone();
711 move || save_config(&config_path, &config)
712 })
713 .await?;
714
715 tracing::info!(
716 "Installed MCP server '{}' as '{}' in {}",
717 server_id,
718 local_name,
719 config_path.display()
720 );
721
722 Ok(McpManagerOutput {
723 operation: "install".to_string(),
724 success: true,
725 message: format!(
726 "Installed '{}' as '{}' in {}",
727 server_id,
728 local_name,
729 config_path.display()
730 ),
731 servers: None,
732 config_path: Some(config_path.to_string_lossy().to_string()),
733 workflow_guidance: Some(INSTALL_GUIDANCE.to_string()),
734 next_steps: Some(vec![
735 format!("Run 'gestura mcp connect {local_name}' to connect without restarting."),
736 "Or restart Gestura to load the new server automatically.".to_string(),
737 format!(
738 "Use operation=list (scope={scope}) to verify the entry was written correctly."
739 ),
740 ]),
741 error: None,
742 })
743}
744
745fn derive_config_entry(
746 s: &RegistryServer,
747 transport_override: Option<&str>,
748 command_override: Option<&str>,
749 args_override: Option<Vec<String>>,
750 url_override: Option<&str>,
751 env_vars: HashMap<String, String>,
752) -> Result<McpConfigEntry> {
753 let prefer_http = transport_override.map_or_else(
755 || {
756 s.remotes.as_ref().is_some_and(|rs| {
757 rs.iter()
758 .any(|r| r.transport_type == "streamable-http" || r.transport_type == "sse")
759 })
760 },
761 |t| t == "http",
762 );
763
764 if prefer_http && transport_override != Some("stdio") {
765 let remote_url = url_override.map(|u| u.to_string()).or_else(|| {
766 s.remotes.as_ref().and_then(|rs| {
767 rs.iter()
768 .find(|r| r.transport_type == "streamable-http")
769 .or_else(|| rs.iter().find(|r| r.transport_type == "sse"))
770 .and_then(|r| r.url.clone())
771 })
772 });
773
774 return Ok(McpConfigEntry {
775 transport: "http".to_string(),
776 command: None,
777 args: vec![],
778 env: env_vars,
779 url: remote_url,
780 enabled: Some(true),
781 });
782 }
783
784 let pkg = s.packages.as_ref().and_then(|pkgs| pkgs.first());
786
787 let (command, args) = if let Some(cmd) = command_override {
788 let final_args = args_override.unwrap_or_default();
789 (cmd.to_string(), final_args)
790 } else if let Some(p) = pkg {
791 let id = p.identifier.as_deref().unwrap_or("");
792 match p.registry_type.as_str() {
793 "npm" => {
794 let hint = p.runtime_hint.as_deref().unwrap_or("npx").to_string();
795 (hint, vec!["-y".to_string(), id.to_string()])
796 }
797 "pypi" => {
798 let hint = p.runtime_hint.as_deref().unwrap_or("uvx").to_string();
799 (hint, vec![id.to_string()])
800 }
801 "oci" => (
802 "docker".to_string(),
803 vec![
804 "run".to_string(),
805 "-i".to_string(),
806 "--rm".to_string(),
807 id.to_string(),
808 ],
809 ),
810 _ => {
811 return Err(AppError::Io(std::io::Error::other(format!(
812 "Cannot auto-derive install command for package type '{}'. \
813 Provide command/args overrides.",
814 p.registry_type
815 ))));
816 }
817 }
818 } else {
819 return Err(AppError::Io(std::io::Error::other(
820 "No packages or remotes found for this server. Provide command/url overrides.",
821 )));
822 };
823
824 Ok(McpConfigEntry {
825 transport: "stdio".to_string(),
826 command: Some(command),
827 args,
828 env: env_vars,
829 url: None,
830 enabled: Some(true),
831 })
832}
833
834pub fn enable(name: &str, scope: &str) -> Result<McpManagerOutput> {
836 set_enabled(name, scope, true)
837}
838
839pub fn disable(name: &str, scope: &str) -> Result<McpManagerOutput> {
841 set_enabled(name, scope, false)
842}
843
844fn set_enabled(name: &str, scope: &str, enabled: bool) -> Result<McpManagerOutput> {
845 let config_path = resolve_config_path(scope);
846 let mut config = load_config(&config_path)?;
847 match config.mcp_servers.get_mut(name) {
848 None => Ok(McpManagerOutput::err(
849 if enabled { "enable" } else { "disable" },
850 format!("Server '{name}' not found in {}", config_path.display()),
851 "Use operation=list to see configured servers.".to_string(),
852 )),
853 Some(entry) => {
854 entry.enabled = Some(enabled);
855 save_config(&config_path, &config)?;
856 let verb = if enabled { "enabled" } else { "disabled" };
857 Ok(McpManagerOutput {
858 operation: if enabled { "enable" } else { "disable" }.to_string(),
859 success: true,
860 message: format!("Server '{name}' {verb} in {}", config_path.display()),
861 servers: None,
862 config_path: Some(config_path.to_string_lossy().to_string()),
863 workflow_guidance: None,
864 next_steps: Some(vec![
865 "Restart Gestura or run 'gestura mcp connect <name>' to apply the change."
866 .to_string(),
867 ]),
868 error: None,
869 })
870 }
871 }
872}
873
874pub fn list(scope: &str) -> Result<McpManagerOutput> {
876 let config_path = resolve_config_path(scope);
877 let config = load_config(&config_path)?;
878 let servers: Vec<serde_json::Value> = config
879 .mcp_servers
880 .iter()
881 .map(|(name, entry)| {
882 serde_json::json!({
883 "name": name,
884 "transport": entry.transport,
885 "command": entry.command.as_deref().unwrap_or(""),
886 "args": entry.args,
887 "url": entry.url.as_deref().unwrap_or(""),
888 "env_keys": entry.env.keys().collect::<Vec<_>>(),
889 "enabled": entry.enabled.unwrap_or(true),
890 })
891 })
892 .collect();
893
894 let count = servers.len();
895 Ok(McpManagerOutput {
896 operation: "list".to_string(),
897 success: true,
898 message: format!("{count} server(s) configured in {}", config_path.display()),
899 servers: Some(servers),
900 config_path: Some(config_path.to_string_lossy().to_string()),
901 workflow_guidance: Some(LIST_GUIDANCE.to_string()),
902 next_steps: None,
903 error: None,
904 })
905}
906
907pub fn remove(name: &str, scope: &str) -> Result<McpManagerOutput> {
909 let config_path = resolve_config_path(scope);
910 let mut config = load_config(&config_path)?;
911 if config.mcp_servers.remove(name).is_none() {
912 return Ok(McpManagerOutput::err(
913 "remove",
914 format!("Server '{name}' not found in {}", config_path.display()),
915 "Use operation=list to see configured servers.".to_string(),
916 ));
917 }
918 save_config(&config_path, &config)?;
919 Ok(McpManagerOutput {
920 operation: "remove".to_string(),
921 success: true,
922 message: format!("Removed '{name}' from {}", config_path.display()),
923 servers: None,
924 config_path: Some(config_path.to_string_lossy().to_string()),
925 workflow_guidance: None,
926 next_steps: Some(vec![
927 "Restart Gestura or run 'gestura mcp disconnect <name>' to apply the change."
928 .to_string(),
929 ]),
930 error: None,
931 })
932}
933
934pub async fn handle(args: &serde_json::Value) -> Result<McpManagerOutput> {
956 let op = args
957 .get("operation")
958 .and_then(|v| v.as_str())
959 .unwrap_or("list");
960
961 let scope = args
962 .get("scope")
963 .and_then(|v| v.as_str())
964 .unwrap_or("project");
965
966 match op {
967 "search" => {
968 let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
969 let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
970 search(query, limit).await
971 }
972 "evaluate" | "info" => {
973 let server_id = args
974 .get("server_id")
975 .and_then(|v| v.as_str())
976 .ok_or_else(|| {
977 AppError::Io(std::io::Error::other(
978 "evaluate requires 'server_id' parameter",
979 ))
980 })?;
981 evaluate(server_id).await
982 }
983 "install" => {
984 let server_id = args
985 .get("server_id")
986 .and_then(|v| v.as_str())
987 .ok_or_else(|| {
988 AppError::Io(std::io::Error::other(
989 "install requires 'server_id' parameter",
990 ))
991 })?;
992 let name = args.get("name").and_then(|v| v.as_str());
993 let transport = args.get("transport").and_then(|v| v.as_str());
994 let command = args.get("command").and_then(|v| v.as_str());
995 let args_list: Option<Vec<String>> =
996 args.get("args").and_then(|v| v.as_array()).map(|arr| {
997 arr.iter()
998 .filter_map(|v| v.as_str().map(|s| s.to_string()))
999 .collect()
1000 });
1001 let url = args.get("url").and_then(|v| v.as_str());
1002 let env: HashMap<String, String> = args
1003 .get("env")
1004 .and_then(|v| v.as_object())
1005 .map(|obj| {
1006 obj.iter()
1007 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
1008 .collect()
1009 })
1010 .unwrap_or_default();
1011 install(
1012 server_id, name, scope, transport, command, args_list, url, env,
1013 )
1014 .await
1015 }
1016 "enable" => {
1017 let name = args.get("name").and_then(|v| v.as_str()).ok_or_else(|| {
1018 AppError::Io(std::io::Error::other("enable requires 'name' parameter"))
1019 })?;
1020 run_blocking_config_op("mcp enable", {
1021 let name = name.to_string();
1022 let scope = scope.to_string();
1023 move || enable(&name, &scope)
1024 })
1025 .await
1026 }
1027 "disable" => {
1028 let name = args.get("name").and_then(|v| v.as_str()).ok_or_else(|| {
1029 AppError::Io(std::io::Error::other("disable requires 'name' parameter"))
1030 })?;
1031 run_blocking_config_op("mcp disable", {
1032 let name = name.to_string();
1033 let scope = scope.to_string();
1034 move || disable(&name, &scope)
1035 })
1036 .await
1037 }
1038 "list" => {
1039 run_blocking_config_op("mcp list", {
1040 let scope = scope.to_string();
1041 move || list(&scope)
1042 })
1043 .await
1044 }
1045 "remove" => {
1046 let name = args.get("name").and_then(|v| v.as_str()).ok_or_else(|| {
1047 AppError::Io(std::io::Error::other("remove requires 'name' parameter"))
1048 })?;
1049 run_blocking_config_op("mcp remove", {
1050 let name = name.to_string();
1051 let scope = scope.to_string();
1052 move || remove(&name, &scope)
1053 })
1054 .await
1055 }
1056 unknown => Ok(McpManagerOutput::err(
1057 unknown,
1058 format!("Unknown mcp operation: '{unknown}'"),
1059 "Valid operations: search, evaluate, install, enable, disable, list, remove, info"
1060 .to_string(),
1061 )),
1062 }
1063}