1use crate::common::types::{GeneratedCode, GeneratedFile};
34use crate::common::typescript::{
35 MAX_SCHEMA_RECURSION_DEPTH, disambiguate_identifier, extract_properties,
36 sanitize_ts_identifier, to_camel_case,
37};
38use crate::progressive::types::{
39 BridgeContext, CategoryInfo, IndexContext, PropertyInfo, ToolCategorization, ToolContext,
40 ToolSummary,
41};
42use crate::template_engine::TemplateEngine;
43use mcp_execution_core::ResourceKind;
44use mcp_execution_core::metadata::{
45 INDEX_FILE_NAME, METADATA_FILE_NAME, METADATA_SCHEMA_VERSION, ParameterMetadata,
46 ServerMetadata, ToolMetadata,
47};
48use mcp_execution_core::provenance::{GenerationProvenance, ToolDigestEntry};
49use mcp_execution_core::{Error, Result, ServerConfig};
50use mcp_execution_introspector::{ServerInfo, ToolInfo};
51use std::collections::{HashMap, HashSet};
52
53const FIXED_FILE_COUNT: usize = 5;
57
58pub const MAX_GENERATED_FILES: usize =
78 mcp_execution_introspector::MAX_TOOL_COUNT + FIXED_FILE_COUNT;
79
80pub const MAX_GENERATED_BYTES: usize = 2
99 * mcp_execution_introspector::MAX_TOOL_COUNT
100 * (mcp_execution_introspector::MAX_TOOL_NAME_LEN
101 + mcp_execution_introspector::MAX_TOOL_DESCRIPTION_LEN
102 + mcp_execution_introspector::MAX_SCHEMA_SIZE_BYTES);
103
104const PACKAGE_JSON: &str = "{\"type\":\"module\",\"devDependencies\":{\"@types/node\":\"^22\"}}\n";
113
114const TSCONFIG_JSON: &str = r#"{
154 "compilerOptions": {
155 "target": "ES2022",
156 "module": "NodeNext",
157 "moduleResolution": "NodeNext",
158 "strict": true,
159 "noEmit": true,
160 "allowImportingTsExtensions": true,
161 "skipLibCheck": true,
162 "types": ["node"]
163 },
164 "include": ["**/*.ts"]
165}
166"#;
167
168#[derive(Debug)]
185pub struct ProgressiveGenerator<'a> {
186 engine: TemplateEngine<'a>,
187}
188
189impl ProgressiveGenerator<'_> {
190 pub fn new() -> Result<Self> {
208 let engine = TemplateEngine::new()?;
209 Ok(Self { engine })
210 }
211
212 pub fn generate(
275 &self,
276 server_info: &ServerInfo,
277 server_config: &ServerConfig,
278 ) -> Result<GeneratedCode> {
279 self.generate_with_categories(server_info, server_config, &HashMap::new())
280 }
281
282 #[tracing::instrument(
339 skip_all,
340 fields(server_id = %server_info.id, tool_count = server_info.tools.len())
341 )]
342 pub fn generate_with_categories(
343 &self,
344 server_info: &ServerInfo,
345 server_config: &ServerConfig,
346 categorizations: &HashMap<String, ToolCategorization>,
347 ) -> Result<GeneratedCode> {
348 if categorizations.is_empty() {
349 tracing::info!(
350 "Generating progressive loading code for server: {}",
351 server_info.name
352 );
353 } else {
354 tracing::info!(
355 "Generating progressive loading code with categorizations for server: {}",
356 server_info.name
357 );
358 }
359
360 enforce_tool_count_bound(server_info)?;
361
362 let mut code = GeneratedCode::new();
363 let mut total_bytes = 0usize;
364 let typescript_names = resolve_typescript_names(&server_info.tools);
365
366 let tool_metadata = self.emit_tool_files(
367 server_info,
368 categorizations,
369 &typescript_names,
370 &mut code,
371 &mut total_bytes,
372 )?;
373
374 self.emit_index_file(
375 server_info,
376 categorizations,
377 &typescript_names,
378 &mut code,
379 &mut total_bytes,
380 )?;
381
382 self.emit_scaffolding_files(&mut code, &mut total_bytes)?;
383
384 add_tracked(
386 &mut code,
387 &mut total_bytes,
388 Self::create_metadata_file(server_info, server_config, tool_metadata)?,
389 )?;
390
391 tracing::debug!("Generated {}", METADATA_FILE_NAME);
392
393 if categorizations.is_empty() {
394 tracing::info!(
395 "Successfully generated {} files for {} (progressive loading)",
396 code.file_count(),
397 server_info.name
398 );
399 } else {
400 tracing::info!(
401 "Successfully generated {} files for {} with categorizations (progressive loading)",
402 code.file_count(),
403 server_info.name
404 );
405 }
406
407 Ok(code)
408 }
409
410 fn emit_tool_files(
414 &self,
415 server_info: &ServerInfo,
416 categorizations: &HashMap<String, ToolCategorization>,
417 typescript_names: &[String],
418 code: &mut GeneratedCode,
419 total_bytes: &mut usize,
420 ) -> Result<Vec<ToolMetadata>> {
421 let server_id = server_info.id.as_str();
422 let mut tool_metadata = Vec::with_capacity(server_info.tools.len());
423
424 for (idx, tool) in server_info.tools.iter().enumerate() {
425 let tool_name = tool.name.as_str();
426 let categorization = categorizations.get(tool_name);
427 let typescript_name = typescript_names.get(idx).cloned().unwrap_or_default();
428 let extracted_properties =
429 Self::extract_property_data(&tool.input_schema).map_err(|source| {
430 Self::wrap_tool_generation_error(tool, "extract property schema", source)
431 })?;
432 let properties_for_context = extracted_properties
433 .iter()
434 .map(|(info, _)| info.clone())
435 .collect();
436 let tool_context = Self::create_tool_context(
437 server_id,
438 tool,
439 categorization,
440 typescript_name.clone(),
441 properties_for_context,
442 );
443 let tool_code = self
444 .engine
445 .render("progressive/tool", &tool_context)
446 .map_err(|source| {
447 Self::wrap_tool_generation_error(tool, "render tool template", source)
448 })?;
449
450 add_tracked(
451 code,
452 total_bytes,
453 GeneratedFile {
454 path: format!("{}.ts", tool_context.typescript_name),
455 content: tool_code,
456 },
457 )
458 .map_err(|source| {
459 Self::wrap_tool_generation_error(tool, "track generated tool file", source)
460 })?;
461
462 tracing::debug!(
463 "Generated tool file: {}.ts (category: {:?})",
464 tool_context.typescript_name,
465 categorization.map(|c| &c.category)
466 );
467
468 tool_metadata.push(Self::create_tool_metadata(
469 tool,
470 categorization,
471 typescript_name,
472 extracted_properties,
473 ));
474 }
475
476 Ok(tool_metadata)
477 }
478
479 fn emit_index_file(
481 &self,
482 server_info: &ServerInfo,
483 categorizations: &HashMap<String, ToolCategorization>,
484 typescript_names: &[String],
485 code: &mut GeneratedCode,
486 total_bytes: &mut usize,
487 ) -> Result<()> {
488 let index_context =
489 Self::create_index_context(server_info, Some(categorizations), typescript_names);
490 let index_code = self.engine.render("progressive/index", &index_context)?;
491
492 add_tracked(
493 code,
494 total_bytes,
495 GeneratedFile {
496 path: INDEX_FILE_NAME.to_string(),
497 content: index_code,
498 },
499 )?;
500
501 tracing::debug!(
502 "Generated index.ts with {} categorizations",
503 categorizations.len()
504 );
505
506 Ok(())
507 }
508
509 fn emit_scaffolding_files(
512 &self,
513 code: &mut GeneratedCode,
514 total_bytes: &mut usize,
515 ) -> Result<()> {
516 let bridge_context = BridgeContext::default();
518 let bridge_code = self
519 .engine
520 .render("progressive/runtime-bridge", &bridge_context)?;
521
522 add_tracked(
523 code,
524 total_bytes,
525 GeneratedFile {
526 path: "_runtime/mcp-bridge.ts".to_string(),
527 content: bridge_code,
528 },
529 )?;
530
531 tracing::debug!("Generated _runtime/mcp-bridge.ts");
532
533 add_tracked(
536 code,
537 total_bytes,
538 GeneratedFile {
539 path: "package.json".to_string(),
540 content: PACKAGE_JSON.to_string(),
541 },
542 )?;
543
544 tracing::debug!("Generated package.json");
545
546 add_tracked(
549 code,
550 total_bytes,
551 GeneratedFile {
552 path: "tsconfig.json".to_string(),
553 content: TSCONFIG_JSON.to_string(),
554 },
555 )?;
556
557 tracing::debug!("Generated tsconfig.json");
558
559 Ok(())
560 }
561
562 fn create_tool_context(
580 server_id: &str,
581 tool: &mcp_execution_introspector::ToolInfo,
582 categorization: Option<&ToolCategorization>,
583 typescript_name: String,
584 properties: Vec<PropertyInfo>,
585 ) -> ToolContext {
586 let description = sanitize_jsdoc(&tool.description, 256);
587 let short_description = categorization.map_or_else(
590 || description.clone(),
591 |c| sanitize_jsdoc(&c.short_description, 256),
592 );
593
594 ToolContext {
595 server_id: sanitize_jsdoc(server_id, 256),
596 name: sanitize_jsdoc(tool.name.as_str(), 256),
597 name_literal: sanitize_ts_string_literal(tool.name.as_str()),
598 server_id_literal: sanitize_ts_string_literal(server_id),
599 typescript_name,
600 description,
601 input_schema: sanitize_schema_jsdoc_descriptions(tool.input_schema.clone()),
602 properties,
603 category: categorization.map(|c| sanitize_jsdoc(&c.category, 128)),
604 keywords: categorization.map(|c| render_keywords_for_jsdoc(&c.keywords)),
605 short_description,
606 }
607 }
608
609 fn create_index_context(
618 server_info: &ServerInfo,
619 categorizations: Option<&HashMap<String, ToolCategorization>>,
620 typescript_names: &[String],
621 ) -> IndexContext {
622 let tools: Vec<ToolSummary> = server_info
623 .tools
624 .iter()
625 .enumerate()
626 .map(|(idx, tool)| {
627 let tool_name = tool.name.as_str();
628 let cat = categorizations.and_then(|c| c.get(tool_name));
629 ToolSummary {
630 typescript_name: typescript_names.get(idx).cloned().unwrap_or_default(),
631 description: sanitize_jsdoc(&tool.description, 256),
632 category: cat.map(|c| sanitize_jsdoc(&c.category, 128)),
633 keywords: cat.map(|c| render_keywords_for_jsdoc(&c.keywords)),
634 short_description: cat.map(|c| sanitize_jsdoc(&c.short_description, 256)),
635 }
636 })
637 .collect();
638
639 let category_groups = categorizations.filter(|c| !c.is_empty()).map(|_| {
645 let mut groups: HashMap<String, Vec<ToolSummary>> = HashMap::new();
646
647 for tool in &tools {
648 let cat_name = tool
649 .category
650 .clone()
651 .unwrap_or_else(|| "uncategorized".to_string());
652 groups.entry(cat_name).or_default().push(tool.clone());
653 }
654
655 let mut result: Vec<CategoryInfo> = groups
656 .into_iter()
657 .map(|(name, tools)| CategoryInfo { name, tools })
658 .collect();
659
660 result.sort_by(|a, b| {
662 if a.name == "uncategorized" {
663 std::cmp::Ordering::Greater
664 } else if b.name == "uncategorized" {
665 std::cmp::Ordering::Less
666 } else {
667 a.name.cmp(&b.name)
668 }
669 });
670
671 result
672 });
673
674 IndexContext {
675 server_name: sanitize_jsdoc(&server_info.name, 256),
676 server_version: sanitize_jsdoc(&server_info.version, 64),
677 tool_count: server_info.tools.len(),
678 tools,
679 categories: category_groups,
680 }
681 }
682
683 fn wrap_tool_generation_error(tool: &ToolInfo, stage: &str, source: Error) -> Error {
696 Error::ScriptGenerationError {
697 tool: tool.name.as_str().to_string(),
698 message: format!("failed to {stage}"),
699 source: Some(Box::new(source)),
700 }
701 }
702
703 #[cfg(test)]
721 fn extract_property_infos(schema: &serde_json::Value) -> Result<Vec<PropertyInfo>> {
722 Ok(Self::extract_property_data(schema)?
723 .into_iter()
724 .map(|(info, _raw_description)| info)
725 .collect())
726 }
727
728 fn extract_property_data(
748 schema: &serde_json::Value,
749 ) -> Result<Vec<(PropertyInfo, Option<String>)>> {
750 let raw_properties = extract_properties(schema);
751
752 let mut properties = Vec::new();
753 let mut used_names = HashSet::new();
754 for prop in raw_properties {
755 let raw_name = prop["name"]
756 .as_str()
757 .ok_or_else(|| Error::ValidationError {
758 field: "name".to_string(),
759 reason: "Property name is not a string".to_string(),
760 })?
761 .to_string();
762
763 let typescript_type = prop["type"]
764 .as_str()
765 .ok_or_else(|| Error::ValidationError {
766 field: "type".to_string(),
767 reason: "Property type is not a string".to_string(),
768 })?
769 .to_string();
770
771 let required = prop["required"].as_bool().unwrap_or(false);
772
773 let raw_description = schema.as_object().and_then(|obj| {
776 obj.get("properties")
777 .and_then(|props| props.as_object())
778 .and_then(|props| props.get(&raw_name))
779 .and_then(|prop_schema| prop_schema.as_object())
780 .and_then(|obj| obj.get("description"))
781 .and_then(|desc| desc.as_str())
782 .map(str::to_string)
783 });
784 let description = raw_description
785 .as_deref()
786 .map(|desc| sanitize_jsdoc(desc, 256));
787
788 let base_name = sanitize_ts_identifier(&raw_name);
789 properties.push((
790 PropertyInfo {
791 name: disambiguate_identifier(&base_name, &mut used_names),
792 typescript_type,
793 description,
794 required,
795 },
796 raw_description,
797 ));
798 }
799
800 Ok(properties)
801 }
802
803 fn create_tool_metadata(
823 tool: &ToolInfo,
824 categorization: Option<&ToolCategorization>,
825 typescript_name: String,
826 properties: Vec<(PropertyInfo, Option<String>)>,
827 ) -> ToolMetadata {
828 let description = (!tool.description.is_empty()).then(|| tool.description.clone());
829 let category = categorization.map(|c| c.category.clone());
830 let keywords = categorization.map_or_else(Vec::new, |c| c.keywords.clone());
831
832 ToolMetadata {
833 name: tool.name.clone(),
834 typescript_name,
835 category,
836 keywords,
837 description,
838 parameters: properties
839 .into_iter()
840 .map(|(p, raw_description)| ParameterMetadata {
841 name: p.name,
842 typescript_type: p.typescript_type,
843 required: p.required,
844 description: raw_description,
845 })
846 .collect(),
847 }
848 }
849
850 fn create_metadata_file(
862 server_info: &ServerInfo,
863 server_config: &ServerConfig,
864 tools: Vec<ToolMetadata>,
865 ) -> Result<GeneratedFile> {
866 let digest_entries: Vec<ToolDigestEntry<'_>> = server_info
867 .tools
868 .iter()
869 .map(|tool| ToolDigestEntry {
870 name: tool.name.as_str(),
871 description: &tool.description,
872 input_schema: &tool.input_schema,
873 output_schema: tool.output_schema.as_ref(),
874 })
875 .collect();
876 let provenance = GenerationProvenance::capture(server_config, &digest_entries);
877
878 let meta = ServerMetadata {
879 schema_version: METADATA_SCHEMA_VERSION,
880 server_id: server_info.id.clone(),
881 server_name: server_info.name.clone(),
882 server_version: server_info.version.clone(),
883 tools,
884 provenance,
885 };
886
887 let content =
888 serde_json::to_string_pretty(&meta).map_err(|e| Error::SerializationError {
889 message: format!("failed to serialize {METADATA_FILE_NAME}"),
890 source: Some(e),
891 })?;
892
893 Ok(GeneratedFile {
894 path: METADATA_FILE_NAME.to_string(),
895 content,
896 })
897 }
898}
899
900fn enforce_tool_count_bound(server_info: &ServerInfo) -> Result<()> {
916 let projected_file_count = server_info.tools.len() + FIXED_FILE_COUNT;
917 if projected_file_count > MAX_GENERATED_FILES {
918 return Err(Error::ResourceLimitExceeded {
919 resource: ResourceKind::ToolCount {
920 server_id: server_info.id.clone(),
921 },
922 actual: server_info.tools.len(),
923 limit: MAX_GENERATED_FILES - FIXED_FILE_COUNT,
924 });
925 }
926 Ok(())
927}
928
929fn add_tracked(
948 code: &mut GeneratedCode,
949 total_bytes: &mut usize,
950 file: GeneratedFile,
951) -> Result<()> {
952 *total_bytes += file.content.len();
953 if *total_bytes > MAX_GENERATED_BYTES {
954 return Err(Error::ResourceLimitExceeded {
955 resource: ResourceKind::GeneratedOutputSize,
956 actual: *total_bytes,
957 limit: MAX_GENERATED_BYTES,
958 });
959 }
960
961 code.add_file(file)?;
962
963 if code.file_count() > MAX_GENERATED_FILES {
964 return Err(Error::ResourceLimitExceeded {
965 resource: ResourceKind::GeneratedFileCount,
966 actual: code.file_count(),
967 limit: MAX_GENERATED_FILES,
968 });
969 }
970
971 Ok(())
972}
973
974fn sanitize_jsdoc(s: &str, max_len: usize) -> String {
989 let neutralized = mcp_execution_core::untrusted::sanitize_untrusted_text(s, usize::MAX);
990 let sanitized = neutralized.replace("*/", "*\\/");
991 if sanitized.chars().count() > max_len {
992 sanitized.chars().take(max_len).collect()
993 } else {
994 sanitized
995 }
996}
997
998fn render_keywords_for_jsdoc(keywords: &[String]) -> String {
1002 sanitize_jsdoc(&keywords.join(", "), 256)
1003}
1004
1005pub(crate) fn sanitize_ts_string_literal(s: &str) -> String {
1043 use mcp_execution_core::untrusted::{MAX_UNTRUSTED_FIELD_LEN, sanitize_untrusted_text};
1044
1045 let truncated: String = if s.chars().count() > MAX_UNTRUSTED_FIELD_LEN {
1046 s.chars().take(MAX_UNTRUSTED_FIELD_LEN).collect()
1047 } else {
1048 s.to_string()
1049 };
1050 let escaped = truncated
1051 .replace('\\', "\\\\")
1052 .replace('\'', "\\'")
1053 .replace('\r', "\\r")
1054 .replace('\n', "\\n")
1055 .replace('\u{2028}', "\\u2028")
1056 .replace('\u{2029}', "\\u2029");
1057 sanitize_untrusted_text(&escaped, usize::MAX)
1058}
1059
1060const RESERVED_WORDS: &[&str] = &[
1066 "arguments",
1067 "await",
1068 "break",
1069 "case",
1070 "catch",
1071 "class",
1072 "const",
1073 "continue",
1074 "debugger",
1075 "default",
1076 "delete",
1077 "do",
1078 "else",
1079 "enum",
1080 "eval",
1081 "export",
1082 "extends",
1083 "false",
1084 "finally",
1085 "for",
1086 "function",
1087 "if",
1088 "implements",
1089 "import",
1090 "in",
1091 "instanceof",
1092 "interface",
1093 "let",
1094 "new",
1095 "null",
1096 "package",
1097 "private",
1098 "protected",
1099 "public",
1100 "return",
1101 "static",
1102 "super",
1103 "switch",
1104 "this",
1105 "throw",
1106 "true",
1107 "try",
1108 "typeof",
1109 "var",
1110 "void",
1111 "while",
1112 "with",
1113 "yield",
1114];
1115
1116const RESERVED_OUTPUT_NAMES: &[&str] = &["index"];
1124
1125fn resolve_typescript_names(tools: &[ToolInfo]) -> Vec<String> {
1154 let mut used_lower: HashSet<String> = RESERVED_OUTPUT_NAMES
1155 .iter()
1156 .map(|&s| s.to_ascii_lowercase())
1157 .collect();
1158 let mut resolved = Vec::with_capacity(tools.len());
1159
1160 for tool in tools {
1161 let base = sanitize_ts_identifier(&to_camel_case(tool.name.as_str()));
1162 resolved.push(disambiguate_output_filename(&base, &mut used_lower));
1163 }
1164
1165 resolved
1166}
1167
1168fn disambiguate_output_filename(base: &str, used_lower: &mut HashSet<String>) -> String {
1182 let mut candidate = base.to_string();
1183 let mut suffix = 2;
1184 loop {
1185 let is_reserved_word = RESERVED_WORDS.contains(&candidate.as_str());
1186 if !is_reserved_word && used_lower.insert(candidate.to_ascii_lowercase()) {
1187 return candidate;
1188 }
1189 candidate = format!("{base}_{suffix}");
1190 suffix += 1;
1191 }
1192}
1193
1194fn sanitize_schema_jsdoc_descriptions(mut value: serde_json::Value) -> serde_json::Value {
1195 let mut cap_hit = false;
1196 sanitize_schema_jsdoc_value(&mut value, 0, &mut cap_hit);
1197 if cap_hit {
1198 tracing::warn!(
1203 max_depth = MAX_SCHEMA_RECURSION_DEPTH,
1204 "schema nesting exceeded MAX_SCHEMA_RECURSION_DEPTH; descriptions beyond that depth \
1205 were left unsanitized"
1206 );
1207 }
1208 value
1209}
1210
1211fn sanitize_schema_jsdoc_value(value: &mut serde_json::Value, depth: usize, cap_hit: &mut bool) {
1225 if depth >= MAX_SCHEMA_RECURSION_DEPTH {
1226 *cap_hit = true;
1227 return;
1228 }
1229
1230 match value {
1231 serde_json::Value::Object(map) => {
1232 for (key, child) in map.iter_mut() {
1233 if key == "description" {
1234 if let Some(description) = child.as_str() {
1235 *child = serde_json::Value::String(sanitize_jsdoc(description, 256));
1236 } else {
1237 *child = serde_json::Value::Null;
1238 }
1239 } else {
1240 sanitize_schema_jsdoc_value(child, depth + 1, cap_hit);
1241 }
1242 }
1243 }
1244 serde_json::Value::Array(values) => {
1245 for child in values {
1246 sanitize_schema_jsdoc_value(child, depth + 1, cap_hit);
1247 }
1248 }
1249 _ => {}
1250 }
1251}
1252
1253#[cfg(test)]
1254mod tests {
1255 use super::*;
1256 use mcp_execution_core::{ServerId, ToolName};
1257 use mcp_execution_introspector::{ServerCapabilities, ToolInfo};
1258 use serde_json::json;
1259
1260 fn create_test_server_info() -> ServerInfo {
1261 ServerInfo {
1262 id: ServerId::new("test-server").unwrap(),
1263 name: "Test Server".to_string(),
1264 version: "1.0.0".to_string(),
1265 tools: vec![
1266 ToolInfo {
1267 name: ToolName::new("create_issue").unwrap(),
1268 description: "Creates a new issue".to_string(),
1269 input_schema: json!({
1270 "type": "object",
1271 "properties": {
1272 "title": {
1273 "type": "string",
1274 "description": "Issue title"
1275 },
1276 "body": {
1277 "type": "string",
1278 "description": "Issue body"
1279 }
1280 },
1281 "required": ["title"]
1282 }),
1283 output_schema: None,
1284 },
1285 ToolInfo {
1286 name: ToolName::new("update_issue").unwrap(),
1287 description: "Updates an existing issue".to_string(),
1288 input_schema: json!({
1289 "type": "object",
1290 "properties": {
1291 "id": {
1292 "type": "number"
1293 }
1294 },
1295 "required": ["id"]
1296 }),
1297 output_schema: None,
1298 },
1299 ],
1300 capabilities: ServerCapabilities {
1301 supports_tools: true,
1302 supports_resources: false,
1303 supports_prompts: false,
1304 },
1305 }
1306 }
1307
1308 fn test_config() -> mcp_execution_core::ServerConfig {
1309 mcp_execution_core::ServerConfig::builder()
1310 .command("test-command".to_string())
1311 .build()
1312 .unwrap()
1313 }
1314
1315 #[test]
1316 fn test_progressive_generator_new() {
1317 let generator = ProgressiveGenerator::new();
1318 assert!(generator.is_ok());
1319 }
1320
1321 #[test]
1322 fn test_generate_progressive_files() {
1323 let generator = ProgressiveGenerator::new().unwrap();
1324 let server_info = create_test_server_info();
1325
1326 let code = generator.generate(&server_info, &test_config()).unwrap();
1327
1328 assert_eq!(code.file_count(), 7);
1336
1337 let tool_files: Vec<_> = code.files.iter().map(|f| f.path.as_str()).collect();
1339
1340 assert!(tool_files.contains(&"createIssue.ts"));
1341 assert!(tool_files.contains(&"updateIssue.ts"));
1342 assert!(tool_files.contains(&"index.ts"));
1343 assert!(tool_files.contains(&"_runtime/mcp-bridge.ts"));
1344 assert!(tool_files.contains(&"package.json"));
1345 assert!(tool_files.contains(&"tsconfig.json"));
1346 assert!(tool_files.contains(&"_meta.json"));
1347 }
1348
1349 #[test]
1355 fn test_generate_index_ts_has_no_category_grouping() {
1356 let generator = ProgressiveGenerator::new().unwrap();
1357 let server_info = create_test_server_info();
1358
1359 let code = generator.generate(&server_info, &test_config()).unwrap();
1360 let index_file = code.files.iter().find(|f| f.path == "index.ts").unwrap();
1361
1362 assert!(
1363 !index_file.content.contains("uncategorized"),
1364 "generate()'s index.ts must not contain category grouping: {}",
1365 index_file.content
1366 );
1367 }
1368
1369 #[test]
1376 fn test_generate_tsconfig_json_allows_ts_extension_imports() {
1377 let generator = ProgressiveGenerator::new().unwrap();
1378 let server_info = create_test_server_info();
1379
1380 let code = generator.generate(&server_info, &test_config()).unwrap();
1381
1382 let tsconfig_file = code
1383 .files
1384 .iter()
1385 .find(|f| f.path == "tsconfig.json")
1386 .expect("tsconfig.json not found");
1387
1388 let parsed: serde_json::Value =
1389 serde_json::from_str(&tsconfig_file.content).expect("tsconfig.json is not valid JSON");
1390 let compiler_options = &parsed["compilerOptions"];
1391
1392 assert_eq!(compiler_options["allowImportingTsExtensions"], true);
1393 assert_eq!(compiler_options["noEmit"], true);
1394 assert_eq!(compiler_options["types"], serde_json::json!(["node"]));
1395 }
1396
1397 #[test]
1402 fn test_generate_package_json_declares_types_node_dev_dependency() {
1403 let generator = ProgressiveGenerator::new().unwrap();
1404 let server_info = create_test_server_info();
1405
1406 let code = generator.generate(&server_info, &test_config()).unwrap();
1407
1408 let package_json_file = code
1409 .files
1410 .iter()
1411 .find(|f| f.path == "package.json")
1412 .expect("package.json not found");
1413
1414 let parsed: serde_json::Value = serde_json::from_str(&package_json_file.content)
1415 .expect("package.json is not valid JSON");
1416
1417 assert!(
1418 parsed["devDependencies"]["@types/node"].is_string(),
1419 "package.json is missing a @types/node devDependency: {parsed}"
1420 );
1421 }
1422
1423 #[test]
1424 fn test_generate_meta_json_preserves_parameter_descriptions() {
1425 let generator = ProgressiveGenerator::new().unwrap();
1429 let server_info = create_test_server_info();
1430
1431 let code = generator.generate(&server_info, &test_config()).unwrap();
1432 let meta_file = code.files.iter().find(|f| f.path == "_meta.json").unwrap();
1433 let meta: ServerMetadata = serde_json::from_str(&meta_file.content).unwrap();
1434
1435 assert_eq!(meta.schema_version, METADATA_SCHEMA_VERSION);
1436 assert_eq!(meta.server_id.as_str(), "test-server");
1437 assert_eq!(meta.server_name, "Test Server");
1438 assert_eq!(meta.server_version, "1.0.0");
1439 assert_eq!(meta.tools.len(), 2);
1440
1441 let create_issue = meta
1442 .tools
1443 .iter()
1444 .find(|t| t.name.as_str() == "create_issue")
1445 .unwrap();
1446 assert_eq!(create_issue.typescript_name, "createIssue");
1447 let title = create_issue
1448 .parameters
1449 .iter()
1450 .find(|p| p.name == "title")
1451 .unwrap();
1452 assert_eq!(title.description, Some("Issue title".to_string()));
1453 assert!(title.required);
1454 }
1455
1456 #[test]
1460 fn test_generate_meta_json_provenance_is_stable_across_runs() {
1461 let generator = ProgressiveGenerator::new().unwrap();
1462 let server_info = create_test_server_info();
1463 let config = test_config();
1464
1465 let first = generator.generate(&server_info, &config).unwrap();
1466 let first_meta_file = first.files.iter().find(|f| f.path == "_meta.json").unwrap();
1467 let first_meta: ServerMetadata = serde_json::from_str(&first_meta_file.content).unwrap();
1468
1469 std::thread::sleep(std::time::Duration::from_millis(2));
1474
1475 let second = generator.generate(&server_info, &config).unwrap();
1476 let second_meta_file = second
1477 .files
1478 .iter()
1479 .find(|f| f.path == "_meta.json")
1480 .unwrap();
1481 let second_meta: ServerMetadata = serde_json::from_str(&second_meta_file.content).unwrap();
1482
1483 assert_eq!(first_meta.schema_version, 2);
1484 assert_eq!(first_meta.provenance.config_fingerprint.as_str().len(), 64);
1485 assert!(
1486 first_meta
1487 .provenance
1488 .config_fingerprint
1489 .as_str()
1490 .chars()
1491 .all(|c| c.is_ascii_hexdigit())
1492 );
1493 assert_eq!(first_meta.provenance.tool_digest.as_str().len(), 64);
1494 assert!(
1495 first_meta
1496 .provenance
1497 .tool_digest
1498 .as_str()
1499 .chars()
1500 .all(|c| c.is_ascii_hexdigit())
1501 );
1502
1503 assert_eq!(
1504 first_meta.provenance.config_fingerprint,
1505 second_meta.provenance.config_fingerprint
1506 );
1507 assert_eq!(
1508 first_meta.provenance.tool_digest,
1509 second_meta.provenance.tool_digest
1510 );
1511 assert_ne!(
1512 first_meta.provenance.generated_at, second_meta.provenance.generated_at,
1513 "the two calls must actually be stamped at different times for \"only generated_at \
1514 differs\" to be a meaningful claim"
1515 );
1516 }
1517
1518 #[test]
1519 fn test_generate_with_categories_meta_json_includes_categorization() {
1520 let generator = ProgressiveGenerator::new().unwrap();
1521 let server_info = create_test_server_info();
1522
1523 let mut categorizations = HashMap::new();
1524 categorizations.insert(
1525 "create_issue".to_string(),
1526 ToolCategorization {
1527 category: "issues".to_string(),
1528 keywords: vec!["create".to_string(), "issue".to_string(), "new".to_string()],
1529 short_description: "Create a new issue".to_string(),
1530 },
1531 );
1532
1533 let code = generator
1534 .generate_with_categories(&server_info, &test_config(), &categorizations)
1535 .unwrap();
1536 let meta_file = code.files.iter().find(|f| f.path == "_meta.json").unwrap();
1537 let meta: ServerMetadata = serde_json::from_str(&meta_file.content).unwrap();
1538
1539 let create_issue = meta
1540 .tools
1541 .iter()
1542 .find(|t| t.name.as_str() == "create_issue")
1543 .unwrap();
1544 assert_eq!(create_issue.category, Some("issues".to_string()));
1545 assert_eq!(
1546 create_issue.keywords,
1547 vec!["create".to_string(), "issue".to_string(), "new".to_string()]
1548 );
1549
1550 let update_issue = meta
1551 .tools
1552 .iter()
1553 .find(|t| t.name.as_str() == "update_issue")
1554 .unwrap();
1555 assert!(update_issue.category.is_none());
1556 assert!(update_issue.keywords.is_empty());
1557 }
1558
1559 #[test]
1560 fn test_generate_meta_json_parameter_description_is_raw_not_jsdoc_sanitized() {
1561 let raw_description = format!(
1566 "Matches C-style /* */ comment blocks.\nSecond line follows. {}",
1567 "x".repeat(300)
1568 );
1569 assert!(raw_description.contains("*/"));
1570 assert!(raw_description.contains('\n'));
1571 assert!(raw_description.chars().count() > 256);
1572
1573 let server_info = ServerInfo {
1574 id: ServerId::new("test-server").unwrap(),
1575 name: "Test Server".to_string(),
1576 version: "1.0.0".to_string(),
1577 tools: vec![ToolInfo {
1578 name: ToolName::new("send_message").unwrap(),
1579 description: "Sends a message".to_string(),
1580 input_schema: json!({
1581 "type": "object",
1582 "properties": {
1583 "notes": {
1584 "type": "string",
1585 "description": raw_description
1586 }
1587 },
1588 "required": []
1589 }),
1590 output_schema: None,
1591 }],
1592 capabilities: ServerCapabilities {
1593 supports_tools: true,
1594 supports_resources: false,
1595 supports_prompts: false,
1596 },
1597 };
1598
1599 let generator = ProgressiveGenerator::new().unwrap();
1600 let code = generator.generate(&server_info, &test_config()).unwrap();
1601
1602 let meta_file = code.files.iter().find(|f| f.path == "_meta.json").unwrap();
1604 let meta: ServerMetadata = serde_json::from_str(&meta_file.content).unwrap();
1605 let send_message = meta
1606 .tools
1607 .iter()
1608 .find(|t| t.name.as_str() == "send_message")
1609 .unwrap();
1610 let notes = send_message
1611 .parameters
1612 .iter()
1613 .find(|p| p.name == "notes")
1614 .unwrap();
1615 assert_eq!(notes.description, Some(raw_description.clone()));
1616
1617 let ts_file = code
1620 .files
1621 .iter()
1622 .find(|f| f.path == "sendMessage.ts")
1623 .unwrap();
1624 assert!(
1625 !ts_file.content.contains(raw_description.as_str()),
1626 "the .ts file must not contain the raw, un-sanitized description verbatim"
1627 );
1628 assert!(
1629 ts_file.content.contains("*\\/"),
1630 "the .ts file must escape '*/' to avoid closing the JSDoc comment early"
1631 );
1632 assert!(
1633 !ts_file
1634 .content
1635 .contains("Matches C-style /* */ comment blocks.\nSecond"),
1636 "the .ts file must flatten newlines within the description to spaces"
1637 );
1638 }
1639
1640 #[test]
1641 fn test_create_tool_context() {
1642 let tool = ToolInfo {
1643 name: ToolName::new("send_message").unwrap(),
1644 description: "Sends a message".to_string(),
1645 input_schema: json!({
1646 "type": "object",
1647 "properties": {
1648 "text": {"type": "string"}
1649 },
1650 "required": ["text"]
1651 }),
1652 output_schema: None,
1653 };
1654
1655 let categorization = ToolCategorization {
1656 category: "messaging".to_string(),
1657 keywords: vec![
1658 "send".to_string(),
1659 "message".to_string(),
1660 "chat".to_string(),
1661 ],
1662 short_description: "Send a message".to_string(),
1663 };
1664 let properties = ProgressiveGenerator::extract_property_infos(&tool.input_schema).unwrap();
1665 let context = ProgressiveGenerator::create_tool_context(
1666 "test-server",
1667 &tool,
1668 Some(&categorization),
1669 "sendMessage".to_string(),
1670 properties,
1671 );
1672
1673 assert_eq!(context.server_id, "test-server");
1674 assert_eq!(context.name, "send_message");
1675 assert_eq!(context.name_literal, "send_message");
1676 assert_eq!(context.server_id_literal, "test-server");
1677 assert_eq!(context.typescript_name, "sendMessage");
1678 assert_eq!(context.description, "Sends a message");
1679 assert_eq!(context.properties.len(), 1);
1680 assert_eq!(context.properties[0].name, "text");
1681 assert_eq!(context.category, Some("messaging".to_string()));
1682 assert_eq!(context.keywords, Some("send, message, chat".to_string()));
1683 assert_eq!(context.short_description, "Send a message".to_string());
1684 }
1685
1686 #[test]
1687 fn test_wrap_tool_generation_error_preserves_tool_name_and_source() {
1688 let tool = ToolInfo {
1692 name: ToolName::new("send_message").unwrap(),
1693 description: String::new(),
1694 input_schema: json!({}),
1695 output_schema: None,
1696 };
1697 let source = Error::ValidationError {
1698 field: "type".to_string(),
1699 reason: "Property type is not a string".to_string(),
1700 };
1701
1702 let wrapped = ProgressiveGenerator::wrap_tool_generation_error(
1703 &tool,
1704 "extract property schema",
1705 source,
1706 );
1707
1708 match wrapped {
1709 Error::ScriptGenerationError {
1710 tool: tool_name,
1711 message,
1712 source,
1713 } => {
1714 assert_eq!(tool_name, "send_message");
1715 assert_eq!(message, "failed to extract property schema");
1718 let source = source.expect("source must be preserved for exit-code classification");
1719 assert!(source.to_string().contains("Property type is not a string"));
1720 }
1721 other => panic!("expected ScriptGenerationError, got {other:?}"),
1722 }
1723 }
1724
1725 #[test]
1726 fn test_wrap_tool_generation_error_covers_render_and_tracking_stages() {
1727 let tool = ToolInfo {
1731 name: ToolName::new("send_message").unwrap(),
1732 description: String::new(),
1733 input_schema: json!({}),
1734 output_schema: None,
1735 };
1736
1737 let render_failure = ProgressiveGenerator::wrap_tool_generation_error(
1738 &tool,
1739 "render tool template",
1740 Error::SerializationError {
1741 message: "Template rendering failed: boom".to_string(),
1742 source: None,
1743 },
1744 );
1745 assert!(render_failure.is_script_generation_error());
1746
1747 let tracking_failure = ProgressiveGenerator::wrap_tool_generation_error(
1748 &tool,
1749 "track generated tool file",
1750 Error::ResourceLimitExceeded {
1751 resource: ResourceKind::GeneratedOutputSize,
1752 actual: 10,
1753 limit: 5,
1754 },
1755 );
1756 match tracking_failure {
1757 Error::ScriptGenerationError {
1758 tool: tool_name,
1759 source,
1760 ..
1761 } => {
1762 assert_eq!(tool_name, "send_message");
1763 let source = source.expect("source must be preserved for exit-code classification");
1764 assert!(
1767 source
1768 .downcast_ref::<Error>()
1769 .unwrap()
1770 .is_resource_limit_exceeded()
1771 );
1772 }
1773 other => panic!("expected ScriptGenerationError, got {other:?}"),
1774 }
1775 }
1776
1777 #[test]
1778 fn test_create_tool_context_without_categorization_falls_back_to_description() {
1779 let generator = ProgressiveGenerator::new().unwrap();
1780 let tool = ToolInfo {
1781 name: ToolName::new("format_document").unwrap(),
1782 description: "Format document with language-specific rules".to_string(),
1783 input_schema: json!({
1784 "type": "object",
1785 "properties": {
1786 "text": {"type": "string"}
1787 },
1788 "required": ["text"]
1789 }),
1790 output_schema: None,
1791 };
1792
1793 let properties = ProgressiveGenerator::extract_property_infos(&tool.input_schema).unwrap();
1794 let context = ProgressiveGenerator::create_tool_context(
1795 "test-server",
1796 &tool,
1797 None,
1798 "formatDocument".to_string(),
1799 properties,
1800 );
1801
1802 assert_eq!(
1803 context.short_description,
1804 "Format document with language-specific rules".to_string()
1805 );
1806
1807 let rendered = generator
1809 .engine
1810 .render("progressive/tool", &context)
1811 .unwrap();
1812 assert!(rendered.contains("@description Format document with language-specific rules"));
1813 }
1814
1815 #[test]
1816 fn test_create_tool_context_input_schema_is_sanitized() {
1817 let tool = ToolInfo {
1818 name: ToolName::new("send_message").unwrap(),
1819 description: "Sends a message".to_string(),
1820 input_schema: json!({
1821 "type": "object",
1822 "description": "Schema */ injected\nnext",
1823 "properties": {
1824 "text": {"type": "string"}
1825 },
1826 "required": ["text"]
1827 }),
1828 output_schema: None,
1829 };
1830
1831 let properties = ProgressiveGenerator::extract_property_infos(&tool.input_schema).unwrap();
1832 let context = ProgressiveGenerator::create_tool_context(
1833 "test-server",
1834 &tool,
1835 None,
1836 "sendMessage".to_string(),
1837 properties,
1838 );
1839
1840 let expected = sanitize_schema_jsdoc_descriptions(tool.input_schema);
1841 assert_eq!(context.input_schema, expected);
1842 assert_eq!(
1843 context.input_schema["description"],
1844 json!("Schema *\\/ injected next")
1845 );
1846 }
1847
1848 #[test]
1849 fn test_create_index_context() {
1850 let server_info = create_test_server_info();
1851 let typescript_names = resolve_typescript_names(&server_info.tools);
1852
1853 let context =
1854 ProgressiveGenerator::create_index_context(&server_info, None, &typescript_names);
1855
1856 assert_eq!(context.server_name, "Test Server");
1857 assert_eq!(context.server_version, "1.0.0");
1858 assert_eq!(context.tool_count, 2);
1859 assert_eq!(context.tools.len(), 2);
1860 assert_eq!(context.tools[0].typescript_name, "createIssue");
1861 assert!(context.categories.is_none());
1862 }
1863
1864 #[test]
1869 fn test_tool_named_index_does_not_collide_with_index_ts() {
1870 let generator = ProgressiveGenerator::new().unwrap();
1871 let server_info = ServerInfo {
1872 id: ServerId::new("test-server").unwrap(),
1873 name: "Test Server".to_string(),
1874 version: "1.0.0".to_string(),
1875 tools: vec![ToolInfo {
1876 name: ToolName::new("index").unwrap(),
1877 description: "A tool literally named index".to_string(),
1878 input_schema: json!({
1879 "type": "object",
1880 "properties": {},
1881 "required": []
1882 }),
1883 output_schema: None,
1884 }],
1885 capabilities: ServerCapabilities {
1886 supports_tools: true,
1887 supports_resources: false,
1888 supports_prompts: false,
1889 },
1890 };
1891
1892 let code = generator.generate(&server_info, &test_config()).unwrap();
1893
1894 let typescript_names = resolve_typescript_names(&server_info.tools);
1895 assert_eq!(
1896 typescript_names[0], "index_2",
1897 "a tool named `index` must be disambiguated, not collide with the fixed index.ts"
1898 );
1899
1900 let tool_file = code
1901 .files
1902 .iter()
1903 .find(|f| f.path == "index_2.ts")
1904 .expect("the tool's own file must exist at its disambiguated path");
1905 assert!(
1906 tool_file.content.contains("A tool literally named index"),
1907 "the tool's own generated content must not have been lost: {}",
1908 tool_file.content
1909 );
1910
1911 let index_file = code
1912 .files
1913 .iter()
1914 .find(|f| f.path == "index.ts")
1915 .expect("the fixed index.ts re-export must still exist");
1916 assert!(
1917 index_file.content.contains("index_2"),
1918 "index.ts must re-export the tool's disambiguated identifier: {}",
1919 index_file.content
1920 );
1921 assert!(
1922 index_file
1923 .content
1924 .contains("export { callMCPTool } from './_runtime/mcp-bridge.ts';"),
1925 "index.ts must be the fixed re-export (with the runtime bridge re-export), \
1926 not the overwritten tool file: {}",
1927 index_file.content
1928 );
1929
1930 assert_eq!(
1932 code.files.iter().filter(|f| f.path == "index.ts").count(),
1933 1
1934 );
1935 assert_eq!(
1936 code.files.iter().filter(|f| f.path == "index_2.ts").count(),
1937 1
1938 );
1939 }
1940
1941 #[test]
1947 fn test_tool_named_index_with_different_case_is_disambiguated() {
1948 let generator = ProgressiveGenerator::new().unwrap();
1949 let server_info = ServerInfo {
1950 id: ServerId::new("test-server").unwrap(),
1951 name: "Test Server".to_string(),
1952 version: "1.0.0".to_string(),
1953 tools: vec![ToolInfo {
1954 name: ToolName::new("Index").unwrap(),
1955 description: "A tool literally named Index".to_string(),
1956 input_schema: json!({
1957 "type": "object",
1958 "properties": {},
1959 "required": []
1960 }),
1961 output_schema: None,
1962 }],
1963 capabilities: ServerCapabilities {
1964 supports_tools: true,
1965 supports_resources: false,
1966 supports_prompts: false,
1967 },
1968 };
1969
1970 let code = generator.generate(&server_info, &test_config()).unwrap();
1971
1972 let typescript_names = resolve_typescript_names(&server_info.tools);
1973 assert_eq!(
1974 typescript_names[0], "Index_2",
1975 "a tool named `Index` must be disambiguated case-insensitively against the \
1976 reserved `index` output name"
1977 );
1978
1979 assert!(
1980 code.files.iter().any(|f| f.path == "Index_2.ts"),
1981 "the tool's own file must exist at its disambiguated path: {:?}",
1982 code.files.iter().map(|f| &f.path).collect::<Vec<_>>()
1983 );
1984 assert_eq!(
1987 code.files.iter().filter(|f| f.path == "index.ts").count(),
1988 1
1989 );
1990 assert!(!code.files.iter().any(|f| f.path == "Index.ts"));
1991 }
1992
1993 #[test]
1999 fn test_tools_named_index_and_capital_index_do_not_collide_with_each_other() {
2000 let generator = ProgressiveGenerator::new().unwrap();
2001 let server_info = ServerInfo {
2002 id: ServerId::new("test-server").unwrap(),
2003 name: "Test Server".to_string(),
2004 version: "1.0.0".to_string(),
2005 tools: vec![
2006 ToolInfo {
2007 name: ToolName::new("Index").unwrap(),
2008 description: "Capitalized".to_string(),
2009 input_schema: json!({"type": "object", "properties": {}, "required": []}),
2010 output_schema: None,
2011 },
2012 ToolInfo {
2013 name: ToolName::new("index").unwrap(),
2014 description: "Lowercase".to_string(),
2015 input_schema: json!({"type": "object", "properties": {}, "required": []}),
2016 output_schema: None,
2017 },
2018 ],
2019 capabilities: ServerCapabilities {
2020 supports_tools: true,
2021 supports_resources: false,
2022 supports_prompts: false,
2023 },
2024 };
2025
2026 let code = generator.generate(&server_info, &test_config()).unwrap();
2027 let typescript_names = resolve_typescript_names(&server_info.tools);
2028
2029 assert_ne!(
2030 typescript_names[0].to_ascii_lowercase(),
2031 typescript_names[1].to_ascii_lowercase(),
2032 "the two tools' resolved names must not collide case-insensitively: {typescript_names:?}"
2033 );
2034
2035 let paths: Vec<_> = code.files.iter().map(|f| f.path.as_str()).collect();
2036 let mut lowercased_paths: Vec<String> =
2037 paths.iter().map(|p| p.to_ascii_lowercase()).collect();
2038 let before = lowercased_paths.len();
2039 lowercased_paths.sort();
2040 lowercased_paths.dedup();
2041 assert_eq!(
2042 lowercased_paths.len(),
2043 before,
2044 "no two generated file paths may be case-insensitive duplicates of each other: {paths:?}"
2045 );
2046 }
2047
2048 #[test]
2053 fn test_tools_differing_only_by_case_are_disambiguated_from_each_other() {
2054 let server_info = ServerInfo {
2055 id: ServerId::new("test-server").unwrap(),
2056 name: "Test Server".to_string(),
2057 version: "1.0.0".to_string(),
2058 tools: vec![
2059 ToolInfo {
2060 name: ToolName::new("get_user").unwrap(),
2061 description: "snake_case".to_string(),
2062 input_schema: json!({"type": "object", "properties": {}, "required": []}),
2063 output_schema: None,
2064 },
2065 ToolInfo {
2066 name: ToolName::new("GetUser").unwrap(),
2067 description: "PascalCase".to_string(),
2068 input_schema: json!({"type": "object", "properties": {}, "required": []}),
2069 output_schema: None,
2070 },
2071 ],
2072 capabilities: ServerCapabilities {
2073 supports_tools: true,
2074 supports_resources: false,
2075 supports_prompts: false,
2076 },
2077 };
2078
2079 let typescript_names = resolve_typescript_names(&server_info.tools);
2080
2081 assert_eq!(typescript_names[0], "getUser");
2082 assert_ne!(
2083 typescript_names[0].to_ascii_lowercase(),
2084 typescript_names[1].to_ascii_lowercase(),
2085 "getUser/GetUser must not collide case-insensitively: {typescript_names:?}"
2086 );
2087 }
2088
2089 #[test]
2090 fn test_extract_property_infos() {
2091 let schema = json!({
2092 "type": "object",
2093 "properties": {
2094 "name": {
2095 "type": "string",
2096 "description": "User name"
2097 },
2098 "age": {
2099 "type": "number"
2100 }
2101 },
2102 "required": ["name"]
2103 });
2104
2105 let props = ProgressiveGenerator::extract_property_infos(&schema).unwrap();
2106
2107 assert_eq!(props.len(), 2);
2108
2109 let name_prop = props.iter().find(|p| p.name == "name").unwrap();
2111 assert_eq!(name_prop.typescript_type, "string");
2112 assert_eq!(name_prop.description, Some("User name".to_string()));
2113 assert!(name_prop.required);
2114
2115 let age_prop = props.iter().find(|p| p.name == "age").unwrap();
2117 assert_eq!(age_prop.typescript_type, "number");
2118 assert!(!age_prop.required);
2119 }
2120
2121 #[test]
2122 fn test_extract_property_infos_sanitizes_malicious_property_name() {
2123 let schema = json!({
2124 "type": "object",
2125 "properties": {
2126 "x: string }; export const pwned = 1; interface J {": {
2127 "type": "string",
2128 "description": "Evil property"
2129 }
2130 },
2131 "required": []
2132 });
2133
2134 let props = ProgressiveGenerator::extract_property_infos(&schema).unwrap();
2135
2136 assert_eq!(props.len(), 1);
2137 assert!(!props[0].name.contains(['{', '}', ';', ':', ' ']));
2138 assert_eq!(props[0].description, Some("Evil property".to_string()));
2141 }
2142
2143 #[test]
2144 fn test_extract_property_infos_disambiguates_colliding_sibling_names() {
2145 let schema = json!({
2149 "type": "object",
2150 "properties": {
2151 "a-b": {"type": "string"},
2152 "a.b": {"type": "number"}
2153 },
2154 "required": []
2155 });
2156
2157 let props = ProgressiveGenerator::extract_property_infos(&schema).unwrap();
2158 let mut names: Vec<&str> = props.iter().map(|p| p.name.as_str()).collect();
2159 names.sort_unstable();
2160
2161 assert_eq!(names, vec!["a_b", "a_b_2"]);
2162 }
2163
2164 #[test]
2165 fn test_extract_property_infos_disambiguates_collision_introduced_by_collapsing() {
2166 let schema = json!({
2171 "type": "object",
2172 "properties": {
2173 "a-b": {"type": "string"},
2174 "a--b": {"type": "number"}
2175 },
2176 "required": []
2177 });
2178
2179 let props = ProgressiveGenerator::extract_property_infos(&schema).unwrap();
2180 let mut names: Vec<&str> = props.iter().map(|p| p.name.as_str()).collect();
2181 names.sort_unstable();
2182
2183 assert_eq!(names, vec!["a_b", "a_b_2"]);
2184 }
2185
2186 #[test]
2187 fn test_extract_property_infos_disambiguates_three_way_collision() {
2188 let schema = json!({
2189 "type": "object",
2190 "properties": {
2191 "a-b": {"type": "string"},
2192 "a.b": {"type": "number"},
2193 "a b": {"type": "boolean"}
2194 },
2195 "required": []
2196 });
2197
2198 let props = ProgressiveGenerator::extract_property_infos(&schema).unwrap();
2199 let mut names: Vec<&str> = props.iter().map(|p| p.name.as_str()).collect();
2200 names.sort_unstable();
2201
2202 assert_eq!(names, vec!["a_b", "a_b_2", "a_b_3"]);
2203 }
2204
2205 #[test]
2206 fn test_generate_disambiguates_colliding_top_level_params() {
2207 let generator = ProgressiveGenerator::new().unwrap();
2208 let mut server_info = create_test_server_info();
2209 server_info.tools[0].input_schema = json!({
2210 "type": "object",
2211 "properties": {
2212 "a-b": {"type": "string"},
2213 "a.b": {"type": "number"}
2214 },
2215 "required": []
2216 });
2217
2218 let code = generator.generate(&server_info, &test_config()).unwrap();
2219 let tool = code
2220 .files
2221 .iter()
2222 .find(|f| f.path == "createIssue.ts")
2223 .unwrap();
2224
2225 assert_eq!(
2226 tool.content.matches("a_b:").count() + tool.content.matches("a_b?:").count(),
2227 1,
2228 "field 'a_b' must appear exactly once in the Params interface: {}",
2229 tool.content
2230 );
2231 assert_eq!(
2232 tool.content.matches("a_b_2:").count() + tool.content.matches("a_b_2?:").count(),
2233 1,
2234 "disambiguated field 'a_b_2' must appear exactly once in the Params interface: {}",
2235 tool.content
2236 );
2237 }
2238
2239 #[test]
2240 fn test_generate_sanitizes_property_name_injection() {
2241 let generator = ProgressiveGenerator::new().unwrap();
2242 let mut server_info = create_test_server_info();
2243 server_info.tools[0].input_schema = json!({
2244 "type": "object",
2245 "properties": {
2246 "x: string }; export const pwned = evil(); interface J {": {"type": "string"}
2247 },
2248 "required": []
2249 });
2250
2251 let code = generator.generate(&server_info, &test_config()).unwrap();
2252 let tool = code
2253 .files
2254 .iter()
2255 .find(|f| f.path == "createIssue.ts")
2256 .unwrap();
2257
2258 assert!(
2259 !tool.content.contains("export const pwned"),
2260 "raw property name must not inject a top-level statement: {}",
2261 tool.content
2262 );
2263 }
2264
2265 #[test]
2266 fn test_sanitize_jsdoc_strips_comment_terminator() {
2267 assert_eq!(sanitize_jsdoc("Foo */ bar", 256), "Foo *\\/ bar");
2268 }
2269
2270 #[test]
2271 fn test_sanitize_jsdoc_replaces_newlines() {
2272 assert_eq!(
2273 sanitize_jsdoc("line1\nline2\r\nline3", 256),
2274 "line1 line2 line3"
2275 );
2276 }
2277
2278 #[test]
2279 fn test_sanitize_jsdoc_replaces_unicode_line_terminators() {
2280 assert_eq!(
2285 sanitize_jsdoc("line1\u{2028}line2\u{2029}line3", 256),
2286 "line1 line2 line3"
2287 );
2288 }
2289
2290 #[test]
2291 fn test_generate_with_categories_sanitizes_unicode_line_terminator_in_category() {
2292 let generator = ProgressiveGenerator::new().unwrap();
2293 let server_info = create_test_server_info();
2294
2295 let mut categorizations = HashMap::new();
2296 categorizations.insert(
2297 "create_issue".to_string(),
2298 ToolCategorization {
2299 category: "issues\u{2028}export const pwned = 1;".to_string(),
2300 keywords: vec![],
2301 short_description: "Create a new issue".to_string(),
2302 },
2303 );
2304
2305 let code = generator
2306 .generate_with_categories(&server_info, &test_config(), &categorizations)
2307 .unwrap();
2308 let index = code.files.iter().find(|f| f.path == "index.ts").unwrap();
2309
2310 assert!(
2315 index
2316 .content
2317 .contains("// --- issues export const pwned = 1; ---"),
2318 "sanitized category text should remain inert inside the comment: {}",
2319 index.content
2320 );
2321 assert!(
2322 !index.content.contains("\nexport const pwned"),
2323 "U+2028 must not terminate the `// --- {{category}} ---` line comment and \
2324 inject a live top-level statement: {}",
2325 index.content
2326 );
2327 }
2328
2329 #[test]
2330 fn test_sanitize_jsdoc_truncates() {
2331 let long = "a".repeat(300);
2332 assert_eq!(sanitize_jsdoc(&long, 256).chars().count(), 256);
2333 }
2334
2335 #[test]
2336 fn test_sanitize_jsdoc_passthrough() {
2337 assert_eq!(sanitize_jsdoc("Normal string", 256), "Normal string");
2338 }
2339
2340 #[test]
2341 fn test_sanitize_jsdoc_strips_ansi_escape_sequence() {
2342 let payload = "Innocuous \x1b[31mred text\x1b[0m looking description";
2347 let sanitized = sanitize_jsdoc(payload, 256);
2348 assert!(!sanitized.contains('\x1b'));
2349 assert_eq!(sanitized, "Innocuous [31mred text [0m looking description");
2350 }
2351
2352 #[test]
2353 fn test_sanitize_jsdoc_replaces_other_c0_control_characters_with_space() {
2354 assert_eq!(sanitize_jsdoc("a\u{0}b\u{7}c\u{7f}d", 256), "a b c d");
2355 }
2356
2357 #[test]
2364 fn test_sanitize_jsdoc_control_char_between_star_slash_cannot_reopen_comment() {
2365 for ctrl in ['\u{0}', '\u{7f}', '\u{1b}'] {
2366 let payload = format!("safe *{ctrl}/ export const pwned = 1; //");
2367 let sanitized = sanitize_jsdoc(&payload, 256);
2368 assert!(
2369 !sanitized.contains("*/"),
2370 "control char {ctrl:?} must not let a bare `*/` reappear: {sanitized:?}"
2371 );
2372 assert!(
2373 sanitized.contains("* /"),
2374 "the control char should be neutralized to a space, not deleted: {sanitized:?}"
2375 );
2376 }
2377 }
2378
2379 #[test]
2380 fn test_sanitize_ts_string_literal_escapes_quote_and_backslash() {
2381 assert_eq!(
2382 sanitize_ts_string_literal(r"it's a \test"),
2383 r"it\'s a \\test"
2384 );
2385 }
2386
2387 #[test]
2388 fn test_sanitize_ts_string_literal_escape_order_prevents_double_escaping() {
2389 assert_eq!(sanitize_ts_string_literal("\\'"), r"\\\'");
2392 }
2393
2394 #[test]
2395 fn test_sanitize_ts_string_literal_escapes_newlines() {
2396 assert_eq!(
2397 sanitize_ts_string_literal("line1\nline2\rline3"),
2398 "line1\\nline2\\rline3"
2399 );
2400 }
2401
2402 #[test]
2403 fn test_sanitize_ts_string_literal_escapes_unicode_line_terminators() {
2404 assert_eq!(
2407 sanitize_ts_string_literal("line1\u{2028}line2\u{2029}line3"),
2408 "line1\\u2028line2\\u2029line3"
2409 );
2410 }
2411
2412 #[test]
2419 fn test_sanitize_ts_string_literal_strips_unicode_tags_block_payload() {
2420 let hostile = "safe\u{E0001}\u{E0073}\u{E006D}\u{E0075}\u{E0067}\u{E0067}\u{E006C}\u{E0065}\u{E0064}\u{E007F}visible";
2421 let sanitized = sanitize_ts_string_literal(hostile);
2422 assert_eq!(sanitized, "safevisible");
2423 assert!(
2424 sanitized
2425 .chars()
2426 .all(|c| !('\u{E0000}'..='\u{E007F}').contains(&c))
2427 );
2428 }
2429
2430 #[test]
2433 fn test_sanitize_ts_string_literal_neutralizes_bidi_override() {
2434 let sanitized = sanitize_ts_string_literal("safe\u{202E}evil");
2435 assert!(!sanitized.contains('\u{202E}'));
2436 assert_eq!(sanitized, "safe evil");
2437 }
2438
2439 #[test]
2449 fn test_sanitize_ts_string_literal_never_leaves_a_dangling_odd_backslash_run_at_the_cap() {
2450 let max = mcp_execution_core::untrusted::MAX_UNTRUSTED_FIELD_LEN;
2451 for payload_char in ['\'', '\\', '\n', '\r'] {
2452 for len in [max - 1, max, max + 1, max + 2, max * 2] {
2453 let hostile: String = std::iter::once('a')
2454 .chain(std::iter::repeat_n(payload_char, len))
2455 .collect();
2456 let sanitized = sanitize_ts_string_literal(&hostile);
2457 let trailing_backslashes =
2458 sanitized.chars().rev().take_while(|&c| c == '\\').count();
2459 assert_eq!(
2460 trailing_backslashes % 2,
2461 0,
2462 "odd trailing backslash run (payload {payload_char:?}, raw len {len}) would \
2463 leave the generated string literal unterminated: {sanitized:?}"
2464 );
2465 }
2466 }
2467 }
2468
2469 #[test]
2474 fn test_sanitize_ts_identifier_passthrough_valid() {
2475 assert_eq!(sanitize_ts_identifier("sendMessage_1"), "sendMessage_1");
2476 }
2477
2478 #[test]
2479 fn test_generate_sanitizes_call_site_string_literal_injection() {
2480 let raw_name = "create_issue's_evil_twin";
2488 let generator = ProgressiveGenerator::new().unwrap();
2489 let mut server_info = create_test_server_info();
2490 server_info.tools[0].name = ToolName::new(raw_name).unwrap();
2491
2492 let code = generator.generate(&server_info, &test_config()).unwrap();
2493 let tool = code
2494 .files
2495 .iter()
2496 .find(|f| {
2497 std::path::Path::new(&f.path)
2498 .extension()
2499 .is_some_and(|ext| ext.eq_ignore_ascii_case("ts"))
2500 && f.path != "index.ts"
2501 })
2502 .unwrap();
2503
2504 let call_site_line = tool
2515 .content
2516 .lines()
2517 .find(|line| line.contains("return (await callMCPTool("))
2518 .expect("generated tool file must contain a callMCPTool(...) invocation");
2519 assert!(
2520 call_site_line.contains("create_issue\\'s_evil_twin"),
2521 "the apostrophe in the tool name must be escaped in the callMCPTool string literal: {call_site_line}"
2522 );
2523 assert!(
2524 !call_site_line.contains(raw_name),
2525 "raw quote must not break out of the callMCPTool string literal: {call_site_line}"
2526 );
2527 }
2528
2529 #[test]
2536 fn test_generate_never_leaves_an_unterminated_call_site_literal_near_the_escape_cap() {
2537 let generator = ProgressiveGenerator::new().unwrap();
2538 let mut server_info = create_test_server_info();
2539 let hostile_name = format!("a{}", "'".repeat(250));
2540 server_info.tools[0].name = ToolName::new(hostile_name).unwrap();
2541
2542 let code = generator.generate(&server_info, &test_config()).unwrap();
2543 let tool = code
2544 .files
2545 .iter()
2546 .find(|f| {
2547 std::path::Path::new(&f.path)
2548 .extension()
2549 .is_some_and(|ext| ext.eq_ignore_ascii_case("ts"))
2550 && f.path != "index.ts"
2551 })
2552 .unwrap();
2553
2554 let call_site_line = tool
2555 .content
2556 .lines()
2557 .find(|line| line.contains("return (await callMCPTool("))
2558 .expect("generated tool file must contain a callMCPTool(...) invocation");
2559
2560 assert!(
2564 call_site_line.contains("params))"),
2565 "the params argument and closing parens must survive as live code, not be \
2566 swallowed into the tool name's string literal: {call_site_line}"
2567 );
2568 let suffix = call_site_line.split("params))").nth(1).unwrap();
2569 assert!(
2570 suffix.trim_start().starts_with("as ") && suffix.trim_end().ends_with(';'),
2571 "the cast-and-semicolon after the call must survive as live code, not be swallowed \
2572 into the tool name's string literal: {call_site_line}"
2573 );
2574 }
2575
2576 #[test]
2577 fn test_resolve_typescript_names_disambiguates_collisions() {
2578 let tools = vec![
2579 ToolInfo {
2580 name: ToolName::new("foo-bar").unwrap(),
2581 description: String::new(),
2582 input_schema: json!({}),
2583 output_schema: None,
2584 },
2585 ToolInfo {
2586 name: ToolName::new("foo.bar").unwrap(),
2587 description: String::new(),
2588 input_schema: json!({}),
2589 output_schema: None,
2590 },
2591 ToolInfo {
2592 name: ToolName::new("foo:bar").unwrap(),
2597 description: String::new(),
2598 input_schema: json!({}),
2599 output_schema: None,
2600 },
2601 ];
2602
2603 let resolved = resolve_typescript_names(&tools);
2604 let mut names: Vec<&String> = resolved.iter().collect();
2605 names.sort();
2606
2607 assert_eq!(resolved.len(), 3);
2609 let unique: HashSet<&String> = names.iter().copied().collect();
2610 assert_eq!(
2611 unique.len(),
2612 3,
2613 "collisions must be disambiguated: {names:?}"
2614 );
2615 assert_eq!(resolved[0], "foo_bar");
2616 }
2617
2618 #[test]
2619 fn test_resolve_typescript_names_disambiguates_identical_raw_names() {
2620 let tools = vec![
2624 ToolInfo {
2625 name: ToolName::new("dup").unwrap(),
2626 description: "First".to_string(),
2627 input_schema: json!({}),
2628 output_schema: None,
2629 },
2630 ToolInfo {
2631 name: ToolName::new("dup").unwrap(),
2632 description: "Second".to_string(),
2633 input_schema: json!({}),
2634 output_schema: None,
2635 },
2636 ];
2637
2638 let resolved = resolve_typescript_names(&tools);
2639
2640 assert_eq!(resolved, vec!["dup".to_string(), "dup_2".to_string()]);
2641 }
2642
2643 #[test]
2644 fn test_resolve_typescript_names_disambiguates_three_way_identical_raw_names() {
2645 let tools: Vec<ToolInfo> = (0..3)
2646 .map(|_| ToolInfo {
2647 name: ToolName::new("dup").unwrap(),
2648 description: String::new(),
2649 input_schema: json!({}),
2650 output_schema: None,
2651 })
2652 .collect();
2653
2654 let resolved = resolve_typescript_names(&tools);
2655
2656 assert_eq!(
2657 resolved,
2658 vec!["dup".to_string(), "dup_2".to_string(), "dup_3".to_string()]
2659 );
2660 }
2661
2662 #[test]
2663 fn test_resolve_typescript_names_disambiguates_reserved_words() {
2664 let reserved_tool_names = [
2665 "delete",
2666 "typeof",
2667 "class",
2668 "new",
2669 "import",
2670 "export",
2671 "in",
2672 "instanceof",
2673 "void",
2674 "enum",
2675 "eval",
2676 "arguments",
2677 ];
2678
2679 for name in reserved_tool_names {
2680 let tools = vec![ToolInfo {
2681 name: ToolName::new(name).unwrap(),
2682 description: String::new(),
2683 input_schema: json!({}),
2684 output_schema: None,
2685 }];
2686
2687 let resolved = resolve_typescript_names(&tools);
2688 let typescript_name = &resolved[0];
2689
2690 assert_ne!(
2691 typescript_name, name,
2692 "reserved word {name} must be disambiguated"
2693 );
2694 assert!(
2695 !RESERVED_WORDS.contains(&typescript_name.as_str()),
2696 "resolved name {typescript_name} for tool {name} must not be a reserved word"
2697 );
2698 }
2699 }
2700
2701 #[test]
2702 fn test_resolve_typescript_names_reserved_word_avoids_existing_collision() {
2703 let tools = vec![
2704 ToolInfo {
2705 name: ToolName::new("class").unwrap(),
2706 description: String::new(),
2707 input_schema: json!({}),
2708 output_schema: None,
2709 },
2710 ToolInfo {
2718 name: ToolName::new("class-2").unwrap(),
2719 description: String::new(),
2720 input_schema: json!({}),
2721 output_schema: None,
2722 },
2723 ];
2724
2725 let resolved = resolve_typescript_names(&tools);
2726
2727 assert_ne!(
2728 resolved[0], resolved[1],
2729 "a reserved-word tool's fallback name must not collide with an unrelated tool that already claims it"
2730 );
2731 assert!(!RESERVED_WORDS.contains(&resolved[0].as_str()));
2732 }
2733
2734 #[test]
2735 fn test_resolve_typescript_names_reserved_word_case_variant_is_not_suffixed() {
2736 let tools = vec![ToolInfo {
2740 name: ToolName::new("Delete").unwrap(),
2741 description: String::new(),
2742 input_schema: json!({}),
2743 output_schema: None,
2744 }];
2745
2746 let resolved = resolve_typescript_names(&tools);
2747
2748 assert_eq!(resolved[0], "Delete");
2749 }
2750
2751 #[test]
2752 fn test_resolve_typescript_names_exact_reserved_word_is_still_suffixed() {
2753 let tools = vec![ToolInfo {
2754 name: ToolName::new("delete").unwrap(),
2755 description: String::new(),
2756 input_schema: json!({}),
2757 output_schema: None,
2758 }];
2759
2760 let resolved = resolve_typescript_names(&tools);
2761
2762 assert_ne!(resolved[0], "delete");
2763 assert!(!RESERVED_WORDS.contains(&resolved[0].as_str()));
2764 }
2765
2766 #[test]
2767 fn test_resolve_typescript_names_delete_and_delete_case_variant_in_same_batch() {
2768 let make_tools = |first: &str, second: &str| {
2774 vec![
2775 ToolInfo {
2776 name: ToolName::new(first).unwrap(),
2777 description: String::new(),
2778 input_schema: json!({}),
2779 output_schema: None,
2780 },
2781 ToolInfo {
2782 name: ToolName::new(second).unwrap(),
2783 description: String::new(),
2784 input_schema: json!({}),
2785 output_schema: None,
2786 },
2787 ]
2788 };
2789
2790 let delete_first = resolve_typescript_names(&make_tools("delete", "Delete"));
2791 assert_ne!(delete_first[0], "delete");
2792 assert_eq!(delete_first[1], "Delete");
2793 assert_ne!(
2794 delete_first[0].to_ascii_lowercase(),
2795 delete_first[1].to_ascii_lowercase()
2796 );
2797
2798 let delete_second = resolve_typescript_names(&make_tools("Delete", "delete"));
2799 assert_eq!(delete_second[0], "Delete");
2800 assert_ne!(delete_second[1], "delete");
2801 assert_ne!(
2802 delete_second[0].to_ascii_lowercase(),
2803 delete_second[1].to_ascii_lowercase()
2804 );
2805 }
2806
2807 #[test]
2808 fn test_resolve_typescript_names_collapses_non_ascii_run() {
2809 let tools = vec![ToolInfo {
2812 name: ToolName::new("café_menu_日本語").unwrap(),
2813 description: String::new(),
2814 input_schema: json!({}),
2815 output_schema: None,
2816 }];
2817
2818 let resolved = resolve_typescript_names(&tools);
2819
2820 assert_eq!(resolved[0], "caf_Menu_");
2821 }
2822
2823 #[test]
2824 fn test_resolve_typescript_names_disambiguates_collision_introduced_by_collapsing() {
2825 let tools = vec![
2830 ToolInfo {
2831 name: ToolName::new("a-b").unwrap(),
2832 description: String::new(),
2833 input_schema: json!({}),
2834 output_schema: None,
2835 },
2836 ToolInfo {
2837 name: ToolName::new("a--b").unwrap(),
2838 description: String::new(),
2839 input_schema: json!({}),
2840 output_schema: None,
2841 },
2842 ];
2843
2844 let resolved = resolve_typescript_names(&tools);
2845
2846 assert_eq!(resolved, vec!["a_b", "a_b_2"]);
2847 }
2848
2849 #[test]
2850 fn test_generate_sanitizes_reserved_word_tool_name() {
2851 let generator = ProgressiveGenerator::new().unwrap();
2852 let mut server_info = create_test_server_info();
2853 server_info.tools = vec![ToolInfo {
2854 name: ToolName::new("delete").unwrap(),
2855 description: "Delete something".to_string(),
2856 input_schema: json!({}),
2857 output_schema: None,
2858 }];
2859
2860 let code = generator.generate(&server_info, &test_config()).unwrap();
2861 let tool_file = code.files.iter().find(|f| f.path == "delete_2.ts").unwrap();
2862
2863 assert!(!tool_file.content.contains("export async function delete("));
2864 assert!(
2865 tool_file
2866 .content
2867 .contains("export async function delete_2(")
2868 );
2869 }
2870
2871 #[test]
2872 fn test_generate_disambiguates_colliding_tool_names() {
2873 let generator = ProgressiveGenerator::new().unwrap();
2874 let mut server_info = create_test_server_info();
2875 server_info.tools = vec![
2876 ToolInfo {
2877 name: ToolName::new("foo-bar").unwrap(),
2878 description: "First".to_string(),
2879 input_schema: json!({}),
2880 output_schema: None,
2881 },
2882 ToolInfo {
2883 name: ToolName::new("foo.bar").unwrap(),
2884 description: "Second".to_string(),
2885 input_schema: json!({}),
2886 output_schema: None,
2887 },
2888 ];
2889
2890 let code = generator.generate(&server_info, &test_config()).unwrap();
2891
2892 let tool_files: Vec<&str> = code
2894 .files
2895 .iter()
2896 .filter(|f| f.path == "foo_bar.ts" || f.path == "foo_bar_2.ts")
2897 .map(|f| f.path.as_str())
2898 .collect();
2899 assert_eq!(
2900 tool_files.len(),
2901 2,
2902 "colliding names must not overwrite each other's file: {tool_files:?}"
2903 );
2904
2905 let index = code.files.iter().find(|f| f.path == "index.ts").unwrap();
2906 assert_eq!(
2907 index.content.matches("export { foo_bar,").count(),
2908 1,
2909 "index.ts must export the first tool's identifier exactly once"
2910 );
2911 assert_eq!(
2912 index.content.matches("export { foo_bar_2,").count(),
2913 1,
2914 "index.ts must export the disambiguated second identifier exactly once"
2915 );
2916 }
2917
2918 #[test]
2919 fn test_generate_disambiguates_identical_raw_tool_names() {
2920 let generator = ProgressiveGenerator::new().unwrap();
2924 let mut server_info = create_test_server_info();
2925 server_info.tools = vec![
2926 ToolInfo {
2927 name: ToolName::new("dup").unwrap(),
2928 description: "First".to_string(),
2929 input_schema: json!({}),
2930 output_schema: None,
2931 },
2932 ToolInfo {
2933 name: ToolName::new("dup").unwrap(),
2934 description: "Second".to_string(),
2935 input_schema: json!({}),
2936 output_schema: None,
2937 },
2938 ];
2939
2940 let code = generator.generate(&server_info, &test_config()).unwrap();
2941
2942 let dup_files: Vec<&str> = code
2943 .files
2944 .iter()
2945 .filter(|f| f.path == "dup.ts" || f.path == "dup_2.ts")
2946 .map(|f| f.path.as_str())
2947 .collect();
2948 assert_eq!(
2949 dup_files.len(),
2950 2,
2951 "identical raw tool names must not overwrite each other's file: {dup_files:?}"
2952 );
2953
2954 let index = code.files.iter().find(|f| f.path == "index.ts").unwrap();
2955 assert_eq!(
2956 index.content.matches("export { dup,").count(),
2957 1,
2958 "index.ts must export the first tool's identifier exactly once"
2959 );
2960 assert_eq!(
2961 index.content.matches("export { dup_2,").count(),
2962 1,
2963 "index.ts must export the disambiguated second identifier exactly once"
2964 );
2965 }
2966
2967 #[test]
2968 fn test_sanitize_schema_jsdoc_drops_non_string_descriptions() {
2969 let sanitized = sanitize_schema_jsdoc_descriptions(json!({
2970 "type": "object",
2971 "description": {"text": "Schema */ injected\nnext"},
2972 "properties": {
2973 "title": {
2974 "type": "string",
2975 "description": ["Title */ injected\nnext"]
2976 }
2977 }
2978 }));
2979
2980 assert!(sanitized["description"].is_null());
2981 assert!(sanitized["properties"]["title"]["description"].is_null());
2982 }
2983
2984 #[test]
2985 fn test_sanitize_schema_jsdoc_recurses_into_array_items() {
2986 let sanitized = sanitize_schema_jsdoc_descriptions(json!({
2987 "type": "object",
2988 "properties": {
2989 "tags": {
2990 "type": "array",
2991 "items": [
2992 {
2993 "type": "string",
2994 "description": "Tag */ injected\nnext"
2995 }
2996 ]
2997 }
2998 }
2999 }));
3000
3001 let description = sanitized["properties"]["tags"]["items"][0]["description"]
3002 .as_str()
3003 .unwrap();
3004
3005 assert_eq!(description, "Tag *\\/ injected next");
3006 }
3007
3008 fn nested_array_schema_with_descriptions(depth: usize, description: &str) -> serde_json::Value {
3020 let mut schema = json!({"type": "string", "description": description});
3021 for _ in 0..depth {
3022 let mut map = serde_json::Map::new();
3023 map.insert(
3024 "type".to_string(),
3025 serde_json::Value::String("array".to_string()),
3026 );
3027 map.insert("items".to_string(), schema);
3028 map.insert(
3029 "description".to_string(),
3030 serde_json::Value::String(description.to_string()),
3031 );
3032 schema = serde_json::Value::Object(map);
3033 }
3034 schema
3035 }
3036
3037 fn nth_level(value: &serde_json::Value, depth: usize) -> serde_json::Value {
3039 let mut v = value.clone();
3040 for _ in 0..depth {
3041 v = v["items"].clone();
3042 }
3043 v
3044 }
3045
3046 #[test]
3047 fn test_sanitize_schema_jsdoc_bounds_deeply_nested_schema() {
3048 const MALICIOUS: &str = "desc */ injected\nnext";
3056 let depth = MAX_SCHEMA_RECURSION_DEPTH + 10;
3057 let schema = nested_array_schema_with_descriptions(depth, MALICIOUS);
3058
3059 let sanitized = sanitize_schema_jsdoc_descriptions(schema);
3060
3061 let just_below_cap = nth_level(&sanitized, MAX_SCHEMA_RECURSION_DEPTH - 1);
3062 let below_description = just_below_cap["description"]
3063 .as_str()
3064 .expect("description below the cap must still be a string");
3065 assert!(
3066 !below_description.contains("*/"),
3067 "description one level below the cap must be sanitized: {below_description}"
3068 );
3069
3070 let at_cap = nth_level(&sanitized, MAX_SCHEMA_RECURSION_DEPTH);
3071 let at_cap_description = at_cap["description"]
3072 .as_str()
3073 .expect("description at the cap must still be a string");
3074 assert_eq!(
3075 at_cap_description, MALICIOUS,
3076 "description at the cap must be left untouched — the function must stop \
3077 recursing before reaching it"
3078 );
3079 }
3080
3081 #[test]
3082 fn test_sanitize_schema_jsdoc_survives_pathologically_deep_input() {
3083 std::thread::Builder::new()
3093 .stack_size(64 * 1024 * 1024)
3094 .spawn(|| {
3095 let schema = nested_array_schema_with_descriptions(5_000, "leaf");
3096 let sanitized = sanitize_schema_jsdoc_descriptions(schema);
3097 assert!(sanitized.is_object());
3098 })
3099 .expect("spawn test thread")
3100 .join()
3101 .expect("test thread panicked");
3102 }
3103
3104 #[test]
3105 fn test_sanitize_jsdoc_truncation_boundary_injection() {
3106 let max_len = 256;
3107 let payload = format!("{}*/{}", "a".repeat(max_len - 1), "trailer");
3111
3112 let sanitized = sanitize_jsdoc(&payload, max_len);
3113
3114 assert!(
3115 !sanitized.contains("*/"),
3116 "truncation must not re-open the JSDoc comment: {sanitized}"
3117 );
3118 assert_eq!(sanitized.chars().count(), max_len);
3119 }
3120
3121 #[test]
3137 fn test_generate_runtime_bridge_declares_forbidden_env_var_list() {
3138 let generator = ProgressiveGenerator::new().unwrap();
3139 let server_info = create_test_server_info();
3140
3141 let code = generator.generate(&server_info, &test_config()).unwrap();
3142 let bridge = code
3143 .files
3144 .iter()
3145 .find(|f| f.path == "_runtime/mcp-bridge.ts")
3146 .unwrap();
3147
3148 for forbidden_env in mcp_execution_core::forbidden_env_names() {
3149 assert!(
3150 bridge.content.contains(&format!("'{forbidden_env}'")),
3151 "runtime bridge must list forbidden env var {forbidden_env}: {}",
3152 bridge.content
3153 );
3154 }
3155
3156 for forbidden_char in mcp_execution_core::forbidden_chars() {
3157 let escaped = sanitize_ts_string_literal(&forbidden_char.to_string());
3158 assert!(
3159 bridge.content.contains(&format!("'{escaped}'")),
3160 "runtime bridge must list forbidden char {forbidden_char:?}: {}",
3161 bridge.content
3162 );
3163 }
3164
3165 assert!(
3166 bridge
3167 .content
3168 .contains(mcp_execution_core::forbidden_env_prefix()),
3169 "runtime bridge must reference the forbidden env prefix: {}",
3170 bridge.content
3171 );
3172 }
3173
3174 #[test]
3182 fn test_generate_runtime_bridge_declares_dos_bounds_and_env_name_charset() {
3183 let generator = ProgressiveGenerator::new().unwrap();
3184 let server_info = create_test_server_info();
3185
3186 let code = generator.generate(&server_info, &test_config()).unwrap();
3187 let bridge = code
3188 .files
3189 .iter()
3190 .find(|f| f.path == "_runtime/mcp-bridge.ts")
3191 .unwrap();
3192
3193 for (name, value) in [
3198 ("MAX_ARG_COUNT", mcp_execution_core::MAX_ARG_COUNT),
3199 ("MAX_ARG_LEN", mcp_execution_core::MAX_ARG_LEN),
3200 ("MAX_ENV_COUNT", mcp_execution_core::MAX_ENV_COUNT),
3201 ("MAX_ENV_VALUE_LEN", mcp_execution_core::MAX_ENV_VALUE_LEN),
3202 ("MAX_URL_LEN", mcp_execution_core::MAX_URL_LEN),
3203 ("MAX_HEADER_COUNT", mcp_execution_core::MAX_HEADER_COUNT),
3204 (
3205 "MAX_HEADER_VALUE_LEN",
3206 mcp_execution_core::MAX_HEADER_VALUE_LEN,
3207 ),
3208 ] {
3209 assert!(
3210 bridge.content.contains(&format!("const {name} = {value};")),
3211 "runtime bridge must declare {name} = {value}: {}",
3212 bridge.content
3213 );
3214 }
3215
3216 assert!(
3217 bridge
3218 .content
3219 .contains(mcp_execution_core::env_name_charset_pattern()),
3220 "runtime bridge must reference the env-name charset pattern: {}",
3221 bridge.content
3222 );
3223 assert!(
3224 bridge
3225 .content
3226 .contains(mcp_execution_core::env_name_charset_desc()),
3227 "runtime bridge must reference the env-name charset description: {}",
3228 bridge.content
3229 );
3230 }
3231
3232 #[test]
3233 fn test_generate_preserves_benign_punctuation() {
3234 let generator = ProgressiveGenerator::new().unwrap();
3238 let mut server_info = create_test_server_info();
3239 server_info.tools[0].description =
3240 "Compares values: a < b && b > c, or use \"quotes\" & don't forget 'em".to_string();
3241
3242 let code = generator.generate(&server_info, &test_config()).unwrap();
3243 let tool = code
3244 .files
3245 .iter()
3246 .find(|f| f.path == "createIssue.ts")
3247 .unwrap();
3248
3249 assert!(
3250 tool.content
3251 .contains("a < b && b > c, or use \"quotes\" & don't forget 'em"),
3252 "benign punctuation must survive verbatim, not be HTML-escaped: {}",
3253 tool.content
3254 );
3255 for entity in ["<", ">", "&", """, "'", "'"] {
3256 assert!(
3257 !tool.content.contains(entity),
3258 "output must not contain HTML entity {entity}: {}",
3259 tool.content
3260 );
3261 }
3262 }
3263
3264 #[test]
3265 fn test_generate_sanitizes_jsdoc_injection() {
3266 let generator = ProgressiveGenerator::new().unwrap();
3267 let mut server_info = create_test_server_info();
3268 server_info.name = "Evil */ injection".to_string();
3269 server_info.version = "1.0\n<script>".to_string();
3270
3271 let code = generator.generate(&server_info, &test_config()).unwrap();
3272 let index = code.files.iter().find(|f| f.path == "index.ts").unwrap();
3273
3274 assert!(
3276 !index.content.contains("Evil */ injection"),
3277 "Server name should be sanitized in JSDoc"
3278 );
3279 assert!(
3280 !index.content.contains("1.0\n<script>"),
3281 "Server version should have newlines stripped"
3282 );
3283 }
3284
3285 #[test]
3286 fn test_generate_sanitizes_schema_and_category_jsdoc_injection() {
3287 let generator = ProgressiveGenerator::new().unwrap();
3288 let mut server_info = create_test_server_info();
3289 server_info.tools[0].input_schema = json!({
3290 "type": "object",
3291 "description": "Schema */ injected\nnext",
3292 "properties": {
3293 "title": {
3294 "type": "string",
3295 "description": "Title */ injected\nnext"
3296 }
3297 },
3298 "required": ["title"]
3299 });
3300
3301 let mut categorizations = HashMap::new();
3302 categorizations.insert(
3303 "create_issue".to_string(),
3304 ToolCategorization {
3305 category: "issues */ injected\nnext".to_string(),
3306 keywords: vec!["create,*/ injected\nnext".to_string()],
3307 short_description: "Create */ injected\nnext".to_string(),
3308 },
3309 );
3310
3311 let code = generator
3312 .generate_with_categories(&server_info, &test_config(), &categorizations)
3313 .unwrap();
3314 let tool = code
3315 .files
3316 .iter()
3317 .find(|f| f.path == "createIssue.ts")
3318 .unwrap();
3319
3320 for raw in [
3321 "Schema */ injected",
3322 "Title */ injected",
3323 "issues */ injected",
3324 "create,*/ injected",
3325 "Create */ injected",
3326 ] {
3327 assert!(
3328 !tool.content.contains(raw),
3329 "generated JSDoc should not contain raw injection text: {raw}"
3330 );
3331 }
3332
3333 assert!(tool.content.contains("Schema *\\/ injected next"));
3334 assert!(tool.content.contains("Title *\\/ injected next"));
3335 assert!(tool.content.contains("issues *\\/ injected next"));
3336 assert!(tool.content.contains("create,*\\/ injected next"));
3337 assert!(tool.content.contains("Create *\\/ injected next"));
3338 }
3339
3340 fn server_info_with_tool_count(count: usize) -> ServerInfo {
3343 ServerInfo {
3344 id: ServerId::new("bulk-server").unwrap(),
3345 name: "Bulk Server".to_string(),
3346 version: "1.0.0".to_string(),
3347 tools: (0..count)
3348 .map(|i| ToolInfo {
3349 name: ToolName::new(format!("tool{i}")).unwrap(),
3350 description: String::new(),
3351 input_schema: json!({}),
3352 output_schema: None,
3353 })
3354 .collect(),
3355 capabilities: ServerCapabilities {
3356 supports_tools: true,
3357 supports_resources: false,
3358 supports_prompts: false,
3359 },
3360 }
3361 }
3362
3363 #[test]
3364 fn test_generate_rejects_tool_count_that_would_exceed_max_generated_files() {
3365 let server_info = server_info_with_tool_count(MAX_GENERATED_FILES - FIXED_FILE_COUNT + 1);
3366 let generator = ProgressiveGenerator::new().unwrap();
3367
3368 let result = generator.generate(&server_info, &test_config());
3369
3370 assert!(result.is_err());
3371 assert!(result.unwrap_err().is_resource_limit_exceeded());
3372 }
3373
3374 #[test]
3375 fn test_generate_accepts_tool_count_at_exact_max_generated_files() {
3376 let server_info = server_info_with_tool_count(MAX_GENERATED_FILES - FIXED_FILE_COUNT);
3377 let generator = ProgressiveGenerator::new().unwrap();
3378
3379 let code = generator.generate(&server_info, &test_config()).unwrap();
3380
3381 assert_eq!(code.file_count(), MAX_GENERATED_FILES);
3382 }
3383
3384 #[test]
3385 fn test_generate_with_categories_rejects_tool_count_that_would_exceed_max_generated_files() {
3386 let server_info = server_info_with_tool_count(MAX_GENERATED_FILES - FIXED_FILE_COUNT + 1);
3387 let generator = ProgressiveGenerator::new().unwrap();
3388
3389 let result =
3390 generator.generate_with_categories(&server_info, &test_config(), &HashMap::new());
3391
3392 assert!(result.is_err());
3393 assert!(result.unwrap_err().is_resource_limit_exceeded());
3394 }
3395
3396 #[test]
3397 fn test_add_tracked_rejects_oversized_total_bytes() {
3398 let mut code = GeneratedCode::new();
3399 let mut total_bytes = 0usize;
3400
3401 let result = add_tracked(
3402 &mut code,
3403 &mut total_bytes,
3404 GeneratedFile {
3405 path: "big.ts".to_string(),
3406 content: "a".repeat(MAX_GENERATED_BYTES + 1),
3407 },
3408 );
3409
3410 assert!(result.is_err());
3411 assert!(result.unwrap_err().is_resource_limit_exceeded());
3412 }
3413
3414 #[test]
3415 fn test_add_tracked_accepts_total_bytes_at_exact_max() {
3416 let mut code = GeneratedCode::new();
3417 let mut total_bytes = 0usize;
3418
3419 let result = add_tracked(
3420 &mut code,
3421 &mut total_bytes,
3422 GeneratedFile {
3423 path: "big.ts".to_string(),
3424 content: "a".repeat(MAX_GENERATED_BYTES),
3425 },
3426 );
3427
3428 assert!(result.is_ok());
3429 }
3430
3431 #[test]
3439 fn test_add_tracked_rejects_immediately_once_running_total_exceeds_max() {
3440 let mut code = GeneratedCode::new();
3441 let mut total_bytes = MAX_GENERATED_BYTES - 1;
3442
3443 let result = add_tracked(
3444 &mut code,
3445 &mut total_bytes,
3446 GeneratedFile {
3447 path: "second.ts".to_string(),
3448 content: "ab".to_string(),
3449 },
3450 );
3451
3452 assert!(result.is_err());
3453 assert!(result.unwrap_err().is_resource_limit_exceeded());
3454 assert_eq!(code.file_count(), 0);
3457 }
3458}