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#[expect(
69 clippy::cast_precision_loss,
70 reason = "Converting byte counts to f64 for human-readable KB/MB formatting; precision loss \
71 at display magnitude is inconsequential."
72)]
73fn format_size(bytes: usize) -> String {
74 if bytes < 1024 {
75 format!("{bytes} B")
76 } else if bytes < 1024 * 1024 {
77 format!("{:.1} KB", bytes as f64 / 1024.0)
78 } else {
79 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
80 }
81}
82
83pub async fn run(
161 source: ServerSource,
162 name: Option<String>,
163 output_dir: Option<PathBuf>,
164 dry_run: bool,
165 output_format: OutputFormat,
166) -> Result<ExitCode> {
167 let id_from_unvalidated_config_key =
173 matches!(source, ServerSource::Config { .. }) && name.is_none();
174 let (server_id, server_config) = resolve_server_config(source)?;
175
176 let server_info = discover_server_info(server_id, &server_config, name.as_deref()).await?;
177
178 if server_info.tools.is_empty() {
179 warn!("Server has no tools to generate code for");
180 return Ok(ExitCode::SUCCESS);
181 }
182
183 let server_dir_name = resolve_server_dir_name(&server_info, id_from_unvalidated_config_key)?;
184 let generated_code = generate_code(&server_info, &server_config)?;
185
186 let base_dir = resolve_base_dir(output_dir)?;
187 let output_path = base_dir.join(&server_dir_name);
188
189 if dry_run {
190 return render_dry_run(&server_info, &generated_code, &output_path, output_format);
191 }
192
193 export_generated_code(generated_code, &base_dir, &output_path)?;
194
195 render_success(&server_info, &output_path, output_format)
196}
197
198async fn discover_server_info(
206 server_id: ServerId,
207 server_config: &ServerConfig,
208 name: Option<&str>,
209) -> Result<ServerInfo> {
210 let override_id = name
219 .map(|custom_name| {
220 validate_server_id(custom_name)
221 .with_context(|| format!("invalid --name '{custom_name}'"))?;
222 ServerId::new(custom_name).with_context(|| format!("invalid --name '{custom_name}'"))
223 })
224 .transpose()?;
225
226 info!("Connecting to MCP server: {}", server_id);
227
228 let mut introspector = Introspector::new();
229 let mut server_info = introspector
230 .discover_server(server_id, server_config)
231 .await
232 .context("failed to introspect MCP server")?;
233
234 info!(
235 "Discovered {} tools from server '{}'",
236 server_info.tools.len(),
237 server_info.name
238 );
239
240 if let Some(id) = override_id {
243 server_info.id = id;
244 }
245
246 Ok(server_info)
247}
248
249fn generate_code(server_info: &ServerInfo, server_config: &ServerConfig) -> Result<GeneratedCode> {
255 let generator = ProgressiveGenerator::new().context("failed to create code generator")?;
256 let generated_code = generator
257 .generate(server_info, server_config)
258 .context("failed to generate TypeScript code")?;
259
260 info!(
261 "Generated {} files for progressive loading",
262 generated_code.file_count()
263 );
264
265 Ok(generated_code)
266}
267
268fn resolve_server_dir_name(
303 server_info: &ServerInfo,
304 is_from_unvalidated_config_key: bool,
305) -> Result<String> {
306 let server_dir_name = server_info.id.to_string();
307 validate_server_id(&server_dir_name).map_err(|source| {
308 if is_from_unvalidated_config_key {
309 let suggested_name = derive_server_id_from_path_or_name(&server_dir_name);
310 anyhow::anyhow!(
311 "server '{server_dir_name}' in ~/.claude/mcp.json is not a valid directory name \
312 ({source}); use --name {suggested_name} to override it"
313 )
314 } else {
315 anyhow::Error::from(source).context(format!(
316 "internal error: resolved server id '{server_dir_name}' is not a valid \
317 directory name"
318 ))
319 }
320 })?;
321 Ok(server_dir_name)
322}
323
324fn resolve_base_dir(output_dir: Option<PathBuf>) -> Result<PathBuf> {
331 if let Some(custom_dir) = output_dir {
332 Ok(custom_dir)
333 } else {
334 Ok(dirs::home_dir()
335 .context("failed to get home directory")?
336 .join(".claude")
337 .join("servers"))
338 }
339}
340
341fn render_dry_run(
343 server_info: &ServerInfo,
344 generated_code: &GeneratedCode,
345 output_path: &Path,
346 output_format: OutputFormat,
347) -> Result<ExitCode> {
348 let server_dir_name = server_info.id.to_string();
349 let files: Vec<FilePreview> = generated_code
350 .files
351 .iter()
352 .map(|f| FilePreview {
353 path: format!("{}/{}", server_dir_name, f.path),
354 size: f.content.len(),
355 })
356 .collect();
357 let total_size: usize = files.iter().map(|f| f.size).sum();
358 let total_files = files.len();
359
360 let result = DryRunResult {
361 server_id: server_info.id.to_string(),
362 server_name: server_info.name.clone(),
363 output_path: output_path.display().to_string(),
364 files,
365 total_files,
366 total_size,
367 };
368
369 println!("{}", format_dry_run(&result, output_format)?);
370
371 Ok(ExitCode::SUCCESS)
372}
373
374fn format_dry_run(result: &DryRunResult, output_format: OutputFormat) -> Result<String> {
380 Ok(match output_format {
381 OutputFormat::Json => serde_json::to_string_pretty(result)?,
382 OutputFormat::Text => format!(
383 "Server: {} ({})\nWould generate {} files ({}) to {}/",
384 escape_display(&result.server_name),
385 result.server_id,
386 result.total_files,
387 format_size(result.total_size),
388 result.output_path
389 ),
390 OutputFormat::Pretty => {
391 use std::fmt::Write as _;
392
393 let mut out = format!(
394 "Would generate {} files to {}/:\n\n",
395 result.total_files, result.output_path
396 );
397 for f in &result.files {
398 let _ = writeln!(out, " - {} ({})", f.path, format_size(f.size));
399 }
400 let _ = write!(
401 out,
402 "\nTotal: {} files, ~{}",
403 result.total_files,
404 format_size(result.total_size)
405 );
406 out
407 }
408 })
409}
410
411fn export_generated_code(
418 generated_code: GeneratedCode,
419 base_dir: &Path,
420 output_path: &Path,
421) -> Result<()> {
422 let vfs = FilesBuilder::from_generated_code(generated_code, "/")
425 .build()
426 .context("failed to build VFS")?;
427
428 info!("Exporting files to: {}", output_path.display());
429
430 std::fs::create_dir_all(base_dir).context("failed to create output directory")?;
435 let options = ExportOptions::new().with_confine_to(base_dir);
441 vfs.export_to_filesystem_with_options(output_path, &options)
442 .context("failed to export files to filesystem")?;
443
444 Ok(())
445}
446
447fn render_success(
449 server_info: &ServerInfo,
450 output_path: &Path,
451 output_format: OutputFormat,
452) -> Result<ExitCode> {
453 let result = GenerationResult {
454 server_id: server_info.id.to_string(),
455 server_name: server_info.name.clone(),
456 tool_count: server_info.tools.len(),
457 output_path: output_path.display().to_string(),
458 next_step: NPM_INSTALL_HINT.to_string(),
459 };
460
461 println!("{}", format_success(&result, output_format)?);
462
463 Ok(ExitCode::SUCCESS)
464}
465
466fn format_success(result: &GenerationResult, output_format: OutputFormat) -> Result<String> {
472 Ok(match output_format {
473 OutputFormat::Json => serde_json::to_string_pretty(result)?,
474 OutputFormat::Text => format!(
475 "Server: {} ({})\nGenerated {} tool files\nOutput: {}\nNext step: {NPM_INSTALL_HINT}",
476 escape_display(&result.server_name),
477 result.server_id,
478 result.tool_count,
479 result.output_path
480 ),
481 OutputFormat::Pretty => format!(
482 "✓ Successfully generated progressive loading files\n Server: {} ({})\n Tools: {}\n Location: {}\n Next step: {NPM_INSTALL_HINT}",
483 escape_display(&result.server_name),
484 result.server_id,
485 result.tool_count,
486 result.output_path
487 ),
488 })
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494 use crate::commands::common::TransportArgs;
495 use mcp_execution_core::ServerId;
496 use mcp_execution_introspector::{ServerCapabilities, ServerInfo, ToolInfo};
497 use serde_json::json;
498
499 fn create_mock_server_info() -> ServerInfo {
500 ServerInfo {
501 id: ServerId::new("test-server").unwrap(),
502 name: "Test Server".to_string(),
503 version: "1.0.0".to_string(),
504 tools: vec![ToolInfo {
505 name: mcp_execution_core::ToolName::new("test_tool").unwrap(),
506 description: "A test tool".to_string(),
507 input_schema: json!({
508 "type": "object",
509 "properties": {
510 "param": {"type": "string"}
511 }
512 }),
513 output_schema: None,
514 }],
515 capabilities: ServerCapabilities {
516 supports_tools: true,
517 supports_resources: false,
518 supports_prompts: false,
519 },
520 }
521 }
522
523 fn create_mock_server_config() -> ServerConfig {
526 ServerConfig::builder()
527 .command("test-command".to_string())
528 .build()
529 .unwrap()
530 }
531
532 #[test]
533 fn test_generation_result_serialization() {
534 let result = GenerationResult {
535 server_id: "test".to_string(),
536 server_name: "Test Server".to_string(),
537 tool_count: 5,
538 output_path: "/path/to/output".to_string(),
539 next_step: NPM_INSTALL_HINT.to_string(),
540 };
541
542 let json = serde_json::to_string(&result).unwrap();
543 assert!(json.contains("\"server_id\":\"test\""));
544 assert!(json.contains("\"tool_count\":5"));
545 assert!(json.contains(NPM_INSTALL_HINT));
546 }
547
548 #[test]
549 fn test_format_success_text_escapes_control_chars() {
550 let result = GenerationResult {
553 server_id: "test".to_string(),
554 server_name: "evil\u{1b}[2J\u{1b}]0;pwned\u{7}".to_string(),
555 tool_count: 1,
556 output_path: "/path/to/output".to_string(),
557 next_step: NPM_INSTALL_HINT.to_string(),
558 };
559
560 let output = format_success(&result, OutputFormat::Text).unwrap();
561 assert!(!output.contains('\u{1b}'));
562 assert!(output.contains("\\u001b"));
563 }
564
565 #[test]
566 fn test_format_success_pretty_escapes_control_chars() {
567 let result = GenerationResult {
568 server_id: "test".to_string(),
569 server_name: "evil\u{1b}[2Jname".to_string(),
570 tool_count: 1,
571 output_path: "/path/to/output".to_string(),
572 next_step: NPM_INSTALL_HINT.to_string(),
573 };
574
575 let output = format_success(&result, OutputFormat::Pretty).unwrap();
576 assert!(!output.contains('\u{1b}'));
577 assert!(output.contains("\\u001b"));
578 }
579
580 #[test]
581 fn test_format_dry_run_text_escapes_control_chars() {
582 let result = DryRunResult {
583 server_id: "test".to_string(),
584 server_name: "evil\u{1b}[2Jname".to_string(),
585 output_path: "/path/to/output".to_string(),
586 files: vec![],
587 total_files: 0,
588 total_size: 0,
589 };
590
591 let output = format_dry_run(&result, OutputFormat::Text).unwrap();
592 assert!(!output.contains('\u{1b}'));
593 assert!(output.contains("\\u001b"));
594 }
595
596 #[test]
597 fn test_format_dry_run_pretty_never_prints_raw_control_chars() {
598 let result = DryRunResult {
602 server_id: "test".to_string(),
603 server_name: "evil\u{1b}[2Jname".to_string(),
604 output_path: "/path/to/output".to_string(),
605 files: vec![],
606 total_files: 0,
607 total_size: 0,
608 };
609
610 let output = format_dry_run(&result, OutputFormat::Pretty).unwrap();
611 assert!(!output.contains('\u{1b}'));
612 }
613
614 #[test]
615 fn test_format_success_json_unaffected_by_control_chars() {
616 let result = GenerationResult {
618 server_id: "test".to_string(),
619 server_name: "evil\u{1b}[2Jname".to_string(),
620 tool_count: 1,
621 output_path: "/path/to/output".to_string(),
622 next_step: NPM_INSTALL_HINT.to_string(),
623 };
624
625 let output = format_success(&result, OutputFormat::Json).unwrap();
626 assert!(!output.contains('\u{1b}'));
627 assert!(output.contains("\\u001b"));
628 }
629
630 #[test]
631 fn test_format_success_text_quotes_benign_server_name() {
632 let result = GenerationResult {
636 server_id: "test".to_string(),
637 server_name: "Test Server".to_string(),
638 tool_count: 1,
639 output_path: "/path/to/output".to_string(),
640 next_step: NPM_INSTALL_HINT.to_string(),
641 };
642
643 let output = format_success(&result, OutputFormat::Text).unwrap();
644 assert!(output.contains("Server: \"Test Server\" (test)"));
645 }
646
647 #[test]
648 fn test_format_dry_run_text_quotes_benign_server_name() {
649 let result = DryRunResult {
650 server_id: "test".to_string(),
651 server_name: "Test Server".to_string(),
652 output_path: "/path/to/output".to_string(),
653 files: vec![],
654 total_files: 0,
655 total_size: 0,
656 };
657
658 let output = format_dry_run(&result, OutputFormat::Text).unwrap();
659 assert!(output.contains("Server: \"Test Server\" (test)"));
660 }
661
662 #[test]
663 fn test_progressive_generator_creation() {
664 let generator = ProgressiveGenerator::new();
665 assert!(generator.is_ok());
666 }
667
668 #[test]
669 fn test_progressive_code_generation() {
670 let generator = ProgressiveGenerator::new().unwrap();
671 let server_info = create_mock_server_info();
672
673 let result = generator.generate(&server_info, &create_mock_server_config());
674 assert!(result.is_ok());
675
676 let code = result.unwrap();
677 assert!(code.file_count() > 0);
678 }
679
680 #[test]
681 fn test_format_size_bytes() {
682 assert_eq!(format_size(0), "0 B");
683 assert_eq!(format_size(512), "512 B");
684 assert_eq!(format_size(1023), "1023 B");
685 }
686
687 #[test]
688 fn test_format_size_kilobytes() {
689 assert_eq!(format_size(1024), "1.0 KB");
690 assert_eq!(format_size(2048), "2.0 KB");
691 assert_eq!(format_size(1536), "1.5 KB");
692 }
693
694 #[test]
695 fn test_format_size_megabytes() {
696 assert_eq!(format_size(1024 * 1024), "1.0 MB");
697 assert_eq!(format_size(2 * 1024 * 1024), "2.0 MB");
698 }
699
700 #[test]
701 fn test_dry_run_result_serialization() {
702 let result = DryRunResult {
703 server_id: "github".to_string(),
704 server_name: "GitHub MCP Server".to_string(),
705 output_path: "/home/user/.claude/servers/github".to_string(),
706 files: vec![
707 FilePreview {
708 path: "github/createIssue.ts".to_string(),
709 size: 2450,
710 },
711 FilePreview {
712 path: "github/listRepos.ts".to_string(),
713 size: 1200,
714 },
715 ],
716 total_files: 2,
717 total_size: 3650,
718 };
719
720 let json = serde_json::to_string_pretty(&result).unwrap();
721 assert!(json.contains("\"server_id\": \"github\""));
722 assert!(json.contains("\"total_files\": 2"));
723 assert!(json.contains("\"total_size\": 3650"));
724 assert!(json.contains("\"path\": \"github/createIssue.ts\""));
725 assert!(json.contains("\"size\": 2450"));
726 }
727
728 #[test]
729 fn test_dry_run_collects_file_metadata() {
730 let generator = ProgressiveGenerator::new().unwrap();
731 let server_info = create_mock_server_info();
732 let generated_code = generator
733 .generate(&server_info, &create_mock_server_config())
734 .unwrap();
735
736 let server_dir_name = server_info.id.to_string();
737 let files: Vec<FilePreview> = generated_code
738 .files
739 .iter()
740 .map(|f| FilePreview {
741 path: format!("{}/{}", server_dir_name, f.path),
742 size: f.content.len(),
743 })
744 .collect();
745
746 assert!(!files.is_empty());
747 for file in &files {
748 assert!(file.path.starts_with("test-server/"));
749 assert!(file.size > 0);
750 }
751
752 let total_size: usize = files.iter().map(|f| f.size).sum();
753 assert_eq!(
754 total_size,
755 generated_code
756 .files
757 .iter()
758 .map(|f| f.content.len())
759 .sum::<usize>()
760 );
761 }
762
763 #[test]
764 fn test_dry_run_does_not_write_files() {
765 use std::path::Path;
766
767 let generator = ProgressiveGenerator::new().unwrap();
768 let server_info = create_mock_server_info();
769 let generated_code = generator
770 .generate(&server_info, &create_mock_server_config())
771 .unwrap();
772
773 let server_dir_name = server_info.id.to_string();
775 let fake_output_path = Path::new("/tmp/dry-run-test-should-not-exist-abc123");
776 let output_path = fake_output_path.join(&server_dir_name);
777
778 let files: Vec<FilePreview> = generated_code
779 .files
780 .iter()
781 .map(|f| FilePreview {
782 path: format!("{}/{}", server_dir_name, f.path),
783 size: f.content.len(),
784 })
785 .collect();
786
787 assert!(!files.is_empty());
789
790 assert!(
792 !output_path.exists(),
793 "dry-run must not write files to disk"
794 );
795 }
796
797 #[tokio::test]
798 async fn test_run_zero_connect_timeout_override_rejected_by_validation() {
799 let source = ServerSource::Flags {
802 transport: TransportArgs::Stdio {
803 command: "nonexistent-server-timeout-test".to_string(),
804 args: vec![],
805 env: vec![],
806 cwd: None,
807 },
808 connect_timeout_secs: Some(0),
809 discover_timeout_secs: None,
810 };
811 let result = run(source, None, None, false, OutputFormat::Json).await;
812
813 assert!(result.is_err());
814 let err = result.unwrap_err();
815 let chain_msg = err
816 .chain()
817 .map(ToString::to_string)
818 .collect::<Vec<_>>()
819 .join(" | ");
820 assert!(
821 chain_msg.contains("greater than zero"),
822 "expected connect_timeout validation error in the error chain, got: {chain_msg}"
823 );
824 }
825
826 #[tokio::test]
827 async fn test_run_with_valid_timeout_overrides_reaches_connection_attempt() {
828 let source = ServerSource::Flags {
830 transport: TransportArgs::Stdio {
831 command: "nonexistent-server-timeout-test-2".to_string(),
832 args: vec![],
833 env: vec![],
834 cwd: None,
835 },
836 connect_timeout_secs: Some(5),
837 discover_timeout_secs: Some(90),
838 };
839 let result = run(source, None, None, false, OutputFormat::Json).await;
840
841 assert!(result.is_err());
842 let err_msg = result.unwrap_err().to_string();
843 assert!(err_msg.contains("failed to introspect MCP server"));
844 }
845
846 #[tokio::test]
850 async fn test_name_override_rejects_traversal_and_absolute_paths() {
851 let server_config = ServerConfig::builder()
860 .command("nonexistent-command-for-name-validation-test".to_string())
861 .build()
862 .unwrap();
863
864 for bad_name in [
865 "../../../../etc/passwd",
866 "/etc/cron.d/evil",
867 "..",
868 "UPPER_CASE",
869 ] {
870 let result = discover_server_info(
871 ServerId::new("placeholder").unwrap(),
872 &server_config,
873 Some(bad_name),
874 )
875 .await;
876
877 assert!(
878 result.is_err(),
879 "expected --name {bad_name:?} to be rejected"
880 );
881 let err_msg = result.unwrap_err().to_string();
882 assert!(
883 err_msg.contains("invalid --name"),
884 "expected a validation error for {bad_name:?}, got: {err_msg}"
885 );
886 }
887 }
888
889 #[test]
893 fn test_resolve_server_dir_name_accepts_valid_id() {
894 let server_info = create_mock_server_info();
895 assert_eq!(
896 resolve_server_dir_name(&server_info, false).unwrap(),
897 "test-server"
898 );
899 }
900
901 #[test]
902 fn test_server_id_construction_rejects_traversal_and_absolute_ids() {
903 for bad_id in ["../../../../etc/passwd", "/etc/cron.d/evil", ".."] {
909 assert!(
910 ServerId::new(bad_id).is_err(),
911 "expected id {bad_id:?} to be rejected by ServerId::new"
912 );
913 }
914 }
915
916 #[test]
917 fn test_resolve_server_dir_name_rejects_charset_violations() {
918 for bad_id in ["UPPER_CASE", "a.b", "a_b"] {
924 let mut server_info = create_mock_server_info();
925 server_info.id = ServerId::new(bad_id).unwrap();
926
927 let result = resolve_server_dir_name(&server_info, false);
928 assert!(result.is_err(), "expected id {bad_id:?} to be rejected");
929 }
930 }
931
932 #[test]
933 fn test_resolve_server_dir_name_non_config_error_is_framed_as_internal() {
934 let mut server_info = create_mock_server_info();
940 server_info.id = ServerId::new("UPPER_CASE").unwrap();
941
942 let err = resolve_server_dir_name(&server_info, false).unwrap_err();
943 let err_msg = err.to_string();
944 assert!(err_msg.contains("internal error"), "got: {err_msg}");
945 assert!(!err_msg.contains("mcp.json"), "got: {err_msg}");
946 }
947
948 #[test]
949 fn test_resolve_server_dir_name_from_config_error_names_mcp_json_and_suggests_name_override() {
950 let mut server_info = create_mock_server_info();
956 server_info.id = ServerId::new("claude_ai_Gmail").unwrap();
957
958 let err = resolve_server_dir_name(&server_info, true).unwrap_err();
959 let err_msg = err.to_string();
960 assert!(err_msg.contains("mcp.json"), "got: {err_msg}");
961 assert!(err_msg.contains("--name claude-ai-gmail"), "got: {err_msg}");
962 assert!(!err_msg.contains("internal error"), "got: {err_msg}");
963 }
964
965 #[test]
966 fn test_export_generated_code_confines_output_to_base_dir() {
967 let temp = tempfile::TempDir::new().unwrap();
973 let base_dir = temp.path().join("servers");
974 std::fs::create_dir_all(&base_dir).unwrap();
975 let escape_target = temp.path().join("escaped");
976
977 let generator = ProgressiveGenerator::new().unwrap();
978 let server_info = create_mock_server_info();
979 let generated_code = generator
980 .generate(&server_info, &create_mock_server_config())
981 .unwrap();
982
983 let result = export_generated_code(generated_code, &base_dir, &escape_target);
984
985 assert!(result.is_err());
986 assert!(
987 !escape_target.exists(),
988 "confinement check must reject the export before anything is written outside base_dir"
989 );
990 }
991}