Skip to main content

mcp_execution_cli/commands/
common.rs

1//! Common utilities shared across CLI commands.
2//!
3//! Provides shared functionality for building server configurations from CLI arguments
4//! and loading MCP server definitions from `~/.claude/mcp.json`.
5
6use anyhow::{Context, Result, bail};
7use mcp_execution_core::{ServerConfig, ServerConfigBuilder, ServerId};
8use serde::Deserialize;
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::time::Duration;
12
13/// MCP configuration file structure (`~/.claude/mcp.json`).
14///
15/// The `mcp_servers` field defaults to an empty map so that an absent file or
16/// a file containing only `{}` does not produce a deserialization error.
17#[derive(Debug, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct McpConfig {
20    /// Map of server name → server configuration entry.
21    #[serde(default)]
22    pub mcp_servers: HashMap<String, McpServerEntry>,
23}
24
25/// Individual MCP server configuration entry from `mcp.json`.
26#[derive(Debug, Clone, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct McpServerEntry {
29    /// Command to execute (binary name or absolute path).
30    pub command: String,
31    /// Arguments to pass to the command.
32    #[serde(default)]
33    pub args: Vec<String>,
34    /// Environment variables for the server process.
35    #[serde(default)]
36    pub env: HashMap<String, String>,
37    /// Connection (handshake) timeout in seconds, overriding the 30-second
38    /// default when set. JSON key: `connectTimeoutSecs`.
39    #[serde(default)]
40    pub connect_timeout_secs: Option<u64>,
41    /// Tool discovery timeout in seconds, overriding the 30-second default
42    /// when set. JSON key: `discoverTimeoutSecs`.
43    #[serde(default)]
44    pub discover_timeout_secs: Option<u64>,
45}
46
47/// Loads MCP configuration from the given path.
48///
49/// This is the primary, testable entry point. [`load_mcp_config`] is a thin
50/// wrapper that resolves the default `~/.claude/mcp.json` location.
51///
52/// # Errors
53///
54/// Returns an error if the file cannot be read or the JSON is malformed.
55///
56/// # Examples
57///
58/// ```no_run
59/// use mcp_execution_cli::commands::common::load_mcp_config_from;
60/// use std::path::Path;
61///
62/// let config = load_mcp_config_from(Path::new("/tmp/mcp.json")).unwrap();
63/// println!("{} servers configured", config.mcp_servers.len());
64/// ```
65pub fn load_mcp_config_from(path: &Path) -> Result<McpConfig> {
66    let content = std::fs::read_to_string(path)
67        .with_context(|| format!("failed to read MCP config from {}", path.display()))?;
68
69    serde_json::from_str(&content).context("failed to parse MCP config JSON")
70}
71
72/// Loads MCP configuration from `~/.claude/mcp.json`.
73///
74/// Delegates to [`load_mcp_config_from`] after resolving the default path.
75///
76/// # Errors
77///
78/// Returns an error if the home directory cannot be determined, the file
79/// cannot be read, or the JSON is malformed.
80pub fn load_mcp_config() -> Result<McpConfig> {
81    let home = dirs::home_dir().context("failed to get home directory")?;
82    load_mcp_config_from(&home.join(".claude").join("mcp.json"))
83}
84
85/// Lists all servers defined in the given `mcp.json` file.
86///
87/// Returns an empty list when the file does not exist — the primary testable
88/// entry point for the "fresh machine" code path (no config file yet).
89///
90/// # Errors
91///
92/// Returns an error if the file exists but cannot be read or parsed.
93///
94/// # Examples
95///
96/// ```no_run
97/// use mcp_execution_cli::commands::common::list_mcp_servers_from;
98/// use std::path::Path;
99///
100/// let servers = list_mcp_servers_from(Path::new("/tmp/mcp.json")).unwrap();
101/// println!("{} servers", servers.len());
102/// ```
103pub fn list_mcp_servers_from(path: &Path) -> Result<Vec<(String, McpServerEntry)>> {
104    if !path.exists() {
105        return Ok(Vec::new());
106    }
107    let config = load_mcp_config_from(path)?;
108    Ok(config.mcp_servers.into_iter().collect())
109}
110
111/// Lists all servers defined in `~/.claude/mcp.json`.
112///
113/// Returns an empty list when the config file does not exist so that
114/// `server list` shows a clear empty result rather than hard-failing.
115///
116/// Delegates to [`list_mcp_servers_from`] after resolving the default path.
117///
118/// # Errors
119///
120/// Returns an error if the home directory cannot be determined, or the config
121/// file exists but cannot be read or parsed.
122///
123/// # Examples
124///
125/// ```no_run
126/// use mcp_execution_cli::commands::common::list_mcp_servers;
127///
128/// for (name, entry) in list_mcp_servers().unwrap() {
129///     println!("{}: {} {:?}", name, entry.command, entry.args);
130/// }
131/// ```
132pub fn list_mcp_servers() -> Result<Vec<(String, McpServerEntry)>> {
133    let home = dirs::home_dir().context("failed to get home directory")?;
134    list_mcp_servers_from(&home.join(".claude").join("mcp.json"))
135}
136
137/// Retrieves a named server from `~/.claude/mcp.json`.
138///
139/// # Arguments
140///
141/// * `name` - Server name as defined under `mcpServers` in `mcp.json`
142///
143/// # Returns
144///
145/// A tuple of `(ServerId, ServerConfig, McpServerEntry)`:
146/// - [`ServerId`] — typed server identifier
147/// - [`ServerConfig`] — ready-to-use connection config for `Introspector`
148/// - [`McpServerEntry`] — raw entry for display purposes (command, args, env)
149///
150/// # Errors
151///
152/// Returns an error if the config file is missing, malformed, or the named
153/// server is not present.
154///
155/// # Examples
156///
157/// ```no_run
158/// use mcp_execution_cli::commands::common::get_mcp_server;
159///
160/// let (id, _config, entry) = get_mcp_server("github").unwrap();
161/// assert_eq!(id.as_str(), "github");
162/// println!("command: {}", entry.command);
163/// ```
164pub fn get_mcp_server(name: &str) -> Result<(ServerId, ServerConfig, McpServerEntry)> {
165    let config = load_mcp_config()?;
166
167    let entry = config
168        .mcp_servers
169        .get(name)
170        .with_context(|| {
171            format!(
172                "server '{name}' not found in ~/.claude/mcp.json\n\
173                 Hint: ensure the server is defined in ~/.claude/mcp.json under \"mcpServers\""
174            )
175        })?
176        .clone();
177
178    let server_config = build_core_config(&entry);
179    Ok((ServerId::new(name), server_config, entry))
180}
181
182/// Loads server configuration from `~/.claude/mcp.json` by server name.
183///
184/// Convenience wrapper around [`get_mcp_server`] that drops the raw entry.
185///
186/// # Arguments
187///
188/// * `name` - Server name from `mcp.json` (e.g., `"github"`)
189///
190/// # Errors
191///
192/// Returns an error if the config file is missing, malformed, or the server
193/// name is not present.
194///
195/// # Examples
196///
197/// ```no_run
198/// use mcp_execution_cli::commands::common::load_server_from_config;
199///
200/// let (id, config) = load_server_from_config("github").unwrap();
201/// assert_eq!(id.as_str(), "github");
202/// ```
203pub fn load_server_from_config(name: &str) -> Result<(ServerId, ServerConfig)> {
204    let (id, config, _) = get_mcp_server(name)?;
205    Ok((id, config))
206}
207
208/// Builds a core [`ServerConfig`] from an [`McpServerEntry`].
209fn build_core_config(entry: &McpServerEntry) -> ServerConfig {
210    let mut builder = ServerConfig::builder().command(entry.command.clone());
211
212    if !entry.args.is_empty() {
213        builder = builder.args(entry.args.clone());
214    }
215
216    for (key, value) in &entry.env {
217        builder = builder.env(key.clone(), value.clone());
218    }
219
220    if let Some(secs) = entry.connect_timeout_secs {
221        builder = builder.connect_timeout(Duration::from_secs(secs));
222    }
223
224    if let Some(secs) = entry.discover_timeout_secs {
225        builder = builder.discover_timeout(Duration::from_secs(secs));
226    }
227
228    builder.build()
229}
230
231/// Builds `ServerConfig` from CLI arguments.
232///
233/// Parses CLI arguments into a `ServerConfig` for connecting to an MCP server.
234///
235/// # Arguments
236///
237/// * `server` - Server command (binary name or path)
238/// * `args` - Arguments to pass to the server command
239/// * `env` - Environment variables in KEY=VALUE format
240/// * `cwd` - Working directory for the server process
241/// * `http` - HTTP transport URL
242/// * `sse` - SSE transport URL
243/// * `headers` - HTTP headers in KEY=VALUE format
244/// * `connect_timeout_secs` - Connection (handshake) timeout override, in
245///   seconds. Same semantics as `mcp.json`'s `connectTimeoutSecs`: must be
246///   greater than zero and at most 600 seconds, enforced by
247///   [`validate_server_config`](mcp_execution_core::validate_server_config)
248///   at connect time.
249/// * `discover_timeout_secs` - Tool discovery timeout override, in seconds.
250///   Same semantics as `mcp.json`'s `discoverTimeoutSecs`.
251///
252/// # Errors
253///
254/// Returns an error if environment variables or headers are not in KEY=VALUE format.
255///
256/// # Panics
257///
258/// Panics if `server` is `None` when using stdio transport (i.e., when neither
259/// `http` nor `sse` is provided). This is enforced by CLI argument validation.
260///
261/// # Examples
262///
263/// ```
264/// use mcp_execution_cli::commands::common::build_server_config;
265///
266/// // Stdio transport
267/// let (id, config) = build_server_config(
268///     Some("github-mcp-server".to_string()),
269///     vec!["stdio".to_string()],
270///     vec!["TOKEN=abc".to_string()],
271///     None,
272///     None,
273///     None,
274///     vec![],
275///     None,
276///     None,
277/// ).unwrap();
278///
279/// assert_eq!(id.as_str(), "github-mcp-server");
280/// assert_eq!(config.args(), &["stdio"]);
281/// ```
282#[allow(clippy::too_many_arguments)] // mirrors the CLI's flat argument surface; grouping would add an abstraction for no behavioral benefit
283pub fn build_server_config(
284    server: Option<String>,
285    args: Vec<String>,
286    env: Vec<String>,
287    cwd: Option<String>,
288    http: Option<String>,
289    sse: Option<String>,
290    headers: Vec<String>,
291    connect_timeout_secs: Option<u64>,
292    discover_timeout_secs: Option<u64>,
293) -> Result<(ServerId, ServerConfig)> {
294    // Parse environment variables / headers in KEY=VALUE format
295    let parse_key_value = |s: &str, kind: &str| -> Result<(String, String)> {
296        let parts: Vec<&str> = s.splitn(2, '=').collect();
297        if parts.len() != 2 {
298            bail!("invalid {kind} format: '{s}' (expected KEY=VALUE)");
299        }
300        if parts[0].is_empty() {
301            bail!("invalid {kind} format: '{s}' (key cannot be empty)");
302        }
303        Ok((parts[0].to_string(), parts[1].to_string()))
304    };
305
306    // Build config based on transport type
307    let (server_id, mut builder) = if let Some(url) = http {
308        // HTTP transport
309        let id = ServerId::new(&url);
310        let mut builder = ServerConfig::builder().http_transport(url);
311
312        for header in headers {
313            let (key, value) = parse_key_value(&header, "header")?;
314            builder = builder.header(key, value);
315        }
316
317        (id, builder)
318    } else if let Some(url) = sse {
319        // SSE transport
320        let id = ServerId::new(&url);
321        let mut builder = ServerConfig::builder().sse_transport(url);
322
323        for header in headers {
324            let (key, value) = parse_key_value(&header, "header")?;
325            builder = builder.header(key, value);
326        }
327
328        (id, builder)
329    } else {
330        // Stdio transport (default)
331        let command = server.expect("server is required for stdio transport");
332        let id = ServerId::new(&command);
333        let mut builder: ServerConfigBuilder = ServerConfig::builder().command(command);
334
335        if !args.is_empty() {
336            builder = builder.args(args);
337        }
338
339        for env_var in env {
340            let (key, value) = parse_key_value(&env_var, "environment variable")?;
341            builder = builder.env(key, value);
342        }
343
344        if let Some(dir) = cwd {
345            builder = builder.cwd(PathBuf::from(dir));
346        }
347
348        (id, builder)
349    };
350
351    if let Some(secs) = connect_timeout_secs {
352        builder = builder.connect_timeout(Duration::from_secs(secs));
353    }
354
355    if let Some(secs) = discover_timeout_secs {
356        builder = builder.discover_timeout(Duration::from_secs(secs));
357    }
358
359    Ok((server_id, builder.build()))
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use std::io::Write;
366
367    /// Creates a temporary mcp.json file for testing.
368    fn create_test_config(content: &str) -> tempfile::NamedTempFile {
369        let mut file = tempfile::NamedTempFile::new().unwrap();
370        file.write_all(content.as_bytes()).unwrap();
371        file.flush().unwrap();
372        file
373    }
374
375    #[test]
376    fn test_load_mcp_config_from_valid() {
377        let json = r#"{"mcpServers": {"github": {"command": "node", "args": ["server.js"]}}}"#;
378        let file = create_test_config(json);
379
380        let config = load_mcp_config_from(file.path()).unwrap();
381        assert_eq!(config.mcp_servers.len(), 1);
382        assert!(config.mcp_servers.contains_key("github"));
383    }
384
385    #[test]
386    fn test_load_mcp_config_from_empty_servers() {
387        // mcp_servers defaults to empty map when key is absent
388        let json = r"{}";
389        let file = create_test_config(json);
390
391        let config = load_mcp_config_from(file.path()).unwrap();
392        assert!(config.mcp_servers.is_empty());
393    }
394
395    #[test]
396    fn test_load_mcp_config_from_minimal_server() {
397        // Server with only command (args and env should default)
398        let json = r#"{"mcpServers": {"minimal": {"command": "python"}}}"#;
399        let file = create_test_config(json);
400
401        let config = load_mcp_config_from(file.path()).unwrap();
402        let entry = &config.mcp_servers["minimal"];
403        assert_eq!(entry.command, "python");
404        assert!(entry.args.is_empty());
405        assert!(entry.env.is_empty());
406    }
407
408    #[test]
409    fn test_load_mcp_config_from_multiple_servers() {
410        let json = r#"{
411            "mcpServers": {
412                "server1": {"command": "node", "args": ["s1.js"]},
413                "server2": {"command": "python", "args": ["s2.py"]}
414            }
415        }"#;
416        let file = create_test_config(json);
417
418        let config = load_mcp_config_from(file.path()).unwrap();
419        assert_eq!(config.mcp_servers.len(), 2);
420        assert!(config.mcp_servers.contains_key("server1"));
421        assert!(config.mcp_servers.contains_key("server2"));
422    }
423
424    #[test]
425    fn test_load_mcp_config_from_not_found() {
426        let result = load_mcp_config_from(Path::new("/nonexistent/path/mcp.json"));
427        assert!(result.is_err());
428        assert!(result.unwrap_err().to_string().contains("failed to read"));
429    }
430
431    #[test]
432    fn test_load_mcp_config_from_malformed_json() {
433        let file = create_test_config("not valid json");
434        let result = load_mcp_config_from(file.path());
435        assert!(result.is_err());
436        assert!(result.unwrap_err().to_string().contains("parse MCP config"));
437    }
438
439    #[test]
440    fn test_build_server_config_stdio() {
441        let (id, config) = build_server_config(
442            Some("github-mcp-server".to_string()),
443            vec!["stdio".to_string()],
444            vec!["TOKEN=abc123".to_string()],
445            None,
446            None,
447            None,
448            vec![],
449            None,
450            None,
451        )
452        .unwrap();
453
454        assert_eq!(id.as_str(), "github-mcp-server");
455        assert_eq!(config.command(), "github-mcp-server");
456        assert_eq!(config.args(), &["stdio"]);
457        assert_eq!(config.env().get("TOKEN"), Some(&"abc123".to_string()));
458    }
459
460    #[test]
461    fn test_build_server_config_docker() {
462        let (id, config) = build_server_config(
463            Some("docker".to_string()),
464            vec![
465                "run".to_string(),
466                "-i".to_string(),
467                "--rm".to_string(),
468                "ghcr.io/github/github-mcp-server".to_string(),
469            ],
470            vec!["GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx".to_string()],
471            None,
472            None,
473            None,
474            vec![],
475            None,
476            None,
477        )
478        .unwrap();
479
480        assert_eq!(id.as_str(), "docker");
481        assert_eq!(config.command(), "docker");
482        assert_eq!(
483            config.args(),
484            &["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"]
485        );
486        assert_eq!(
487            config.env().get("GITHUB_PERSONAL_ACCESS_TOKEN"),
488            Some(&"ghp_xxx".to_string())
489        );
490    }
491
492    #[test]
493    fn test_build_server_config_http() {
494        let (id, config) = build_server_config(
495            None,
496            vec![],
497            vec![],
498            None,
499            Some("https://api.githubcopilot.com/mcp/".to_string()),
500            None,
501            vec!["Authorization=Bearer token123".to_string()],
502            None,
503            None,
504        )
505        .unwrap();
506
507        assert_eq!(id.as_str(), "https://api.githubcopilot.com/mcp/");
508        assert_eq!(config.url(), Some("https://api.githubcopilot.com/mcp/"));
509        assert_eq!(
510            config.headers().get("Authorization"),
511            Some(&"Bearer token123".to_string())
512        );
513    }
514
515    #[test]
516    fn test_build_server_config_sse() {
517        let (id, config) = build_server_config(
518            None,
519            vec![],
520            vec![],
521            None,
522            None,
523            Some("https://example.com/sse".to_string()),
524            vec!["X-API-Key=secret".to_string()],
525            None,
526            None,
527        )
528        .unwrap();
529
530        assert_eq!(id.as_str(), "https://example.com/sse");
531        assert_eq!(config.url(), Some("https://example.com/sse"));
532        assert_eq!(
533            config.headers().get("X-API-Key"),
534            Some(&"secret".to_string())
535        );
536    }
537
538    #[test]
539    fn test_build_server_config_with_cwd() {
540        let (_, config) = build_server_config(
541            Some("server".to_string()),
542            vec![],
543            vec![],
544            Some("/tmp/workdir".to_string()),
545            None,
546            None,
547            vec![],
548            None,
549            None,
550        )
551        .unwrap();
552
553        assert_eq!(config.cwd(), Some(PathBuf::from("/tmp/workdir")).as_ref());
554    }
555
556    #[test]
557    fn test_build_server_config_invalid_env() {
558        let result = build_server_config(
559            Some("server".to_string()),
560            vec![],
561            vec!["INVALID_FORMAT".to_string()],
562            None,
563            None,
564            None,
565            vec![],
566            None,
567            None,
568        );
569
570        assert!(result.is_err());
571        assert!(
572            result
573                .unwrap_err()
574                .to_string()
575                .contains("expected KEY=VALUE")
576        );
577    }
578
579    #[test]
580    fn test_build_server_config_invalid_header() {
581        let result = build_server_config(
582            None,
583            vec![],
584            vec![],
585            None,
586            Some("https://example.com".to_string()),
587            None,
588            vec!["InvalidHeader".to_string()],
589            None,
590            None,
591        );
592
593        assert!(result.is_err());
594        assert!(
595            result
596                .unwrap_err()
597                .to_string()
598                .contains("expected KEY=VALUE")
599        );
600    }
601
602    #[test]
603    fn test_build_server_config_multiple_env_vars() {
604        let (_, config) = build_server_config(
605            Some("server".to_string()),
606            vec![],
607            vec![
608                "TOKEN=abc123".to_string(),
609                "API_KEY=secret456".to_string(),
610                "DEBUG=true".to_string(),
611            ],
612            None,
613            None,
614            None,
615            vec![],
616            None,
617            None,
618        )
619        .unwrap();
620
621        assert_eq!(config.env().get("TOKEN"), Some(&"abc123".to_string()));
622        assert_eq!(config.env().get("API_KEY"), Some(&"secret456".to_string()));
623        assert_eq!(config.env().get("DEBUG"), Some(&"true".to_string()));
624        assert_eq!(config.env().len(), 3);
625    }
626
627    #[test]
628    fn test_build_server_config_env_with_special_chars() {
629        // Test environment variable values containing equals signs
630        let (_, config) = build_server_config(
631            Some("server".to_string()),
632            vec![],
633            vec![
634                "TOKEN=abc=def=123".to_string(),
635                "URL=https://example.com?key=value".to_string(),
636                "ENCODED=a=b=c=d".to_string(),
637            ],
638            None,
639            None,
640            None,
641            vec![],
642            None,
643            None,
644        )
645        .unwrap();
646
647        assert_eq!(config.env().get("TOKEN"), Some(&"abc=def=123".to_string()));
648        assert_eq!(
649            config.env().get("URL"),
650            Some(&"https://example.com?key=value".to_string())
651        );
652        assert_eq!(config.env().get("ENCODED"), Some(&"a=b=c=d".to_string()));
653    }
654
655    #[test]
656    fn test_build_server_config_empty_args_stdio() {
657        let (id, config) = build_server_config(
658            Some("simple-server".to_string()),
659            vec![],
660            vec![],
661            None,
662            None,
663            None,
664            vec![],
665            None,
666            None,
667        )
668        .unwrap();
669
670        assert_eq!(id.as_str(), "simple-server");
671        assert_eq!(config.command(), "simple-server");
672        assert!(config.args().is_empty());
673        assert!(config.env().is_empty());
674    }
675
676    #[test]
677    fn test_build_server_config_http_multiple_headers() {
678        let (_, config) = build_server_config(
679            None,
680            vec![],
681            vec![],
682            None,
683            Some("https://api.example.com".to_string()),
684            None,
685            vec![
686                "Authorization=Bearer token123".to_string(),
687                "X-API-Key=secret".to_string(),
688                "Content-Type=application/json".to_string(),
689            ],
690            None,
691            None,
692        )
693        .unwrap();
694
695        assert_eq!(
696            config.headers().get("Authorization"),
697            Some(&"Bearer token123".to_string())
698        );
699        assert_eq!(
700            config.headers().get("X-API-Key"),
701            Some(&"secret".to_string())
702        );
703        assert_eq!(
704            config.headers().get("Content-Type"),
705            Some(&"application/json".to_string())
706        );
707        assert_eq!(config.headers().len(), 3);
708    }
709
710    #[test]
711    fn test_build_server_config_header_with_special_chars() {
712        // Test header values containing equals signs
713        let (_, config) = build_server_config(
714            None,
715            vec![],
716            vec![],
717            None,
718            Some("https://api.example.com".to_string()),
719            None,
720            vec![
721                "X-Custom=value=with=equals".to_string(),
722                "X-Query=a=b&c=d".to_string(),
723            ],
724            None,
725            None,
726        )
727        .unwrap();
728
729        assert_eq!(
730            config.headers().get("X-Custom"),
731            Some(&"value=with=equals".to_string())
732        );
733        assert_eq!(
734            config.headers().get("X-Query"),
735            Some(&"a=b&c=d".to_string())
736        );
737    }
738
739    #[test]
740    fn test_build_server_config_sse_with_headers() {
741        let (id, config) = build_server_config(
742            None,
743            vec![],
744            vec![],
745            None,
746            None,
747            Some("https://sse.example.com/events".to_string()),
748            vec!["Authorization=Bearer xyz".to_string()],
749            None,
750            None,
751        )
752        .unwrap();
753
754        assert_eq!(id.as_str(), "https://sse.example.com/events");
755        assert_eq!(config.url(), Some("https://sse.example.com/events"));
756        assert_eq!(
757            config.headers().get("Authorization"),
758            Some(&"Bearer xyz".to_string())
759        );
760    }
761
762    #[test]
763    fn test_build_server_config_empty_value_in_env() {
764        // Test environment variable with empty value after equals
765        let (_, config) = build_server_config(
766            Some("server".to_string()),
767            vec![],
768            vec!["EMPTY=".to_string()],
769            None,
770            None,
771            None,
772            vec![],
773            None,
774            None,
775        )
776        .unwrap();
777
778        assert_eq!(config.env().get("EMPTY"), Some(&String::new()));
779    }
780
781    #[test]
782    fn test_build_server_config_empty_value_in_header() {
783        // Test header with empty value after equals
784        let (_, config) = build_server_config(
785            None,
786            vec![],
787            vec![],
788            None,
789            Some("https://example.com".to_string()),
790            None,
791            vec!["X-Empty=".to_string()],
792            None,
793            None,
794        )
795        .unwrap();
796
797        assert_eq!(config.headers().get("X-Empty"), Some(&String::new()));
798    }
799
800    #[test]
801    fn test_build_server_config_complex_docker_scenario() {
802        let (id, config) = build_server_config(
803            Some("docker".to_string()),
804            vec![
805                "run".to_string(),
806                "-i".to_string(),
807                "--rm".to_string(),
808                "--network=host".to_string(),
809                "my-image:latest".to_string(),
810            ],
811            vec![
812                "API_TOKEN=secret123".to_string(),
813                "LOG_LEVEL=debug".to_string(),
814            ],
815            Some("/app/workdir".to_string()),
816            None,
817            None,
818            vec![],
819            None,
820            None,
821        )
822        .unwrap();
823
824        assert_eq!(id.as_str(), "docker");
825        assert_eq!(config.command(), "docker");
826        assert_eq!(
827            config.args(),
828            &["run", "-i", "--rm", "--network=host", "my-image:latest"]
829        );
830        assert_eq!(
831            config.env().get("API_TOKEN"),
832            Some(&"secret123".to_string())
833        );
834        assert_eq!(config.env().get("LOG_LEVEL"), Some(&"debug".to_string()));
835        assert_eq!(config.cwd(), Some(PathBuf::from("/app/workdir")).as_ref());
836    }
837
838    #[test]
839    fn test_build_server_config_empty_key_in_env() {
840        let result = build_server_config(
841            Some("server".to_string()),
842            vec![],
843            vec!["=value".to_string()],
844            None,
845            None,
846            None,
847            vec![],
848            None,
849            None,
850        );
851
852        assert!(result.is_err());
853        assert!(
854            result
855                .unwrap_err()
856                .to_string()
857                .contains("key cannot be empty")
858        );
859    }
860
861    #[test]
862    fn test_build_server_config_empty_key_in_header() {
863        let result = build_server_config(
864            None,
865            vec![],
866            vec![],
867            None,
868            Some("https://example.com".to_string()),
869            None,
870            vec!["=value".to_string()],
871            None,
872            None,
873        );
874
875        assert!(result.is_err());
876        assert!(
877            result
878                .unwrap_err()
879                .to_string()
880                .contains("key cannot be empty")
881        );
882    }
883
884    #[test]
885    fn test_build_server_config_timeout_override_reaches_core_validation() {
886        // The manual CLI-flag path must fail identically to the mcp.json path:
887        // both end up calling the same `validate_server_config`, so a zero
888        // override must trip the same `connect_timeout` ValidationError.
889        let (_, config) = build_server_config(
890            Some("docker".to_string()),
891            vec![],
892            vec![],
893            None,
894            None,
895            None,
896            vec![],
897            Some(0),
898            None,
899        )
900        .unwrap();
901
902        let result = mcp_execution_core::validate_server_config(&config);
903        assert!(result.is_err());
904        if let Err(mcp_execution_core::Error::ValidationError { field, reason }) = result {
905            assert_eq!(field, "connect_timeout");
906            assert!(reason.contains("greater than zero"));
907        } else {
908            panic!("expected ValidationError for connect_timeout");
909        }
910    }
911
912    #[test]
913    fn test_build_server_config_timeout_overrides() {
914        let (_, config) = build_server_config(
915            Some("server".to_string()),
916            vec![],
917            vec![],
918            None,
919            None,
920            None,
921            vec![],
922            Some(5),
923            Some(90),
924        )
925        .unwrap();
926
927        assert_eq!(config.connect_timeout(), Duration::from_secs(5));
928        assert_eq!(config.discover_timeout(), Duration::from_secs(90));
929    }
930
931    #[test]
932    fn test_build_server_config_default_timeouts_without_overrides() {
933        let (_, config) = build_server_config(
934            Some("server".to_string()),
935            vec![],
936            vec![],
937            None,
938            None,
939            None,
940            vec![],
941            None,
942            None,
943        )
944        .unwrap();
945
946        assert_eq!(config.connect_timeout(), Duration::from_secs(30));
947        assert_eq!(config.discover_timeout(), Duration::from_secs(30));
948    }
949
950    #[test]
951    fn test_load_server_from_config_not_found() {
952        // Should fail because either config doesn't exist or server not in it
953        let result = load_server_from_config("nonexistent");
954        assert!(result.is_err());
955    }
956
957    #[test]
958    fn test_load_mcp_config_no_file() {
959        // Should fail gracefully when config file doesn't exist
960        let result = load_mcp_config_from(Path::new("/nonexistent/mcp.json"));
961
962        if let Err(error) = result {
963            let error = error.to_string();
964            assert!(
965                error.contains("failed to read MCP config")
966                    || error.contains("failed to get home directory"),
967                "Expected config read error or home dir error, got: {error}"
968            );
969        }
970    }
971
972    #[test]
973    fn test_list_mcp_servers_from_missing_file_returns_empty() {
974        // GAP-1: the primary UX fix for #81 — missing config → empty list, not error.
975        let result = list_mcp_servers_from(Path::new("/nonexistent/path/mcp.json"));
976        assert!(result.is_ok());
977        assert!(result.unwrap().is_empty());
978    }
979
980    #[test]
981    fn test_list_mcp_servers_from_valid_file() {
982        let json = r#"{"mcpServers": {"github": {"command": "node"}}}"#;
983        let file = create_test_config(json);
984
985        let servers = list_mcp_servers_from(file.path()).unwrap();
986        assert_eq!(servers.len(), 1);
987        assert_eq!(servers[0].0, "github");
988        assert_eq!(servers[0].1.command, "node");
989    }
990
991    #[test]
992    fn test_list_mcp_servers_from_empty_servers_key() {
993        let json = r#"{"mcpServers": {}}"#;
994        let file = create_test_config(json);
995
996        let servers = list_mcp_servers_from(file.path()).unwrap();
997        assert!(servers.is_empty());
998    }
999
1000    #[test]
1001    fn test_load_mcp_config_without_timeout_keys_uses_defaults() {
1002        let json = r#"{"mcpServers": {"github": {"command": "node"}}}"#;
1003        let file = create_test_config(json);
1004
1005        let config = load_mcp_config_from(file.path()).unwrap();
1006        let entry = &config.mcp_servers["github"];
1007        assert_eq!(entry.connect_timeout_secs, None);
1008        assert_eq!(entry.discover_timeout_secs, None);
1009
1010        let server_config = build_core_config(entry);
1011        assert_eq!(server_config.connect_timeout(), Duration::from_secs(30));
1012        assert_eq!(server_config.discover_timeout(), Duration::from_secs(30));
1013    }
1014
1015    #[test]
1016    fn test_load_mcp_config_with_timeout_keys_reaches_server_config() {
1017        let json = r#"{"mcpServers": {"github": {
1018            "command": "node",
1019            "connectTimeoutSecs": 5,
1020            "discoverTimeoutSecs": 90
1021        }}}"#;
1022        let file = create_test_config(json);
1023
1024        let config = load_mcp_config_from(file.path()).unwrap();
1025        let entry = &config.mcp_servers["github"];
1026        assert_eq!(entry.connect_timeout_secs, Some(5));
1027        assert_eq!(entry.discover_timeout_secs, Some(90));
1028
1029        let server_config = build_core_config(entry);
1030        assert_eq!(server_config.connect_timeout(), Duration::from_secs(5));
1031        assert_eq!(server_config.discover_timeout(), Duration::from_secs(90));
1032    }
1033
1034    #[test]
1035    fn test_load_mcp_config_serde_default_on_missing_mcp_servers() {
1036        // When mcp.json has no mcpServers key, should deserialize to empty map
1037        let json = r#"{"someOtherKey": "value"}"#;
1038        let file = create_test_config(json);
1039
1040        let config = load_mcp_config_from(file.path()).unwrap();
1041        assert!(
1042            config.mcp_servers.is_empty(),
1043            "missing mcpServers key must produce empty map, not error"
1044        );
1045    }
1046}