1use clap::builder::{PossibleValuesParser, TypedValueParser as _};
8use clap::{ArgGroup, Args, Parser, Subcommand};
9use clap_complete::Shell;
10use std::fmt;
11use std::path::{Path, PathBuf};
12use std::str::FromStr;
13
14use crate::actions::ServerAction;
15use crate::commands::common::{ServerSource, TransportArgs};
16use mcp_execution_core::cli::{LogFormat, OutputFormat};
17use mcp_execution_core::{Error as CoreError, RedactedItems, RedactedUrl, sanitize_path_for_error};
18
19#[derive(Parser)]
36#[command(version, about, long_about = None)]
37#[command(author = "MCP Execution Team")]
38pub struct Cli {
39 #[command(subcommand)]
41 pub command: Commands,
42
43 #[arg(short, long, global = true)]
45 pub verbose: bool,
46
47 #[arg(
49 long = "format",
50 global = true,
51 default_value = "pretty",
52 ignore_case = true,
53 value_parser = PossibleValuesParser::new(["json", "text", "pretty"])
54 .map(|s| OutputFormat::from_str(&s).expect("possible values are OutputFormat variants"))
55 )]
56 pub format: OutputFormat,
57
58 #[arg(
64 long = "log-format",
65 global = true,
66 ignore_case = true,
67 value_parser = PossibleValuesParser::new(["text", "json"])
68 .map(|s| LogFormat::from_str(&s).expect("possible values are LogFormat variants"))
69 )]
70 pub log_format: Option<LogFormat>,
71}
72
73impl fmt::Debug for Cli {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 let Self {
87 command,
88 verbose,
89 format,
90 log_format,
91 } = self;
92 f.debug_struct("Cli")
93 .field("command", command)
94 .field("verbose", verbose)
95 .field("format", format)
96 .field("log_format", log_format)
97 .finish()
98 }
99}
100
101#[derive(Args)]
133#[command(group(
134 ArgGroup::new("server_source")
135 .required(true)
136 .args(["from_config", "server", "http", "sse"])
137))]
138pub struct ServerFlags {
139 #[arg(long = "from-config", conflicts_with_all = ["server", "args", "env", "cwd", "http", "sse", "headers", "connect_timeout_secs", "discover_timeout_secs"])]
163 from_config: Option<String>,
164
165 server: Option<String>,
170
171 #[arg(short, long = "arg", num_args = 1)]
173 args: Vec<String>,
174
175 #[arg(short, long = "env", num_args = 1)]
177 env: Vec<String>,
178
179 #[arg(long)]
181 cwd: Option<String>,
182
183 #[arg(long, conflicts_with = "sse")]
185 http: Option<String>,
186
187 #[arg(long, conflicts_with = "http")]
189 sse: Option<String>,
190
191 #[arg(long = "header", num_args = 1)]
196 headers: Vec<String>,
197
198 #[arg(long = "connect-timeout-secs")]
210 connect_timeout_secs: Option<u64>,
211
212 #[arg(long = "discover-timeout-secs")]
217 discover_timeout_secs: Option<u64>,
218}
219
220impl fmt::Debug for ServerFlags {
238 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239 let Self {
240 from_config,
241 server,
242 args,
243 env,
244 cwd,
245 http,
246 sse,
247 headers,
248 connect_timeout_secs,
249 discover_timeout_secs,
250 } = self;
251 f.debug_struct("ServerFlags")
252 .field("from_config", from_config)
253 .field(
254 "server",
255 &server
256 .as_deref()
257 .map(|s| sanitize_path_for_error(Path::new(s))),
258 )
259 .field("args", args)
260 .field("env", &RedactedItems(env))
261 .field(
262 "cwd",
263 &cwd.as_deref()
264 .map(|cwd| sanitize_path_for_error(Path::new(cwd))),
265 )
266 .field("http", &http.as_deref().map(RedactedUrl))
267 .field("sse", &sse.as_deref().map(RedactedUrl))
268 .field("headers", &RedactedItems(headers))
269 .field("connect_timeout_secs", connect_timeout_secs)
270 .field("discover_timeout_secs", discover_timeout_secs)
271 .finish()
272 }
273}
274
275impl TryFrom<ServerFlags> for ServerSource {
287 type Error = CoreError;
288
289 fn try_from(flags: ServerFlags) -> Result<Self, Self::Error> {
290 let ServerFlags {
291 from_config,
292 server,
293 args,
294 env,
295 cwd,
296 http,
297 sse,
298 headers,
299 connect_timeout_secs,
300 discover_timeout_secs,
301 } = flags;
302
303 match (from_config, server, http, sse) {
304 (Some(name), None, None, None) => Ok(Self::Config { name }),
305 (None, Some(command), None, None) => Ok(Self::Flags {
306 transport: TransportArgs::Stdio {
307 command,
308 args,
309 env,
310 cwd,
311 },
312 connect_timeout_secs,
313 discover_timeout_secs,
314 }),
315 (None, None, Some(url), None) => Ok(Self::Flags {
316 transport: TransportArgs::Http { url, headers },
317 connect_timeout_secs,
318 discover_timeout_secs,
319 }),
320 (None, None, None, Some(url)) => Ok(Self::Flags {
321 transport: TransportArgs::Sse { url, headers },
322 connect_timeout_secs,
323 discover_timeout_secs,
324 }),
325 _ => Err(CoreError::InvalidArgument(
326 "exactly one of --from-config, a server command, --http, or --sse must be set"
327 .to_string(),
328 )),
329 }
330 }
331}
332
333#[derive(Subcommand)]
352pub enum Commands {
353 Introspect {
395 #[command(flatten)]
397 flags: ServerFlags,
398
399 #[arg(short, long)]
401 detailed: bool,
402 },
403
404 Skill {
433 #[arg(short, long)]
437 server: String,
438
439 #[arg(long)]
443 servers_dir: Option<PathBuf>,
444
445 #[arg(short, long)]
449 output: Option<PathBuf>,
450
451 #[arg(long)]
455 skill_name: Option<String>,
456
457 #[arg(long = "hint", num_args = 1)]
463 hints: Vec<String>,
464
465 #[arg(long)]
467 overwrite: bool,
468 },
469
470 Generate {
503 #[command(flatten)]
505 flags: ServerFlags,
506
507 #[arg(long)]
510 name: Option<String>,
511
512 #[arg(long)]
515 progressive_output: Option<PathBuf>,
516
517 #[arg(long)]
519 dry_run: bool,
520 },
521
522 Server {
526 #[command(subcommand)]
528 action: ServerAction,
529 },
530
531 Setup,
550
551 Completions {
556 #[arg(value_enum)]
558 shell: Shell,
559 },
560}
561
562impl fmt::Debug for Commands {
563 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
564 match self {
565 Self::Introspect { flags, detailed } => f
566 .debug_struct("Introspect")
567 .field("flags", flags)
568 .field("detailed", detailed)
569 .finish(),
570 Self::Skill {
571 server,
572 servers_dir,
573 output,
574 skill_name,
575 hints,
576 overwrite,
577 } => f
578 .debug_struct("Skill")
579 .field("server", server)
580 .field("servers_dir", servers_dir)
581 .field("output", output)
582 .field("skill_name", skill_name)
583 .field("hints", hints)
584 .field("overwrite", overwrite)
585 .finish(),
586 Self::Generate {
587 flags,
588 name,
589 progressive_output,
590 dry_run,
591 } => f
592 .debug_struct("Generate")
593 .field("flags", flags)
594 .field("name", name)
595 .field("progressive_output", progressive_output)
596 .field("dry_run", dry_run)
597 .finish(),
598 Self::Server { action } => f.debug_struct("Server").field("action", action).finish(),
599 Self::Setup => write!(f, "Setup"),
600 Self::Completions { shell } => {
601 f.debug_struct("Completions").field("shell", shell).finish()
602 }
603 }
604 }
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610 use clap::CommandFactory;
611
612 #[test]
613 fn test_cli_help_examples_use_published_binary_name() {
614 let mut command = Cli::command();
615
616 for subcommand in ["introspect", "generate", "skill"] {
617 let help = command
618 .find_subcommand_mut(subcommand)
619 .expect("subcommand should exist")
620 .render_long_help()
621 .to_string();
622
623 assert!(
624 help.contains(&format!("mcp-execution-cli {subcommand}")),
625 "{subcommand} help should include examples with the published binary name"
626 );
627 assert!(
628 !help.contains(&format!("mcp-cli {subcommand}")),
629 "{subcommand} help should not reference the old binary name"
630 );
631 }
632 }
633
634 #[test]
635 fn test_cli_parsing_introspect() {
636 let cli = Cli::parse_from(["mcp-cli", "introspect", "github"]);
637 assert!(matches!(cli.command, Commands::Introspect { .. }));
638 }
639
640 #[test]
641 fn test_cli_parsing_introspect_with_args() {
642 let cli = Cli::parse_from([
643 "mcp-cli",
644 "introspect",
645 "docker",
646 "--arg=run",
647 "--arg=-i",
648 "--arg=--rm",
649 "--arg=ghcr.io/github/github-mcp-server",
650 "--env=GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx",
651 ]);
652 if let Commands::Introspect { flags, .. } = cli.command {
653 assert_eq!(flags.server, Some("docker".to_string()));
654 assert_eq!(
655 flags.args,
656 vec!["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"]
657 );
658 assert_eq!(flags.env, vec!["GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx"]);
659 } else {
660 panic!("Expected Introspect command");
661 }
662 }
663
664 #[test]
665 fn test_cli_parsing_introspect_http() {
666 let cli = Cli::parse_from([
667 "mcp-cli",
668 "introspect",
669 "--http",
670 "https://api.githubcopilot.com/mcp/",
671 "--header",
672 "Authorization=Bearer token",
673 ]);
674 if let Commands::Introspect { flags, .. } = cli.command {
675 assert_eq!(flags.server, None);
676 assert_eq!(
677 flags.http,
678 Some("https://api.githubcopilot.com/mcp/".to_string())
679 );
680 assert_eq!(flags.headers, vec!["Authorization=Bearer token"]);
681 } else {
682 panic!("Expected Introspect command");
683 }
684 }
685
686 #[test]
687 fn test_cli_parsing_introspect_timeout_overrides() {
688 let cli = Cli::parse_from([
689 "mcp-cli",
690 "introspect",
691 "docker",
692 "--connect-timeout-secs",
693 "5",
694 "--discover-timeout-secs",
695 "90",
696 ]);
697 if let Commands::Introspect { flags, .. } = cli.command {
698 assert_eq!(flags.connect_timeout_secs, Some(5));
699 assert_eq!(flags.discover_timeout_secs, Some(90));
700 } else {
701 panic!("Expected Introspect command");
702 }
703 }
704
705 #[test]
706 fn test_cli_parsing_introspect_timeout_conflicts_with_from_config() {
707 let result = Cli::try_parse_from([
708 "mcp-cli",
709 "introspect",
710 "--from-config",
711 "github",
712 "--connect-timeout-secs",
713 "5",
714 ]);
715 assert!(result.is_err());
716 }
717
718 #[test]
719 fn test_cli_parsing_generate_timeout_conflicts_with_from_config() {
720 let result = Cli::try_parse_from([
721 "mcp-cli",
722 "generate",
723 "--from-config",
724 "github",
725 "--discover-timeout-secs",
726 "90",
727 ]);
728 assert!(result.is_err());
729 }
730
731 #[test]
732 fn test_cli_parsing_introspect_header_conflicts_with_from_config() {
733 let result = Cli::try_parse_from([
734 "mcp-cli",
735 "introspect",
736 "--from-config",
737 "github",
738 "--header",
739 "Authorization=Bearer x",
740 ]);
741 assert!(result.is_err());
742 }
743
744 #[test]
745 fn test_cli_parsing_generate_header_conflicts_with_from_config() {
746 let result = Cli::try_parse_from([
747 "mcp-cli",
748 "generate",
749 "--from-config",
750 "github",
751 "--header",
752 "Authorization=Bearer x",
753 ]);
754 assert!(result.is_err());
755 }
756
757 #[test]
758 fn test_cli_parsing_generate() {
759 let cli = Cli::parse_from(["mcp-cli", "generate", "server"]);
760 assert!(matches!(cli.command, Commands::Generate { .. }));
761
762 let cli = Cli::parse_from([
763 "mcp-cli",
764 "generate",
765 "server",
766 "--progressive-output",
767 "/tmp/output",
768 ]);
769 if let Commands::Generate {
770 progressive_output, ..
771 } = cli.command
772 {
773 assert_eq!(progressive_output, Some(PathBuf::from("/tmp/output")));
774 } else {
775 panic!("Expected Generate command");
776 }
777 }
778
779 #[test]
780 fn test_cli_parsing_generate_timeout_overrides() {
781 let cli = Cli::parse_from([
782 "mcp-cli",
783 "generate",
784 "docker",
785 "--connect-timeout-secs",
786 "5",
787 "--discover-timeout-secs",
788 "90",
789 ]);
790 if let Commands::Generate { flags, .. } = cli.command {
791 assert_eq!(flags.connect_timeout_secs, Some(5));
792 assert_eq!(flags.discover_timeout_secs, Some(90));
793 } else {
794 panic!("Expected Generate command");
795 }
796 }
797
798 #[test]
799 fn test_cli_parsing_generate_dry_run() {
800 let cli = Cli::parse_from(["mcp-cli", "generate", "server", "--dry-run"]);
801 if let Commands::Generate { dry_run, .. } = cli.command {
802 assert!(dry_run);
803 } else {
804 panic!("Expected Generate command");
805 }
806 }
807
808 #[test]
809 fn test_cli_parsing_generate_dry_run_default_false() {
810 let cli = Cli::parse_from(["mcp-cli", "generate", "server"]);
811 if let Commands::Generate { dry_run, .. } = cli.command {
812 assert!(!dry_run);
813 } else {
814 panic!("Expected Generate command");
815 }
816 }
817
818 #[test]
819 fn test_cli_parsing_server_list() {
820 let cli = Cli::parse_from(["mcp-cli", "server", "list"]);
821 assert!(matches!(cli.command, Commands::Server { .. }));
822 }
823
824 #[test]
825 fn test_cli_verbose_flag() {
826 let cli = Cli::parse_from(["mcp-cli", "--verbose", "introspect", "github"]);
827 assert!(cli.verbose);
828 }
829
830 #[test]
831 fn test_cli_output_format_default() {
832 let cli = Cli::parse_from(["mcp-cli", "introspect", "github"]);
833 assert_eq!(cli.format, OutputFormat::Pretty);
834 }
835
836 #[test]
837 fn test_cli_output_format_custom() {
838 let cli = Cli::parse_from(["mcp-cli", "--format", "json", "introspect", "github"]);
839 assert_eq!(cli.format, OutputFormat::Json);
840 }
841
842 #[test]
843 fn test_cli_output_format_invalid_rejected_by_clap() {
844 let result = Cli::try_parse_from(["mcp-cli", "--format", "xml", "introspect", "github"]);
845 assert!(result.is_err());
846 }
847
848 #[test]
849 fn test_cli_output_format_possible_values_parse_via_from_str() {
850 let cmd = Cli::command();
857 let arg = cmd
858 .get_arguments()
859 .find(|a| a.get_id() == "format")
860 .expect("--format argument must exist");
861 let values = arg.get_possible_values();
862 assert!(!values.is_empty(), "--format must declare possible values");
863 for possible_value in values {
864 let name = possible_value.get_name();
865 assert!(
866 OutputFormat::from_str(name).is_ok(),
867 "{name} must parse via OutputFormat::from_str to match the --format value parser"
868 );
869 }
870 }
871
872 #[test]
873 fn test_cli_output_format_case_insensitive() {
874 let cli = Cli::parse_from(["mcp-cli", "--format", "JSON", "introspect", "github"]);
875 assert_eq!(cli.format, OutputFormat::Json);
876
877 let cli = Cli::parse_from(["mcp-cli", "--format", "PRETTY", "introspect", "github"]);
878 assert_eq!(cli.format, OutputFormat::Pretty);
879 }
880
881 #[test]
882 fn test_output_format_parsing_valid() {
883 use mcp_execution_core::cli::OutputFormat;
884
885 let format: OutputFormat = "json".parse().unwrap();
886 assert_eq!(format, OutputFormat::Json);
887
888 let format: OutputFormat = "text".parse().unwrap();
889 assert_eq!(format, OutputFormat::Text);
890
891 let format: OutputFormat = "pretty".parse().unwrap();
892 assert_eq!(format, OutputFormat::Pretty);
893 }
894
895 #[test]
896 fn test_output_format_parsing_invalid() {
897 use mcp_execution_core::cli::OutputFormat;
898 assert!("invalid".parse::<OutputFormat>().is_err());
899 }
900
901 #[test]
902 fn test_cli_log_format_default_unset() {
903 let cli = Cli::parse_from(["mcp-cli", "introspect", "github"]);
904 assert_eq!(cli.log_format, None);
905 }
906
907 #[test]
908 fn test_cli_log_format_json() {
909 let cli = Cli::parse_from(["mcp-cli", "--log-format", "json", "introspect", "github"]);
910 assert_eq!(cli.log_format, Some(LogFormat::Json));
911 }
912
913 #[test]
914 fn test_cli_log_format_case_insensitive() {
915 let cli = Cli::parse_from(["mcp-cli", "--log-format", "JSON", "introspect", "github"]);
916 assert_eq!(cli.log_format, Some(LogFormat::Json));
917 }
918
919 #[test]
920 fn test_cli_log_format_invalid_rejected_by_clap() {
921 let result =
922 Cli::try_parse_from(["mcp-cli", "--log-format", "xml", "introspect", "github"]);
923 assert!(result.is_err());
924 }
925
926 #[test]
927 fn test_cli_log_format_global_flag_accepted_after_subcommand() {
928 let cli = Cli::parse_from(["mcp-cli", "introspect", "github", "--log-format", "json"]);
929 assert_eq!(cli.log_format, Some(LogFormat::Json));
930 }
931
932 #[test]
933 fn test_cli_log_format_possible_values_parse_via_from_str() {
934 let cmd = Cli::command();
935 let arg = cmd
936 .get_arguments()
937 .find(|a| a.get_id() == "log_format")
938 .expect("--log-format argument must exist");
939 let values = arg.get_possible_values();
940 assert!(
941 !values.is_empty(),
942 "--log-format must declare possible values"
943 );
944 for possible_value in values {
945 let name = possible_value.get_name();
946 assert!(
947 LogFormat::from_str(name).is_ok(),
948 "{name} must parse via LogFormat::from_str to match the --log-format value parser"
949 );
950 }
951 }
952
953 #[test]
954 fn test_cli_log_format_help_documents_env_var() {
955 let mut command = Cli::command();
956 let help = command.render_long_help().to_string();
957 assert!(
958 help.contains("MCP_EXECUTION_LOG_FORMAT"),
959 "--help must document the MCP_EXECUTION_LOG_FORMAT environment variable per FR-004"
960 );
961 }
962
963 #[test]
964 fn test_cli_parsing_completions_bash() {
965 let cli = Cli::parse_from(["mcp-cli", "completions", "bash"]);
966 assert!(matches!(cli.command, Commands::Completions { .. }));
967 }
968
969 #[test]
970 fn test_cli_parsing_completions_zsh() {
971 let cli = Cli::parse_from(["mcp-cli", "completions", "zsh"]);
972 if let Commands::Completions { shell } = cli.command {
973 assert_eq!(shell, Shell::Zsh);
974 } else {
975 panic!("Expected Completions command");
976 }
977 }
978
979 #[test]
980 fn test_cli_parsing_skill_basic() {
981 let cli = Cli::parse_from(["mcp-cli", "skill", "--server", "github"]);
982 if let Commands::Skill {
983 server,
984 servers_dir,
985 output,
986 skill_name,
987 hints,
988 overwrite,
989 } = cli.command
990 {
991 assert_eq!(server, "github");
992 assert!(servers_dir.is_none());
993 assert!(output.is_none());
994 assert!(skill_name.is_none());
995 assert!(hints.is_empty());
996 assert!(!overwrite);
997 } else {
998 panic!("Expected Skill command");
999 }
1000 }
1001
1002 #[test]
1003 fn test_cli_parsing_skill_all_options() {
1004 let cli = Cli::parse_from([
1005 "mcp-cli",
1006 "skill",
1007 "--server",
1008 "github",
1009 "--servers-dir",
1010 "/custom/servers",
1011 "--output",
1012 "/custom/skills/github.md",
1013 "--skill-name",
1014 "github-advanced",
1015 "--hint",
1016 "pull requests",
1017 "--hint",
1018 "code review",
1019 "--overwrite",
1020 ]);
1021 if let Commands::Skill {
1022 server,
1023 servers_dir,
1024 output,
1025 skill_name,
1026 hints,
1027 overwrite,
1028 } = cli.command
1029 {
1030 assert_eq!(server, "github");
1031 assert_eq!(servers_dir, Some(PathBuf::from("/custom/servers")));
1032 assert_eq!(output, Some(PathBuf::from("/custom/skills/github.md")));
1033 assert_eq!(skill_name, Some("github-advanced".to_string()));
1034 assert_eq!(
1035 hints,
1036 vec!["pull requests".to_string(), "code review".to_string()]
1037 );
1038 assert!(overwrite);
1039 } else {
1040 panic!("Expected Skill command");
1041 }
1042 }
1043
1044 #[test]
1045 fn test_cli_parsing_skill_short_flags() {
1046 let cli = Cli::parse_from(["mcp-cli", "skill", "-s", "github", "-o", "/tmp/skill.md"]);
1047 if let Commands::Skill { server, output, .. } = cli.command {
1048 assert_eq!(server, "github");
1049 assert_eq!(output, Some(PathBuf::from("/tmp/skill.md")));
1050 } else {
1051 panic!("Expected Skill command");
1052 }
1053 }
1054
1055 #[test]
1056 fn test_cli_parsing_skill_multiple_hints() {
1057 let cli = Cli::parse_from([
1058 "mcp-cli",
1059 "skill",
1060 "--server",
1061 "github",
1062 "--hint",
1063 "managing pull requests",
1064 "--hint",
1065 "code review",
1066 "--hint",
1067 "CI/CD automation",
1068 ]);
1069 if let Commands::Skill { hints, .. } = cli.command {
1070 assert_eq!(hints.len(), 3);
1071 assert_eq!(hints[0], "managing pull requests");
1072 assert_eq!(hints[1], "code review");
1073 assert_eq!(hints[2], "CI/CD automation");
1074 } else {
1075 panic!("Expected Skill command");
1076 }
1077 }
1078
1079 #[test]
1080 fn test_cli_parsing_skill_overwrite() {
1081 let cli = Cli::parse_from(["mcp-cli", "skill", "--server", "test", "--overwrite"]);
1082 if let Commands::Skill { overwrite, .. } = cli.command {
1083 assert!(overwrite);
1084 } else {
1085 panic!("Expected Skill command");
1086 }
1087 }
1088
1089 #[test]
1090 fn test_commands_debug_redacts_introspect_env_and_headers() {
1091 let secret_body = "sk-verySECRETtoken1234567890";
1092 let cli = Cli::parse_from([
1093 "mcp-cli",
1094 "introspect",
1095 "docker",
1096 "--env",
1097 &format!("GITHUB_TOKEN={secret_body}"),
1098 "--header",
1099 &format!("Authorization=Bearer {secret_body}"),
1100 ]);
1101
1102 let debug_output = format!("{:?}", cli.command);
1103 assert!(debug_output.contains("<redacted>"));
1104 assert!(!debug_output.contains(secret_body));
1105 }
1106
1107 #[test]
1108 fn test_commands_debug_redacts_generate_env_and_headers() {
1109 let secret_body = "sk-verySECRETtoken1234567890";
1110 let cli = Cli::parse_from([
1111 "mcp-cli",
1112 "generate",
1113 "docker",
1114 "--env",
1115 &format!("GITHUB_TOKEN={secret_body}"),
1116 "--header",
1117 &format!("Authorization=Bearer {secret_body}"),
1118 ]);
1119
1120 let debug_output = format!("{:?}", cli.command);
1121 assert!(debug_output.contains("<redacted>"));
1122 assert!(!debug_output.contains(secret_body));
1123 }
1124
1125 #[test]
1126 fn test_commands_debug_does_not_redact_args() {
1127 let cli = Cli::parse_from(["mcp-cli", "introspect", "docker", "--arg=stdio"]);
1130 let debug_output = format!("{:?}", cli.command);
1131 assert!(debug_output.contains("stdio"));
1132 }
1133
1134 #[test]
1135 fn test_commands_debug_redacts_introspect_http_url() {
1136 let secret = "sk-verySECRETtoken1234567890";
1137 let cli = Cli::parse_from([
1138 "mcp-cli",
1139 "introspect",
1140 "--http",
1141 &format!("https://user:{secret}@host.example.com/mcp?token={secret}"),
1142 ]);
1143
1144 let debug_output = format!("{:?}", cli.command);
1145 assert!(!debug_output.contains(secret));
1146 assert!(debug_output.contains("host.example.com/mcp"));
1147 }
1148
1149 #[test]
1150 fn test_commands_debug_redacts_introspect_sse_url() {
1151 let secret = "sk-verySECRETtoken1234567890";
1152 let cli = Cli::parse_from([
1153 "mcp-cli",
1154 "introspect",
1155 "--sse",
1156 &format!("https://user:{secret}@host.example.com/mcp?token={secret}"),
1157 ]);
1158
1159 let debug_output = format!("{:?}", cli.command);
1160 assert!(!debug_output.contains(secret));
1161 assert!(debug_output.contains("host.example.com/mcp"));
1162 }
1163
1164 #[test]
1165 fn test_commands_debug_redacts_generate_http_url() {
1166 let secret = "sk-verySECRETtoken1234567890";
1167 let cli = Cli::parse_from([
1168 "mcp-cli",
1169 "generate",
1170 "--http",
1171 &format!("https://user:{secret}@host.example.com/mcp?token={secret}"),
1172 ]);
1173
1174 let debug_output = format!("{:?}", cli.command);
1175 assert!(!debug_output.contains(secret));
1176 assert!(debug_output.contains("host.example.com/mcp"));
1177 }
1178
1179 #[test]
1180 fn test_commands_debug_redacts_generate_sse_url() {
1181 let secret = "sk-verySECRETtoken1234567890";
1182 let cli = Cli::parse_from([
1183 "mcp-cli",
1184 "generate",
1185 "--sse",
1186 &format!("https://user:{secret}@host.example.com/mcp?token={secret}"),
1187 ]);
1188
1189 let debug_output = format!("{:?}", cli.command);
1190 assert!(!debug_output.contains(secret));
1191 assert!(debug_output.contains("host.example.com/mcp"));
1192 }
1193
1194 #[test]
1195 fn test_server_flags_debug_redacts_secret_shaped_fields() {
1196 let secret = "sk-live-secret";
1200 let home = dirs::home_dir().expect("home directory must be resolvable in test environment");
1201 let server_path = home.join("tools").join("mcp-server");
1202
1203 let cli = Cli::parse_from([
1204 "mcp-cli",
1205 "introspect",
1206 &server_path.display().to_string(),
1207 "--env",
1208 &format!("GITHUB_TOKEN={secret}"),
1209 "--connect-timeout-secs",
1210 "30",
1211 ]);
1212
1213 let debug_output = format!("{:?}", cli.command);
1214 assert!(!debug_output.contains(secret));
1215 assert!(!debug_output.contains(&home.display().to_string()));
1216 assert!(debug_output.contains('~'));
1217 assert!(debug_output.contains("connect_timeout_secs: Some(30)"));
1218 }
1219
1220 #[test]
1224 fn test_cli_parsing_introspect_positional_with_http_errors() {
1225 let result = Cli::try_parse_from([
1229 "mcp-cli",
1230 "introspect",
1231 "docker",
1232 "--http",
1233 "https://api.example.com",
1234 ]);
1235 assert!(result.is_err());
1236 }
1237
1238 #[test]
1239 fn test_cli_parsing_generate_positional_with_http_errors() {
1240 let result = Cli::try_parse_from([
1241 "mcp-cli",
1242 "generate",
1243 "docker",
1244 "--http",
1245 "https://api.example.com",
1246 ]);
1247 assert!(result.is_err());
1248 }
1249
1250 #[test]
1251 fn test_cli_parsing_introspect_no_selector_errors() {
1252 let result = Cli::try_parse_from(["mcp-cli", "introspect"]);
1253 assert!(result.is_err());
1254 }
1255
1256 #[test]
1257 fn test_cli_parsing_generate_no_selector_errors() {
1258 let result = Cli::try_parse_from(["mcp-cli", "generate"]);
1259 assert!(result.is_err());
1260 }
1261
1262 #[test]
1263 fn test_cli_parsing_introspect_http_and_sse_together_errors() {
1264 let result = Cli::try_parse_from([
1265 "mcp-cli",
1266 "introspect",
1267 "--http",
1268 "https://api.example.com",
1269 "--sse",
1270 "https://api.example.com/sse",
1271 ]);
1272 assert!(result.is_err());
1273 }
1274
1275 #[test]
1276 fn test_cli_parsing_generate_http_and_sse_together_errors() {
1277 let result = Cli::try_parse_from([
1278 "mcp-cli",
1279 "generate",
1280 "--http",
1281 "https://api.example.com",
1282 "--sse",
1283 "https://api.example.com/sse",
1284 ]);
1285 assert!(result.is_err());
1286 }
1287
1288 #[test]
1289 fn test_cli_parsing_introspect_from_config_and_http_together_errors() {
1290 let result = Cli::try_parse_from([
1291 "mcp-cli",
1292 "introspect",
1293 "--from-config",
1294 "github",
1295 "--http",
1296 "https://api.example.com",
1297 ]);
1298 assert!(result.is_err());
1299 }
1300
1301 #[test]
1302 fn test_cli_parsing_generate_from_config_and_http_together_errors() {
1303 let result = Cli::try_parse_from([
1304 "mcp-cli",
1305 "generate",
1306 "--from-config",
1307 "github",
1308 "--http",
1309 "https://api.example.com",
1310 ]);
1311 assert!(result.is_err());
1312 }
1313
1314 #[test]
1315 fn test_server_source_try_from_server_flags_catch_all_errors() {
1316 let flags = ServerFlags {
1321 from_config: None,
1322 server: None,
1323 args: vec![],
1324 env: vec![],
1325 cwd: None,
1326 http: None,
1327 sse: None,
1328 headers: vec![],
1329 connect_timeout_secs: None,
1330 discover_timeout_secs: None,
1331 };
1332
1333 let result = ServerSource::try_from(flags);
1334 assert!(result.is_err());
1335 }
1336
1337 fn introspect_flags(cli: Cli) -> ServerFlags {
1345 match cli.command {
1346 Commands::Introspect { flags, .. } => flags,
1347 other => panic!("expected Introspect command, got {other:?}"),
1348 }
1349 }
1350
1351 #[test]
1352 fn test_server_source_try_from_config_arm() {
1353 let cli = Cli::parse_from(["mcp-cli", "introspect", "--from-config", "github"]);
1354 let source = ServerSource::try_from(introspect_flags(cli)).unwrap();
1355
1356 assert!(matches!(source, ServerSource::Config { name } if name == "github"));
1357 }
1358
1359 #[test]
1360 fn test_server_source_try_from_stdio_arm_does_not_transpose_args_and_env() {
1361 let cli = Cli::parse_from([
1362 "mcp-cli",
1363 "introspect",
1364 "docker",
1365 "--arg=run",
1366 "--env=TOKEN=abc",
1367 "--cwd=/tmp/work",
1368 ]);
1369 let source = ServerSource::try_from(introspect_flags(cli)).unwrap();
1370
1371 match source {
1372 ServerSource::Flags {
1373 transport:
1374 TransportArgs::Stdio {
1375 command,
1376 args,
1377 env,
1378 cwd,
1379 },
1380 ..
1381 } => {
1382 assert_eq!(command, "docker");
1383 assert_eq!(args, vec!["run".to_string()]);
1384 assert_eq!(env, vec!["TOKEN=abc".to_string()]);
1385 assert_eq!(cwd, Some("/tmp/work".to_string()));
1386 }
1387 other => panic!("expected Flags{{Stdio}}, got {other:?}"),
1388 }
1389 }
1390
1391 #[test]
1392 fn test_server_source_try_from_http_arm_does_not_swap_with_sse() {
1393 let cli = Cli::parse_from([
1394 "mcp-cli",
1395 "introspect",
1396 "--http",
1397 "https://api.example.com/mcp",
1398 "--header=Authorization=Bearer x",
1399 ]);
1400 let source = ServerSource::try_from(introspect_flags(cli)).unwrap();
1401
1402 match source {
1403 ServerSource::Flags {
1404 transport: TransportArgs::Http { url, headers },
1405 ..
1406 } => {
1407 assert_eq!(url, "https://api.example.com/mcp");
1408 assert_eq!(headers, vec!["Authorization=Bearer x".to_string()]);
1409 }
1410 other => panic!("expected Flags{{Http}}, got {other:?}"),
1411 }
1412 }
1413
1414 #[test]
1415 fn test_server_source_try_from_sse_arm_does_not_swap_with_http() {
1416 let cli = Cli::parse_from([
1417 "mcp-cli",
1418 "introspect",
1419 "--sse",
1420 "https://api.example.com/sse",
1421 "--header=X-API-Key=secret",
1422 ]);
1423 let source = ServerSource::try_from(introspect_flags(cli)).unwrap();
1424
1425 match source {
1426 ServerSource::Flags {
1427 transport: TransportArgs::Sse { url, headers },
1428 ..
1429 } => {
1430 assert_eq!(url, "https://api.example.com/sse");
1431 assert_eq!(headers, vec!["X-API-Key=secret".to_string()]);
1432 }
1433 other => panic!("expected Flags{{Sse}}, got {other:?}"),
1434 }
1435 }
1436}