mcp-execution-cli 0.7.0

CLI for MCP progressive loading code generation
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
//! Generate command implementation.
//!
//! Generates progressive loading TypeScript files from MCP server tool definitions.
//! This command:
//! 1. Introspects the server to discover tools and schemas
//! 2. Generates TypeScript files for progressive loading (one file per tool)
//! 3. Saves files to `~/.claude/servers/{server-id}/` directory

use super::common::{build_server_config, load_server_from_config};
use anyhow::{Context, Result};
use mcp_execution_codegen::progressive::ProgressiveGenerator;
use mcp_execution_core::cli::{ExitCode, OutputFormat};
use mcp_execution_files::FilesBuilder;
use mcp_execution_introspector::Introspector;
use serde::Serialize;
use std::path::PathBuf;
use tracing::{debug, info, warn};

/// Result of progressive loading code generation.
#[derive(Debug, Serialize)]
struct GenerationResult {
    /// Server ID
    server_id: String,
    /// Server name
    server_name: String,
    /// Number of tools generated
    tool_count: usize,
    /// Path where files were saved
    output_path: String,
}

/// Preview of a file that would be generated in dry-run mode.
#[derive(Debug, Serialize)]
struct FilePreview {
    /// Relative file path under the server directory
    path: String,
    /// File size in bytes
    size: usize,
}

/// Result of a dry-run preview.
#[derive(Debug, Serialize)]
struct DryRunResult {
    /// Server ID
    server_id: String,
    /// Server name
    server_name: String,
    /// Output path that would be used
    output_path: String,
    /// Files that would be generated
    files: Vec<FilePreview>,
    /// Total number of files
    total_files: usize,
    /// Total estimated size in bytes
    total_size: usize,
}

#[allow(clippy::cast_precision_loss)]
fn format_size(bytes: usize) -> String {
    if bytes < 1024 {
        format!("{bytes} B")
    } else if bytes < 1024 * 1024 {
        format!("{:.1} KB", bytes as f64 / 1024.0)
    } else {
        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
    }
}

