Skip to main content

mcp_execution_codegen/progressive/
generator.rs

1//! Progressive loading code generator.
2//!
3//! Generates TypeScript files for progressive loading where each tool
4//! is in a separate file, enabling Claude Code to load only what it needs.
5//!
6//! # Examples
7//!
8//! ```no_run
9//! use mcp_execution_codegen::progressive::ProgressiveGenerator;
10//! use mcp_execution_introspector::{Introspector, ServerInfo};
11//! use mcp_execution_core::{ServerId, ServerConfig};
12//!
13//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
14//! let mut introspector = Introspector::new();
15//! let server_id = ServerId::new("github");
16//! let config = ServerConfig::builder().command("/path/to/server".to_string()).build();
17//! let info = introspector.discover_server(server_id, &config).await?;
18//!
19//! let generator = ProgressiveGenerator::new()?;
20//! let code = generator.generate(&info)?;
21//!
22//! // Generated files:
23//! // - index.ts (re-exports)
24//! // - createIssue.ts
25//! // - updateIssue.ts
26//! // - ...
27//! // - _runtime/mcp-bridge.ts
28//! println!("Generated {} files", code.file_count());
29//! # Ok(())
30//! # }
31//! ```
32
33use 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/// Generator for progressive loading TypeScript files.
45///
46/// Creates one file per tool plus an index file and runtime bridge,
47/// enabling progressive loading where only needed tools are loaded.
48///
49/// # Thread Safety
50///
51/// This type is `Send` and `Sync`, allowing safe use across threads.
52///
53/// # Examples
54///
55/// ```
56/// use mcp_execution_codegen::progressive::ProgressiveGenerator;
57///
58/// let generator = ProgressiveGenerator::new().unwrap();
59/// ```
60#[derive(Debug)]
61pub struct ProgressiveGenerator<'a> {
62    engine: TemplateEngine<'a>,
63}
64
65impl<'a> ProgressiveGenerator<'a> {
66    /// Creates a new progressive generator.
67    ///
68    /// Initializes the template engine and registers all progressive
69    /// loading templates.
70    ///
71    /// # Errors
72    ///
73    /// Returns error if template registration fails (should not happen
74    /// with valid built-in templates).
75    ///
76    /// # Examples
77    ///
78    /// ```
79    /// use mcp_execution_codegen::progressive::ProgressiveGenerator;
80    ///
81    /// let generator = ProgressiveGenerator::new().unwrap();
82    /// ```
83    pub fn new() -> Result<Self> {
84        let engine = TemplateEngine::new()?;
85        Ok(Self { engine })
86    }
87
88    /// Generates progressive loading files for a server.
89    ///
90    /// Creates one TypeScript file per tool, plus:
91    /// - `index.ts`: Re-exports all tools
92    /// - `_runtime/mcp-bridge.ts`: Runtime bridge for calling MCP tools
93    /// - `package.json`: ES module type declaration
94    ///
95    /// # Arguments
96    ///
97    /// * `server_info` - MCP server introspection data
98    ///
99    /// # Returns
100    ///
101    /// Generated code with one file per tool plus index and runtime bridge.
102    ///
103    /// # Errors
104    ///
105    /// Returns error if:
106    /// - Template rendering fails
107    /// - Type conversion fails
108    ///
109    /// # Examples
110    ///
111    /// ```no_run
112    /// use mcp_execution_codegen::progressive::ProgressiveGenerator;
113    /// use mcp_execution_introspector::{ServerInfo, ServerCapabilities};
114    /// use mcp_execution_core::ServerId;
115    ///
116    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
117    /// let generator = ProgressiveGenerator::new()?;
118    ///
119    /// let info = ServerInfo {
120    ///     id: ServerId::new("github"),
121    ///     name: "GitHub".to_string(),
122    ///     version: "1.0.0".to_string(),
123    ///     tools: vec![],
124    ///     capabilities: ServerCapabilities {
125    ///         supports_tools: true,
126    ///         supports_resources: false,
127    ///         supports_prompts: false,
128    ///     },
129    /// };
130    ///
131    /// let code = generator.generate(&info)?;
132    ///
133    /// // Files generated:
134    /// // - index.ts
135    /// // - _runtime/mcp-bridge.ts
136    /// // - package.json
137    /// // - one file per tool
138    /// println!("Generated {} files", code.file_count());
139    /// # Ok(())
140    /// # }
141    /// ```
142    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        // Generate tool files (one per tool)
152        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        // Generate index.ts
165        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        // Generate runtime bridge
176        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        // Generate package.json for ES module identification
189        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    /// Generates progressive loading files with categorization metadata.
206    ///
207    /// Like `generate`, but includes full categorization information from Claude's
208    /// analysis. Categories, keywords, and short descriptions are displayed in
209    /// the index file and included in individual tool file headers.
210    ///
211    /// # Arguments
212    ///
213    /// * `server_info` - MCP server introspection data
214    /// * `categorizations` - Map of tool name to categorization metadata
215    ///
216    /// # Returns
217    ///
218    /// Generated code with categorization metadata included.
219    ///
220    /// # Errors
221    ///
222    /// Returns error if template rendering fails.
223    ///
224    /// # Examples
225    ///
226    /// ```no_run
227    /// use mcp_execution_codegen::progressive::{ProgressiveGenerator, ToolCategorization};
228    /// use mcp_execution_introspector::{ServerInfo, ServerCapabilities};
229    /// use mcp_execution_core::ServerId;
230    /// use std::collections::HashMap;
231    ///
232    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
233    /// let generator = ProgressiveGenerator::new()?;
234    ///
235    /// let info = ServerInfo {
236    ///     id: ServerId::new("github"),
237    ///     name: "GitHub".to_string(),
238    ///     version: "1.0.0".to_string(),
239    ///     tools: vec![],
240    ///     capabilities: ServerCapabilities {
241    ///         supports_tools: true,
242    ///         supports_resources: false,
243    ///         supports_prompts: false,
244    ///     },
245    /// };
246    ///
247    /// let mut categorizations = HashMap::new();
248    /// categorizations.insert("create_issue".to_string(), ToolCategorization {
249    ///     category: "issues".to_string(),
250    ///     keywords: "create,issue,new,bug".to_string(),
251    ///     short_description: "Create a new issue".to_string(),
252    /// });
253    ///
254    /// let code = generator.generate_with_categories(&info, &categorizations)?;
255    /// # Ok(())
256    /// # }
257    /// ```
258    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        // Generate tool files (one per tool) with categorization metadata
272        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        // Generate index.ts with category grouping
291        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        // Generate runtime bridge (same as non-categorized)
305        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        // Generate package.json for ES module identification
318        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    /// Creates tool context from MCP tool information.
335    ///
336    /// Converts MCP tool schema to the format needed for template rendering.
337    ///
338    /// # Errors
339    ///
340    /// Returns error if schema conversion fails.
341    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        // Extract properties from input schema
350        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    /// Creates index context from server information.
366    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        // Build category groups if categorizations are provided
388        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            // Sort categories alphabetically, but keep "uncategorized" last
405            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    /// Extracts property information from JSON Schema.
428    ///
429    /// Converts JSON Schema properties into `PropertyInfo` structures
430    /// suitable for template rendering.
431    ///
432    /// # Errors
433    ///
434    /// Returns error if schema is malformed or type conversion fails.
435    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            // Extract description if available
459            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
483/// Sanitizes a server-controlled string for safe interpolation into JSDoc block comments.
484///
485/// Prevents JSDoc comment terminator injection by replacing `*/` sequences,
486/// stripping newlines, and truncating to a safe maximum length.
487fn 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        // Should generate:
594        // - 2 tool files
595        // - 1 index.ts
596        // - 1 runtime bridge
597        // - 1 package.json
598        assert_eq!(code.file_count(), 5);
599
600        // Check tool files exist
601        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        // Find name property
686        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        // Find age property
692        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        // Raw injected strings must not appear in the output.
749        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}