1use crate::config::{McpScope, McpServerEntry, McpTransportType};
16use crate::error::Result;
17use gestura_core_foundation::error::AppError;
18
19#[derive(serde::Serialize, Debug, Clone)]
24pub struct PopularMcpServer {
25 pub display_name: String,
27 pub description: String,
29 pub repository_url: String,
31 pub package_identifier: String,
33 pub version: String,
35 pub tool: McpServerEntry,
37}
38
39#[derive(serde::Serialize, Debug, Clone)]
56pub struct RegistryBrowseEntry {
57 pub display_name: String,
59 pub description: String,
61 pub repository_url: String,
63 pub version: String,
65 pub quick_add: Option<McpServerEntry>,
70 pub add_disabled: Option<McpServerEntry>,
77}
78
79#[derive(serde::Serialize, Debug, Clone)]
81pub struct RegistryBrowsePage {
82 pub servers: Vec<RegistryBrowseEntry>,
84 pub next_cursor: Option<String>,
86 pub page_count: Option<u64>,
88}
89
90pub async fn list_popular_mcp_servers(limit: usize) -> Result<Vec<PopularMcpServer>> {
104 fetch_popular_mcp_servers_from_registry(limit).await
105}
106
107pub 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
192const PRIORITY_PACKAGES: &[&str] = &[
200 "chrome-devtools-mcp", "@gitkraken/gk", "computer-use-mcp", "defuddle-fetch-mcp-server", "filesystem-mcp", "shell-exec-mcp", "xcodebuildmcp", "docfork", "reddit-mcp-buddy", "@azure/mcp", "@google-cloud/gemini-cloud-assist-mcp", "firebase-tools", "@sveltejs/mcp", "@goreleaser/mcp", "@discourse/mcp", "@alisaitteke/docker-mcp", "mcp-prometheus", "mcp-server-code-runner", "@hypothesi/tauri-mcp-server", "@dollhousemcp/mcp-server", ];
221
222fn 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 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 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 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
344fn infer_repository_url(server: &serde_json::Value) -> Option<String> {
353 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 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
380fn 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
440fn 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(), scope: McpScope::User,
486 timeout_secs: 30,
487 auto_reconnect: true,
488 session_default_enabled: true,
489 })
490}
491
492fn 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
506fn 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 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 let repository_url = infer_repository_url(server)?;
523
524 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 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 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 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 if eligible.is_empty()
560 && let Some(remotes) = server.get("remotes").and_then(|v| v.as_array())
561 {
562 for remote in remotes {
563 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 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
591fn 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 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 let quick_add = popular_candidate_from_registry_item(item).map(|p| p.tool);
626 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
643fn 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 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)?; 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 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 if !has_required_fields(pkg.get("environmentVariables")) {
684 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 if eligible.is_empty()
696 && let Some(remotes) = server.get("remotes").and_then(|v| v.as_array())
697 {
698 for remote in remotes {
699 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
718pub 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 {
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 {
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 {
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 let a = popular_candidate_from_registry_item(&servers[0]);
823 assert!(a.is_none(), "server with required env var must not qualify");
824
825 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 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 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 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 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 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 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 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 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 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 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 let entry = browse_entry_from_registry_item(&item).unwrap();
1021 assert!(entry.quick_add.is_none());
1022 assert!(entry.add_disabled.is_some());
1023 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 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 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 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 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 assert_eq!(priority_index("some-random-server"), usize::MAX);
1069 assert_eq!(priority_index(""), usize::MAX);
1070
1071 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 #[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 "remotes": [{
1107 "type": "streamable-http",
1108 "url": "https://mcp.example.com/server",
1109 "headers": [
1110 {"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 #[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 "repository": {"url": "https://github.com/Nekzus/npm-sentinel-mcp"},
1150 "version": "1.0.0",
1151 "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 #[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}