use crate::common::types::{GeneratedCode, GeneratedFile};
use crate::common::typescript::{
MAX_SCHEMA_RECURSION_DEPTH, disambiguate_identifier, extract_properties,
sanitize_ts_identifier, to_camel_case,
};
use crate::progressive::types::{
BridgeContext, CategoryInfo, IndexContext, PropertyInfo, ToolCategorization, ToolContext,
ToolSummary,
};
use crate::template_engine::TemplateEngine;
use mcp_execution_core::ResourceKind;
use mcp_execution_core::metadata::{
METADATA_FILE_NAME, METADATA_SCHEMA_VERSION, ParameterMetadata, ServerMetadata, ToolMetadata,
};
use mcp_execution_core::{Error, Result};
use mcp_execution_introspector::{ServerInfo, ToolInfo};
use std::collections::{HashMap, HashSet};
const FIXED_FILE_COUNT: usize = 5;
pub const MAX_GENERATED_FILES: usize =
mcp_execution_introspector::MAX_TOOL_COUNT + FIXED_FILE_COUNT;
pub const MAX_GENERATED_BYTES: usize = 2
* mcp_execution_introspector::MAX_TOOL_COUNT
* (mcp_execution_introspector::MAX_TOOL_NAME_LEN
+ mcp_execution_introspector::MAX_TOOL_DESCRIPTION_LEN
+ mcp_execution_introspector::MAX_SCHEMA_SIZE_BYTES);
const PACKAGE_JSON: &str = "{\"type\":\"module\",\"devDependencies\":{\"@types/node\":\"^22\"}}\n";
const TSCONFIG_JSON: &str = r#"{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["**/*.ts"]
}
"#;
#[derive(Debug)]
pub struct ProgressiveGenerator<'a> {
engine: TemplateEngine<'a>,
}
impl ProgressiveGenerator<'_> {
pub fn new() -> Result<Self> {
let engine = TemplateEngine::new()?;
Ok(Self { engine })
}
pub fn generate(&self, server_info: &ServerInfo) -> Result<GeneratedCode> {
self.generate_with_categories(server_info, &HashMap::new())
}
#[tracing::instrument(
skip_all,
fields(server_id = %server_info.id, tool_count = server_info.tools.len())
)]
pub fn generate_with_categories(
&self,
server_info: &ServerInfo,
categorizations: &HashMap<String, ToolCategorization>,
) -> Result<GeneratedCode> {
if categorizations.is_empty() {
tracing::info!(
"Generating progressive loading code for server: {}",
server_info.name
);
} else {
tracing::info!(
"Generating progressive loading code with categorizations for server: {}",
server_info.name
);
}
enforce_tool_count_bound(server_info)?;
let mut code = GeneratedCode::new();
let mut total_bytes = 0usize;
let server_id = server_info.id.as_str();
let typescript_names = resolve_typescript_names(&server_info.tools);
let mut tool_metadata = Vec::with_capacity(server_info.tools.len());
for (idx, tool) in server_info.tools.iter().enumerate() {
let tool_name = tool.name.as_str();
let categorization = categorizations.get(tool_name);
let typescript_name = typescript_names.get(idx).cloned().unwrap_or_default();
let extracted_properties =
Self::extract_property_data(&tool.input_schema).map_err(|source| {
Self::wrap_tool_generation_error(tool, "extract property schema", source)
})?;
let properties_for_context = extracted_properties
.iter()
.map(|(info, _)| info.clone())
.collect();
let tool_context = Self::create_tool_context(
server_id,
tool,
categorization,
typescript_name.clone(),
properties_for_context,
);
let tool_code = self
.engine
.render("progressive/tool", &tool_context)
.map_err(|source| {
Self::wrap_tool_generation_error(tool, "render tool template", source)
})?;
add_tracked(
&mut code,
&mut total_bytes,
GeneratedFile {
path: format!("{}.ts", tool_context.typescript_name),
content: tool_code,
},
)
.map_err(|source| {
Self::wrap_tool_generation_error(tool, "track generated tool file", source)
})?;
tracing::debug!(
"Generated tool file: {}.ts (category: {:?})",
tool_context.typescript_name,
categorization.map(|c| &c.category)
);
tool_metadata.push(Self::create_tool_metadata(
tool,
categorization,
typescript_name,
extracted_properties,
));
}
let index_context =
Self::create_index_context(server_info, Some(categorizations), &typescript_names);
let index_code = self.engine.render("progressive/index", &index_context)?;
add_tracked(
&mut code,
&mut total_bytes,
GeneratedFile {
path: "index.ts".to_string(),
content: index_code,
},
)?;
tracing::debug!(
"Generated index.ts with {} categorizations",
categorizations.len()
);
let bridge_context = BridgeContext::default();
let bridge_code = self
.engine
.render("progressive/runtime-bridge", &bridge_context)?;
add_tracked(
&mut code,
&mut total_bytes,
GeneratedFile {
path: "_runtime/mcp-bridge.ts".to_string(),
content: bridge_code,
},
)?;
tracing::debug!("Generated _runtime/mcp-bridge.ts");
add_tracked(
&mut code,
&mut total_bytes,
GeneratedFile {
path: "package.json".to_string(),
content: PACKAGE_JSON.to_string(),
},
)?;
tracing::debug!("Generated package.json");
add_tracked(
&mut code,
&mut total_bytes,
GeneratedFile {
path: "tsconfig.json".to_string(),
content: TSCONFIG_JSON.to_string(),
},
)?;
tracing::debug!("Generated tsconfig.json");
add_tracked(
&mut code,
&mut total_bytes,
Self::create_metadata_file(server_info, tool_metadata)?,
)?;
tracing::debug!("Generated {}", METADATA_FILE_NAME);
if categorizations.is_empty() {
tracing::info!(
"Successfully generated {} files for {} (progressive loading)",
code.file_count(),
server_info.name
);
} else {
tracing::info!(
"Successfully generated {} files for {} with categorizations (progressive loading)",
code.file_count(),
server_info.name
);
}
Ok(code)
}
fn create_tool_context(
server_id: &str,
tool: &mcp_execution_introspector::ToolInfo,
categorization: Option<&ToolCategorization>,
typescript_name: String,
properties: Vec<PropertyInfo>,
) -> ToolContext {
let description = sanitize_jsdoc(&tool.description, 256);
let short_description = categorization.map_or_else(
|| description.clone(),
|c| sanitize_jsdoc(&c.short_description, 256),
);
ToolContext {
server_id: sanitize_jsdoc(server_id, 256),
name: sanitize_jsdoc(tool.name.as_str(), 256),
name_literal: sanitize_ts_string_literal(tool.name.as_str()),
server_id_literal: sanitize_ts_string_literal(server_id),
typescript_name,
description,
input_schema: sanitize_schema_jsdoc_descriptions(tool.input_schema.clone()),
properties,
category: categorization.map(|c| sanitize_jsdoc(&c.category, 128)),
keywords: categorization.map(|c| render_keywords_for_jsdoc(&c.keywords)),
short_description,
}
}
fn create_index_context(
server_info: &ServerInfo,
categorizations: Option<&HashMap<String, ToolCategorization>>,
typescript_names: &[String],
) -> IndexContext {
let tools: Vec<ToolSummary> = server_info
.tools
.iter()
.enumerate()
.map(|(idx, tool)| {
let tool_name = tool.name.as_str();
let cat = categorizations.and_then(|c| c.get(tool_name));
ToolSummary {
typescript_name: typescript_names.get(idx).cloned().unwrap_or_default(),
description: sanitize_jsdoc(&tool.description, 256),
category: cat.map(|c| sanitize_jsdoc(&c.category, 128)),
keywords: cat.map(|c| render_keywords_for_jsdoc(&c.keywords)),
short_description: cat.map(|c| sanitize_jsdoc(&c.short_description, 256)),
}
})
.collect();
let category_groups = categorizations.filter(|c| !c.is_empty()).map(|_| {
let mut groups: HashMap<String, Vec<ToolSummary>> = HashMap::new();
for tool in &tools {
let cat_name = tool
.category
.clone()
.unwrap_or_else(|| "uncategorized".to_string());
groups.entry(cat_name).or_default().push(tool.clone());
}
let mut result: Vec<CategoryInfo> = groups
.into_iter()
.map(|(name, tools)| CategoryInfo { name, tools })
.collect();
result.sort_by(|a, b| {
if a.name == "uncategorized" {
std::cmp::Ordering::Greater
} else if b.name == "uncategorized" {
std::cmp::Ordering::Less
} else {
a.name.cmp(&b.name)
}
});
result
});
IndexContext {
server_name: sanitize_jsdoc(&server_info.name, 256),
server_version: sanitize_jsdoc(&server_info.version, 64),
tool_count: server_info.tools.len(),
tools,
categories: category_groups,
}
}
fn wrap_tool_generation_error(tool: &ToolInfo, stage: &str, source: Error) -> Error {
Error::ScriptGenerationError {
tool: tool.name.as_str().to_string(),
message: format!("failed to {stage}"),
source: Some(Box::new(source)),
}
}
#[cfg(test)]
fn extract_property_infos(schema: &serde_json::Value) -> Result<Vec<PropertyInfo>> {
Ok(Self::extract_property_data(schema)?
.into_iter()
.map(|(info, _raw_description)| info)
.collect())
}
fn extract_property_data(
schema: &serde_json::Value,
) -> Result<Vec<(PropertyInfo, Option<String>)>> {
let raw_properties = extract_properties(schema);
let mut properties = Vec::new();
let mut used_names = HashSet::new();
for prop in raw_properties {
let raw_name = prop["name"]
.as_str()
.ok_or_else(|| Error::ValidationError {
field: "name".to_string(),
reason: "Property name is not a string".to_string(),
})?
.to_string();
let typescript_type = prop["type"]
.as_str()
.ok_or_else(|| Error::ValidationError {
field: "type".to_string(),
reason: "Property type is not a string".to_string(),
})?
.to_string();
let required = prop["required"].as_bool().unwrap_or(false);
let raw_description = schema.as_object().and_then(|obj| {
obj.get("properties")
.and_then(|props| props.as_object())
.and_then(|props| props.get(&raw_name))
.and_then(|prop_schema| prop_schema.as_object())
.and_then(|obj| obj.get("description"))
.and_then(|desc| desc.as_str())
.map(str::to_string)
});
let description = raw_description
.as_deref()
.map(|desc| sanitize_jsdoc(desc, 256));
let base_name = sanitize_ts_identifier(&raw_name);
properties.push((
PropertyInfo {
name: disambiguate_identifier(&base_name, &mut used_names),
typescript_type,
description,
required,
},
raw_description,
));
}
Ok(properties)
}
fn create_tool_metadata(
tool: &ToolInfo,
categorization: Option<&ToolCategorization>,
typescript_name: String,
properties: Vec<(PropertyInfo, Option<String>)>,
) -> ToolMetadata {
let description = (!tool.description.is_empty()).then(|| tool.description.clone());
let category = categorization.map(|c| c.category.clone());
let keywords = categorization.map_or_else(Vec::new, |c| c.keywords.clone());
ToolMetadata {
name: tool.name.clone(),
typescript_name,
category,
keywords,
description,
parameters: properties
.into_iter()
.map(|(p, raw_description)| ParameterMetadata {
name: p.name,
typescript_type: p.typescript_type,
required: p.required,
description: raw_description,
})
.collect(),
}
}
fn create_metadata_file(
server_info: &ServerInfo,
tools: Vec<ToolMetadata>,
) -> Result<GeneratedFile> {
let meta = ServerMetadata {
schema_version: METADATA_SCHEMA_VERSION,
server_id: server_info.id.clone(),
server_name: server_info.name.clone(),
server_version: server_info.version.clone(),
tools,
};
let content =
serde_json::to_string_pretty(&meta).map_err(|e| Error::SerializationError {
message: format!("failed to serialize {METADATA_FILE_NAME}"),
source: Some(e),
})?;
Ok(GeneratedFile {
path: METADATA_FILE_NAME.to_string(),
content,
})
}
}
fn enforce_tool_count_bound(server_info: &ServerInfo) -> Result<()> {
let projected_file_count = server_info.tools.len() + FIXED_FILE_COUNT;
if projected_file_count > MAX_GENERATED_FILES {
return Err(Error::ResourceLimitExceeded {
resource: ResourceKind::ToolCount {
server_id: server_info.id.clone(),
},
actual: server_info.tools.len(),
limit: MAX_GENERATED_FILES - FIXED_FILE_COUNT,
});
}
Ok(())
}
fn add_tracked(
code: &mut GeneratedCode,
total_bytes: &mut usize,
file: GeneratedFile,
) -> Result<()> {
*total_bytes += file.content.len();
if *total_bytes > MAX_GENERATED_BYTES {
return Err(Error::ResourceLimitExceeded {
resource: ResourceKind::GeneratedOutputSize,
actual: *total_bytes,
limit: MAX_GENERATED_BYTES,
});
}
code.add_file(file)?;
if code.file_count() > MAX_GENERATED_FILES {
return Err(Error::ResourceLimitExceeded {
resource: ResourceKind::GeneratedFileCount,
actual: code.file_count(),
limit: MAX_GENERATED_FILES,
});
}
Ok(())
}
fn sanitize_jsdoc(s: &str, max_len: usize) -> String {
let neutralized = mcp_execution_core::untrusted::sanitize_untrusted_text(s, usize::MAX);
let sanitized = neutralized.replace("*/", "*\\/");
if sanitized.chars().count() > max_len {
sanitized.chars().take(max_len).collect()
} else {
sanitized
}
}
fn render_keywords_for_jsdoc(keywords: &[String]) -> String {
sanitize_jsdoc(&keywords.join(", "), 256)
}
pub(crate) fn sanitize_ts_string_literal(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('\'', "\\'")
.replace('\r', "\\r")
.replace('\n', "\\n")
.replace('\u{2028}', "\\u2028")
.replace('\u{2029}', "\\u2029")
}
const RESERVED_WORDS: &[&str] = &[
"arguments",
"await",
"break",
"case",
"catch",
"class",
"const",
"continue",
"debugger",
"default",
"delete",
"do",
"else",
"enum",
"eval",
"export",
"extends",
"false",
"finally",
"for",
"function",
"if",
"implements",
"import",
"in",
"instanceof",
"interface",
"let",
"new",
"null",
"package",
"private",
"protected",
"public",
"return",
"static",
"super",
"switch",
"this",
"throw",
"true",
"try",
"typeof",
"var",
"void",
"while",
"with",
"yield",
];
const RESERVED_OUTPUT_NAMES: &[&str] = &["index"];
fn resolve_typescript_names(tools: &[ToolInfo]) -> Vec<String> {
let mut used_lower: HashSet<String> = RESERVED_OUTPUT_NAMES
.iter()
.map(|&s| s.to_ascii_lowercase())
.collect();
let mut resolved = Vec::with_capacity(tools.len());
for tool in tools {
let base = sanitize_ts_identifier(&to_camel_case(tool.name.as_str()));
resolved.push(disambiguate_output_filename(&base, &mut used_lower));
}
resolved
}
fn disambiguate_output_filename(base: &str, used_lower: &mut HashSet<String>) -> String {
let mut candidate = base.to_string();
let mut suffix = 2;
loop {
let is_reserved_word = RESERVED_WORDS.contains(&candidate.as_str());
if !is_reserved_word && used_lower.insert(candidate.to_ascii_lowercase()) {
return candidate;
}
candidate = format!("{base}_{suffix}");
suffix += 1;
}
}
fn sanitize_schema_jsdoc_descriptions(mut value: serde_json::Value) -> serde_json::Value {
let mut cap_hit = false;
sanitize_schema_jsdoc_value(&mut value, 0, &mut cap_hit);
if cap_hit {
tracing::warn!(
max_depth = MAX_SCHEMA_RECURSION_DEPTH,
"schema nesting exceeded MAX_SCHEMA_RECURSION_DEPTH; descriptions beyond that depth \
were left unsanitized"
);
}
value
}
fn sanitize_schema_jsdoc_value(value: &mut serde_json::Value, depth: usize, cap_hit: &mut bool) {
if depth >= MAX_SCHEMA_RECURSION_DEPTH {
*cap_hit = true;
return;
}
match value {
serde_json::Value::Object(map) => {
for (key, child) in map.iter_mut() {
if key == "description" {
if let Some(description) = child.as_str() {
*child = serde_json::Value::String(sanitize_jsdoc(description, 256));
} else {
*child = serde_json::Value::Null;
}
} else {
sanitize_schema_jsdoc_value(child, depth + 1, cap_hit);
}
}
}
serde_json::Value::Array(values) => {
for child in values {
sanitize_schema_jsdoc_value(child, depth + 1, cap_hit);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use mcp_execution_core::{ServerId, ToolName};
use mcp_execution_introspector::{ServerCapabilities, ToolInfo};
use serde_json::json;
fn create_test_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: ToolName::new("create_issue").unwrap(),
description: "Creates a new issue".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Issue title"
},
"body": {
"type": "string",
"description": "Issue body"
}
},
"required": ["title"]
}),
output_schema: None,
},
ToolInfo {
name: ToolName::new("update_issue").unwrap(),
description: "Updates an existing issue".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"id": {
"type": "number"
}
},
"required": ["id"]
}),
output_schema: None,
},
],
capabilities: ServerCapabilities {
supports_tools: true,
supports_resources: false,
supports_prompts: false,
},
}
}
#[test]
fn test_progressive_generator_new() {
let generator = ProgressiveGenerator::new();
assert!(generator.is_ok());
}
#[test]
fn test_generate_progressive_files() {
let generator = ProgressiveGenerator::new().unwrap();
let server_info = create_test_server_info();
let code = generator.generate(&server_info).unwrap();
assert_eq!(code.file_count(), 7);
let tool_files: Vec<_> = code.files.iter().map(|f| f.path.as_str()).collect();
assert!(tool_files.contains(&"createIssue.ts"));
assert!(tool_files.contains(&"updateIssue.ts"));
assert!(tool_files.contains(&"index.ts"));
assert!(tool_files.contains(&"_runtime/mcp-bridge.ts"));
assert!(tool_files.contains(&"package.json"));
assert!(tool_files.contains(&"tsconfig.json"));
assert!(tool_files.contains(&"_meta.json"));
}
#[test]
fn test_generate_index_ts_has_no_category_grouping() {
let generator = ProgressiveGenerator::new().unwrap();
let server_info = create_test_server_info();
let code = generator.generate(&server_info).unwrap();
let index_file = code.files.iter().find(|f| f.path == "index.ts").unwrap();
assert!(
!index_file.content.contains("uncategorized"),
"generate()'s index.ts must not contain category grouping: {}",
index_file.content
);
}
#[test]
fn test_generate_tsconfig_json_allows_ts_extension_imports() {
let generator = ProgressiveGenerator::new().unwrap();
let server_info = create_test_server_info();
let code = generator.generate(&server_info).unwrap();
let tsconfig_file = code
.files
.iter()
.find(|f| f.path == "tsconfig.json")
.expect("tsconfig.json not found");
let parsed: serde_json::Value =
serde_json::from_str(&tsconfig_file.content).expect("tsconfig.json is not valid JSON");
let compiler_options = &parsed["compilerOptions"];
assert_eq!(compiler_options["allowImportingTsExtensions"], true);
assert_eq!(compiler_options["noEmit"], true);
assert_eq!(compiler_options["types"], serde_json::json!(["node"]));
}
#[test]
fn test_generate_package_json_declares_types_node_dev_dependency() {
let generator = ProgressiveGenerator::new().unwrap();
let server_info = create_test_server_info();
let code = generator.generate(&server_info).unwrap();
let package_json_file = code
.files
.iter()
.find(|f| f.path == "package.json")
.expect("package.json not found");
let parsed: serde_json::Value = serde_json::from_str(&package_json_file.content)
.expect("package.json is not valid JSON");
assert!(
parsed["devDependencies"]["@types/node"].is_string(),
"package.json is missing a @types/node devDependency: {parsed}"
);
}
#[test]
fn test_generate_meta_json_preserves_parameter_descriptions() {
let generator = ProgressiveGenerator::new().unwrap();
let server_info = create_test_server_info();
let code = generator.generate(&server_info).unwrap();
let meta_file = code.files.iter().find(|f| f.path == "_meta.json").unwrap();
let meta: ServerMetadata = serde_json::from_str(&meta_file.content).unwrap();
assert_eq!(meta.schema_version, METADATA_SCHEMA_VERSION);
assert_eq!(meta.server_id.as_str(), "test-server");
assert_eq!(meta.server_name, "Test Server");
assert_eq!(meta.server_version, "1.0.0");
assert_eq!(meta.tools.len(), 2);
let create_issue = meta
.tools
.iter()
.find(|t| t.name.as_str() == "create_issue")
.unwrap();
assert_eq!(create_issue.typescript_name, "createIssue");
let title = create_issue
.parameters
.iter()
.find(|p| p.name == "title")
.unwrap();
assert_eq!(title.description, Some("Issue title".to_string()));
assert!(title.required);
}
#[test]
fn test_generate_with_categories_meta_json_includes_categorization() {
let generator = ProgressiveGenerator::new().unwrap();
let server_info = create_test_server_info();
let mut categorizations = HashMap::new();
categorizations.insert(
"create_issue".to_string(),
ToolCategorization {
category: "issues".to_string(),
keywords: vec!["create".to_string(), "issue".to_string(), "new".to_string()],
short_description: "Create a new issue".to_string(),
},
);
let code = generator
.generate_with_categories(&server_info, &categorizations)
.unwrap();
let meta_file = code.files.iter().find(|f| f.path == "_meta.json").unwrap();
let meta: ServerMetadata = serde_json::from_str(&meta_file.content).unwrap();
let create_issue = meta
.tools
.iter()
.find(|t| t.name.as_str() == "create_issue")
.unwrap();
assert_eq!(create_issue.category, Some("issues".to_string()));
assert_eq!(
create_issue.keywords,
vec!["create".to_string(), "issue".to_string(), "new".to_string()]
);
let update_issue = meta
.tools
.iter()
.find(|t| t.name.as_str() == "update_issue")
.unwrap();
assert!(update_issue.category.is_none());
assert!(update_issue.keywords.is_empty());
}
#[test]
fn test_generate_meta_json_parameter_description_is_raw_not_jsdoc_sanitized() {
let raw_description = format!(
"Matches C-style /* */ comment blocks.\nSecond line follows. {}",
"x".repeat(300)
);
assert!(raw_description.contains("*/"));
assert!(raw_description.contains('\n'));
assert!(raw_description.chars().count() > 256);
let server_info = ServerInfo {
id: ServerId::new("test-server").unwrap(),
name: "Test Server".to_string(),
version: "1.0.0".to_string(),
tools: vec![ToolInfo {
name: ToolName::new("send_message").unwrap(),
description: "Sends a message".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"notes": {
"type": "string",
"description": raw_description
}
},
"required": []
}),
output_schema: None,
}],
capabilities: ServerCapabilities {
supports_tools: true,
supports_resources: false,
supports_prompts: false,
},
};
let generator = ProgressiveGenerator::new().unwrap();
let code = generator.generate(&server_info).unwrap();
let meta_file = code.files.iter().find(|f| f.path == "_meta.json").unwrap();
let meta: ServerMetadata = serde_json::from_str(&meta_file.content).unwrap();
let send_message = meta
.tools
.iter()
.find(|t| t.name.as_str() == "send_message")
.unwrap();
let notes = send_message
.parameters
.iter()
.find(|p| p.name == "notes")
.unwrap();
assert_eq!(notes.description, Some(raw_description.clone()));
let ts_file = code
.files
.iter()
.find(|f| f.path == "sendMessage.ts")
.unwrap();
assert!(
!ts_file.content.contains(raw_description.as_str()),
"the .ts file must not contain the raw, un-sanitized description verbatim"
);
assert!(
ts_file.content.contains("*\\/"),
"the .ts file must escape '*/' to avoid closing the JSDoc comment early"
);
assert!(
!ts_file
.content
.contains("Matches C-style /* */ comment blocks.\nSecond"),
"the .ts file must flatten newlines within the description to spaces"
);
}
#[test]
fn test_create_tool_context() {
let tool = ToolInfo {
name: ToolName::new("send_message").unwrap(),
description: "Sends a message".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"text": {"type": "string"}
},
"required": ["text"]
}),
output_schema: None,
};
let categorization = ToolCategorization {
category: "messaging".to_string(),
keywords: vec![
"send".to_string(),
"message".to_string(),
"chat".to_string(),
],
short_description: "Send a message".to_string(),
};
let properties = ProgressiveGenerator::extract_property_infos(&tool.input_schema).unwrap();
let context = ProgressiveGenerator::create_tool_context(
"test-server",
&tool,
Some(&categorization),
"sendMessage".to_string(),
properties,
);
assert_eq!(context.server_id, "test-server");
assert_eq!(context.name, "send_message");
assert_eq!(context.name_literal, "send_message");
assert_eq!(context.server_id_literal, "test-server");
assert_eq!(context.typescript_name, "sendMessage");
assert_eq!(context.description, "Sends a message");
assert_eq!(context.properties.len(), 1);
assert_eq!(context.properties[0].name, "text");
assert_eq!(context.category, Some("messaging".to_string()));
assert_eq!(context.keywords, Some("send, message, chat".to_string()));
assert_eq!(context.short_description, "Send a message".to_string());
}
#[test]
fn test_wrap_tool_generation_error_preserves_tool_name_and_source() {
let tool = ToolInfo {
name: ToolName::new("send_message").unwrap(),
description: String::new(),
input_schema: json!({}),
output_schema: None,
};
let source = Error::ValidationError {
field: "type".to_string(),
reason: "Property type is not a string".to_string(),
};
let wrapped = ProgressiveGenerator::wrap_tool_generation_error(
&tool,
"extract property schema",
source,
);
match wrapped {
Error::ScriptGenerationError {
tool: tool_name,
message,
source,
} => {
assert_eq!(tool_name, "send_message");
assert_eq!(message, "failed to extract property schema");
let source = source.expect("source must be preserved for exit-code classification");
assert!(source.to_string().contains("Property type is not a string"));
}
other => panic!("expected ScriptGenerationError, got {other:?}"),
}
}
#[test]
fn test_wrap_tool_generation_error_covers_render_and_tracking_stages() {
let tool = ToolInfo {
name: ToolName::new("send_message").unwrap(),
description: String::new(),
input_schema: json!({}),
output_schema: None,
};
let render_failure = ProgressiveGenerator::wrap_tool_generation_error(
&tool,
"render tool template",
Error::SerializationError {
message: "Template rendering failed: boom".to_string(),
source: None,
},
);
assert!(render_failure.is_script_generation_error());
let tracking_failure = ProgressiveGenerator::wrap_tool_generation_error(
&tool,
"track generated tool file",
Error::ResourceLimitExceeded {
resource: ResourceKind::GeneratedOutputSize,
actual: 10,
limit: 5,
},
);
match tracking_failure {
Error::ScriptGenerationError {
tool: tool_name,
source,
..
} => {
assert_eq!(tool_name, "send_message");
let source = source.expect("source must be preserved for exit-code classification");
assert!(
source
.downcast_ref::<Error>()
.unwrap()
.is_resource_limit_exceeded()
);
}
other => panic!("expected ScriptGenerationError, got {other:?}"),
}
}
#[test]
fn test_create_tool_context_without_categorization_falls_back_to_description() {
let generator = ProgressiveGenerator::new().unwrap();
let tool = ToolInfo {
name: ToolName::new("format_document").unwrap(),
description: "Format document with language-specific rules".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"text": {"type": "string"}
},
"required": ["text"]
}),
output_schema: None,
};
let properties = ProgressiveGenerator::extract_property_infos(&tool.input_schema).unwrap();
let context = ProgressiveGenerator::create_tool_context(
"test-server",
&tool,
None,
"formatDocument".to_string(),
properties,
);
assert_eq!(
context.short_description,
"Format document with language-specific rules".to_string()
);
let rendered = generator
.engine
.render("progressive/tool", &context)
.unwrap();
assert!(rendered.contains("@description Format document with language-specific rules"));
}
#[test]
fn test_create_tool_context_input_schema_is_sanitized() {
let tool = ToolInfo {
name: ToolName::new("send_message").unwrap(),
description: "Sends a message".to_string(),
input_schema: json!({
"type": "object",
"description": "Schema */ injected\nnext",
"properties": {
"text": {"type": "string"}
},
"required": ["text"]
}),
output_schema: None,
};
let properties = ProgressiveGenerator::extract_property_infos(&tool.input_schema).unwrap();
let context = ProgressiveGenerator::create_tool_context(
"test-server",
&tool,
None,
"sendMessage".to_string(),
properties,
);
let expected = sanitize_schema_jsdoc_descriptions(tool.input_schema);
assert_eq!(context.input_schema, expected);
assert_eq!(
context.input_schema["description"],
json!("Schema *\\/ injected next")
);
}
#[test]
fn test_create_index_context() {
let server_info = create_test_server_info();
let typescript_names = resolve_typescript_names(&server_info.tools);
let context =
ProgressiveGenerator::create_index_context(&server_info, None, &typescript_names);
assert_eq!(context.server_name, "Test Server");
assert_eq!(context.server_version, "1.0.0");
assert_eq!(context.tool_count, 2);
assert_eq!(context.tools.len(), 2);
assert_eq!(context.tools[0].typescript_name, "createIssue");
assert!(context.categories.is_none());
}
#[test]
fn test_tool_named_index_does_not_collide_with_index_ts() {
let generator = ProgressiveGenerator::new().unwrap();
let server_info = ServerInfo {
id: ServerId::new("test-server").unwrap(),
name: "Test Server".to_string(),
version: "1.0.0".to_string(),
tools: vec![ToolInfo {
name: ToolName::new("index").unwrap(),
description: "A tool literally named index".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"required": []
}),
output_schema: None,
}],
capabilities: ServerCapabilities {
supports_tools: true,
supports_resources: false,
supports_prompts: false,
},
};
let code = generator.generate(&server_info).unwrap();
let typescript_names = resolve_typescript_names(&server_info.tools);
assert_eq!(
typescript_names[0], "index_2",
"a tool named `index` must be disambiguated, not collide with the fixed index.ts"
);
let tool_file = code
.files
.iter()
.find(|f| f.path == "index_2.ts")
.expect("the tool's own file must exist at its disambiguated path");
assert!(
tool_file.content.contains("A tool literally named index"),
"the tool's own generated content must not have been lost: {}",
tool_file.content
);
let index_file = code
.files
.iter()
.find(|f| f.path == "index.ts")
.expect("the fixed index.ts re-export must still exist");
assert!(
index_file.content.contains("index_2"),
"index.ts must re-export the tool's disambiguated identifier: {}",
index_file.content
);
assert!(
index_file
.content
.contains("export { callMCPTool } from './_runtime/mcp-bridge.ts';"),
"index.ts must be the fixed re-export (with the runtime bridge re-export), \
not the overwritten tool file: {}",
index_file.content
);
assert_eq!(
code.files.iter().filter(|f| f.path == "index.ts").count(),
1
);
assert_eq!(
code.files.iter().filter(|f| f.path == "index_2.ts").count(),
1
);
}
#[test]
fn test_tool_named_index_with_different_case_is_disambiguated() {
let generator = ProgressiveGenerator::new().unwrap();
let server_info = ServerInfo {
id: ServerId::new("test-server").unwrap(),
name: "Test Server".to_string(),
version: "1.0.0".to_string(),
tools: vec![ToolInfo {
name: ToolName::new("Index").unwrap(),
description: "A tool literally named Index".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"required": []
}),
output_schema: None,
}],
capabilities: ServerCapabilities {
supports_tools: true,
supports_resources: false,
supports_prompts: false,
},
};
let code = generator.generate(&server_info).unwrap();
let typescript_names = resolve_typescript_names(&server_info.tools);
assert_eq!(
typescript_names[0], "Index_2",
"a tool named `Index` must be disambiguated case-insensitively against the \
reserved `index` output name"
);
assert!(
code.files.iter().any(|f| f.path == "Index_2.ts"),
"the tool's own file must exist at its disambiguated path: {:?}",
code.files.iter().map(|f| &f.path).collect::<Vec<_>>()
);
assert_eq!(
code.files.iter().filter(|f| f.path == "index.ts").count(),
1
);
assert!(!code.files.iter().any(|f| f.path == "Index.ts"));
}
#[test]
fn test_tools_named_index_and_capital_index_do_not_collide_with_each_other() {
let generator = ProgressiveGenerator::new().unwrap();
let server_info = ServerInfo {
id: ServerId::new("test-server").unwrap(),
name: "Test Server".to_string(),
version: "1.0.0".to_string(),
tools: vec![
ToolInfo {
name: ToolName::new("Index").unwrap(),
description: "Capitalized".to_string(),
input_schema: json!({"type": "object", "properties": {}, "required": []}),
output_schema: None,
},
ToolInfo {
name: ToolName::new("index").unwrap(),
description: "Lowercase".to_string(),
input_schema: json!({"type": "object", "properties": {}, "required": []}),
output_schema: None,
},
],
capabilities: ServerCapabilities {
supports_tools: true,
supports_resources: false,
supports_prompts: false,
},
};
let code = generator.generate(&server_info).unwrap();
let typescript_names = resolve_typescript_names(&server_info.tools);
assert_ne!(
typescript_names[0].to_ascii_lowercase(),
typescript_names[1].to_ascii_lowercase(),
"the two tools' resolved names must not collide case-insensitively: {typescript_names:?}"
);
let paths: Vec<_> = code.files.iter().map(|f| f.path.as_str()).collect();
let mut lowercased_paths: Vec<String> =
paths.iter().map(|p| p.to_ascii_lowercase()).collect();
let before = lowercased_paths.len();
lowercased_paths.sort();
lowercased_paths.dedup();
assert_eq!(
lowercased_paths.len(),
before,
"no two generated file paths may be case-insensitive duplicates of each other: {paths:?}"
);
}
#[test]
fn test_tools_differing_only_by_case_are_disambiguated_from_each_other() {
let server_info = ServerInfo {
id: ServerId::new("test-server").unwrap(),
name: "Test Server".to_string(),
version: "1.0.0".to_string(),
tools: vec![
ToolInfo {
name: ToolName::new("get_user").unwrap(),
description: "snake_case".to_string(),
input_schema: json!({"type": "object", "properties": {}, "required": []}),
output_schema: None,
},
ToolInfo {
name: ToolName::new("GetUser").unwrap(),
description: "PascalCase".to_string(),
input_schema: json!({"type": "object", "properties": {}, "required": []}),
output_schema: None,
},
],
capabilities: ServerCapabilities {
supports_tools: true,
supports_resources: false,
supports_prompts: false,
},
};
let typescript_names = resolve_typescript_names(&server_info.tools);
assert_eq!(typescript_names[0], "getUser");
assert_ne!(
typescript_names[0].to_ascii_lowercase(),
typescript_names[1].to_ascii_lowercase(),
"getUser/GetUser must not collide case-insensitively: {typescript_names:?}"
);
}
#[test]
fn test_extract_property_infos() {
let schema = json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "User name"
},
"age": {
"type": "number"
}
},
"required": ["name"]
});
let props = ProgressiveGenerator::extract_property_infos(&schema).unwrap();
assert_eq!(props.len(), 2);
let name_prop = props.iter().find(|p| p.name == "name").unwrap();
assert_eq!(name_prop.typescript_type, "string");
assert_eq!(name_prop.description, Some("User name".to_string()));
assert!(name_prop.required);
let age_prop = props.iter().find(|p| p.name == "age").unwrap();
assert_eq!(age_prop.typescript_type, "number");
assert!(!age_prop.required);
}
#[test]
fn test_extract_property_infos_sanitizes_malicious_property_name() {
let schema = json!({
"type": "object",
"properties": {
"x: string }; export const pwned = 1; interface J {": {
"type": "string",
"description": "Evil property"
}
},
"required": []
});
let props = ProgressiveGenerator::extract_property_infos(&schema).unwrap();
assert_eq!(props.len(), 1);
assert!(!props[0].name.contains(['{', '}', ';', ':', ' ']));
assert_eq!(props[0].description, Some("Evil property".to_string()));
}
#[test]
fn test_extract_property_infos_disambiguates_colliding_sibling_names() {
let schema = json!({
"type": "object",
"properties": {
"a-b": {"type": "string"},
"a.b": {"type": "number"}
},
"required": []
});
let props = ProgressiveGenerator::extract_property_infos(&schema).unwrap();
let mut names: Vec<&str> = props.iter().map(|p| p.name.as_str()).collect();
names.sort_unstable();
assert_eq!(names, vec!["a_b", "a_b_2"]);
}
#[test]
fn test_extract_property_infos_disambiguates_collision_introduced_by_collapsing() {
let schema = json!({
"type": "object",
"properties": {
"a-b": {"type": "string"},
"a--b": {"type": "number"}
},
"required": []
});
let props = ProgressiveGenerator::extract_property_infos(&schema).unwrap();
let mut names: Vec<&str> = props.iter().map(|p| p.name.as_str()).collect();
names.sort_unstable();
assert_eq!(names, vec!["a_b", "a_b_2"]);
}
#[test]
fn test_extract_property_infos_disambiguates_three_way_collision() {
let schema = json!({
"type": "object",
"properties": {
"a-b": {"type": "string"},
"a.b": {"type": "number"},
"a b": {"type": "boolean"}
},
"required": []
});
let props = ProgressiveGenerator::extract_property_infos(&schema).unwrap();
let mut names: Vec<&str> = props.iter().map(|p| p.name.as_str()).collect();
names.sort_unstable();
assert_eq!(names, vec!["a_b", "a_b_2", "a_b_3"]);
}
#[test]
fn test_generate_disambiguates_colliding_top_level_params() {
let generator = ProgressiveGenerator::new().unwrap();
let mut server_info = create_test_server_info();
server_info.tools[0].input_schema = json!({
"type": "object",
"properties": {
"a-b": {"type": "string"},
"a.b": {"type": "number"}
},
"required": []
});
let code = generator.generate(&server_info).unwrap();
let tool = code
.files
.iter()
.find(|f| f.path == "createIssue.ts")
.unwrap();
assert_eq!(
tool.content.matches("a_b:").count() + tool.content.matches("a_b?:").count(),
1,
"field 'a_b' must appear exactly once in the Params interface: {}",
tool.content
);
assert_eq!(
tool.content.matches("a_b_2:").count() + tool.content.matches("a_b_2?:").count(),
1,
"disambiguated field 'a_b_2' must appear exactly once in the Params interface: {}",
tool.content
);
}
#[test]
fn test_generate_sanitizes_property_name_injection() {
let generator = ProgressiveGenerator::new().unwrap();
let mut server_info = create_test_server_info();
server_info.tools[0].input_schema = json!({
"type": "object",
"properties": {
"x: string }; export const pwned = evil(); interface J {": {"type": "string"}
},
"required": []
});
let code = generator.generate(&server_info).unwrap();
let tool = code
.files
.iter()
.find(|f| f.path == "createIssue.ts")
.unwrap();
assert!(
!tool.content.contains("export const pwned"),
"raw property name must not inject a top-level statement: {}",
tool.content
);
}
#[test]
fn test_sanitize_jsdoc_strips_comment_terminator() {
assert_eq!(sanitize_jsdoc("Foo */ bar", 256), "Foo *\\/ bar");
}
#[test]
fn test_sanitize_jsdoc_replaces_newlines() {
assert_eq!(
sanitize_jsdoc("line1\nline2\r\nline3", 256),
"line1 line2 line3"
);
}
#[test]
fn test_sanitize_jsdoc_replaces_unicode_line_terminators() {
assert_eq!(
sanitize_jsdoc("line1\u{2028}line2\u{2029}line3", 256),
"line1 line2 line3"
);
}
#[test]
fn test_generate_with_categories_sanitizes_unicode_line_terminator_in_category() {
let generator = ProgressiveGenerator::new().unwrap();
let server_info = create_test_server_info();
let mut categorizations = HashMap::new();
categorizations.insert(
"create_issue".to_string(),
ToolCategorization {
category: "issues\u{2028}export const pwned = 1;".to_string(),
keywords: vec![],
short_description: "Create a new issue".to_string(),
},
);
let code = generator
.generate_with_categories(&server_info, &categorizations)
.unwrap();
let index = code.files.iter().find(|f| f.path == "index.ts").unwrap();
assert!(
index
.content
.contains("// --- issues export const pwned = 1; ---"),
"sanitized category text should remain inert inside the comment: {}",
index.content
);
assert!(
!index.content.contains("\nexport const pwned"),
"U+2028 must not terminate the `// --- {{category}} ---` line comment and \
inject a live top-level statement: {}",
index.content
);
}
#[test]
fn test_sanitize_jsdoc_truncates() {
let long = "a".repeat(300);
assert_eq!(sanitize_jsdoc(&long, 256).chars().count(), 256);
}
#[test]
fn test_sanitize_jsdoc_passthrough() {
assert_eq!(sanitize_jsdoc("Normal string", 256), "Normal string");
}
#[test]
fn test_sanitize_jsdoc_strips_ansi_escape_sequence() {
let payload = "Innocuous \x1b[31mred text\x1b[0m looking description";
let sanitized = sanitize_jsdoc(payload, 256);
assert!(!sanitized.contains('\x1b'));
assert_eq!(sanitized, "Innocuous [31mred text [0m looking description");
}
#[test]
fn test_sanitize_jsdoc_replaces_other_c0_control_characters_with_space() {
assert_eq!(sanitize_jsdoc("a\u{0}b\u{7}c\u{7f}d", 256), "a b c d");
}
#[test]
fn test_sanitize_jsdoc_control_char_between_star_slash_cannot_reopen_comment() {
for ctrl in ['\u{0}', '\u{7f}', '\u{1b}'] {
let payload = format!("safe *{ctrl}/ export const pwned = 1; //");
let sanitized = sanitize_jsdoc(&payload, 256);
assert!(
!sanitized.contains("*/"),
"control char {ctrl:?} must not let a bare `*/` reappear: {sanitized:?}"
);
assert!(
sanitized.contains("* /"),
"the control char should be neutralized to a space, not deleted: {sanitized:?}"
);
}
}
#[test]
fn test_sanitize_ts_string_literal_escapes_quote_and_backslash() {
assert_eq!(
sanitize_ts_string_literal(r"it's a \test"),
r"it\'s a \\test"
);
}
#[test]
fn test_sanitize_ts_string_literal_escape_order_prevents_double_escaping() {
assert_eq!(sanitize_ts_string_literal("\\'"), r"\\\'");
}
#[test]
fn test_sanitize_ts_string_literal_escapes_newlines() {
assert_eq!(
sanitize_ts_string_literal("line1\nline2\rline3"),
"line1\\nline2\\rline3"
);
}
#[test]
fn test_sanitize_ts_string_literal_escapes_unicode_line_terminators() {
assert_eq!(
sanitize_ts_string_literal("line1\u{2028}line2\u{2029}line3"),
"line1\\u2028line2\\u2029line3"
);
}
#[test]
fn test_sanitize_ts_identifier_passthrough_valid() {
assert_eq!(sanitize_ts_identifier("sendMessage_1"), "sendMessage_1");
}
#[test]
fn test_generate_sanitizes_call_site_string_literal_injection() {
let generator = ProgressiveGenerator::new().unwrap();
let mut server_info = create_test_server_info();
server_info.tools[0].name = ToolName::new("create_issue'); alert('pwned").unwrap();
let code = generator.generate(&server_info).unwrap();
let tool = code
.files
.iter()
.find(|f| {
std::path::Path::new(&f.path)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("ts"))
&& f.path != "index.ts"
})
.unwrap();
let call_site_line = tool
.content
.lines()
.find(|line| line.contains("return (await callMCPTool("))
.expect("generated tool file must contain a callMCPTool(...) invocation");
assert!(
!call_site_line.contains("'); alert('pwned"),
"raw quote must not break out of the callMCPTool string literal: {call_site_line}"
);
}
#[test]
fn test_resolve_typescript_names_disambiguates_collisions() {
let tools = vec![
ToolInfo {
name: ToolName::new("foo-bar").unwrap(),
description: String::new(),
input_schema: json!({}),
output_schema: None,
},
ToolInfo {
name: ToolName::new("foo.bar").unwrap(),
description: String::new(),
input_schema: json!({}),
output_schema: None,
},
ToolInfo {
name: ToolName::new("foo bar").unwrap(),
description: String::new(),
input_schema: json!({}),
output_schema: None,
},
];
let resolved = resolve_typescript_names(&tools);
let mut names: Vec<&String> = resolved.iter().collect();
names.sort();
assert_eq!(resolved.len(), 3);
let unique: HashSet<&String> = names.iter().copied().collect();
assert_eq!(
unique.len(),
3,
"collisions must be disambiguated: {names:?}"
);
assert_eq!(resolved[0], "foo_bar");
}
#[test]
fn test_resolve_typescript_names_disambiguates_identical_raw_names() {
let tools = vec![
ToolInfo {
name: ToolName::new("dup").unwrap(),
description: "First".to_string(),
input_schema: json!({}),
output_schema: None,
},
ToolInfo {
name: ToolName::new("dup").unwrap(),
description: "Second".to_string(),
input_schema: json!({}),
output_schema: None,
},
];
let resolved = resolve_typescript_names(&tools);
assert_eq!(resolved, vec!["dup".to_string(), "dup_2".to_string()]);
}
#[test]
fn test_resolve_typescript_names_disambiguates_three_way_identical_raw_names() {
let tools: Vec<ToolInfo> = (0..3)
.map(|_| ToolInfo {
name: ToolName::new("dup").unwrap(),
description: String::new(),
input_schema: json!({}),
output_schema: None,
})
.collect();
let resolved = resolve_typescript_names(&tools);
assert_eq!(
resolved,
vec!["dup".to_string(), "dup_2".to_string(), "dup_3".to_string()]
);
}
#[test]
fn test_resolve_typescript_names_disambiguates_reserved_words() {
let reserved_tool_names = [
"delete",
"typeof",
"class",
"new",
"import",
"export",
"in",
"instanceof",
"void",
"enum",
"eval",
"arguments",
];
for name in reserved_tool_names {
let tools = vec![ToolInfo {
name: ToolName::new(name).unwrap(),
description: String::new(),
input_schema: json!({}),
output_schema: None,
}];
let resolved = resolve_typescript_names(&tools);
let typescript_name = &resolved[0];
assert_ne!(
typescript_name, name,
"reserved word {name} must be disambiguated"
);
assert!(
!RESERVED_WORDS.contains(&typescript_name.as_str()),
"resolved name {typescript_name} for tool {name} must not be a reserved word"
);
}
}
#[test]
fn test_resolve_typescript_names_reserved_word_avoids_existing_collision() {
let tools = vec![
ToolInfo {
name: ToolName::new("class").unwrap(),
description: String::new(),
input_schema: json!({}),
output_schema: None,
},
ToolInfo {
name: ToolName::new("class-2").unwrap(),
description: String::new(),
input_schema: json!({}),
output_schema: None,
},
];
let resolved = resolve_typescript_names(&tools);
assert_ne!(
resolved[0], resolved[1],
"a reserved-word tool's fallback name must not collide with an unrelated tool that already claims it"
);
assert!(!RESERVED_WORDS.contains(&resolved[0].as_str()));
}
#[test]
fn test_resolve_typescript_names_reserved_word_case_variant_is_not_suffixed() {
let tools = vec![ToolInfo {
name: ToolName::new("Delete").unwrap(),
description: String::new(),
input_schema: json!({}),
output_schema: None,
}];
let resolved = resolve_typescript_names(&tools);
assert_eq!(resolved[0], "Delete");
}
#[test]
fn test_resolve_typescript_names_exact_reserved_word_is_still_suffixed() {
let tools = vec![ToolInfo {
name: ToolName::new("delete").unwrap(),
description: String::new(),
input_schema: json!({}),
output_schema: None,
}];
let resolved = resolve_typescript_names(&tools);
assert_ne!(resolved[0], "delete");
assert!(!RESERVED_WORDS.contains(&resolved[0].as_str()));
}
#[test]
fn test_resolve_typescript_names_delete_and_delete_case_variant_in_same_batch() {
let make_tools = |first: &str, second: &str| {
vec![
ToolInfo {
name: ToolName::new(first).unwrap(),
description: String::new(),
input_schema: json!({}),
output_schema: None,
},
ToolInfo {
name: ToolName::new(second).unwrap(),
description: String::new(),
input_schema: json!({}),
output_schema: None,
},
]
};
let delete_first = resolve_typescript_names(&make_tools("delete", "Delete"));
assert_ne!(delete_first[0], "delete");
assert_eq!(delete_first[1], "Delete");
assert_ne!(
delete_first[0].to_ascii_lowercase(),
delete_first[1].to_ascii_lowercase()
);
let delete_second = resolve_typescript_names(&make_tools("Delete", "delete"));
assert_eq!(delete_second[0], "Delete");
assert_ne!(delete_second[1], "delete");
assert_ne!(
delete_second[0].to_ascii_lowercase(),
delete_second[1].to_ascii_lowercase()
);
}
#[test]
fn test_resolve_typescript_names_collapses_non_ascii_run() {
let tools = vec![ToolInfo {
name: ToolName::new("café_menu_日本語").unwrap(),
description: String::new(),
input_schema: json!({}),
output_schema: None,
}];
let resolved = resolve_typescript_names(&tools);
assert_eq!(resolved[0], "caf_Menu_");
}
#[test]
fn test_resolve_typescript_names_disambiguates_collision_introduced_by_collapsing() {
let tools = vec![
ToolInfo {
name: ToolName::new("a-b").unwrap(),
description: String::new(),
input_schema: json!({}),
output_schema: None,
},
ToolInfo {
name: ToolName::new("a--b").unwrap(),
description: String::new(),
input_schema: json!({}),
output_schema: None,
},
];
let resolved = resolve_typescript_names(&tools);
assert_eq!(resolved, vec!["a_b", "a_b_2"]);
}
#[test]
fn test_generate_sanitizes_reserved_word_tool_name() {
let generator = ProgressiveGenerator::new().unwrap();
let mut server_info = create_test_server_info();
server_info.tools = vec![ToolInfo {
name: ToolName::new("delete").unwrap(),
description: "Delete something".to_string(),
input_schema: json!({}),
output_schema: None,
}];
let code = generator.generate(&server_info).unwrap();
let tool_file = code.files.iter().find(|f| f.path == "delete_2.ts").unwrap();
assert!(!tool_file.content.contains("export async function delete("));
assert!(
tool_file
.content
.contains("export async function delete_2(")
);
}
#[test]
fn test_generate_disambiguates_colliding_tool_names() {
let generator = ProgressiveGenerator::new().unwrap();
let mut server_info = create_test_server_info();
server_info.tools = vec![
ToolInfo {
name: ToolName::new("foo-bar").unwrap(),
description: "First".to_string(),
input_schema: json!({}),
output_schema: None,
},
ToolInfo {
name: ToolName::new("foo.bar").unwrap(),
description: "Second".to_string(),
input_schema: json!({}),
output_schema: None,
},
];
let code = generator.generate(&server_info).unwrap();
let tool_files: Vec<&str> = code
.files
.iter()
.filter(|f| f.path == "foo_bar.ts" || f.path == "foo_bar_2.ts")
.map(|f| f.path.as_str())
.collect();
assert_eq!(
tool_files.len(),
2,
"colliding names must not overwrite each other's file: {tool_files:?}"
);
let index = code.files.iter().find(|f| f.path == "index.ts").unwrap();
assert_eq!(
index.content.matches("export { foo_bar,").count(),
1,
"index.ts must export the first tool's identifier exactly once"
);
assert_eq!(
index.content.matches("export { foo_bar_2,").count(),
1,
"index.ts must export the disambiguated second identifier exactly once"
);
}
#[test]
fn test_generate_disambiguates_identical_raw_tool_names() {
let generator = ProgressiveGenerator::new().unwrap();
let mut server_info = create_test_server_info();
server_info.tools = vec![
ToolInfo {
name: ToolName::new("dup").unwrap(),
description: "First".to_string(),
input_schema: json!({}),
output_schema: None,
},
ToolInfo {
name: ToolName::new("dup").unwrap(),
description: "Second".to_string(),
input_schema: json!({}),
output_schema: None,
},
];
let code = generator.generate(&server_info).unwrap();
let dup_files: Vec<&str> = code
.files
.iter()
.filter(|f| f.path == "dup.ts" || f.path == "dup_2.ts")
.map(|f| f.path.as_str())
.collect();
assert_eq!(
dup_files.len(),
2,
"identical raw tool names must not overwrite each other's file: {dup_files:?}"
);
let index = code.files.iter().find(|f| f.path == "index.ts").unwrap();
assert_eq!(
index.content.matches("export { dup,").count(),
1,
"index.ts must export the first tool's identifier exactly once"
);
assert_eq!(
index.content.matches("export { dup_2,").count(),
1,
"index.ts must export the disambiguated second identifier exactly once"
);
}
#[test]
fn test_sanitize_schema_jsdoc_drops_non_string_descriptions() {
let sanitized = sanitize_schema_jsdoc_descriptions(json!({
"type": "object",
"description": {"text": "Schema */ injected\nnext"},
"properties": {
"title": {
"type": "string",
"description": ["Title */ injected\nnext"]
}
}
}));
assert!(sanitized["description"].is_null());
assert!(sanitized["properties"]["title"]["description"].is_null());
}
#[test]
fn test_sanitize_schema_jsdoc_recurses_into_array_items() {
let sanitized = sanitize_schema_jsdoc_descriptions(json!({
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": [
{
"type": "string",
"description": "Tag */ injected\nnext"
}
]
}
}
}));
let description = sanitized["properties"]["tags"]["items"][0]["description"]
.as_str()
.unwrap();
assert_eq!(description, "Tag *\\/ injected next");
}
fn nested_array_schema_with_descriptions(depth: usize, description: &str) -> serde_json::Value {
let mut schema = json!({"type": "string", "description": description});
for _ in 0..depth {
let mut map = serde_json::Map::new();
map.insert(
"type".to_string(),
serde_json::Value::String("array".to_string()),
);
map.insert("items".to_string(), schema);
map.insert(
"description".to_string(),
serde_json::Value::String(description.to_string()),
);
schema = serde_json::Value::Object(map);
}
schema
}
fn nth_level(value: &serde_json::Value, depth: usize) -> serde_json::Value {
let mut v = value.clone();
for _ in 0..depth {
v = v["items"].clone();
}
v
}
#[test]
fn test_sanitize_schema_jsdoc_bounds_deeply_nested_schema() {
const MALICIOUS: &str = "desc */ injected\nnext";
let depth = MAX_SCHEMA_RECURSION_DEPTH + 10;
let schema = nested_array_schema_with_descriptions(depth, MALICIOUS);
let sanitized = sanitize_schema_jsdoc_descriptions(schema);
let just_below_cap = nth_level(&sanitized, MAX_SCHEMA_RECURSION_DEPTH - 1);
let below_description = just_below_cap["description"]
.as_str()
.expect("description below the cap must still be a string");
assert!(
!below_description.contains("*/"),
"description one level below the cap must be sanitized: {below_description}"
);
let at_cap = nth_level(&sanitized, MAX_SCHEMA_RECURSION_DEPTH);
let at_cap_description = at_cap["description"]
.as_str()
.expect("description at the cap must still be a string");
assert_eq!(
at_cap_description, MALICIOUS,
"description at the cap must be left untouched — the function must stop \
recursing before reaching it"
);
}
#[test]
fn test_sanitize_schema_jsdoc_survives_pathologically_deep_input() {
std::thread::Builder::new()
.stack_size(64 * 1024 * 1024)
.spawn(|| {
let schema = nested_array_schema_with_descriptions(5_000, "leaf");
let sanitized = sanitize_schema_jsdoc_descriptions(schema);
assert!(sanitized.is_object());
})
.expect("spawn test thread")
.join()
.expect("test thread panicked");
}
#[test]
fn test_sanitize_jsdoc_truncation_boundary_injection() {
let max_len = 256;
let payload = format!("{}*/{}", "a".repeat(max_len - 1), "trailer");
let sanitized = sanitize_jsdoc(&payload, max_len);
assert!(
!sanitized.contains("*/"),
"truncation must not re-open the JSDoc comment: {sanitized}"
);
assert_eq!(sanitized.chars().count(), max_len);
}
#[test]
fn test_generate_runtime_bridge_declares_forbidden_env_var_list() {
let generator = ProgressiveGenerator::new().unwrap();
let server_info = create_test_server_info();
let code = generator.generate(&server_info).unwrap();
let bridge = code
.files
.iter()
.find(|f| f.path == "_runtime/mcp-bridge.ts")
.unwrap();
for forbidden_env in mcp_execution_core::forbidden_env_names() {
assert!(
bridge.content.contains(&format!("'{forbidden_env}'")),
"runtime bridge must list forbidden env var {forbidden_env}: {}",
bridge.content
);
}
for forbidden_char in mcp_execution_core::forbidden_chars() {
let escaped = sanitize_ts_string_literal(&forbidden_char.to_string());
assert!(
bridge.content.contains(&format!("'{escaped}'")),
"runtime bridge must list forbidden char {forbidden_char:?}: {}",
bridge.content
);
}
assert!(
bridge
.content
.contains(mcp_execution_core::forbidden_env_prefix()),
"runtime bridge must reference the forbidden env prefix: {}",
bridge.content
);
}
#[test]
fn test_generate_preserves_benign_punctuation() {
let generator = ProgressiveGenerator::new().unwrap();
let mut server_info = create_test_server_info();
server_info.tools[0].description =
"Compares values: a < b && b > c, or use \"quotes\" & don't forget 'em".to_string();
let code = generator.generate(&server_info).unwrap();
let tool = code
.files
.iter()
.find(|f| f.path == "createIssue.ts")
.unwrap();
assert!(
tool.content
.contains("a < b && b > c, or use \"quotes\" & don't forget 'em"),
"benign punctuation must survive verbatim, not be HTML-escaped: {}",
tool.content
);
for entity in ["<", ">", "&", """, "'", "'"] {
assert!(
!tool.content.contains(entity),
"output must not contain HTML entity {entity}: {}",
tool.content
);
}
}
#[test]
fn test_generate_sanitizes_jsdoc_injection() {
let generator = ProgressiveGenerator::new().unwrap();
let mut server_info = create_test_server_info();
server_info.name = "Evil */ injection".to_string();
server_info.version = "1.0\n<script>".to_string();
let code = generator.generate(&server_info).unwrap();
let index = code.files.iter().find(|f| f.path == "index.ts").unwrap();
assert!(
!index.content.contains("Evil */ injection"),
"Server name should be sanitized in JSDoc"
);
assert!(
!index.content.contains("1.0\n<script>"),
"Server version should have newlines stripped"
);
}
#[test]
fn test_generate_sanitizes_schema_and_category_jsdoc_injection() {
let generator = ProgressiveGenerator::new().unwrap();
let mut server_info = create_test_server_info();
server_info.tools[0].input_schema = json!({
"type": "object",
"description": "Schema */ injected\nnext",
"properties": {
"title": {
"type": "string",
"description": "Title */ injected\nnext"
}
},
"required": ["title"]
});
let mut categorizations = HashMap::new();
categorizations.insert(
"create_issue".to_string(),
ToolCategorization {
category: "issues */ injected\nnext".to_string(),
keywords: vec!["create,*/ injected\nnext".to_string()],
short_description: "Create */ injected\nnext".to_string(),
},
);
let code = generator
.generate_with_categories(&server_info, &categorizations)
.unwrap();
let tool = code
.files
.iter()
.find(|f| f.path == "createIssue.ts")
.unwrap();
for raw in [
"Schema */ injected",
"Title */ injected",
"issues */ injected",
"create,*/ injected",
"Create */ injected",
] {
assert!(
!tool.content.contains(raw),
"generated JSDoc should not contain raw injection text: {raw}"
);
}
assert!(tool.content.contains("Schema *\\/ injected next"));
assert!(tool.content.contains("Title *\\/ injected next"));
assert!(tool.content.contains("issues *\\/ injected next"));
assert!(tool.content.contains("create,*\\/ injected next"));
assert!(tool.content.contains("Create *\\/ injected next"));
}
fn server_info_with_tool_count(count: usize) -> ServerInfo {
ServerInfo {
id: ServerId::new("bulk-server").unwrap(),
name: "Bulk Server".to_string(),
version: "1.0.0".to_string(),
tools: (0..count)
.map(|i| ToolInfo {
name: ToolName::new(format!("tool{i}")).unwrap(),
description: String::new(),
input_schema: json!({}),
output_schema: None,
})
.collect(),
capabilities: ServerCapabilities {
supports_tools: true,
supports_resources: false,
supports_prompts: false,
},
}
}
#[test]
fn test_generate_rejects_tool_count_that_would_exceed_max_generated_files() {
let server_info = server_info_with_tool_count(MAX_GENERATED_FILES - FIXED_FILE_COUNT + 1);
let generator = ProgressiveGenerator::new().unwrap();
let result = generator.generate(&server_info);
assert!(result.is_err());
assert!(result.unwrap_err().is_resource_limit_exceeded());
}
#[test]
fn test_generate_accepts_tool_count_at_exact_max_generated_files() {
let server_info = server_info_with_tool_count(MAX_GENERATED_FILES - FIXED_FILE_COUNT);
let generator = ProgressiveGenerator::new().unwrap();
let code = generator.generate(&server_info).unwrap();
assert_eq!(code.file_count(), MAX_GENERATED_FILES);
}
#[test]
fn test_generate_with_categories_rejects_tool_count_that_would_exceed_max_generated_files() {
let server_info = server_info_with_tool_count(MAX_GENERATED_FILES - FIXED_FILE_COUNT + 1);
let generator = ProgressiveGenerator::new().unwrap();
let result = generator.generate_with_categories(&server_info, &HashMap::new());
assert!(result.is_err());
assert!(result.unwrap_err().is_resource_limit_exceeded());
}
#[test]
fn test_add_tracked_rejects_oversized_total_bytes() {
let mut code = GeneratedCode::new();
let mut total_bytes = 0usize;
let result = add_tracked(
&mut code,
&mut total_bytes,
GeneratedFile {
path: "big.ts".to_string(),
content: "a".repeat(MAX_GENERATED_BYTES + 1),
},
);
assert!(result.is_err());
assert!(result.unwrap_err().is_resource_limit_exceeded());
}
#[test]
fn test_add_tracked_accepts_total_bytes_at_exact_max() {
let mut code = GeneratedCode::new();
let mut total_bytes = 0usize;
let result = add_tracked(
&mut code,
&mut total_bytes,
GeneratedFile {
path: "big.ts".to_string(),
content: "a".repeat(MAX_GENERATED_BYTES),
},
);
assert!(result.is_ok());
}
#[test]
fn test_add_tracked_rejects_immediately_once_running_total_exceeds_max() {
let mut code = GeneratedCode::new();
let mut total_bytes = MAX_GENERATED_BYTES - 1;
let result = add_tracked(
&mut code,
&mut total_bytes,
GeneratedFile {
path: "second.ts".to_string(),
content: "ab".to_string(),
},
);
assert!(result.is_err());
assert!(result.unwrap_err().is_resource_limit_exceeded());
assert_eq!(code.file_count(), 0);
}
}