1use 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#[derive(Debug, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct McpConfig {
20 #[serde(default)]
22 pub mcp_servers: HashMap<String, McpServerEntry>,
23}
24
25#[derive(Debug, Clone, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct McpServerEntry {
29 pub command: String,
31 #[serde(default)]
33 pub args: Vec<String>,
34 #[serde(default)]
36 pub env: HashMap<String, String>,
37 #[serde(default)]
40 pub connect_timeout_secs: Option<u64>,
41 #[serde(default)]
44 pub discover_timeout_secs: Option<u64>,
45}
46
47pub 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
72pub 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
85pub 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
111pub 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
137pub 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
182pub fn load_server_from_config(name: &str) -> Result<(ServerId, ServerConfig)> {
204 let (id, config, _) = get_mcp_server(name)?;
205 Ok((id, config))
206}
207
208fn 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#[allow(clippy::too_many_arguments)] pub 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 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 let (server_id, mut builder) = if let Some(url) = http {
308 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}