reasonkit-core 0.1.8

The Reasoning Engine — Auditable Reasoning for Production AI | Rust-Native | Turn Prompts into Protocols
//! Server Configuration for MCP Daemon
//!
//! Handles loading and parsing of MCP server configurations from:
//! - `~/.config/reasonkit/mcp_servers.json` (user config)
//! - Built-in server definitions (ThinkTools, etc.)
//!
//! # Config File Format
//!
//! ```json
//! [
//!   {
//!     "name": "custom-server",
//!     "command": "/path/to/server",
//!     "args": ["--port", "3000"],
//!     "env": {"API_KEY": "..."},
//!     "timeout_secs": 30,
//!     "auto_reconnect": true,
//!     "max_retries": 3,
//!     "tools": ["tool1", "tool2"]
//!   }
//! ]
//! ```

use crate::error::{Error, Result};
use crate::mcp::McpClientConfig;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use tracing::{debug, info, warn};

/// Server configuration entry from config file
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfigEntry {
    /// Server name identifier
    pub name: String,
    /// Command to execute
    pub command: String,
    /// Command arguments
    #[serde(default)]
    pub args: Vec<String>,
    /// Environment variables
    #[serde(default)]
    pub env: HashMap<String, String>,
    /// Connection timeout in seconds
    #[serde(default = "default_timeout")]
    pub timeout_secs: u64,
    /// Auto-reconnect on disconnect
    #[serde(default)]
    pub auto_reconnect: bool,
    /// Max retry attempts
    #[serde(default = "default_retries")]
    pub max_retries: u32,
    /// Tools provided by this server (for routing)
    #[serde(default)]
    pub tools: Vec<String>,
}

fn default_timeout() -> u64 {
    30
}

fn default_retries() -> u32 {
    3
}

impl From<ServerConfigEntry> for McpClientConfig {
    fn from(entry: ServerConfigEntry) -> Self {
        McpClientConfig {
            name: entry.name,
            command: entry.command,
            args: entry.args,
            env: entry.env,
            timeout_secs: entry.timeout_secs,
            auto_reconnect: entry.auto_reconnect,
            max_retries: entry.max_retries,
        }
    }
}

/// Configuration registry for MCP servers
#[derive(Debug, Clone)]
pub struct ServerRegistry {
    /// Tool name -> Server config mapping
    tool_servers: HashMap<String, ServerConfigEntry>,
    /// Server name -> Server config mapping
    servers: HashMap<String, ServerConfigEntry>,
}

impl ServerRegistry {
    /// Create a new registry with built-in servers
    pub fn new() -> Self {
        let mut registry = Self {
            tool_servers: HashMap::new(),
            servers: HashMap::new(),
        };
        registry.register_builtin_servers();
        registry
    }

    /// Load registry from config file, falling back to built-ins
    pub fn load() -> Result<Self> {
        let mut registry = Self::new();

        // Try to load user config
        if let Some(config_path) = Self::get_config_path() {
            if config_path.exists() {
                match registry.load_from_file(&config_path) {
                    Ok(count) => {
                        info!(path = %config_path.display(), servers = count, "Loaded server config");
                    }
                    Err(e) => {
                        warn!(error = %e, "Failed to load server config, using built-ins only");
                    }
                }
            } else {
                debug!(
                    "No user config found at {}, using built-ins",
                    config_path.display()
                );
            }
        }

        Ok(registry)
    }

    /// Get config file path
    fn get_config_path() -> Option<PathBuf> {
        dirs::config_dir().map(|d| d.join("reasonkit").join("mcp_servers.json"))
    }

    /// Load servers from config file
    fn load_from_file(&mut self, path: &PathBuf) -> Result<usize> {
        let content = std::fs::read_to_string(path)?;
        let entries: Vec<ServerConfigEntry> = serde_json::from_str(&content)
            .map_err(|e| Error::config(format!("Invalid server config: {}", e)))?;

        let count = entries.len();
        for entry in entries {
            self.register_server(entry);
        }

        Ok(count)
    }

