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::{build_server_config, load_server_from_config};
10use anyhow::{Context, Result};
11use mcp_execution_codegen::progressive::ProgressiveGenerator;
12use mcp_execution_core::cli::{ExitCode, OutputFormat};
13use mcp_execution_files::FilesBuilder;
14use mcp_execution_introspector::Introspector;
15use serde::Serialize;
16use std::path::PathBuf;
17use tracing::{debug, info, warn};
18
19/// Result of progressive loading code generation.
20#[derive(Debug, Serialize)]
21struct GenerationResult {
22    /// Server ID
23    server_id: String,
24    /// Server name
25    server_name: String,
26    /// Number of tools generated
27    tool_count: usize,
28    /// Path where files were saved
29    output_path: String,
30}
31
32/// Preview of a file that would be generated in dry-run mode.
33#[derive(Debug, Serialize)]
34struct FilePreview {
35    /// Relative file path under the server directory
36    path: String,
37    /// File size in bytes
38    size: usize,
39}
40
41/// Result of a dry-run preview.
42#[derive(Debug, Serialize)]
43struct DryRunResult {
44    /// Server ID
45    server_id: String,
46    /// Server name
47    server_name: String,
48    /// Output path that would be used
49    output_path: String,
50    /// Files that would be generated
51    files: Vec<FilePreview>,
52    /// Total number of files
53    total_files: usize,
54    /// Total estimated size in bytes
55    total_size: usize,
56}
57
58// Converting byte counts to f64 for human-readable KB/MB formatting; precision
59// loss at display magnitude is inconsequential.
60#[allow(clippy::cast_precision_loss)]
61fn format_size(bytes: usize) -> String {
62    if bytes < 1024 {
63        format!("{bytes} B")
64    } else if bytes < 1024 * 1024 {
65        format!("{:.1} KB", bytes as f64 / 1024.0)
66    } else {
67        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
68    }
69}
70
71/// Runs the generate command.
72///
73/// Generates progressive loading TypeScript files from an MCP server.
74///
75/// This command performs the following steps:
76/// 1. Builds `ServerConfig` from CLI arguments or loads from ~/.claude/mcp.json
77/// 2. Introspects the MCP server to discover tools
78/// 3. Generates TypeScript files (one per tool) using progressive loading pattern
79/// 4. Exports VFS to `~/.claude/servers/{server-id}/` directory
80///
81/// # Arguments
82///
83/// * `from_config` - Load server config from ~/.claude/mcp.json by name
84/// * `server` - Server command (binary name or path), None for HTTP/SSE
85/// * `args` - Arguments to pass to the server command
86/// * `env` - Environment variables in KEY=VALUE format
87/// * `cwd` - Working directory for the server process
88/// * `http` - HTTP transport URL
89/// * `sse` - SSE transport URL
90/// * `headers` - HTTP headers in KEY=VALUE format
91/// * `name` - Custom server name for directory (default: `server_id`)
92/// * `output_dir` - Custom output directory (default: ~/.claude/servers/)
93/// * `dry_run` - When true, preview files without writing to disk
94/// * `connect_timeout_secs` - Connection (handshake) timeout override, in
95///   seconds. Ignored when `from_config` is set (the `mcp.json` entry's
96///   `connectTimeoutSecs` applies instead). Same bounds as `mcp.json`: must
97///   be greater than zero and at most 600 seconds.
98/// * `discover_timeout_secs` - Tool discovery timeout override, in seconds.
99///   Same rules as `connect_timeout_secs`.
100/// * `output_format` - Output format (json, text, pretty)
101///
102/// # Errors
103///
104/// Returns an error if:
105/// - Server configuration is invalid
106/// - Server not found in mcp.json (when using --from-config)
107/// - Server connection fails
108/// - Tool introspection fails
109/// - Code generation fails
110/// - File export fails (skipped in dry-run mode)
111// One argument per CLI flag; clap already destructures flags for us, and
112// grouping them into a struct would only benefit this function, not caller ergonomics.
113#[allow(clippy::too_many_arguments)]
114pub async fn run(
115    from_config: Option<String>,
116    server: Option<String>,
117    args: Vec<String>,
118    env: Vec<String>,
119    cwd: Option<String>,
120    http: Option<String>,
121    sse: Option<String>,
122    headers: Vec<String>,
123    name: Option<String>,
124    output_dir: Option<PathBuf>,
125    dry_run: bool,
126    connect_timeout_secs: Option<u64>,
127    discover_timeout_secs: Option<u64>,
128    output_format: OutputFormat,
129) -> Result<ExitCode> {
130    let (server_id, server_config) = if let Some(config_name) = from_config {
131        debug!(
132            "Loading server configuration from ~/.claude/mcp.json: {}",
133            config_name
134        );
135        load_server_from_config(&config_name)?
136    } else {
137        build_server_config(
138            server,
139            args,
140            env,
141            cwd,
142            http,
143            sse,
144            headers,
145            connect_timeout_secs,
146            discover_timeout_secs,
147        )?
148    };
149
150    info!("Connecting to MCP server: {}", server_id);
151
152    let mut introspector = Introspector::new();
153    let server_info = introspector
154        .discover_server(server_id, &server_config)
155        .await
156        .context("failed to introspect MCP server")?;
157
158    info!(
159        "Discovered {} tools from server '{}'",
160        server_info.tools.len(),
161        server_info.name
162    );
163
164    if server_info.tools.is_empty() {
165        warn!("Server has no tools to generate code for");
166        return Ok(ExitCode::SUCCESS);
167    }
168
169    // Override server_info.id with custom name if provided
170    // This ensures generated code uses the correct server_id that matches mcp.json
171    let mut server_info = server_info;
172    if let Some(ref custom_name) = name {
173        server_info.id = mcp_execution_core::ServerId::new(custom_name);
174    }
175
176    let server_dir_name = server_info.id.to_string();
177
178    let generator = ProgressiveGenerator::new().context("failed to create code generator")?;
179    let generated_code = generator
180        .generate(&server_info)
181        .context("failed to generate TypeScript code")?;
182
183    info!(
184        "Generated {} files for progressive loading",
185        generated_code.file_count()
186    );
187
188    let base_dir = if let Some(custom_dir) = output_dir {
189        custom_dir
190    } else {
191        dirs::home_dir()
192            .context("failed to get home directory")?
193            .join(".claude")
194            .join("servers")
195    };
196    let output_path = base_dir.join(&server_dir_name);
197
198    if dry_run {
199        let files: Vec<FilePreview> = generated_code
200            .files
201            .iter()
202            .map(|f| FilePreview {
203                path: format!("{}/{}", server_dir_name, f.path),
204                size: f.content.len(),
205            })
206            .collect();
207        let total_size: usize = files.iter().map(|f| f.size).sum();
208        let total_files = files.len();
209
210        let result = DryRunResult {
211            server_id: server_info.id.to_string(),
212            server_name: server_info.name,
213            output_path: output_path.display().to_string(),
214            files,
215            total_files,
216            total_size,
217        };
218
219        match output_format {
220            OutputFormat::Json => {
221                println!("{}", serde_json::to_string_pretty(&result)?);
222            }
223            OutputFormat::Text => {
224                println!("Server: {} ({})", result.server_name, result.server_id);
225                println!(
226                    "Would generate {} files ({}) to {}/",
227                    result.total_files,
228                    format_size(result.total_size),
229                    result.output_path
230                );
231            }
232            OutputFormat::Pretty => {
233                println!(
234                    "Would generate {} files to {}/:",
235                    result.total_files, result.output_path
236                );
237                println!();
238                for f in &result.files {
239                    println!("  - {} ({})", f.path, format_size(f.size));
240                }
241                println!();
242                println!(
243                    "Total: {} files, ~{}",
244                    result.total_files,
245                    format_size(result.total_size)
246                );
247            }
248        }
249
250        return Ok(ExitCode::SUCCESS);
251    }
252
253    // Build VFS with base_path="/" since generated files already have flat structure;
254    // server_dir_name will be used when exporting to filesystem
255    let vfs = FilesBuilder::from_generated_code(generated_code, "/")
256        .build()
257        .context("failed to build VFS")?;
258
259    info!("Exporting files to: {}", output_path.display());
260
261    // Only the parent needs to exist: `export_to_filesystem` publishes
262    // `output_path` itself atomically (single rename on first generate,
263    // stage-then-swap on regeneration), so pre-creating it here would just
264    // force the slower regeneration path even on a brand-new server.
265    std::fs::create_dir_all(&base_dir).context("failed to create output directory")?;
266    vfs.export_to_filesystem(&output_path)
267        .context("failed to export files to filesystem")?;
268
269    let result = GenerationResult {
270        server_id: server_info.id.to_string(),
271        server_name: server_info.name.clone(),
272        tool_count: server_info.tools.len(),
273        output_path: output_path.display().to_string(),
274    };
275
276    match output_format {
277        OutputFormat::Json => {
278            println!("{}", serde_json::to_string_pretty(&result)?);
279        }
280        OutputFormat::Text => {
281            println!("Server: {} ({})", result.server_name, result.server_id);
282            println!("Generated {} tool files", result.tool_count);
283            println!("Output: {}", result.output_path);
284        }
285        OutputFormat::Pretty => {
286            println!("✓ Successfully generated progressive loading files");
287            println!("  Server: {} ({})", result.server_name, result.server_id);
288            println!("  Tools: {}", result.tool_count);
289            println!("  Location: {}", result.output_path);
290        }
291    }
292
293    Ok(ExitCode::SUCCESS)
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use mcp_execution_core::ServerId;
300    use mcp_execution_introspector::{ServerCapabilities, ServerInfo, ToolInfo};
301    use serde_json::json;
302
303    fn create_mock_server_info() -> ServerInfo {
304        ServerInfo {
305            id: ServerId::new("test-server"),
306            name: "Test Server".to_string(),
307            version: "1.0.0".to_string(),
308            tools: vec![ToolInfo {
309                name: mcp_execution_core::ToolName::new("test_tool"),
310                description: "A test tool".to_string(),
311                input_schema: json!({
312                    "type": "object",
313                    "properties": {
314                        "param": {"type": "string"}
315                    }
316                }),
317                output_schema: None,
318            }],
319            capabilities: ServerCapabilities {
320                supports_tools: true,
321                supports_resources: false,
322                supports_prompts: false,
323            },
324        }
325    }
326
327    #[test]
328    fn test_generation_result_serialization() {
329        let result = GenerationResult {
330            server_id: "test".to_string(),
331            server_name: "Test Server".to_string(),
332            tool_count: 5,
333            output_path: "/path/to/output".to_string(),
334        };
335
336        let json = serde_json::to_string(&result).unwrap();
337        assert!(json.contains("\"server_id\":\"test\""));
338        assert!(json.contains("\"tool_count\":5"));
339    }
340
341    #[test]
342    fn test_progressive_generator_creation() {
343        let generator = ProgressiveGenerator::new();
344        assert!(generator.is_ok());
345    }
346
347    #[test]
348    fn test_progressive_code_generation() {
349        let generator = ProgressiveGenerator::new().unwrap();
350        let server_info = create_mock_server_info();
351
352        let result = generator.generate(&server_info);
353        assert!(result.is_ok());
354
355        let code = result.unwrap();
356        assert!(code.file_count() > 0);
357    }
358
359    #[test]
360    fn test_format_size_bytes() {
361        assert_eq!(format_size(0), "0 B");
362        assert_eq!(format_size(512), "512 B");
363        assert_eq!(format_size(1023), "1023 B");
364    }
365
366    #[test]
367    fn test_format_size_kilobytes() {
368        assert_eq!(format_size(1024), "1.0 KB");
369        assert_eq!(format_size(2048), "2.0 KB");
370        assert_eq!(format_size(1536), "1.5 KB");
371    }
372
373    #[test]
374    fn test_format_size_megabytes() {
375        assert_eq!(format_size(1024 * 1024), "1.0 MB");
376        assert_eq!(format_size(2 * 1024 * 1024), "2.0 MB");
377    }
378
379    #[test]
380    fn test_dry_run_result_serialization() {
381        let result = DryRunResult {
382            server_id: "github".to_string(),
383            server_name: "GitHub MCP Server".to_string(),
384            output_path: "/home/user/.claude/servers/github".to_string(),
385            files: vec![
386                FilePreview {
387                    path: "github/createIssue.ts".to_string(),
388                    size: 2450,
389                },
390                FilePreview {
391                    path: "github/listRepos.ts".to_string(),
392                    size: 1200,
393                },
394            ],
395            total_files: 2,
396            total_size: 3650,
397        };
398
399        let json = serde_json::to_string_pretty(&result).unwrap();
400        assert!(json.contains("\"server_id\": \"github\""));
401        assert!(json.contains("\"total_files\": 2"));
402        assert!(json.contains("\"total_size\": 3650"));
403        assert!(json.contains("\"path\": \"github/createIssue.ts\""));
404        assert!(json.contains("\"size\": 2450"));
405    }
406
407    #[test]
408    fn test_dry_run_collects_file_metadata() {
409        let generator = ProgressiveGenerator::new().unwrap();
410        let server_info = create_mock_server_info();
411        let generated_code = generator.generate(&server_info).unwrap();
412
413        let server_dir_name = server_info.id.to_string();
414        let files: Vec<FilePreview> = generated_code
415            .files
416            .iter()
417            .map(|f| FilePreview {
418                path: format!("{}/{}", server_dir_name, f.path),
419                size: f.content.len(),
420            })
421            .collect();
422
423        assert!(!files.is_empty());
424        for file in &files {
425            assert!(file.path.starts_with("test-server/"));
426            assert!(file.size > 0);
427        }
428
429        let total_size: usize = files.iter().map(|f| f.size).sum();
430        assert_eq!(
431            total_size,
432            generated_code
433                .files
434                .iter()
435                .map(|f| f.content.len())
436                .sum::<usize>()
437        );
438    }
439
440    #[test]
441    fn test_dry_run_does_not_write_files() {
442        use std::path::Path;
443
444        let generator = ProgressiveGenerator::new().unwrap();
445        let server_info = create_mock_server_info();
446        let generated_code = generator.generate(&server_info).unwrap();
447
448        // Simulate what dry-run does: collect metadata without touching the filesystem
449        let server_dir_name = server_info.id.to_string();
450        let fake_output_path = Path::new("/tmp/dry-run-test-should-not-exist-abc123");
451        let output_path = fake_output_path.join(&server_dir_name);
452
453        let files: Vec<FilePreview> = generated_code
454            .files
455            .iter()
456            .map(|f| FilePreview {
457                path: format!("{}/{}", server_dir_name, f.path),
458                size: f.content.len(),
459            })
460            .collect();
461
462        // Verify metadata collected correctly
463        assert!(!files.is_empty());
464
465        // Verify nothing was written to disk
466        assert!(
467            !output_path.exists(),
468            "dry-run must not write files to disk"
469        );
470    }
471
472    #[tokio::test]
473    async fn test_run_zero_connect_timeout_override_rejected_by_validation() {
474        // A zero override must surface the same connect_timeout validation
475        // error as the mcp.json path, not just a generic connection failure.
476        let result = run(
477            None,
478            Some("nonexistent-server-timeout-test".to_string()),
479            vec![],
480            vec![],
481            None,
482            None,
483            None,
484            vec![],
485            None,
486            None,
487            false,
488            Some(0),
489            None,
490            OutputFormat::Json,
491        )
492        .await;
493
494        assert!(result.is_err());
495        let err = result.unwrap_err();
496        let chain_msg = err
497            .chain()
498            .map(ToString::to_string)
499            .collect::<Vec<_>>()
500            .join(" | ");
501        assert!(
502            chain_msg.contains("greater than zero"),
503            "expected connect_timeout validation error in the error chain, got: {chain_msg}"
504        );
505    }
506
507    #[tokio::test]
508    async fn test_run_with_valid_timeout_overrides_reaches_connection_attempt() {
509        // Valid overrides must not be rejected before the connection attempt.
510        let result = run(
511            None,
512            Some("nonexistent-server-timeout-test-2".to_string()),
513            vec![],
514            vec![],
515            None,
516            None,
517            None,
518            vec![],
519            None,
520            None,
521            false,
522            Some(5),
523            Some(90),
524            OutputFormat::Json,
525        )
526        .await;
527
528        assert!(result.is_err());
529        let err_msg = result.unwrap_err().to_string();
530        assert!(err_msg.contains("failed to introspect MCP server"));
531    }
532}