gestura_core_mcp/
registry.rs

1//! MCP Registry integration — popular server discovery.
2//!
3//! Fetches and filters the official MCP Registry
4//! (<https://registry.modelcontextprotocol.io>) to surface open-source
5//! servers. Three transport families are supported: stdio (npm / pypi),
6//! streamable-HTTP, and SSE. Two add-ability tiers are exposed per server:
7//!
8//! * **Quick Add** — server with no required env vars / auth headers; added
9//!   enabled and ready to use immediately (`enabled = true`).
10//! * **Add (Disabled)** — server that requires at least one env var or auth
11//!   header before it can connect; added in a disabled state (`enabled = false`)
12//!   so the user can fill in secrets / config in the settings panel and then
13//!   enable it.
14
15use crate::config::{McpScope, McpServerEntry, McpTransportType};
16use crate::error::Result;
17use gestura_core_foundation::error::AppError;
18
19/// A recommended MCP server entry sourced from the official MCP Registry.
20///
21/// Shaped for the configuration window: carries display metadata (description
22/// and repo URL) plus a fully-formed [`McpServerEntry`] ready for 1-click add.
23#[derive(serde::Serialize, Debug, Clone)]
24pub struct PopularMcpServer {
25    /// Display name from the registry (may include dots/slashes).
26    pub display_name: String,
27    /// Human description from the registry.
28    pub description: String,
29    /// Source repository URL (open-source signal).
30    pub repository_url: String,
31    /// Package identifier (npm or pypi) used to invoke the server.
32    pub package_identifier: String,
33    /// Version string from registry (`server.version`).
34    pub version: String,
35    /// Fully-formed MCP server config entry (stdio).
36    pub tool: McpServerEntry,
37}
38
39/// One entry in a paginated registry browse result.
40///
41/// Unlike [`PopularMcpServer`], this covers **all active** registry servers, not
42/// just the no-configuration npm/stdio subset.
43///
44/// Two optional add-ability tiers are populated by the backend:
45///
46/// | Field | Transport family | Condition | Button |
47/// |-------|-----------------|-----------|--------|
48/// | `quick_add` | stdio (npm/pypi) | no required env vars | "Quick Add" (enabled) |
49/// | `quick_add` | HTTP / SSE remote | no required auth headers | "Quick Add" (enabled) |
50/// | `add_disabled` | stdio (npm/pypi) | has ≥1 required env var | "Add (Disabled)" |
51/// | `add_disabled` | HTTP / SSE remote | has ≥1 required header | "Add (Disabled)" |
52///
53/// At most one of the two will be `Some` for any given entry; stdio packages
54/// take priority over remotes within each tier.
55#[derive(serde::Serialize, Debug, Clone)]
56pub struct RegistryBrowseEntry {
57    /// Registry display name (e.g. `"io.github.owner/server-name"`).
58    pub display_name: String,
59    /// Human-readable description from the registry.
60    pub description: String,
61    /// Source repository URL; may be an empty string for non-open-source entries.
62    pub repository_url: String,
63    /// Version string from the registry.
64    pub version: String,
65    /// If `Some`, this server can be Quick-Added without any additional
66    /// configuration.  Covers stdio (npm/pypi) servers with no required env
67    /// vars **and** HTTP/SSE remote servers with no required auth headers.
68    /// Added with `enabled = true`; ready to use immediately.
69    pub quick_add: Option<McpServerEntry>,
70    /// If `Some`, this server requires at least one secret or configuration
71    /// value before it can connect.  Covers stdio (npm/pypi) servers with
72    /// required env vars **and** HTTP/SSE remote servers with required auth
73    /// headers.  Added with `enabled = false`; the user fills in values in
74    /// the settings panel and then enables it.  Mutually exclusive with
75    /// `quick_add`.
76    pub add_disabled: Option<McpServerEntry>,
77}
78
79/// Paginated response returned by [`browse_mcp_registry`].
80#[derive(serde::Serialize, Debug, Clone)]
81pub struct RegistryBrowsePage {
82    /// Servers on this page.
83    pub servers: Vec<RegistryBrowseEntry>,
84    /// Opaque cursor for fetching the next page; `None` means no more pages.
85    pub next_cursor: Option<String>,
86    /// Number of items returned on this page (from registry metadata).
87    pub page_count: Option<u64>,
88}
89
90/// Return up to `limit` popular, open-source MCP servers that can be added
91/// without additional configuration.
92///
93/// Selection rules (registry-driven):
94/// - MCP Registry listing status is **active**
95/// - Has a repository URL (open-source signal)
96/// - `$schema` field contains `server.schema.json` (current MCP standard)
97/// - Has an npm `stdio` package
98/// - Declares **zero** environment variables (no required user setup)
99///
100/// Results are sorted by the curated `PRIORITY_PACKAGES` list first, then
101/// alphabetically as a tiebreak for servers not in the curated set.
102/// Returns an error if fewer than `limit` servers pass all filters.
103pub async fn list_popular_mcp_servers(limit: usize) -> Result<Vec<PopularMcpServer>> {
104    fetch_popular_mcp_servers_from_registry(limit).await
105}
106
107/// Fetch a single page from the MCP Registry with optional full-text search.
108///
109/// Unlike [`list_popular_mcp_servers`], this function:
110/// - Fetches **one page only** (no multi-page accumulation).
111/// - Includes **all active servers** regardless of transport or env-var requirements.
112/// - Marks servers that pass the zero-config npm/stdio criteria via
113///   [`RegistryBrowseEntry::quick_add`] so the UI can surface a "Quick Add" button.
114///
115/// # Parameters
116/// - `query` – Optional search string forwarded to the registry `search=` param.
117///   Pass `None` or an empty string to list all servers alphabetically.
118/// - `cursor` – Opaque pagination cursor from a previous [`RegistryBrowsePage::next_cursor`].
119/// - `limit`  – Number of servers to request per page (typically 20).
120pub async fn browse_mcp_registry(
121    query: Option<String>,
122    cursor: Option<String>,
123    limit: usize,
124) -> Result<RegistryBrowsePage> {
125    const REGISTRY_URL: &str = "https://registry.modelcontextprotocol.io/v0.1/servers";
126
127    let client = reqwest::Client::builder()
128        .timeout(std::time::Duration::from_secs(12))
129        .user_agent(format!(
130            "Gestura/{} (config window; https://gestura.ai)",
131            env!("CARGO_PKG_VERSION")
132        ))
133        .build()
134        .map_err(AppError::Http)?;
135
136    let mut url = reqwest::Url::parse(REGISTRY_URL).map_err(|e| AppError::Mcp(e.to_string()))?;
137    {
138        let mut qp = url.query_pairs_mut();
139        qp.append_pair("version", "latest");
140        qp.append_pair("limit", &limit.to_string());
141        if let Some(ref q) = query {
142            let trimmed = q.trim();
143            if !trimmed.is_empty() {
144                qp.append_pair("search", trimmed);
145            }
146        }
147        if let Some(ref c) = cursor {
148            qp.append_pair("cursor", c);
149        }
150    }
151
152    let resp = client.get(url).send().await.map_err(AppError::Http)?;
153    if !resp.status().is_success() {
154        return Err(AppError::Mcp(format!(
155            "MCP Registry returned status {}",
156            resp.status()
157        )));
158    }
159
160    let data: serde_json::Value = resp.json().await.map_err(AppError::Http)?;
161
162    let items = data
163        .get("servers")
164        .and_then(|v| v.as_array())
165        .ok_or_else(|| {
166            AppError::Mcp("MCP Registry response missing 'servers' array".to_string())
167        })?;
168
169    let servers: Vec<RegistryBrowseEntry> = items
170        .iter()
171        .filter_map(browse_entry_from_registry_item)
172        .collect();
173
174    let next_cursor = data
175        .get("metadata")
176        .and_then(|m| m.get("nextCursor"))
177        .and_then(|v| v.as_str())
178        .map(|s| s.to_string());
179
180    let page_count = data
181        .get("metadata")
182        .and_then(|m| m.get("count"))
183        .and_then(|v| v.as_u64());
184
185    Ok(RegistryBrowsePage {
186        servers,
187        next_cursor,
188        page_count,
189    })
190}
191
192/// Curated set of 20 production-ready, no-configuration MCP servers in priority order.
193///
194/// Each entry is an npm package identifier as it appears in the MCP Registry.
195/// When sorting registry candidates, servers whose `package_identifier` matches an
196/// entry here are surfaced first (lower index = higher priority).  Any server
197/// not in the list receives a priority value of [`usize::MAX`] and sorts last,
198/// falling back to alphabetical ordering by `(display_name, package_identifier)`.
199const PRIORITY_PACKAGES: &[&str] = &[
200    "chrome-devtools-mcp",       // ChromeDevTools official — browser debugging
201    "@gitkraken/gk",             // GitKraken CLI — mature cross-platform Git
202    "computer-use-mcp",          // Full computer control, no API key required
203    "defuddle-fetch-mcp-server", // Clean web-content fetching
204    "filesystem-mcp",            // File read / create / edit operations
205    "shell-exec-mcp",            // Bash command execution
206    "xcodebuildmcp",             // Xcode build tooling — iOS/macOS dev
207    "docfork",                   // Up-to-date library docs for AI agents
208    "reddit-mcp-buddy",          // Reddit browsing — no API keys required
209    "@azure/mcp",                // Microsoft Azure official MCP server
210    "@google-cloud/gemini-cloud-assist-mcp", // Google Cloud Platform official
211    "firebase-tools",            // Firebase / Google official CLI tools
212    "@sveltejs/mcp",             // Official Svelte framework tooling
213    "@goreleaser/mcp",           // Official GoReleaser — release automation
214    "@discourse/mcp",            // Official Discourse — community platforms
215    "@alisaitteke/docker-mcp",   // Docker container management
216    "mcp-prometheus",            // Prometheus monitoring & alerting
217    "mcp-server-code-runner",    // Multi-language code runner
218    "@hypothesi/tauri-mcp-server", // Tauri v2 desktop-app tooling
219    "@dollhousemcp/mcp-server",  // AI personas, skills & persistent memory
220];
221
222/// Return the priority rank of a package identifier.
223///
224/// Packages in `PRIORITY_PACKAGES` return their 0-based index; anything else
225/// returns [`usize::MAX`] so it sorts after all curated entries.
226fn priority_index(pkg_id: &str) -> usize {
227    PRIORITY_PACKAGES
228        .iter()
229        .position(|&p| p == pkg_id)
230        .unwrap_or(usize::MAX)
231}
232
233async fn fetch_popular_mcp_servers_from_registry(limit: usize) -> Result<Vec<PopularMcpServer>> {
234    use std::collections::{HashMap, HashSet};
235
236    const REGISTRY_URL: &str = "https://registry.modelcontextprotocol.io/v0.1/servers";
237
238    let client = reqwest::Client::builder()
239        .timeout(std::time::Duration::from_secs(12))
240        .user_agent(format!(
241            "Gestura/{} (config window; https://gestura.ai)",
242            env!("CARGO_PKG_VERSION")
243        ))
244        .build()
245        .map_err(AppError::Http)?;
246
247    let mut cursor: Option<String> = None;
248    // Collect ALL passing candidates before sorting — priority ordering requires
249    // the complete candidate set, not just what appears on the first few pages.
250    let mut all_candidates: Vec<PopularMcpServer> = Vec::new();
251
252    for _page in 0..15 {
253        let mut url =
254            reqwest::Url::parse(REGISTRY_URL).map_err(|e| AppError::Mcp(e.to_string()))?;
255        {
256            let mut qp = url.query_pairs_mut();
257            qp.append_pair("version", "latest");
258            qp.append_pair("limit", "100");
259            if let Some(ref c) = cursor {
260                qp.append_pair("cursor", c);
261            }
262        }
263
264        let resp = client.get(url).send().await.map_err(AppError::Http)?;
265
266        if !resp.status().is_success() {
267            return Err(AppError::Mcp(format!(
268                "MCP Registry returned status {}",
269                resp.status()
270            )));
271        }
272
273        let data: serde_json::Value = resp.json().await.map_err(AppError::Http)?;
274
275        let servers = data
276            .get("servers")
277            .and_then(|v| v.as_array())
278            .ok_or_else(|| {
279                AppError::Mcp("MCP Registry response missing 'servers' array".to_string())
280            })?;
281
282        for item in servers {
283            if let Some(candidate) = popular_candidate_from_registry_item(item) {
284                all_candidates.push(candidate);
285            }
286        }
287
288        cursor = data
289            .get("metadata")
290            .and_then(|m| m.get("nextCursor"))
291            .and_then(|v| v.as_str())
292            .map(|s| s.to_string());
293
294        if cursor.is_none() {
295            break;
296        }
297    }
298
299    // Sort: curated-priority index first, then alphabetical tiebreak.
300    all_candidates.sort_by(|a, b| {
301        let pa = priority_index(&a.package_identifier);
302        let pb = priority_index(&b.package_identifier);
303        pa.cmp(&pb)
304            .then_with(|| a.display_name.as_str().cmp(b.display_name.as_str()))
305            .then_with(|| {
306                a.package_identifier
307                    .as_str()
308                    .cmp(b.package_identifier.as_str())
309            })
310    });
311
312    // Deduplicate tool names, then take up to `limit`.
313    let mut out: Vec<PopularMcpServer> = Vec::with_capacity(limit);
314    let mut used_names: HashSet<String> = HashSet::new();
315    let mut name_collision_counts: HashMap<String, usize> = HashMap::new();
316
317    for mut c in all_candidates {
318        if out.len() >= limit {
319            break;
320        }
321        let base = normalize_mcp_server_name(&c.display_name);
322        let mut candidate_name = base.clone();
323        if used_names.contains(&candidate_name) {
324            let n = name_collision_counts.entry(base.clone()).or_insert(1);
325            *n += 1;
326            candidate_name = format!("{}-{}", base, *n);
327        }
328        used_names.insert(candidate_name.clone());
329        c.tool.name = candidate_name;
330        out.push(c);
331    }
332
333    if out.len() != limit {
334        return Err(AppError::Mcp(format!(
335            "MCP Registry filtering produced {} server(s); expected {}.",
336            out.len(),
337            limit
338        )));
339    }
340
341    Ok(out)
342}
343
344/// Resolve the source-repository URL for a registry server object.
345///
346/// Checks the explicit `repository.url` field first.  When that is absent,
347/// attempts to infer the GitHub URL from the well-known server-name convention
348/// `io.github.{org}/{repo}` used by many official MCP servers.
349///
350/// Returns `None` when no URL can be determined — the server is excluded from
351/// add-able tiers that require an open-source signal.
352fn infer_repository_url(server: &serde_json::Value) -> Option<String> {
353    // Prefer the explicit registry field.
354    let explicit = server
355        .get("repository")
356        .and_then(|r| r.get("url"))
357        .and_then(|v| v.as_str())
358        .unwrap_or("")
359        .trim();
360    if !explicit.is_empty() {
361        return Some(explicit.to_string());
362    }
363
364    // Fallback: infer from `io.github.{org}/{repo}` naming convention.
365    // Example: "io.github.redis/mcp-redis" → "https://github.com/redis/mcp-redis"
366    let name = server.get("name").and_then(|v| v.as_str()).unwrap_or("");
367    if let Some(rest) = name.strip_prefix("io.github.")
368        && let Some(slash_pos) = rest.find('/')
369    {
370        let org = &rest[..slash_pos];
371        let repo = &rest[slash_pos + 1..];
372        if !org.is_empty() && !repo.is_empty() {
373            return Some(format!("https://github.com/{org}/{repo}"));
374        }
375    }
376
377    None
378}
379
380/// Build a stdio [`McpServerEntry`] from a single package element in the
381/// registry `packages` array.
382///
383/// Supported `registryType` values:
384///
385/// | Type | Command | Version syntax |
386/// |------|---------|----------------|
387/// | `npm` | `npx -y` | `identifier@version` |
388/// | `pypi` | `uvx` | `identifier==version` |
389///
390/// Returns `None` for unsupported types, non-stdio transports, or missing
391/// required fields.  The returned tuple is `(identifier, entry)` so callers
392/// can sort candidates deterministically by identifier.
393fn build_mcp_entry_for_package(
394    pkg: &serde_json::Value,
395    display_name: &str,
396    version: &str,
397    enabled: bool,
398) -> Option<(String, McpServerEntry)> {
399    let registry_type = pkg.get("registryType").and_then(|v| v.as_str())?;
400    let transport_type = pkg
401        .get("transport")
402        .and_then(|t| t.get("type"))
403        .and_then(|v| v.as_str())?;
404    if transport_type != "stdio" {
405        return None;
406    }
407
408    let identifier = pkg.get("identifier")?.as_str()?.to_string();
409    let (command, args) = match registry_type {
410        "npm" => (
411            "npx".to_string(),
412            vec!["-y".to_string(), format!("{}@{}", identifier, version)],
413        ),
414        "pypi" => (
415            "uvx".to_string(),
416            vec![format!("{}=={}", identifier, version)],
417        ),
418        _ => return None,
419    };
420
421    Some((
422        identifier,
423        McpServerEntry {
424            name: normalize_mcp_server_name(display_name),
425            transport: McpTransportType::Stdio,
426            enabled,
427            command: Some(command),
428            args,
429            env: Default::default(),
430            url: None,
431            headers: Default::default(),
432            scope: McpScope::User,
433            timeout_secs: 30,
434            auto_reconnect: true,
435            session_default_enabled: true,
436        },
437    ))
438}
439
440/// Build an HTTP or SSE [`McpServerEntry`] from a single remote element in the
441/// registry `remotes` array.
442///
443/// Supported `type` values:
444///
445/// | Registry type | Transport |
446/// |---------------|-----------|
447/// | `streamable-http` | [`McpTransportType::Http`] |
448/// | `sse` | [`McpTransportType::Sse`] |
449///
450/// HTTP headers are intentionally **not** pre-populated in the returned entry.
451/// Registry header values contain user-specific placeholder strings such as
452/// `"Bearer {smithery_api_key}"` that are meaningless until the user supplies
453/// real values.  The `headers` map is left empty so the user can fill it in
454/// through the settings panel before enabling the server.
455///
456/// Returns `None` for unsupported transport types or a missing/empty URL.
457fn build_mcp_entry_for_remote(
458    remote: &serde_json::Value,
459    display_name: &str,
460    enabled: bool,
461) -> Option<McpServerEntry> {
462    let remote_type = remote.get("type").and_then(|v| v.as_str())?;
463    let transport = match remote_type {
464        "streamable-http" => McpTransportType::Http,
465        "sse" => McpTransportType::Sse,
466        _ => return None,
467    };
468
469    let url = remote
470        .get("url")
471        .and_then(|v| v.as_str())
472        .map(str::trim)
473        .filter(|s| !s.is_empty())?
474        .to_string();
475
476    Some(McpServerEntry {
477        name: normalize_mcp_server_name(display_name),
478        transport,
479        enabled,
480        command: None,
481        args: Vec::new(),
482        env: Default::default(),
483        url: Some(url),
484        headers: Default::default(), // user fills in via settings panel
485        scope: McpScope::User,
486        timeout_secs: 30,
487        auto_reconnect: true,
488        session_default_enabled: true,
489    })
490}
491
492/// Return `true` if any element of a registry `headers` or `environmentVariables`
493/// array declares `isRequired: true`.
494fn has_required_fields(arr: Option<&serde_json::Value>) -> bool {
495    arr.and_then(|v| v.as_array())
496        .map(|items| {
497            items.iter().any(|item| {
498                item.get("isRequired")
499                    .and_then(|v| v.as_bool())
500                    .unwrap_or(false)
501            })
502        })
503        .unwrap_or(false)
504}
505
506/// Attempt to build a [`PopularMcpServer`] from a single registry API item.
507///
508/// Returns `None` if the item does not pass all filter criteria.
509fn popular_candidate_from_registry_item(item: &serde_json::Value) -> Option<PopularMcpServer> {
510    let server = item.get("server")?;
511    let meta = item.get("_meta")?;
512
513    // Require active listing.
514    let official = meta
515        .get("io.modelcontextprotocol.registry/official")
516        .and_then(|v| v.as_object())?;
517    if official.get("status").and_then(|v| v.as_str())? != "active" {
518        return None;
519    }
520
521    // Require a resolvable repository URL (open-source signal).
522    let repository_url = infer_repository_url(server)?;
523
524    // Basic registry fields.
525    let display_name = server.get("name")?.as_str()?.to_string();
526    let description = server
527        .get("description")
528        .and_then(|v| v.as_str())
529        .unwrap_or("")
530        .to_string();
531    let version = server.get("version")?.as_str()?.to_string();
532
533    // Ensure it looks like a server schema entry (current MCP standard signal).
534    let schema_ok = server
535        .get("$schema")
536        .and_then(|v| v.as_str())
537        .map(|s| s.contains("server.schema.json"))
538        .unwrap_or(false);
539    if !schema_ok {
540        return None;
541    }
542
543    // ── Tier 1a: stdio packages (npm / pypi) with no required env vars ──────
544    let mut eligible: Vec<(String, McpServerEntry)> = Vec::new();
545    if let Some(packages) = server.get("packages").and_then(|v| v.as_array()) {
546        for pkg in packages {
547            // Allow optional env vars; reject only when isRequired: true.
548            if has_required_fields(pkg.get("environmentVariables")) {
549                continue;
550            }
551            if let Some(entry) = build_mcp_entry_for_package(pkg, &display_name, &version, true) {
552                eligible.push(entry);
553            }
554        }
555    }
556
557    // ── Tier 1b: HTTP / SSE remotes with no required headers (fallback) ──────
558    // Only considered when no qualifying stdio package was found first.
559    if eligible.is_empty()
560        && let Some(remotes) = server.get("remotes").and_then(|v| v.as_array())
561    {
562        for remote in remotes {
563            // Skip remotes that require auth headers — those go to Add (Disabled).
564            if has_required_fields(remote.get("headers")) {
565                continue;
566            }
567            if let Some(entry) = build_mcp_entry_for_remote(remote, &display_name, true) {
568                let key = entry.url.clone().unwrap_or_else(|| display_name.clone());
569                eligible.push((key, entry));
570            }
571        }
572    }
573
574    if eligible.is_empty() {
575        return None;
576    }
577    // Prefer the lexicographically first identifier / URL for determinism.
578    eligible.sort_by(|a, b| a.0.cmp(&b.0));
579    let (package_identifier, tool) = eligible.into_iter().next()?;
580
581    Some(PopularMcpServer {
582        display_name,
583        description,
584        repository_url,
585        package_identifier,
586        version,
587        tool,
588    })
589}
590
591/// Build a [`RegistryBrowseEntry`] from any active registry item.
592///
593/// Unlike [`popular_candidate_from_registry_item`], this accepts servers of any
594/// transport type and any env-var count, showing them all in the browse panel.
595/// Delegates to [`popular_candidate_from_registry_item`] for `quick_add`, and
596/// to [`disabled_candidate_from_registry_item`] for `add_disabled`.
597///
598/// Returns `None` only if the item lacks required fields (name, status active).
599fn browse_entry_from_registry_item(item: &serde_json::Value) -> Option<RegistryBrowseEntry> {
600    let server = item.get("server")?;
601    let meta = item.get("_meta")?;
602
603    // Only show active listings.
604    let official = meta
605        .get("io.modelcontextprotocol.registry/official")
606        .and_then(|v| v.as_object())?;
607    if official.get("status").and_then(|v| v.as_str())? != "active" {
608        return None;
609    }
610
611    let display_name = server.get("name")?.as_str()?.to_string();
612    let description = server
613        .get("description")
614        .and_then(|v| v.as_str())
615        .unwrap_or("")
616        .to_string();
617    let repository_url = infer_repository_url(server).unwrap_or_default();
618    let version = server
619        .get("version")
620        .and_then(|v| v.as_str())
621        .unwrap_or("")
622        .to_string();
623
624    // Tier 1: zero-config Quick Add.
625    let quick_add = popular_candidate_from_registry_item(item).map(|p| p.tool);
626    // Tier 2: needs configuration — only populated when Tier 1 is absent.
627    let add_disabled = if quick_add.is_none() {
628        disabled_candidate_from_registry_item(item)
629    } else {
630        None
631    };
632
633    Some(RegistryBrowseEntry {
634        display_name,
635        description,
636        repository_url,
637        version,
638        quick_add,
639        add_disabled,
640    })
641}
642
643/// Attempt to build a disabled [`McpServerEntry`] from a registry item that
644/// requires at least one configuration secret before it can connect.
645///
646/// Covers two transport families:
647/// * **stdio** (npm / pypi) — requires at least one `isRequired` env var.
648/// * **HTTP / SSE remote** — requires at least one `isRequired` auth header.
649///
650/// The entry is returned with `enabled = false` so it appears in the user's
651/// MCP list awaiting configuration.  Returns `None` for servers that already
652/// qualify for Quick Add (no required secrets) or for unsupported formats.
653fn disabled_candidate_from_registry_item(item: &serde_json::Value) -> Option<McpServerEntry> {
654    let server = item.get("server")?;
655    let meta = item.get("_meta")?;
656
657    // Active + repository (or inferred) + $schema — same gates as quick_add.
658    let official = meta
659        .get("io.modelcontextprotocol.registry/official")
660        .and_then(|v| v.as_object())?;
661    if official.get("status").and_then(|v| v.as_str())? != "active" {
662        return None;
663    }
664    infer_repository_url(server)?; // must be resolvable
665    let schema_ok = server
666        .get("$schema")
667        .and_then(|v| v.as_str())
668        .map(|s| s.contains("server.schema.json"))
669        .unwrap_or(false);
670    if !schema_ok {
671        return None;
672    }
673
674    let display_name = server.get("name")?.as_str()?.to_string();
675    let version = server.get("version")?.as_str()?.to_string();
676
677    // ── Stdio packages (npm / pypi) with at least one required env var ──────
678    let mut eligible: Vec<(String, McpServerEntry)> = Vec::new();
679    if let Some(packages) = server.get("packages").and_then(|v| v.as_array()) {
680        for pkg in packages {
681            // Only qualify packages that have at least one *required* env var —
682            // those are exactly the ones Quick Add rejects.
683            if !has_required_fields(pkg.get("environmentVariables")) {
684                // Zero required env vars → Quick Add handles this; skip here.
685                continue;
686            }
687            if let Some(entry) = build_mcp_entry_for_package(pkg, &display_name, &version, false) {
688                eligible.push(entry);
689            }
690        }
691    }
692
693    // ── HTTP / SSE remotes with at least one required header (fallback) ──────
694    // Only considered when no qualifying stdio package was found first.
695    if eligible.is_empty()
696        && let Some(remotes) = server.get("remotes").and_then(|v| v.as_array())
697    {
698        for remote in remotes {
699            // Only qualify remotes that require auth headers —
700            // those with no required headers qualify for Quick Add instead.
701            if !has_required_fields(remote.get("headers")) {
702                continue;
703            }
704            if let Some(entry) = build_mcp_entry_for_remote(remote, &display_name, false) {
705                let key = entry.url.clone().unwrap_or_else(|| display_name.clone());
706                eligible.push((key, entry));
707            }
708        }
709    }
710
711    if eligible.is_empty() {
712        return None;
713    }
714    eligible.sort_by(|a, b| a.0.cmp(&b.0));
715    Some(eligible.into_iter().next()?.1)
716}
717
718/// Convert an arbitrary registry name into a safe, stable tool identifier.
719///
720/// Lowercases all ASCII letters, keeps alphanumerics and `_`/`-`, and
721/// replaces runs of other characters with a single `-`.  Leading/trailing
722/// dashes are stripped.  Returns `"mcp-server"` if the result would be empty.
723pub fn normalize_mcp_server_name(input: &str) -> String {
724    let mut out = String::new();
725    let mut last_dash = false;
726
727    for ch in input.trim().chars() {
728        let lower = ch.to_ascii_lowercase();
729        let is_ok = lower.is_ascii_alphanumeric() || lower == '_' || lower == '-';
730        if is_ok {
731            out.push(lower);
732            last_dash = false;
733        } else if !last_dash {
734            out.push('-');
735            last_dash = true;
736        }
737    }
738
739    let out = out.trim_matches('-').to_string();
740    if out.is_empty() {
741        "mcp-server".to_string()
742    } else {
743        out
744    }
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    #[test]
752    fn popular_candidate_filters_out_required_env_var_servers() {
753        let json = serde_json::json!({
754            "servers": [
755                // [0] Blocked: has a required env var → cannot quick-add.
756                {
757                    "server": {
758                        "$schema": "https://modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
759                        "name": "com.example/needs-env",
760                        "description": "Requires env",
761                        "repository": {"url": "https://github.com/example/repo"},
762                        "version": "1.0.0",
763                        "packages": [
764                            {
765                                "registryType": "npm",
766                                "identifier": "@example/needs-env",
767                                "transport": {"type": "stdio"},
768                                "environmentVariables": [{"name": "TOKEN", "isRequired": true}]
769                            }
770                        ]
771                    },
772                    "_meta": {"io.modelcontextprotocol.registry/official": {"status": "active"}}
773                },
774                // [1] Allowed: zero env vars.
775                {
776                    "server": {
777                        "$schema": "https://modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
778                        "name": "com.example/good",
779                        "description": "No config",
780                        "repository": {"url": "https://github.com/example/good"},
781                        "version": "2.3.4",
782                        "packages": [
783                            {
784                                "registryType": "npm",
785                                "identifier": "@example/good",
786                                "transport": {"type": "stdio"},
787                                "environmentVariables": []
788                            }
789                        ]
790                    },
791                    "_meta": {"io.modelcontextprotocol.registry/official": {"status": "active"}}
792                },
793                // [2] Allowed: declares optional env vars only (isRequired absent/false).
794                {
795                    "server": {
796                        "$schema": "https://modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
797                        "name": "com.example/optional-env",
798                        "description": "Optional API key for enhanced rate limits",
799                        "repository": {"url": "https://github.com/example/optional-env"},
800                        "version": "3.0.0",
801                        "packages": [
802                            {
803                                "registryType": "npm",
804                                "identifier": "@example/optional-env",
805                                "transport": {"type": "stdio"},
806                                "environmentVariables": [
807                                    {"name": "API_KEY", "isRequired": false},
808                                    {"name": "LOG_LEVEL"}
809                                ]
810                            }
811                        ]
812                    },
813                    "_meta": {"io.modelcontextprotocol.registry/official": {"status": "active"}}
814                }
815            ],
816            "metadata": {"nextCursor": null}
817        });
818
819        let servers = json.get("servers").and_then(|v| v.as_array()).unwrap();
820
821        // Required env var → blocked.
822        let a = popular_candidate_from_registry_item(&servers[0]);
823        assert!(a.is_none(), "server with required env var must not qualify");
824
825        // Zero env vars → allowed.
826        let b = popular_candidate_from_registry_item(&servers[1]).unwrap();
827        assert_eq!(b.display_name, "com.example/good");
828        assert_eq!(b.package_identifier, "@example/good");
829        assert_eq!(b.tool.command.as_deref(), Some("npx"));
830        assert_eq!(b.tool.transport, McpTransportType::Stdio);
831        assert!(
832            b.tool
833                .args
834                .iter()
835                .any(|s| s.contains("@example/good@2.3.4"))
836        );
837
838        // Optional-only env vars → allowed (server works without any config).
839        let c = popular_candidate_from_registry_item(&servers[2]).unwrap();
840        assert_eq!(c.display_name, "com.example/optional-env");
841        assert_eq!(c.package_identifier, "@example/optional-env");
842        assert_eq!(c.tool.command.as_deref(), Some("npx"));
843        // The McpServerEntry env map must be empty — optional vars are not injected.
844        assert!(
845            c.tool.env.is_empty(),
846            "optional env vars must not be pre-populated in the tool entry"
847        );
848        assert!(
849            c.tool
850                .args
851                .iter()
852                .any(|s| s.contains("@example/optional-env@3.0.0"))
853        );
854    }
855
856    #[test]
857    fn disabled_candidate_produced_for_required_env_var_server() {
858        let item = serde_json::json!({
859            "server": {
860                "$schema": "https://modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
861                "name": "com.example/needs-token",
862                "description": "Requires an API token",
863                "repository": {"url": "https://github.com/example/needs-token"},
864                "version": "1.2.0",
865                "packages": [
866                    {
867                        "registryType": "npm",
868                        "identifier": "@example/needs-token",
869                        "transport": {"type": "stdio"},
870                        "environmentVariables": [{"name": "API_TOKEN", "isRequired": true}]
871                    }
872                ]
873            },
874            "_meta": {"io.modelcontextprotocol.registry/official": {"status": "active"}}
875        });
876
877        // quick_add must be absent (has required env var).
878        let quick = popular_candidate_from_registry_item(&item);
879        assert!(
880            quick.is_none(),
881            "required-env server must not qualify for quick_add"
882        );
883
884        // disabled_candidate must be present.
885        let disabled = disabled_candidate_from_registry_item(&item).unwrap();
886        assert!(
887            !disabled.enabled,
888            "add_disabled entry must have enabled=false"
889        );
890        assert!(
891            disabled.env.is_empty(),
892            "env map must be empty — user fills it in via config panel"
893        );
894        assert_eq!(disabled.command.as_deref(), Some("npx"));
895        assert!(
896            disabled
897                .args
898                .iter()
899                .any(|s| s.contains("@example/needs-token@1.2.0"))
900        );
901
902        // browse_entry must set quick_add=None and add_disabled=Some.
903        let entry = browse_entry_from_registry_item(&item).unwrap();
904        assert!(entry.quick_add.is_none());
905        assert!(entry.add_disabled.is_some());
906        assert!(!entry.add_disabled.unwrap().enabled);
907    }
908
909    #[test]
910    fn browse_entry_quick_add_suppresses_add_disabled() {
911        // A server with zero required env vars should get quick_add but NOT add_disabled.
912        let item = serde_json::json!({
913            "server": {
914                "$schema": "https://modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
915                "name": "com.example/zero-config",
916                "description": "No env required",
917                "repository": {"url": "https://github.com/example/zero-config"},
918                "version": "2.0.0",
919                "packages": [
920                    {
921                        "registryType": "npm",
922                        "identifier": "@example/zero-config",
923                        "transport": {"type": "stdio"},
924                        "environmentVariables": []
925                    }
926                ]
927            },
928            "_meta": {"io.modelcontextprotocol.registry/official": {"status": "active"}}
929        });
930
931        let entry = browse_entry_from_registry_item(&item).unwrap();
932        assert!(
933            entry.quick_add.is_some(),
934            "zero-config server must have quick_add"
935        );
936        assert!(
937            entry.add_disabled.is_none(),
938            "add_disabled must not be set when quick_add is Some"
939        );
940    }
941
942    #[test]
943    fn pypi_quick_add_uses_uvx_command() {
944        // A pypi/stdio server with no required env vars must get Quick Add
945        // with `uvx identifier==version` — not npx.
946        let item = serde_json::json!({
947            "server": {
948                "$schema": "https://modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
949                "name": "io.github.example/pypi-server",
950                "description": "A pypi MCP server",
951                "repository": {"url": "https://github.com/example/pypi-server"},
952                "version": "1.0.0",
953                "packages": [
954                    {
955                        "registryType": "pypi",
956                        "identifier": "example-mcp-server",
957                        "transport": {"type": "stdio"},
958                        "environmentVariables": []
959                    }
960                ]
961            },
962            "_meta": {"io.modelcontextprotocol.registry/official": {"status": "active"}}
963        });
964
965        let candidate = popular_candidate_from_registry_item(&item).unwrap();
966        assert_eq!(candidate.tool.command.as_deref(), Some("uvx"));
967        assert!(
968            candidate
969                .tool
970                .args
971                .iter()
972                .any(|s| s == "example-mcp-server==1.0.0"),
973            "uvx arg must be identifier==version, got {:?}",
974            candidate.tool.args
975        );
976        assert!(candidate.tool.enabled);
977        assert_eq!(candidate.package_identifier, "example-mcp-server");
978    }
979
980    #[test]
981    fn pypi_disabled_candidate_uses_uvx_command() {
982        // A pypi/stdio server with a required env var must become add_disabled
983        // with `uvx identifier==version` — not npx.
984        let item = serde_json::json!({
985            "server": {
986                "$schema": "https://modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
987                "name": "io.github.redis/mcp-redis",
988                "description": "Redis MCP server",
989                "version": "0.4.1",
990                "packages": [
991                    {
992                        "registryType": "pypi",
993                        "identifier": "redis-mcp-server",
994                        "transport": {"type": "stdio"},
995                        "environmentVariables": [{"name": "REDIS_URL", "isRequired": true}]
996                    }
997                ]
998            },
999            "_meta": {"io.modelcontextprotocol.registry/official": {"status": "active"}}
1000        });
1001
1002        // Must not qualify for Quick Add (has required env var).
1003        let quick = popular_candidate_from_registry_item(&item);
1004        assert!(
1005            quick.is_none(),
1006            "required-env pypi server must not get quick_add"
1007        );
1008
1009        // Must get an add_disabled entry using uvx.
1010        let disabled = disabled_candidate_from_registry_item(&item).unwrap();
1011        assert!(!disabled.enabled);
1012        assert_eq!(disabled.command.as_deref(), Some("uvx"));
1013        assert!(
1014            disabled.args.iter().any(|s| s == "redis-mcp-server==0.4.1"),
1015            "uvx arg must be identifier==version, got {:?}",
1016            disabled.args
1017        );
1018
1019        // browse_entry must surface it as add_disabled.
1020        let entry = browse_entry_from_registry_item(&item).unwrap();
1021        assert!(entry.quick_add.is_none());
1022        assert!(entry.add_disabled.is_some());
1023        // Repo URL inferred from io.github.redis/mcp-redis name.
1024        assert_eq!(entry.repository_url, "https://github.com/redis/mcp-redis");
1025    }
1026
1027    #[test]
1028    fn infer_repository_url_from_io_github_name() {
1029        let server_with_repo = serde_json::json!({
1030            "name": "io.github.foo/bar",
1031            "repository": {"url": "https://github.com/explicit/repo"}
1032        });
1033        // Explicit field wins.
1034        assert_eq!(
1035            infer_repository_url(&server_with_repo),
1036            Some("https://github.com/explicit/repo".to_string())
1037        );
1038
1039        let server_no_repo = serde_json::json!({"name": "io.github.redis/mcp-redis"});
1040        // Inferred from name.
1041        assert_eq!(
1042            infer_repository_url(&server_no_repo),
1043            Some("https://github.com/redis/mcp-redis".to_string())
1044        );
1045
1046        let server_unknown = serde_json::json!({"name": "com.example/something"});
1047        // No repo field and no io.github.* pattern → None.
1048        assert!(infer_repository_url(&server_unknown).is_none());
1049    }
1050
1051    #[test]
1052    fn normalize_mcp_server_name_is_safe_and_stable() {
1053        assert_eq!(
1054            normalize_mcp_server_name("Com.Example/Thing"),
1055            "com-example-thing"
1056        );
1057        assert_eq!(normalize_mcp_server_name("  "), "mcp-server");
1058    }
1059
1060    #[test]
1061    fn priority_index_curated_packages_sort_before_unknowns() {
1062        // Known curated packages get their 0-based slot.
1063        assert_eq!(priority_index("chrome-devtools-mcp"), 0);
1064        assert_eq!(priority_index("@gitkraken/gk"), 1);
1065        assert_eq!(priority_index("@dollhousemcp/mcp-server"), 19);
1066
1067        // Unknown packages receive usize::MAX and sort last.
1068        assert_eq!(priority_index("some-random-server"), usize::MAX);
1069        assert_eq!(priority_index(""), usize::MAX);
1070
1071        // Curated packages sort before unknown ones.
1072        assert!(priority_index("chrome-devtools-mcp") < priority_index("zzz-unknown"));
1073        assert!(priority_index("@dollhousemcp/mcp-server") < priority_index("zzz-unknown"));
1074    }
1075
1076    #[test]
1077    fn priority_packages_list_has_exactly_20_entries() {
1078        assert_eq!(PRIORITY_PACKAGES.len(), 20);
1079    }
1080
1081    #[test]
1082    fn priority_packages_are_all_distinct() {
1083        let mut seen = std::collections::HashSet::new();
1084        for pkg in PRIORITY_PACKAGES {
1085            assert!(
1086                seen.insert(*pkg),
1087                "duplicate entry in PRIORITY_PACKAGES: {pkg}"
1088            );
1089        }
1090    }
1091
1092    // ── Remote / HTTP / SSE transport tests ──────────────────────────────────
1093
1094    /// A streamable-HTTP remote with no required auth headers should qualify for
1095    /// Quick Add (enabled = true) and produce no Add-Disabled entry.
1096    #[test]
1097    fn remote_http_quick_add_no_required_headers() {
1098        let item = serde_json::json!({
1099            "server": {
1100                "$schema": "https://modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
1101                "name": "io.github.example/open-remote",
1102                "description": "Public HTTP MCP server — no auth required",
1103                "repository": {"url": "https://github.com/example/open-remote"},
1104                "version": "1.0.0",
1105                // No packages array — remote only.
1106                "remotes": [{
1107                    "type": "streamable-http",
1108                    "url": "https://mcp.example.com/server",
1109                    "headers": [
1110                        // Optional header — isRequired absent, treated as false.
1111                        {"name": "X-Trace-Id", "value": "optional-trace", "isRequired": false}
1112                    ]
1113                }]
1114            },
1115            "_meta": {"io.modelcontextprotocol.registry/official": {"status": "active"}}
1116        });
1117
1118        let quick = popular_candidate_from_registry_item(&item);
1119        assert!(
1120            quick.is_some(),
1121            "expected Quick Add candidate for open remote"
1122        );
1123        let tool = quick.unwrap().tool;
1124        assert_eq!(tool.transport, McpTransportType::Http);
1125        assert_eq!(tool.url.as_deref(), Some("https://mcp.example.com/server"));
1126        assert!(tool.command.is_none(), "HTTP entry must have no command");
1127        assert!(tool.headers.is_empty(), "headers must not be pre-populated");
1128        assert!(tool.enabled, "Quick Add must produce enabled = true");
1129
1130        let disabled = disabled_candidate_from_registry_item(&item);
1131        assert!(
1132            disabled.is_none(),
1133            "no required headers → should not produce Add-Disabled"
1134        );
1135    }
1136
1137    /// A streamable-HTTP remote that requires an Authorization bearer token
1138    /// (like Smithery-hosted servers) should produce an Add-Disabled entry and
1139    /// no Quick-Add entry.
1140    #[test]
1141    fn remote_http_disabled_required_header() {
1142        let item = serde_json::json!({
1143            "server": {
1144                "$schema": "https://modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
1145                "name": "ai.smithery/Nekzus-npm-sentinel-mcp",
1146                "description": "npm sentinel via Smithery",
1147                // No repository field — inferred from io.github name pattern via
1148                // the smithery name convention; use explicit repo to keep test simple.
1149                "repository": {"url": "https://github.com/Nekzus/npm-sentinel-mcp"},
1150                "version": "1.0.0",
1151                // No packages array.
1152                "remotes": [{
1153                    "type": "streamable-http",
1154                    "url": "https://server.smithery.ai/@Nekzus/npm-sentinel-mcp/mcp",
1155                    "headers": [{
1156                        "name": "Authorization",
1157                        "value": "Bearer {smithery_api_key}",
1158                        "isRequired": true,
1159                        "isSecret": true
1160                    }]
1161                }]
1162            },
1163            "_meta": {"io.modelcontextprotocol.registry/official": {"status": "active"}}
1164        });
1165
1166        let quick = popular_candidate_from_registry_item(&item);
1167        assert!(
1168            quick.is_none(),
1169            "required auth header → must not qualify for Quick Add"
1170        );
1171
1172        let disabled = disabled_candidate_from_registry_item(&item);
1173        assert!(
1174            disabled.is_some(),
1175            "expected Add-Disabled entry for Smithery server"
1176        );
1177        let entry = disabled.unwrap();
1178        assert_eq!(entry.transport, McpTransportType::Http);
1179        assert_eq!(
1180            entry.url.as_deref(),
1181            Some("https://server.smithery.ai/@Nekzus/npm-sentinel-mcp/mcp")
1182        );
1183        assert!(entry.command.is_none(), "HTTP entry must have no command");
1184        assert!(
1185            entry.headers.is_empty(),
1186            "headers must be left empty for user to fill in"
1187        );
1188        assert!(!entry.enabled, "Add-Disabled must produce enabled = false");
1189    }
1190
1191    /// An SSE remote that requires an auth header should appear as Add-Disabled
1192    /// with McpTransportType::Sse (not Http).
1193    #[test]
1194    fn remote_sse_disabled_required_header() {
1195        let item = serde_json::json!({
1196            "server": {
1197                "$schema": "https://modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
1198                "name": "io.github.example/sse-server",
1199                "description": "SSE-based MCP server",
1200                "repository": {"url": "https://github.com/example/sse-server"},
1201                "version": "0.9.0",
1202                "remotes": [{
1203                    "type": "sse",
1204                    "url": "https://sse.example.com/mcp",
1205                    "headers": [{
1206                        "name": "X-Api-Key",
1207                        "value": "{api_key}",
1208                        "isRequired": true
1209                    }]
1210                }]
1211            },
1212            "_meta": {"io.modelcontextprotocol.registry/official": {"status": "active"}}
1213        });
1214
1215        let quick = popular_candidate_from_registry_item(&item);
1216        assert!(
1217            quick.is_none(),
1218            "required header → not a Quick Add candidate"
1219        );
1220
1221        let disabled = disabled_candidate_from_registry_item(&item);
1222        assert!(
1223            disabled.is_some(),
1224            "expected Add-Disabled entry for SSE server"
1225        );
1226        let entry = disabled.unwrap();
1227        assert_eq!(entry.transport, McpTransportType::Sse);
1228        assert_eq!(entry.url.as_deref(), Some("https://sse.example.com/mcp"));
1229        assert!(entry.command.is_none());
1230        assert!(entry.headers.is_empty());
1231        assert!(!entry.enabled);
1232    }
1233}