1use std::collections::HashMap;
49use std::env;
50use std::path::{Path, PathBuf};
51use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
52
53use asupersync::Cx;
54use fastmcp_core::{McpError, McpResult};
55use fastmcp_transport::StdioTransport;
56use serde::{Deserialize, Serialize};
57
58use crate::{Client, ClientSession};
59use fastmcp_protocol::{ClientCapabilities, ClientInfo};
60
61#[derive(Debug, Clone, Default, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct McpConfig {
69 #[serde(default)]
71 pub mcp_servers: HashMap<String, ServerConfig>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct ServerConfig {
78 pub command: String,
80
81 #[serde(default)]
83 pub args: Vec<String>,
84
85 #[serde(default)]
87 pub env: HashMap<String, String>,
88
89 #[serde(default)]
91 pub cwd: Option<String>,
92
93 #[serde(default)]
95 pub disabled: bool,
96}
97
98impl ServerConfig {
99 #[must_use]
101 pub fn new(command: impl Into<String>) -> Self {
102 Self {
103 command: command.into(),
104 args: Vec::new(),
105 env: HashMap::new(),
106 cwd: None,
107 disabled: false,
108 }
109 }
110
111 #[must_use]
113 pub fn with_args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
114 self.args = args.into_iter().map(Into::into).collect();
115 self
116 }
117
118 #[must_use]
120 pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
121 self.env.insert(key.into(), value.into());
122 self
123 }
124
125 #[must_use]
127 pub fn with_cwd(mut self, cwd: impl Into<String>) -> Self {
128 self.cwd = Some(cwd.into());
129 self
130 }
131
132 #[must_use]
134 pub fn disabled(mut self) -> Self {
135 self.disabled = true;
136 self
137 }
138}
139
140#[derive(Debug)]
146pub enum ConfigError {
147 NotFound(String),
149 ReadError(std::io::Error),
151 ParseError(String),
153 ServerNotFound(String),
155 ServerDisabled(String),
157 SpawnError(String),
159}
160
161impl std::fmt::Display for ConfigError {
162 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163 match self {
164 ConfigError::NotFound(path) => write!(f, "Configuration file not found: {path}"),
165 ConfigError::ReadError(e) => write!(f, "Failed to read configuration: {e}"),
166 ConfigError::ParseError(e) => write!(f, "Failed to parse configuration: {e}"),
167 ConfigError::ServerNotFound(name) => write!(f, "Server not found: {name}"),
168 ConfigError::ServerDisabled(name) => write!(f, "Server is disabled: {name}"),
169 ConfigError::SpawnError(e) => write!(f, "Failed to spawn server: {e}"),
170 }
171 }
172}
173
174impl std::error::Error for ConfigError {
175 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
176 match self {
177 ConfigError::ReadError(e) => Some(e),
178 _ => None,
179 }
180 }
181}
182
183impl From<ConfigError> for McpError {
184 fn from(err: ConfigError) -> Self {
185 McpError::internal_error(err.to_string())
186 }
187}
188
189impl McpConfig {
194 #[must_use]
196 pub fn new() -> Self {
197 Self::default()
198 }
199
200 pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
206 let path = path.as_ref();
207 let content = std::fs::read_to_string(path).map_err(|e| {
208 if e.kind() == std::io::ErrorKind::NotFound {
209 ConfigError::NotFound(path.display().to_string())
210 } else {
211 ConfigError::ReadError(e)
212 }
213 })?;
214
215 Self::from_json(&content)
216 }
217
218 pub fn from_json(json: &str) -> Result<Self, ConfigError> {
224 serde_json::from_str(json).map_err(|e| ConfigError::ParseError(e.to_string()))
225 }
226
227 pub fn from_toml(toml: &str) -> Result<Self, ConfigError> {
244 toml::from_str(toml).map_err(|e| ConfigError::ParseError(e.to_string()))
245 }
246
247 #[must_use]
249 pub fn to_json(&self) -> String {
250 serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
251 }
252
253 #[must_use]
255 pub fn to_toml(&self) -> String {
256 toml::to_string_pretty(self).unwrap_or_else(|_| String::new())
257 }
258
259 pub fn add_server(&mut self, name: impl Into<String>, config: ServerConfig) {
261 self.mcp_servers.insert(name.into(), config);
262 }
263
264 #[must_use]
266 pub fn get_server(&self, name: &str) -> Option<&ServerConfig> {
267 self.mcp_servers.get(name)
268 }
269
270 #[must_use]
272 pub fn server_names(&self) -> Vec<&str> {
273 self.mcp_servers.keys().map(String::as_str).collect()
274 }
275
276 #[must_use]
278 pub fn enabled_servers(&self) -> Vec<&str> {
279 self.mcp_servers
280 .iter()
281 .filter(|(_, c)| !c.disabled)
282 .map(|(n, _)| n.as_str())
283 .collect()
284 }
285
286 pub fn client(&self, name: &str) -> Result<Client, ConfigError> {
292 self.client_with_cx(name, Cx::for_request())
293 }
294
295 pub fn client_with_cx(&self, name: &str, cx: Cx) -> Result<Client, ConfigError> {
301 let config = self
302 .mcp_servers
303 .get(name)
304 .ok_or_else(|| ConfigError::ServerNotFound(name.to_string()))?;
305
306 if config.disabled {
307 return Err(ConfigError::ServerDisabled(name.to_string()));
308 }
309
310 spawn_client_from_config(name, config, cx)
311 }
312
313 pub fn merge(&mut self, other: McpConfig) {
317 self.mcp_servers.extend(other.mcp_servers);
318 }
319}
320
321fn spawn_client_from_config(
323 name: &str,
324 config: &ServerConfig,
325 cx: Cx,
326) -> Result<Client, ConfigError> {
327 let mut cmd = Command::new(&config.command);
329 cmd.args(&config.args);
330
331 for (key, value) in &config.env {
333 cmd.env(key, value);
334 }
335
336 if let Some(ref cwd) = config.cwd {
338 cmd.current_dir(cwd);
339 }
340
341 cmd.stdin(Stdio::piped());
343 cmd.stdout(Stdio::piped());
344 cmd.stderr(Stdio::inherit());
345
346 let mut child = cmd.spawn().map_err(|e| {
348 ConfigError::SpawnError(format!("Failed to spawn {}: {}", config.command, e))
349 })?;
350
351 let stdin = child.stdin.take().ok_or_else(|| {
353 ConfigError::SpawnError(format!("Failed to get stdin for server '{name}'"))
354 })?;
355 let stdout = child.stdout.take().ok_or_else(|| {
356 ConfigError::SpawnError(format!("Failed to get stdout for server '{name}'"))
357 })?;
358
359 let transport = StdioTransport::new(stdout, stdin);
361
362 let client_info = ClientInfo {
364 name: format!("fastmcp-client:{name}"),
365 version: env!("CARGO_PKG_VERSION").to_owned(),
366 };
367 let client_capabilities = ClientCapabilities::default();
368
369 create_and_initialize_client(child, transport, cx, client_info, client_capabilities)
371 .map_err(|e| ConfigError::SpawnError(format!("Initialization failed: {e}")))
372}
373
374fn create_and_initialize_client(
376 child: Child,
377 mut transport: StdioTransport<ChildStdout, ChildStdin>,
378 cx: Cx,
379 client_info: ClientInfo,
380 client_capabilities: ClientCapabilities,
381) -> McpResult<Client> {
382 use fastmcp_protocol::{
383 InitializeParams, InitializeResult, JsonRpcMessage, JsonRpcRequest, PROTOCOL_VERSION,
384 };
385 use fastmcp_transport::Transport;
386
387 let params = InitializeParams {
389 protocol_version: PROTOCOL_VERSION.to_string(),
390 capabilities: client_capabilities.clone(),
391 client_info: client_info.clone(),
392 };
393
394 let params_value = serde_json::to_value(¶ms)
395 .map_err(|e| McpError::internal_error(format!("Failed to serialize params: {e}")))?;
396
397 let request = JsonRpcRequest::new("initialize", Some(params_value), 1);
398
399 transport
400 .send(&cx, &JsonRpcMessage::Request(request))
401 .map_err(crate::transport_error_to_mcp)?;
402
403 let response = loop {
405 let message = transport.recv(&cx).map_err(crate::transport_error_to_mcp)?;
406 if let JsonRpcMessage::Response(resp) = message {
407 break resp;
408 }
409 };
410
411 if let Some(error) = response.error {
413 return Err(McpError::new(
414 fastmcp_core::McpErrorCode::from(error.code),
415 error.message,
416 ));
417 }
418
419 let result_value = response
421 .result
422 .ok_or_else(|| McpError::internal_error("No result in initialize response"))?;
423
424 let init_result: InitializeResult = serde_json::from_value(result_value)
425 .map_err(|e| McpError::internal_error(format!("Failed to parse initialize result: {e}")))?;
426
427 let notification = JsonRpcRequest {
429 jsonrpc: std::borrow::Cow::Borrowed(fastmcp_protocol::JSONRPC_VERSION),
430 method: "initialized".to_string(),
431 params: Some(serde_json::json!({})),
432 id: None,
433 };
434
435 transport
436 .send(&cx, &JsonRpcMessage::Request(notification))
437 .map_err(crate::transport_error_to_mcp)?;
438
439 let session = ClientSession::new(
441 client_info,
442 client_capabilities,
443 init_result.server_info,
444 init_result.capabilities,
445 init_result.protocol_version,
446 );
447
448 Ok(Client::from_parts(child, transport, cx, session, 30_000))
450}
451
452#[derive(Debug, Clone)]
461pub struct ConfigLoader {
462 search_paths: Vec<PathBuf>,
464}
465
466impl Default for ConfigLoader {
467 fn default() -> Self {
468 Self::new()
469 }
470}
471
472impl ConfigLoader {
473 #[must_use]
475 pub fn new() -> Self {
476 Self {
477 search_paths: default_config_paths(),
478 }
479 }
480
481 #[must_use]
483 pub fn from_path(path: impl Into<PathBuf>) -> Self {
484 Self {
485 search_paths: vec![path.into()],
486 }
487 }
488
489 #[must_use]
491 pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
492 self.search_paths.push(path.into());
493 self
494 }
495
496 #[must_use]
498 pub fn with_priority_path(mut self, path: impl Into<PathBuf>) -> Self {
499 self.search_paths.insert(0, path.into());
500 self
501 }
502
503 pub fn load(&self) -> Result<McpConfig, ConfigError> {
509 for path in &self.search_paths {
510 if path.exists() {
511 return McpConfig::from_file(path);
512 }
513 }
514
515 Err(ConfigError::NotFound(
516 "No MCP configuration file found".to_string(),
517 ))
518 }
519
520 pub fn load_all(&self) -> McpConfig {
524 let mut config = McpConfig::new();
525
526 for path in &self.search_paths {
527 if path.exists() {
528 if let Ok(loaded) = McpConfig::from_file(path) {
529 config.merge(loaded);
530 }
531 }
532 }
533
534 config
535 }
536
537 #[must_use]
539 pub fn search_paths(&self) -> &[PathBuf] {
540 &self.search_paths
541 }
542
543 #[must_use]
545 pub fn existing_paths(&self) -> Vec<&PathBuf> {
546 self.search_paths.iter().filter(|p| p.exists()).collect()
547 }
548}
549
550#[must_use]
556pub fn default_config_paths() -> Vec<PathBuf> {
557 let mut paths = Vec::new();
558
559 paths.push(PathBuf::from(".mcp/config.json"));
561 paths.push(PathBuf::from(".vscode/mcp.json"));
562
563 if let Some(home) = dirs::home_dir() {
565 #[cfg(target_os = "macos")]
566 {
567 paths.push(home.join("Library/Application Support/Claude/claude_desktop_config.json"));
569 paths.push(home.join(".config/mcp/config.json"));
571 }
572
573 #[cfg(target_os = "windows")]
574 {
575 if let Some(appdata) = dirs::data_dir() {
577 paths.push(appdata.join("Claude/claude_desktop_config.json"));
578 }
579 paths.push(home.join(".mcp/config.json"));
581 }
582
583 #[cfg(all(unix, not(target_os = "macos")))]
584 {
585 if let Some(xdg_config) = env::var("XDG_CONFIG_HOME")
597 .ok()
598 .filter(|s| !s.is_empty())
599 {
600 let xdg_path = PathBuf::from(xdg_config);
601 paths.push(xdg_path.join("mcp/config.json"));
602 paths.push(xdg_path.join("claude/config.json"));
603 } else {
604 paths.push(home.join(".config/mcp/config.json"));
605 paths.push(home.join(".config/claude/config.json"));
606 }
607 }
608 }
609
610 paths
611}
612
613#[must_use]
615pub fn claude_desktop_config_path() -> Option<PathBuf> {
616 #[cfg(target_os = "macos")]
617 {
618 dirs::home_dir()
619 .map(|h| h.join("Library/Application Support/Claude/claude_desktop_config.json"))
620 }
621
622 #[cfg(target_os = "windows")]
623 {
624 dirs::data_dir().map(|d| d.join("Claude/claude_desktop_config.json"))
625 }
626
627 #[cfg(all(unix, not(target_os = "macos")))]
628 {
629 if let Some(xdg_config) = env::var("XDG_CONFIG_HOME")
635 .ok()
636 .filter(|s| !s.is_empty())
637 {
638 Some(PathBuf::from(xdg_config).join("claude/config.json"))
639 } else {
640 dirs::home_dir().map(|h| h.join(".config/claude/config.json"))
641 }
642 }
643
644 #[cfg(not(any(target_os = "macos", target_os = "windows", unix)))]
645 {
646 None
647 }
648}
649
650#[cfg(test)]
655mod tests {
656 use super::*;
657
658 #[test]
659 fn test_empty_config() {
660 let config = McpConfig::new();
661 assert!(config.mcp_servers.is_empty());
662 assert!(config.server_names().is_empty());
663 }
664
665 #[test]
666 fn test_parse_json_config() {
667 let json = r#"{
668 "mcpServers": {
669 "filesystem": {
670 "command": "npx",
671 "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
672 "env": {
673 "DEBUG": "true"
674 }
675 },
676 "other": {
677 "command": "python",
678 "args": ["-m", "my_server"],
679 "disabled": true
680 }
681 }
682 }"#;
683
684 let config = McpConfig::from_json(json).unwrap();
685
686 assert_eq!(config.mcp_servers.len(), 2);
687
688 let fs = config.get_server("filesystem").unwrap();
689 assert_eq!(fs.command, "npx");
690 assert_eq!(fs.args.len(), 3);
691 assert_eq!(fs.env.get("DEBUG"), Some(&"true".to_string()));
692 assert!(!fs.disabled);
693
694 let other = config.get_server("other").unwrap();
695 assert!(other.disabled);
696
697 let enabled = config.enabled_servers();
699 assert_eq!(enabled.len(), 1);
700 assert!(enabled.contains(&"filesystem"));
701 }
702
703 #[test]
704 fn test_parse_toml_config() {
705 let toml = r#"
707 [mcpServers.filesystem]
708 command = "npx"
709 args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
710
711 [mcpServers.filesystem.env]
712 DEBUG = "true"
713 "#;
714
715 let config = McpConfig::from_toml(toml).unwrap();
716
717 let fs = config.get_server("filesystem").unwrap();
718 assert_eq!(fs.command, "npx");
719 assert_eq!(fs.args.len(), 3);
720 assert_eq!(fs.env.get("DEBUG"), Some(&"true".to_string()));
721 }
722
723 #[test]
724 fn test_server_config_builder() {
725 let config = ServerConfig::new("python")
726 .with_args(["-m", "my_server"])
727 .with_env("EXAMPLE_ENV", "example-value")
728 .with_cwd("/opt/server");
729
730 assert_eq!(config.command, "python");
731 assert_eq!(config.args, vec!["-m", "my_server"]);
732 assert_eq!(
733 config.env.get("EXAMPLE_ENV"),
734 Some(&"example-value".to_string())
735 );
736 assert_eq!(config.cwd, Some("/opt/server".to_string()));
737 assert!(!config.disabled);
738 }
739
740 #[test]
741 fn test_config_add_and_get_server() {
742 let mut config = McpConfig::new();
743
744 config.add_server("test", ServerConfig::new("echo").with_args(["hello"]));
745
746 assert_eq!(config.server_names().len(), 1);
747 assert!(config.get_server("test").is_some());
748 assert!(config.get_server("nonexistent").is_none());
749 }
750
751 #[test]
752 fn test_config_merge() {
753 let mut base = McpConfig::new();
754 base.add_server("server1", ServerConfig::new("cmd1"));
755 base.add_server("server2", ServerConfig::new("cmd2"));
756
757 let mut overlay = McpConfig::new();
758 overlay.add_server("server2", ServerConfig::new("cmd2-override"));
759 overlay.add_server("server3", ServerConfig::new("cmd3"));
760
761 base.merge(overlay);
762
763 assert_eq!(base.mcp_servers.len(), 3);
764 assert_eq!(base.get_server("server1").unwrap().command, "cmd1");
765 assert_eq!(base.get_server("server2").unwrap().command, "cmd2-override");
766 assert_eq!(base.get_server("server3").unwrap().command, "cmd3");
767 }
768
769 #[test]
770 fn test_config_serialization() {
771 let mut config = McpConfig::new();
772 config.add_server(
773 "test",
774 ServerConfig::new("npx")
775 .with_args(["-y", "server"])
776 .with_env("KEY", "value"),
777 );
778
779 let json = config.to_json();
780 assert!(json.contains("mcpServers"));
781 assert!(json.contains("npx"));
782
783 let toml = config.to_toml();
784 assert!(toml.contains("mcpServers"));
785 assert!(toml.contains("npx"));
786 }
787
788 #[test]
789 fn test_config_loader() {
790 let loader = ConfigLoader::new()
791 .with_path("/custom/path/config.json")
792 .with_priority_path("/priority/config.json");
793
794 let paths = loader.search_paths();
795 assert!(
796 paths
797 .first()
798 .unwrap()
799 .to_str()
800 .unwrap()
801 .contains("priority")
802 );
803 assert!(paths.last().unwrap().to_str().unwrap().contains("custom"));
804 }
805
806 #[test]
807 fn test_error_server_not_found() {
808 let config = McpConfig::new();
809 let result = config.client("nonexistent");
810 assert!(matches!(result, Err(ConfigError::ServerNotFound(_))));
811 }
812
813 #[test]
814 fn test_error_server_disabled() {
815 let mut config = McpConfig::new();
816 config.add_server("disabled", ServerConfig::new("echo").disabled());
817
818 let result = config.client("disabled");
819 assert!(matches!(result, Err(ConfigError::ServerDisabled(_))));
820 }
821
822 #[test]
823 fn test_default_config_paths_not_empty() {
824 let paths = default_config_paths();
825 assert!(!paths.is_empty());
826 }
827
828 #[test]
829 fn test_config_error_display() {
830 let errors = vec![
831 (ConfigError::NotFound("path".into()), "not found"),
832 (
833 ConfigError::ServerNotFound("name".into()),
834 "server not found",
835 ),
836 (ConfigError::ServerDisabled("name".into()), "disabled"),
837 (ConfigError::ParseError("msg".into()), "parse"),
838 ];
839
840 for (error, expected) in errors {
841 assert!(
842 error.to_string().to_lowercase().contains(expected),
843 "Expected '{}' to contain '{}'",
844 error,
845 expected
846 );
847 }
848 }
849
850 #[test]
851 fn test_config_error_source() {
852 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "no access");
853 let config_err = ConfigError::ReadError(io_err);
854 assert!(std::error::Error::source(&config_err).is_some());
855
856 let not_found = ConfigError::NotFound("path".into());
857 assert!(std::error::Error::source(¬_found).is_none());
858
859 let parse_err = ConfigError::ParseError("bad".into());
860 assert!(std::error::Error::source(&parse_err).is_none());
861 }
862
863 #[test]
864 fn test_config_error_into_mcp_error() {
865 let err = ConfigError::ServerNotFound("test-srv".into());
866 let mcp_err: McpError = err.into();
867 assert_eq!(mcp_err.code, fastmcp_core::McpErrorCode::InternalError);
868 assert!(mcp_err.message.contains("test-srv"));
869 }
870
871 #[test]
872 fn test_server_config_disabled_builder() {
873 let config = ServerConfig::new("echo").disabled();
874 assert!(config.disabled);
875 }
876
877 #[test]
878 fn test_config_json_round_trip() {
879 let mut config = McpConfig::new();
880 config.add_server(
881 "srv",
882 ServerConfig::new("cmd")
883 .with_args(["a1", "a2"])
884 .with_env("K", "V")
885 .with_cwd("/tmp"),
886 );
887
888 let json = config.to_json();
889 let restored = McpConfig::from_json(&json).expect("round-trip parse");
890 let srv = restored.get_server("srv").expect("server present");
891 assert_eq!(srv.command, "cmd");
892 assert_eq!(srv.args, vec!["a1", "a2"]);
893 assert_eq!(srv.env.get("K"), Some(&"V".to_string()));
894 assert_eq!(srv.cwd.as_deref(), Some("/tmp"));
895 }
896
897 #[test]
898 fn test_config_toml_round_trip() {
899 let mut config = McpConfig::new();
900 config.add_server(
901 "srv",
902 ServerConfig::new("python").with_args(["-m", "server"]),
903 );
904
905 let toml_str = config.to_toml();
906 let restored = McpConfig::from_toml(&toml_str).expect("round-trip parse");
907 let srv = restored.get_server("srv").expect("server present");
908 assert_eq!(srv.command, "python");
909 assert_eq!(srv.args, vec!["-m", "server"]);
910 }
911
912 #[test]
913 fn test_parse_invalid_json() {
914 let result = McpConfig::from_json("not json {{{");
915 assert!(matches!(result, Err(ConfigError::ParseError(_))));
916 }
917
918 #[test]
919 fn test_parse_invalid_toml() {
920 let result = McpConfig::from_toml("[invalid toml = = =");
921 assert!(matches!(result, Err(ConfigError::ParseError(_))));
922 }
923
924 #[test]
925 fn test_from_file_not_found() {
926 let result = McpConfig::from_file("/nonexistent/path/to/config.json");
927 assert!(matches!(result, Err(ConfigError::NotFound(_))));
928 }
929
930 #[test]
931 fn test_config_merge_empty() {
932 let mut base = McpConfig::new();
933 base.add_server("a", ServerConfig::new("cmd_a"));
934 base.merge(McpConfig::new());
935 assert_eq!(base.mcp_servers.len(), 1);
936 assert!(base.get_server("a").is_some());
937 }
938
939 #[test]
940 fn test_config_loader_from_path() {
941 let loader = ConfigLoader::from_path("/specific/path.json");
942 assert_eq!(loader.search_paths().len(), 1);
943 assert_eq!(
944 loader.search_paths()[0],
945 PathBuf::from("/specific/path.json")
946 );
947 }
948
949 #[test]
950 fn test_config_loader_load_no_files_exist() {
951 let loader =
952 ConfigLoader::from_path("/nonexistent/a.json").with_path("/nonexistent/b.json");
953 let result = loader.load();
954 assert!(matches!(result, Err(ConfigError::NotFound(_))));
955 }
956
957 #[test]
958 fn test_config_loader_load_all_no_files() {
959 let loader = ConfigLoader::from_path("/nonexistent/a.json");
960 let config = loader.load_all();
961 assert!(config.mcp_servers.is_empty());
962 }
963
964 #[test]
965 fn test_config_loader_existing_paths_empty() {
966 let loader = ConfigLoader::from_path("/nonexistent/file.json");
967 assert!(loader.existing_paths().is_empty());
968 }
969
970 #[test]
971 fn test_config_loader_default() {
972 let loader = ConfigLoader::default();
973 assert!(!loader.search_paths().is_empty());
974 }
975
976 #[test]
977 fn test_enabled_servers_all_disabled() {
978 let mut config = McpConfig::new();
979 config.add_server("a", ServerConfig::new("cmd").disabled());
980 config.add_server("b", ServerConfig::new("cmd").disabled());
981 assert!(config.enabled_servers().is_empty());
982 }
983
984 #[test]
985 fn test_claude_desktop_config_path_is_some() {
986 let path = claude_desktop_config_path();
988 if dirs::home_dir().is_some() {
990 assert!(path.is_some());
991 }
992 }
993
994 #[test]
995 fn test_server_config_with_multiple_env_vars() {
996 let config = ServerConfig::new("cmd")
997 .with_env("A", "1")
998 .with_env("B", "2")
999 .with_env("C", "3");
1000 assert_eq!(config.env.len(), 3);
1001 assert_eq!(config.env.get("A"), Some(&"1".to_string()));
1002 assert_eq!(config.env.get("B"), Some(&"2".to_string()));
1003 assert_eq!(config.env.get("C"), Some(&"3".to_string()));
1004 }
1005
1006 #[test]
1007 fn test_config_spawn_error_display() {
1008 let err = ConfigError::SpawnError("process died".into());
1009 let msg = err.to_string().to_lowercase();
1010 assert!(msg.contains("spawn"));
1011 assert!(msg.contains("process died"));
1012 }
1013
1014 #[test]
1015 fn test_config_empty_json_object() {
1016 let config = McpConfig::from_json("{}").expect("parse empty object");
1017 assert!(config.mcp_servers.is_empty());
1018 }
1019
1020 #[test]
1021 fn test_config_json_with_defaults() {
1022 let json = r#"{"mcpServers": {"srv": {"command": "echo"}}}"#;
1023 let config = McpConfig::from_json(json).expect("parse");
1024 let srv = config.get_server("srv").unwrap();
1025 assert!(srv.args.is_empty());
1026 assert!(srv.env.is_empty());
1027 assert!(srv.cwd.is_none());
1028 assert!(!srv.disabled);
1029 }
1030}