Skip to main content

mcp_execution_cli/
cli.rs

1//! CLI argument definitions and parsing.
2//!
3//! Defines the command-line interface structure using clap:
4//! - `Cli` - Main CLI entry point
5//! - `Commands` - Available subcommands
6
7use 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::OutputFormat;
17use mcp_execution_core::{Error as CoreError, RedactedItems, RedactedUrl, sanitize_path_for_error};
18
19/// MCP Code Execution - Secure WASM-based MCP tool execution.
20///
21/// This CLI provides secure execution of MCP tools in a WebAssembly sandbox,
22/// achieving 90-98% token savings through progressive tool loading.
23///
24/// # Examples
25///
26/// ```no_run
27/// use mcp_execution_cli::cli::Cli;
28/// use clap::Parser;
29///
30/// // Parse command-line arguments into a Cli struct
31/// let args = Cli::parse();
32/// println!("Verbose: {}", args.verbose);
33/// println!("Format: {:?}", args.format);
34/// ```
35#[derive(Parser)]
36#[command(version, about, long_about = None)]
37#[command(author = "MCP Execution Team")]
38pub struct Cli {
39    /// Subcommand to execute
40    #[command(subcommand)]
41    pub command: Commands,
42
43    /// Enable verbose logging (debug level)
44    #[arg(short, long, global = true)]
45    pub verbose: bool,
46
47    /// Output format
48    #[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
59// Hand-written to redact `Commands::Introspect`'s `env`/`headers`/`http`/`sse`
60// and `Commands::Generate`'s `server_env`/`server_headers`/`http_url`/
61// `sse_url` — these carry raw, unparsed `KEY=VALUE` secrets and URLs (which
62// may embed credentials, e.g. `https://user:token@host/mcp`) straight from
63// argv, before `TransportArgs`/`McpTransport` ever get a chance to redact
64// them. Mirrors `commands::common::TransportArgs`'s `Debug` impl and reuses
65// `mcp_execution_core::RedactedItems`/`RedactedUrl` rather than duplicating
66// the redaction logic.
67impl fmt::Debug for Cli {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        // Destructuring (rather than `&self.field`) turns a field added to
70        // `Cli` without a matching arm here into a compile error instead of
71        // relying solely on `clippy::missing_fields_in_debug` to catch it.
72        let Self {
73            command,
74            verbose,
75            format,
76        } = self;
77        f.debug_struct("Cli")
78            .field("command", command)
79            .field("verbose", verbose)
80            .field("format", format)
81            .finish()
82    }
83}
84
85/// Shared server-selection, transport, and timeout flags for `introspect` and `generate`.
86///
87/// Fields are private: the only way to obtain a value of this type is via clap parsing
88/// (`#[command(flatten)]` on `Commands::Introspect`/`Commands::Generate`), which — via the
89/// `server_source` argument group below — guarantees exactly one of `--from-config`, the
90/// positional `server`, `--http`, or `--sse` is set before [`TryFrom<ServerFlags> for
91/// ServerSource`](ServerSource) ever runs. This makes the illegal states (zero or multiple
92/// selectors) unconstructible outside this module rather than merely checked at runtime.
93///
94/// # Examples
95///
96/// ```
97/// use clap::Parser;
98/// use mcp_execution_cli::cli::{Cli, Commands};
99///
100/// // The positional `server` and `--from-config`/`--http`/`--sse` are
101/// // alternative selectors accepted by the same `server_source` group.
102/// let cli = Cli::parse_from(["mcp-execution-cli", "introspect", "github-mcp-server"]);
103/// assert!(matches!(cli.command, Commands::Introspect { .. }));
104///
105/// let cli = Cli::parse_from([
106///     "mcp-execution-cli",
107///     "introspect",
108///     "--http",
109///     "https://api.example.com/mcp",
110/// ]);
111/// assert!(matches!(cli.command, Commands::Introspect { .. }));
112///
113/// // Exactly one selector is required: none set is a parse error.
114/// assert!(Cli::try_parse_from(["mcp-execution-cli", "introspect"]).is_err());
115/// ```
116#[derive(Args)]
117#[command(group(
118    ArgGroup::new("server_source")
119        .required(true)
120        .args(["from_config", "server", "http", "sse"])
121))]
122pub struct ServerFlags {
123    /// Load server configuration from ~/.claude/mcp.json by name
124    ///
125    /// When specified, all other server configuration options are ignored.
126    /// The server must be defined in ~/.claude/mcp.json with matching name.
127    ///
128    /// Example mcp.json (stdio and http entries can be mixed freely):
129    /// ```json
130    /// {
131    ///   "mcpServers": {
132    ///     "github": {
133    ///       "command": "docker",
134    ///       "args": ["run", "-i", "--rm", "..."],
135    ///       "env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "..."}
136    ///     },
137    ///     "remote": {
138    ///       "type": "http",
139    ///       "url": "https://api.example.com/mcp",
140    ///       "headers": {"Authorization": "Bearer ..."}
141    ///     }
142    ///   }
143    /// }
144    /// ```
145    #[arg(long = "from-config", conflicts_with_all = ["server", "args", "env", "cwd", "http", "sse", "connect_timeout_secs", "discover_timeout_secs"])]
146    from_config: Option<String>,
147
148    /// Server command (binary name or path)
149    ///
150    /// For stdio transport: command to execute (e.g., "docker", "npx", "github-mcp-server")
151    /// Not required when using --from-config, --http, or --sse
152    server: Option<String>,
153
154    /// Arguments to pass to the server command
155    #[arg(short, long = "arg", num_args = 1)]
156    args: Vec<String>,
157
158    /// Environment variables in KEY=VALUE format
159    #[arg(short, long = "env", num_args = 1)]
160    env: Vec<String>,
161
162    /// Working directory for the server process
163    #[arg(long)]
164    cwd: Option<String>,
165
166    /// Use HTTP transport with specified URL
167    #[arg(long, conflicts_with = "sse")]
168    http: Option<String>,
169
170    /// Use SSE transport with specified URL
171    #[arg(long, conflicts_with = "http")]
172    sse: Option<String>,
173
174    /// HTTP headers in KEY=VALUE format (for HTTP/SSE transport)
175    #[arg(long = "header", num_args = 1)]
176    headers: Vec<String>,
177
178    /// Override the connection (handshake) timeout, in seconds.
179    ///
180    /// Same field/units as `mcp.json`'s `connectTimeoutSecs`. Must be
181    /// greater than zero and at most 600 seconds (10 minutes); there is
182    /// no infinite-timeout option, since an unbounded wait would let a
183    /// hung server block this command forever.
184    ///
185    /// Conflicts with `--from-config`: to override the timeout for a
186    /// server defined in `mcp.json`, either edit its `connectTimeoutSecs`
187    /// field, or re-run this command without `--from-config` using the
188    /// server's command/args/env directly.
189    #[arg(long = "connect-timeout-secs")]
190    connect_timeout_secs: Option<u64>,
191
192    /// Override the tool discovery timeout, in seconds.
193    ///
194    /// Same field/units as `mcp.json`'s `discoverTimeoutSecs`. Same
195    /// bounds and `--from-config` conflict as `--connect-timeout-secs`.
196    #[arg(long = "discover-timeout-secs")]
197    discover_timeout_secs: Option<u64>,
198}
199
200// Hand-written to redact `server`/`env`/`http`/`sse`/`headers` — these carry
201// raw, unparsed `KEY=VALUE` secrets and URLs (which may embed credentials,
202// e.g. `https://user:token@host/mcp`) straight from argv, before
203// `TransportArgs`/`McpTransport` ever get a chance to redact them. `args` is
204// deliberately left unredacted here, matching the pre-existing
205// `Commands::Debug` invariant (see `test_commands_debug_does_not_redact_args`):
206// it is positional, not secret-shaped, and stays visible. This is an
207// intentional asymmetry with `TransportArgs::Stdio`/`McpTransport::Stdio`,
208// whose own `Debug` impls *do* wrap `args` in `RedactedItems` — a caller can
209// still smuggle a secret through `--arg` (e.g. `docker run -e TOKEN=...`
210// style), but by the time a `ServerSource`/`ServerConfig` value (the type
211// that actually flows through the app and can end up in an error's
212// `anyhow::Context`) is built from these flags, that later layer's
213// redaction applies. `ServerFlags::Debug` itself is not on that path today
214// (nothing prints a bare `Commands`/`ServerFlags` value), so this only
215// matters if a future caller adds one.
216
217impl fmt::Debug for ServerFlags {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        let Self {
220            from_config,
221            server,
222            args,
223            env,
224            cwd,
225            http,
226            sse,
227            headers,
228            connect_timeout_secs,
229            discover_timeout_secs,
230        } = self;
231        f.debug_struct("ServerFlags")
232            .field("from_config", from_config)
233            .field(
234                "server",
235                &server
236                    .as_deref()
237                    .map(|s| sanitize_path_for_error(Path::new(s))),
238            )
239            .field("args", args)
240            .field("env", &RedactedItems(env))
241            .field(
242                "cwd",
243                &cwd.as_deref()
244                    .map(|cwd| sanitize_path_for_error(Path::new(cwd))),
245            )
246            .field("http", &http.as_deref().map(RedactedUrl))
247            .field("sse", &sse.as_deref().map(RedactedUrl))
248            .field("headers", &RedactedItems(headers))
249            .field("connect_timeout_secs", connect_timeout_secs)
250            .field("discover_timeout_secs", discover_timeout_secs)
251            .finish()
252    }
253}
254
255/// Converts clap's parsed [`ServerFlags`] landing zone into the closed
256/// [`ServerSource`] domain enum.
257///
258/// # Errors
259///
260/// Returns [`CoreError::InvalidArgument`] if none or more than one of
261/// `from_config`/`server`/`http`/`sse` is set. Unreachable when `flags` came
262/// from real CLI parsing — the `server_source` argument group on
263/// [`ServerFlags`] already enforces exactly one — but reachable from a
264/// directly-constructed `ServerFlags` value (e.g. in tests, which can build
265/// one since they are a child module of this one).
266impl TryFrom<ServerFlags> for ServerSource {
267    type Error = CoreError;
268
269    fn try_from(flags: ServerFlags) -> Result<Self, Self::Error> {
270        let ServerFlags {
271            from_config,
272            server,
273            args,
274            env,
275            cwd,
276            http,
277            sse,
278            headers,
279            connect_timeout_secs,
280            discover_timeout_secs,
281        } = flags;
282
283        match (from_config, server, http, sse) {
284            (Some(name), None, None, None) => Ok(Self::Config { name }),
285            (None, Some(command), None, None) => Ok(Self::Flags {
286                transport: TransportArgs::Stdio {
287                    command,
288                    args,
289                    env,
290                    cwd,
291                },
292                connect_timeout_secs,
293                discover_timeout_secs,
294            }),
295            (None, None, Some(url), None) => Ok(Self::Flags {
296                transport: TransportArgs::Http { url, headers },
297                connect_timeout_secs,
298                discover_timeout_secs,
299            }),
300            (None, None, None, Some(url)) => Ok(Self::Flags {
301                transport: TransportArgs::Sse { url, headers },
302                connect_timeout_secs,
303                discover_timeout_secs,
304            }),
305            _ => Err(CoreError::InvalidArgument(
306                "exactly one of --from-config, a server command, --http, or --sse must be set"
307                    .to_string(),
308            )),
309        }
310    }
311}
312
313/// Available CLI subcommands.
314///
315/// # Examples
316///
317/// ```no_run
318/// use mcp_execution_cli::cli::{Cli, Commands};
319/// use clap::Parser;
320///
321/// let args = Cli::parse();
322/// match args.command {
323///     Commands::Introspect { .. } => println!("Introspect command"),
324///     Commands::Generate { .. } => println!("Generate command"),
325///     Commands::Server { .. } => println!("Server command"),
326///     Commands::Skill { .. } => println!("Skill command"),
327///     Commands::Setup => println!("Setup command"),
328///     Commands::Completions { .. } => println!("Completions command"),
329/// }
330/// ```
331#[derive(Subcommand)]
332pub enum Commands {
333    /// Introspect an MCP server and display its capabilities.
334    ///
335    /// Connects to an MCP server, discovers its tools, and displays
336    /// detailed information about available capabilities.
337    ///
338    /// # Configuration Modes
339    ///
340    /// 1. Load from ~/.claude/mcp.json (recommended):
341    ///    ```bash
342    ///    mcp-execution-cli introspect --from-config github
343    ///    ```
344    ///
345    /// 2. Manual configuration:
346    ///    ```bash
347    ///    mcp-execution-cli introspect github-mcp-server --arg=stdio
348    ///    ```
349    ///
350    /// # Examples
351    ///
352    /// ```bash
353    /// # Load GitHub server config from mcp.json
354    /// mcp-execution-cli introspect --from-config github
355    ///
356    /// # Load with detailed schemas
357    /// mcp-execution-cli introspect --from-config github --detailed
358    ///
359    /// # Manual: Simple binary
360    /// mcp-execution-cli introspect github-mcp-server
361    ///
362    /// # Manual: With arguments
363    /// mcp-execution-cli introspect github-mcp-server --arg=stdio
364    ///
365    /// # Manual: Docker container
366    /// mcp-execution-cli introspect docker --arg=run --arg=-i --arg=--rm \
367    ///     --arg=ghcr.io/github/github-mcp-server \
368    ///     --env=GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx
369    ///
370    /// # HTTP transport
371    /// mcp-execution-cli introspect --http https://api.githubcopilot.com/mcp/ \
372    ///     --header "Authorization=Bearer ghp_xxx"
373    /// ```
374    Introspect {
375        /// Server selection, transport, and timeout flags (shared with `generate`)
376        #[command(flatten)]
377        flags: ServerFlags,
378
379        /// Show detailed tool schemas
380        #[arg(short, long)]
381        detailed: bool,
382    },
383
384    /// Generate Claude Code skill file from progressive loading tools.
385    ///
386    /// Scans generated progressive loading TypeScript files and creates
387    /// an instruction skill (SKILL.md) for Claude Code integration.
388    ///
389    /// # Note
390    ///
391    /// For optimal results, prefer using the MCP server (`mcp-server`) for skill generation.
392    /// The MCP server can leverage LLM capabilities to summarize tool descriptions and reduce
393    /// context size, resulting in more concise and effective skill files.
394    ///
395    /// # Examples
396    ///
397    /// ```bash
398    /// # Generate skill for GitHub server
399    /// mcp-execution-cli skill --server github
400    ///
401    /// # With custom output path
402    /// mcp-execution-cli skill --server github --output ~/.claude/skills/github/SKILL.md
403    ///
404    /// # With use case hints
405    /// mcp-execution-cli skill --server github \
406    ///     --hint "managing pull requests" \
407    ///     --hint "reviewing code changes"
408    ///
409    /// # Overwrite existing skill
410    /// mcp-execution-cli skill --server github --overwrite
411    /// ```
412    Skill {
413        /// Server identifier (e.g., "github")
414        ///
415        /// Must match a directory in `servers_dir` containing generated TypeScript files.
416        #[arg(short, long)]
417        server: String,
418
419        /// Base directory for generated servers
420        ///
421        /// Default: ~/.claude/servers
422        #[arg(long)]
423        servers_dir: Option<PathBuf>,
424
425        /// Custom output path for SKILL.md file
426        ///
427        /// Default: ~/.claude/skills/{server}/SKILL.md
428        #[arg(short, long)]
429        output: Option<PathBuf>,
430
431        /// Custom skill name
432        ///
433        /// Default: {server}-progressive
434        #[arg(long)]
435        skill_name: Option<String>,
436
437        /// Use case hints for skill generation
438        ///
439        /// Multiple hints can be provided to generate more relevant documentation.
440        /// Examples: "managing pull requests", "code review", "CI/CD automation"
441        #[arg(long = "hint", num_args = 1)]
442        hints: Vec<String>,
443
444        /// Overwrite existing SKILL.md file
445        #[arg(long)]
446        overwrite: bool,
447    },
448
449    /// Generate progressive loading code from MCP server.
450    ///
451    /// Introspects an MCP server and generates TypeScript files
452    /// for progressive tool loading.
453    ///
454    /// # Configuration Modes
455    ///
456    /// 1. Load from ~/.claude/mcp.json (recommended):
457    ///    ```bash
458    ///    mcp-execution-cli generate --from-config github
459    ///    ```
460    ///
461    /// 2. Manual configuration:
462    ///    ```bash
463    ///    mcp-execution-cli generate docker --arg=run --arg=-i --arg=--rm \
464    ///        --arg=ghcr.io/github/github-mcp-server \
465    ///        --env=GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx \
466    ///        --name=github
467    ///    ```
468    ///
469    /// # Examples
470    ///
471    /// ```bash
472    /// # Load GitHub server config from mcp.json
473    /// mcp-execution-cli generate --from-config github
474    ///
475    /// # Manual Docker container
476    /// mcp-execution-cli generate docker --arg=run --arg=-i --arg=--rm \
477    ///     --arg=-e --arg=GITHUB_PERSONAL_ACCESS_TOKEN \
478    ///     --arg=ghcr.io/github/github-mcp-server \
479    ///     --env=GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx
480    /// ```
481    Generate {
482        /// Server selection, transport, and timeout flags (shared with `introspect`)
483        #[command(flatten)]
484        flags: ServerFlags,
485
486        /// Custom server name for directory (e.g., 'github' instead of 'docker')
487        /// (default: uses server command name)
488        #[arg(long)]
489        name: Option<String>,
490
491        /// Custom output directory for progressive loading files
492        /// (default: ~/.claude/servers/)
493        #[arg(long)]
494        progressive_output: Option<PathBuf>,
495
496        /// Preview files that would be generated without writing to disk
497        #[arg(long)]
498        dry_run: bool,
499    },
500
501    /// Manage MCP server connections.
502    ///
503    /// List, validate, and manage configured MCP servers.
504    Server {
505        /// Server management action
506        #[command(subcommand)]
507        action: ServerAction,
508    },
509
510    /// Validate runtime environment for MCP tool execution.
511    ///
512    /// Checks that the system is ready to execute generated MCP tools:
513    /// - Verifies Node.js 18+ is installed
514    /// - Checks MCP configuration exists
515    /// - Makes TypeScript files executable (Unix only)
516    ///
517    /// # Examples
518    ///
519    /// ```bash
520    /// # Validate environment
521    /// mcp-execution-cli setup
522    ///
523    /// # Output:
524    /// # ✓ Node.js v20.10.0 detected
525    /// # ✓ MCP configuration found
526    /// # ✓ Runtime setup complete
527    /// ```
528    Setup,
529
530    /// Generate shell completions.
531    ///
532    /// Generates completion scripts for various shells that can be
533    /// sourced or saved to enable tab completion for this CLI.
534    Completions {
535        /// Target shell for completion generation
536        #[arg(value_enum)]
537        shell: Shell,
538    },
539}
540
541impl fmt::Debug for Commands {
542    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
543        match self {
544            Self::Introspect { flags, detailed } => f
545                .debug_struct("Introspect")
546                .field("flags", flags)
547                .field("detailed", detailed)
548                .finish(),
549            Self::Skill {
550                server,
551                servers_dir,
552                output,
553                skill_name,
554                hints,
555                overwrite,
556            } => f
557                .debug_struct("Skill")
558                .field("server", server)
559                .field("servers_dir", servers_dir)
560                .field("output", output)
561                .field("skill_name", skill_name)
562                .field("hints", hints)
563                .field("overwrite", overwrite)
564                .finish(),
565            Self::Generate {
566                flags,
567                name,
568                progressive_output,
569                dry_run,
570            } => f
571                .debug_struct("Generate")
572                .field("flags", flags)
573                .field("name", name)
574                .field("progressive_output", progressive_output)
575                .field("dry_run", dry_run)
576                .finish(),
577            Self::Server { action } => f.debug_struct("Server").field("action", action).finish(),
578            Self::Setup => write!(f, "Setup"),
579            Self::Completions { shell } => {
580                f.debug_struct("Completions").field("shell", shell).finish()
581            }
582        }
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use clap::CommandFactory;
590
591    #[test]
592    fn test_cli_help_examples_use_published_binary_name() {
593        let mut command = Cli::command();
594
595        for subcommand in ["introspect", "generate", "skill"] {
596            let help = command
597                .find_subcommand_mut(subcommand)
598                .expect("subcommand should exist")
599                .render_long_help()
600                .to_string();
601
602            assert!(
603                help.contains(&format!("mcp-execution-cli {subcommand}")),
604                "{subcommand} help should include examples with the published binary name"
605            );
606            assert!(
607                !help.contains(&format!("mcp-cli {subcommand}")),
608                "{subcommand} help should not reference the old binary name"
609            );
610        }
611    }
612
613    #[test]
614    fn test_cli_parsing_introspect() {
615        let cli = Cli::parse_from(["mcp-cli", "introspect", "github"]);
616        assert!(matches!(cli.command, Commands::Introspect { .. }));
617    }
618
619    #[test]
620    fn test_cli_parsing_introspect_with_args() {
621        let cli = Cli::parse_from([
622            "mcp-cli",
623            "introspect",
624            "docker",
625            "--arg=run",
626            "--arg=-i",
627            "--arg=--rm",
628            "--arg=ghcr.io/github/github-mcp-server",
629            "--env=GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx",
630        ]);
631        if let Commands::Introspect { flags, .. } = cli.command {
632            assert_eq!(flags.server, Some("docker".to_string()));
633            assert_eq!(
634                flags.args,
635                vec!["run", "-i", "--rm", "ghcr.io/github/github-mcp-server"]
636            );
637            assert_eq!(flags.env, vec!["GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx"]);
638        } else {
639            panic!("Expected Introspect command");
640        }
641    }
642
643    #[test]
644    fn test_cli_parsing_introspect_http() {
645        let cli = Cli::parse_from([
646            "mcp-cli",
647            "introspect",
648            "--http",
649            "https://api.githubcopilot.com/mcp/",
650            "--header",
651            "Authorization=Bearer token",
652        ]);
653        if let Commands::Introspect { flags, .. } = cli.command {
654            assert_eq!(flags.server, None);
655            assert_eq!(
656                flags.http,
657                Some("https://api.githubcopilot.com/mcp/".to_string())
658            );
659            assert_eq!(flags.headers, vec!["Authorization=Bearer token"]);
660        } else {
661            panic!("Expected Introspect command");
662        }
663    }
664
665    #[test]
666    fn test_cli_parsing_introspect_timeout_overrides() {
667        let cli = Cli::parse_from([
668            "mcp-cli",
669            "introspect",
670            "docker",
671            "--connect-timeout-secs",
672            "5",
673            "--discover-timeout-secs",
674            "90",
675        ]);
676        if let Commands::Introspect { flags, .. } = cli.command {
677            assert_eq!(flags.connect_timeout_secs, Some(5));
678            assert_eq!(flags.discover_timeout_secs, Some(90));
679        } else {
680            panic!("Expected Introspect command");
681        }
682    }
683
684    #[test]
685    fn test_cli_parsing_introspect_timeout_conflicts_with_from_config() {
686        let result = Cli::try_parse_from([
687            "mcp-cli",
688            "introspect",
689            "--from-config",
690            "github",
691            "--connect-timeout-secs",
692            "5",
693        ]);
694        assert!(result.is_err());
695    }
696
697    #[test]
698    fn test_cli_parsing_generate_timeout_conflicts_with_from_config() {
699        let result = Cli::try_parse_from([
700            "mcp-cli",
701            "generate",
702            "--from-config",
703            "github",
704            "--discover-timeout-secs",
705            "90",
706        ]);
707        assert!(result.is_err());
708    }
709
710    #[test]
711    fn test_cli_parsing_generate() {
712        let cli = Cli::parse_from(["mcp-cli", "generate", "server"]);
713        assert!(matches!(cli.command, Commands::Generate { .. }));
714
715        let cli = Cli::parse_from([
716            "mcp-cli",
717            "generate",
718            "server",
719            "--progressive-output",
720            "/tmp/output",
721        ]);
722        if let Commands::Generate {
723            progressive_output, ..
724        } = cli.command
725        {
726            assert_eq!(progressive_output, Some(PathBuf::from("/tmp/output")));
727        } else {
728            panic!("Expected Generate command");
729        }
730    }
731
732    #[test]
733    fn test_cli_parsing_generate_timeout_overrides() {
734        let cli = Cli::parse_from([
735            "mcp-cli",
736            "generate",
737            "docker",
738            "--connect-timeout-secs",
739            "5",
740            "--discover-timeout-secs",
741            "90",
742        ]);
743        if let Commands::Generate { flags, .. } = cli.command {
744            assert_eq!(flags.connect_timeout_secs, Some(5));
745            assert_eq!(flags.discover_timeout_secs, Some(90));
746        } else {
747            panic!("Expected Generate command");
748        }
749    }
750
751    #[test]
752    fn test_cli_parsing_generate_dry_run() {
753        let cli = Cli::parse_from(["mcp-cli", "generate", "server", "--dry-run"]);
754        if let Commands::Generate { dry_run, .. } = cli.command {
755            assert!(dry_run);
756        } else {
757            panic!("Expected Generate command");
758        }
759    }
760
761    #[test]
762    fn test_cli_parsing_generate_dry_run_default_false() {
763        let cli = Cli::parse_from(["mcp-cli", "generate", "server"]);
764        if let Commands::Generate { dry_run, .. } = cli.command {
765            assert!(!dry_run);
766        } else {
767            panic!("Expected Generate command");
768        }
769    }
770
771    #[test]
772    fn test_cli_parsing_server_list() {
773        let cli = Cli::parse_from(["mcp-cli", "server", "list"]);
774        assert!(matches!(cli.command, Commands::Server { .. }));
775    }
776
777    #[test]
778    fn test_cli_verbose_flag() {
779        let cli = Cli::parse_from(["mcp-cli", "--verbose", "introspect", "github"]);
780        assert!(cli.verbose);
781    }
782
783    #[test]
784    fn test_cli_output_format_default() {
785        let cli = Cli::parse_from(["mcp-cli", "introspect", "github"]);
786        assert_eq!(cli.format, OutputFormat::Pretty);
787    }
788
789    #[test]
790    fn test_cli_output_format_custom() {
791        let cli = Cli::parse_from(["mcp-cli", "--format", "json", "introspect", "github"]);
792        assert_eq!(cli.format, OutputFormat::Json);
793    }
794
795    #[test]
796    fn test_cli_output_format_invalid_rejected_by_clap() {
797        let result = Cli::try_parse_from(["mcp-cli", "--format", "xml", "introspect", "github"]);
798        assert!(result.is_err());
799    }
800
801    #[test]
802    fn test_cli_output_format_possible_values_parse_via_from_str() {
803        // Guards the `--format` value parser's `expect()` against the
804        // `PossibleValuesParser` list drifting from `OutputFormat`'s actual
805        // variants: reads the possible values off the real `clap::Command`
806        // rather than hardcoding a third independent copy of the list, so an
807        // entry added to one side without the other fails here instead of
808        // panicking inside clap's argument parsing.
809        let cmd = Cli::command();
810        let arg = cmd
811            .get_arguments()
812            .find(|a| a.get_id() == "format")
813            .expect("--format argument must exist");
814        let values = arg.get_possible_values();
815        assert!(!values.is_empty(), "--format must declare possible values");
816        for possible_value in values {
817            let name = possible_value.get_name();
818            assert!(
819                OutputFormat::from_str(name).is_ok(),
820                "{name} must parse via OutputFormat::from_str to match the --format value parser"
821            );
822        }
823    }
824
825    #[test]
826    fn test_cli_output_format_case_insensitive() {
827        let cli = Cli::parse_from(["mcp-cli", "--format", "JSON", "introspect", "github"]);
828        assert_eq!(cli.format, OutputFormat::Json);
829
830        let cli = Cli::parse_from(["mcp-cli", "--format", "PRETTY", "introspect", "github"]);
831        assert_eq!(cli.format, OutputFormat::Pretty);
832    }
833
834    #[test]
835    fn test_output_format_parsing_valid() {
836        use mcp_execution_core::cli::OutputFormat;
837
838        let format: OutputFormat = "json".parse().unwrap();
839        assert_eq!(format, OutputFormat::Json);
840
841        let format: OutputFormat = "text".parse().unwrap();
842        assert_eq!(format, OutputFormat::Text);
843
844        let format: OutputFormat = "pretty".parse().unwrap();
845        assert_eq!(format, OutputFormat::Pretty);
846    }
847
848    #[test]
849    fn test_output_format_parsing_invalid() {
850        use mcp_execution_core::cli::OutputFormat;
851        assert!("invalid".parse::<OutputFormat>().is_err());
852    }
853
854    #[test]
855    fn test_cli_parsing_completions_bash() {
856        let cli = Cli::parse_from(["mcp-cli", "completions", "bash"]);
857        assert!(matches!(cli.command, Commands::Completions { .. }));
858    }
859
860    #[test]
861    fn test_cli_parsing_completions_zsh() {
862        let cli = Cli::parse_from(["mcp-cli", "completions", "zsh"]);
863        if let Commands::Completions { shell } = cli.command {
864            assert_eq!(shell, Shell::Zsh);
865        } else {
866            panic!("Expected Completions command");
867        }
868    }
869
870    #[test]
871    fn test_cli_parsing_skill_basic() {
872        let cli = Cli::parse_from(["mcp-cli", "skill", "--server", "github"]);
873        if let Commands::Skill {
874            server,
875            servers_dir,
876            output,
877            skill_name,
878            hints,
879            overwrite,
880        } = cli.command
881        {
882            assert_eq!(server, "github");
883            assert!(servers_dir.is_none());
884            assert!(output.is_none());
885            assert!(skill_name.is_none());
886            assert!(hints.is_empty());
887            assert!(!overwrite);
888        } else {
889            panic!("Expected Skill command");
890        }
891    }
892
893    #[test]
894    fn test_cli_parsing_skill_all_options() {
895        let cli = Cli::parse_from([
896            "mcp-cli",
897            "skill",
898            "--server",
899            "github",
900            "--servers-dir",
901            "/custom/servers",
902            "--output",
903            "/custom/skills/github.md",
904            "--skill-name",
905            "github-advanced",
906            "--hint",
907            "pull requests",
908            "--hint",
909            "code review",
910            "--overwrite",
911        ]);
912        if let Commands::Skill {
913            server,
914            servers_dir,
915            output,
916            skill_name,
917            hints,
918            overwrite,
919        } = cli.command
920        {
921            assert_eq!(server, "github");
922            assert_eq!(servers_dir, Some(PathBuf::from("/custom/servers")));
923            assert_eq!(output, Some(PathBuf::from("/custom/skills/github.md")));
924            assert_eq!(skill_name, Some("github-advanced".to_string()));
925            assert_eq!(
926                hints,
927                vec!["pull requests".to_string(), "code review".to_string()]
928            );
929            assert!(overwrite);
930        } else {
931            panic!("Expected Skill command");
932        }
933    }
934
935    #[test]
936    fn test_cli_parsing_skill_short_flags() {
937        let cli = Cli::parse_from(["mcp-cli", "skill", "-s", "github", "-o", "/tmp/skill.md"]);
938        if let Commands::Skill { server, output, .. } = cli.command {
939            assert_eq!(server, "github");
940            assert_eq!(output, Some(PathBuf::from("/tmp/skill.md")));
941        } else {
942            panic!("Expected Skill command");
943        }
944    }
945
946    #[test]
947    fn test_cli_parsing_skill_multiple_hints() {
948        let cli = Cli::parse_from([
949            "mcp-cli",
950            "skill",
951            "--server",
952            "github",
953            "--hint",
954            "managing pull requests",
955            "--hint",
956            "code review",
957            "--hint",
958            "CI/CD automation",
959        ]);
960        if let Commands::Skill { hints, .. } = cli.command {
961            assert_eq!(hints.len(), 3);
962            assert_eq!(hints[0], "managing pull requests");
963            assert_eq!(hints[1], "code review");
964            assert_eq!(hints[2], "CI/CD automation");
965        } else {
966            panic!("Expected Skill command");
967        }
968    }
969
970    #[test]
971    fn test_cli_parsing_skill_overwrite() {
972        let cli = Cli::parse_from(["mcp-cli", "skill", "--server", "test", "--overwrite"]);
973        if let Commands::Skill { overwrite, .. } = cli.command {
974            assert!(overwrite);
975        } else {
976            panic!("Expected Skill command");
977        }
978    }
979
980    #[test]
981    fn test_commands_debug_redacts_introspect_env_and_headers() {
982        let secret_body = "sk-verySECRETtoken1234567890";
983        let cli = Cli::parse_from([
984            "mcp-cli",
985            "introspect",
986            "docker",
987            "--env",
988            &format!("GITHUB_TOKEN={secret_body}"),
989            "--header",
990            &format!("Authorization=Bearer {secret_body}"),
991        ]);
992
993        let debug_output = format!("{:?}", cli.command);
994        assert!(debug_output.contains("<redacted>"));
995        assert!(!debug_output.contains(secret_body));
996    }
997
998    #[test]
999    fn test_commands_debug_redacts_generate_env_and_headers() {
1000        let secret_body = "sk-verySECRETtoken1234567890";
1001        let cli = Cli::parse_from([
1002            "mcp-cli",
1003            "generate",
1004            "docker",
1005            "--env",
1006            &format!("GITHUB_TOKEN={secret_body}"),
1007            "--header",
1008            &format!("Authorization=Bearer {secret_body}"),
1009        ]);
1010
1011        let debug_output = format!("{:?}", cli.command);
1012        assert!(debug_output.contains("<redacted>"));
1013        assert!(!debug_output.contains(secret_body));
1014    }
1015
1016    #[test]
1017    fn test_commands_debug_does_not_redact_args() {
1018        // `args`/`server_args` are positional, not secret-shaped, and must
1019        // remain visible in Debug output.
1020        let cli = Cli::parse_from(["mcp-cli", "introspect", "docker", "--arg=stdio"]);
1021        let debug_output = format!("{:?}", cli.command);
1022        assert!(debug_output.contains("stdio"));
1023    }
1024
1025    #[test]
1026    fn test_commands_debug_redacts_introspect_http_url() {
1027        let secret = "sk-verySECRETtoken1234567890";
1028        let cli = Cli::parse_from([
1029            "mcp-cli",
1030            "introspect",
1031            "--http",
1032            &format!("https://user:{secret}@host.example.com/mcp?token={secret}"),
1033        ]);
1034
1035        let debug_output = format!("{:?}", cli.command);
1036        assert!(!debug_output.contains(secret));
1037        assert!(debug_output.contains("host.example.com/mcp"));
1038    }
1039
1040    #[test]
1041    fn test_commands_debug_redacts_introspect_sse_url() {
1042        let secret = "sk-verySECRETtoken1234567890";
1043        let cli = Cli::parse_from([
1044            "mcp-cli",
1045            "introspect",
1046            "--sse",
1047            &format!("https://user:{secret}@host.example.com/mcp?token={secret}"),
1048        ]);
1049
1050        let debug_output = format!("{:?}", cli.command);
1051        assert!(!debug_output.contains(secret));
1052        assert!(debug_output.contains("host.example.com/mcp"));
1053    }
1054
1055    #[test]
1056    fn test_commands_debug_redacts_generate_http_url() {
1057        let secret = "sk-verySECRETtoken1234567890";
1058        let cli = Cli::parse_from([
1059            "mcp-cli",
1060            "generate",
1061            "--http",
1062            &format!("https://user:{secret}@host.example.com/mcp?token={secret}"),
1063        ]);
1064
1065        let debug_output = format!("{:?}", cli.command);
1066        assert!(!debug_output.contains(secret));
1067        assert!(debug_output.contains("host.example.com/mcp"));
1068    }
1069
1070    #[test]
1071    fn test_commands_debug_redacts_generate_sse_url() {
1072        let secret = "sk-verySECRETtoken1234567890";
1073        let cli = Cli::parse_from([
1074            "mcp-cli",
1075            "generate",
1076            "--sse",
1077            &format!("https://user:{secret}@host.example.com/mcp?token={secret}"),
1078        ]);
1079
1080        let debug_output = format!("{:?}", cli.command);
1081        assert!(!debug_output.contains(secret));
1082        assert!(debug_output.contains("host.example.com/mcp"));
1083    }
1084
1085    #[test]
1086    fn test_server_flags_debug_redacts_secret_shaped_fields() {
1087        // Migrated from the former `RawServerArgs` regression test: `server`'s
1088        // home-relative path must be tilde-sanitized, not just the URL/env/header
1089        // fields already covered above.
1090        let secret = "sk-live-secret";
1091        let home = dirs::home_dir().expect("home directory must be resolvable in test environment");
1092        let server_path = home.join("tools").join("mcp-server");
1093
1094        let cli = Cli::parse_from([
1095            "mcp-cli",
1096            "introspect",
1097            &server_path.display().to_string(),
1098            "--env",
1099            &format!("GITHUB_TOKEN={secret}"),
1100            "--connect-timeout-secs",
1101            "30",
1102        ]);
1103
1104        let debug_output = format!("{:?}", cli.command);
1105        assert!(!debug_output.contains(secret));
1106        assert!(!debug_output.contains(&home.display().to_string()));
1107        assert!(debug_output.contains('~'));
1108        assert!(debug_output.contains("connect_timeout_secs: Some(30)"));
1109    }
1110
1111    // ── #314: `server_source` argument group makes the transport-selector
1112    // exclusivity a clap-level guarantee instead of a runtime check ──
1113
1114    #[test]
1115    fn test_cli_parsing_introspect_positional_with_http_errors() {
1116        // Approved behavior change: previously the positional command was
1117        // silently discarded by `TransportArgs::from_flags` in favor of
1118        // `--http`. Now the `server_source` group rejects both being set.
1119        let result = Cli::try_parse_from([
1120            "mcp-cli",
1121            "introspect",
1122            "docker",
1123            "--http",
1124            "https://api.example.com",
1125        ]);
1126        assert!(result.is_err());
1127    }
1128
1129    #[test]
1130    fn test_cli_parsing_generate_positional_with_http_errors() {
1131        let result = Cli::try_parse_from([
1132            "mcp-cli",
1133            "generate",
1134            "docker",
1135            "--http",
1136            "https://api.example.com",
1137        ]);
1138        assert!(result.is_err());
1139    }
1140
1141    #[test]
1142    fn test_cli_parsing_introspect_no_selector_errors() {
1143        let result = Cli::try_parse_from(["mcp-cli", "introspect"]);
1144        assert!(result.is_err());
1145    }
1146
1147    #[test]
1148    fn test_cli_parsing_generate_no_selector_errors() {
1149        let result = Cli::try_parse_from(["mcp-cli", "generate"]);
1150        assert!(result.is_err());
1151    }
1152
1153    #[test]
1154    fn test_cli_parsing_introspect_http_and_sse_together_errors() {
1155        let result = Cli::try_parse_from([
1156            "mcp-cli",
1157            "introspect",
1158            "--http",
1159            "https://api.example.com",
1160            "--sse",
1161            "https://api.example.com/sse",
1162        ]);
1163        assert!(result.is_err());
1164    }
1165
1166    #[test]
1167    fn test_cli_parsing_generate_http_and_sse_together_errors() {
1168        let result = Cli::try_parse_from([
1169            "mcp-cli",
1170            "generate",
1171            "--http",
1172            "https://api.example.com",
1173            "--sse",
1174            "https://api.example.com/sse",
1175        ]);
1176        assert!(result.is_err());
1177    }
1178
1179    #[test]
1180    fn test_cli_parsing_introspect_from_config_and_http_together_errors() {
1181        let result = Cli::try_parse_from([
1182            "mcp-cli",
1183            "introspect",
1184            "--from-config",
1185            "github",
1186            "--http",
1187            "https://api.example.com",
1188        ]);
1189        assert!(result.is_err());
1190    }
1191
1192    #[test]
1193    fn test_cli_parsing_generate_from_config_and_http_together_errors() {
1194        let result = Cli::try_parse_from([
1195            "mcp-cli",
1196            "generate",
1197            "--from-config",
1198            "github",
1199            "--http",
1200            "https://api.example.com",
1201        ]);
1202        assert!(result.is_err());
1203    }
1204
1205    #[test]
1206    fn test_server_source_try_from_server_flags_catch_all_errors() {
1207        // Legal only because this test module is a child of `cli`, so it can
1208        // see `ServerFlags`'s private fields. Unreachable via real CLI
1209        // parsing: the `server_source` argument group already guarantees
1210        // exactly one selector is set.
1211        let flags = ServerFlags {
1212            from_config: None,
1213            server: None,
1214            args: vec![],
1215            env: vec![],
1216            cwd: None,
1217            http: None,
1218            sse: None,
1219            headers: vec![],
1220            connect_timeout_secs: None,
1221            discover_timeout_secs: None,
1222        };
1223
1224        let result = ServerSource::try_from(flags);
1225        assert!(result.is_err());
1226    }
1227
1228    // ── S1: round-trip parse -> `TryFrom<ServerFlags>` -> assert variant and
1229    // payload for each of the four `Ok` arms. Without these, transposing the
1230    // `Http`/`Sse` arms or the `args`/`env` fields inside `Stdio` (all
1231    // same-typed) would compile and pass the rest of the suite — exactly
1232    // issue #286's bug class, previously guarded by the now-deleted
1233    // `test_transport_args_from_flags_{stdio,http,sse}` tests. ──
1234
1235    fn introspect_flags(cli: Cli) -> ServerFlags {
1236        match cli.command {
1237            Commands::Introspect { flags, .. } => flags,
1238            other => panic!("expected Introspect command, got {other:?}"),
1239        }
1240    }
1241
1242    #[test]
1243    fn test_server_source_try_from_config_arm() {
1244        let cli = Cli::parse_from(["mcp-cli", "introspect", "--from-config", "github"]);
1245        let source = ServerSource::try_from(introspect_flags(cli)).unwrap();
1246
1247        assert!(matches!(source, ServerSource::Config { name } if name == "github"));
1248    }
1249
1250    #[test]
1251    fn test_server_source_try_from_stdio_arm_does_not_transpose_args_and_env() {
1252        let cli = Cli::parse_from([
1253            "mcp-cli",
1254            "introspect",
1255            "docker",
1256            "--arg=run",
1257            "--env=TOKEN=abc",
1258            "--cwd=/tmp/work",
1259        ]);
1260        let source = ServerSource::try_from(introspect_flags(cli)).unwrap();
1261
1262        match source {
1263            ServerSource::Flags {
1264                transport:
1265                    TransportArgs::Stdio {
1266                        command,
1267                        args,
1268                        env,
1269                        cwd,
1270                    },
1271                ..
1272            } => {
1273                assert_eq!(command, "docker");
1274                assert_eq!(args, vec!["run".to_string()]);
1275                assert_eq!(env, vec!["TOKEN=abc".to_string()]);
1276                assert_eq!(cwd, Some("/tmp/work".to_string()));
1277            }
1278            other => panic!("expected Flags{{Stdio}}, got {other:?}"),
1279        }
1280    }
1281
1282    #[test]
1283    fn test_server_source_try_from_http_arm_does_not_swap_with_sse() {
1284        let cli = Cli::parse_from([
1285            "mcp-cli",
1286            "introspect",
1287            "--http",
1288            "https://api.example.com/mcp",
1289            "--header=Authorization=Bearer x",
1290        ]);
1291        let source = ServerSource::try_from(introspect_flags(cli)).unwrap();
1292
1293        match source {
1294            ServerSource::Flags {
1295                transport: TransportArgs::Http { url, headers },
1296                ..
1297            } => {
1298                assert_eq!(url, "https://api.example.com/mcp");
1299                assert_eq!(headers, vec!["Authorization=Bearer x".to_string()]);
1300            }
1301            other => panic!("expected Flags{{Http}}, got {other:?}"),
1302        }
1303    }
1304
1305    #[test]
1306    fn test_server_source_try_from_sse_arm_does_not_swap_with_http() {
1307        let cli = Cli::parse_from([
1308            "mcp-cli",
1309            "introspect",
1310            "--sse",
1311            "https://api.example.com/sse",
1312            "--header=X-API-Key=secret",
1313        ]);
1314        let source = ServerSource::try_from(introspect_flags(cli)).unwrap();
1315
1316        match source {
1317            ServerSource::Flags {
1318                transport: TransportArgs::Sse { url, headers },
1319                ..
1320            } => {
1321                assert_eq!(url, "https://api.example.com/sse");
1322                assert_eq!(headers, vec!["X-API-Key=secret".to_string()]);
1323            }
1324            other => panic!("expected Flags{{Sse}}, got {other:?}"),
1325        }
1326    }
1327}