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#[allow(clippy::cast_precision_loss)]
59fn format_size(bytes: usize) -> String {
60    if bytes < 1024 {
61        format!("{bytes} B")
62    } else if bytes < 1024 * 1024 {
63        format!("{:.1} KB", bytes as f64 / 1024.0)
64    } else {
65        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
66    }
67}
68
69/// Runs the generate command.
70///
71/// Generates progressive loading TypeScript files from an MCP server.
72///
73/// This command performs the following steps:
74/// 1. Builds `ServerConfig` from CLI arguments or loads from ~/.claude/mcp.json
75/// 2. Introspects the MCP server to discover tools
76/// 3. Generates TypeScript files (one per tool) using progressive loading pattern
77/// 4. Exports VFS to `~/.claude/servers/{server-id}/` directory
78///
79/// # Arguments
80///
81/// * `from_config` - Load server config from ~/.claude/mcp.json by name
82/// * `server` - Server command (binary name or path), None for HTTP/SSE
83/// * `args` - Arguments to pass to the server command
84/// * `env` - Environment variables in KEY=VALUE format
85/// * `cwd` - Working directory for the server process
86/// * `http` - HTTP transport URL
87/// * `sse` - SSE transport URL
88/// * `headers` - HTTP headers in KEY=VALUE format
89/// * `name` - Custom server name for directory (default: `server_id`)
90/// * `output_dir` - Custom output directory (default: ~/.claude/servers/)
91/// * `dry_run` - When true, preview files without writing to disk
92/// * `output_format` - Output format (json, text, pretty)
93///
94/// # Errors
95///
96/// Returns an error if:
97/// - Server configuration is invalid
98/// - Server not found in mcp.json (when using --from-config)
99/// - Server connection fails
100/// - Tool introspection fails
101/// - Code generation fails
102/// - File export fails (skipped in dry-run mode)
103#[allow(clippy::too_many_arguments)]
104pub async fn run(
105    from_config: Option<String>,
106    server: Option<String>,
107    args: Vec<String>,
108    env: Vec<String>,
109    cwd: Option<String>,
110    http: Option<String>,
111    sse: Option<String>,
112    headers: Vec<String>,
113    name: Option<String>,
114    output_dir: Option<PathBuf>,
115    dry_run: bool,
116    output_format: OutputFormat,
117) -> Result<ExitCode> {
118    let (server_id, server_config) = if let Some(config_name) = from_config {
119        debug!(
120            "Loading server configuration from ~/.claude/mcp.json: {}",
121            config_name
122        );
123        load_server_from_config(&config_name)?
124    } else {
125        build_server_config(server, args, env, cwd, http, sse, headers)?
126    };
127
128    info!("Connecting to MCP server: {}", server_id);
129
130    let mut introspector = Introspector::new();
131    let server_info = introspector
132        .discover_server(server_id, &server_config)
133        .await
134        .context("failed to introspect MCP server")?;
135
136    info!(
137        "Discovered {} tools from server '{}'",
138        server_info.tools.len(),
139        server_info.name
140    );
141
142    if server_info.tools.is_empty() {
143        warn!("Server has no tools to generate code for");
144        return Ok(ExitCode::SUCCESS);
145    }
146
147    // Override server_info.id with custom name if provided
148    // This ensures generated code uses the correct server_id that matches mcp.json
149    let mut server_info = server_info;
150    if let Some(ref custom_name) = name {
151        server_info.id = mcp_execution_core::ServerId::new(custom_name);
152    }
153
154    let server_dir_name = server_info.id.to_string();
155
156    let generator = ProgressiveGenerator::new().context("failed to create code generator")?;
157    let generated_code = generator
158        .generate(&server_info)
159        .context("failed to generate TypeScript code")?;
160
161    info!(
162        "Generated {} files for progressive loading",
163        generated_code.file_count()
164    );
165
166    let base_dir = if let Some(custom_dir) = output_dir {
167        custom_dir
168    } else {
169        dirs::home_dir()
170            .context("failed to get home directory")?
171            .join(".claude")
172            .join("servers")
173    };
174    let output_path = base_dir.join(&server_dir_name);
175
176    if dry_run {
177        let files: Vec<FilePreview> = generated_code
178            .files
179            .iter()
180            .map(|f| FilePreview {
181                path: format!("{}/{}", server_dir_name, f.path),
182                size: f.content.len(),
183            })
184            .collect();
185        let total_size: usize = files.iter().map(|f| f.size).sum();
186        let total_files = files.len();
187
188        let result = DryRunResult {
189            server_id: server_info.id.to_string(),
190            server_name: server_info.name,
191            output_path: output_path.display().to_string(),
192            files,
193            total_files,
194            total_size,
195        };
196
197        match output_format {
198            OutputFormat::Json => {
199                println!("{}", serde_json::to_string_pretty(&result)?);
200            }
201            OutputFormat::Text => {
202                println!("Server: {} ({})", result.server_name, result.server_id);
203                println!(
204                    "Would generate {} files ({}) to {}/",
205                    result.total_files,
206                    format_size(result.total_size),
207                    result.output_path
208                );
209            }
210            OutputFormat::Pretty => {
211                println!(
212                    "Would generate {} files to {}/:",
213                    result.total_files, result.output_path
214                );
215                println!();
216                for f in &result.files {
217                    println!("  - {} ({})", f.path, format_size(f.size));
218                }
219                println!();
220                println!(
221                    "Total: {} files, ~{}",
222                    result.total_files,
223                    format_size(result.total_size)
224                );
225            }
226        }
227
228        return Ok(ExitCode::SUCCESS);
229    }
230
231    // Build VFS with base_path="/" since generated files already have flat structure;
232    // server_dir_name will be used when exporting to filesystem
233    let vfs = FilesBuilder::from_generated_code(generated_code, "/")
234        .build()
235        .context("failed to build VFS")?;
236
237    info!("Exporting files to: {}", output_path.display());
238
239    std::fs::create_dir_all(&output_path).context("failed to create output directory")?;
240    vfs.export_to_filesystem(&output_path)
241        .context("failed to export files to filesystem")?;
242
243    let result = GenerationResult {
244        server_id: server_info.id.to_string(),
245        server_name: server_info.name.clone(),
246        tool_count: server_info.tools.len(),
247        output_path: output_path.display().to_string(),
248    };
249
250    match output_format {
251        OutputFormat::Json => {
252            println!("{}", serde_json::to_string_pretty(&result)?);
253        }
254        OutputFormat::Text => {
255            println!("Server: {} ({})", result.server_name, result.server_id);
256            println!("Generated {} tool files", result.tool_count);
257            println!("Output: {}", result.output_path);
258        }
259        OutputFormat::Pretty => {
260            println!("✓ Successfully generated progressive loading files");
261            println!("  Server: {} ({})", result.server_name, result.server_id);
262            println!("  Tools: {}", result.tool_count);
263            println!("  Location: {}", result.output_path);
264        }
265    }
266
267    Ok(ExitCode::SUCCESS)
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use mcp_execution_core::ServerId;
274    use mcp_execution_introspector::{ServerCapabilities, ServerInfo, ToolInfo};
275    use serde_json::json;
276
277    fn create_mock_server_info() -> ServerInfo {
278        ServerInfo {
279            id: ServerId::new("test-server"),
280            name: "Test Server".to_string(),
281            version: "1.0.0".to_string(),
282            tools: vec![ToolInfo {
283                name: mcp_execution_core::ToolName::new("test_tool"),
284                description: "A test tool".to_string(),
285                input_schema: json!({
286                    "type": "object",
287                    "properties": {
288                        "param": {"type": "string"}
289                    }
290                }),
291                output_schema: None,
292            }],
293            capabilities: ServerCapabilities {
294                supports_tools: true,
295                supports_resources: false,
296                supports_prompts: false,
297            },
298        }
299    }
300
301    #[test]
302    fn test_generation_result_serialization() {
303        let result = GenerationResult {
304            server_id: "test".to_string(),
305            server_name: "Test Server".to_string(),
306            tool_count: 5,
307            output_path: "/path/to/output".to_string(),
308        };
309
310        let json = serde_json::to_string(&result).unwrap();
311        assert!(json.contains("\"server_id\":\"test\""));
312        assert!(json.contains("\"tool_count\":5"));
313    }
314
315    #[test]
316    fn test_progressive_generator_creation() {
317        let generator = ProgressiveGenerator::new();
318        assert!(generator.is_ok());
319    }
320
321    #[test]
322    fn test_progressive_code_generation() {
323        let generator = ProgressiveGenerator::new().unwrap();
324        let server_info = create_mock_server_info();
325
326        let result = generator.generate(&server_info);
327        assert!(result.is_ok());
328
329        let code = result.unwrap();
330        assert!(code.file_count() > 0);
331    }
332
333    #[test]
334    fn test_format_size_bytes() {
335        assert_eq!(format_size(0), "0 B");
336        assert_eq!(format_size(512), "512 B");
337        assert_eq!(format_size(1023), "1023 B");
338    }
339
340    #[test]
341    fn test_format_size_kilobytes() {
342        assert_eq!(format_size(1024), "1.0 KB");
343        assert_eq!(format_size(2048), "2.0 KB");
344        assert_eq!(format_size(1536), "1.5 KB");
345    }
346
347    #[test]
348    fn test_format_size_megabytes() {
349        assert_eq!(format_size(1024 * 1024), "1.0 MB");
350        assert_eq!(format_size(2 * 1024 * 1024), "2.0 MB");
351    }
352
353    #[test]
354    fn test_dry_run_result_serialization() {
355        let result = DryRunResult {
356            server_id: "github".to_string(),
357            server_name: "GitHub MCP Server".to_string(),
358            output_path: "/home/user/.claude/servers/github".to_string(),
359            files: vec![
360                FilePreview {
361                    path: "github/createIssue.ts".to_string(),
362                    size: 2450,
363                },
364                FilePreview {
365                    path: "github/listRepos.ts".to_string(),
366                    size: 1200,
367                },
368            ],
369            total_files: 2,
370            total_size: 3650,
371        };
372
373        let json = serde_json::to_string_pretty(&result).unwrap();
374        assert!(json.contains("\"server_id\": \"github\""));
375        assert!(json.contains("\"total_files\": 2"));
376        assert!(json.contains("\"total_size\": 3650"));
377        assert!(json.contains("\"path\": \"github/createIssue.ts\""));
378        assert!(json.contains("\"size\": 2450"));
379    }
380
381    #[test]
382    fn test_dry_run_collects_file_metadata() {
383        let generator = ProgressiveGenerator::new().unwrap();
384        let server_info = create_mock_server_info();
385        let generated_code = generator.generate(&server_info).unwrap();
386
387        let server_dir_name = server_info.id.to_string();
388        let files: Vec<FilePreview> = generated_code
389            .files
390            .iter()
391            .map(|f| FilePreview {
392                path: format!("{}/{}", server_dir_name, f.path),
393                size: f.content.len(),
394            })
395            .collect();
396
397        assert!(!files.is_empty());
398        for file in &files {
399            assert!(file.path.starts_with("test-server/"));
400            assert!(file.size > 0);
401        }
402
403        let total_size: usize = files.iter().map(|f| f.size).sum();
404        assert_eq!(
405            total_size,
406            generated_code
407                .files
408                .iter()
409                .map(|f| f.content.len())
410                .sum::<usize>()
411        );
412    }
413
414    #[test]
415    fn test_dry_run_does_not_write_files() {
416        use std::path::Path;
417
418        let generator = ProgressiveGenerator::new().unwrap();
419        let server_info = create_mock_server_info();
420        let generated_code = generator.generate(&server_info).unwrap();
421
422        // Simulate what dry-run does: collect metadata without touching the filesystem
423        let server_dir_name = server_info.id.to_string();
424        let fake_output_path = Path::new("/tmp/dry-run-test-should-not-exist-abc123");
425        let output_path = fake_output_path.join(&server_dir_name);
426
427        let files: Vec<FilePreview> = generated_code
428            .files
429            .iter()
430            .map(|f| FilePreview {
431                path: format!("{}/{}", server_dir_name, f.path),
432                size: f.content.len(),
433            })
434            .collect();
435
436        // Verify metadata collected correctly
437        assert!(!files.is_empty());
438
439        // Verify nothing was written to disk
440        assert!(
441            !output_path.exists(),
442            "dry-run must not write files to disk"
443        );
444    }
445}