    /// Register built-in ReasonKit servers
    fn register_builtin_servers(&mut self) {
        // ThinkTools server (Rust-native)
        let thinktools = ServerConfigEntry {
            name: "reasonkit-thinktools".to_string(),
            command: "rk".to_string(),
            args: vec!["serve-mcp".to_string()],
            env: HashMap::new(),
            timeout_secs: 30,
            auto_reconnect: false,
            max_retries: 1,
            tools: vec![
                "gigathink".to_string(),
                "laserlogic".to_string(),
                "bedrock".to_string(),
                "proofguard".to_string(),
                "brutalhonesty".to_string(),
            ],
        };
        self.register_server(thinktools);

        // Sequential thinking server (Rust-native)
        let sequential = ServerConfigEntry {
            name: "reasonkit-sequential".to_string(),
            command: "rk-core".to_string(),
            args: vec!["serve-mcp".to_string()],
            env: HashMap::new(),
            timeout_secs: 30,
            auto_reconnect: false,
            max_retries: 1,
            tools: vec![
                "think".to_string(),
                "analyze".to_string(),
                "reason".to_string(),
                "reflect".to_string(),
            ],
        };
        self.register_server(sequential);

        // Memory server (optional, Rust-native)
        let memory = ServerConfigEntry {
            name: "reasonkit-memory".to_string(),
            command: "rk-mem".to_string(),
            args: vec!["serve".to_string()],
            env: HashMap::new(),
            timeout_secs: 30,
            auto_reconnect: true,
            max_retries: 3,
            tools: vec![
                "store".to_string(),
                "search".to_string(),
                "retrieve".to_string(),
                "forget".to_string(),
            ],
        };
        self.register_server(memory);

        // Web server (optional, Rust-native)
        let web = ServerConfigEntry {
            name: "reasonkit-web".to_string(),
            command: "rk-web".to_string(),
            args: vec!["serve-mcp".to_string()],
            env: HashMap::new(),
            timeout_secs: 60,
            auto_reconnect: true,
            max_retries: 2,
            tools: vec![
                "browse".to_string(),
                "screenshot".to_string(),
                "extract".to_string(),
                "fetch".to_string(),
            ],
        };
        self.register_server(web);
    }

    /// Register a server and its tools
    fn register_server(&mut self, entry: ServerConfigEntry) {
        // Register tool -> server mappings
        for tool in &entry.tools {
            self.tool_servers.insert(tool.clone(), entry.clone());
        }

        // Register server by name
        self.servers.insert(entry.name.clone(), entry);
    }

    /// Get server config for a tool
    pub fn get_server_for_tool(&self, tool_name: &str) -> Option<&ServerConfigEntry> {
        self.tool_servers.get(tool_name)
    }

    /// Get server config by name
    pub fn get_server(&self, server_name: &str) -> Option<&ServerConfigEntry> {
        self.servers.get(server_name)
    }

    /// Get MCP client config for a tool
    pub fn get_client_config(&self, tool_name: &str) -> Option<McpClientConfig> {
        self.get_server_for_tool(tool_name)
            .map(|entry| entry.clone().into())
    }

    /// List all available tools
    pub fn list_tools(&self) -> Vec<&str> {
        self.tool_servers.keys().map(|s| s.as_str()).collect()
    }

    /// List all registered servers
    pub fn list_servers(&self) -> Vec<&str> {
        self.servers.keys().map(|s| s.as_str()).collect()
    }
}

impl Default for ServerRegistry {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_builtin_thinktools() {
        let registry = ServerRegistry::new();

        // All ThinkTools should be registered
        assert!(registry.get_server_for_tool("gigathink").is_some());
        assert!(registry.get_server_for_tool("laserlogic").is_some());
        assert!(registry.get_server_for_tool("bedrock").is_some());
        assert!(registry.get_server_for_tool("proofguard").is_some());
        assert!(registry.get_server_for_tool("brutalhonesty").is_some());
    }

    #[test]
    fn test_builtin_sequential() {
        let registry = ServerRegistry::new();

        assert!(registry.get_server_for_tool("think").is_some());
        assert!(registry.get_server_for_tool("analyze").is_some());
        assert!(registry.get_server_for_tool("reason").is_some());
    }

    #[test]
    fn test_unknown_tool() {
        let registry = ServerRegistry::new();
        assert!(registry.get_server_for_tool("unknown_tool").is_none());
    }

    #[test]
    fn test_client_config_conversion() {
        let registry = ServerRegistry::new();
        let config = registry.get_client_config("gigathink").unwrap();

        assert_eq!(config.name, "reasonkit-thinktools");
        assert_eq!(config.command, "rk");
        assert_eq!(config.args, vec!["serve-mcp"]);
    }

    #[test]
    fn test_list_tools() {
        let registry = ServerRegistry::new();
        let tools = registry.list_tools();

        assert!(tools.contains(&"gigathink"));
        assert!(tools.contains(&"think"));
        assert!(tools.contains(&"store"));
        assert!(tools.contains(&"browse"));
    }

    #[test]
    fn test_config_entry_serialization() {
        let entry = ServerConfigEntry {
            name: "test".to_string(),
            command: "test-cmd".to_string(),
            args: vec!["--arg".to_string()],
            env: HashMap::new(),
            timeout_secs: 30,
            auto_reconnect: true,
            max_retries: 3,
            tools: vec!["tool1".to_string()],
        };

        let json = serde_json::to_string(&entry).unwrap();
        let parsed: ServerConfigEntry = serde_json::from_str(&json).unwrap();

        assert_eq!(parsed.name, "test");
        assert_eq!(parsed.timeout_secs, 30);
    }

    #[test]
    fn test_config_entry_defaults() {
        let json = r#"{"name": "test", "command": "cmd"}"#;
        let entry: ServerConfigEntry = serde_json::from_str(json).unwrap();

        assert_eq!(entry.timeout_secs, 30); // default
        assert_eq!(entry.max_retries, 3); // default
        assert!(!entry.auto_reconnect); // default false
        assert!(entry.tools.is_empty()); // default empty
    }
}