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