/// Runs the generate command.
///
/// Generates progressive loading TypeScript files from an MCP server.
///
/// This command performs the following steps:
/// 1. Builds `ServerConfig` from CLI arguments or loads from ~/.claude/mcp.json
/// 2. Introspects the MCP server to discover tools
/// 3. Generates TypeScript files (one per tool) using progressive loading pattern
/// 4. Exports VFS to `~/.claude/servers/{server-id}/` directory
///
/// # Arguments
///
/// * `from_config` - Load server config from ~/.claude/mcp.json by name
/// * `server` - Server command (binary name or path), None for HTTP/SSE
/// * `args` - Arguments to pass to the server command
/// * `env` - Environment variables in KEY=VALUE format
/// * `cwd` - Working directory for the server process
/// * `http` - HTTP transport URL
/// * `sse` - SSE transport URL
/// * `headers` - HTTP headers in KEY=VALUE format
/// * `name` - Custom server name for directory (default: `server_id`)
/// * `output_dir` - Custom output directory (default: ~/.claude/servers/)
/// * `dry_run` - When true, preview files without writing to disk
/// * `output_format` - Output format (json, text, pretty)
///
/// # Errors
///
/// Returns an error if:
/// - Server configuration is invalid
/// - Server not found in mcp.json (when using --from-config)
/// - Server connection fails
/// - Tool introspection fails
/// - Code generation fails
/// - File export fails (skipped in dry-run mode)
#[allow(clippy::too_many_arguments)]
pub async fn run(
    from_config: Option<String>,
    server: Option<String>,
    args: Vec<String>,
    env: Vec<String>,
    cwd: Option<String>,
    http: Option<String>,
    sse: Option<String>,
    headers: Vec<String>,
    name: Option<String>,
    output_dir: Option<PathBuf>,
    dry_run: bool,
    output_format: OutputFormat,
) -> Result<ExitCode> {
    let (server_id, server_config) = if let Some(config_name) = from_config {
        debug!(
            "Loading server configuration from ~/.claude/mcp.json: {}",
            config_name
        );
        load_server_from_config(&config_name)?
    } else {
        build_server_config(server, args, env, cwd, http, sse, headers)?
    };

    info!("Connecting to MCP server: {}", server_id);

    let mut introspector = Introspector::new();
    let server_info = introspector
        .discover_server(server_id, &server_config)
        .await
        .context("failed to introspect MCP server")?;

    info!(
        "Discovered {} tools from server '{}'",
        server_info.tools.len(),
        server_info.name
    );

    if server_info.tools.is_empty() {
        warn!("Server has no tools to generate code for");
        return Ok(ExitCode::SUCCESS);
    }

    // Override server_info.id with custom name if provided
    // This ensures generated code uses the correct server_id that matches mcp.json
    let mut server_info = server_info;
    if let Some(ref custom_name) = name {
        server_info.id = mcp_execution_core::ServerId::new(custom_name);
    }

    let server_dir_name = server_info.id.to_string();

    let generator = ProgressiveGenerator::new().context("failed to create code generator")?;
    let generated_code = generator
        .generate(&server_info)
        .context("failed to generate TypeScript code")?;

    info!(
        "Generated {} files for progressive loading",
        generated_code.file_count()
    );

    let base_dir = if let Some(custom_dir) = output_dir {
        custom_dir
    } else {
        dirs::home_dir()
            .context("failed to get home directory")?
            .join(".claude")
            .join("servers")
    };
    let output_path = base_dir.join(&server_dir_name);

    if dry_run {
        let files: Vec<FilePreview> = generated_code
            .files
            .iter()
            .map(|f| FilePreview {
                path: format!("{}/{}", server_dir_name, f.path),
                size: f.content.len(),
            })
            .collect();
        let total_size: usize = files.iter().map(|f| f.size).sum();
        let total_files = files.len();

        let result = DryRunResult {
            server_id: server_info.id.to_string(),
            server_name: server_info.name,
            output_path: output_path.display().to_string(),
            files,
            total_files,
            total_size,
        };

        match output_format {
            OutputFormat::Json => {
                println!("{}", serde_json::to_string_pretty(&result)?);
            }
            OutputFormat::Text => {
                println!("Server: {} ({})", result.server_name, result.server_id);
                println!(
                    "Would generate {} files ({}) to {}/",
                    result.total_files,
                    format_size(result.total_size),
                    result.output_path
                );
            }
            OutputFormat::Pretty => {
                println!(
                    "Would generate {} files to {}/:",
                    result.total_files, result.output_path
                );
                println!();
                for f in &result.files {
                    println!("  - {} ({})", f.path, format_size(f.size));
                }
                println!();
                println!(
                    "Total: {} files, ~{}",
                    result.total_files,
                    format_size(result.total_size)
                );
            }
        }

        return Ok(ExitCode::SUCCESS);
    }

    // Build VFS with base_path="/" since generated files already have flat structure;
    // server_dir_name will be used when exporting to filesystem
    let vfs = FilesBuilder::from_generated_code(generated_code, "/")
        .build()
        .context("failed to build VFS")?;

    info!("Exporting files to: {}", output_path.display());

    std::fs::create_dir_all(&output_path).context("failed to create output directory")?;
    vfs.export_to_filesystem(&output_path)
        .context("failed to export files to filesystem")?;

    let result = GenerationResult {
        server_id: server_info.id.to_string(),
        server_name: server_info.name.clone(),
        tool_count: server_info.tools.len(),
        output_path: output_path.display().to_string(),
    };

    match output_format {
        OutputFormat::Json => {
            println!("{}", serde_json::to_string_pretty(&result)?);
        }
        OutputFormat::Text => {
            println!("Server: {} ({})", result.server_name, result.server_id);
            println!("Generated {} tool files", result.tool_count);
            println!("Output: {}", result.output_path);
        }
        OutputFormat::Pretty => {
            println!("✓ Successfully generated progressive loading files");
            println!("  Server: {} ({})", result.server_name, result.server_id);
            println!("  Tools: {}", result.tool_count);
            println!("  Location: {}", result.output_path);
        }
    }

    Ok(ExitCode::SUCCESS)
}

#[cfg(test)]
mod tests {
    use super::*;
    use mcp_execution_core::ServerId;
    use mcp_execution_introspector::{ServerCapabilities, ServerInfo, ToolInfo};
    use serde_json::json;

    fn create_mock_server_info() -> ServerInfo {
        ServerInfo {
            id: ServerId::new("test-server"),
            name: "Test Server".to_string(),
            version: "1.0.0".to_string(),
            tools: vec![ToolInfo {
                name: mcp_execution_core::ToolName::new("test_tool"),
                description: "A test tool".to_string(),
                input_schema: json!({
                    "type": "object",
                    "properties": {
                        "param": {"type": "string"}
                    }
                }),
                output_schema: None,
            }],
            capabilities: ServerCapabilities {
                supports_tools: true,
                supports_resources: false,
                supports_prompts: false,
            },
        }
    }

