1use crate::common::types::{GeneratedCode, GeneratedFile};
34use crate::common::typescript::{extract_properties, to_camel_case};
35use crate::progressive::types::{
36 BridgeContext, CategoryInfo, IndexContext, PropertyInfo, ToolCategorization, ToolContext,
37 ToolSummary,
38};
39use crate::template_engine::TemplateEngine;
40use mcp_execution_core::{Error, Result};
41use mcp_execution_introspector::ServerInfo;
42use std::collections::HashMap;
43
44#[derive(Debug)]
61pub struct ProgressiveGenerator<'a> {
62 engine: TemplateEngine<'a>,
63}
64
65impl<'a> ProgressiveGenerator<'a> {
66 pub fn new() -> Result<Self> {
84 let engine = TemplateEngine::new()?;
85 Ok(Self { engine })
86 }
87
88 pub fn generate(&self, server_info: &ServerInfo) -> Result<GeneratedCode> {
143 tracing::info!(
144 "Generating progressive loading code for server: {}",
145 server_info.name
146 );
147
148 let mut code = GeneratedCode::new();
149 let server_id = server_info.id.as_str();
150
151 for tool in &server_info.tools {
153 let tool_context = self.create_tool_context(server_id, tool, None)?;
154 let tool_code = self.engine.render("progressive/tool", &tool_context)?;
155
156 code.add_file(GeneratedFile {
157 path: format!("{}.ts", tool_context.typescript_name),
158 content: tool_code,
159 });
160
161 tracing::debug!("Generated tool file: {}.ts", tool_context.typescript_name);
162 }
163
164 let index_context = self.create_index_context(server_info, None)?;
166 let index_code = self.engine.render("progressive/index", &index_context)?;
167
168 code.add_file(GeneratedFile {
169 path: "index.ts".to_string(),
170 content: index_code,
171 });
172
173 tracing::debug!("Generated index.ts");
174
175 let bridge_context = BridgeContext::default();
177 let bridge_code = self
178 .engine
179 .render("progressive/runtime-bridge", &bridge_context)?;
180
181 code.add_file(GeneratedFile {
182 path: "_runtime/mcp-bridge.ts".to_string(),
183 content: bridge_code,
184 });
185
186 tracing::debug!("Generated _runtime/mcp-bridge.ts");
187
188 code.add_file(GeneratedFile {
190 path: "package.json".to_string(),
191 content: "{\"type\":\"module\"}\n".to_string(),
192 });
193
194 tracing::debug!("Generated package.json");
195
196 tracing::info!(
197 "Successfully generated {} files for {} (progressive loading)",
198 code.file_count(),
199 server_info.name
200 );
201
202 Ok(code)
203 }
204
205 pub fn generate_with_categories(
259 &self,
260 server_info: &ServerInfo,
261 categorizations: &HashMap<String, ToolCategorization>,
262 ) -> Result<GeneratedCode> {
263 tracing::info!(
264 "Generating progressive loading code with categorizations for server: {}",
265 server_info.name
266 );
267
268 let mut code = GeneratedCode::new();
269 let server_id = server_info.id.as_str();
270
271 for tool in &server_info.tools {
273 let tool_name = tool.name.as_str();
274 let categorization = categorizations.get(tool_name);
275 let tool_context = self.create_tool_context(server_id, tool, categorization)?;
276 let tool_code = self.engine.render("progressive/tool", &tool_context)?;
277
278 code.add_file(GeneratedFile {
279 path: format!("{}.ts", tool_context.typescript_name),
280 content: tool_code,
281 });
282
283 tracing::debug!(
284 "Generated tool file: {}.ts (category: {:?})",
285 tool_context.typescript_name,
286 categorization.map(|c| &c.category)
287 );
288 }
289
290 let index_context = self.create_index_context(server_info, Some(categorizations))?;
292 let index_code = self.engine.render("progressive/index", &index_context)?;
293
294 code.add_file(GeneratedFile {
295 path: "index.ts".to_string(),
296 content: index_code,
297 });
298
299 tracing::debug!(
300 "Generated index.ts with {} categorizations",
301 categorizations.len()
302 );
303
304 let bridge_context = BridgeContext::default();
306 let bridge_code = self
307 .engine
308 .render("progressive/runtime-bridge", &bridge_context)?;
309
310 code.add_file(GeneratedFile {
311 path: "_runtime/mcp-bridge.ts".to_string(),
312 content: bridge_code,
313 });
314
315 tracing::debug!("Generated _runtime/mcp-bridge.ts");
316
317 code.add_file(GeneratedFile {
319 path: "package.json".to_string(),
320 content: "{\"type\":\"module\"}\n".to_string(),
321 });
322
323 tracing::debug!("Generated package.json");
324
325 tracing::info!(
326 "Successfully generated {} files for {} with categorizations (progressive loading)",
327 code.file_count(),
328 server_info.name
329 );
330
331 Ok(code)
332 }
333
334 fn create_tool_context(
342 &self,
343 server_id: &str,
344 tool: &mcp_execution_introspector::ToolInfo,
345 categorization: Option<&ToolCategorization>,
346 ) -> Result<ToolContext> {
347 let typescript_name = to_camel_case(tool.name.as_str());
348
349 let properties = self.extract_property_infos(&tool.input_schema)?;
351
352 Ok(ToolContext {
353 server_id: server_id.to_string(),
354 name: sanitize_jsdoc(tool.name.as_str(), 256),
355 typescript_name,
356 description: sanitize_jsdoc(&tool.description, 256),
357 input_schema: sanitize_schema_jsdoc_descriptions(tool.input_schema.clone()),
358 properties,
359 category: categorization.map(|c| sanitize_jsdoc(&c.category, 128)),
360 keywords: categorization.map(|c| sanitize_jsdoc(&c.keywords, 256)),
361 short_description: categorization.map(|c| sanitize_jsdoc(&c.short_description, 256)),
362 })
363 }
364
365 fn create_index_context(
367 &self,
368 server_info: &ServerInfo,
369 categorizations: Option<&HashMap<String, ToolCategorization>>,
370 ) -> Result<IndexContext> {
371 let tools: Vec<ToolSummary> = server_info
372 .tools
373 .iter()
374 .map(|tool| {
375 let tool_name = tool.name.as_str();
376 let cat = categorizations.and_then(|c| c.get(tool_name));
377 ToolSummary {
378 typescript_name: to_camel_case(tool_name),
379 description: sanitize_jsdoc(&tool.description, 256),
380 category: cat.map(|c| sanitize_jsdoc(&c.category, 128)),
381 keywords: cat.map(|c| sanitize_jsdoc(&c.keywords, 256)),
382 short_description: cat.map(|c| sanitize_jsdoc(&c.short_description, 256)),
383 }
384 })
385 .collect();
386
387 let category_groups = categorizations.map(|_| {
389 let mut groups: HashMap<String, Vec<ToolSummary>> = HashMap::new();
390
391 for tool in &tools {
392 let cat_name = tool
393 .category
394 .clone()
395 .unwrap_or_else(|| "uncategorized".to_string());
396 groups.entry(cat_name).or_default().push(tool.clone());
397 }
398
399 let mut result: Vec<CategoryInfo> = groups
400 .into_iter()
401 .map(|(name, tools)| CategoryInfo { name, tools })
402 .collect();
403
404 result.sort_by(|a, b| {
406 if a.name == "uncategorized" {
407 std::cmp::Ordering::Greater
408 } else if b.name == "uncategorized" {
409 std::cmp::Ordering::Less
410 } else {
411 a.name.cmp(&b.name)
412 }
413 });
414
415 result
416 });
417
418 Ok(IndexContext {
419 server_name: sanitize_jsdoc(&server_info.name, 256),
420 server_version: sanitize_jsdoc(&server_info.version, 64),
421 tool_count: server_info.tools.len(),
422 tools,
423 categories: category_groups,
424 })
425 }
426
427 fn extract_property_infos(&self, schema: &serde_json::Value) -> Result<Vec<PropertyInfo>> {
436 let raw_properties = extract_properties(schema);
437
438 let mut properties = Vec::new();
439 for prop in raw_properties {
440 let name = prop["name"]
441 .as_str()
442 .ok_or_else(|| Error::ValidationError {
443 field: "name".to_string(),
444 reason: "Property name is not a string".to_string(),
445 })?
446 .to_string();
447
448 let typescript_type = prop["type"]
449 .as_str()
450 .ok_or_else(|| Error::ValidationError {
451 field: "type".to_string(),
452 reason: "Property type is not a string".to_string(),
453 })?
454 .to_string();
455
456 let required = prop["required"].as_bool().unwrap_or(false);
457
458 let description = if let Some(obj) = schema.as_object() {
460 obj.get("properties")
461 .and_then(|props| props.as_object())
462 .and_then(|props| props.get(&name))
463 .and_then(|prop_schema| prop_schema.as_object())
464 .and_then(|obj| obj.get("description"))
465 .and_then(|desc| desc.as_str())
466 .map(|desc| sanitize_jsdoc(desc, 256))
467 } else {
468 None
469 };
470
471 properties.push(PropertyInfo {
472 name,
473 typescript_type,
474 description,
475 required,
476 });
477 }
478
479 Ok(properties)
480 }
481}
482
483fn sanitize_jsdoc(s: &str, max_len: usize) -> String {
488 let sanitized = s.replace("*/", "*\\/").replace(['\r', '\n'], " ");
489 if sanitized.chars().count() > max_len {
490 sanitized.chars().take(max_len).collect()
491 } else {
492 sanitized
493 }
494}
495
496fn sanitize_schema_jsdoc_descriptions(mut value: serde_json::Value) -> serde_json::Value {
497 sanitize_schema_jsdoc_value(&mut value);
498 value
499}
500
501fn sanitize_schema_jsdoc_value(value: &mut serde_json::Value) {
502 match value {
503 serde_json::Value::Object(map) => {
504 for (key, child) in map.iter_mut() {
505 if key == "description" {
506 if let Some(description) = child.as_str() {
507 *child = serde_json::Value::String(sanitize_jsdoc(description, 256));
508 } else {
509 *child = serde_json::Value::Null;
510 }
511 } else {
512 sanitize_schema_jsdoc_value(child);
513 }
514 }
515 }
516 serde_json::Value::Array(values) => {
517 for child in values {
518 sanitize_schema_jsdoc_value(child);
519 }
520 }
521 _ => {}
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use mcp_execution_core::{ServerId, ToolName};
529 use mcp_execution_introspector::{ServerCapabilities, ToolInfo};
530 use serde_json::json;
531
532 fn create_test_server_info() -> ServerInfo {
533 ServerInfo {
534 id: ServerId::new("test-server"),
535 name: "Test Server".to_string(),
536 version: "1.0.0".to_string(),
537 tools: vec![
538 ToolInfo {
539 name: ToolName::new("create_issue"),
540 description: "Creates a new issue".to_string(),
541 input_schema: json!({
542 "type": "object",
543 "properties": {
544 "title": {
545 "type": "string",
546 "description": "Issue title"
547 },
548 "body": {
549 "type": "string",
550 "description": "Issue body"
551 }
552 },
553 "required": ["title"]
554 }),
555 output_schema: None,
556 },
557 ToolInfo {
558 name: ToolName::new("update_issue"),
559 description: "Updates an existing issue".to_string(),
560 input_schema: json!({
561 "type": "object",
562 "properties": {
563 "id": {
564 "type": "number"
565 }
566 },
567 "required": ["id"]
568 }),
569 output_schema: None,
570 },
571 ],
572 capabilities: ServerCapabilities {
573 supports_tools: true,
574 supports_resources: false,
575 supports_prompts: false,
576 },
577 }
578 }
579
580 #[test]
581 fn test_progressive_generator_new() {
582 let generator = ProgressiveGenerator::new();
583 assert!(generator.is_ok());
584 }
585
586 #[test]
587 fn test_generate_progressive_files() {
588 let generator = ProgressiveGenerator::new().unwrap();
589 let server_info = create_test_server_info();
590
591 let code = generator.generate(&server_info).unwrap();
592
593 assert_eq!(code.file_count(), 5);
599
600 let tool_files: Vec<_> = code.files.iter().map(|f| f.path.as_str()).collect();
602
603 assert!(tool_files.contains(&"createIssue.ts"));
604 assert!(tool_files.contains(&"updateIssue.ts"));
605 assert!(tool_files.contains(&"index.ts"));
606 assert!(tool_files.contains(&"_runtime/mcp-bridge.ts"));
607 assert!(tool_files.contains(&"package.json"));
608 }
609
610 #[test]
611 fn test_create_tool_context() {
612 let generator = ProgressiveGenerator::new().unwrap();
613 let tool = ToolInfo {
614 name: ToolName::new("send_message"),
615 description: "Sends a message".to_string(),
616 input_schema: json!({
617 "type": "object",
618 "properties": {
619 "text": {"type": "string"}
620 },
621 "required": ["text"]
622 }),
623 output_schema: None,
624 };
625
626 let categorization = ToolCategorization {
627 category: "messaging".to_string(),
628 keywords: "send,message,chat".to_string(),
629 short_description: "Send a message".to_string(),
630 };
631 let context = generator
632 .create_tool_context("test-server", &tool, Some(&categorization))
633 .unwrap();
634
635 assert_eq!(context.server_id, "test-server");
636 assert_eq!(context.name, "send_message");
637 assert_eq!(context.typescript_name, "sendMessage");
638 assert_eq!(context.description, "Sends a message");
639 assert_eq!(context.properties.len(), 1);
640 assert_eq!(context.properties[0].name, "text");
641 assert_eq!(context.category, Some("messaging".to_string()));
642 assert_eq!(context.keywords, Some("send,message,chat".to_string()));
643 assert_eq!(
644 context.short_description,
645 Some("Send a message".to_string())
646 );
647 }
648
649 #[test]
650 fn test_create_index_context() {
651 let generator = ProgressiveGenerator::new().unwrap();
652 let server_info = create_test_server_info();
653
654 let context = generator.create_index_context(&server_info, None).unwrap();
655
656 assert_eq!(context.server_name, "Test Server");
657 assert_eq!(context.server_version, "1.0.0");
658 assert_eq!(context.tool_count, 2);
659 assert_eq!(context.tools.len(), 2);
660 assert_eq!(context.tools[0].typescript_name, "createIssue");
661 assert!(context.categories.is_none());
662 }
663
664 #[test]
665 fn test_extract_property_infos() {
666 let generator = ProgressiveGenerator::new().unwrap();
667 let schema = json!({
668 "type": "object",
669 "properties": {
670 "name": {
671 "type": "string",
672 "description": "User name"
673 },
674 "age": {
675 "type": "number"
676 }
677 },
678 "required": ["name"]
679 });
680
681 let props = generator.extract_property_infos(&schema).unwrap();
682
683 assert_eq!(props.len(), 2);
684
685 let name_prop = props.iter().find(|p| p.name == "name").unwrap();
687 assert_eq!(name_prop.typescript_type, "string");
688 assert_eq!(name_prop.description, Some("User name".to_string()));
689 assert!(name_prop.required);
690
691 let age_prop = props.iter().find(|p| p.name == "age").unwrap();
693 assert_eq!(age_prop.typescript_type, "number");
694 assert!(!age_prop.required);
695 }
696
697 #[test]
698 fn test_sanitize_jsdoc_strips_comment_terminator() {
699 assert_eq!(sanitize_jsdoc("Foo */ bar", 256), "Foo *\\/ bar");
700 }
701
702 #[test]
703 fn test_sanitize_jsdoc_replaces_newlines() {
704 assert_eq!(
705 sanitize_jsdoc("line1\nline2\r\nline3", 256),
706 "line1 line2 line3"
707 );
708 }
709
710 #[test]
711 fn test_sanitize_jsdoc_truncates() {
712 let long = "a".repeat(300);
713 assert_eq!(sanitize_jsdoc(&long, 256).chars().count(), 256);
714 }
715
716 #[test]
717 fn test_sanitize_jsdoc_passthrough() {
718 assert_eq!(sanitize_jsdoc("Normal string", 256), "Normal string");
719 }
720
721 #[test]
722 fn test_sanitize_schema_jsdoc_drops_non_string_descriptions() {
723 let sanitized = sanitize_schema_jsdoc_descriptions(json!({
724 "type": "object",
725 "description": {"text": "Schema */ injected\nnext"},
726 "properties": {
727 "title": {
728 "type": "string",
729 "description": ["Title */ injected\nnext"]
730 }
731 }
732 }));
733
734 assert!(sanitized["description"].is_null());
735 assert!(sanitized["properties"]["title"]["description"].is_null());
736 }
737
738 #[test]
739 fn test_generate_sanitizes_jsdoc_injection() {
740 let generator = ProgressiveGenerator::new().unwrap();
741 let mut server_info = create_test_server_info();
742 server_info.name = "Evil */ injection".to_string();
743 server_info.version = "1.0\n<script>".to_string();
744
745 let code = generator.generate(&server_info).unwrap();
746 let index = code.files.iter().find(|f| f.path == "index.ts").unwrap();
747
748 assert!(
750 !index.content.contains("Evil */ injection"),
751 "Server name should be sanitized in JSDoc"
752 );
753 assert!(
754 !index.content.contains("1.0\n<script>"),
755 "Server version should have newlines stripped"
756 );
757 }
758
759 #[test]
760 fn test_generate_sanitizes_schema_and_category_jsdoc_injection() {
761 let generator = ProgressiveGenerator::new().unwrap();
762 let mut server_info = create_test_server_info();
763 server_info.tools[0].input_schema = json!({
764 "type": "object",
765 "description": "Schema */ injected\nnext",
766 "properties": {
767 "title": {
768 "type": "string",
769 "description": "Title */ injected\nnext"
770 }
771 },
772 "required": ["title"]
773 });
774
775 let mut categorizations = HashMap::new();
776 categorizations.insert(
777 "create_issue".to_string(),
778 ToolCategorization {
779 category: "issues */ injected\nnext".to_string(),
780 keywords: "create,*/ injected\nnext".to_string(),
781 short_description: "Create */ injected\nnext".to_string(),
782 },
783 );
784
785 let code = generator
786 .generate_with_categories(&server_info, &categorizations)
787 .unwrap();
788 let tool = code
789 .files
790 .iter()
791 .find(|f| f.path == "createIssue.ts")
792 .unwrap();
793
794 for raw in [
795 "Schema */ injected",
796 "Title */ injected",
797 "issues */ injected",
798 "create,*/ injected",
799 "Create */ injected",
800 ] {
801 assert!(
802 !tool.content.contains(raw),
803 "generated JSDoc should not contain raw injection text: {raw}"
804 );
805 }
806
807 assert!(tool.content.contains("Schema *\\/ injected next"));
808 assert!(tool.content.contains("Title *\\/ injected next"));
809 assert!(tool.content.contains("issues *\\/ injected next"));
810 assert!(tool.content.contains("create,*\\/ injected next"));
811 assert!(tool.content.contains("Create *\\/ injected next"));
812 }
813}