1use 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#[derive(Debug, Serialize)]
25struct GenerationResult {
26 server_id: String,
28 server_name: String,
30 tool_count: usize,
32 output_path: String,
34 next_step: String,
36}
37
38const NPM_INSTALL_HINT: &str =
40 "run 'npm install' in the output directory before type-checking the generated package";
41
42#[derive(Debug, Serialize)]
44struct FilePreview {
45 path: String,
47 size: usize,
49}
50
51#[derive(Debug, Serialize)]
53struct DryRunResult {
54 server_id: String,
56 server_name: String,
58 output_path: String,
60 files: Vec<FilePreview>,
62 total_files: usize,
64 total_size: usize,
66}
67
68#[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
81pub 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 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
150async fn discover_server_info(
158 server_id: ServerId,
159 server_config: &ServerConfig,
160 name: Option<&str>,
161) -> Result<ServerInfo> {
162 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 if let Some(id) = override_id {
195 server_info.id = id;
196 }
197
198 Ok(server_info)
199}
200
201fn 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
220fn 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
276fn 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
293fn 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
326fn 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
363fn export_generated_code(
370 generated_code: GeneratedCode,
371 base_dir: &Path,
372 output_path: &Path,
373) -> Result<()> {
374 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 std::fs::create_dir_all(base_dir).context("failed to create output directory")?;
387 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
399fn 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
418fn 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 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 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 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 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 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 assert!(!files.is_empty());
728
729 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 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 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 #[tokio::test]
789 async fn test_name_override_rejects_traversal_and_absolute_paths() {
790 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 #[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 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 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 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 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 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}