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