Skip to main content

mcp_execution_cli/commands/
generate.rs

1//! Generate command implementation.
2//!
3//! Generates progressive loading TypeScript files from MCP server tool definitions.
4//! This command:
5//! 1. Introspects the server to discover tools and schemas
6//! 2. Generates TypeScript files for progressive loading (one file per tool)
7//! 3. Saves files to `~/.claude/servers/{server-id}/` directory
8
9use super::common::{ServerSource, derive_server_id_from_path_or_name, resolve_server_config};
10use crate::formatters::escape_display;
11use anyhow::{Context, Result};
12use mcp_execution_codegen::GeneratedCode;
13use mcp_execution_codegen::progressive::ProgressiveGenerator;
14use mcp_execution_core::cli::{ExitCode, OutputFormat};
15use mcp_execution_core::{ServerConfig, ServerId};
16use mcp_execution_files::{ExportOptions, FilesBuilder};
17use mcp_execution_introspector::{Introspector, ServerInfo};
18use mcp_execution_skill::validate_server_id;
19use serde::Serialize;
20use std::path::{Path, PathBuf};
21use tracing::{info, warn};
22
23/// Result of progressive loading code generation.
24#[derive(Debug, Serialize)]
25struct GenerationResult {
26    /// Server ID
27    server_id: String,
28    /// Server name
29    server_name: String,
30    /// Number of tools generated
31    tool_count: usize,
32    /// Path where files were saved
33    output_path: String,
34    /// Hint describing the required post-export step (issue #257).
35    next_step: String,
36}
37
38/// Post-export step required before the generated package type-checks.
39const NPM_INSTALL_HINT: &str =
40    "run 'npm install' in the output directory before type-checking the generated package";
41
42/// Preview of a file that would be generated in dry-run mode.
43#[derive(Debug, Serialize)]
44struct FilePreview {
45    /// Relative file path under the server directory
46    path: String,
47    /// File size in bytes
48    size: usize,
49}
50
51/// Result of a dry-run preview.
52#[derive(Debug, Serialize)]
53struct DryRunResult {
54    /// Server ID
55    server_id: String,
56    /// Server name
57    server_name: String,
58    /// Output path that would be used
59    output_path: String,
60    /// Files that would be generated
61    files: Vec<FilePreview>,
62    /// Total number of files
63    total_files: usize,
64    /// Total estimated size in bytes
65    total_size: usize,
66}
67
68#[expect(
69    clippy::cast_precision_loss,
70    reason = "Converting byte counts to f64 for human-readable KB/MB formatting; precision loss \
71              at display magnitude is inconsequential."
72)]
73fn format_size(bytes: usize) -> String {
74    if bytes < 1024 {
75        format!("{bytes} B")
76    } else if bytes < 1024 * 1024 {
77        format!("{:.1} KB", bytes as f64 / 1024.0)
78    } else {
79        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
80    }
81}
82
83/// Runs the generate command.
84///
85/// Generates progressive loading TypeScript files from an MCP server.
86///
87/// This command performs the following steps:
88/// 1. Builds `ServerConfig` from CLI arguments or loads from ~/.claude/mcp.json
89/// 2. Introspects the MCP server to discover tools
90/// 3. Generates TypeScript files (one per tool) using progressive loading pattern
91/// 4. Exports VFS to `~/.claude/servers/{server-id}/` directory
92///
93/// # Arguments
94///
95/// * `source` - Resolved server-selection source: either a `~/.claude/mcp.json`
96///   name or CLI transport flags with timeout overrides. Timeout overrides
97///   only exist on the `Flags` arm — a `Config` source always uses the
98///   `mcp.json` entry's own `connectTimeoutSecs`/`discoverTimeoutSecs`, so
99///   there is no "ignored override" state to document.
100/// * `name` - Custom server name for directory (default: `server_id`)
101/// * `output_dir` - Custom output directory (default: ~/.claude/servers/)
102/// * `dry_run` - When true, preview files without writing to disk
103/// * `output_format` - Output format (json, text, pretty)
104///
105/// # Errors
106///
107/// Returns an error if:
108/// - Server configuration is invalid
109/// - Server not found in mcp.json (when using --from-config)
110/// - Server connection fails
111/// - Tool introspection fails
112/// - Code generation fails
113/// - File export fails (skipped in dry-run mode)
114///
115/// # Examples
116///
117/// ```no_run
118/// use mcp_execution_cli::commands::common::{ServerSource, TransportArgs};
119/// use mcp_execution_cli::commands::generate;
120/// use mcp_execution_core::cli::OutputFormat;
121/// use std::path::PathBuf;
122///
123/// # async fn example() -> anyhow::Result<()> {
124/// // Generate from a stdio transport
125/// let exit_code = generate::run(
126///     ServerSource::Flags {
127///         transport: TransportArgs::Stdio {
128///             command: "github-mcp-server".to_string(),
129///             args: vec![],
130///             env: vec![],
131///             cwd: None,
132///         },
133///         connect_timeout_secs: None,
134///         discover_timeout_secs: None,
135///     },
136///     None,
137///     None,
138///     false,
139///     OutputFormat::Pretty
140/// ).await?;
141///
142/// // Generate with custom output directory and name override
143/// let exit_code = generate::run(
144///     ServerSource::Flags {
145///         transport: TransportArgs::Http {
146///             url: "https://api.example.com/mcp/".to_string(),
147///             headers: vec!["Authorization=Bearer token".to_string()],
148///         },
149///         connect_timeout_secs: Some(10),
150///         discover_timeout_secs: Some(30),
151///     },
152///     Some("my-custom-name".to_string()),
153///     Some(PathBuf::from("/tmp/generated")),
154///     false,
155///     OutputFormat::Json
156/// ).await?;
157/// # Ok(())
158/// # }
159/// ```
160pub async fn run(
161    source: ServerSource,
162    name: Option<String>,
163    output_dir: Option<PathBuf>,
164    dry_run: bool,
165    output_format: OutputFormat,
166) -> Result<ExitCode> {
167    // Captured before `source` is consumed below: an id resolved from
168    // `--from-config` without a `--name` override is the one case
169    // `resolve_server_dir_name` isn't a redundant backstop for (see its doc
170    // comment) — `--name` always overrides `server_info.id`, so if it was
171    // given the id came from its own, already-validated value instead.
172    let id_from_unvalidated_config_key =
173        matches!(source, ServerSource::Config { .. }) && name.is_none();
174    let (server_id, server_config) = resolve_server_config(source)?;
175
176    let server_info = discover_server_info(server_id, &server_config, name.as_deref()).await?;
177
178    if server_info.tools.is_empty() {
179        warn!("Server has no tools to generate code for");
180        return Ok(ExitCode::SUCCESS);
181    }
182
183    let server_dir_name = resolve_server_dir_name(&server_info, id_from_unvalidated_config_key)?;
184    let generated_code = generate_code(&server_info, &server_config)?;
185
186    let base_dir = resolve_base_dir(output_dir)?;
187    let output_path = base_dir.join(&server_dir_name);
188
189    if dry_run {
190        return render_dry_run(&server_info, &generated_code, &output_path, output_format);
191    }
192
193    export_generated_code(generated_code, &base_dir, &output_path)?;
194
195    render_success(&server_info, &output_path, output_format)
196}
197
198/// Connects to the target server, discovers its tools, and applies the
199/// `--name` override to [`ServerInfo::id`] if one was given.
200///
201/// # Errors
202///
203/// Returns an error if `name` fails [`validate_server_id`](mcp_execution_skill::validate_server_id),
204/// or if the connection or tool discovery fails.
205async fn discover_server_info(
206    server_id: ServerId,
207    server_config: &ServerConfig,
208    name: Option<&str>,
209) -> Result<ServerInfo> {
210    // Validated up front, before spending a network round trip: unlike a
211    // stdio command (sanitized via `derive_server_id_from_path_or_name`
212    // because it commonly *is* a legitimate path), `--name` is documented as
213    // overriding the id to match an identity the caller already has in mind
214    // (typically an `mcp.json` key) — silently rewriting an invalid value
215    // (e.g. stripping `..`/`/`) would produce a directory name the caller
216    // didn't ask for and that may no longer match anything, so it is
217    // rejected outright instead.
218    let override_id = name
219        .map(|custom_name| {
220            validate_server_id(custom_name)
221                .with_context(|| format!("invalid --name '{custom_name}'"))?;
222            ServerId::new(custom_name).with_context(|| format!("invalid --name '{custom_name}'"))
223        })
224        .transpose()?;
225
226    info!("Connecting to MCP server: {}", server_id);
227
228    let mut introspector = Introspector::new();
229    let mut server_info = introspector
230        .discover_server(server_id, server_config)
231        .await
232        .context("failed to introspect MCP server")?;
233
234    info!(
235        "Discovered {} tools from server '{}'",
236        server_info.tools.len(),
237        server_info.name
238    );
239
240    // Override server_info.id with custom name if provided.
241    // This ensures generated code uses the correct server_id that matches mcp.json.
242    if let Some(id) = override_id {
243        server_info.id = id;
244    }
245
246    Ok(server_info)
247}
248
249/// Generates progressive-loading TypeScript code for `server_info`.
250///
251/// # Errors
252///
253/// Returns an error if the code generator fails to initialize or generate code.
254fn generate_code(server_info: &ServerInfo, server_config: &ServerConfig) -> Result<GeneratedCode> {
255    let generator = ProgressiveGenerator::new().context("failed to create code generator")?;
256    let generated_code = generator
257        .generate(server_info, server_config)
258        .context("failed to generate TypeScript code")?;
259
260    info!(
261        "Generated {} files for progressive loading",
262        generated_code.file_count()
263    );
264
265    Ok(generated_code)
266}
267
268/// Resolves `server_info.id` to a directory name, guaranteeing it is safe to
269/// join onto `base_dir`.
270///
271/// This is `generate`'s own sink — the single point where the id is turned
272/// into a directory name — rather than a shared helper like
273/// [`get_mcp_server`](super::common::get_mcp_server), because `generate` is
274/// the only command that needs the id to be a filesystem-safe slug;
275/// `introspect`/`server` read the same `mcp.json` key without that
276/// constraint (#311).
277///
278/// For the stdio arm (`derive_server_id_from_path_or_name`), the http/sse arm
279/// (`derive_server_id_from_url`), and `--name` (its own `validate_server_id`
280/// check in `discover_server_info`), this check is a redundant backstop:
281/// each of those already guarantees the id is valid before it ever reaches
282/// here, so failing here for one of them would indicate a bug in that arm's
283/// own check, not in the user's input. It is **not** redundant for
284/// `--from-config` without a `--name` override — `get_mcp_server` is
285/// deliberately unvalidated (shared by commands that don't need a
286/// filesystem-safe id), so `is_from_unvalidated_config_key` being `true`
287/// means this is the *sole* enforcement point for that arm. Do not delete
288/// this check on the theory that every arm upstream already covers it — that
289/// theory is false for `--from-config`, and removing it would silently
290/// reopen #311 for that specific case.
291///
292/// # Errors
293///
294/// Returns an error if `server_info.id` fails
295/// [`validate_server_id`](mcp_execution_skill::validate_server_id). When
296/// `is_from_unvalidated_config_key` is `true`, the error names
297/// `~/.claude/mcp.json` and suggests the `--name` override (with a
298/// filesystem-safe slug derived from the offending id) as a fix, since the
299/// fault is the user's config, not this tool. Otherwise it is framed as an
300/// internal error, since reaching it would mean one of the other arms' own
301/// checks has a bug.
302fn resolve_server_dir_name(
303    server_info: &ServerInfo,
304    is_from_unvalidated_config_key: bool,
305) -> Result<String> {
306    let server_dir_name = server_info.id.to_string();
307    validate_server_id(&server_dir_name).map_err(|source| {
308        if is_from_unvalidated_config_key {
309            let suggested_name = derive_server_id_from_path_or_name(&server_dir_name);
310            anyhow::anyhow!(
311                "server '{server_dir_name}' in ~/.claude/mcp.json is not a valid directory name \
312                 ({source}); use --name {suggested_name} to override it"
313            )
314        } else {
315            anyhow::Error::from(source).context(format!(
316                "internal error: resolved server id '{server_dir_name}' is not a valid \
317                 directory name"
318            ))
319        }
320    })?;
321    Ok(server_dir_name)
322}
323
324/// Resolves the base directory generated servers are exported under, defaulting to
325/// `~/.claude/servers` when `output_dir` is not set.
326///
327/// # Errors
328///
329/// Returns an error if `output_dir` is `None` and the home directory cannot be determined.
330fn resolve_base_dir(output_dir: Option<PathBuf>) -> Result<PathBuf> {
331    if let Some(custom_dir) = output_dir {
332        Ok(custom_dir)
333    } else {
334        Ok(dirs::home_dir()
335            .context("failed to get home directory")?
336            .join(".claude")
337            .join("servers"))
338    }
339}
340
341/// Renders a dry-run preview of the files that would be generated, without writing anything.
342fn render_dry_run(
343    server_info: &ServerInfo,
344    generated_code: &GeneratedCode,
345    output_path: &Path,
346    output_format: OutputFormat,
347) -> Result<ExitCode> {
348    let server_dir_name = server_info.id.to_string();
349    let files: Vec<FilePreview> = generated_code
350        .files
351        .iter()
352        .map(|f| FilePreview {
353            path: format!("{}/{}", server_dir_name, f.path),
354            size: f.content.len(),
355        })
356        .collect();
357    let total_size: usize = files.iter().map(|f| f.size).sum();
358    let total_files = files.len();
359
360    let result = DryRunResult {
361        server_id: server_info.id.to_string(),
362        server_name: server_info.name.clone(),
363        output_path: output_path.display().to_string(),
364        files,
365        total_files,
366        total_size,
367    };
368
369    println!("{}", format_dry_run(&result, output_format)?);
370
371    Ok(ExitCode::SUCCESS)
372}
373
374/// Renders a [`DryRunResult`] for the given `output_format`.
375///
376/// `server_name` is server-supplied (untrusted), so `Text`/`Pretty` output escapes it via
377/// [`escape_display`] to neutralize embedded control characters; `Json` output is unaffected
378/// since `serde_json` already escapes string values.
379fn format_dry_run(result: &DryRunResult, output_format: OutputFormat) -> Result<String> {
380    Ok(match output_format {
381        OutputFormat::Json => serde_json::to_string_pretty(result)?,
382        OutputFormat::Text => format!(
383            "Server: {} ({})\nWould generate {} files ({}) to {}/",
384            escape_display(&result.server_name),
385            result.server_id,
386            result.total_files,
387            format_size(result.total_size),
388            result.output_path
389        ),
390        OutputFormat::Pretty => {
391            use std::fmt::Write as _;
392
393            let mut out = format!(
394                "Would generate {} files to {}/:\n\n",
395                result.total_files, result.output_path
396            );
397            for f in &result.files {
398                let _ = writeln!(out, "  - {} ({})", f.path, format_size(f.size));
399            }
400            let _ = write!(
401                out,
402                "\nTotal: {} files, ~{}",
403                result.total_files,
404                format_size(result.total_size)
405            );
406            out
407        }
408    })
409}
410
411/// Builds the VFS from `generated_code` and exports it to `output_path` under `base_dir`.
412///
413/// # Errors
414///
415/// Returns an error if VFS construction fails, `base_dir` cannot be created, or the export to
416/// the filesystem fails.
417fn export_generated_code(
418    generated_code: GeneratedCode,
419    base_dir: &Path,
420    output_path: &Path,
421) -> Result<()> {
422    // Build VFS with base_path="/" since generated files already have flat structure;
423    // server_dir_name will be used when exporting to filesystem
424    let vfs = FilesBuilder::from_generated_code(generated_code, "/")
425        .build()
426        .context("failed to build VFS")?;
427
428    info!("Exporting files to: {}", output_path.display());
429
430    // Only the parent needs to exist: `export_to_filesystem` publishes
431    // `output_path` itself atomically (single rename on first generate,
432    // stage-then-swap on regeneration), so pre-creating it here would just
433    // force the slower regeneration path even on a brand-new server.
434    std::fs::create_dir_all(base_dir).context("failed to create output directory")?;
435    // Defense-in-depth: `output_path` is built by joining a server-id-derived
436    // directory name onto `base_dir` (see `derive_server_id_from_path_or_name`,
437    // which is the primary guard); confining the export here means a future
438    // caller that skips that sanitization fails loudly instead of writing
439    // outside `base_dir`.
440    let options = ExportOptions::new().with_confine_to(base_dir);
441    vfs.export_to_filesystem_with_options(output_path, &options)
442        .context("failed to export files to filesystem")?;
443
444    Ok(())
445}
446
447/// Renders the success output for a completed export, including the #257 npm-install hint.
448fn render_success(
449    server_info: &ServerInfo,
450    output_path: &Path,
451    output_format: OutputFormat,
452) -> Result<ExitCode> {
453    let result = GenerationResult {
454        server_id: server_info.id.to_string(),
455        server_name: server_info.name.clone(),
456        tool_count: server_info.tools.len(),
457        output_path: output_path.display().to_string(),
458        next_step: NPM_INSTALL_HINT.to_string(),
459    };
460
461    println!("{}", format_success(&result, output_format)?);
462
463    Ok(ExitCode::SUCCESS)
464}
465
466/// Renders a [`GenerationResult`] for the given `output_format`.
467///
468/// `server_name` is server-supplied (untrusted), so `Text`/`Pretty` output escapes it via
469/// [`escape_display`] to neutralize embedded control characters; `Json` output is unaffected
470/// since `serde_json` already escapes string values.
471fn format_success(result: &GenerationResult, output_format: OutputFormat) -> Result<String> {
472    Ok(match output_format {
473        OutputFormat::Json => serde_json::to_string_pretty(result)?,
474        OutputFormat::Text => format!(
475            "Server: {} ({})\nGenerated {} tool files\nOutput: {}\nNext step: {NPM_INSTALL_HINT}",
476            escape_display(&result.server_name),
477            result.server_id,
478            result.tool_count,
479            result.output_path
480        ),
481        OutputFormat::Pretty => format!(
482            "✓ Successfully generated progressive loading files\n  Server: {} ({})\n  Tools: {}\n  Location: {}\n  Next step: {NPM_INSTALL_HINT}",
483            escape_display(&result.server_name),
484            result.server_id,
485            result.tool_count,
486            result.output_path
487        ),
488    })
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::commands::common::TransportArgs;
495    use mcp_execution_core::ServerId;
496    use mcp_execution_introspector::{ServerCapabilities, ServerInfo, ToolInfo};
497    use serde_json::json;
498
499    fn create_mock_server_info() -> ServerInfo {
500        ServerInfo {
501            id: ServerId::new("test-server").unwrap(),
502            name: "Test Server".to_string(),
503            version: "1.0.0".to_string(),
504            tools: vec![ToolInfo {
505                name: mcp_execution_core::ToolName::new("test_tool").unwrap(),
506                description: "A test tool".to_string(),
507                input_schema: json!({
508                    "type": "object",
509                    "properties": {
510                        "param": {"type": "string"}
511                    }
512                }),
513                output_schema: None,
514            }],
515            capabilities: ServerCapabilities {
516                supports_tools: true,
517                supports_resources: false,
518                supports_prompts: false,
519            },
520        }
521    }
522
523    /// `ServerConfig` passed alongside `create_mock_server_info`'s output so `generate` can
524    /// stamp generation provenance.
525    fn create_mock_server_config() -> ServerConfig {
526        ServerConfig::builder()
527            .command("test-command".to_string())
528            .build()
529            .unwrap()
530    }
531
532    #[test]
533    fn test_generation_result_serialization() {
534        let result = GenerationResult {
535            server_id: "test".to_string(),
536            server_name: "Test Server".to_string(),
537            tool_count: 5,
538            output_path: "/path/to/output".to_string(),
539            next_step: NPM_INSTALL_HINT.to_string(),
540        };
541
542        let json = serde_json::to_string(&result).unwrap();
543        assert!(json.contains("\"server_id\":\"test\""));
544        assert!(json.contains("\"tool_count\":5"));
545        assert!(json.contains(NPM_INSTALL_HINT));
546    }
547
548    #[test]
549    fn test_format_success_text_escapes_control_chars() {
550        // A malicious MCP server can set its handshake `serverInfo.name` to anything,
551        // including raw ANSI/control escape sequences. Text output must not pass them through.
552        let result = GenerationResult {
553            server_id: "test".to_string(),
554            server_name: "evil\u{1b}[2J\u{1b}]0;pwned\u{7}".to_string(),
555            tool_count: 1,
556            output_path: "/path/to/output".to_string(),
557            next_step: NPM_INSTALL_HINT.to_string(),
558        };
559
560        let output = format_success(&result, OutputFormat::Text).unwrap();
561        assert!(!output.contains('\u{1b}'));
562        assert!(output.contains("\\u001b"));
563    }
564
565    #[test]
566    fn test_format_success_pretty_escapes_control_chars() {
567        let result = GenerationResult {
568            server_id: "test".to_string(),
569            server_name: "evil\u{1b}[2Jname".to_string(),
570            tool_count: 1,
571            output_path: "/path/to/output".to_string(),
572            next_step: NPM_INSTALL_HINT.to_string(),
573        };
574
575        let output = format_success(&result, OutputFormat::Pretty).unwrap();
576        assert!(!output.contains('\u{1b}'));
577        assert!(output.contains("\\u001b"));
578    }
579
580    #[test]
581    fn test_format_dry_run_text_escapes_control_chars() {
582        let result = DryRunResult {
583            server_id: "test".to_string(),
584            server_name: "evil\u{1b}[2Jname".to_string(),
585            output_path: "/path/to/output".to_string(),
586            files: vec![],
587            total_files: 0,
588            total_size: 0,
589        };
590
591        let output = format_dry_run(&result, OutputFormat::Text).unwrap();
592        assert!(!output.contains('\u{1b}'));
593        assert!(output.contains("\\u001b"));
594    }
595
596    #[test]
597    fn test_format_dry_run_pretty_never_prints_raw_control_chars() {
598        // The Pretty dry-run branch does not interpolate `server_name` at all (only file
599        // paths and computed sizes/counts), but assert this stays true rather than silently
600        // regressing if someone adds a server-name line later without escaping it.
601        let result = DryRunResult {
602            server_id: "test".to_string(),
603            server_name: "evil\u{1b}[2Jname".to_string(),
604            output_path: "/path/to/output".to_string(),
605            files: vec![],
606            total_files: 0,
607            total_size: 0,
608        };
609
610        let output = format_dry_run(&result, OutputFormat::Pretty).unwrap();
611        assert!(!output.contains('\u{1b}'));
612    }
613
614    #[test]
615    fn test_format_success_json_unaffected_by_control_chars() {
616        // Json path already relies on serde_json escaping and must stay unchanged.
617        let result = GenerationResult {
618            server_id: "test".to_string(),
619            server_name: "evil\u{1b}[2Jname".to_string(),
620            tool_count: 1,
621            output_path: "/path/to/output".to_string(),
622            next_step: NPM_INSTALL_HINT.to_string(),
623        };
624
625        let output = format_success(&result, OutputFormat::Json).unwrap();
626        assert!(!output.contains('\u{1b}'));
627        assert!(output.contains("\\u001b"));
628    }
629
630    #[test]
631    fn test_format_success_text_quotes_benign_server_name() {
632        // Pin the visible output-format change: escape_display always JSON-quotes the server
633        // name, even when it contains no control characters, so `Server: Test Server (id)`
634        // becomes `Server: "Test Server" (id)` for every server, not just malicious ones.
635        let result = GenerationResult {
636            server_id: "test".to_string(),
637            server_name: "Test Server".to_string(),
638            tool_count: 1,
639            output_path: "/path/to/output".to_string(),
640            next_step: NPM_INSTALL_HINT.to_string(),
641        };
642
643        let output = format_success(&result, OutputFormat::Text).unwrap();
644        assert!(output.contains("Server: \"Test Server\" (test)"));
645    }
646
647    #[test]
648    fn test_format_dry_run_text_quotes_benign_server_name() {
649        let result = DryRunResult {
650            server_id: "test".to_string(),
651            server_name: "Test Server".to_string(),
652            output_path: "/path/to/output".to_string(),
653            files: vec![],
654            total_files: 0,
655            total_size: 0,
656        };
657
658        let output = format_dry_run(&result, OutputFormat::Text).unwrap();
659        assert!(output.contains("Server: \"Test Server\" (test)"));
660    }
661
662    #[test]
663    fn test_progressive_generator_creation() {
664        let generator = ProgressiveGenerator::new();
665        assert!(generator.is_ok());
666    }
667
668    #[test]
669    fn test_progressive_code_generation() {
670        let generator = ProgressiveGenerator::new().unwrap();
671        let server_info = create_mock_server_info();
672
673        let result = generator.generate(&server_info, &create_mock_server_config());
674        assert!(result.is_ok());
675
676        let code = result.unwrap();
677        assert!(code.file_count() > 0);
678    }
679
680    #[test]
681    fn test_format_size_bytes() {
682        assert_eq!(format_size(0), "0 B");
683        assert_eq!(format_size(512), "512 B");
684        assert_eq!(format_size(1023), "1023 B");
685    }
686
687    #[test]
688    fn test_format_size_kilobytes() {
689        assert_eq!(format_size(1024), "1.0 KB");
690        assert_eq!(format_size(2048), "2.0 KB");
691        assert_eq!(format_size(1536), "1.5 KB");
692    }
693
694    #[test]
695    fn test_format_size_megabytes() {
696        assert_eq!(format_size(1024 * 1024), "1.0 MB");
697        assert_eq!(format_size(2 * 1024 * 1024), "2.0 MB");
698    }
699
700    #[test]
701    fn test_dry_run_result_serialization() {
702        let result = DryRunResult {
703            server_id: "github".to_string(),
704            server_name: "GitHub MCP Server".to_string(),
705            output_path: "/home/user/.claude/servers/github".to_string(),
706            files: vec![
707                FilePreview {
708                    path: "github/createIssue.ts".to_string(),
709                    size: 2450,
710                },
711                FilePreview {
712                    path: "github/listRepos.ts".to_string(),
713                    size: 1200,
714                },
715            ],
716            total_files: 2,
717            total_size: 3650,
718        };
719
720        let json = serde_json::to_string_pretty(&result).unwrap();
721        assert!(json.contains("\"server_id\": \"github\""));
722        assert!(json.contains("\"total_files\": 2"));
723        assert!(json.contains("\"total_size\": 3650"));
724        assert!(json.contains("\"path\": \"github/createIssue.ts\""));
725        assert!(json.contains("\"size\": 2450"));
726    }
727
728    #[test]
729    fn test_dry_run_collects_file_metadata() {
730        let generator = ProgressiveGenerator::new().unwrap();
731        let server_info = create_mock_server_info();
732        let generated_code = generator
733            .generate(&server_info, &create_mock_server_config())
734            .unwrap();
735
736        let server_dir_name = server_info.id.to_string();
737        let files: Vec<FilePreview> = generated_code
738            .files
739            .iter()
740            .map(|f| FilePreview {
741                path: format!("{}/{}", server_dir_name, f.path),
742                size: f.content.len(),
743            })
744            .collect();
745
746        assert!(!files.is_empty());
747        for file in &files {
748            assert!(file.path.starts_with("test-server/"));
749            assert!(file.size > 0);
750        }
751
752        let total_size: usize = files.iter().map(|f| f.size).sum();
753        assert_eq!(
754            total_size,
755            generated_code
756                .files
757                .iter()
758                .map(|f| f.content.len())
759                .sum::<usize>()
760        );
761    }
762
763    #[test]
764    fn test_dry_run_does_not_write_files() {
765        use std::path::Path;
766
767        let generator = ProgressiveGenerator::new().unwrap();
768        let server_info = create_mock_server_info();
769        let generated_code = generator
770            .generate(&server_info, &create_mock_server_config())
771            .unwrap();
772
773        // Simulate what dry-run does: collect metadata without touching the filesystem
774        let server_dir_name = server_info.id.to_string();
775        let fake_output_path = Path::new("/tmp/dry-run-test-should-not-exist-abc123");
776        let output_path = fake_output_path.join(&server_dir_name);
777
778        let files: Vec<FilePreview> = generated_code
779            .files
780            .iter()
781            .map(|f| FilePreview {
782                path: format!("{}/{}", server_dir_name, f.path),
783                size: f.content.len(),
784            })
785            .collect();
786
787        // Verify metadata collected correctly
788        assert!(!files.is_empty());
789
790        // Verify nothing was written to disk
791        assert!(
792            !output_path.exists(),
793            "dry-run must not write files to disk"
794        );
795    }
796
797    #[tokio::test]
798    async fn test_run_zero_connect_timeout_override_rejected_by_validation() {
799        // A zero override must surface the same connect_timeout validation
800        // error as the mcp.json path, not just a generic connection failure.
801        let source = ServerSource::Flags {
802            transport: TransportArgs::Stdio {
803                command: "nonexistent-server-timeout-test".to_string(),
804                args: vec![],
805                env: vec![],
806                cwd: None,
807            },
808            connect_timeout_secs: Some(0),
809            discover_timeout_secs: None,
810        };
811        let result = run(source, None, None, false, OutputFormat::Json).await;
812
813        assert!(result.is_err());
814        let err = result.unwrap_err();
815        let chain_msg = err
816            .chain()
817            .map(ToString::to_string)
818            .collect::<Vec<_>>()
819            .join(" | ");
820        assert!(
821            chain_msg.contains("greater than zero"),
822            "expected connect_timeout validation error in the error chain, got: {chain_msg}"
823        );
824    }
825
826    #[tokio::test]
827    async fn test_run_with_valid_timeout_overrides_reaches_connection_attempt() {
828        // Valid overrides must not be rejected before the connection attempt.
829        let source = ServerSource::Flags {
830            transport: TransportArgs::Stdio {
831                command: "nonexistent-server-timeout-test-2".to_string(),
832                args: vec![],
833                env: vec![],
834                cwd: None,
835            },
836            connect_timeout_secs: Some(5),
837            discover_timeout_secs: Some(90),
838        };
839        let result = run(source, None, None, false, OutputFormat::Json).await;
840
841        assert!(result.is_err());
842        let err_msg = result.unwrap_err().to_string();
843        assert!(err_msg.contains("failed to introspect MCP server"));
844    }
845
846    // ── issue #311 (S1): `--name` override must be rejected outright when
847    // invalid, not silently rewritten into a different id ──
848
849    #[tokio::test]
850    async fn test_name_override_rejects_traversal_and_absolute_paths() {
851        // Regression test for #311: the `--name` override used to construct
852        // `ServerId::new(custom_name)` directly with no validation at all, so
853        // a malicious `--name` flowed unmodified into a directory name under
854        // `~/.claude/servers/{id}/`. Unlike a stdio command (sanitized, since
855        // it commonly *is* a legitimate path), `--name` is meant to match an
856        // identity the caller already has in mind, so it must be rejected
857        // rather than silently transformed. Validation happens before any
858        // connection attempt, so this never reaches the network.
859        let server_config = ServerConfig::builder()
860            .command("nonexistent-command-for-name-validation-test".to_string())
861            .build()
862            .unwrap();
863
864        for bad_name in [
865            "../../../../etc/passwd",
866            "/etc/cron.d/evil",
867            "..",
868            "UPPER_CASE",
869        ] {
870            let result = discover_server_info(
871                ServerId::new("placeholder").unwrap(),
872                &server_config,
873                Some(bad_name),
874            )
875            .await;
876
877            assert!(
878                result.is_err(),
879                "expected --name {bad_name:?} to be rejected"
880            );
881            let err_msg = result.unwrap_err().to_string();
882            assert!(
883                err_msg.contains("invalid --name"),
884                "expected a validation error for {bad_name:?}, got: {err_msg}"
885            );
886        }
887    }
888
889    // ── issue #311 (S4): `resolve_server_dir_name` is the sink-level
890    // invariant check, independent of which arm produced `server_info.id` ──
891
892    #[test]
893    fn test_resolve_server_dir_name_accepts_valid_id() {
894        let server_info = create_mock_server_info();
895        assert_eq!(
896            resolve_server_dir_name(&server_info, false).unwrap(),
897            "test-server"
898        );
899    }
900
901    #[test]
902    fn test_server_id_construction_rejects_traversal_and_absolute_ids() {
903        // Regression guard for #287: `ServerId::new` now owns this
904        // invariant at construction time, so a traversal/absolute-shaped id
905        // can no longer reach `resolve_server_dir_name` (or anywhere else)
906        // in the first place — it is rejected before a `ServerId` even
907        // exists, rather than relying solely on this sink-level check.
908        for bad_id in ["../../../../etc/passwd", "/etc/cron.d/evil", ".."] {
909            assert!(
910                ServerId::new(bad_id).is_err(),
911                "expected id {bad_id:?} to be rejected by ServerId::new"
912            );
913        }
914    }
915
916    #[test]
917    fn test_resolve_server_dir_name_rejects_charset_violations() {
918        // `UPPER_CASE`, `a.b`, and `a_b` all pass `ServerId::new`'s baseline
919        // invariant (single, non-empty path segment, no `..`/separator), so
920        // this sink-level check must independently reject them rather than
921        // trust that `ServerId::new` alone is enough for a filesystem-safe
922        // directory name.
923        for bad_id in ["UPPER_CASE", "a.b", "a_b"] {
924            let mut server_info = create_mock_server_info();
925            server_info.id = ServerId::new(bad_id).unwrap();
926
927            let result = resolve_server_dir_name(&server_info, false);
928            assert!(result.is_err(), "expected id {bad_id:?} to be rejected");
929        }
930    }
931
932    #[test]
933    fn test_resolve_server_dir_name_non_config_error_is_framed_as_internal() {
934        // Regression test for #311 review M10/M11: when the id did NOT come
935        // from an unvalidated `--from-config` lookup, reaching this check at
936        // all would mean one of the other arms' own validation has a bug —
937        // the error must say so, not blame a user-supplied mcp.json key that
938        // was never involved.
939        let mut server_info = create_mock_server_info();
940        server_info.id = ServerId::new("UPPER_CASE").unwrap();
941
942        let err = resolve_server_dir_name(&server_info, false).unwrap_err();
943        let err_msg = err.to_string();
944        assert!(err_msg.contains("internal error"), "got: {err_msg}");
945        assert!(!err_msg.contains("mcp.json"), "got: {err_msg}");
946    }
947
948    #[test]
949    fn test_resolve_server_dir_name_from_config_error_names_mcp_json_and_suggests_name_override() {
950        // Regression test for #311 review M10: a `claude_ai_Gmail`-style
951        // legitimate mcp.json key fails `validate_server_id`'s stricter
952        // filesystem-safety charset. The error must name the actual fault
953        // (the user's config) and point at the `--name` workaround with a
954        // ready-to-use slug, not blame this tool.
955        let mut server_info = create_mock_server_info();
956        server_info.id = ServerId::new("claude_ai_Gmail").unwrap();
957
958        let err = resolve_server_dir_name(&server_info, true).unwrap_err();
959        let err_msg = err.to_string();
960        assert!(err_msg.contains("mcp.json"), "got: {err_msg}");
961        assert!(err_msg.contains("--name claude-ai-gmail"), "got: {err_msg}");
962        assert!(!err_msg.contains("internal error"), "got: {err_msg}");
963    }
964
965    #[test]
966    fn test_export_generated_code_confines_output_to_base_dir() {
967        // End-to-end reproduction of the vulnerable call site
968        // (`export_generated_code`): even if a caller upstream failed to
969        // sanitize the id, the confinement check wired in via
970        // `ExportOptions::with_confine_to` must reject an `output_path` that
971        // escapes `base_dir`, rather than silently writing outside it.
972        let temp = tempfile::TempDir::new().unwrap();
973        let base_dir = temp.path().join("servers");
974        std::fs::create_dir_all(&base_dir).unwrap();
975        let escape_target = temp.path().join("escaped");
976
977        let generator = ProgressiveGenerator::new().unwrap();
978        let server_info = create_mock_server_info();
979        let generated_code = generator
980            .generate(&server_info, &create_mock_server_config())
981            .unwrap();
982
983        let result = export_generated_code(generated_code, &base_dir, &escape_target);
984
985        assert!(result.is_err());
986        assert!(
987            !escape_target.exists(),
988            "confinement check must reject the export before anything is written outside base_dir"
989        );
990    }
991}