diff --git a/Cargo.lock b/Cargo.lock index 12c3e92e..76c2f2f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1975,9 +1975,11 @@ dependencies = [ "async-trait", "cortex-common", "cortex-mcp-types", + "dirs 6.0.0", "reqwest 0.12.28", "serde", "serde_json", + "tempfile", "tokio", "tracing", "url", diff --git a/cortex-mcp-client/Cargo.toml b/cortex-mcp-client/Cargo.toml index c4805246..758f0d2b 100644 --- a/cortex-mcp-client/Cargo.toml +++ b/cortex-mcp-client/Cargo.toml @@ -23,3 +23,7 @@ anyhow = { workspace = true } tracing = { workspace = true } async-trait = { workspace = true } url = { workspace = true } +dirs = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/cortex-mcp-client/src/lib.rs b/cortex-mcp-client/src/lib.rs index 72f141cc..c324817a 100644 --- a/cortex-mcp-client/src/lib.rs +++ b/cortex-mcp-client/src/lib.rs @@ -1,9 +1,17 @@ //! MCP Client implementation for Cortex +//! +//! This crate provides: +//! - MCP client for connecting to MCP servers +//! - Transport layer implementations (stdio, HTTP) +//! - Tool discovery and execution +//! - Registry browser for discovering and installing MCP servers pub mod client; pub mod discovery; +pub mod registry; pub mod transport; pub use client::McpClient; pub use discovery::ToolDiscovery; +pub use registry::{McpCategory, McpInstaller, McpRegistry, McpRegistryBrowser, McpServerEntry}; pub use transport::Transport; diff --git a/cortex-mcp-client/src/registry/browser.rs b/cortex-mcp-client/src/registry/browser.rs new file mode 100644 index 00000000..63a64fda --- /dev/null +++ b/cortex-mcp-client/src/registry/browser.rs @@ -0,0 +1,646 @@ +//! MCP Registry Browser +//! +//! Interactive browser for discovering and selecting MCP servers from the registry. + +use super::data::{McpCategory, McpRegistry, McpServerEntry}; +use std::fmt; + +// ============================================================================ +// Browser State +// ============================================================================ + +/// Interactive browser state for navigating the MCP registry. +#[derive(Debug, Clone)] +pub struct McpRegistryBrowser { + /// The underlying registry data. + registry: McpRegistry, + /// Current search query (if any). + search_query: Option, + /// Current category filter (if any). + category_filter: Option, + /// Currently selected server index. + selected_index: usize, + /// Cached filtered results. + filtered_results: Vec, +} + +impl McpRegistryBrowser { + /// Create a new browser with the embedded registry. + pub fn new() -> Self { + let registry = McpRegistry::load(); + let filtered_results: Vec = (0..registry.servers.len()).collect(); + + Self { + registry, + search_query: None, + category_filter: None, + selected_index: 0, + filtered_results, + } + } + + /// Create a browser from a custom registry. + pub fn with_registry(registry: McpRegistry) -> Self { + let filtered_results: Vec = (0..registry.servers.len()).collect(); + + Self { + registry, + search_query: None, + category_filter: None, + selected_index: 0, + filtered_results, + } + } + + /// Set the search query and update filtered results. + pub fn set_search(&mut self, query: impl Into) { + let query = query.into(); + self.search_query = if query.is_empty() { None } else { Some(query) }; + self.update_filtered_results(); + self.selected_index = 0; + } + + /// Clear the search query. + pub fn clear_search(&mut self) { + self.search_query = None; + self.update_filtered_results(); + self.selected_index = 0; + } + + /// Set the category filter. + pub fn set_category(&mut self, category: Option) { + self.category_filter = category; + self.update_filtered_results(); + self.selected_index = 0; + } + + /// Cycle to the next category. + pub fn next_category(&mut self) { + let categories = McpCategory::all(); + match self.category_filter { + None => { + self.category_filter = Some(categories[0]); + } + Some(current) => { + let idx = categories.iter().position(|&c| c == current).unwrap_or(0); + if idx + 1 >= categories.len() { + self.category_filter = None; + } else { + self.category_filter = Some(categories[idx + 1]); + } + } + } + self.update_filtered_results(); + self.selected_index = 0; + } + + /// Cycle to the previous category. + pub fn prev_category(&mut self) { + let categories = McpCategory::all(); + match self.category_filter { + None => { + self.category_filter = Some(categories[categories.len() - 1]); + } + Some(current) => { + let idx = categories.iter().position(|&c| c == current).unwrap_or(0); + if idx == 0 { + self.category_filter = None; + } else { + self.category_filter = Some(categories[idx - 1]); + } + } + } + self.update_filtered_results(); + self.selected_index = 0; + } + + /// Update the filtered results based on current search and category. + fn update_filtered_results(&mut self) { + self.filtered_results = self + .registry + .servers + .iter() + .enumerate() + .filter(|(_, server)| { + // Category filter + let category_match = self.category_filter.map_or(true, |c| server.category == c); + + // Search filter + let search_match = self.search_query.as_ref().map_or(true, |q| { + let q = q.to_lowercase(); + server.name.to_lowercase().contains(&q) + || server.description.to_lowercase().contains(&q) + || server.id.to_lowercase().contains(&q) + || server.tags.iter().any(|t| t.to_lowercase().contains(&q)) + }); + + category_match && search_match + }) + .map(|(idx, _)| idx) + .collect(); + } + + /// Move selection up. + pub fn select_up(&mut self) { + if !self.filtered_results.is_empty() && self.selected_index > 0 { + self.selected_index -= 1; + } + } + + /// Move selection down. + pub fn select_down(&mut self) { + if !self.filtered_results.is_empty() + && self.selected_index < self.filtered_results.len() - 1 + { + self.selected_index += 1; + } + } + + /// Move selection to the beginning. + pub fn select_first(&mut self) { + self.selected_index = 0; + } + + /// Move selection to the end. + pub fn select_last(&mut self) { + if !self.filtered_results.is_empty() { + self.selected_index = self.filtered_results.len() - 1; + } + } + + /// Get the currently selected server. + pub fn selected(&self) -> Option<&McpServerEntry> { + self.filtered_results + .get(self.selected_index) + .and_then(|&idx| self.registry.servers.get(idx)) + } + + /// Get the currently selected server's ID. + pub fn selected_id(&self) -> Option<&str> { + self.selected().map(|s| s.id.as_str()) + } + + /// Get the current selection index. + pub fn selected_index(&self) -> usize { + self.selected_index + } + + /// Get the filtered server list. + pub fn filtered(&self) -> Vec<&McpServerEntry> { + self.filtered_results + .iter() + .filter_map(|&idx| self.registry.servers.get(idx)) + .collect() + } + + /// Get the count of filtered results. + pub fn filtered_count(&self) -> usize { + self.filtered_results.len() + } + + /// Get the total count of servers in the registry. + pub fn total_count(&self) -> usize { + self.registry.servers.len() + } + + /// Get the current search query. + pub fn search_query(&self) -> Option<&str> { + self.search_query.as_deref() + } + + /// Get the current category filter. + pub fn category_filter(&self) -> Option { + self.category_filter + } + + /// Get a server by ID. + pub fn get(&self, id: &str) -> Option<&McpServerEntry> { + self.registry.get(id) + } + + /// Get the underlying registry. + pub fn registry(&self) -> &McpRegistry { + &self.registry + } + + /// Get categories with their counts (based on current filters). + pub fn category_counts(&self) -> Vec<(McpCategory, usize)> { + let mut counts = std::collections::HashMap::new(); + + for &idx in &self.filtered_results { + if let Some(server) = self.registry.servers.get(idx) { + *counts.entry(server.category).or_insert(0) += 1; + } + } + + let mut result: Vec<_> = counts.into_iter().collect(); + result.sort_by_key(|(cat, _)| *cat as u8); + result + } + + /// Format the current filter status for display. + pub fn filter_status(&self) -> String { + let mut parts = Vec::new(); + + if let Some(ref query) = self.search_query { + parts.push(format!("Search: \"{}\"", query)); + } + + if let Some(category) = self.category_filter { + parts.push(format!("Category: {}", category.display_name())); + } + + if parts.is_empty() { + format!("Showing all {} servers", self.total_count()) + } else { + format!( + "{} ({}/{} servers)", + parts.join(" | "), + self.filtered_count(), + self.total_count() + ) + } + } +} + +impl Default for McpRegistryBrowser { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================ +// Display Helpers +// ============================================================================ + +/// Browser view item for display. +#[derive(Debug, Clone)] +pub struct BrowserViewItem<'a> { + /// The server entry. + pub server: &'a McpServerEntry, + /// Whether this item is selected. + pub selected: bool, + /// Index in the filtered list. + pub index: usize, +} + +impl<'a> BrowserViewItem<'a> { + /// Format for list display. + pub fn list_line(&self) -> String { + let env_indicator = if self.server.requires_env() { + " [!]" + } else { + "" + }; + + format!( + "{} {} - {}{}", + self.server.category.icon(), + self.server.name, + truncate(&self.server.description, 50), + env_indicator + ) + } + + /// Format detailed view. + pub fn detail_view(&self) -> String { + let mut lines = Vec::new(); + + lines.push(format!( + "{} {}", + self.server.category.icon(), + self.server.name + )); + lines.push(format!("ID: {}", self.server.id)); + lines.push(format!("Category: {}", self.server.category.display_name())); + lines.push(String::new()); + lines.push(self.server.description.clone()); + lines.push(String::new()); + lines.push(format!("Install: {}", self.server.install_command())); + + if !self.server.required_env.is_empty() { + lines.push(String::new()); + lines.push("Required environment variables:".to_string()); + for env in &self.server.required_env { + lines.push(format!(" - {}", env)); + } + } + + if let Some(ref homepage) = self.server.homepage { + lines.push(String::new()); + lines.push(format!("Homepage: {}", homepage)); + } + + if !self.server.tags.is_empty() { + lines.push(String::new()); + lines.push(format!("Tags: {}", self.server.tags.join(", "))); + } + + lines.join("\n") + } +} + +/// Iterator over browser view items. +pub struct BrowserViewIterator<'a> { + browser: &'a McpRegistryBrowser, + current: usize, +} + +impl<'a> Iterator for BrowserViewIterator<'a> { + type Item = BrowserViewItem<'a>; + + fn next(&mut self) -> Option { + if self.current >= self.browser.filtered_results.len() { + return None; + } + + let idx = self.browser.filtered_results[self.current]; + let server = self.browser.registry.servers.get(idx)?; + let selected = self.current == self.browser.selected_index; + let item = BrowserViewItem { + server, + selected, + index: self.current, + }; + + self.current += 1; + Some(item) + } +} + +impl McpRegistryBrowser { + /// Get an iterator over view items. + pub fn view_items(&self) -> BrowserViewIterator<'_> { + BrowserViewIterator { + browser: self, + current: 0, + } + } + + /// Get view items for a specific page. + pub fn view_page(&self, page: usize, items_per_page: usize) -> Vec> { + let start = page * items_per_page; + let end = (start + items_per_page).min(self.filtered_results.len()); + + (start..end) + .filter_map(|i| { + let idx = self.filtered_results.get(i)?; + let server = self.registry.servers.get(*idx)?; + let selected = i == self.selected_index; + Some(BrowserViewItem { + server, + selected, + index: i, + }) + }) + .collect() + } + + /// Get the current page number. + pub fn current_page(&self, items_per_page: usize) -> usize { + if items_per_page == 0 { + return 0; + } + self.selected_index / items_per_page + } + + /// Get the total number of pages. + pub fn total_pages(&self, items_per_page: usize) -> usize { + if items_per_page == 0 { + return 1; + } + (self.filtered_results.len() + items_per_page - 1) / items_per_page + } +} + +// ============================================================================ +// CLI Output Formatting +// ============================================================================ + +/// Format for CLI table output. +pub struct CliTableFormatter<'a> { + browser: &'a McpRegistryBrowser, +} + +impl<'a> CliTableFormatter<'a> { + /// Create a new table formatter. + pub fn new(browser: &'a McpRegistryBrowser) -> Self { + Self { browser } + } + + /// Format as a simple table. + pub fn format_simple(&self) -> String { + let mut lines = Vec::new(); + + // Header + lines.push(format!( + "{:<15} {:<20} {:<12} {}", + "ID", "NAME", "CATEGORY", "DESCRIPTION" + )); + lines.push("-".repeat(80)); + + // Rows + for item in self.browser.view_items() { + lines.push(format!( + "{:<15} {:<20} {:<12} {}", + truncate(&item.server.id, 15), + truncate(&item.server.name, 20), + truncate(item.server.category.display_name(), 12), + truncate(&item.server.description, 40) + )); + } + + // Footer + lines.push("-".repeat(80)); + lines.push(self.browser.filter_status()); + + lines.join("\n") + } + + /// Format as detailed list. + pub fn format_detailed(&self) -> String { + let mut lines = Vec::new(); + + for item in self.browser.view_items() { + lines.push(item.detail_view()); + lines.push(String::new()); + lines.push("─".repeat(60)); + lines.push(String::new()); + } + + if lines.is_empty() { + lines.push("No servers found matching the current filters.".to_string()); + } + + lines.join("\n") + } + + /// Format as JSON. + pub fn format_json(&self) -> Result { + let servers: Vec<_> = self.browser.filtered().into_iter().collect(); + serde_json::to_string_pretty(&servers) + } +} + +impl fmt::Display for CliTableFormatter<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.format_simple()) + } +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/// Truncate a string to a maximum length. +fn truncate(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else if max_len <= 3 { + ".".repeat(max_len) + } else { + format!("{}...", &s[..max_len - 3]) + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_browser_new() { + let browser = McpRegistryBrowser::new(); + assert!(browser.total_count() > 0); + assert_eq!(browser.filtered_count(), browser.total_count()); + } + + #[test] + fn test_browser_search() { + let mut browser = McpRegistryBrowser::new(); + browser.set_search("database"); + assert!(browser.filtered_count() > 0); + assert!(browser.filtered_count() < browser.total_count()); + + // All results should contain "database" in name or description + for server in browser.filtered() { + let matches = server.name.to_lowercase().contains("database") + || server.description.to_lowercase().contains("database") + || server + .tags + .iter() + .any(|t| t.to_lowercase().contains("database")); + assert!(matches, "Server {} should match 'database'", server.id); + } + } + + #[test] + fn test_browser_category_filter() { + let mut browser = McpRegistryBrowser::new(); + browser.set_category(Some(McpCategory::Database)); + + assert!(browser.filtered_count() > 0); + for server in browser.filtered() { + assert_eq!(server.category, McpCategory::Database); + } + } + + #[test] + fn test_browser_navigation() { + let mut browser = McpRegistryBrowser::new(); + + // Initial state + assert_eq!(browser.selected_index(), 0); + + // Move down + browser.select_down(); + assert_eq!(browser.selected_index(), 1); + + // Move up + browser.select_up(); + assert_eq!(browser.selected_index(), 0); + + // Can't go above 0 + browser.select_up(); + assert_eq!(browser.selected_index(), 0); + + // Select last + browser.select_last(); + assert_eq!(browser.selected_index(), browser.filtered_count() - 1); + + // Select first + browser.select_first(); + assert_eq!(browser.selected_index(), 0); + } + + #[test] + fn test_browser_category_cycling() { + let mut browser = McpRegistryBrowser::new(); + + assert!(browser.category_filter().is_none()); + + browser.next_category(); + assert!(browser.category_filter().is_some()); + + // Cycle through all categories + for _ in 0..McpCategory::all().len() { + browser.next_category(); + } + // Should be back to None + assert!(browser.category_filter().is_none()); + } + + #[test] + fn test_browser_combined_filters() { + let mut browser = McpRegistryBrowser::new(); + browser.set_category(Some(McpCategory::Database)); + browser.set_search("postgres"); + + for server in browser.filtered() { + assert_eq!(server.category, McpCategory::Database); + let matches = server.name.to_lowercase().contains("postgres") + || server.description.to_lowercase().contains("postgres") + || server.id.to_lowercase().contains("postgres") + || server + .tags + .iter() + .any(|t| t.to_lowercase().contains("postgres")); + assert!(matches, "Server {} should match 'postgres'", server.id); + } + } + + #[test] + fn test_browser_view_items() { + let browser = McpRegistryBrowser::new(); + let items: Vec<_> = browser.view_items().collect(); + + assert_eq!(items.len(), browser.filtered_count()); + assert!(items.iter().filter(|i| i.selected).count() == 1); + } + + #[test] + fn test_truncate() { + assert_eq!(truncate("hello", 10), "hello"); + assert_eq!(truncate("hello world", 8), "hello..."); + assert_eq!(truncate("hi", 2), "hi"); + assert_eq!(truncate("hello", 3), "..."); + } + + #[test] + fn test_cli_formatter() { + let browser = McpRegistryBrowser::new(); + let formatter = CliTableFormatter::new(&browser); + + let output = formatter.format_simple(); + assert!(output.contains("ID")); + assert!(output.contains("NAME")); + assert!(output.contains("CATEGORY")); + + let json = formatter.format_json().unwrap(); + assert!(json.starts_with('[')); + assert!(json.ends_with(']')); + } +} diff --git a/cortex-mcp-client/src/registry/data.rs b/cortex-mcp-client/src/registry/data.rs new file mode 100644 index 00000000..a45b98ff --- /dev/null +++ b/cortex-mcp-client/src/registry/data.rs @@ -0,0 +1,465 @@ +//! MCP Registry Data Structures +//! +//! Core data types for representing MCP server entries in the registry. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +// ============================================================================ +// Registry Data Structures +// ============================================================================ + +/// The MCP server registry containing all available server entries. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpRegistry { + /// Registry schema version. + pub version: String, + /// List of registered MCP servers. + pub servers: Vec, +} + +impl McpRegistry { + /// Load the embedded registry data. + /// + /// This loads the pre-configured servers from the embedded JSON file. + pub fn load() -> Self { + let data = include_str!("registry.json"); + serde_json::from_str(data).expect("Invalid embedded registry data") + } + + /// Filter servers by category. + /// + /// Returns all servers matching the specified category. + pub fn by_category(&self, category: McpCategory) -> Vec<&McpServerEntry> { + self.servers + .iter() + .filter(|s| s.category == category) + .collect() + } + + /// Search servers by name or description. + /// + /// Case-insensitive search that matches against both the name and description fields. + pub fn search(&self, query: &str) -> Vec<&McpServerEntry> { + let query = query.to_lowercase(); + self.servers + .iter() + .filter(|s| { + s.name.to_lowercase().contains(&query) + || s.description.to_lowercase().contains(&query) + || s.id.to_lowercase().contains(&query) + }) + .collect() + } + + /// Search servers by multiple criteria. + /// + /// Supports filtering by category and text query simultaneously. + pub fn search_filtered( + &self, + query: Option<&str>, + category: Option, + ) -> Vec<&McpServerEntry> { + self.servers + .iter() + .filter(|s| { + // Category filter + let category_match = category.map_or(true, |c| s.category == c); + + // Query filter + let query_match = query.map_or(true, |q| { + let q = q.to_lowercase(); + s.name.to_lowercase().contains(&q) + || s.description.to_lowercase().contains(&q) + || s.id.to_lowercase().contains(&q) + }); + + category_match && query_match + }) + .collect() + } + + /// Get a server by ID. + pub fn get(&self, id: &str) -> Option<&McpServerEntry> { + self.servers.iter().find(|s| s.id == id) + } + + /// Get all unique categories with their server counts. + pub fn categories(&self) -> Vec<(McpCategory, usize)> { + let mut counts = std::collections::HashMap::new(); + for server in &self.servers { + *counts.entry(server.category).or_insert(0) += 1; + } + + let mut result: Vec<_> = counts.into_iter().collect(); + result.sort_by_key(|(cat, _)| *cat as u8); + result + } + + /// Get total number of servers in the registry. + pub fn len(&self) -> usize { + self.servers.len() + } + + /// Check if the registry is empty. + pub fn is_empty(&self) -> bool { + self.servers.is_empty() + } +} + +impl Default for McpRegistry { + fn default() -> Self { + Self::load() + } +} + +/// A single MCP server entry in the registry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpServerEntry { + /// Unique identifier for the server. + pub id: String, + /// Human-readable name. + pub name: String, + /// Description of the server's functionality. + pub description: String, + /// Category for grouping related servers. + pub category: McpCategory, + /// Installation method configuration. + pub install: InstallMethod, + /// Optional configuration template for the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub config_template: Option, + /// Required environment variables. + #[serde(default)] + pub required_env: Vec, + /// Optional homepage URL for documentation. + #[serde(skip_serializing_if = "Option::is_none")] + pub homepage: Option, + /// Optional icon identifier. + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + /// Optional list of tags for additional categorization. + #[serde(default)] + pub tags: Vec, +} + +impl McpServerEntry { + /// Check if the server requires any environment variables. + pub fn requires_env(&self) -> bool { + !self.required_env.is_empty() + } + + /// Get the installation command as a displayable string. + pub fn install_command(&self) -> String { + match &self.install { + InstallMethod::Npx { package } => format!("npx -y {}", package), + InstallMethod::Pip { package } => format!("python -m {}", package), + InstallMethod::Docker { image } => format!("docker run -i --rm {}", image), + InstallMethod::Uvx { package } => format!("uvx {}", package), + InstallMethod::Binary { url } => format!("Binary from {}", url), + InstallMethod::Custom { command, args } => { + format!("{} {}", command, args.join(" ")) + } + } + } + + /// Get a formatted list of required environment variables. + pub fn env_requirements(&self) -> String { + if self.required_env.is_empty() { + "None".to_string() + } else { + self.required_env.join(", ") + } + } +} + +/// Categories for MCP servers. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum McpCategory { + /// Development tools (testing, CI/CD, code analysis). + Development, + /// Database and data storage solutions. + Database, + /// Security scanning and vulnerability tools. + Security, + /// Productivity and project management. + Productivity, + /// Communication and messaging platforms. + Communication, + /// Cloud services and infrastructure. + Cloud, + /// AI and machine learning tools. + AI, + /// Web services and APIs. + Web, + /// File and document management. + Files, + /// Utilities and general purpose tools. + Other, +} + +impl McpCategory { + /// Get the display name for the category. + pub fn display_name(&self) -> &'static str { + match self { + Self::Development => "Development", + Self::Database => "Database", + Self::Security => "Security", + Self::Productivity => "Productivity", + Self::Communication => "Communication", + Self::Cloud => "Cloud", + Self::AI => "AI & ML", + Self::Web => "Web", + Self::Files => "Files", + Self::Other => "Other", + } + } + + /// Get the icon for the category. + pub fn icon(&self) -> &'static str { + match self { + Self::Development => "🔧", + Self::Database => "🗃️", + Self::Security => "🔒", + Self::Productivity => "📋", + Self::Communication => "💬", + Self::Cloud => "☁️", + Self::AI => "🤖", + Self::Web => "🌐", + Self::Files => "📁", + Self::Other => "📦", + } + } + + /// Get the short code for the category. + pub fn code(&self) -> &'static str { + match self { + Self::Development => "dev", + Self::Database => "db", + Self::Security => "sec", + Self::Productivity => "prod", + Self::Communication => "comm", + Self::Cloud => "cloud", + Self::AI => "ai", + Self::Web => "web", + Self::Files => "files", + Self::Other => "other", + } + } + + /// Parse a category from a string code. + pub fn from_code(code: &str) -> Option { + match code.to_lowercase().as_str() { + "dev" | "development" => Some(Self::Development), + "db" | "database" => Some(Self::Database), + "sec" | "security" => Some(Self::Security), + "prod" | "productivity" => Some(Self::Productivity), + "comm" | "communication" => Some(Self::Communication), + "cloud" => Some(Self::Cloud), + "ai" | "ml" => Some(Self::AI), + "web" => Some(Self::Web), + "files" | "file" => Some(Self::Files), + "other" => Some(Self::Other), + _ => None, + } + } + + /// Get all category variants. + pub fn all() -> &'static [McpCategory] { + &[ + Self::Development, + Self::Database, + Self::Security, + Self::Productivity, + Self::Communication, + Self::Cloud, + Self::AI, + Self::Web, + Self::Files, + Self::Other, + ] + } +} + +impl fmt::Display for McpCategory { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} {}", self.icon(), self.display_name()) + } +} + +/// Installation method for an MCP server. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum InstallMethod { + /// Install via npx (Node.js package runner). + Npx { + /// NPM package name. + package: String, + }, + /// Install via pip (Python package manager). + Pip { + /// Python package name. + package: String, + }, + /// Run via Docker container. + Docker { + /// Docker image name. + image: String, + }, + /// Install via uvx (Python tool runner). + Uvx { + /// Package name for uvx. + package: String, + }, + /// Download a binary from URL. + Binary { + /// URL to download the binary from. + url: String, + }, + /// Custom command execution. + Custom { + /// Command to execute. + command: String, + /// Arguments for the command. + #[serde(default)] + args: Vec, + }, +} + +impl InstallMethod { + /// Get the command that will be executed for this installation method. + pub fn command(&self) -> &str { + match self { + Self::Npx { .. } => "npx", + Self::Pip { .. } => "python", + Self::Docker { .. } => "docker", + Self::Uvx { .. } => "uvx", + Self::Binary { .. } => "binary", + Self::Custom { command, .. } => command, + } + } + + /// Get the arguments for the installation command. + pub fn args(&self) -> Vec { + match self { + Self::Npx { package } => vec!["-y".to_string(), package.clone()], + Self::Pip { package } => vec!["-m".to_string(), package.clone()], + Self::Docker { image } => vec![ + "run".to_string(), + "-i".to_string(), + "--rm".to_string(), + image.clone(), + ], + Self::Uvx { package } => vec![package.clone()], + Self::Binary { url } => vec![url.clone()], + Self::Custom { args, .. } => args.clone(), + } + } + + /// Get the method type as a string. + pub fn method_type(&self) -> &'static str { + match self { + Self::Npx { .. } => "npx", + Self::Pip { .. } => "pip", + Self::Docker { .. } => "docker", + Self::Uvx { .. } => "uvx", + Self::Binary { .. } => "binary", + Self::Custom { .. } => "custom", + } + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_registry_load() { + let registry = McpRegistry::load(); + assert!(!registry.servers.is_empty(), "Registry should have servers"); + assert!(!registry.version.is_empty(), "Registry should have version"); + } + + #[test] + fn test_registry_search() { + let registry = McpRegistry::load(); + let results = registry.search("database"); + assert!(!results.is_empty(), "Should find database-related servers"); + } + + #[test] + fn test_registry_by_category() { + let registry = McpRegistry::load(); + let dev_servers = registry.by_category(McpCategory::Development); + assert!( + dev_servers + .iter() + .all(|s| s.category == McpCategory::Development), + "All filtered servers should be in Development category" + ); + } + + #[test] + fn test_registry_get_by_id() { + let registry = McpRegistry::load(); + let server = registry.get("filesystem"); + assert!(server.is_some(), "Should find filesystem server"); + } + + #[test] + fn test_category_from_code() { + assert_eq!( + McpCategory::from_code("dev"), + Some(McpCategory::Development) + ); + assert_eq!(McpCategory::from_code("db"), Some(McpCategory::Database)); + assert_eq!(McpCategory::from_code("unknown"), None); + } + + #[test] + fn test_install_method_args() { + let npx = InstallMethod::Npx { + package: "@test/package".to_string(), + }; + let args = npx.args(); + assert_eq!(args, vec!["-y", "@test/package"]); + + let docker = InstallMethod::Docker { + image: "test-image:latest".to_string(), + }; + let args = docker.args(); + assert_eq!(args, vec!["run", "-i", "--rm", "test-image:latest"]); + } + + #[test] + fn test_server_entry_env_requirements() { + let entry = McpServerEntry { + id: "test".to_string(), + name: "Test".to_string(), + description: "Test server".to_string(), + category: McpCategory::Development, + install: InstallMethod::Npx { + package: "test".to_string(), + }, + config_template: None, + required_env: vec!["API_KEY".to_string(), "SECRET".to_string()], + homepage: None, + icon: None, + tags: vec![], + }; + + assert!(entry.requires_env()); + assert_eq!(entry.env_requirements(), "API_KEY, SECRET"); + } + + #[test] + fn test_category_display() { + assert_eq!(McpCategory::Development.display_name(), "Development"); + assert_eq!(McpCategory::Database.icon(), "🗃️"); + assert_eq!(McpCategory::Security.code(), "sec"); + } +} diff --git a/cortex-mcp-client/src/registry/installer.rs b/cortex-mcp-client/src/registry/installer.rs new file mode 100644 index 00000000..85ef291a --- /dev/null +++ b/cortex-mcp-client/src/registry/installer.rs @@ -0,0 +1,604 @@ +//! MCP Server Installer +//! +//! Handles the installation and configuration of MCP servers from the registry. + +use super::data::{InstallMethod, McpServerEntry}; +use anyhow::{Context, Result, anyhow}; +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::path::PathBuf; +use tokio::fs; + +// ============================================================================ +// Installer Configuration +// ============================================================================ + +/// Configuration for the MCP installer. +#[derive(Debug, Clone)] +pub struct InstallerConfig { + /// Path to the MCP configuration file. + pub config_path: PathBuf, + /// Whether to validate environment variables before installation. + pub validate_env: bool, + /// Whether to backup the config file before modifying. + pub backup_config: bool, +} + +impl InstallerConfig { + /// Create a new installer config with the default MCP config path. + pub fn new() -> Self { + Self::with_path(Self::default_config_path()) + } + + /// Create a new installer config with a custom path. + pub fn with_path(config_path: PathBuf) -> Self { + Self { + config_path, + validate_env: true, + backup_config: true, + } + } + + /// Get the default MCP configuration path. + pub fn default_config_path() -> PathBuf { + // Try common locations + if let Some(config_dir) = dirs::config_dir() { + // ~/.config/cortex/mcp.json on Linux/macOS + config_dir.join("cortex").join("mcp.json") + } else if let Some(home) = dirs::home_dir() { + // Fallback to ~/.cortex/mcp.json + home.join(".cortex").join("mcp.json") + } else { + // Last resort + PathBuf::from("mcp.json") + } + } + + /// Disable environment variable validation. + pub fn skip_env_validation(mut self) -> Self { + self.validate_env = false; + self + } + + /// Disable config backup. + pub fn skip_backup(mut self) -> Self { + self.backup_config = false; + self + } +} + +impl Default for InstallerConfig { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================ +// MCP Installer +// ============================================================================ + +/// Installer for MCP servers from the registry. +#[derive(Debug)] +pub struct McpInstaller { + /// Installer configuration. + config: InstallerConfig, +} + +impl McpInstaller { + /// Create a new installer with default configuration. + pub fn new() -> Self { + Self { + config: InstallerConfig::default(), + } + } + + /// Create a new installer with custom configuration. + pub fn with_config(config: InstallerConfig) -> Self { + Self { config } + } + + /// Install an MCP server from a registry entry. + /// + /// This adds the server configuration to the MCP config file. + pub async fn install(&self, entry: &McpServerEntry) -> Result { + // Validate environment variables if enabled + if self.config.validate_env { + self.validate_env(entry)?; + } + + // Generate the server configuration + let server_config = self.generate_config(entry)?; + + // Add to the MCP config file + self.add_to_config(&entry.id, server_config).await?; + + Ok(InstallResult { + server_id: entry.id.clone(), + server_name: entry.name.clone(), + config_path: self.config.config_path.clone(), + missing_env: self.check_missing_env(entry), + }) + } + + /// Uninstall an MCP server by ID. + pub async fn uninstall(&self, server_id: &str) -> Result<()> { + let mut mcp_config = self.load_mcp_config().await?; + + if let Some(servers) = mcp_config.get_mut("mcpServers") { + if let Some(obj) = servers.as_object_mut() { + if obj.remove(server_id).is_none() { + return Err(anyhow!("Server '{}' not found in configuration", server_id)); + } + } + } + + self.save_mcp_config(&mcp_config).await?; + Ok(()) + } + + /// Check if a server is installed. + pub async fn is_installed(&self, server_id: &str) -> Result { + let mcp_config = self.load_mcp_config().await?; + + if let Some(servers) = mcp_config.get("mcpServers") { + if let Some(obj) = servers.as_object() { + return Ok(obj.contains_key(server_id)); + } + } + + Ok(false) + } + + /// Get list of installed server IDs. + pub async fn installed_servers(&self) -> Result> { + let mcp_config = self.load_mcp_config().await?; + + if let Some(servers) = mcp_config.get("mcpServers") { + if let Some(obj) = servers.as_object() { + return Ok(obj.keys().cloned().collect()); + } + } + + Ok(Vec::new()) + } + + /// Validate that all required environment variables are set. + fn validate_env(&self, entry: &McpServerEntry) -> Result<()> { + let missing: Vec<&String> = entry + .required_env + .iter() + .filter(|env| std::env::var(env).is_err()) + .collect(); + + if !missing.is_empty() { + let missing_list: Vec<&str> = missing.iter().map(|s| s.as_str()).collect(); + return Err(anyhow!( + "Missing required environment variables for '{}': {}.\n\ + Please set these before installing:\n{}", + entry.name, + missing_list.join(", "), + missing + .iter() + .map(|e| format!(" export {}=", e)) + .collect::>() + .join("\n") + )); + } + + Ok(()) + } + + /// Check which environment variables are missing (without failing). + fn check_missing_env(&self, entry: &McpServerEntry) -> Vec { + entry + .required_env + .iter() + .filter(|env| std::env::var(env).is_err()) + .cloned() + .collect() + } + + /// Generate the MCP server configuration. + fn generate_config(&self, entry: &McpServerEntry) -> Result { + let mut config = match &entry.install { + InstallMethod::Npx { package } => { + json!({ + "command": "npx", + "args": ["-y", package] + }) + } + InstallMethod::Pip { package } => { + json!({ + "command": "python", + "args": ["-m", package] + }) + } + InstallMethod::Docker { image } => { + json!({ + "command": "docker", + "args": ["run", "-i", "--rm", image] + }) + } + InstallMethod::Uvx { package } => { + json!({ + "command": "uvx", + "args": [package] + }) + } + InstallMethod::Binary { url } => { + // For binary downloads, we'd need to download and install + // For now, return a placeholder config + json!({ + "command": "binary", + "args": [], + "_note": format!("Binary should be downloaded from: {}", url) + }) + } + InstallMethod::Custom { command, args } => { + json!({ + "command": command, + "args": args + }) + } + }; + + // Add config template if present + if let Some(ref template) = entry.config_template { + config["config"] = template.clone(); + } + + // Add environment variable references + if !entry.required_env.is_empty() { + let mut env = serde_json::Map::new(); + for var in &entry.required_env { + env.insert(var.clone(), json!(format!("${{{}}}", var))); + } + config["env"] = Value::Object(env); + } + + Ok(config) + } + + /// Load the MCP configuration file. + async fn load_mcp_config(&self) -> Result { + if self.config.config_path.exists() { + let content = fs::read_to_string(&self.config.config_path) + .await + .context("Failed to read MCP config file")?; + + serde_json::from_str(&content).context("Failed to parse MCP config file") + } else { + // Return default empty config + Ok(json!({ + "mcpServers": {} + })) + } + } + + /// Save the MCP configuration file. + async fn save_mcp_config(&self, config: &Value) -> Result<()> { + // Create parent directories if needed + if let Some(parent) = self.config.config_path.parent() { + fs::create_dir_all(parent) + .await + .context("Failed to create config directory")?; + } + + // Backup existing config if enabled + if self.config.backup_config && self.config.config_path.exists() { + let backup_path = self.config.config_path.with_extension("json.bak"); + fs::copy(&self.config.config_path, &backup_path) + .await + .context("Failed to backup config file")?; + } + + // Write the new config + let content = serde_json::to_string_pretty(config)?; + fs::write(&self.config.config_path, content) + .await + .context("Failed to write MCP config file")?; + + Ok(()) + } + + /// Add a server configuration to the MCP config file. + async fn add_to_config(&self, id: &str, server_config: Value) -> Result<()> { + let mut mcp_config = self.load_mcp_config().await?; + + // Ensure mcpServers object exists + if !mcp_config.get("mcpServers").is_some() { + mcp_config["mcpServers"] = json!({}); + } + + // Add the server + mcp_config["mcpServers"][id] = server_config; + + self.save_mcp_config(&mcp_config).await?; + Ok(()) + } + + /// Update a server configuration. + pub async fn update_config( + &self, + server_id: &str, + updates: HashMap, + ) -> Result<()> { + let mut mcp_config = self.load_mcp_config().await?; + + if let Some(servers) = mcp_config.get_mut("mcpServers") { + if let Some(server) = servers.get_mut(server_id) { + if let Some(obj) = server.as_object_mut() { + for (key, value) in updates { + obj.insert(key, value); + } + } + } else { + return Err(anyhow!("Server '{}' not found", server_id)); + } + } + + self.save_mcp_config(&mcp_config).await?; + Ok(()) + } + + /// Get the configuration path. + pub fn config_path(&self) -> &PathBuf { + &self.config.config_path + } +} + +impl Default for McpInstaller { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================ +// Install Result +// ============================================================================ + +/// Result of an MCP server installation. +#[derive(Debug, Clone)] +pub struct InstallResult { + /// The installed server's ID. + pub server_id: String, + /// The installed server's name. + pub server_name: String, + /// Path to the configuration file. + pub config_path: PathBuf, + /// Environment variables that are not currently set. + pub missing_env: Vec, +} + +impl InstallResult { + /// Check if there are missing environment variables. + pub fn has_missing_env(&self) -> bool { + !self.missing_env.is_empty() + } + + /// Get a user-friendly message about the installation. + pub fn message(&self) -> String { + let mut lines = vec![format!( + "✓ Installed MCP server '{}' ({})", + self.server_name, self.server_id + )]; + + lines.push(format!( + " Configuration saved to: {}", + self.config_path.display() + )); + + if !self.missing_env.is_empty() { + lines.push(String::new()); + lines.push("⚠ Warning: The following environment variables are not set:".to_string()); + for env in &self.missing_env { + lines.push(format!(" - {}", env)); + } + lines.push(String::new()); + lines.push( + " The server may not work correctly until these are configured.".to_string(), + ); + } + + lines.join("\n") + } +} + +// ============================================================================ +// Installation Preview +// ============================================================================ + +/// Preview of what will be installed. +#[derive(Debug, Clone)] +pub struct InstallPreview { + /// Server entry being installed. + pub entry: McpServerEntry, + /// Generated configuration. + pub config: Value, + /// Missing environment variables. + pub missing_env: Vec, + /// Configuration file path. + pub config_path: PathBuf, +} + +impl McpInstaller { + /// Preview an installation without making changes. + pub fn preview(&self, entry: &McpServerEntry) -> Result { + let config = self.generate_config(entry)?; + let missing_env = self.check_missing_env(entry); + + Ok(InstallPreview { + entry: entry.clone(), + config, + missing_env, + config_path: self.config.config_path.clone(), + }) + } +} + +impl InstallPreview { + /// Format as a readable preview. + pub fn format(&self) -> String { + let mut lines = Vec::new(); + + lines.push(format!("Server: {} ({})", self.entry.name, self.entry.id)); + lines.push(format!("Category: {}", self.entry.category)); + lines.push(format!("Install method: {}", self.entry.install_command())); + lines.push(String::new()); + + lines.push("Configuration to be added:".to_string()); + if let Ok(pretty) = serde_json::to_string_pretty(&self.config) { + for line in pretty.lines() { + lines.push(format!(" {}", line)); + } + } + + lines.push(String::new()); + lines.push(format!("Config file: {}", self.config_path.display())); + + if !self.missing_env.is_empty() { + lines.push(String::new()); + lines.push("⚠ Missing environment variables:".to_string()); + for env in &self.missing_env { + lines.push(format!(" - {}", env)); + } + } + + lines.join("\n") + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn test_entry() -> McpServerEntry { + McpServerEntry { + id: "test-server".to_string(), + name: "Test Server".to_string(), + description: "A test MCP server".to_string(), + category: crate::registry::McpCategory::Development, + install: InstallMethod::Npx { + package: "@test/mcp-server".to_string(), + }, + config_template: Some(json!({ "option": true })), + required_env: vec!["TEST_API_KEY".to_string()], + homepage: None, + icon: None, + tags: vec![], + } + } + + #[test] + fn test_generate_config_npx() { + let installer = McpInstaller::new(); + let entry = test_entry(); + let config = installer.generate_config(&entry).unwrap(); + + assert_eq!(config["command"], "npx"); + assert_eq!(config["args"][0], "-y"); + assert_eq!(config["args"][1], "@test/mcp-server"); + assert!(config["config"]["option"].as_bool().unwrap()); + } + + #[test] + fn test_generate_config_docker() { + let installer = McpInstaller::new(); + let entry = McpServerEntry { + id: "docker-test".to_string(), + name: "Docker Test".to_string(), + description: "Test".to_string(), + category: crate::registry::McpCategory::Development, + install: InstallMethod::Docker { + image: "test/image:latest".to_string(), + }, + config_template: None, + required_env: vec![], + homepage: None, + icon: None, + tags: vec![], + }; + + let config = installer.generate_config(&entry).unwrap(); + assert_eq!(config["command"], "docker"); + assert_eq!(config["args"][0], "run"); + assert_eq!(config["args"][3], "test/image:latest"); + } + + #[tokio::test] + async fn test_install_preview() { + let installer = McpInstaller::new(); + let entry = test_entry(); + let preview = installer.preview(&entry).unwrap(); + + assert_eq!(preview.entry.id, "test-server"); + assert!(!preview.missing_env.is_empty()); + } + + #[tokio::test] + async fn test_install_uninstall() { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("mcp.json"); + + let installer = McpInstaller::with_config( + InstallerConfig::with_path(config_path.clone()).skip_env_validation(), + ); + + let entry = test_entry(); + + // Install + let result = installer.install(&entry).await.unwrap(); + assert_eq!(result.server_id, "test-server"); + assert!(config_path.exists()); + + // Check installed + assert!(installer.is_installed("test-server").await.unwrap()); + + // Uninstall + installer.uninstall("test-server").await.unwrap(); + assert!(!installer.is_installed("test-server").await.unwrap()); + } + + #[tokio::test] + async fn test_installed_servers() { + let dir = tempdir().unwrap(); + let config_path = dir.path().join("mcp.json"); + + let installer = McpInstaller::with_config( + InstallerConfig::with_path(config_path).skip_env_validation(), + ); + + // Initially empty + let servers = installer.installed_servers().await.unwrap(); + assert!(servers.is_empty()); + + // Install a server + let entry = test_entry(); + installer.install(&entry).await.unwrap(); + + // Now has one server + let servers = installer.installed_servers().await.unwrap(); + assert_eq!(servers.len(), 1); + assert!(servers.contains(&"test-server".to_string())); + } + + #[test] + fn test_install_result_message() { + let result = InstallResult { + server_id: "test".to_string(), + server_name: "Test Server".to_string(), + config_path: PathBuf::from("/config/mcp.json"), + missing_env: vec!["API_KEY".to_string()], + }; + + let msg = result.message(); + assert!(msg.contains("Test Server")); + assert!(msg.contains("API_KEY")); + assert!(msg.contains("Warning")); + } +} diff --git a/cortex-mcp-client/src/registry/mod.rs b/cortex-mcp-client/src/registry/mod.rs new file mode 100644 index 00000000..fe5c5f13 --- /dev/null +++ b/cortex-mcp-client/src/registry/mod.rs @@ -0,0 +1,12 @@ +//! MCP Registry Browser +//! +//! Provides a comprehensive registry of pre-configured MCP servers for easy discovery +//! and installation. Supports searching by name, description, and category. + +pub mod browser; +pub mod data; +pub mod installer; + +pub use browser::McpRegistryBrowser; +pub use data::{InstallMethod, McpCategory, McpRegistry, McpServerEntry}; +pub use installer::McpInstaller; diff --git a/cortex-mcp-client/src/registry/registry.json b/cortex-mcp-client/src/registry/registry.json new file mode 100644 index 00000000..6a60d69c --- /dev/null +++ b/cortex-mcp-client/src/registry/registry.json @@ -0,0 +1,494 @@ +{ + "version": "1.0.0", + "servers": [ + { + "id": "filesystem", + "name": "Filesystem", + "description": "File system operations: read, write, search, and manage files", + "category": "Files", + "install": { "type": "npx", "package": "@modelcontextprotocol/server-filesystem" }, + "required_env": [], + "homepage": "https://github.com/modelcontextprotocol/servers", + "tags": ["files", "filesystem", "read", "write"] + }, + { + "id": "github", + "name": "GitHub", + "description": "GitHub API integration for repositories, issues, PRs, and actions", + "category": "Development", + "install": { "type": "npx", "package": "@modelcontextprotocol/server-github" }, + "required_env": ["GITHUB_TOKEN"], + "homepage": "https://github.com/modelcontextprotocol/servers", + "tags": ["git", "code", "repository", "version-control"] + }, + { + "id": "gitlab", + "name": "GitLab", + "description": "GitLab API integration for projects, merge requests, and CI/CD", + "category": "Development", + "install": { "type": "npx", "package": "@modelcontextprotocol/server-gitlab" }, + "required_env": ["GITLAB_TOKEN"], + "homepage": "https://gitlab.com", + "tags": ["git", "code", "repository", "ci-cd"] + }, + { + "id": "postgres", + "name": "PostgreSQL", + "description": "PostgreSQL database queries, schema inspection, and management", + "category": "Database", + "install": { "type": "npx", "package": "@modelcontextprotocol/server-postgres" }, + "required_env": ["DATABASE_URL"], + "homepage": "https://github.com/modelcontextprotocol/servers", + "tags": ["sql", "database", "postgresql", "queries"] + }, + { + "id": "sqlite", + "name": "SQLite", + "description": "SQLite database operations for local databases", + "category": "Database", + "install": { "type": "npx", "package": "@modelcontextprotocol/server-sqlite" }, + "required_env": [], + "homepage": "https://github.com/modelcontextprotocol/servers", + "tags": ["sql", "database", "sqlite", "local"] + }, + { + "id": "mysql", + "name": "MySQL", + "description": "MySQL database queries and management", + "category": "Database", + "install": { "type": "npx", "package": "mcp-server-mysql" }, + "required_env": ["MYSQL_URL"], + "tags": ["sql", "database", "mysql", "mariadb"] + }, + { + "id": "mongodb", + "name": "MongoDB", + "description": "MongoDB NoSQL database operations and queries", + "category": "Database", + "install": { "type": "npx", "package": "mcp-server-mongodb" }, + "required_env": ["MONGODB_URI"], + "homepage": "https://mongodb.com", + "tags": ["nosql", "database", "mongodb", "documents"] + }, + { + "id": "redis", + "name": "Redis", + "description": "Redis in-memory data store operations", + "category": "Database", + "install": { "type": "npx", "package": "mcp-server-redis" }, + "required_env": ["REDIS_URL"], + "homepage": "https://redis.io", + "tags": ["cache", "database", "redis", "key-value"] + }, + { + "id": "supabase", + "name": "Supabase", + "description": "Supabase database, auth, and storage operations", + "category": "Database", + "install": { "type": "npx", "package": "supabase-mcp" }, + "required_env": ["SUPABASE_URL", "SUPABASE_KEY"], + "homepage": "https://supabase.com", + "tags": ["database", "auth", "storage", "postgres"] + }, + { + "id": "neon", + "name": "Neon", + "description": "Neon serverless PostgreSQL database", + "category": "Database", + "install": { "type": "npx", "package": "neon-mcp" }, + "required_env": ["NEON_API_KEY"], + "homepage": "https://neon.tech", + "tags": ["database", "serverless", "postgresql"] + }, + { + "id": "prisma", + "name": "Prisma", + "description": "Prisma ORM for database operations and migrations", + "category": "Database", + "install": { "type": "npx", "package": "prisma-mcp" }, + "required_env": ["DATABASE_URL"], + "homepage": "https://prisma.io", + "tags": ["orm", "database", "migrations", "schema"] + }, + { + "id": "slack", + "name": "Slack", + "description": "Slack workspace messaging, channels, and user management", + "category": "Communication", + "install": { "type": "npx", "package": "@modelcontextprotocol/server-slack" }, + "required_env": ["SLACK_BOT_TOKEN"], + "homepage": "https://slack.com", + "tags": ["messaging", "chat", "workspace", "team"] + }, + { + "id": "discord", + "name": "Discord", + "description": "Discord bot integration for servers and channels", + "category": "Communication", + "install": { "type": "npx", "package": "mcp-server-discord" }, + "required_env": ["DISCORD_BOT_TOKEN"], + "homepage": "https://discord.com", + "tags": ["messaging", "chat", "community", "gaming"] + }, + { + "id": "email", + "name": "Email", + "description": "Email sending and reading via IMAP/SMTP", + "category": "Communication", + "install": { "type": "npx", "package": "mcp-server-email" }, + "required_env": ["EMAIL_HOST", "EMAIL_USER", "EMAIL_PASSWORD"], + "tags": ["email", "smtp", "imap", "messaging"] + }, + { + "id": "linear", + "name": "Linear", + "description": "Linear issue tracking and project management", + "category": "Productivity", + "install": { "type": "npx", "package": "linear-mcp" }, + "required_env": ["LINEAR_API_KEY"], + "homepage": "https://linear.app", + "tags": ["issues", "project-management", "tracking"] + }, + { + "id": "jira", + "name": "Jira", + "description": "Jira issue tracking and agile project management", + "category": "Productivity", + "install": { "type": "npx", "package": "mcp-server-jira" }, + "required_env": ["JIRA_URL", "JIRA_EMAIL", "JIRA_API_TOKEN"], + "homepage": "https://atlassian.com/jira", + "tags": ["issues", "agile", "project-management", "atlassian"] + }, + { + "id": "notion", + "name": "Notion", + "description": "Notion workspace, pages, and database operations", + "category": "Productivity", + "install": { "type": "npx", "package": "notion-mcp" }, + "required_env": ["NOTION_API_KEY"], + "homepage": "https://notion.so", + "tags": ["notes", "documentation", "wiki", "workspace"] + }, + { + "id": "todoist", + "name": "Todoist", + "description": "Todoist task management and to-do lists", + "category": "Productivity", + "install": { "type": "npx", "package": "mcp-server-todoist" }, + "required_env": ["TODOIST_API_TOKEN"], + "homepage": "https://todoist.com", + "tags": ["tasks", "todo", "productivity", "lists"] + }, + { + "id": "google-calendar", + "name": "Google Calendar", + "description": "Google Calendar event management and scheduling", + "category": "Productivity", + "install": { "type": "npx", "package": "mcp-server-google-calendar" }, + "required_env": ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"], + "homepage": "https://calendar.google.com", + "tags": ["calendar", "events", "scheduling", "google"] + }, + { + "id": "google-drive", + "name": "Google Drive", + "description": "Google Drive file storage and document management", + "category": "Files", + "install": { "type": "npx", "package": "mcp-server-google-drive" }, + "required_env": ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"], + "homepage": "https://drive.google.com", + "tags": ["files", "storage", "documents", "google"] + }, + { + "id": "dropbox", + "name": "Dropbox", + "description": "Dropbox file storage and sharing", + "category": "Files", + "install": { "type": "npx", "package": "mcp-server-dropbox" }, + "required_env": ["DROPBOX_ACCESS_TOKEN"], + "homepage": "https://dropbox.com", + "tags": ["files", "storage", "sharing", "sync"] + }, + { + "id": "brave-search", + "name": "Brave Search", + "description": "Brave Search API for web searches", + "category": "Web", + "install": { "type": "npx", "package": "@modelcontextprotocol/server-brave-search" }, + "required_env": ["BRAVE_API_KEY"], + "homepage": "https://search.brave.com", + "tags": ["search", "web", "api", "brave"] + }, + { + "id": "google-search", + "name": "Google Search", + "description": "Google Custom Search API for web searches", + "category": "Web", + "install": { "type": "npx", "package": "mcp-server-google-search" }, + "required_env": ["GOOGLE_API_KEY", "GOOGLE_CX"], + "homepage": "https://google.com", + "tags": ["search", "web", "api", "google"] + }, + { + "id": "fetch", + "name": "Fetch", + "description": "HTTP fetch operations for web requests", + "category": "Web", + "install": { "type": "uvx", "package": "mcp-server-fetch" }, + "required_env": [], + "homepage": "https://github.com/modelcontextprotocol/servers", + "tags": ["http", "fetch", "api", "requests"] + }, + { + "id": "puppeteer", + "name": "Puppeteer", + "description": "Browser automation with Puppeteer for web scraping and testing", + "category": "Web", + "install": { "type": "npx", "package": "@modelcontextprotocol/server-puppeteer" }, + "required_env": [], + "homepage": "https://pptr.dev", + "tags": ["browser", "automation", "testing", "scraping"] + }, + { + "id": "playwright", + "name": "Playwright", + "description": "Browser automation and testing with Playwright", + "category": "Development", + "install": { "type": "npx", "package": "@anthropic/mcp-playwright" }, + "config_template": { "headless": true }, + "required_env": [], + "homepage": "https://playwright.dev", + "tags": ["browser", "testing", "automation", "e2e"] + }, + { + "id": "sentry", + "name": "Sentry", + "description": "Sentry error tracking and performance monitoring", + "category": "Development", + "install": { "type": "npx", "package": "mcp-server-sentry" }, + "required_env": ["SENTRY_AUTH_TOKEN", "SENTRY_ORG"], + "homepage": "https://sentry.io", + "tags": ["errors", "monitoring", "debugging", "apm"] + }, + { + "id": "datadog", + "name": "Datadog", + "description": "Datadog monitoring, metrics, and logging", + "category": "Development", + "install": { "type": "npx", "package": "mcp-server-datadog" }, + "required_env": ["DD_API_KEY", "DD_APP_KEY"], + "homepage": "https://datadoghq.com", + "tags": ["monitoring", "metrics", "logging", "apm"] + }, + { + "id": "snyk", + "name": "Snyk", + "description": "Snyk security vulnerability scanning", + "category": "Security", + "install": { "type": "npx", "package": "snyk-mcp" }, + "required_env": ["SNYK_TOKEN"], + "homepage": "https://snyk.io", + "tags": ["security", "vulnerabilities", "scanning", "dependencies"] + }, + { + "id": "semgrep", + "name": "Semgrep", + "description": "Semgrep static analysis and security scanning", + "category": "Security", + "install": { "type": "pip", "package": "semgrep-mcp" }, + "required_env": [], + "homepage": "https://semgrep.dev", + "tags": ["security", "static-analysis", "code-review", "sast"] + }, + { + "id": "aws", + "name": "AWS", + "description": "Amazon Web Services cloud operations", + "category": "Cloud", + "install": { "type": "npx", "package": "mcp-server-aws" }, + "required_env": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"], + "homepage": "https://aws.amazon.com", + "tags": ["cloud", "aws", "infrastructure", "services"] + }, + { + "id": "azure", + "name": "Azure", + "description": "Microsoft Azure cloud services", + "category": "Cloud", + "install": { "type": "npx", "package": "mcp-server-azure" }, + "required_env": ["AZURE_SUBSCRIPTION_ID", "AZURE_TENANT_ID"], + "homepage": "https://azure.microsoft.com", + "tags": ["cloud", "azure", "microsoft", "infrastructure"] + }, + { + "id": "gcp", + "name": "Google Cloud", + "description": "Google Cloud Platform services", + "category": "Cloud", + "install": { "type": "npx", "package": "mcp-server-gcp" }, + "required_env": ["GOOGLE_APPLICATION_CREDENTIALS"], + "homepage": "https://cloud.google.com", + "tags": ["cloud", "gcp", "google", "infrastructure"] + }, + { + "id": "vercel", + "name": "Vercel", + "description": "Vercel deployment and hosting management", + "category": "Cloud", + "install": { "type": "npx", "package": "vercel-mcp" }, + "required_env": ["VERCEL_TOKEN"], + "homepage": "https://vercel.com", + "tags": ["deployment", "hosting", "serverless", "frontend"] + }, + { + "id": "cloudflare", + "name": "Cloudflare", + "description": "Cloudflare DNS, workers, and CDN management", + "category": "Cloud", + "install": { "type": "npx", "package": "mcp-server-cloudflare" }, + "required_env": ["CLOUDFLARE_API_TOKEN"], + "homepage": "https://cloudflare.com", + "tags": ["cdn", "dns", "workers", "security"] + }, + { + "id": "docker", + "name": "Docker", + "description": "Docker container management and operations", + "category": "Development", + "install": { "type": "npx", "package": "mcp-server-docker" }, + "required_env": [], + "homepage": "https://docker.com", + "tags": ["containers", "docker", "devops", "orchestration"] + }, + { + "id": "kubernetes", + "name": "Kubernetes", + "description": "Kubernetes cluster management and operations", + "category": "Cloud", + "install": { "type": "npx", "package": "mcp-server-kubernetes" }, + "required_env": [], + "homepage": "https://kubernetes.io", + "tags": ["containers", "k8s", "orchestration", "cloud-native"] + }, + { + "id": "memory", + "name": "Memory", + "description": "Persistent memory storage for context retention", + "category": "AI", + "install": { "type": "npx", "package": "@modelcontextprotocol/server-memory" }, + "required_env": [], + "homepage": "https://github.com/modelcontextprotocol/servers", + "tags": ["memory", "context", "persistence", "storage"] + }, + { + "id": "sequential-thinking", + "name": "Sequential Thinking", + "description": "Step-by-step reasoning and chain-of-thought processing", + "category": "AI", + "install": { "type": "npx", "package": "@modelcontextprotocol/server-sequential-thinking" }, + "required_env": [], + "homepage": "https://github.com/modelcontextprotocol/servers", + "tags": ["reasoning", "thinking", "chain-of-thought", "ai"] + }, + { + "id": "time", + "name": "Time", + "description": "Time and timezone utilities", + "category": "Other", + "install": { "type": "uvx", "package": "mcp-server-time" }, + "required_env": [], + "homepage": "https://github.com/modelcontextprotocol/servers", + "tags": ["time", "timezone", "date", "utilities"] + }, + { + "id": "stripe", + "name": "Stripe", + "description": "Stripe payment processing and billing management", + "category": "Cloud", + "install": { "type": "npx", "package": "stripe-mcp" }, + "required_env": ["STRIPE_API_KEY"], + "homepage": "https://stripe.com", + "tags": ["payments", "billing", "subscriptions", "commerce"] + }, + { + "id": "openai", + "name": "OpenAI", + "description": "OpenAI API for GPT models and embeddings", + "category": "AI", + "install": { "type": "npx", "package": "mcp-server-openai" }, + "required_env": ["OPENAI_API_KEY"], + "homepage": "https://openai.com", + "tags": ["ai", "llm", "gpt", "embeddings"] + }, + { + "id": "anthropic", + "name": "Anthropic", + "description": "Anthropic Claude API integration", + "category": "AI", + "install": { "type": "npx", "package": "mcp-server-anthropic" }, + "required_env": ["ANTHROPIC_API_KEY"], + "homepage": "https://anthropic.com", + "tags": ["ai", "llm", "claude", "anthropic"] + }, + { + "id": "braintrust", + "name": "Braintrust", + "description": "Braintrust AI evaluation and monitoring", + "category": "AI", + "install": { "type": "npx", "package": "braintrust-mcp" }, + "required_env": ["BRAINTRUST_API_KEY"], + "homepage": "https://braintrust.dev", + "tags": ["ai", "evaluation", "monitoring", "testing"] + }, + { + "id": "honeycomb", + "name": "Honeycomb", + "description": "Honeycomb observability and debugging", + "category": "Development", + "install": { "type": "npx", "package": "honeycomb-mcp" }, + "required_env": ["HONEYCOMB_API_KEY"], + "homepage": "https://honeycomb.io", + "tags": ["observability", "debugging", "tracing", "monitoring"] + }, + { + "id": "google-maps", + "name": "Google Maps", + "description": "Google Maps API for geocoding and directions", + "category": "Web", + "install": { "type": "npx", "package": "@modelcontextprotocol/server-google-maps" }, + "required_env": ["GOOGLE_MAPS_API_KEY"], + "homepage": "https://developers.google.com/maps", + "tags": ["maps", "geocoding", "directions", "google"] + }, + { + "id": "twilio", + "name": "Twilio", + "description": "Twilio SMS, voice, and communication APIs", + "category": "Communication", + "install": { "type": "npx", "package": "mcp-server-twilio" }, + "required_env": ["TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"], + "homepage": "https://twilio.com", + "tags": ["sms", "voice", "messaging", "communication"] + }, + { + "id": "contentful", + "name": "Contentful", + "description": "Contentful headless CMS content management", + "category": "Web", + "install": { "type": "npx", "package": "mcp-server-contentful" }, + "required_env": ["CONTENTFUL_SPACE_ID", "CONTENTFUL_ACCESS_TOKEN"], + "homepage": "https://contentful.com", + "tags": ["cms", "content", "headless", "api"] + }, + { + "id": "shopify", + "name": "Shopify", + "description": "Shopify e-commerce store management", + "category": "Web", + "install": { "type": "npx", "package": "mcp-server-shopify" }, + "required_env": ["SHOPIFY_STORE_URL", "SHOPIFY_ACCESS_TOKEN"], + "homepage": "https://shopify.com", + "tags": ["ecommerce", "store", "products", "orders"] + } + ] +} diff --git a/cortex-tui/src/commands/executor.rs b/cortex-tui/src/commands/executor.rs index 7edb02c3..c42de5d4 100644 --- a/cortex-tui/src/commands/executor.rs +++ b/cortex-tui/src/commands/executor.rs @@ -158,6 +158,7 @@ impl CommandExecutor { // ============ MCP ============ // All MCP commands redirect to the interactive panel "mcp" => self.cmd_mcp(cmd), + "mcp-registry" | "registry" | "mcp-browse" => self.cmd_mcp_registry(cmd), "mcp-tools" | "tools" | "lt" => CommandResult::OpenModal(ModalType::McpManager), "mcp-auth" | "auth" => CommandResult::OpenModal(ModalType::McpManager), "mcp-reload" | "reload" => CommandResult::OpenModal(ModalType::McpManager), @@ -582,6 +583,74 @@ impl CommandExecutor { CommandResult::OpenModal(ModalType::McpManager) } + /// Handles the /mcp-registry command for browsing and installing MCP servers. + /// + /// Supports: + /// - `/mcp-registry` - Open the MCP registry browser + /// - `/mcp-registry ` - Search the registry + /// - `/mcp-registry --category ` - Filter by category + /// - `/mcp-registry --install ` - Install a server by ID + /// - `/mcp-registry --list` - List all available servers + fn cmd_mcp_registry(&self, cmd: &ParsedCommand) -> CommandResult { + // Parse arguments + let mut search_query: Option = None; + let mut category: Option = None; + let mut install_id: Option = None; + let mut list_mode = false; + + let mut args_iter = cmd.args.iter().peekable(); + while let Some(arg) = args_iter.next() { + match arg.as_str() { + "--category" | "-c" => { + if let Some(cat) = args_iter.next() { + category = Some(cat.clone()); + } + } + "--install" | "-i" => { + if let Some(id) = args_iter.next() { + install_id = Some(id.clone()); + } + } + "--list" | "-l" => { + list_mode = true; + } + _ if !arg.starts_with('-') => { + // Treat non-flag arguments as search query + if search_query.is_none() { + search_query = Some(arg.clone()); + } + } + _ => {} + } + } + + // Build the async command string + if let Some(id) = install_id { + // Direct install mode + CommandResult::Async(format!("mcp:registry:install:{}", id)) + } else if list_mode { + // List mode + if let Some(cat) = category { + CommandResult::Async(format!("mcp:registry:list:category={}", cat)) + } else { + CommandResult::Async("mcp:registry:list".to_string()) + } + } else if let Some(query) = search_query { + // Search mode + if let Some(cat) = category { + CommandResult::Async(format!("mcp:registry:search:{}:category={}", query, cat)) + } else { + CommandResult::Async(format!("mcp:registry:search:{}", query)) + } + } else if let Some(cat) = category { + // Category filter only + CommandResult::Async(format!("mcp:registry:list:category={}", cat)) + } else { + // Default: open the registry browser modal + CommandResult::OpenModal(ModalType::McpRegistry) + } + } + // ========== DEBUG ========== fn cmd_debug(&self, cmd: &ParsedCommand) -> CommandResult { diff --git a/cortex-tui/src/commands/registry.rs b/cortex-tui/src/commands/registry.rs index 34569330..c8505fc2 100644 --- a/cortex-tui/src/commands/registry.rs +++ b/cortex-tui/src/commands/registry.rs @@ -714,6 +714,15 @@ pub fn register_builtin_commands(registry: &mut CommandRegistry) { false, )); + registry.register(CommandDef::new( + "mcp-registry", + &["registry", "mcp-browse"], + "Browse and install MCP servers from the registry", + "/mcp-registry [search] [--category ] [--install ]", + CommandCategory::Mcp, + true, + )); + // NOTE: /mcp-tools, /mcp-auth, /mcp-reload, /mcp-logs are deprecated. // All MCP management is now centralized in the interactive /mcp panel. // These commands are hidden but still work for backwards compatibility. diff --git a/cortex-tui/src/commands/types.rs b/cortex-tui/src/commands/types.rs index 3d411658..aefdb96b 100644 --- a/cortex-tui/src/commands/types.rs +++ b/cortex-tui/src/commands/types.rs @@ -150,6 +150,8 @@ pub enum ModalType { LogLevelPicker, /// MCP Server manager modal McpManager, + /// MCP Registry browser modal for discovering and installing servers + McpRegistry, /// Login modal for device code authentication Login, /// Upgrade modal for self-update @@ -178,6 +180,7 @@ impl ModalType { ModalType::ApprovalPicker => "Approval Mode", ModalType::LogLevelPicker => "Log Level", ModalType::McpManager => "MCP Servers", + ModalType::McpRegistry => "MCP Registry", ModalType::Login => "Login", ModalType::Upgrade => "Upgrade", ModalType::Agents => "Agents",