1use 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
31pub type ConfigChangeCallback =
33 Arc<dyn Fn(&HashMap<String, McpServerConfig>, Option<&str>) + Send + Sync>;
34
35#[derive(Debug, Clone, Default, Serialize, Deserialize)]
37pub struct McpConfigFile {
38 #[serde(default, rename = "mcpServers")]
40 pub mcp_servers: HashMap<String, McpServerConfig>,
41}
42
43#[async_trait]
47pub trait ConfigManager: Send + Sync {
48 async fn load(&self) -> McpResult<()>;
50
51 async fn reload(&self) -> McpResult<()>;
53
54 fn get_servers(&self) -> HashMap<String, McpServerConfig>;
56
57 fn get_server(&self, name: &str) -> Option<McpServerConfig>;
59
60 async fn add_server(&self, name: &str, config: McpServerConfig) -> McpResult<()>;
62
63 async fn update_server(&self, name: &str, config: McpServerConfig) -> McpResult<()>;
65
66 async fn remove_server(&self, name: &str) -> McpResult<bool>;
68
69 async fn enable_server(&self, name: &str) -> McpResult<()>;
71
72 async fn disable_server(&self, name: &str) -> McpResult<()>;
74
75 fn get_enabled_servers(&self) -> HashMap<String, McpServerConfig>;
77
78 fn validate(&self, config: &McpServerConfig) -> ValidationResult;
80
81 fn validate_all(&self) -> Vec<ServerValidationResult>;
83
84 async fn save(&self, scope: ConfigScope) -> McpResult<()>;
86
87 async fn backup(&self) -> McpResult<PathBuf>;
89
90 async fn restore(&self, backup_path: &Path) -> McpResult<()>;
92
93 fn export(&self, mask_secrets: bool) -> String;
95
96 async fn import(&self, config_json: &str, scope: ConfigScope) -> McpResult<()>;
98
99 fn on_change(&self, callback: ConfigChangeCallback) -> Box<dyn FnOnce() + Send>;
101}
102
103#[derive(Debug, Clone)]
105pub enum ConfigEvent {
106 Loaded,
108 Reloaded,
110 ServerAdded(String),
112 ServerUpdated(String),
114 ServerRemoved(String),
116 ServerEnabled(String),
118 ServerDisabled(String),
120}
121
122struct ConfigState {
124 global_config: HashMap<String, McpServerConfig>,
126 project_config: HashMap<String, McpServerConfig>,
128 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 fn merge(&mut self) {
143 self.merged_config = merge_configs(&self.global_config, &self.project_config);
144 }
145}
146
147pub struct McpConfigManager {
149 state: Arc<RwLock<ConfigState>>,
151 options: ConfigManagerOptions,
153 callbacks: Arc<Mutex<Vec<ConfigChangeCallback>>>,
155 #[allow(dead_code)]
157 watcher_handle: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
158}
159
160impl McpConfigManager {
161 pub fn new() -> Self {
163 Self::with_options(ConfigManagerOptions::default())
164 }
165
166 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 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 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 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 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 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 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 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 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 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 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 fn get_mtime(path: &Path) -> Option<std::time::SystemTime> {
278 std::fs::metadata(path).ok().and_then(|m| m.modified().ok())
279 }
280
281 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 async fn save_config_to_file(
300 path: &Path,
301 servers: &HashMap<String, McpServerConfig>,
302 ) -> McpResult<()> {
303 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 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 config_file.mcp_servers = servers.clone();
325
326 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 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 fn check_command_exists(command: &str) -> bool {
349 which::which(command).is_ok()
350 }
351
352 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 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 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 {
435 let mut state = self.state.write().await;
436 state.project_config.insert(name.to_string(), config);
437 state.merge();
438 }
439
440 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 {
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 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 {
469 let mut state = self.state.write().await;
470 state.project_config.insert(name.to_string(), config);
471 state.merge();
472 }
473
474 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 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 if config.timeout.as_secs() == 0 {
566 result.add_warning("Timeout is set to 0, which may cause issues");
567 }
568
569 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 let masked: HashMap<String, McpServerConfig> = servers
659 .into_iter()
660 .map(|(name, mut config)| {
661 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 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 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 {
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 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 tokio::spawn(async move {
726 callbacks.lock().await.push(callback_clone);
727 });
728
729 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
741pub 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 *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
760fn 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
781fn 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 assert_eq!(
949 merged.get("server1").unwrap().command,
950 Some("project_cmd".to_string())
951 );
952 assert!(!merged.get("server1").unwrap().enabled);
953
954 assert_eq!(
956 merged.get("server2").unwrap().url,
957 Some("http://global.example.com".to_string())
958 );
959
960 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())); 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 {
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 assert!(exported.contains("supe***y123")); assert!(exported.contains("true")); assert!(!exported.contains("supersecretkey123")); }
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 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 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 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 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, 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 assert!(
1143 McpConfigManager::check_command_exists("ls")
1144 || McpConfigManager::check_command_exists("dir")
1145 );
1146
1147 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 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 assert!(manager.get_server("toggle-test").unwrap().enabled);
1177 assert!(manager.get_enabled_servers().contains_key("toggle-test"));
1178
1179 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 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 let call_count = Arc::new(AtomicUsize::new(0));
1206 let call_count_clone = call_count.clone();
1207
1208 let _unsubscribe = manager.on_change(Arc::new(move |_config, _changed| {
1210 call_count_clone.fetch_add(1, Ordering::SeqCst);
1211 }));
1212
1213 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1215
1216 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 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1228
1229 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 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 let backup_path = manager.backup().await.unwrap();
1258 assert!(backup_path.exists());
1259
1260 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 assert_eq!(
1275 manager.get_server("backup-test").unwrap().command,
1276 Some("modified-cmd".to_string())
1277 );
1278
1279 manager.restore(&backup_path).await.unwrap();
1281
1282 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 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 let exported = manager.export(false);
1313 assert!(exported.contains("export-cmd"));
1314
1315 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 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}