Skip to main content

fastmcp_client/
mcp_config.rs

1//! MCP Configuration file support for server registry.
2//!
3//! This module provides configuration file parsing and client creation from config.
4//! It supports the standard MCP configuration format used by Claude Desktop and other clients.
5//!
6//! # Configuration Format
7//!
8//! The standard format is JSON with the following structure:
9//!
10//! ```json
11//! {
12//!     "mcpServers": {
13//!         "server-name": {
14//!             "command": "npx",
15//!             "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path"],
16//!             "env": {
17//!                 "EXAMPLE_ENV": "example-value"
18//!             }
19//!         }
20//!     }
21//! }
22//! ```
23//!
24//! # Usage
25//!
26//! ```ignore
27//! use fastmcp_rust::mcp_config::{McpConfig, ConfigLoader};
28//!
29//! // Load from default location
30//! let config = ConfigLoader::default()?.load()?;
31//!
32//! // Create a client for a specific server
33//! let client = config.client("filesystem")?;
34//!
35//! // Or load from a specific path
36//! let config = McpConfig::from_file("/path/to/config.json")?;
37//! ```
38//!
39//! # Default Locations
40//!
41//! Config files are searched in order:
42//! - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
43//! - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
44//! - Linux: `~/.config/claude/config.json`
45//!
46//! Project-specific configs can be in `.vscode/mcp.json` or `.mcp/config.json`.
47
48use 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// ============================================================================
62// Configuration Types
63// ============================================================================
64
65/// MCP configuration file containing server definitions.
66#[derive(Debug, Clone, Default, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct McpConfig {
69    /// Server configurations keyed by name.
70    #[serde(default)]
71    pub mcp_servers: HashMap<String, ServerConfig>,
72}
73
74/// Configuration for a single MCP server.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct ServerConfig {
78    /// Command to execute (e.g., "npx", "uvx", "python").
79    pub command: String,
80
81    /// Arguments to pass to the command.
82    #[serde(default)]
83    pub args: Vec<String>,
84
85    /// Environment variables to set.
86    #[serde(default)]
87    pub env: HashMap<String, String>,
88
89    /// Working directory for the server process.
90    #[serde(default)]
91    pub cwd: Option<String>,
92
93    /// Whether the server is disabled.
94    #[serde(default)]
95    pub disabled: bool,
96}
97
98impl ServerConfig {
99    /// Creates a new server configuration.
100    #[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    /// Adds arguments.
112    #[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    /// Adds an environment variable.
119    #[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    /// Sets the working directory.
126    #[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    /// Sets the disabled flag.
133    #[must_use]
134    pub fn disabled(mut self) -> Self {
135        self.disabled = true;
136        self
137    }
138}
139
140// ============================================================================
141// Configuration Errors
142// ============================================================================
143
144/// Errors that can occur during configuration operations.
145#[derive(Debug)]
146pub enum ConfigError {
147    /// Configuration file not found.
148    NotFound(String),
149    /// Failed to read configuration file.
150    ReadError(std::io::Error),
151    /// Failed to parse configuration.
152    ParseError(String),
153    /// Server not found in configuration.
154    ServerNotFound(String),
155    /// Server is disabled.
156    ServerDisabled(String),
157    /// Failed to spawn server process.
158    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
189// ============================================================================
190// Configuration Loading
191// ============================================================================
192
193impl McpConfig {
194    /// Creates an empty configuration.
195    #[must_use]
196    pub fn new() -> Self {
197        Self::default()
198    }
199
200    /// Loads configuration from a JSON file.
201    ///
202    /// # Errors
203    ///
204    /// Returns an error if the file cannot be read or parsed.
205    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    /// Parses configuration from a JSON string.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if parsing fails.
223    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    /// Parses configuration from a TOML string.
228    ///
229    /// TOML format is an alternative supported by some MCP clients:
230    ///
231    /// ```toml
232    /// [mcp_servers.filesystem]
233    /// command = "npx"
234    /// args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"]
235    ///
236    /// [mcp_servers.filesystem.env]
237    /// EXAMPLE_ENV = "example-value"
238    /// ```
239    ///
240    /// # Errors
241    ///
242    /// Returns an error if parsing fails.
243    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    /// Serializes configuration to JSON.
248    #[must_use]
249    pub fn to_json(&self) -> String {
250        serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
251    }
252
253    /// Serializes configuration to TOML.
254    #[must_use]
255    pub fn to_toml(&self) -> String {
256        toml::to_string_pretty(self).unwrap_or_else(|_| String::new())
257    }
258
259    /// Adds a server configuration.
260    pub fn add_server(&mut self, name: impl Into<String>, config: ServerConfig) {
261        self.mcp_servers.insert(name.into(), config);
262    }
263
264    /// Gets a server configuration by name.
265    #[must_use]
266    pub fn get_server(&self, name: &str) -> Option<&ServerConfig> {
267        self.mcp_servers.get(name)
268    }
269
270    /// Returns all server names.
271    #[must_use]
272    pub fn server_names(&self) -> Vec<&str> {
273        self.mcp_servers.keys().map(String::as_str).collect()
274    }
275
276    /// Returns enabled server names.
277    #[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    /// Creates a client for a server by name.
287    ///
288    /// # Errors
289    ///
290    /// Returns an error if the server is not found, disabled, or fails to start.
291    pub fn client(&self, name: &str) -> Result<Client, ConfigError> {
292        self.client_with_cx(name, Cx::for_request())
293    }
294
295    /// Creates a client with a provided Cx for cancellation support.
296    ///
297    /// # Errors
298    ///
299    /// Returns an error if the server is not found, disabled, or fails to start.
300    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    /// Merges another configuration into this one.
314    ///
315    /// Servers from `other` override servers with the same name.
316    pub fn merge(&mut self, other: McpConfig) {
317        self.mcp_servers.extend(other.mcp_servers);
318    }
319}
320
321/// Spawns a client from a server configuration.
322fn spawn_client_from_config(
323    name: &str,
324    config: &ServerConfig,
325    cx: Cx,
326) -> Result<Client, ConfigError> {
327    // Build the command
328    let mut cmd = Command::new(&config.command);
329    cmd.args(&config.args);
330
331    // Set environment variables
332    for (key, value) in &config.env {
333        cmd.env(key, value);
334    }
335
336    // Set working directory if specified
337    if let Some(ref cwd) = config.cwd {
338        cmd.current_dir(cwd);
339    }
340
341    // Configure stdio
342    cmd.stdin(Stdio::piped());
343    cmd.stdout(Stdio::piped());
344    cmd.stderr(Stdio::inherit());
345
346    // Spawn the process
347    let mut child = cmd.spawn().map_err(|e| {
348        ConfigError::SpawnError(format!("Failed to spawn {}: {}", config.command, e))
349    })?;
350
351    // Get stdin/stdout handles
352    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    // Create transport
360    let transport = StdioTransport::new(stdout, stdin);
361
362    // Create client info
363    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 client and initialize
370    create_and_initialize_client(child, transport, cx, client_info, client_capabilities)
371        .map_err(|e| ConfigError::SpawnError(format!("Initialization failed: {e}")))
372}
373
374/// Creates a client and performs initialization handshake.
375fn 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    // Send initialize request
388    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(&params)
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    // Receive response
404    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    // Check for error
412    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    // Parse result
420    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    // Send initialized notification
428    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    // Create session
440    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    // Return client
449    Ok(Client::from_parts(child, transport, cx, session, 30_000))
450}
451
452// ============================================================================
453// Configuration Loader
454// ============================================================================
455
456/// Loader for finding and loading MCP configurations.
457///
458/// This handles platform-specific default locations and searching
459/// multiple potential config file paths.
460#[derive(Debug, Clone)]
461pub struct ConfigLoader {
462    /// Paths to search for configuration files.
463    search_paths: Vec<PathBuf>,
464}
465
466impl Default for ConfigLoader {
467    fn default() -> Self {
468        Self::new()
469    }
470}
471
472impl ConfigLoader {
473    /// Creates a new loader with default search paths.
474    #[must_use]
475    pub fn new() -> Self {
476        Self {
477            search_paths: default_config_paths(),
478        }
479    }
480
481    /// Creates a loader with a single specific path.
482    #[must_use]
483    pub fn from_path(path: impl Into<PathBuf>) -> Self {
484        Self {
485            search_paths: vec![path.into()],
486        }
487    }
488
489    /// Adds a search path.
490    #[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    /// Prepends a search path (searched first).
497    #[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    /// Loads configuration from the first existing file.
504    ///
505    /// # Errors
506    ///
507    /// Returns an error if no configuration file is found or parsing fails.
508    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    /// Loads and merges all existing configuration files.
521    ///
522    /// Later files override earlier ones.
523    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    /// Returns all search paths.
538    #[must_use]
539    pub fn search_paths(&self) -> &[PathBuf] {
540        &self.search_paths
541    }
542
543    /// Returns paths that exist.
544    #[must_use]
545    pub fn existing_paths(&self) -> Vec<&PathBuf> {
546        self.search_paths.iter().filter(|p| p.exists()).collect()
547    }
548}
549
550// ============================================================================
551// Default Config Paths
552// ============================================================================
553
554/// Returns platform-specific default configuration paths.
555#[must_use]
556pub fn default_config_paths() -> Vec<PathBuf> {
557    let mut paths = Vec::new();
558
559    // Project-specific configs (current directory)
560    paths.push(PathBuf::from(".mcp/config.json"));
561    paths.push(PathBuf::from(".vscode/mcp.json"));
562
563    // User-level configs
564    if let Some(home) = dirs::home_dir() {
565        #[cfg(target_os = "macos")]
566        {
567            // Claude Desktop on macOS
568            paths.push(home.join("Library/Application Support/Claude/claude_desktop_config.json"));
569            // Generic MCP config
570            paths.push(home.join(".config/mcp/config.json"));
571        }
572
573        #[cfg(target_os = "windows")]
574        {
575            // Claude Desktop on Windows
576            if let Some(appdata) = dirs::data_dir() {
577                paths.push(appdata.join("Claude/claude_desktop_config.json"));
578            }
579            // Generic MCP config
580            paths.push(home.join(".mcp/config.json"));
581        }
582
583        #[cfg(all(unix, not(target_os = "macos")))]
584        {
585            // XDG config directory — applies to Linux and every other
586            // non-macOS Unix (FreeBSD, NetBSD, OpenBSD, Illumos, etc.).
587            // Mirrors the same broadening done in
588            // `claude_desktop_config_path` so callers iterating both
589            // surfaces don't see a Linux/BSD asymmetry.
590            //
591            // Per XDG Base Directory Specification: empty XDG_CONFIG_HOME
592            // is equivalent to unset (`env::var` returns Ok("") for an
593            // empty value, so check `is_empty` explicitly to avoid
594            // emitting relative `mcp/config.json` / `claude/config.json`
595            // paths against the caller's CWD).
596            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/// Returns the Claude Desktop configuration path for the current platform.
614#[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        // Per the XDG Base Directory Specification: "If $XDG_CONFIG_HOME
630        // is either not set or empty, a default equal to $HOME/.config
631        // should be used." `env::var` returns Ok("") for an empty value,
632        // so check `is_empty` explicitly to avoid emitting a relative
633        // `claude/config.json` path.
634        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// ============================================================================
651// Tests
652// ============================================================================
653
654#[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        // enabled_servers should only return non-disabled servers
698        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        // Note: serde rename_all="camelCase" applies to TOML too
706        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(&not_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        // On all supported platforms, this should return Some when home dir is available
987        let path = claude_desktop_config_path();
988        // Home dir is usually available in CI and dev environments
989        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}