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
5use anyhow::{Context, Result, bail};
6use mcp_execution_core::{ServerConfig, ServerConfigBuilder, ServerId};
7use serde::Deserialize;
8use std::collections::HashMap;
9use std::path::PathBuf;
10
11/// MCP configuration file structure (~/.claude/mcp.json)
12#[derive(Debug, Deserialize)]
13#[serde(rename_all = "camelCase")]
14struct McpConfig {
15    mcp_servers: HashMap<String, McpServerConfig>,
16}
17
18/// Individual MCP server configuration
19#[derive(Debug, Deserialize)]
20struct McpServerConfig {
21    command: String,
22    #[serde(default)]
23    args: Vec<String>,
24    #[serde(default)]
25    env: HashMap<String, String>,
26}
27
28/// Loads MCP configuration from ~/.claude/mcp.json
29///
30/// # Errors
31///
32/// Returns error if:
33/// - Home directory cannot be determined
34/// - Config file cannot be read
35/// - JSON is malformed
36fn load_mcp_config() -> Result<McpConfig> {
37    let home = dirs::home_dir().context("failed to get home directory")?;
38    let config_path = home.join(".claude").join("mcp.json");
39
40    let content = std::fs::read_to_string(&config_path)
41        .with_context(|| "failed to read MCP config from ~/.claude/mcp.json")?;
42
43    let config: McpConfig =
44        serde_json::from_str(&content).context("failed to parse MCP config JSON")?;
45
46    Ok(config)
47}
48
49/// Loads server configuration from ~/.claude/mcp.json by server name.
50///
51/// # Arguments
52///
53/// * `name` - Server name from mcp.json (e.g., "github")
54///
55/// # Returns
56///
57/// Returns `(ServerId, ServerConfig)` if server is found in config.
58///
59/// # Errors
60///
61/// Returns error if:
62/// - Config file doesn't exist or is malformed
63/// - Server name not found in config
64///
65/// # Examples
66///
67/// ```no_run
68/// use mcp_execution_cli::commands::common::load_server_from_config;
69///
70/// let (id, config) = load_server_from_config("github").unwrap();
71/// assert_eq!(id.as_str(), "github");
72/// ```
73pub fn load_server_from_config(name: &str) -> Result<(ServerId, ServerConfig)> {
74    let config = load_mcp_config()?;
75
76    let server_config = config.mcp_servers.get(name).with_context(|| {
77        format!(
78            "server '{name}' not found in MCP config at ~/.claude/mcp.json\n\
79             Hint: Use 'mcp-execution-cli server list' to see available servers"
80        )
81    })?;
82
83    let id = ServerId::new(name);
84    let mut builder = ServerConfig::builder().command(server_config.command.clone());
85
86    if !server_config.args.is_empty() {
87        builder = builder.args(server_config.args.clone());
88    }
89
90    for (key, value) in &server_config.env {
91        builder = builder.env(key.clone(), value.clone());
92    }
93
94    Ok((id, builder.build()))
95}
96
97/// Builds `ServerConfig` from CLI arguments.
98///
99/// Parses CLI arguments into a `ServerConfig` for connecting to an MCP server.
100///
101/// # Arguments
102///
103/// * `server` - Server command (binary name or path)
104/// * `args` - Arguments to pass to the server command
105/// * `env` - Environment variables in KEY=VALUE format
106/// * `cwd` - Working directory for the server process
107/// * `http` - HTTP transport URL
108/// * `sse` - SSE transport URL
109/// * `headers` - HTTP headers in KEY=VALUE format
110///
111/// # Errors
112///
113/// Returns an error if environment variables or headers are not in KEY=VALUE format.
114///
115/// # Panics
116///
117/// Panics if `server` is `None` when using stdio transport (i.e., when neither
118/// `http` nor `sse` is provided). This is enforced by CLI argument validation.
119///
120/// # Examples
121///
122/// ```
123/// use mcp_execution_cli::commands::common::build_server_config;
124///
125/// // Stdio transport
126/// let (id, config) = build_server_config(
127///     Some("github-mcp-server".to_string()),
128///     vec!["stdio".to_string()],
129///     vec!["TOKEN=abc".to_string()],
130///     None,
131///     None,
132///     None,
133///     vec![],
134/// ).unwrap();
135///
136/// assert_eq!(id.as_str(), "github-mcp-server");
137/// assert_eq!(config.args(), &["stdio"]);
138/// ```
139pub fn build_server_config(
140    server: Option<String>,
141    args: Vec<String>,
142    env: Vec<String>,
143    cwd: Option<String>,
144    http: Option<String>,
145    sse: Option<String>,
146    headers: Vec<String>,
147) -> Result<(ServerId, ServerConfig)> {
148    // Parse environment variables / headers in KEY=VALUE format
149    let parse_key_value = |s: &str, kind: &str| -> Result<(String, String)> {
150        let parts: Vec<&str> = s.splitn(2, '=').collect();
151        if parts.len() != 2 {
152            bail!("invalid {kind} format: '{s}' (expected KEY=VALUE)");
153        }
154        if parts[0].is_empty() {
155            bail!("invalid {kind} format: '{s}' (key cannot be empty)");
156        }
157        Ok((parts[0].to_string(), parts[1].to_string()))
158    };
159
160    // Build config based on transport type
161    let (server_id, config) = if let Some(url) = http {
162        // HTTP transport
163        let id = ServerId::new(&url);
164        let mut builder = ServerConfig::builder().http_transport(url);
165
166        for header in headers {
167            let (key, value) = parse_key_value(&header, "header")?;
168            builder = builder.header(key, value);
169        }
170
171        (id, builder.build())
172    } else if let Some(url) = sse {
173        // SSE transport
174        let id = ServerId::new(&url);
175        let mut builder = ServerConfig::builder().sse_transport(url);
176
177        for header in headers {
178            let (key, value) = parse_key_value(&header, "header")?;
179            builder = builder.header(key, value);
180        }
181
182        (id, builder.build())
183    } else {
184        // Stdio transport (default)
185        let command = server.expect("server is required for stdio transport");
186        let id = ServerId::new(&command);
187        let mut builder: ServerConfigBuilder = ServerConfig::builder().command(command);
188
189        if !args.is_empty() {
190            builder = builder.args(args);
191        }
192
193        for env_var in env {
194            let (key, value) = parse_key_value(&env_var, "environment variable")?;
195            builder = builder.env(key, value);
196        }
197
198        if let Some(dir) = cwd {
199            builder = builder.cwd(PathBuf::from(dir));
200        }
201
202        (id, builder.build())
203    };
204
205    Ok((server_id, config))
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_build_server_config_stdio() {
214        let (id, config) = build_server_config(
215            Some("github-mcp-server".to_string()),
216            vec!["stdio".to_string()],
217            vec!["TOKEN=abc123".to_string()],
218            None,
219            None,
220            None,
221            vec![],
222        )
223        .unwrap();
224
225        assert_eq!(id.as_str(), "github-mcp-server");
226        assert_eq!(config.command(), "github-mcp-server");
227        assert_eq!(config.args(), &["stdio"]);
228        assert_eq!(config.env().get("TOKEN"), Some(&"abc123".to_string()));
229    }
230
231    #[test]
232    fn test_build_server_config_docker() {
233        let (id, config) = build_server_config(
234            Some("docker".to_string()),
235            vec![
236                "run".to_string(),
237                "-i".to_string(),
238                "--rm".to_string(),
239                "ghcr.io/github/github-mcp-server".to_string(),
240            ],
241            vec!["GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx".to_string()],
242            None,
243            None,
244            None,
245            vec![],
246        )
247        .unwrap();
248
249        assert_eq!(id.as_str(), "docker");
250        assert_eq!(config.command(), "docker");
251        assert_eq!(
252            config.args(),
253            &["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"]
254        );
255        assert_eq!(
256            config.env().get("GITHUB_PERSONAL_ACCESS_TOKEN"),
257            Some(&"ghp_xxx".to_string())
258        );
259    }
260
261    #[test]
262    fn test_build_server_config_http() {
263        let (id, config) = build_server_config(
264            None,
265            vec![],
266            vec![],
267            None,
268            Some("https://api.githubcopilot.com/mcp/".to_string()),
269            None,
270            vec!["Authorization=Bearer token123".to_string()],
271        )
272        .unwrap();
273
274        assert_eq!(id.as_str(), "https://api.githubcopilot.com/mcp/");
275        assert_eq!(config.url(), Some("https://api.githubcopilot.com/mcp/"));
276        assert_eq!(
277            config.headers().get("Authorization"),
278            Some(&"Bearer token123".to_string())
279        );
280    }
281
282    #[test]
283    fn test_build_server_config_sse() {
284        let (id, config) = build_server_config(
285            None,
286            vec![],
287            vec![],
288            None,
289            None,
290            Some("https://example.com/sse".to_string()),
291            vec!["X-API-Key=secret".to_string()],
292        )
293        .unwrap();
294
295        assert_eq!(id.as_str(), "https://example.com/sse");
296        assert_eq!(config.url(), Some("https://example.com/sse"));
297        assert_eq!(
298            config.headers().get("X-API-Key"),
299            Some(&"secret".to_string())
300        );
301    }
302
303    #[test]
304    fn test_build_server_config_with_cwd() {
305        let (_, config) = build_server_config(
306            Some("server".to_string()),
307            vec![],
308            vec![],
309            Some("/tmp/workdir".to_string()),
310            None,
311            None,
312            vec![],
313        )
314        .unwrap();
315
316        assert_eq!(config.cwd(), Some(PathBuf::from("/tmp/workdir")).as_ref());
317    }
318
319    #[test]
320    fn test_build_server_config_invalid_env() {
321        let result = build_server_config(
322            Some("server".to_string()),
323            vec![],
324            vec!["INVALID_FORMAT".to_string()],
325            None,
326            None,
327            None,
328            vec![],
329        );
330
331        assert!(result.is_err());
332        assert!(
333            result
334                .unwrap_err()
335                .to_string()
336                .contains("expected KEY=VALUE")
337        );
338    }
339
340    #[test]
341    fn test_build_server_config_invalid_header() {
342        let result = build_server_config(
343            None,
344            vec![],
345            vec![],
346            None,
347            Some("https://example.com".to_string()),
348            None,
349            vec!["InvalidHeader".to_string()],
350        );
351
352        assert!(result.is_err());
353        assert!(
354            result
355                .unwrap_err()
356                .to_string()
357                .contains("expected KEY=VALUE")
358        );
359    }
360
361    #[test]
362    fn test_build_server_config_multiple_env_vars() {
363        let (_, config) = build_server_config(
364            Some("server".to_string()),
365            vec![],
366            vec![
367                "TOKEN=abc123".to_string(),
368                "API_KEY=secret456".to_string(),
369                "DEBUG=true".to_string(),
370            ],
371            None,
372            None,
373            None,
374            vec![],
375        )
376        .unwrap();
377
378        assert_eq!(config.env().get("TOKEN"), Some(&"abc123".to_string()));
379        assert_eq!(config.env().get("API_KEY"), Some(&"secret456".to_string()));
380        assert_eq!(config.env().get("DEBUG"), Some(&"true".to_string()));
381        assert_eq!(config.env().len(), 3);
382    }
383
384    #[test]
385    fn test_build_server_config_env_with_special_chars() {
386        // Test environment variable values containing equals signs
387        let (_, config) = build_server_config(
388            Some("server".to_string()),
389            vec![],
390            vec![
391                "TOKEN=abc=def=123".to_string(),
392                "URL=https://example.com?key=value".to_string(),
393                "ENCODED=a=b=c=d".to_string(),
394            ],
395            None,
396            None,
397            None,
398            vec![],
399        )
400        .unwrap();
401
402        assert_eq!(config.env().get("TOKEN"), Some(&"abc=def=123".to_string()));
403        assert_eq!(
404            config.env().get("URL"),
405            Some(&"https://example.com?key=value".to_string())
406        );
407        assert_eq!(config.env().get("ENCODED"), Some(&"a=b=c=d".to_string()));
408    }
409
410    #[test]
411    fn test_build_server_config_empty_args_stdio() {
412        let (id, config) = build_server_config(
413            Some("simple-server".to_string()),
414            vec![],
415            vec![],
416            None,
417            None,
418            None,
419            vec![],
420        )
421        .unwrap();
422
423        assert_eq!(id.as_str(), "simple-server");
424        assert_eq!(config.command(), "simple-server");
425        assert!(config.args().is_empty());
426        assert!(config.env().is_empty());
427    }
428
429    #[test]
430    fn test_build_server_config_http_multiple_headers() {
431        let (_, config) = build_server_config(
432            None,
433            vec![],
434            vec![],
435            None,
436            Some("https://api.example.com".to_string()),
437            None,
438            vec![
439                "Authorization=Bearer token123".to_string(),
440                "X-API-Key=secret".to_string(),
441                "Content-Type=application/json".to_string(),
442            ],
443        )
444        .unwrap();
445
446        assert_eq!(
447            config.headers().get("Authorization"),
448            Some(&"Bearer token123".to_string())
449        );
450        assert_eq!(
451            config.headers().get("X-API-Key"),
452            Some(&"secret".to_string())
453        );
454        assert_eq!(
455            config.headers().get("Content-Type"),
456            Some(&"application/json".to_string())
457        );
458        assert_eq!(config.headers().len(), 3);
459    }
460
461    #[test]
462    fn test_build_server_config_header_with_special_chars() {
463        // Test header values containing equals signs
464        let (_, config) = build_server_config(
465            None,
466            vec![],
467            vec![],
468            None,
469            Some("https://api.example.com".to_string()),
470            None,
471            vec![
472                "X-Custom=value=with=equals".to_string(),
473                "X-Query=a=b&c=d".to_string(),
474            ],
475        )
476        .unwrap();
477
478        assert_eq!(
479            config.headers().get("X-Custom"),
480            Some(&"value=with=equals".to_string())
481        );
482        assert_eq!(
483            config.headers().get("X-Query"),
484            Some(&"a=b&c=d".to_string())
485        );
486    }
487
488    #[test]
489    fn test_build_server_config_sse_with_headers() {
490        let (id, config) = build_server_config(
491            None,
492            vec![],
493            vec![],
494            None,
495            None,
496            Some("https://sse.example.com/events".to_string()),
497            vec!["Authorization=Bearer xyz".to_string()],
498        )
499        .unwrap();
500
501        assert_eq!(id.as_str(), "https://sse.example.com/events");
502        assert_eq!(config.url(), Some("https://sse.example.com/events"));
503        assert_eq!(
504            config.headers().get("Authorization"),
505            Some(&"Bearer xyz".to_string())
506        );
507    }
508
509    #[test]
510    fn test_build_server_config_empty_value_in_env() {
511        // Test environment variable with empty value after equals
512        let (_, config) = build_server_config(
513            Some("server".to_string()),
514            vec![],
515            vec!["EMPTY=".to_string()],
516            None,
517            None,
518            None,
519            vec![],
520        )
521        .unwrap();
522
523        assert_eq!(config.env().get("EMPTY"), Some(&String::new()));
524    }
525
526    #[test]
527    fn test_build_server_config_empty_value_in_header() {
528        // Test header with empty value after equals
529        let (_, config) = build_server_config(
530            None,
531            vec![],
532            vec![],
533            None,
534            Some("https://example.com".to_string()),
535            None,
536            vec!["X-Empty=".to_string()],
537        )
538        .unwrap();
539
540        assert_eq!(config.headers().get("X-Empty"), Some(&String::new()));
541    }
542
543    #[test]
544    fn test_build_server_config_complex_docker_scenario() {
545        let (id, config) = build_server_config(
546            Some("docker".to_string()),
547            vec![
548                "run".to_string(),
549                "-i".to_string(),
550                "--rm".to_string(),
551                "--network=host".to_string(),
552                "my-image:latest".to_string(),
553            ],
554            vec![
555                "API_TOKEN=secret123".to_string(),
556                "LOG_LEVEL=debug".to_string(),
557            ],
558            Some("/app/workdir".to_string()),
559            None,
560            None,
561            vec![],
562        )
563        .unwrap();
564
565        assert_eq!(id.as_str(), "docker");
566        assert_eq!(config.command(), "docker");
567        assert_eq!(
568            config.args(),
569            &["run", "-i", "--rm", "--network=host", "my-image:latest"]
570        );
571        assert_eq!(
572            config.env().get("API_TOKEN"),
573            Some(&"secret123".to_string())
574        );
575        assert_eq!(config.env().get("LOG_LEVEL"), Some(&"debug".to_string()));
576        assert_eq!(config.cwd(), Some(PathBuf::from("/app/workdir")).as_ref());
577    }
578
579    #[test]
580    fn test_build_server_config_empty_key_in_env() {
581        let result = build_server_config(
582            Some("server".to_string()),
583            vec![],
584            vec!["=value".to_string()],
585            None,
586            None,
587            None,
588            vec![],
589        );
590
591        assert!(result.is_err());
592        assert!(
593            result
594                .unwrap_err()
595                .to_string()
596                .contains("key cannot be empty")
597        );
598    }
599
600    #[test]
601    fn test_build_server_config_empty_key_in_header() {
602        let result = build_server_config(
603            None,
604            vec![],
605            vec![],
606            None,
607            Some("https://example.com".to_string()),
608            None,
609            vec!["=value".to_string()],
610        );
611
612        assert!(result.is_err());
613        assert!(
614            result
615                .unwrap_err()
616                .to_string()
617                .contains("key cannot be empty")
618        );
619    }
620
621    #[test]
622    fn test_load_server_from_config_not_found() {
623        // Test with non-existent server name
624        let result = load_server_from_config("nonexistent");
625
626        // Should fail because either config doesn't exist or server not in it
627        assert!(result.is_err());
628    }
629
630    #[test]
631    fn test_load_mcp_config_no_file() {
632        // Should fail gracefully when config file doesn't exist
633        let result = load_mcp_config();
634
635        // Can fail either because home dir not found or config file missing
636        // Both are acceptable error states
637        if let Err(error) = result {
638            let error = error.to_string();
639            assert!(
640                error.contains("failed to read MCP config")
641                    || error.contains("failed to get home directory"),
642                "Expected config read error or home dir error, got: {error}"
643            );
644        }
645    }
646}