    #[test]
    fn test_generation_result_serialization() {
        let result = GenerationResult {
            server_id: "test".to_string(),
            server_name: "Test Server".to_string(),
            tool_count: 5,
            output_path: "/path/to/output".to_string(),
        };

        let json = serde_json::to_string(&result).unwrap();
        assert!(json.contains("\"server_id\":\"test\""));
        assert!(json.contains("\"tool_count\":5"));
    }

    #[test]
    fn test_progressive_generator_creation() {
        let generator = ProgressiveGenerator::new();
        assert!(generator.is_ok());
    }

    #[test]
    fn test_progressive_code_generation() {
        let generator = ProgressiveGenerator::new().unwrap();
        let server_info = create_mock_server_info();

        let result = generator.generate(&server_info);
        assert!(result.is_ok());

        let code = result.unwrap();
        assert!(code.file_count() > 0);
    }

    #[test]
    fn test_format_size_bytes() {
        assert_eq!(format_size(0), "0 B");
        assert_eq!(format_size(512), "512 B");
        assert_eq!(format_size(1023), "1023 B");
    }

    #[test]
    fn test_format_size_kilobytes() {
        assert_eq!(format_size(1024), "1.0 KB");
        assert_eq!(format_size(2048), "2.0 KB");
        assert_eq!(format_size(1536), "1.5 KB");
    }

    #[test]
    fn test_format_size_megabytes() {
        assert_eq!(format_size(1024 * 1024), "1.0 MB");
        assert_eq!(format_size(2 * 1024 * 1024), "2.0 MB");
    }

    #[test]
    fn test_dry_run_result_serialization() {
        let result = DryRunResult {
            server_id: "github".to_string(),
            server_name: "GitHub MCP Server".to_string(),
            output_path: "/home/user/.claude/servers/github".to_string(),
            files: vec![
                FilePreview {
                    path: "github/createIssue.ts".to_string(),
                    size: 2450,
                },
                FilePreview {
                    path: "github/listRepos.ts".to_string(),
                    size: 1200,
                },
            ],
            total_files: 2,
            total_size: 3650,
        };

        let json = serde_json::to_string_pretty(&result).unwrap();
        assert!(json.contains("\"server_id\": \"github\""));
        assert!(json.contains("\"total_files\": 2"));
        assert!(json.contains("\"total_size\": 3650"));
        assert!(json.contains("\"path\": \"github/createIssue.ts\""));
        assert!(json.contains("\"size\": 2450"));
    }

    #[test]
    fn test_dry_run_collects_file_metadata() {
        let generator = ProgressiveGenerator::new().unwrap();
        let server_info = create_mock_server_info();
        let generated_code = generator.generate(&server_info).unwrap();

        let server_dir_name = server_info.id.to_string();
        let files: Vec<FilePreview> = generated_code
            .files
            .iter()
            .map(|f| FilePreview {
                path: format!("{}/{}", server_dir_name, f.path),
                size: f.content.len(),
            })
            .collect();

        assert!(!files.is_empty());
        for file in &files {
            assert!(file.path.starts_with("test-server/"));
            assert!(file.size > 0);
        }

        let total_size: usize = files.iter().map(|f| f.size).sum();
        assert_eq!(
            total_size,
            generated_code
                .files
                .iter()
                .map(|f| f.content.len())
                .sum::<usize>()
        );
    }

    #[test]
    fn test_dry_run_does_not_write_files() {
        use std::path::Path;

        let generator = ProgressiveGenerator::new().unwrap();
        let server_info = create_mock_server_info();
        let generated_code = generator.generate(&server_info).unwrap();

        // Simulate what dry-run does: collect metadata without touching the filesystem
        let server_dir_name = server_info.id.to_string();
        let fake_output_path = Path::new("/tmp/dry-run-test-should-not-exist-abc123");
        let output_path = fake_output_path.join(&server_dir_name);

        let files: Vec<FilePreview> = generated_code
            .files
            .iter()
            .map(|f| FilePreview {
                path: format!("{}/{}", server_dir_name, f.path),
                size: f.content.len(),
            })
            .collect();

        // Verify metadata collected correctly
        assert!(!files.is_empty());

        // Verify nothing was written to disk
        assert!(
            !output_path.exists(),
            "dry-run must not write files to disk"
        );
    }
}