1use 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#[derive(Debug, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct McpConfig {
19 #[serde(default)]
21 pub mcp_servers: HashMap<String, McpServerEntry>,
22}
23
24#[derive(Debug, Clone, Deserialize)]
26pub struct McpServerEntry {
27 pub command: String,
29 #[serde(default)]
31 pub args: Vec<String>,
32 #[serde(default)]
34 pub env: HashMap<String, String>,
35}
36
37pub 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
62pub 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
75pub 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
101pub 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
127pub 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
172pub fn load_server_from_config(name: &str) -> Result<(ServerId, ServerConfig)> {
194 let (id, config, _) = get_mcp_server(name)?;
195 Ok((id, config))
196}
197
198fn 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
213pub 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 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 let (server_id, config) = if let Some(url) = http {
278 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 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 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 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 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 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 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 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 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 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 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 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 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 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}