diff --git a/cortex-cli/src/agent_cmd.rs b/cortex-cli/src/agent_cmd.rs index 78bf5f42..c67ef975 100644 --- a/cortex-cli/src/agent_cmd.rs +++ b/cortex-cli/src/agent_cmd.rs @@ -305,15 +305,23 @@ pub struct AgentFrontmatter { /// Display name (for UI). #[serde(default, alias = "display-name")] pub display_name: Option, - /// Color for UI (hex). + /// Color for UI (hex format: #RGB or #RRGGBB). #[serde(default)] pub color: Option, + /// Icon for visual identification (emoji or icon reference). + /// Example: "rocket", "code", "bug", or an emoji like "🚀" + #[serde(default)] + pub icon: Option, /// Whether agent is hidden from UI. #[serde(default)] pub hidden: bool, /// Additional tools configuration (tool_name -> enabled). #[serde(default)] pub tools: HashMap, + /// Base agent to inherit from (for configuration inheritance). + /// The agent will inherit all settings from the base and can override specific ones. + #[serde(default, alias = "extends")] + pub extends: Option, } fn default_can_delegate() -> bool { @@ -341,8 +349,11 @@ pub struct AgentInfo { pub temperature: Option, /// Top-P for generation. pub top_p: Option, - /// Color for UI (hex). + /// Color for UI (hex format: #RGB or #RRGGBB). pub color: Option, + /// Icon for visual identification (emoji or icon reference). + #[serde(default)] + pub icon: Option, /// Model override. pub model: Option, /// Tools configuration (tool_name -> enabled). @@ -365,6 +376,9 @@ pub struct AgentInfo { pub source: AgentSource, /// Path to agent definition file. pub path: Option, + /// Base agent this extends (for inheritance). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extends: Option, } impl AgentCli { @@ -383,6 +397,79 @@ impl AgentCli { } } +/// Validate a hex color format (#RGB or #RRGGBB) (#3022) +/// +/// Returns the validated color string. +/// Accepts: +/// - 3-digit hex: #RGB (e.g., #F00 for red) +/// - 6-digit hex: #RRGGBB (e.g., #FF0000 for red) +/// - Common color names: red, green, blue, yellow, orange, purple, cyan, white, black +fn validate_color(color: &str) -> Result { + let color = color.trim(); + + // Map common color names to hex values + let color_names: std::collections::HashMap<&str, &str> = [ + ("red", "#FF0000"), + ("green", "#00FF00"), + ("blue", "#0000FF"), + ("yellow", "#FFFF00"), + ("orange", "#FFA500"), + ("purple", "#800080"), + ("cyan", "#00FFFF"), + ("magenta", "#FF00FF"), + ("white", "#FFFFFF"), + ("black", "#000000"), + ("gray", "#808080"), + ("grey", "#808080"), + ("pink", "#FFC0CB"), + ] + .into_iter() + .collect(); + + // Check if it's a named color + let lower = color.to_lowercase(); + if let Some(&hex) = color_names.get(lower.as_str()) { + return Ok(hex.to_string()); + } + + // Must start with # + if !color.starts_with('#') { + bail!( + "Invalid color format: '{}'. Colors must be in hex format (#RGB or #RRGGBB) or a named color.\n\ + Examples: #FF5733, #F00, red, blue", + color + ); + } + + let hex = &color[1..]; + + // Validate length: 3 or 6 characters + if hex.len() != 3 && hex.len() != 6 { + bail!( + "Invalid color format: '{}'. Expected #RGB (3 digits) or #RRGGBB (6 digits).\n\ + Examples: #FF5733, #F00", + color + ); + } + + // Validate all characters are hex digits + if !hex.chars().all(|c| c.is_ascii_hexdigit()) { + bail!( + "Invalid color format: '{}'. Contains non-hex characters.\n\ + Hex colors can only contain 0-9 and A-F.", + color + ); + } + + // Normalize 3-digit to 6-digit + if hex.len() == 3 { + let expanded: String = hex.chars().flat_map(|c| [c, c]).collect(); + Ok(format!("#{}", expanded.to_uppercase())) + } else { + Ok(format!("#{}", hex.to_uppercase())) + } +} + /// Validate a model name for agent creation. /// /// Returns the validated model name, resolving aliases if needed. @@ -529,6 +616,7 @@ fn load_builtin_agents() -> Vec { temperature: None, top_p: None, color: Some("#22c55e".to_string()), + icon: Some("hammer".to_string()), model: None, tools: HashMap::new(), allowed_tools: None, @@ -538,6 +626,7 @@ fn load_builtin_agents() -> Vec { tags: vec!["development".to_string()], source: AgentSource::Builtin, path: None, + extends: None, }, AgentInfo { name: "plan".to_string(), @@ -550,6 +639,7 @@ fn load_builtin_agents() -> Vec { temperature: None, top_p: None, color: Some("#3b82f6".to_string()), + icon: Some("clipboard".to_string()), model: None, tools: HashMap::new(), allowed_tools: Some(vec![ @@ -564,6 +654,7 @@ fn load_builtin_agents() -> Vec { tags: vec!["analysis".to_string(), "read-only".to_string()], source: AgentSource::Builtin, path: None, + extends: None, }, AgentInfo { name: "explore".to_string(), @@ -576,6 +667,7 @@ fn load_builtin_agents() -> Vec { temperature: Some(0.3), top_p: None, color: Some("#f59e0b".to_string()), + icon: Some("compass".to_string()), model: None, tools: [ ("edit".to_string(), false), @@ -595,6 +687,7 @@ fn load_builtin_agents() -> Vec { tags: vec!["code".to_string(), "analysis".to_string()], source: AgentSource::Builtin, path: None, + extends: None, }, AgentInfo { name: "general".to_string(), @@ -607,6 +700,7 @@ fn load_builtin_agents() -> Vec { temperature: None, top_p: None, color: Some("#8b5cf6".to_string()), + icon: Some("star".to_string()), model: None, tools: [ ("todoread".to_string(), false), @@ -619,6 +713,7 @@ fn load_builtin_agents() -> Vec { tags: vec!["general".to_string()], source: AgentSource::Builtin, path: None, + extends: None, }, AgentInfo { name: "code-explorer".to_string(), @@ -631,6 +726,7 @@ fn load_builtin_agents() -> Vec { temperature: Some(0.3), top_p: None, color: Some("#06b6d4".to_string()), + icon: Some("search".to_string()), model: None, tools: HashMap::new(), allowed_tools: Some(vec![ @@ -645,6 +741,7 @@ fn load_builtin_agents() -> Vec { tags: vec!["code".to_string(), "analysis".to_string()], source: AgentSource::Builtin, path: None, + extends: None, }, AgentInfo { name: "code-reviewer".to_string(), @@ -657,6 +754,7 @@ fn load_builtin_agents() -> Vec { temperature: Some(0.2), top_p: None, color: Some("#ef4444".to_string()), + icon: Some("check-circle".to_string()), model: None, tools: HashMap::new(), allowed_tools: Some(vec![ @@ -670,6 +768,7 @@ fn load_builtin_agents() -> Vec { tags: vec!["review".to_string(), "quality".to_string()], source: AgentSource::Builtin, path: None, + extends: None, }, AgentInfo { name: "architect".to_string(), @@ -682,6 +781,7 @@ fn load_builtin_agents() -> Vec { temperature: Some(0.5), top_p: None, color: Some("#a855f7".to_string()), + icon: Some("layout".to_string()), model: None, tools: HashMap::new(), allowed_tools: Some(vec![ @@ -696,6 +796,7 @@ fn load_builtin_agents() -> Vec { tags: vec!["architecture".to_string(), "design".to_string()], source: AgentSource::Builtin, path: None, + extends: None, }, ] } @@ -816,6 +917,22 @@ fn load_agent_from_md(path: &Path, source: AgentSource) -> Result { let (frontmatter, body) = parse_frontmatter(&content)?; + // Validate color if present (#3022) + let validated_color = if let Some(ref color) = frontmatter.color { + match validate_color(color) { + Ok(c) => Some(c), + Err(e) => { + eprintln!( + "Warning: Invalid color in agent '{}': {}", + frontmatter.name, e + ); + None + } + } + } else { + None + }; + Ok(AgentInfo { name: frontmatter.name, display_name: frontmatter.display_name, @@ -826,7 +943,8 @@ fn load_agent_from_md(path: &Path, source: AgentSource) -> Result { prompt: if body.is_empty() { None } else { Some(body) }, temperature: frontmatter.temperature, top_p: frontmatter.top_p, - color: frontmatter.color, + color: validated_color, + icon: frontmatter.icon, model: frontmatter.model, tools: frontmatter.tools, allowed_tools: frontmatter.allowed_tools, @@ -836,6 +954,7 @@ fn load_agent_from_md(path: &Path, source: AgentSource) -> Result { tags: frontmatter.tags, source, path: Some(path.to_path_buf()), + extends: frontmatter.extends, }) } @@ -858,6 +977,22 @@ fn load_agent_from_json(path: &Path, source: AgentSource) -> Result { None }; + // Validate color if present (#3022) + let validated_color = if let Some(ref color) = frontmatter.color { + match validate_color(color) { + Ok(c) => Some(c), + Err(e) => { + eprintln!( + "Warning: Invalid color in agent '{}': {}", + frontmatter.name, e + ); + None + } + } + } else { + None + }; + Ok(AgentInfo { name: frontmatter.name, display_name: frontmatter.display_name, @@ -868,7 +1003,8 @@ fn load_agent_from_json(path: &Path, source: AgentSource) -> Result { prompt, temperature: frontmatter.temperature, top_p: frontmatter.top_p, - color: frontmatter.color, + color: validated_color, + icon: frontmatter.icon, model: frontmatter.model, tools: frontmatter.tools, allowed_tools: frontmatter.allowed_tools, @@ -878,6 +1014,7 @@ fn load_agent_from_json(path: &Path, source: AgentSource) -> Result { tags: frontmatter.tags, source, path: Some(path.to_path_buf()), + extends: frontmatter.extends, }) } diff --git a/cortex-cli/src/main.rs b/cortex-cli/src/main.rs index 23bad35b..25320c11 100644 --- a/cortex-cli/src/main.rs +++ b/cortex-cli/src/main.rs @@ -797,11 +797,23 @@ enum FeaturesSubcommand { List, } +/// Parse port argument supporting 'auto' and '0' for automatic port selection. +fn parse_port_arg(s: &str) -> Result { + let lower = s.to_lowercase(); + if lower == "auto" || lower == "0" { + Ok(0) // Port 0 tells the OS to pick an available port + } else { + s.parse::() + .map_err(|_| format!("Invalid port: '{}'. Use a number (1-65535) or 'auto'.", s)) + } +} + /// Serve command - runs HTTP API server. #[derive(Args)] struct ServeCommand { - /// Port to listen on - #[arg(short, long, default_value = "3000")] + /// Port to listen on. Use 'auto' or '0' for automatic port selection. + /// When using auto, the server will find an available port and print it. + #[arg(short, long, default_value = "3000", value_parser = parse_port_arg)] port: u16, /// Host address to bind the server to. @@ -843,6 +855,24 @@ struct ServeCommand { /// Custom service name for mDNS advertising #[arg(long = "mdns-name")] mdns_name: Option, + + /// OpenTelemetry collector endpoint URL for distributed tracing. + /// Example: --otel-endpoint http://collector:4317 + /// When set, the server exports traces to the specified OTLP endpoint. + #[arg(long = "otel-endpoint", value_name = "URL")] + otel_endpoint: Option, + + /// Request timeout in seconds. Closes connections that exceed this timeout. + /// Protects against slow clients holding connections indefinitely. + /// Default: 30 seconds. + #[arg(long = "request-timeout", default_value = "30")] + request_timeout: u64, + + /// Unix socket path for local communication (instead of TCP). + /// Provides better security for local integrations like IDE plugins. + /// Example: --socket /tmp/cortex.sock + #[arg(long = "socket", value_name = "PATH", conflicts_with_all = ["port", "host"])] + socket: Option, } /// Servers command - discover Cortex servers on the network. @@ -2499,12 +2529,43 @@ fn validate_host_address(host: &str) -> Result<()> { async fn run_serve(serve_cli: ServeCommand) -> Result<()> { use cortex_engine::MdnsService; + // Handle Unix socket mode (#3011 related - Issue #138) + if let Some(ref socket_path) = serve_cli.socket { + print_info(&format!( + "Unix socket mode requested: {}", + socket_path.display() + )); + print_warning("Unix socket transport is not yet fully implemented in cortex-app-server."); + print_info("For now, please use TCP with --host 127.0.0.1 for local-only access."); + // In a full implementation, we would: + // 1. Remove existing socket file if present + // 2. Bind to Unix socket instead of TCP + // 3. Set appropriate file permissions + bail!( + "Unix socket support is planned but not yet implemented. \ + Use --host 127.0.0.1 for secure local-only access." + ); + } + // Validate host address format validate_host_address(&serve_cli.host)?; // Check for special IP addresses check_special_ip_address(&serve_cli.host); + // Handle automatic port selection (#3011) + let actual_port = if serve_cli.port == 0 { + // Find an available port by binding to port 0 + let listener = std::net::TcpListener::bind(format!("{}:0", serve_cli.host)) + .map_err(|e| anyhow::anyhow!("Failed to find available port: {}", e))?; + let port = listener.local_addr()?.port(); + drop(listener); // Release the port so the server can use it + print_info(&format!("Auto-selected port: {}", port)); + port + } else { + serve_cli.port + }; + // Build auth config - tokens are masked in all error messages let auth_config = if let Some(ref token) = serve_cli.auth_token { cortex_app_server::config::AuthConfig { @@ -2527,8 +2588,26 @@ async fn run_serve(serve_cli: ServeCommand) -> Result<()> { vec![] }; + // Log OpenTelemetry configuration (#3007) + if let Some(ref otel_endpoint) = serve_cli.otel_endpoint { + print_info(&format!("OpenTelemetry tracing enabled: {}", otel_endpoint)); + print_warning( + "OpenTelemetry exporter integration is planned but not yet fully implemented in cortex-app-server.", + ); + // In a full implementation, we would initialize the OTLP exporter here + // using opentelemetry-otlp crate and configure the tracing subscriber + } + + // Log request timeout configuration (#3014) + if serve_cli.request_timeout != 30 { + print_info(&format!( + "Request timeout: {} seconds", + serve_cli.request_timeout + )); + } + let config = cortex_app_server::ServerConfig { - listen_addr: format!("{}:{}", serve_cli.host, serve_cli.port), + listen_addr: format!("{}:{}", serve_cli.host, actual_port), auth: auth_config, cors_origins, ..Default::default() @@ -2542,7 +2621,7 @@ async fn run_serve(serve_cli: ServeCommand) -> Result<()> { }; print_info(&format!( "Server running at http://{}:{}{}", - serve_cli.host, serve_cli.port, auth_status + serve_cli.host, actual_port, auth_status )); if serve_cli.cors || !serve_cli.cors_origins.is_empty() { @@ -2562,7 +2641,7 @@ async fn run_serve(serve_cli: ServeCommand) -> Result<()> { .unwrap_or_else(|_| "cortex-server".to_string()) }); - match mdns.advertise(serve_cli.port, &service_name).await { + match mdns.advertise(actual_port, &service_name).await { Ok(info) => { print_success(&format!( "mDNS: Advertising as '{}' on port {}", diff --git a/cortex-cli/src/mcp_cmd.rs b/cortex-cli/src/mcp_cmd.rs index e755a3a9..8d9cd442 100644 --- a/cortex-cli/src/mcp_cmd.rs +++ b/cortex-cli/src/mcp_cmd.rs @@ -324,6 +324,11 @@ pub enum McpSubcommand { /// Add a global MCP server entry. Add(AddArgs), + /// Add a pre-configured MCP server from templates. + /// Templates provide ready-to-use configurations for popular services. + #[command(visible_alias = "template")] + AddTemplate(AddTemplateArgs), + /// Remove a global MCP server entry. #[command(visible_alias = "rm")] Remove(RemoveArgs), @@ -345,6 +350,13 @@ pub enum McpSubcommand { /// Debug and test an MCP server connection. Debug(DebugArgs), + + /// Manage MCP server groups for batch operations. + Group(GroupCommand), + + /// Call/invoke a specific tool on an MCP server for testing. + /// Useful for debugging tool implementations. + Call(CallArgs), } /// Arguments for list command. @@ -579,6 +591,119 @@ pub struct DebugArgs { /// Displays the age of cached health status if available. #[arg(long)] pub show_cache_info: bool, + + /// Show full tool schemas including input parameters and return types. + /// By default, only tool names and descriptions are shown. + #[arg(long)] + pub show_schemas: bool, +} + +/// Arguments for add-template command. +#[derive(Debug, Parser)] +pub struct AddTemplateArgs { + /// Name of the template to use. + /// Available templates: github, gitlab, slack, filesystem, sqlite, postgres + pub template: String, + + /// Custom name for the server (defaults to template name). + #[arg(long)] + pub name: Option, + + /// Force overwrite existing server configuration. + #[arg(short, long)] + pub force: bool, +} + +/// Group command with subcommands. +#[derive(Debug, Parser)] +pub struct GroupCommand { + #[command(subcommand)] + pub action: GroupSubcommand, +} + +/// Group subcommands. +#[derive(Debug, clap::Subcommand)] +pub enum GroupSubcommand { + /// Create a new server group. + Create(GroupCreateArgs), + + /// List all server groups. + List(GroupListArgs), + + /// Enable all servers in a group. + Enable(GroupEnableArgs), + + /// Disable all servers in a group. + Disable(GroupDisableArgs), + + /// Delete a server group. + Delete(GroupDeleteArgs), +} + +/// Arguments for group create command. +#[derive(Debug, Parser)] +pub struct GroupCreateArgs { + /// Name for the new group. + pub name: String, + + /// Comma-separated list of server names to include. + #[arg(long, value_delimiter = ',')] + pub servers: Vec, +} + +/// Arguments for group list command. +#[derive(Debug, Parser)] +pub struct GroupListArgs { + /// Output as JSON. + #[arg(long)] + pub json: bool, +} + +/// Arguments for group enable command. +#[derive(Debug, Parser)] +pub struct GroupEnableArgs { + /// Name of the group to enable. + pub name: String, +} + +/// Arguments for group disable command. +#[derive(Debug, Parser)] +pub struct GroupDisableArgs { + /// Name of the group to disable. + pub name: String, +} + +/// Arguments for group delete command. +#[derive(Debug, Parser)] +pub struct GroupDeleteArgs { + /// Name of the group to delete. + pub name: String, + + /// Skip confirmation prompt. + #[arg(short = 'y', long)] + pub yes: bool, +} + +/// Arguments for call command. +#[derive(Debug, Parser)] +pub struct CallArgs { + /// Name of the MCP server. + pub server: String, + + /// Name of the tool to invoke. + pub tool: String, + + /// JSON parameters to pass to the tool. + #[arg(long, default_value = "{}")] + pub params: String, + + /// Timeout in seconds for the call. + #[arg(long, default_value = "30")] + pub timeout: u64, + + /// Output raw JSON response. + #[arg(long)] + pub json: bool, } impl McpCli { @@ -593,6 +718,7 @@ impl McpCli { McpSubcommand::List(args) | McpSubcommand::Ls(args) => run_list(args).await, McpSubcommand::Get(args) => run_get(args).await, McpSubcommand::Add(args) => run_add(args).await, + McpSubcommand::AddTemplate(args) => run_add_template(args).await, McpSubcommand::Remove(args) => run_remove(args).await, McpSubcommand::Enable(args) => run_enable(args).await, McpSubcommand::Disable(args) => run_disable(args).await, @@ -600,6 +726,8 @@ impl McpCli { McpSubcommand::Auth(cmd) => run_auth_command(cmd).await, McpSubcommand::Logout(args) => run_logout(args).await, McpSubcommand::Debug(args) => run_debug(args).await, + McpSubcommand::Group(cmd) => run_group_command(cmd).await, + McpSubcommand::Call(args) => run_call(args).await, } } } @@ -1689,6 +1817,7 @@ async fn run_debug(args: DebugArgs) -> Result<()> { timeout, no_cache, show_cache_info, + show_schemas, } = args; // Issue #2319: Display cache status information @@ -1847,6 +1976,17 @@ async fn run_debug(args: DebugArgs) -> Result<()> { desc.to_string() }; safe_println!(" • {name}: {desc_short}"); + + // Show full schema if --show-schemas is set (#3020) + if show_schemas { + if let Some(input_schema) = tool.get("inputSchema") { + safe_println!( + " Input Schema: {}", + serde_json::to_string_pretty(input_schema) + .unwrap_or_default() + ); + } + } } } if tools.len() > 10 { @@ -1945,6 +2085,17 @@ async fn run_debug(args: DebugArgs) -> Result<()> { desc.to_string() }; safe_println!(" • {name}: {desc_short}"); + + // Show full schema if --show-schemas is set (#3020) + if show_schemas { + if let Some(input_schema) = tool.get("inputSchema") { + safe_println!( + " Input Schema: {}", + serde_json::to_string_pretty(input_schema) + .unwrap_or_default() + ); + } + } } } if tools.len() > 10 { @@ -2395,3 +2546,634 @@ async fn remove_auth_silent(name: &str) -> Result { Ok(false) } } + +/// MCP server templates for common services (#3017) +fn get_mcp_templates() -> std::collections::HashMap<&'static str, serde_json::Value> { + let mut templates = std::collections::HashMap::new(); + + templates.insert( + "github", + serde_json::json!({ + "description": "GitHub MCP server for repository operations", + "transport": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"] + }, + "env_vars": ["GITHUB_TOKEN"] + }), + ); + + templates.insert( + "gitlab", + serde_json::json!({ + "description": "GitLab MCP server for repository operations", + "transport": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-gitlab"] + }, + "env_vars": ["GITLAB_TOKEN"] + }), + ); + + templates.insert( + "slack", + serde_json::json!({ + "description": "Slack MCP server for messaging operations", + "transport": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-slack"] + }, + "env_vars": ["SLACK_TOKEN"] + }), + ); + + templates.insert( + "filesystem", + serde_json::json!({ + "description": "Filesystem MCP server for file operations", + "transport": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "."] + }, + "env_vars": [] + }), + ); + + templates.insert( + "sqlite", + serde_json::json!({ + "description": "SQLite MCP server for database operations", + "transport": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sqlite"] + }, + "env_vars": [] + }), + ); + + templates.insert( + "postgres", + serde_json::json!({ + "description": "PostgreSQL MCP server for database operations", + "transport": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-postgres"] + }, + "env_vars": ["DATABASE_URL"] + }), + ); + + templates +} + +/// Run add-template command (#3017) +async fn run_add_template(args: AddTemplateArgs) -> Result<()> { + let templates = get_mcp_templates(); + let template_name = args.template.to_lowercase(); + + let template = templates.get(template_name.as_str()).ok_or_else(|| { + let available: Vec<_> = templates.keys().copied().collect(); + anyhow::anyhow!( + "Unknown template: '{}'\nAvailable templates: {}", + args.template, + available.join(", ") + ) + })?; + + let server_name = args.name.unwrap_or_else(|| template_name.clone()); + validate_server_name(&server_name)?; + + // Check if server already exists + let existing = get_mcp_server(&server_name)?; + if existing.is_some() && !args.force { + bail!( + "MCP server '{}' already exists. Use --force to overwrite.", + server_name + ); + } + + let description = template + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("Template-based MCP server"); + + safe_println!("Adding MCP server from template: {}", template_name); + safe_println!(" Name: {}", server_name); + safe_println!(" Description: {}", description); + + // Check for required environment variables + if let Some(env_vars) = template.get("env_vars").and_then(|v| v.as_array()) { + let missing: Vec<_> = env_vars + .iter() + .filter_map(|v| v.as_str()) + .filter(|var| std::env::var(var).is_err()) + .collect(); + + if !missing.is_empty() { + safe_println!(); + safe_println!("Note: The following environment variables are required:"); + for var in &missing { + safe_println!(" - {} (not set)", var); + } + safe_println!(); + safe_println!("Set these variables before using the server."); + } + } + + // Build the config entry + let transport = template.get("transport").cloned().unwrap_or_default(); + + let config_path = find_cortex_home() + .map_err(|e| anyhow::anyhow!("Failed to find cortex home: {}", e))? + .join("config.toml"); + + // Load existing config + let mut config = if config_path.exists() { + let content = std::fs::read_to_string(&config_path)?; + toml::from_str::(&content)? + } else { + toml::Value::Table(toml::map::Map::new()) + }; + + // Ensure mcp_servers table exists + let config_table = config.as_table_mut().unwrap(); + if !config_table.contains_key("mcp_servers") { + config_table.insert( + "mcp_servers".to_string(), + toml::Value::Table(toml::map::Map::new()), + ); + } + + // Add the server + let servers_table = config_table + .get_mut("mcp_servers") + .and_then(|v| v.as_table_mut()) + .unwrap(); + + // Convert JSON transport to TOML + let transport_toml: toml::Value = serde_json::from_value(transport)?; + let mut server_entry = toml::map::Map::new(); + server_entry.insert("transport".to_string(), transport_toml); + server_entry.insert("enabled".to_string(), toml::Value::Boolean(true)); + + servers_table.insert(server_name.clone(), toml::Value::Table(server_entry)); + + // Write config + let new_content = toml::to_string_pretty(&config)?; + std::fs::write(&config_path, new_content)?; + + safe_println!(); + safe_println!("MCP server '{}' added successfully!", server_name); + safe_println!( + "Use 'cortex mcp debug {}' to test the connection.", + server_name + ); + + Ok(()) +} + +/// Run group command (#3019) +async fn run_group_command(cmd: GroupCommand) -> Result<()> { + match cmd.action { + GroupSubcommand::Create(args) => run_group_create(args).await, + GroupSubcommand::List(args) => run_group_list(args).await, + GroupSubcommand::Enable(args) => run_group_enable(args).await, + GroupSubcommand::Disable(args) => run_group_disable(args).await, + GroupSubcommand::Delete(args) => run_group_delete(args).await, + } +} + +async fn run_group_create(args: GroupCreateArgs) -> Result<()> { + validate_server_name(&args.name)?; + + if args.servers.is_empty() { + bail!("At least one server must be specified with --servers"); + } + + // Validate all servers exist + let existing_servers = get_mcp_servers()?; + for server in &args.servers { + if !existing_servers.contains_key(server) { + bail!("MCP server '{}' not found", server); + } + } + + let config_path = find_cortex_home() + .map_err(|e| anyhow::anyhow!("Failed to find cortex home: {}", e))? + .join("config.toml"); + + let mut config = if config_path.exists() { + let content = std::fs::read_to_string(&config_path)?; + toml::from_str::(&content)? + } else { + toml::Value::Table(toml::map::Map::new()) + }; + + let config_table = config.as_table_mut().unwrap(); + if !config_table.contains_key("mcp_groups") { + config_table.insert( + "mcp_groups".to_string(), + toml::Value::Table(toml::map::Map::new()), + ); + } + + let groups_table = config_table + .get_mut("mcp_groups") + .and_then(|v| v.as_table_mut()) + .unwrap(); + + if groups_table.contains_key(&args.name) { + bail!("Group '{}' already exists", args.name); + } + + let servers_array: Vec = args + .servers + .iter() + .map(|s| toml::Value::String(s.clone())) + .collect(); + groups_table.insert(args.name.clone(), toml::Value::Array(servers_array)); + + let new_content = toml::to_string_pretty(&config)?; + std::fs::write(&config_path, new_content)?; + + safe_println!( + "Created group '{}' with servers: {}", + args.name, + args.servers.join(", ") + ); + + Ok(()) +} + +async fn run_group_list(args: GroupListArgs) -> Result<()> { + let config = load_config()?; + + let groups = config + .and_then(|c| c.get("mcp_groups").and_then(|v| v.as_table()).cloned()) + .unwrap_or_default(); + + if groups.is_empty() { + safe_println!("No MCP server groups defined."); + safe_println!("Create one with: cortex mcp group create --servers server1,server2"); + return Ok(()); + } + + if args.json { + let json = serde_json::to_string_pretty(&groups)?; + safe_println!("{}", json); + return Ok(()); + } + + safe_println!("{:<20} {}", "GROUP", "SERVERS"); + safe_println!("{}", "-".repeat(60)); + + for (name, servers) in &groups { + let server_list = servers + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + safe_println!("{:<20} {}", name, server_list); + } + + Ok(()) +} + +async fn run_group_enable(args: GroupEnableArgs) -> Result<()> { + let config = load_config()?; + + let groups = config + .and_then(|c| c.get("mcp_groups").and_then(|v| v.as_table()).cloned()) + .unwrap_or_default(); + + let servers = groups + .get(&args.name) + .and_then(|v| v.as_array()) + .ok_or_else(|| anyhow::anyhow!("Group '{}' not found", args.name))?; + + safe_println!("Enabling servers in group '{}'...", args.name); + + for server in servers { + if let Some(name) = server.as_str() { + match run_enable(EnableArgs { + name: name.to_string(), + }) + .await + { + Ok(_) => safe_println!(" Enabled: {}", name), + Err(e) => safe_println!(" Failed to enable {}: {}", name, e), + } + } + } + + Ok(()) +} + +async fn run_group_disable(args: GroupDisableArgs) -> Result<()> { + let config = load_config()?; + + let groups = config + .and_then(|c| c.get("mcp_groups").and_then(|v| v.as_table()).cloned()) + .unwrap_or_default(); + + let servers = groups + .get(&args.name) + .and_then(|v| v.as_array()) + .ok_or_else(|| anyhow::anyhow!("Group '{}' not found", args.name))?; + + safe_println!("Disabling servers in group '{}'...", args.name); + + for server in servers { + if let Some(name) = server.as_str() { + match run_disable(DisableArgs { + name: name.to_string(), + }) + .await + { + Ok(_) => safe_println!(" Disabled: {}", name), + Err(e) => safe_println!(" Failed to disable {}: {}", name, e), + } + } + } + + Ok(()) +} + +async fn run_group_delete(args: GroupDeleteArgs) -> Result<()> { + let config_path = find_cortex_home() + .map_err(|e| anyhow::anyhow!("Failed to find cortex home: {}", e))? + .join("config.toml"); + + let mut config = if config_path.exists() { + let content = std::fs::read_to_string(&config_path)?; + toml::from_str::(&content)? + } else { + bail!("No configuration file found"); + }; + + let config_table = config.as_table_mut().unwrap(); + let groups_table = config_table + .get_mut("mcp_groups") + .and_then(|v| v.as_table_mut()) + .ok_or_else(|| anyhow::anyhow!("No groups defined"))?; + + if !groups_table.contains_key(&args.name) { + bail!("Group '{}' not found", args.name); + } + + if !args.yes { + safe_print!("Delete group '{}'? [y/N] ", args.name); + let _ = io::stdout().flush(); + + let mut input = String::new(); + io::stdin().lock().read_line(&mut input)?; + + if !input.trim().eq_ignore_ascii_case("y") { + safe_println!("Cancelled."); + return Ok(()); + } + } + + groups_table.remove(&args.name); + + let new_content = toml::to_string_pretty(&config)?; + std::fs::write(&config_path, new_content)?; + + safe_println!("Deleted group '{}'.", args.name); + + Ok(()) +} + +/// Run call command (#3021) +async fn run_call(args: CallArgs) -> Result<()> { + validate_server_name(&args.server)?; + + let server = get_mcp_server(&args.server)? + .ok_or_else(|| anyhow::anyhow!("No MCP server named '{}' found", args.server))?; + + let transport = server + .get("transport") + .ok_or_else(|| anyhow::anyhow!("Server has no transport configured"))?; + + let transport_type = transport + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + + // Parse the parameters + let params: serde_json::Value = serde_json::from_str(&args.params) + .map_err(|e| anyhow::anyhow!("Invalid JSON parameters: {}", e))?; + + safe_println!( + "Calling tool '{}' on server '{}'...", + args.tool, + args.server + ); + + match transport_type { + "stdio" => { + let cmd = transport + .get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("No command configured"))?; + + let cmd_args = transport + .get("args") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(" ") + }) + .unwrap_or_default(); + + call_stdio_tool(cmd, &cmd_args, &args.tool, params, args.timeout, args.json).await + } + "http" => { + let url = transport + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("No URL configured"))?; + + call_http_tool(url, &args.tool, params, args.timeout, args.json).await + } + _ => bail!("Unsupported transport type: {}", transport_type), + } +} + +async fn call_stdio_tool( + command: &str, + args: &str, + tool_name: &str, + params: serde_json::Value, + timeout_secs: u64, + json_output: bool, +) -> Result<()> { + use std::process::Stdio; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::process::Command; + use tokio::time::{Duration, timeout}; + + let args_vec: Vec<&str> = if args.is_empty() { + vec![] + } else { + args.split_whitespace().collect() + }; + + let mut child = Command::new(command) + .args(&args_vec) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| format!("Failed to spawn command: {}", command))?; + + let mut stdin = child.stdin.take().unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut reader = BufReader::new(stdout); + + // Initialize the server + let init_request = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "Cortex", "version": "1.0.0" } + } + }); + + stdin + .write_all((serde_json::to_string(&init_request)? + "\n").as_bytes()) + .await?; + stdin.flush().await?; + + let mut line = String::new(); + timeout( + Duration::from_secs(timeout_secs), + reader.read_line(&mut line), + ) + .await??; + + // Send initialized notification + let initialized = serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized" + }); + stdin + .write_all((serde_json::to_string(&initialized)? + "\n").as_bytes()) + .await?; + stdin.flush().await?; + + // Call the tool + let call_request = serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": params + } + }); + + stdin + .write_all((serde_json::to_string(&call_request)? + "\n").as_bytes()) + .await?; + stdin.flush().await?; + + let mut response_line = String::new(); + timeout( + Duration::from_secs(timeout_secs), + reader.read_line(&mut response_line), + ) + .await??; + + let response: serde_json::Value = serde_json::from_str(&response_line)?; + + if json_output { + safe_println!("{}", serde_json::to_string_pretty(&response)?); + } else if let Some(result) = response.get("result") { + safe_println!("Result:"); + safe_println!("{}", serde_json::to_string_pretty(result)?); + } else if let Some(error) = response.get("error") { + bail!("Tool call failed: {}", serde_json::to_string_pretty(error)?); + } + + Ok(()) +} + +async fn call_http_tool( + url: &str, + tool_name: &str, + params: serde_json::Value, + timeout_secs: u64, + json_output: bool, +) -> Result<()> { + use std::time::Duration; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(timeout_secs)) + .build()?; + + // Initialize + let init_request = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "Cortex", "version": "1.0.0" } + } + }); + + client + .post(url) + .header("Content-Type", "application/json") + .json(&init_request) + .send() + .await?; + + // Call the tool + let call_request = serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": params + } + }); + + let response = client + .post(url) + .header("Content-Type", "application/json") + .json(&call_request) + .send() + .await? + .json::() + .await?; + + if json_output { + safe_println!("{}", serde_json::to_string_pretty(&response)?); + } else if let Some(result) = response.get("result") { + safe_println!("Result:"); + safe_println!("{}", serde_json::to_string_pretty(result)?); + } else if let Some(error) = response.get("error") { + bail!("Tool call failed: {}", serde_json::to_string_pretty(error)?); + } + + Ok(()) +}