Skip to main content

aster/mcp/
config_manager.rs

1//! MCP Configuration Manager
2//!
3//! This module implements the configuration manager for MCP servers.
4//! It manages global and project-level configurations, validation,
5//! change notifications, and import/export functionality.
6//!
7//! # Features
8//!
9//! - Load configurations from global (~/.aster/settings.yaml) and project (.aster/settings.yaml) paths
10//! - Merge configurations with project-level taking precedence
11//! - Validate server configurations using defined schema
12//! - Check command existence for stdio servers
13//! - Notify listeners on configuration changes
14//! - Enable/disable individual servers
15//! - Mask sensitive information when exporting
16//! - Backup and restore configurations
17
18use async_trait::async_trait;
19use serde::{Deserialize, Serialize};
20use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23use tokio::sync::{Mutex, RwLock};
24
25use crate::mcp::error::{McpError, McpResult};
26use crate::mcp::types::{
27    ConfigManagerOptions, ConfigScope, McpServerConfig, ServerValidationResult, TransportType,
28    ValidationResult,
29};
30
31/// Configuration change callback type
32pub type ConfigChangeCallback =
33    Arc<dyn Fn(&HashMap<String, McpServerConfig>, Option<&str>) + Send + Sync>;
34
35/// MCP configuration file structure
36#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37pub struct McpConfigFile {
38    /// MCP server configurations
39    #[serde(default, rename = "mcpServers")]
40    pub mcp_servers: HashMap<String, McpServerConfig>,
41}
42
43/// Configuration manager trait
44///
45/// Defines the interface for managing MCP server configurations.
46#[async_trait]
47pub trait ConfigManager: Send + Sync {
48    /// Load configurations from files
49    async fn load(&self) -> McpResult<()>;
50
51    /// Reload configurations
52    async fn reload(&self) -> McpResult<()>;
53
54    /// Get all server configurations (merged)
55    fn get_servers(&self) -> HashMap<String, McpServerConfig>;
56
57    /// Get a single server configuration
58    fn get_server(&self, name: &str) -> Option<McpServerConfig>;
59
60    /// Add a new server configuration
61    async fn add_server(&self, name: &str, config: McpServerConfig) -> McpResult<()>;
62
63    /// Update an existing server configuration
64    async fn update_server(&self, name: &str, config: McpServerConfig) -> McpResult<()>;
65
66    /// Remove a server configuration
67    async fn remove_server(&self, name: &str) -> McpResult<bool>;
68
69    /// Enable a server
70    async fn enable_server(&self, name: &str) -> McpResult<()>;
71
72    /// Disable a server
73    async fn disable_server(&self, name: &str) -> McpResult<()>;
74
75    /// Get enabled servers only
76    fn get_enabled_servers(&self) -> HashMap<String, McpServerConfig>;
77
78    /// Validate a server configuration
79    fn validate(&self, config: &McpServerConfig) -> ValidationResult;
80
81    /// Validate all server configurations
82    fn validate_all(&self) -> Vec<ServerValidationResult>;
83
84    /// Save configurations to file
85    async fn save(&self, scope: ConfigScope) -> McpResult<()>;
86
87    /// Backup current configuration
88    async fn backup(&self) -> McpResult<PathBuf>;
89
90    /// Restore configuration from backup
91    async fn restore(&self, backup_path: &Path) -> McpResult<()>;
92
93    /// Export configuration as JSON string
94    fn export(&self, mask_secrets: bool) -> String;
95
96    /// Import configuration from JSON string
97    async fn import(&self, config_json: &str, scope: ConfigScope) -> McpResult<()>;
98
99    /// Register a callback for configuration changes
100    fn on_change(&self, callback: ConfigChangeCallback) -> Box<dyn FnOnce() + Send>;
101}
102
103/// Configuration change event
104#[derive(Debug, Clone)]
105pub enum ConfigEvent {
106    /// Configuration loaded
107    Loaded,
108    /// Configuration reloaded
109    Reloaded,
110    /// Server added
111    ServerAdded(String),
112    /// Server updated
113    ServerUpdated(String),
114    /// Server removed
115    ServerRemoved(String),
116    /// Server enabled
117    ServerEnabled(String),
118    /// Server disabled
119    ServerDisabled(String),
120}
121
122/// Internal configuration state
123struct ConfigState {
124    /// Global configuration
125    global_config: HashMap<String, McpServerConfig>,
126    /// Project configuration
127    project_config: HashMap<String, McpServerConfig>,
128    /// Merged configuration (project takes precedence)
129    merged_config: HashMap<String, McpServerConfig>,
130}
131
132impl ConfigState {
133    fn new() -> Self {
134        Self {
135            global_config: HashMap::new(),
136            project_config: HashMap::new(),
137            merged_config: HashMap::new(),
138        }
139    }
140
141    /// Merge global and project configs (project takes precedence)
142    fn merge(&mut self) {
143        self.merged_config = merge_configs(&self.global_config, &self.project_config);
144    }
145}
146
147/// Default implementation of the configuration manager
148pub struct McpConfigManager {
149    /// Configuration state
150    state: Arc<RwLock<ConfigState>>,
151    /// Manager options
152    options: ConfigManagerOptions,
153    /// Change callbacks
154    callbacks: Arc<Mutex<Vec<ConfigChangeCallback>>>,
155    /// File watcher handle (for cleanup)
156    #[allow(dead_code)]
157    watcher_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
158}
159
160impl McpConfigManager {
161    /// Create a new configuration manager with default options
162    pub fn new() -> Self {
163        Self::with_options(ConfigManagerOptions::default())
164    }
165
166    /// Create a new configuration manager with custom options
167    pub fn with_options(options: ConfigManagerOptions) -> Self {
168        Self {
169            state: Arc::new(RwLock::new(ConfigState::new())),
170            options,
171            callbacks: Arc::new(Mutex::new(Vec::new())),
172            watcher_handle: Arc::new(Mutex::new(None)),
173        }
174    }
175
176    /// Get the global config path
177    pub fn global_config_path(&self) -> PathBuf {
178        self.options.global_config_path.clone().unwrap_or_else(|| {
179            dirs::home_dir()
180                .unwrap_or_else(|| PathBuf::from("~"))
181                .join(".aster")
182                .join("settings.yaml")
183        })
184    }
185
186    /// Get the project config path
187    pub fn project_config_path(&self) -> PathBuf {
188        self.options
189            .project_config_path
190            .clone()
191            .unwrap_or_else(|| PathBuf::from(".aster").join("settings.yaml"))
192    }
193
194    /// Start watching configuration files for changes
195    ///
196    /// This method spawns a background task that monitors the global and project
197    /// configuration files for changes. When a change is detected, the configuration
198    /// is automatically reloaded and all registered callbacks are notified.
199    pub async fn start_watching(&self) -> McpResult<()> {
200        let global_path = self.global_config_path();
201        let project_path = self.project_config_path();
202        let state = self.state.clone();
203        let callbacks = self.callbacks.clone();
204
205        // Store last modified times
206        let global_mtime = Arc::new(Mutex::new(Self::get_mtime(&global_path)));
207        let project_mtime = Arc::new(Mutex::new(Self::get_mtime(&project_path)));
208
209        let handle = tokio::spawn(async move {
210            let mut interval = tokio::time::interval(std::time::Duration::from_secs(2));
211
212            loop {
213                interval.tick().await;
214
215                let mut changed = false;
216
217                // Check global config
218                let new_global_mtime = Self::get_mtime(&global_path);
219                {
220                    let mut last_mtime = global_mtime.lock().await;
221                    if new_global_mtime != *last_mtime {
222                        *last_mtime = new_global_mtime;
223                        changed = true;
224                    }
225                }
226
227                // Check project config
228                let new_project_mtime = Self::get_mtime(&project_path);
229                {
230                    let mut last_mtime = project_mtime.lock().await;
231                    if new_project_mtime != *last_mtime {
232                        *last_mtime = new_project_mtime;
233                        changed = true;
234                    }
235                }
236
237                if changed {
238                    // Reload configuration
239                    if let Ok(global_config) = Self::load_config_from_file(&global_path).await {
240                        if let Ok(project_config) = Self::load_config_from_file(&project_path).await
241                        {
242                            let mut s = state.write().await;
243                            s.global_config = global_config;
244                            s.project_config = project_config;
245                            s.merge();
246
247                            // Notify callbacks
248                            let cbs = callbacks.lock().await;
249                            for cb in cbs.iter() {
250                                cb(&s.merged_config, None);
251                            }
252                        }
253                    }
254                }
255            }
256        });
257
258        // Store the handle
259        let mut watcher = self.watcher_handle.lock().await;
260        if let Some(old_handle) = watcher.take() {
261            old_handle.abort();
262        }
263        *watcher = Some(handle);
264
265        Ok(())
266    }
267
268    /// Stop watching configuration files
269    pub async fn stop_watching(&self) {
270        let mut watcher = self.watcher_handle.lock().await;
271        if let Some(handle) = watcher.take() {
272            handle.abort();
273        }
274    }
275
276    /// Get file modification time
277    fn get_mtime(path: &Path) -> Option<std::time::SystemTime> {
278        std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
279    }
280
281    /// Load configuration from a file
282    async fn load_config_from_file(path: &Path) -> McpResult<HashMap<String, McpServerConfig>> {
283        if !path.exists() {
284            return Ok(HashMap::new());
285        }
286
287        let content = tokio::fs::read_to_string(path).await.map_err(|e| {
288            McpError::config_with_source(format!("Failed to read config file: {:?}", path), e)
289        })?;
290
291        let config_file: McpConfigFile = serde_yaml::from_str(&content).map_err(|e| {
292            McpError::config_with_source(format!("Failed to parse config file: {:?}", path), e)
293        })?;
294
295        Ok(config_file.mcp_servers)
296    }
297
298    /// Save configuration to a file
299    async fn save_config_to_file(
300        path: &Path,
301        servers: &HashMap<String, McpServerConfig>,
302    ) -> McpResult<()> {
303        // Ensure parent directory exists
304        if let Some(parent) = path.parent() {
305            tokio::fs::create_dir_all(parent).await.map_err(|e| {
306                McpError::config_with_source(
307                    format!("Failed to create config directory: {:?}", parent),
308                    e,
309                )
310            })?;
311        }
312
313        // Read existing config or create new
314        let mut config_file = if path.exists() {
315            let content = tokio::fs::read_to_string(path).await.map_err(|e| {
316                McpError::config_with_source(format!("Failed to read config file: {:?}", path), e)
317            })?;
318            serde_yaml::from_str(&content).unwrap_or_default()
319        } else {
320            McpConfigFile::default()
321        };
322
323        // Update MCP servers
324        config_file.mcp_servers = servers.clone();
325
326        // Write back
327        let content = serde_yaml::to_string(&config_file)
328            .map_err(|e| McpError::config_with_source("Failed to serialize config", e))?;
329
330        tokio::fs::write(path, content).await.map_err(|e| {
331            McpError::config_with_source(format!("Failed to write config file: {:?}", path), e)
332        })?;
333
334        Ok(())
335    }
336
337    /// Notify all registered callbacks of a configuration change
338    async fn notify_change(&self, changed_server: Option<&str>) {
339        let callbacks = self.callbacks.lock().await;
340        let state = self.state.read().await;
341
342        for callback in callbacks.iter() {
343            callback(&state.merged_config, changed_server);
344        }
345    }
346
347    /// Check if a command exists on the system
348    fn check_command_exists(command: &str) -> bool {
349        which::which(command).is_ok()
350    }
351
352    /// Check if a key is sensitive (contains secret-like patterns)
353    fn is_sensitive_key(key: &str) -> bool {
354        let lower = key.to_lowercase();
355        lower.contains("key")
356            || lower.contains("token")
357            || lower.contains("secret")
358            || lower.contains("password")
359            || lower.contains("auth")
360            || lower.contains("credential")
361            || lower.contains("api_key")
362            || lower.contains("apikey")
363    }
364
365    /// Mask a sensitive value
366    fn mask_secret(value: &str) -> String {
367        if value.len() <= 8 {
368            "***".to_string()
369        } else {
370            format!(
371                "{}***{}",
372                value.get(..4).unwrap_or(""),
373                value.get(value.len().saturating_sub(4)..).unwrap_or("")
374            )
375        }
376    }
377}
378
379impl Default for McpConfigManager {
380    fn default() -> Self {
381        Self::new()
382    }
383}
384
385#[async_trait]
386impl ConfigManager for McpConfigManager {
387    async fn load(&self) -> McpResult<()> {
388        let global_path = self.global_config_path();
389        let project_path = self.project_config_path();
390
391        let global_config = Self::load_config_from_file(&global_path).await?;
392        let project_config = Self::load_config_from_file(&project_path).await?;
393
394        let mut state = self.state.write().await;
395        state.global_config = global_config;
396        state.project_config = project_config;
397        state.merge();
398
399        drop(state);
400        self.notify_change(None).await;
401
402        Ok(())
403    }
404
405    async fn reload(&self) -> McpResult<()> {
406        self.load().await
407    }
408
409    fn get_servers(&self) -> HashMap<String, McpServerConfig> {
410        self.state
411            .try_read()
412            .map(|s| s.merged_config.clone())
413            .unwrap_or_default()
414    }
415
416    fn get_server(&self, name: &str) -> Option<McpServerConfig> {
417        self.state
418            .try_read()
419            .ok()
420            .and_then(|s| s.merged_config.get(name).cloned())
421    }
422
423    async fn add_server(&self, name: &str, config: McpServerConfig) -> McpResult<()> {
424        // Validate configuration
425        let validation = self.validate(&config);
426        if !validation.valid {
427            return Err(McpError::validation(
428                format!("Invalid server configuration for '{}'", name),
429                validation.errors,
430            ));
431        }
432
433        // Add to project config
434        {
435            let mut state = self.state.write().await;
436            state.project_config.insert(name.to_string(), config);
437            state.merge();
438        }
439
440        // Auto-save if enabled
441        if self.options.auto_save {
442            self.save(ConfigScope::Project).await?;
443        }
444
445        self.notify_change(Some(name)).await;
446        Ok(())
447    }
448
449    async fn update_server(&self, name: &str, config: McpServerConfig) -> McpResult<()> {
450        // Check if server exists
451        {
452            let state = self.state.read().await;
453            if !state.merged_config.contains_key(name) {
454                return Err(McpError::config(format!("Server not found: {}", name)));
455            }
456        }
457
458        // Validate configuration
459        let validation = self.validate(&config);
460        if !validation.valid {
461            return Err(McpError::validation(
462                format!("Invalid server configuration for '{}'", name),
463                validation.errors,
464            ));
465        }
466
467        // Update project config
468        {
469            let mut state = self.state.write().await;
470            state.project_config.insert(name.to_string(), config);
471            state.merge();
472        }
473
474        // Auto-save if enabled
475        if self.options.auto_save {
476            self.save(ConfigScope::Project).await?;
477        }
478
479        self.notify_change(Some(name)).await;
480        Ok(())
481    }
482
483    async fn remove_server(&self, name: &str) -> McpResult<bool> {
484        let existed = {
485            let mut state = self.state.write().await;
486            let existed = state.merged_config.contains_key(name);
487            state.global_config.remove(name);
488            state.project_config.remove(name);
489            state.merge();
490            existed
491        };
492
493        if existed && self.options.auto_save {
494            self.save(ConfigScope::Global).await?;
495            self.save(ConfigScope::Project).await?;
496        }
497
498        if existed {
499            self.notify_change(Some(name)).await;
500        }
501
502        Ok(existed)
503    }
504
505    async fn enable_server(&self, name: &str) -> McpResult<()> {
506        let config = self
507            .get_server(name)
508            .ok_or_else(|| McpError::config(format!("Server not found: {}", name)))?;
509
510        let mut updated = config;
511        updated.enabled = true;
512        self.update_server(name, updated).await
513    }
514
515    async fn disable_server(&self, name: &str) -> McpResult<()> {
516        let config = self
517            .get_server(name)
518            .ok_or_else(|| McpError::config(format!("Server not found: {}", name)))?;
519
520        let mut updated = config;
521        updated.enabled = false;
522        self.update_server(name, updated).await
523    }
524
525    fn get_enabled_servers(&self) -> HashMap<String, McpServerConfig> {
526        self.state
527            .try_read()
528            .map(|s| {
529                s.merged_config
530                    .iter()
531                    .filter(|(_, config)| config.enabled)
532                    .map(|(k, v)| (k.clone(), v.clone()))
533                    .collect()
534            })
535            .unwrap_or_default()
536    }
537
538    fn validate(&self, config: &McpServerConfig) -> ValidationResult {
539        let mut result = ValidationResult::valid();
540
541        // Validate transport-specific requirements
542        match config.transport_type {
543            TransportType::Stdio => {
544                if config.command.is_none() {
545                    result.add_error("Stdio transport requires a command");
546                } else if self.options.validate_commands {
547                    if let Some(ref cmd) = config.command {
548                        if !Self::check_command_exists(cmd) {
549                            result.add_warning(format!("Command not found: {}", cmd));
550                        }
551                    }
552                }
553            }
554            TransportType::Http | TransportType::Sse | TransportType::WebSocket => {
555                if config.url.is_none() {
556                    result.add_error(format!(
557                        "{} transport requires a URL",
558                        config.transport_type
559                    ));
560                }
561            }
562        }
563
564        // Validate timeout
565        if config.timeout.as_secs() == 0 {
566            result.add_warning("Timeout is set to 0, which may cause issues");
567        }
568
569        // Check for empty environment variables
570        if let Some(ref env) = config.env {
571            for (key, value) in env {
572                if value.is_empty() {
573                    result.add_warning(format!("Environment variable '{}' is empty", key));
574                }
575            }
576        }
577
578        result
579    }
580
581    fn validate_all(&self) -> Vec<ServerValidationResult> {
582        let servers = self.get_servers();
583        let mut results = Vec::new();
584
585        for (name, config) in servers {
586            let validation = self.validate(&config);
587            let command_exists = if config.transport_type == TransportType::Stdio {
588                config
589                    .command
590                    .as_ref()
591                    .map(|cmd| Self::check_command_exists(cmd))
592            } else {
593                None
594            };
595
596            results.push(ServerValidationResult {
597                server_name: name,
598                valid: validation.valid,
599                command_exists,
600                errors: validation.errors,
601                warnings: validation.warnings,
602            });
603        }
604
605        results
606    }
607
608    async fn save(&self, scope: ConfigScope) -> McpResult<()> {
609        let (path, config) = {
610            let state = self.state.read().await;
611            match scope {
612                ConfigScope::Global => (self.global_config_path(), state.global_config.clone()),
613                ConfigScope::Project => (self.project_config_path(), state.project_config.clone()),
614            }
615        };
616
617        Self::save_config_to_file(&path, &config).await
618    }
619
620    async fn backup(&self) -> McpResult<PathBuf> {
621        let project_path = self.project_config_path();
622        let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
623        let backup_path = project_path.with_extension(format!("yaml.backup.{}", timestamp));
624
625        if project_path.exists() {
626            tokio::fs::copy(&project_path, &backup_path)
627                .await
628                .map_err(|e| McpError::config_with_source("Failed to create backup", e))?;
629        }
630
631        Ok(backup_path)
632    }
633
634    async fn restore(&self, backup_path: &Path) -> McpResult<()> {
635        if !backup_path.exists() {
636            return Err(McpError::config(format!(
637                "Backup file not found: {:?}",
638                backup_path
639            )));
640        }
641
642        let project_path = self.project_config_path();
643        tokio::fs::copy(backup_path, &project_path)
644            .await
645            .map_err(|e| McpError::config_with_source("Failed to restore backup", e))?;
646
647        self.reload().await
648    }
649
650    fn export(&self, mask_secrets: bool) -> String {
651        let servers = self.get_servers();
652
653        if !mask_secrets {
654            return serde_json::to_string_pretty(&servers).unwrap_or_default();
655        }
656
657        // Mask sensitive values
658        let masked: HashMap<String, McpServerConfig> = servers
659            .into_iter()
660            .map(|(name, mut config)| {
661                // Mask environment variables
662                if let Some(ref mut env) = config.env {
663                    for (key, value) in env.iter_mut() {
664                        if Self::is_sensitive_key(key) {
665                            *value = Self::mask_secret(value);
666                        }
667                    }
668                }
669
670                // Mask headers
671                if let Some(ref mut headers) = config.headers {
672                    for (key, value) in headers.iter_mut() {
673                        if Self::is_sensitive_key(key) {
674                            *value = Self::mask_secret(value);
675                        }
676                    }
677                }
678
679                (name, config)
680            })
681            .collect();
682
683        serde_json::to_string_pretty(&masked).unwrap_or_default()
684    }
685
686    async fn import(&self, config_json: &str, scope: ConfigScope) -> McpResult<()> {
687        let servers: HashMap<String, McpServerConfig> = serde_json::from_str(config_json)
688            .map_err(|e| McpError::config_with_source("Failed to parse import JSON", e))?;
689
690        // Validate all servers
691        for (name, config) in &servers {
692            let validation = self.validate(config);
693            if !validation.valid {
694                return Err(McpError::validation(
695                    format!("Invalid configuration for server '{}'", name),
696                    validation.errors,
697                ));
698            }
699        }
700
701        // Update state
702        {
703            let mut state = self.state.write().await;
704            match scope {
705                ConfigScope::Global => state.global_config = servers,
706                ConfigScope::Project => state.project_config = servers,
707            }
708            state.merge();
709        }
710
711        // Save if auto-save enabled
712        if self.options.auto_save {
713            self.save(scope).await?;
714        }
715
716        self.notify_change(None).await;
717        Ok(())
718    }
719
720    fn on_change(&self, callback: ConfigChangeCallback) -> Box<dyn FnOnce() + Send> {
721        let callbacks = self.callbacks.clone();
722        let callback_clone = callback.clone();
723
724        // Add callback
725        tokio::spawn(async move {
726            callbacks.lock().await.push(callback_clone);
727        });
728
729        // Return unsubscribe function
730        let callbacks_for_unsub = self.callbacks.clone();
731        Box::new(move || {
732            let cb = callback;
733            tokio::spawn(async move {
734                let mut cbs = callbacks_for_unsub.lock().await;
735                cbs.retain(|c| !Arc::ptr_eq(c, &cb));
736            });
737        })
738    }
739}
740
741/// Merge two configurations (right takes precedence over left)
742pub fn merge_configs(
743    global: &HashMap<String, McpServerConfig>,
744    project: &HashMap<String, McpServerConfig>,
745) -> HashMap<String, McpServerConfig> {
746    let mut merged = global.clone();
747
748    for (name, project_config) in project {
749        if let Some(global_config) = merged.get_mut(name) {
750            // Merge: project values override global values
751            *global_config = merge_server_config(global_config, project_config);
752        } else {
753            merged.insert(name.clone(), project_config.clone());
754        }
755    }
756
757    merged
758}
759
760/// Merge two server configurations (right takes precedence)
761fn merge_server_config(global: &McpServerConfig, project: &McpServerConfig) -> McpServerConfig {
762    McpServerConfig {
763        transport_type: project.transport_type,
764        command: project.command.clone().or_else(|| global.command.clone()),
765        args: project.args.clone().or_else(|| global.args.clone()),
766        env: merge_optional_maps(&global.env, &project.env),
767        url: project.url.clone().or_else(|| global.url.clone()),
768        headers: merge_optional_maps(&global.headers, &project.headers),
769        enabled: project.enabled,
770        timeout: project.timeout,
771        retries: project.retries,
772        auto_approve: if project.auto_approve.is_empty() {
773            global.auto_approve.clone()
774        } else {
775            project.auto_approve.clone()
776        },
777        log_level: project.log_level,
778    }
779}
780
781/// Merge two optional HashMaps (right takes precedence)
782fn merge_optional_maps(
783    left: &Option<HashMap<String, String>>,
784    right: &Option<HashMap<String, String>>,
785) -> Option<HashMap<String, String>> {
786    match (left, right) {
787        (None, None) => None,
788        (Some(l), None) => Some(l.clone()),
789        (None, Some(r)) => Some(r.clone()),
790        (Some(l), Some(r)) => {
791            let mut merged = l.clone();
792            merged.extend(r.clone());
793            Some(merged)
794        }
795    }
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801    use std::time::Duration;
802
803    fn create_test_config() -> McpServerConfig {
804        McpServerConfig {
805            transport_type: TransportType::Stdio,
806            command: Some("node".to_string()),
807            args: Some(vec!["server.js".to_string()]),
808            env: Some(HashMap::from([
809                ("API_KEY".to_string(), "secret123".to_string()),
810                ("DEBUG".to_string(), "true".to_string()),
811            ])),
812            url: None,
813            headers: None,
814            enabled: true,
815            timeout: Duration::from_secs(30),
816            retries: 3,
817            auto_approve: vec![],
818            log_level: Default::default(),
819        }
820    }
821
822    #[test]
823    fn test_config_manager_new() {
824        let manager = McpConfigManager::new();
825        assert!(manager.get_servers().is_empty());
826    }
827
828    #[test]
829    fn test_validation_result() {
830        let mut result = ValidationResult::valid();
831        assert!(result.valid);
832        assert!(result.errors.is_empty());
833
834        result.add_error("test error");
835        assert!(!result.valid);
836        assert_eq!(result.errors.len(), 1);
837
838        result.add_warning("test warning");
839        assert_eq!(result.warnings.len(), 1);
840    }
841
842    #[test]
843    fn test_validate_stdio_config() {
844        let manager = McpConfigManager::with_options(ConfigManagerOptions {
845            validate_commands: false,
846            ..Default::default()
847        });
848
849        let config = create_test_config();
850        let result = manager.validate(&config);
851        assert!(result.valid);
852    }
853
854    #[test]
855    fn test_validate_stdio_missing_command() {
856        let manager = McpConfigManager::new();
857        let config = McpServerConfig {
858            transport_type: TransportType::Stdio,
859            command: None,
860            ..Default::default()
861        };
862
863        let result = manager.validate(&config);
864        assert!(!result.valid);
865        assert!(result.errors.iter().any(|e| e.contains("command")));
866    }
867
868    #[test]
869    fn test_validate_http_missing_url() {
870        let manager = McpConfigManager::new();
871        let config = McpServerConfig {
872            transport_type: TransportType::Http,
873            url: None,
874            ..Default::default()
875        };
876
877        let result = manager.validate(&config);
878        assert!(!result.valid);
879        assert!(result.errors.iter().any(|e| e.contains("URL")));
880    }
881
882    #[test]
883    fn test_is_sensitive_key() {
884        assert!(McpConfigManager::is_sensitive_key("API_KEY"));
885        assert!(McpConfigManager::is_sensitive_key("api_key"));
886        assert!(McpConfigManager::is_sensitive_key("SECRET_TOKEN"));
887        assert!(McpConfigManager::is_sensitive_key("password"));
888        assert!(McpConfigManager::is_sensitive_key("AUTH_TOKEN"));
889        assert!(!McpConfigManager::is_sensitive_key("DEBUG"));
890        assert!(!McpConfigManager::is_sensitive_key("PORT"));
891    }
892
893    #[test]
894    fn test_mask_secret() {
895        assert_eq!(McpConfigManager::mask_secret("short"), "***");
896        assert_eq!(McpConfigManager::mask_secret("12345678"), "***");
897        assert_eq!(
898            McpConfigManager::mask_secret("longsecretvalue"),
899            "long***alue"
900        );
901    }
902
903    #[test]
904    fn test_merge_configs() {
905        let mut global = HashMap::new();
906        global.insert(
907            "server1".to_string(),
908            McpServerConfig {
909                transport_type: TransportType::Stdio,
910                command: Some("global_cmd".to_string()),
911                enabled: true,
912                ..Default::default()
913            },
914        );
915        global.insert(
916            "server2".to_string(),
917            McpServerConfig {
918                transport_type: TransportType::Http,
919                url: Some("http://global.example.com".to_string()),
920                enabled: true,
921                ..Default::default()
922            },
923        );
924
925        let mut project = HashMap::new();
926        project.insert(
927            "server1".to_string(),
928            McpServerConfig {
929                transport_type: TransportType::Stdio,
930                command: Some("project_cmd".to_string()),
931                enabled: false,
932                ..Default::default()
933            },
934        );
935        project.insert(
936            "server3".to_string(),
937            McpServerConfig {
938                transport_type: TransportType::WebSocket,
939                url: Some("ws://project.example.com".to_string()),
940                enabled: true,
941                ..Default::default()
942            },
943        );
944
945        let merged = merge_configs(&global, &project);
946
947        // server1: project takes precedence
948        assert_eq!(
949            merged.get("server1").unwrap().command,
950            Some("project_cmd".to_string())
951        );
952        assert!(!merged.get("server1").unwrap().enabled);
953
954        // server2: only in global
955        assert_eq!(
956            merged.get("server2").unwrap().url,
957            Some("http://global.example.com".to_string())
958        );
959
960        // server3: only in project
961        assert_eq!(
962            merged.get("server3").unwrap().url,
963            Some("ws://project.example.com".to_string())
964        );
965    }
966
967    #[test]
968    fn test_merge_optional_maps() {
969        let left = Some(HashMap::from([
970            ("a".to_string(), "1".to_string()),
971            ("b".to_string(), "2".to_string()),
972        ]));
973        let right = Some(HashMap::from([
974            ("b".to_string(), "3".to_string()),
975            ("c".to_string(), "4".to_string()),
976        ]));
977
978        let merged = merge_optional_maps(&left, &right).unwrap();
979        assert_eq!(merged.get("a"), Some(&"1".to_string()));
980        assert_eq!(merged.get("b"), Some(&"3".to_string())); // right takes precedence
981        assert_eq!(merged.get("c"), Some(&"4".to_string()));
982    }
983
984    #[tokio::test]
985    async fn test_export_with_masking() {
986        let manager = McpConfigManager::new();
987
988        // Add a server with sensitive data
989        {
990            let mut state = manager.state.write().await;
991            state.merged_config.insert(
992                "test".to_string(),
993                McpServerConfig {
994                    transport_type: TransportType::Stdio,
995                    command: Some("node".to_string()),
996                    env: Some(HashMap::from([
997                        ("API_KEY".to_string(), "supersecretkey123".to_string()),
998                        ("DEBUG".to_string(), "true".to_string()),
999                    ])),
1000                    ..Default::default()
1001                },
1002            );
1003        }
1004
1005        let exported = manager.export(true);
1006        // The mask function shows first 4 and last 4 chars: "supe***y123"
1007        assert!(exported.contains("supe***y123")); // masked
1008        assert!(exported.contains("true")); // not masked
1009        assert!(!exported.contains("supersecretkey123")); // original not present
1010    }
1011
1012    #[tokio::test]
1013    async fn test_load_from_file() {
1014        let temp_dir = tempfile::tempdir().unwrap();
1015        let config_path = temp_dir.path().join("settings.yaml");
1016
1017        // Create a test config file
1018        let config_content = r#"
1019mcpServers:
1020  test-server:
1021    transport_type: stdio
1022    command: node
1023    args:
1024      - server.js
1025    enabled: true
1026    timeout: 30000
1027    retries: 3
1028"#;
1029        tokio::fs::write(&config_path, config_content)
1030            .await
1031            .unwrap();
1032
1033        let manager = McpConfigManager::with_options(ConfigManagerOptions {
1034            project_config_path: Some(config_path),
1035            auto_save: false,
1036            validate_commands: false,
1037            ..Default::default()
1038        });
1039
1040        manager.load().await.unwrap();
1041
1042        let servers = manager.get_servers();
1043        assert!(servers.contains_key("test-server"));
1044        assert_eq!(
1045            servers.get("test-server").unwrap().command,
1046            Some("node".to_string())
1047        );
1048    }
1049
1050    #[tokio::test]
1051    async fn test_save_and_load_roundtrip() {
1052        let temp_dir = tempfile::tempdir().unwrap();
1053        let config_path = temp_dir.path().join("settings.yaml");
1054
1055        let manager = McpConfigManager::with_options(ConfigManagerOptions {
1056            project_config_path: Some(config_path.clone()),
1057            auto_save: false,
1058            validate_commands: false,
1059            ..Default::default()
1060        });
1061
1062        // Add a server
1063        let config = McpServerConfig {
1064            transport_type: TransportType::Stdio,
1065            command: Some("test-cmd".to_string()),
1066            args: Some(vec!["arg1".to_string()]),
1067            enabled: true,
1068            ..Default::default()
1069        };
1070
1071        manager
1072            .add_server("roundtrip-test", config.clone())
1073            .await
1074            .unwrap();
1075        manager.save(ConfigScope::Project).await.unwrap();
1076
1077        // Create a new manager and load
1078        let manager2 = McpConfigManager::with_options(ConfigManagerOptions {
1079            project_config_path: Some(config_path),
1080            auto_save: false,
1081            validate_commands: false,
1082            ..Default::default()
1083        });
1084
1085        manager2.load().await.unwrap();
1086
1087        let loaded = manager2.get_server("roundtrip-test").unwrap();
1088        assert_eq!(loaded.command, Some("test-cmd".to_string()));
1089        assert_eq!(loaded.args, Some(vec!["arg1".to_string()]));
1090    }
1091
1092    #[test]
1093    fn test_validate_all() {
1094        let manager = McpConfigManager::with_options(ConfigManagerOptions {
1095            validate_commands: false,
1096            ..Default::default()
1097        });
1098
1099        // Add servers directly to state for testing
1100        let rt = tokio::runtime::Runtime::new().unwrap();
1101        rt.block_on(async {
1102            let mut state = manager.state.write().await;
1103            state.merged_config.insert(
1104                "valid-server".to_string(),
1105                McpServerConfig {
1106                    transport_type: TransportType::Stdio,
1107                    command: Some("node".to_string()),
1108                    enabled: true,
1109                    ..Default::default()
1110                },
1111            );
1112            state.merged_config.insert(
1113                "invalid-server".to_string(),
1114                McpServerConfig {
1115                    transport_type: TransportType::Http,
1116                    url: None, // Missing required URL
1117                    enabled: true,
1118                    ..Default::default()
1119                },
1120            );
1121        });
1122
1123        let results = manager.validate_all();
1124        assert_eq!(results.len(), 2);
1125
1126        let valid_result = results
1127            .iter()
1128            .find(|r| r.server_name == "valid-server")
1129            .unwrap();
1130        assert!(valid_result.valid);
1131
1132        let invalid_result = results
1133            .iter()
1134            .find(|r| r.server_name == "invalid-server")
1135            .unwrap();
1136        assert!(!invalid_result.valid);
1137    }
1138
1139    #[test]
1140    fn test_command_exists_check() {
1141        // Test with a command that should exist on most systems
1142        assert!(
1143            McpConfigManager::check_command_exists("ls")
1144                || McpConfigManager::check_command_exists("dir")
1145        );
1146
1147        // Test with a command that shouldn't exist
1148        assert!(!McpConfigManager::check_command_exists(
1149            "nonexistent_command_xyz123"
1150        ));
1151    }
1152
1153    #[tokio::test]
1154    async fn test_enable_disable_server() {
1155        let temp_dir = tempfile::tempdir().unwrap();
1156        let config_path = temp_dir.path().join("settings.yaml");
1157
1158        let manager = McpConfigManager::with_options(ConfigManagerOptions {
1159            project_config_path: Some(config_path),
1160            auto_save: false,
1161            validate_commands: false,
1162            ..Default::default()
1163        });
1164
1165        // Add a server
1166        let config = McpServerConfig {
1167            transport_type: TransportType::Stdio,
1168            command: Some("test-cmd".to_string()),
1169            enabled: true,
1170            ..Default::default()
1171        };
1172
1173        manager.add_server("toggle-test", config).await.unwrap();
1174
1175        // Verify initially enabled
1176        assert!(manager.get_server("toggle-test").unwrap().enabled);
1177        assert!(manager.get_enabled_servers().contains_key("toggle-test"));
1178
1179        // Disable
1180        manager.disable_server("toggle-test").await.unwrap();
1181        assert!(!manager.get_server("toggle-test").unwrap().enabled);
1182        assert!(!manager.get_enabled_servers().contains_key("toggle-test"));
1183
1184        // Enable again
1185        manager.enable_server("toggle-test").await.unwrap();
1186        assert!(manager.get_server("toggle-test").unwrap().enabled);
1187        assert!(manager.get_enabled_servers().contains_key("toggle-test"));
1188    }
1189
1190    #[tokio::test]
1191    async fn test_on_change_callback() {
1192        use std::sync::atomic::{AtomicUsize, Ordering};
1193
1194        let temp_dir = tempfile::tempdir().unwrap();
1195        let config_path = temp_dir.path().join("settings.yaml");
1196
1197        let manager = McpConfigManager::with_options(ConfigManagerOptions {
1198            project_config_path: Some(config_path),
1199            auto_save: false,
1200            validate_commands: false,
1201            ..Default::default()
1202        });
1203
1204        // Track callback invocations
1205        let call_count = Arc::new(AtomicUsize::new(0));
1206        let call_count_clone = call_count.clone();
1207
1208        // Register callback
1209        let _unsubscribe = manager.on_change(Arc::new(move |_config, _changed| {
1210            call_count_clone.fetch_add(1, Ordering::SeqCst);
1211        }));
1212
1213        // Give time for callback registration
1214        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1215
1216        // Add a server (should trigger callback)
1217        let config = McpServerConfig {
1218            transport_type: TransportType::Stdio,
1219            command: Some("test-cmd".to_string()),
1220            enabled: true,
1221            ..Default::default()
1222        };
1223
1224        manager.add_server("callback-test", config).await.unwrap();
1225
1226        // Give time for callback to be invoked
1227        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1228
1229        // Callback should have been called at least once
1230        assert!(call_count.load(Ordering::SeqCst) >= 1);
1231    }
1232
1233    #[tokio::test]
1234    async fn test_backup_and_restore() {
1235        let temp_dir = tempfile::tempdir().unwrap();
1236        let config_path = temp_dir.path().join("settings.yaml");
1237
1238        let manager = McpConfigManager::with_options(ConfigManagerOptions {
1239            project_config_path: Some(config_path.clone()),
1240            auto_save: false,
1241            validate_commands: false,
1242            ..Default::default()
1243        });
1244
1245        // Add a server and save
1246        let config = McpServerConfig {
1247            transport_type: TransportType::Stdio,
1248            command: Some("original-cmd".to_string()),
1249            enabled: true,
1250            ..Default::default()
1251        };
1252
1253        manager.add_server("backup-test", config).await.unwrap();
1254        manager.save(ConfigScope::Project).await.unwrap();
1255
1256        // Create backup
1257        let backup_path = manager.backup().await.unwrap();
1258        assert!(backup_path.exists());
1259
1260        // Modify the config
1261        let new_config = McpServerConfig {
1262            transport_type: TransportType::Stdio,
1263            command: Some("modified-cmd".to_string()),
1264            enabled: true,
1265            ..Default::default()
1266        };
1267        manager
1268            .update_server("backup-test", new_config)
1269            .await
1270            .unwrap();
1271        manager.save(ConfigScope::Project).await.unwrap();
1272
1273        // Verify modification
1274        assert_eq!(
1275            manager.get_server("backup-test").unwrap().command,
1276            Some("modified-cmd".to_string())
1277        );
1278
1279        // Restore from backup
1280        manager.restore(&backup_path).await.unwrap();
1281
1282        // Verify restoration
1283        assert_eq!(
1284            manager.get_server("backup-test").unwrap().command,
1285            Some("original-cmd".to_string())
1286        );
1287    }
1288
1289    #[tokio::test]
1290    async fn test_import_export() {
1291        let temp_dir = tempfile::tempdir().unwrap();
1292        let config_path = temp_dir.path().join("settings.yaml");
1293
1294        let manager = McpConfigManager::with_options(ConfigManagerOptions {
1295            project_config_path: Some(config_path),
1296            auto_save: false,
1297            validate_commands: false,
1298            ..Default::default()
1299        });
1300
1301        // Add a server
1302        let config = McpServerConfig {
1303            transport_type: TransportType::Stdio,
1304            command: Some("export-cmd".to_string()),
1305            enabled: true,
1306            ..Default::default()
1307        };
1308
1309        manager.add_server("export-test", config).await.unwrap();
1310
1311        // Export without masking
1312        let exported = manager.export(false);
1313        assert!(exported.contains("export-cmd"));
1314
1315        // Create a new manager and import
1316        let temp_dir2 = tempfile::tempdir().unwrap();
1317        let config_path2 = temp_dir2.path().join("settings.yaml");
1318
1319        let manager2 = McpConfigManager::with_options(ConfigManagerOptions {
1320            project_config_path: Some(config_path2),
1321            auto_save: false,
1322            validate_commands: false,
1323            ..Default::default()
1324        });
1325
1326        manager2
1327            .import(&exported, ConfigScope::Project)
1328            .await
1329            .unwrap();
1330
1331        // Verify import
1332        assert!(manager2.get_server("export-test").is_some());
1333        assert_eq!(
1334            manager2.get_server("export-test").unwrap().command,
1335            Some("export-cmd".to_string())
1336        );
1337    }
1338}