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::{LogFormat, 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    /// Diagnostic log format: `text` (default) or `json`.
59    ///
60    /// Independent of `--format`, which controls command *result* output, not diagnostic
61    /// logging. When unset, falls back to the `MCP_EXECUTION_LOG_FORMAT` environment variable;
62    /// when that is also unset or invalid, defaults to `text`.
63    #[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
73// Hand-written to redact `Commands::Introspect`'s `env`/`headers`/`http`/`sse`
74// and `Commands::Generate`'s `server_env`/`server_headers`/`http_url`/
75// `sse_url` — these carry raw, unparsed `KEY=VALUE` secrets and URLs (which
76// may embed credentials, e.g. `https://user:token@host/mcp`) straight from
77// argv, before `TransportArgs`/`McpTransport` ever get a chance to redact
78// them. Mirrors `commands::common::TransportArgs`'s `Debug` impl and reuses
79// `mcp_execution_core::RedactedItems`/`RedactedUrl` rather than duplicating
80// the redaction logic.
81impl fmt::Debug for Cli {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        // Destructuring (rather than `&self.field`) turns a field added to
84        // `Cli` without a matching arm here into a compile error instead of
85        // relying solely on `clippy::missing_fields_in_debug` to catch it.
86        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/// Shared server-selection, transport, and timeout flags for `introspect` and `generate`.
102///
103/// Fields are private: the only way to obtain a value of this type is via clap parsing
104/// (`#[command(flatten)]` on `Commands::Introspect`/`Commands::Generate`), which — via the
105/// `server_source` argument group below — guarantees exactly one of `--from-config`, the
106/// positional `server`, `--http`, or `--sse` is set before [`TryFrom<ServerFlags> for
107/// ServerSource`](ServerSource) ever runs. This makes the illegal states (zero or multiple
108/// selectors) unconstructible outside this module rather than merely checked at runtime.
109///
110/// # Examples
111///
112/// ```
113/// use clap::Parser;
114/// use mcp_execution_cli::cli::{Cli, Commands};
115///
116/// // The positional `server` and `--from-config`/`--http`/`--sse` are
117/// // alternative selectors accepted by the same `server_source` group.
118/// let cli = Cli::parse_from(["mcp-execution-cli", "introspect", "github-mcp-server"]);
119/// assert!(matches!(cli.command, Commands::Introspect { .. }));
120///
121/// let cli = Cli::parse_from([
122///     "mcp-execution-cli",
123///     "introspect",
124///     "--http",
125///     "https://api.example.com/mcp",
126/// ]);
127/// assert!(matches!(cli.command, Commands::Introspect { .. }));
128///
129/// // Exactly one selector is required: none set is a parse error.
130/// assert!(Cli::try_parse_from(["mcp-execution-cli", "introspect"]).is_err());
131/// ```
132#[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    /// Load server configuration from ~/.claude/mcp.json by name
140    ///
141    /// When specified, all other server configuration options are rejected as
142    /// conflicting arguments. The server must be defined in ~/.claude/mcp.json
143    /// with matching name.
144    ///
145    /// Example mcp.json (stdio and http entries can be mixed freely):
146    /// ```json
147    /// {
148    ///   "mcpServers": {
149    ///     "github": {
150    ///       "command": "docker",
151    ///       "args": ["run", "-i", "--rm", "..."],
152    ///       "env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "..."}
153    ///     },
154    ///     "remote": {
155    ///       "type": "http",
156    ///       "url": "https://api.example.com/mcp",
157    ///       "headers": {"Authorization": "Bearer ..."}
158    ///     }
159    ///   }
160    /// }
161    /// ```
162    #[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 command (binary name or path)
166    ///
167    /// For stdio transport: command to execute (e.g., "docker", "npx", "github-mcp-server")
168    /// Not required when using --from-config, --http, or --sse
169    server: Option<String>,
170
171    /// Arguments to pass to the server command
172    #[arg(short, long = "arg", num_args = 1)]
173    args: Vec<String>,
174
175    /// Environment variables in KEY=VALUE format
176    #[arg(short, long = "env", num_args = 1)]
177    env: Vec<String>,
178
179    /// Working directory for the server process
180    #[arg(long)]
181    cwd: Option<String>,
182
183    /// Use HTTP transport with specified URL
184    #[arg(long, conflicts_with = "sse")]
185    http: Option<String>,
186
187    /// Use SSE transport with specified URL
188    #[arg(long, conflicts_with = "http")]
189    sse: Option<String>,
190
191    /// HTTP headers in KEY=VALUE format (for HTTP/SSE transport)
192    ///
193    /// Conflicts with `--from-config`: to send headers for a server defined
194    /// in `mcp.json`, add them to its `headers` field instead.
195    #[arg(long = "header", num_args = 1)]
196    headers: Vec<String>,
197
198    /// Override the connection (handshake) timeout, in seconds.
199    ///
200    /// Same field/units as `mcp.json`'s `connectTimeoutSecs`. Must be
201    /// greater than zero and at most 600 seconds (10 minutes); there is
202    /// no infinite-timeout option, since an unbounded wait would let a
203    /// hung server block this command forever.
204    ///
205    /// Conflicts with `--from-config`: to override the timeout for a
206    /// server defined in `mcp.json`, either edit its `connectTimeoutSecs`
207    /// field, or re-run this command without `--from-config` using the
208    /// server's command/args/env directly.
209    #[arg(long = "connect-timeout-secs")]
210    connect_timeout_secs: Option<u64>,
211
212    /// Override the tool discovery timeout, in seconds.
213    ///
214    /// Same field/units as `mcp.json`'s `discoverTimeoutSecs`. Same
215    /// bounds and `--from-config` conflict as `--connect-timeout-secs`.
216    #[arg(long = "discover-timeout-secs")]
217    discover_timeout_secs: Option<u64>,
218}
219
220// Hand-written to redact `server`/`env`/`http`/`sse`/`headers` — these carry
221// raw, unparsed `KEY=VALUE` secrets and URLs (which may embed credentials,
222// e.g. `https://user:token@host/mcp`) straight from argv, before
223// `TransportArgs`/`McpTransport` ever get a chance to redact them. `args` is
224// deliberately left unredacted here, matching the pre-existing
225// `Commands::Debug` invariant (see `test_commands_debug_does_not_redact_args`):
226// it is positional, not secret-shaped, and stays visible. This is an
227// intentional asymmetry with `TransportArgs::Stdio`/`McpTransport::Stdio`,
228// whose own `Debug` impls *do* wrap `args` in `RedactedItems` — a caller can
229// still smuggle a secret through `--arg` (e.g. `docker run -e TOKEN=...`
230// style), but by the time a `ServerSource`/`ServerConfig` value (the type
231// that actually flows through the app and can end up in an error's
232// `anyhow::Context`) is built from these flags, that later layer's
233// redaction applies. `ServerFlags::Debug` itself is not on that path today
234// (nothing prints a bare `Commands`/`ServerFlags` value), so this only
235// matters if a future caller adds one.
236
237impl 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
275/// Converts clap's parsed [`ServerFlags`] landing zone into the closed
276/// [`ServerSource`] domain enum.
277///
278/// # Errors
279///
280/// Returns [`CoreError::InvalidArgument`] if none or more than one of
281/// `from_config`/`server`/`http`/`sse` is set. Unreachable when `flags` came
282/// from real CLI parsing — the `server_source` argument group on
283/// [`ServerFlags`] already enforces exactly one — but reachable from a
284/// directly-constructed `ServerFlags` value (e.g. in tests, which can build
285/// one since they are a child module of this one).
286impl 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/// Available CLI subcommands.
334///
335/// # Examples
336///
337/// ```no_run
338/// use mcp_execution_cli::cli::{Cli, Commands};
339/// use clap::Parser;
340///
341/// let args = Cli::parse();
342/// match args.command {
343///     Commands::Introspect { .. } => println!("Introspect command"),
344///     Commands::Generate { .. } => println!("Generate command"),
345///     Commands::Server { .. } => println!("Server command"),
346///     Commands::Skill { .. } => println!("Skill command"),
347///     Commands::Setup => println!("Setup command"),
348///     Commands::Completions { .. } => println!("Completions command"),
349/// }
350/// ```
351#[derive(Subcommand)]
352pub enum Commands {
353    /// Introspect an MCP server and display its capabilities.
354    ///
355    /// Connects to an MCP server, discovers its tools, and displays
356    /// detailed information about available capabilities.
357    ///
358    /// # Configuration Modes
359    ///
360    /// 1. Load from ~/.claude/mcp.json (recommended):
361    ///    ```bash
362    ///    mcp-execution-cli introspect --from-config github
363    ///    ```
364    ///
365    /// 2. Manual configuration:
366    ///    ```bash
367    ///    mcp-execution-cli introspect github-mcp-server --arg=stdio
368    ///    ```
369    ///
370    /// # Examples
371    ///
372    /// ```bash
373    /// # Load GitHub server config from mcp.json
374    /// mcp-execution-cli introspect --from-config github
375    ///
376    /// # Load with detailed schemas
377    /// mcp-execution-cli introspect --from-config github --detailed
378    ///
379    /// # Manual: Simple binary
380    /// mcp-execution-cli introspect github-mcp-server
381    ///
382    /// # Manual: With arguments
383    /// mcp-execution-cli introspect github-mcp-server --arg=stdio
384    ///
385    /// # Manual: Docker container
386    /// mcp-execution-cli introspect docker --arg=run --arg=-i --arg=--rm \
387    ///     --arg=ghcr.io/github/github-mcp-server \
388    ///     --env=GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx
389    ///
390    /// # HTTP transport
391    /// mcp-execution-cli introspect --http https://api.githubcopilot.com/mcp/ \
392    ///     --header "Authorization=Bearer ghp_xxx"
393    /// ```
394    Introspect {
395        /// Server selection, transport, and timeout flags (shared with `generate`)
396        #[command(flatten)]
397        flags: ServerFlags,
398
399        /// Show detailed tool schemas
400        #[arg(short, long)]
401        detailed: bool,
402    },
403
404    /// Generate Claude Code skill file from progressive loading tools.
405    ///
406    /// Scans generated progressive loading TypeScript files and creates
407    /// an instruction skill (SKILL.md) for Claude Code integration.
408    ///
409    /// # Note
410    ///
411    /// For optimal results, prefer using the MCP server (`mcp-server`) for skill generation.
412    /// The MCP server can leverage LLM capabilities to summarize tool descriptions and reduce
413    /// context size, resulting in more concise and effective skill files.
414    ///
415    /// # Examples
416    ///
417    /// ```bash
418    /// # Generate skill for GitHub server
419    /// mcp-execution-cli skill --server github
420    ///
421    /// # With custom output path
422    /// mcp-execution-cli skill --server github --output ~/.claude/skills/github/SKILL.md
423    ///
424    /// # With use case hints
425    /// mcp-execution-cli skill --server github \
426    ///     --hint "managing pull requests" \
427    ///     --hint "reviewing code changes"
428    ///
429    /// # Overwrite existing skill
430    /// mcp-execution-cli skill --server github --overwrite
431    /// ```
432    Skill {
433        /// Server identifier (e.g., "github")
434        ///
435        /// Must match a directory in `servers_dir` containing generated TypeScript files.
436        #[arg(short, long)]
437        server: String,
438
439        /// Base directory for generated servers
440        ///
441        /// Default: ~/.claude/servers
442        #[arg(long)]
443        servers_dir: Option<PathBuf>,
444
445        /// Custom output path for SKILL.md file
446        ///
447        /// Default: ~/.claude/skills/{server}/SKILL.md
448        #[arg(short, long)]
449        output: Option<PathBuf>,
450
451        /// Custom skill name
452        ///
453        /// Default: {server}-progressive
454        #[arg(long)]
455        skill_name: Option<String>,
456
457        /// Use case hints for skill generation
458        ///
459        /// Multiple hints can be provided to generate more relevant documentation. Each hint is
460        /// rendered as a bullet in the generated SKILL.md's "Use Cases" section.
461        /// Examples: "managing pull requests", "code review", "CI/CD automation"
462        #[arg(long = "hint", num_args = 1)]
463        hints: Vec<String>,
464
465        /// Overwrite existing SKILL.md file
466        #[arg(long)]
467        overwrite: bool,
468    },
469
470    /// Generate progressive loading code from MCP server.
471    ///
472    /// Introspects an MCP server and generates TypeScript files
473    /// for progressive tool loading.
474    ///
475    /// # Configuration Modes
476    ///
477    /// 1. Load from ~/.claude/mcp.json (recommended):
478    ///    ```bash
479    ///    mcp-execution-cli generate --from-config github
480    ///    ```
481    ///
482    /// 2. Manual configuration:
483    ///    ```bash
484    ///    mcp-execution-cli generate docker --arg=run --arg=-i --arg=--rm \
485    ///        --arg=ghcr.io/github/github-mcp-server \
486    ///        --env=GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx \
487    ///        --name=github
488    ///    ```
489    ///
490    /// # Examples
491    ///
492    /// ```bash
493    /// # Load GitHub server config from mcp.json
494    /// mcp-execution-cli generate --from-config github
495    ///
496    /// # Manual Docker container
497    /// mcp-execution-cli generate docker --arg=run --arg=-i --arg=--rm \
498    ///     --arg=-e --arg=GITHUB_PERSONAL_ACCESS_TOKEN \
499    ///     --arg=ghcr.io/github/github-mcp-server \
500    ///     --env=GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx
501    /// ```
502    Generate {
503        /// Server selection, transport, and timeout flags (shared with `introspect`)
504        #[command(flatten)]
505        flags: ServerFlags,
506
507        /// Custom server name for directory (e.g., 'github' instead of 'docker')
508        /// (default: uses server command name)
509        #[arg(long)]
510        name: Option<String>,
511
512        /// Custom output directory for progressive loading files
513        /// (default: ~/.claude/servers/)
514        #[arg(long)]
515        progressive_output: Option<PathBuf>,
516
517        /// Preview files that would be generated without writing to disk
518        #[arg(long)]
519        dry_run: bool,
520    },
521
522    /// Manage MCP server connections.
523    ///
524    /// List, validate, and manage configured MCP servers.
525    Server {
526        /// Server management action
527        #[command(subcommand)]
528        action: ServerAction,
529    },
530
531    /// Validate runtime environment for MCP tool execution.
532    ///
533    /// Checks that the system is ready to execute generated MCP tools:
534    /// - Verifies Node.js 18+ is installed
535    /// - Checks MCP configuration exists
536    /// - Makes TypeScript files executable (Unix only)
537    ///
538    /// # Examples
539    ///
540    /// ```bash
541    /// # Validate environment
542    /// mcp-execution-cli setup
543    ///
544    /// # Output:
545    /// # ✓ Node.js v20.10.0 detected
546    /// # ✓ MCP configuration found
547    /// # ✓ Runtime setup complete
548    /// ```
549    Setup,
550
551    /// Generate shell completions.
552    ///
553    /// Generates completion scripts for various shells that can be
554    /// sourced or saved to enable tab completion for this CLI.
555    Completions {
556        /// Target shell for completion generation
557        #[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        // Guards the `--format` value parser's `expect()` against the
851        // `PossibleValuesParser` list drifting from `OutputFormat`'s actual
852        // variants: reads the possible values off the real `clap::Command`
853        // rather than hardcoding a third independent copy of the list, so an
854        // entry added to one side without the other fails here instead of
855        // panicking inside clap's argument parsing.
856        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        // `args`/`server_args` are positional, not secret-shaped, and must
1128        // remain visible in Debug output.
1129        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        // Migrated from the former `RawServerArgs` regression test: `server`'s
1197        // home-relative path must be tilde-sanitized, not just the URL/env/header
1198        // fields already covered above.
1199        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    // ── #314: `server_source` argument group makes the transport-selector
1221    // exclusivity a clap-level guarantee instead of a runtime check ──
1222
1223    #[test]
1224    fn test_cli_parsing_introspect_positional_with_http_errors() {
1225        // Approved behavior change: previously the positional command was
1226        // silently discarded by `TransportArgs::from_flags` in favor of
1227        // `--http`. Now the `server_source` group rejects both being set.
1228        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        // Legal only because this test module is a child of `cli`, so it can
1317        // see `ServerFlags`'s private fields. Unreachable via real CLI
1318        // parsing: the `server_source` argument group already guarantees
1319        // exactly one selector is set.
1320        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    // ── S1: round-trip parse -> `TryFrom<ServerFlags>` -> assert variant and
1338    // payload for each of the four `Ok` arms. Without these, transposing the
1339    // `Http`/`Sse` arms or the `args`/`env` fields inside `Stdio` (all
1340    // same-typed) would compile and pass the rest of the suite — exactly
1341    // issue #286's bug class, previously guarded by the now-deleted
1342    // `test_transport_args_from_flags_{stdio,http,sse}` tests. ──
1343
1344    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}