use super::common::{ServerSource, derive_server_id_from_path_or_name, resolve_server_config};
use crate::formatters::escape_display;
use anyhow::{Context, Result};
use mcp_execution_codegen::GeneratedCode;
use mcp_execution_codegen::progressive::ProgressiveGenerator;
use mcp_execution_core::cli::{ExitCode, OutputFormat};
use mcp_execution_core::{ServerConfig, ServerId};
use mcp_execution_files::{ExportOptions, FilesBuilder};
use mcp_execution_introspector::{Introspector, ServerInfo};
use mcp_execution_skill::validate_server_id;
use serde::Serialize;
use std::path::{Path, PathBuf};
use tracing::{info, warn};
#[derive(Debug, Serialize)]
struct GenerationResult {
server_id: String,
server_name: String,
tool_count: usize,
output_path: String,
next_step: String,
}
const NPM_INSTALL_HINT: &str =
"run 'npm install' in the output directory before type-checking the generated package";
#[derive(Debug, Serialize)]
struct FilePreview {
path: String,
size: usize,
}
#[derive(Debug, Serialize)]
struct DryRunResult {
server_id: String,
server_name: String,
output_path: String,
files: Vec<FilePreview>,
total_files: usize,
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))
}
}
pub async fn run(
source: ServerSource,
name: Option<String>,
output_dir: Option<PathBuf>,
dry_run: bool,
output_format: OutputFormat,
) -> Result<ExitCode> {
let id_from_unvalidated_config_key =
matches!(source, ServerSource::Config { .. }) && name.is_none();
let (server_id, server_config) = resolve_server_config(source)?;
let server_info = discover_server_info(server_id, &server_config, name.as_deref()).await?;
if server_info.tools.is_empty() {
warn!("Server has no tools to generate code for");
return Ok(ExitCode::SUCCESS);
}
let server_dir_name = resolve_server_dir_name(&server_info, id_from_unvalidated_config_key)?;
let generated_code = generate_code(&server_info)?;
let base_dir = resolve_base_dir(output_dir)?;
let output_path = base_dir.join(&server_dir_name);
if dry_run {
return render_dry_run(&server_info, &generated_code, &output_path, output_format);
}
export_generated_code(generated_code, &base_dir, &output_path)?;
render_success(&server_info, &output_path, output_format)
}
async fn discover_server_info(
server_id: ServerId,
server_config: &ServerConfig,
name: Option<&str>,
) -> Result<ServerInfo> {
let override_id = name
.map(|custom_name| {
validate_server_id(custom_name)
.with_context(|| format!("invalid --name '{custom_name}'"))?;
ServerId::new(custom_name).with_context(|| format!("invalid --name '{custom_name}'"))
})
.transpose()?;
info!("Connecting to MCP server: {}", server_id);
let mut introspector = Introspector::new();
let mut 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 let Some(id) = override_id {
server_info.id = id;
}
Ok(server_info)
}
fn generate_code(server_info: &ServerInfo) -> Result<GeneratedCode> {
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()
);
Ok(generated_code)
}
fn resolve_server_dir_name(
server_info: &ServerInfo,
is_from_unvalidated_config_key: bool,
) -> Result<String> {
let server_dir_name = server_info.id.to_string();
validate_server_id(&server_dir_name).map_err(|source| {
if is_from_unvalidated_config_key {
let suggested_name = derive_server_id_from_path_or_name(&server_dir_name);
anyhow::anyhow!(
"server '{server_dir_name}' in ~/.claude/mcp.json is not a valid directory name \
({source}); use --name {suggested_name} to override it"
)
} else {
anyhow::Error::from(source).context(format!(
"internal error: resolved server id '{server_dir_name}' is not a valid \
directory name"
))
}
})?;
Ok(server_dir_name)
}
fn resolve_base_dir(output_dir: Option<PathBuf>) -> Result<PathBuf> {
if let Some(custom_dir) = output_dir {
Ok(custom_dir)
} else {
Ok(dirs::home_dir()
.context("failed to get home directory")?
.join(".claude")
.join("servers"))
}
}
fn render_dry_run(
server_info: &ServerInfo,
generated_code: &GeneratedCode,
output_path: &Path,
output_format: OutputFormat,
) -> Result<ExitCode> {
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();
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.clone(),
output_path: output_path.display().to_string(),
files,
total_files,
total_size,
};
println!("{}", format_dry_run(&result, output_format)?);
Ok(ExitCode::SUCCESS)
}
fn format_dry_run(result: &DryRunResult, output_format: OutputFormat) -> Result<String> {
Ok(match output_format {
OutputFormat::Json => serde_json::to_string_pretty(result)?,
OutputFormat::Text => format!(
"Server: {} ({})\nWould generate {} files ({}) to {}/",
escape_display(&result.server_name),
result.server_id,
result.total_files,
format_size(result.total_size),
result.output_path
),
OutputFormat::Pretty => {
use std::fmt::Write as _;
let mut out = format!(
"Would generate {} files to {}/:\n\n",
result.total_files, result.output_path
);
for f in &result.files {
let _ = writeln!(out, " - {} ({})", f.path, format_size(f.size));
}
let _ = write!(
out,
"\nTotal: {} files, ~{}",
result.total_files,
format_size(result.total_size)
);
out
}
})
}
fn export_generated_code(
generated_code: GeneratedCode,
base_dir: &Path,
output_path: &Path,
) -> Result<()> {
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(base_dir).context("failed to create output directory")?;
let options = ExportOptions::new().with_confine_to(base_dir);
vfs.export_to_filesystem_with_options(output_path, &options)
.context("failed to export files to filesystem")?;
Ok(())
}
fn render_success(
server_info: &ServerInfo,
output_path: &Path,
output_format: OutputFormat,
) -> Result<ExitCode> {
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(),
next_step: NPM_INSTALL_HINT.to_string(),
};
println!("{}", format_success(&result, output_format)?);
Ok(ExitCode::SUCCESS)
}
fn format_success(result: &GenerationResult, output_format: OutputFormat) -> Result<String> {
Ok(match output_format {
OutputFormat::Json => serde_json::to_string_pretty(result)?,
OutputFormat::Text => format!(
"Server: {} ({})\nGenerated {} tool files\nOutput: {}\nNext step: {NPM_INSTALL_HINT}",
escape_display(&result.server_name),
result.server_id,
result.tool_count,
result.output_path
),
OutputFormat::Pretty => format!(
"✓ Successfully generated progressive loading files\n Server: {} ({})\n Tools: {}\n Location: {}\n Next step: {NPM_INSTALL_HINT}",
escape_display(&result.server_name),
result.server_id,
result.tool_count,
result.output_path
),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::common::TransportArgs;
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").unwrap(),
name: "Test Server".to_string(),
version: "1.0.0".to_string(),
tools: vec![ToolInfo {
name: mcp_execution_core::ToolName::new("test_tool").unwrap(),
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(),
next_step: NPM_INSTALL_HINT.to_string(),
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"server_id\":\"test\""));
assert!(json.contains("\"tool_count\":5"));
assert!(json.contains(NPM_INSTALL_HINT));
}
#[test]
fn test_format_success_text_escapes_control_chars() {
let result = GenerationResult {
server_id: "test".to_string(),
server_name: "evil\u{1b}[2J\u{1b}]0;pwned\u{7}".to_string(),
tool_count: 1,
output_path: "/path/to/output".to_string(),
next_step: NPM_INSTALL_HINT.to_string(),
};
let output = format_success(&result, OutputFormat::Text).unwrap();
assert!(!output.contains('\u{1b}'));
assert!(output.contains("\\u001b"));
}
#[test]
fn test_format_success_pretty_escapes_control_chars() {
let result = GenerationResult {
server_id: "test".to_string(),
server_name: "evil\u{1b}[2Jname".to_string(),
tool_count: 1,
output_path: "/path/to/output".to_string(),
next_step: NPM_INSTALL_HINT.to_string(),
};
let output = format_success(&result, OutputFormat::Pretty).unwrap();
assert!(!output.contains('\u{1b}'));
assert!(output.contains("\\u001b"));
}
#[test]
fn test_format_dry_run_text_escapes_control_chars() {
let result = DryRunResult {
server_id: "test".to_string(),
server_name: "evil\u{1b}[2Jname".to_string(),
output_path: "/path/to/output".to_string(),
files: vec![],
total_files: 0,
total_size: 0,
};
let output = format_dry_run(&result, OutputFormat::Text).unwrap();
assert!(!output.contains('\u{1b}'));
assert!(output.contains("\\u001b"));
}
#[test]
fn test_format_dry_run_pretty_never_prints_raw_control_chars() {
let result = DryRunResult {
server_id: "test".to_string(),
server_name: "evil\u{1b}[2Jname".to_string(),
output_path: "/path/to/output".to_string(),
files: vec![],
total_files: 0,
total_size: 0,
};
let output = format_dry_run(&result, OutputFormat::Pretty).unwrap();
assert!(!output.contains('\u{1b}'));
}
#[test]
fn test_format_success_json_unaffected_by_control_chars() {
let result = GenerationResult {
server_id: "test".to_string(),
server_name: "evil\u{1b}[2Jname".to_string(),
tool_count: 1,
output_path: "/path/to/output".to_string(),
next_step: NPM_INSTALL_HINT.to_string(),
};
let output = format_success(&result, OutputFormat::Json).unwrap();
assert!(!output.contains('\u{1b}'));
assert!(output.contains("\\u001b"));
}
#[test]
fn test_format_success_text_quotes_benign_server_name() {
let result = GenerationResult {
server_id: "test".to_string(),
server_name: "Test Server".to_string(),
tool_count: 1,
output_path: "/path/to/output".to_string(),
next_step: NPM_INSTALL_HINT.to_string(),
};
let output = format_success(&result, OutputFormat::Text).unwrap();
assert!(output.contains("Server: \"Test Server\" (test)"));
}
#[test]
fn test_format_dry_run_text_quotes_benign_server_name() {
let result = DryRunResult {
server_id: "test".to_string(),
server_name: "Test Server".to_string(),
output_path: "/path/to/output".to_string(),
files: vec![],
total_files: 0,
total_size: 0,
};
let output = format_dry_run(&result, OutputFormat::Text).unwrap();
assert!(output.contains("Server: \"Test Server\" (test)"));
}
#[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();
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();
assert!(!files.is_empty());
assert!(
!output_path.exists(),
"dry-run must not write files to disk"
);
}
#[tokio::test]
async fn test_run_zero_connect_timeout_override_rejected_by_validation() {
let source = ServerSource::Flags {
transport: TransportArgs::Stdio {
command: "nonexistent-server-timeout-test".to_string(),
args: vec![],
env: vec![],
cwd: None,
},
connect_timeout_secs: Some(0),
discover_timeout_secs: None,
};
let result = run(source, None, None, false, OutputFormat::Json).await;
assert!(result.is_err());
let err = result.unwrap_err();
let chain_msg = err
.chain()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(" | ");
assert!(
chain_msg.contains("greater than zero"),
"expected connect_timeout validation error in the error chain, got: {chain_msg}"
);
}
#[tokio::test]
async fn test_run_with_valid_timeout_overrides_reaches_connection_attempt() {
let source = ServerSource::Flags {
transport: TransportArgs::Stdio {
command: "nonexistent-server-timeout-test-2".to_string(),
args: vec![],
env: vec![],
cwd: None,
},
connect_timeout_secs: Some(5),
discover_timeout_secs: Some(90),
};
let result = run(source, None, None, false, OutputFormat::Json).await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("failed to introspect MCP server"));
}
#[tokio::test]
async fn test_name_override_rejects_traversal_and_absolute_paths() {
let server_config = ServerConfig::builder()
.command("nonexistent-command-for-name-validation-test".to_string())
.build()
.unwrap();
for bad_name in [
"../../../../etc/passwd",
"/etc/cron.d/evil",
"..",
"UPPER_CASE",
] {
let result = discover_server_info(
ServerId::new("placeholder").unwrap(),
&server_config,
Some(bad_name),
)
.await;
assert!(
result.is_err(),
"expected --name {bad_name:?} to be rejected"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("invalid --name"),
"expected a validation error for {bad_name:?}, got: {err_msg}"
);
}
}
#[test]
fn test_resolve_server_dir_name_accepts_valid_id() {
let server_info = create_mock_server_info();
assert_eq!(
resolve_server_dir_name(&server_info, false).unwrap(),
"test-server"
);
}
#[test]
fn test_server_id_construction_rejects_traversal_and_absolute_ids() {
for bad_id in ["../../../../etc/passwd", "/etc/cron.d/evil", ".."] {
assert!(
ServerId::new(bad_id).is_err(),
"expected id {bad_id:?} to be rejected by ServerId::new"
);
}
}
#[test]
fn test_resolve_server_dir_name_rejects_charset_violations() {
for bad_id in ["UPPER_CASE", "a.b", "a_b"] {
let mut server_info = create_mock_server_info();
server_info.id = ServerId::new(bad_id).unwrap();
let result = resolve_server_dir_name(&server_info, false);
assert!(result.is_err(), "expected id {bad_id:?} to be rejected");
}
}
#[test]
fn test_resolve_server_dir_name_non_config_error_is_framed_as_internal() {
let mut server_info = create_mock_server_info();
server_info.id = ServerId::new("UPPER_CASE").unwrap();
let err = resolve_server_dir_name(&server_info, false).unwrap_err();
let err_msg = err.to_string();
assert!(err_msg.contains("internal error"), "got: {err_msg}");
assert!(!err_msg.contains("mcp.json"), "got: {err_msg}");
}
#[test]
fn test_resolve_server_dir_name_from_config_error_names_mcp_json_and_suggests_name_override() {
let mut server_info = create_mock_server_info();
server_info.id = ServerId::new("claude_ai_Gmail").unwrap();
let err = resolve_server_dir_name(&server_info, true).unwrap_err();
let err_msg = err.to_string();
assert!(err_msg.contains("mcp.json"), "got: {err_msg}");
assert!(err_msg.contains("--name claude-ai-gmail"), "got: {err_msg}");
assert!(!err_msg.contains("internal error"), "got: {err_msg}");
}
#[test]
fn test_export_generated_code_confines_output_to_base_dir() {
let temp = tempfile::TempDir::new().unwrap();
let base_dir = temp.path().join("servers");
std::fs::create_dir_all(&base_dir).unwrap();
let escape_target = temp.path().join("escaped");
let generator = ProgressiveGenerator::new().unwrap();
let server_info = create_mock_server_info();
let generated_code = generator.generate(&server_info).unwrap();
let result = export_generated_code(generated_code, &base_dir, &escape_target);
assert!(result.is_err());
assert!(
!escape_target.exists(),
"confinement check must reject the export before anything is written outside base_dir"
);
}
}