gestura_core_tools/
mcp_manager.rs

1//! MCP Manager — Search, evaluate, install, and manage MCP servers
2//!
3//! Queries the official MCP registry at <https://registry.modelcontextprotocol.io/v0/servers>
4//! to discover servers by keyword, evaluate them in detail, and install them into the local
5//! `.mcp.json` configuration (Claude Desktop / Gestura compatible format).
6//!
7//! No dependency on `gestura-core-mcp` is introduced here (circular-dep prevention).
8//! Config types are defined locally and serialize to the same `.mcp.json` wire format.
9
10use crate::error::{AppError, Result};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::path::PathBuf;
14
15// ── Registry API ─────────────────────────────────────────────────────────────
16
17const REGISTRY_BASE: &str = "https://registry.modelcontextprotocol.io/v0";
18
19// ── Registry API response types ───────────────────────────────────────────────
20
21/// Top-level list response from the registry.
22#[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/// One entry in the registry list (wraps the server object + registry meta).
37#[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/// A server record from the registry.
58#[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, // "npm" | "pypi" | "oci"
75    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, // "streamable-http" | "sse"
90    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// ── Local .mcp.json config types ─────────────────────────────────────────────
128// These mirror McpServerEntry / McpJsonFile from gestura-core-mcp WITHOUT
129// importing from that crate (which would create a circular dependency).
130
131/// A single server entry in `.mcp.json`.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct McpConfigEntry {
134    /// Transport type: "stdio" or "http".
135    #[serde(rename = "type")]
136    pub transport: String,
137    /// Command to launch (stdio servers).
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub command: Option<String>,
140    /// CLI arguments (stdio servers).
141    #[serde(skip_serializing_if = "Vec::is_empty", default)]
142    pub args: Vec<String>,
143    /// Environment variables passed to the server process.
144    #[serde(skip_serializing_if = "HashMap::is_empty", default)]
145    pub env: HashMap<String, String>,
146    /// Remote URL (HTTP/SSE servers).
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub url: Option<String>,
149    /// Whether the server is enabled (None = treat as enabled).
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub enabled: Option<bool>,
152}
153
154/// Root structure of a `.mcp.json` file.
155#[derive(Debug, Clone, Serialize, Deserialize, Default)]
156pub struct McpJsonConfig {
157    #[serde(rename = "mcpServers", default)]
158    pub mcp_servers: HashMap<String, McpConfigEntry>,
159}
160
161// ── Operation output ──────────────────────────────────────────────────────────
162
163/// Unified output for all mcp_manager operations.
164/// The `workflow_guidance` and `next_steps` fields carry LLM-facing prompts
165/// that guide the agent through multi-step MCP discovery and install workflows.
166#[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
198// ── Workflow guidance (LLM-facing prompts) ────────────────────────────────────
199
200const 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
247// ── Config file helpers ───────────────────────────────────────────────────────
248
249/// Resolve the `.mcp.json` path for a given scope.
250/// - "user"    → `~/.mcp.json`
251/// - "project" → `.mcp.json` in the current working directory
252/// - anything else is treated as a literal file path
253fn 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
299// ── HTTP client helper ────────────────────────────────────────────────────────
300
301fn 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
309// ── Serialise a registry server to a JSON value for output ───────────────────
310
311fn 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
371// ── Operations ────────────────────────────────────────────────────────────────
372
373/// Search the MCP registry by keyword.
374pub 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
441/// Fetch detailed information about a single registry server and generate
442/// a concrete install recommendation for the agent to present to the user.
443pub async fn evaluate(server_id: &str) -> Result<McpManagerOutput> {
444    let client = build_http_client()?;
445    // The registry uses URL-encoded server name as the ID path component.
446    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
526/// Detailed info — alias for evaluate with extra raw JSON.
527pub 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    // Prefer streamable-http remote (no local install)
552    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    // Fall back to best package option
565    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/// Install a server into .mcp.json by looking it up in the registry and
627/// deriving the best config entry automatically.
628///
629/// # Parameters (from `args` JSON)
630/// - `server_id`   – registry server name (e.g. `"io.github.org/repo"`)
631/// - `name`        – local alias in .mcp.json (defaults to last path segment of `server_id`)
632/// - `scope`       – `"user"` | `"project"` (default `"project"`)
633/// - `transport`   – override transport: `"stdio"` | `"http"` (auto-detected if omitted)
634/// - `command`     – override launch command (stdio only)
635/// - `args`        – override args array (stdio only)
636/// - `url`         – override URL (http only)
637/// - `env`         – map of env var name → value
638#[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    // 1. Fetch server details from registry.
650    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    // 2. Derive the config entry.
682    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    // 3. Determine local name.
692    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    // 4. Write to .mcp.json.
701    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    // Prefer remote HTTP unless overridden.
754    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    // stdio via package
785    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
834/// Set a server's `enabled` flag to `true` in .mcp.json.
835pub fn enable(name: &str, scope: &str) -> Result<McpManagerOutput> {
836    set_enabled(name, scope, true)
837}
838
839/// Set a server's `enabled` flag to `false` in .mcp.json.
840pub 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
874/// List all servers configured in .mcp.json for the given scope.
875pub 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
907/// Remove a server entry from .mcp.json.
908pub 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
934// ── Main dispatcher ───────────────────────────────────────────────────────────
935
936/// Entry point called by the pipeline tool executor.
937///
938/// Expected `args` shape (all fields optional unless noted):
939/// ```json
940/// {
941///   "operation": "search" | "evaluate" | "install" | "enable" | "disable" | "list" | "remove" | "info",
942///   "query":      "<search terms>",          // search
943///   "limit":      20,                         // search (default 20)
944///   "cursor":     "<opaque>",                 // search pagination
945///   "server_id":  "<registry-name>",          // evaluate, install, info
946///   "name":       "<local-alias>",            // install (optional)
947///   "scope":      "project" | "user",         // install/enable/disable/list/remove
948///   "transport":  "stdio" | "http",           // install override
949///   "command":    "npx",                      // install stdio override
950///   "args":       ["-y", "package"],          // install stdio override
951///   "url":        "https://...",              // install http override
952///   "env":        {"KEY": "value"}            // install env vars
953/// }
954/// ```
955pub 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}