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").unwrap();
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, &config)?;
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::{
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
53/// Files emitted by every `generate`/`generate_with_categories` call regardless of tool
54/// count: `index.ts`, the runtime bridge, `package.json`, `tsconfig.json`, and the `_meta.json`
55/// sidecar.
56const FIXED_FILE_COUNT: usize = 5;
57
58/// Maximum number of files a single `generate`/`generate_with_categories` call will produce
59/// (denial-of-service protection, CWE-400).
60///
61/// Each tool becomes its own `.ts` file, so this bounds the file-count amplification of a
62/// single generation run. Derived directly from
63/// `mcp_execution_introspector::MAX_TOOL_COUNT` (rather than an independently chosen number)
64/// plus this module's `FIXED_FILE_COUNT`, so a `ServerInfo` that already cleared introspection's
65/// own tool-count bound can never be deterministically rejected here for simply having "as many tools
66/// as introspection already allows" (issue #198 M1). This check remains meaningful
67/// defense-in-depth for callers that construct a `ServerInfo` directly rather than going
68/// through introspection.
69///
70/// # Examples
71///
72/// ```
73/// use mcp_execution_codegen::progressive::generator::MAX_GENERATED_FILES;
74///
75/// assert!(MAX_GENERATED_FILES > 0);
76/// ```
77pub const MAX_GENERATED_FILES: usize =
78    mcp_execution_introspector::MAX_TOOL_COUNT + FIXED_FILE_COUNT;
79
80/// Maximum total bytes across every file in a single `generate`/`generate_with_categories`
81/// call's output (denial-of-service protection, CWE-400).
82///
83/// Derived from `mcp_execution_introspector`'s own per-tool bounds — up to `MAX_TOOL_COUNT`
84/// tools, each up to `MAX_TOOL_NAME_LEN` + `MAX_TOOL_DESCRIPTION_LEN` + `MAX_SCHEMA_SIZE_BYTES`
85/// — rather than chosen independently, so a `ServerInfo` that already cleared introspection's
86/// own bounds can never be deterministically rejected here for simply being "as large as
87/// introspection already allows" (issue #198 M1). The 2x multiplier accounts for `_meta.json`
88/// re-embedding every tool's raw name/description/schema alongside the already-rendered `.ts`
89/// file content, roughly doubling the total.
90///
91/// # Examples
92///
93/// ```
94/// use mcp_execution_codegen::progressive::generator::MAX_GENERATED_BYTES;
95///
96/// assert!(MAX_GENERATED_BYTES > 0);
97/// ```
98pub 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
104/// Contents of the generated `package.json`.
105///
106/// Declares `@types/node` as a `devDependency` — pinned to major version 22, matching the
107/// Node.js version this project's own CI targets — because `_runtime/mcp-bridge.ts` imports
108/// Node builtins (`child_process`, `fs/promises`, `os`, `path`) and references ambient globals
109/// (`process`, the `NodeJS` namespace) that only resolve once type declarations for Node are
110/// present; without it, `tsc --noEmit` fails on the generated package even with a correct
111/// `tsconfig.json` (see `TSCONFIG_JSON` below).
112const PACKAGE_JSON: &str = "{\"type\":\"module\",\"devDependencies\":{\"@types/node\":\"^22\"}}\n";
113
114/// Contents of the generated `tsconfig.json`.
115///
116/// ## Implementation rationale (for maintainers)
117///
118/// Tool files import the runtime bridge with an explicit `.ts` extension (see
119/// `tool.ts.hbs`), which requires `allowImportingTsExtensions`. TypeScript requires `noEmit`
120/// (or a declaration/emit setting) whenever `allowImportingTsExtensions` is set, since that
121/// option only changes how imports are type-checked, not how output is emitted.
122///
123/// `"types": ["node"]` is explicit rather than relying on automatic `@types/*` acquisition:
124/// TypeScript 5.x auto-includes every package under `node_modules/@types` when `types` is
125/// unset, but TypeScript 7 (the native/Go-based compiler) does not extend that same implicit
126/// behavior to `@types/node`'s ambient globals (`process`, the `NodeJS` namespace) — `tsc
127/// --noEmit` fails with `TS2591`/`TS2503` under TS 7 even with `@types/node` correctly
128/// installed, unless `types` names it explicitly. Listing it explicitly works identically
129/// under both major versions.
130///
131/// ## Consumer guidance (intended behavior for users)
132///
133/// **This is a leaf configuration, not intended to be extended.** If a consumer's own
134/// `tsconfig.json` uses `"extends"` to reference the generated one, `"noEmit": true` will be
135/// inherited silently, preventing their own build from emitting output. **Do not extend this
136/// file.**
137///
138/// **This file is regenerated on every `generate` call.** Any manual edits are lost when
139/// `mcp-execution generate` runs again, the same as `package.json`. Treat the generated
140/// `tsconfig.json` as read-only.
141///
142/// **How to use the generated package:** The generated TypeScript files are a standalone
143/// package meant to be executed or type-checked as a separate process, not merged into your
144/// own TypeScript compilation:
145/// - Execute the generated code directly via a TS-aware runtime: `tsx` (for Node.js),
146///   `deno`, or Node.js's native type-stripping (when available).
147/// - Or type-check it independently: run `tsc -p <generated-dir>` as a separate build step,
148///   without merging it into your own `include`-based program.
149/// - If your own build uses a bundler (esbuild, swc, Vite, etc.) that doesn't enforce
150///   TypeScript's `noEmit` constraint, you may be able to bundle the generated files
151///   alongside your code (consult your bundler's documentation for mixing `noEmit`
152///   and emitting configurations).
153const 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/// Generator for progressive loading TypeScript files.
169///
170/// Creates one file per tool plus an index file and runtime bridge,
171/// enabling progressive loading where only needed tools are loaded.
172///
173/// # Thread Safety
174///
175/// This type is `Send` and `Sync`, allowing safe use across threads.
176///
177/// # Examples
178///
179/// ```
180/// use mcp_execution_codegen::progressive::ProgressiveGenerator;
181///
182/// let generator = ProgressiveGenerator::new().unwrap();
183/// ```
184#[derive(Debug)]
185pub struct ProgressiveGenerator<'a> {
186    engine: TemplateEngine<'a>,
187}
188
189impl ProgressiveGenerator<'_> {
190    /// Creates a new progressive generator.
191    ///
192    /// Initializes the template engine and registers all progressive
193    /// loading templates.
194    ///
195    /// # Errors
196    ///
197    /// Returns error if template registration fails (should not happen
198    /// with valid built-in templates).
199    ///
200    /// # Examples
201    ///
202    /// ```
203    /// use mcp_execution_codegen::progressive::ProgressiveGenerator;
204    ///
205    /// let generator = ProgressiveGenerator::new().unwrap();
206    /// ```
207    pub fn new() -> Result<Self> {
208        let engine = TemplateEngine::new()?;
209        Ok(Self { engine })
210    }
211
212    /// Generates progressive loading files for a server.
213    ///
214    /// Creates one TypeScript file per tool, plus:
215    /// - `index.ts`: Re-exports all tools
216    /// - `_runtime/mcp-bridge.ts`: Runtime bridge for calling MCP tools
217    /// - `package.json`: ES module type declaration
218    /// - `tsconfig.json`: compiler options allowing the `.ts`-extensioned imports above
219    ///
220    /// Delegates to [`generate_with_categories`](Self::generate_with_categories) with an empty
221    /// categorization map, so `index.ts` contains no category grouping.
222    ///
223    /// # Arguments
224    ///
225    /// * `server_info` - MCP server introspection data
226    /// * `server_config` - The [`ServerConfig`] used to connect to and introspect the server,
227    ///   used to stamp the `_meta.json` sidecar's [`GenerationProvenance`]
228    ///
229    /// # Returns
230    ///
231    /// Generated code with one file per tool plus index and runtime bridge.
232    ///
233    /// # Errors
234    ///
235    /// Returns error if:
236    /// - Template rendering fails
237    /// - Type conversion fails
238    ///
239    /// # Examples
240    ///
241    /// ```no_run
242    /// use mcp_execution_codegen::progressive::ProgressiveGenerator;
243    /// use mcp_execution_introspector::{ServerInfo, ServerCapabilities};
244    /// use mcp_execution_core::{ServerConfig, ServerId};
245    ///
246    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
247    /// let generator = ProgressiveGenerator::new()?;
248    ///
249    /// let info = ServerInfo {
250    ///     id: ServerId::new("github").unwrap(),
251    ///     name: "GitHub".to_string(),
252    ///     version: "1.0.0".to_string(),
253    ///     tools: vec![],
254    ///     capabilities: ServerCapabilities {
255    ///         supports_tools: true,
256    ///         supports_resources: false,
257    ///         supports_prompts: false,
258    ///     },
259    /// };
260    /// let config = ServerConfig::builder().command("/path/to/github-server".to_string()).build()?;
261    ///
262    /// let code = generator.generate(&info, &config)?;
263    ///
264    /// // Files generated:
265    /// // - index.ts
266    /// // - _runtime/mcp-bridge.ts
267    /// // - package.json
268    /// // - tsconfig.json
269    /// // - one file per tool
270    /// println!("Generated {} files", code.file_count());
271    /// # Ok(())
272    /// # }
273    /// ```
274    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    /// Generates progressive loading files with categorization metadata.
283    ///
284    /// Like `generate`, but includes full categorization information from Claude's
285    /// analysis. Categories, keywords, and short descriptions are displayed in
286    /// the index file and included in individual tool file headers.
287    ///
288    /// # Arguments
289    ///
290    /// * `server_info` - MCP server introspection data
291    /// * `server_config` - The [`ServerConfig`] used to connect to and introspect the server,
292    ///   used to stamp the `_meta.json` sidecar's [`GenerationProvenance`]
293    /// * `categorizations` - Map of tool name to categorization metadata
294    ///
295    /// # Returns
296    ///
297    /// Generated code with categorization metadata included.
298    ///
299    /// # Errors
300    ///
301    /// Returns error if template rendering fails.
302    ///
303    /// # Examples
304    ///
305    /// ```no_run
306    /// use mcp_execution_codegen::progressive::{ProgressiveGenerator, ToolCategorization};
307    /// use mcp_execution_introspector::{ServerInfo, ServerCapabilities};
308    /// use mcp_execution_core::{ServerConfig, ServerId};
309    /// use std::collections::HashMap;
310    ///
311    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
312    /// let generator = ProgressiveGenerator::new()?;
313    ///
314    /// let info = ServerInfo {
315    ///     id: ServerId::new("github").unwrap(),
316    ///     name: "GitHub".to_string(),
317    ///     version: "1.0.0".to_string(),
318    ///     tools: vec![],
319    ///     capabilities: ServerCapabilities {
320    ///         supports_tools: true,
321    ///         supports_resources: false,
322    ///         supports_prompts: false,
323    ///     },
324    /// };
325    /// let config = ServerConfig::builder().command("/path/to/github-server".to_string()).build()?;
326    ///
327    /// let mut categorizations = HashMap::new();
328    /// categorizations.insert("create_issue".to_string(), ToolCategorization {
329    ///     category: "issues".to_string(),
330    ///     keywords: vec!["create".to_string(), "issue".to_string(), "new".to_string(), "bug".to_string()],
331    ///     short_description: "Create a new issue".to_string(),
332    /// });
333    ///
334    /// let code = generator.generate_with_categories(&info, &config, &categorizations)?;
335    /// # Ok(())
336    /// # }
337    /// ```
338    #[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        // Generate _meta.json sidecar with structured tool metadata
385        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    /// Renders one `.ts` file per tool with categorization metadata, tracking each into `code`.
411    ///
412    /// Returns per-tool [`ToolMetadata`] in tool order for the `_meta.json` sidecar.
413    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    /// Builds and renders `index.ts` with category grouping, tracking it into `code`.
480    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    /// Emits the tool-independent scaffolding files: the runtime bridge, `package.json`, and
510    /// `tsconfig.json`.
511    fn emit_scaffolding_files(
512        &self,
513        code: &mut GeneratedCode,
514        total_bytes: &mut usize,
515    ) -> Result<()> {
516        // Generate runtime bridge (same as non-categorized)
517        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        // Generate package.json for ES module identification and the @types/node devDependency
534        // needed for the runtime bridge to type-check (see PACKAGE_JSON doc comment)
535        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        // Generate tsconfig.json so `tsc --noEmit` accepts the `.ts`-extensioned import in
547        // each tool file (see TSCONFIG_JSON doc comment)
548        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    /// Creates tool context from MCP tool information.
563    ///
564    /// Converts MCP tool schema to the format needed for template rendering.
565    ///
566    /// `typescript_name` must be pre-resolved via [`resolve_typescript_names`] so that
567    /// collisions across a server's tools are disambiguated consistently between the tool
568    /// file and its `index.ts` re-export.
569    ///
570    /// `properties` must come from [`extract_property_data`](Self::extract_property_data) run
571    /// against `tool.input_schema`. It is taken as a parameter rather than derived internally
572    /// so that callers generating both the tool context and [`ToolMetadata`] for the same tool
573    /// (see [`create_tool_metadata`](Self::create_tool_metadata)) can share a single schema
574    /// walk instead of parsing and sanitizing the same schema twice (issue #295).
575    ///
576    /// Takes no `&self`: unlike [`extract_property_data`](Self::extract_property_data), nothing
577    /// here reads generator state (the `Handlebars` engine is only touched by the caller, when
578    /// rendering the context this returns).
579    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        // Falls back to the tool's own description when no LLM categorization is
588        // available, so the header JSDoc always emits `@description` (issue #94).
589        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    /// Creates index context from server information.
610    ///
611    /// `typescript_names` must be the same pre-resolved mapping (from
612    /// [`resolve_typescript_names`]) used to generate each tool's file, so the `index.ts`
613    /// re-exports reference the exact identifiers those files actually export.
614    ///
615    /// Takes no `&self`: this only transforms the arguments it is given, and does not touch
616    /// generator state.
617    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        // Build category groups if categorizations are provided and non-empty. Filtering on
640        // emptiness (not just Option-ness) matters because `generate` delegates to
641        // `generate_with_categories` with `Some(&HashMap::new())`: an empty-but-`Some` map must
642        // behave identically to `None` (no category grouping), not synthesize a spurious
643        // "uncategorized" group.
644        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            // Sort categories alphabetically, but keep "uncategorized" last
661            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    /// Wraps a per-tool codegen failure — property-schema extraction, template rendering, or
684    /// output tracking — in [`Error::ScriptGenerationError`] so the failing tool's name
685    /// survives past the point where `tool` goes out of scope, instead of the generic
686    /// [`Error::ValidationError`]/[`Error::SerializationError`]/[`Error::ResourceLimitExceeded`]
687    /// each stage raises on its own. `stage` is a short, lower-case description of the failed
688    /// step (e.g. `"extract property schema"`) used to build `message`.
689    ///
690    /// Unlike [`TemplateEngine`]'s convention of embedding the source's `Display` text into
691    /// `message` and setting `source: None`, this
692    /// keeps `source` populated: `classify_exit_code` in `mcp-execution-cli` downcasts into it
693    /// so a wrapped [`Error::ResourceLimitExceeded`] still maps to `SERVER_ERROR` rather than
694    /// collapsing to the generic exit code for every wrapped cause.
695    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    /// Extracts property information from JSON Schema, discarding raw descriptions.
704    ///
705    /// Converts JSON Schema properties into `PropertyInfo` structures
706    /// suitable for template rendering. Sibling property names that sanitize to the same
707    /// TypeScript identifier (e.g. `a-b` and `a.b` both becoming `a_b`) are disambiguated
708    /// with a numeric suffix, since these become fields of the same generated `Params`
709    /// interface and an undetected collision would produce a duplicate, non-compiling field.
710    ///
711    /// Test-only: production code calls [`extract_property_data`](Self::extract_property_data)
712    /// directly and shares the single resulting `Vec` between
713    /// [`create_tool_context`](Self::create_tool_context) and
714    /// [`create_tool_metadata`](Self::create_tool_metadata) instead of extracting twice (issue
715    /// #295). This wrapper remains for tests that only care about the sanitized half.
716    ///
717    /// # Errors
718    ///
719    /// Returns error if schema is malformed or type conversion fails.
720    #[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    /// Extracts property information from JSON Schema, alongside each property's raw
729    /// (un-sanitized) description.
730    ///
731    /// Callers that only need the JSDoc-sanitized `PropertyInfo` for template rendering (e.g.
732    /// [`create_tool_context`](Self::create_tool_context)) can discard the raw-description half.
733    /// Consumers that need the description as originally authored — e.g. the `_meta.json`
734    /// sidecar built by [`create_tool_metadata`](Self::create_tool_metadata), which is JSON
735    /// consumed by Rust rather than text interpolated into a JS comment — use the raw half
736    /// instead, so they are not subject to JSDoc-safety truncation/escaping that doesn't apply
737    /// to their format (issue #141). Both are derived from a single call per tool rather than
738    /// two, since re-walking and re-sanitizing the same schema twice is wasted work (issue
739    /// #295).
740    ///
741    /// Takes no `&self`: this only walks the `schema` it is given, and does not touch
742    /// generator state.
743    ///
744    /// # Errors
745    ///
746    /// Returns error if schema is malformed or type conversion fails.
747    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            // Extract description if available (looked up by the raw schema key, before
774            // sanitization, since that's what the input schema is actually keyed by)
775            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    /// Builds structured metadata for a single tool, for the `_meta.json` sidecar.
804    ///
805    /// Unlike [`create_tool_context`](Self::create_tool_context), `name`, `description`, and
806    /// parameter descriptions all use the RAW, unsanitized MCP values: the sidecar is a data
807    /// contract consumed by other Rust code, not interpolated into a `JSDoc` comment, so
808    /// JSDoc-safety sanitization (truncation, `*/`-escaping, newline-flattening) would only
809    /// lose fidelity. Parameter descriptions come from
810    /// [`extract_property_data`](Self::extract_property_data)'s raw half rather than the
811    /// JSDoc-sanitized `PropertyInfo` used for template rendering, which is what fully fixes
812    /// the data loss described in issue #141 (the old regex-based parser could not recover
813    /// parameter descriptions from the generated TypeScript at all).
814    ///
815    /// `properties` must come from [`extract_property_data`](Self::extract_property_data) run
816    /// against `tool.input_schema` — passed in rather than derived internally so this can share
817    /// a single schema walk with [`create_tool_context`](Self::create_tool_context) for the same
818    /// tool instead of parsing and sanitizing the same schema twice (issue #295).
819    ///
820    /// Takes no `&self`: this only transforms the arguments it is given, and does not touch
821    /// generator state.
822    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    /// Builds the `_meta.json` sidecar file from per-tool metadata already collected
851    /// during the tool-file generation loop.
852    ///
853    /// Computes [`GenerationProvenance`] from `server_config` and `server_info.tools` — the
854    /// same inputs this whole call is generating from — so the recorded digest can never drift
855    /// from the files actually emitted.
856    ///
857    /// # Errors
858    ///
859    /// Returns error if the metadata cannot be serialized to JSON (should not happen
860    /// with these plain-data types).
861    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
900/// Cheaply rejects an oversized `server_info.tools` list before any per-tool template
901/// rendering happens (denial-of-service protection, CWE-400).
902///
903/// A caller reaching [`ProgressiveGenerator::generate`]/`generate_with_categories` through
904/// `mcp_execution_introspector::Introspector::discover_server` already has its tool count
905/// bounded upstream, but this crate's generator functions are public API and can be called
906/// directly with a hand-built `ServerInfo`, so this check is not purely redundant.
907///
908/// # Errors
909///
910/// Returns [`Error::ResourceLimitExceeded`] if `server_info.tools.len()` plus the
911/// [`FIXED_FILE_COUNT`] files every call emits would exceed [`MAX_GENERATED_FILES`] — the same
912/// threshold [`add_tracked`] checks incrementally as each file is produced, so this
913/// short-circuits exactly the inputs that check would go on to reject anyway, before any
914/// template rendering happens at all.
915fn 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
929/// Adds `file` to `code`, tracking its contribution to `total_bytes` and bailing out
930/// immediately if either the running byte total or the file count would exceed its configured
931/// bound (denial-of-service protection, CWE-400).
932///
933/// Checked incrementally as each file is produced, rather than only after the entire
934/// [`GeneratedCode`] has been built: an oversized `ServerInfo` (or one whose fixed-overhead
935/// files, e.g. `_meta.json` re-embedding every tool's schema, push the total over the edge)
936/// is rejected as soon as the offending file is generated, so this generator never holds the
937/// full amplified output in memory before the bound is enforced (issue #198 S4).
938///
939/// # Errors
940///
941/// Returns [`Error::ResourceLimitExceeded`] if adding `file` would push the running byte total
942/// past [`MAX_GENERATED_BYTES`], or the file count past [`MAX_GENERATED_FILES`]. Returns
943/// [`Error::DuplicateGeneratedFilePath`] if `file.path` was already added earlier in this
944/// call — see [`GeneratedCode::add_file`]; this is defense-in-depth once
945/// [`resolve_typescript_names`] seeds its collision set with this module's own reserved
946/// output filenames (issue #312), not a path this function expects to hit in practice.
947fn 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
974/// Sanitizes a server-controlled string for safe interpolation into `JSDoc` block comments.
975///
976/// Neutralizes control characters (C0, DEL, C1 — everything `char::is_control` reports —
977/// plus U+2028 LINE SEPARATOR/U+2029 PARAGRAPH SEPARATOR, which ECMAScript treats as line
978/// terminators even though they aren't in the control-character category) by delegating to
979/// the shared [`mcp_execution_core::untrusted::sanitize_untrusted_text`], which *replaces*
980/// each with a space rather than deleting it — deleting would glue adjacent words together
981/// (`"tab\tseparated"` -> `"tabseparated"`) and, more importantly, would let a control
982/// character sitting between `*` and `/` collapse into a live `JSDoc` comment terminator once
983/// removed. This neutralization runs *before* the `*/` escape step for exactly that reason:
984/// escaping first would see no `*/` match (the control character still separates them), then
985/// deleting/collapsing that character afterward would reopen the comment. Truncation to
986/// `max_len` runs last, after escaping, so a `*/` straddling the truncation boundary is
987/// already widened to `*\/` and cannot be split back into a bare `*/` by the cut.
988fn 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
998/// Joins `ToolCategorization::keywords` into the comma-separated form `JSDoc` rendering displays
999/// (`@keywords foo, bar, baz`), sanitizing the joined text the same way any other JSDoc-embedded
1000/// value is sanitized.
1001fn render_keywords_for_jsdoc(keywords: &[String]) -> String {
1002    sanitize_jsdoc(&keywords.join(", "), 256)
1003}
1004
1005/// Escapes a string for safe embedding inside a single-quoted TypeScript string literal.
1006///
1007/// Backslashes are escaped before quotes so the backslash introduced by quote-escaping
1008/// is not itself re-escaped. Carriage returns and newlines are escaped so the value
1009/// cannot terminate the literal by injecting a raw line break. U+2028/U+2029 are also
1010/// escaped: legal but unescaped inside a string literal only since ES2019, so a raw
1011/// occurrence would be a syntax error for consumers targeting an older ECMAScript target.
1012///
1013/// The **raw** input is truncated to
1014/// [`mcp_execution_core::untrusted::MAX_UNTRUSTED_FIELD_LEN`] `char`s *before* any escaping
1015/// runs, bounding this call site's own defense (`ToolName` itself enforces no length limit —
1016/// `validate_path_segment` accepts any non-empty single path component, however long — and the
1017/// introspector's `MAX_TOOL_NAME_LEN` only gates names that actually arrive from `tools/list`,
1018/// never a hand-built `ToolName`; `server_id` similarly has no shorter bound reaching this
1019/// function). Truncating pre-escape, not post-escape, is load-bearing: escaping can expand a
1020/// single input `char` into a multi-character output sequence (a lone backslash becomes two
1021/// backslashes; the LINE SEPARATOR character becomes a six-character escape sequence), so
1022/// truncating the *escaped* string can land the cut in the middle of such a sequence and leave a
1023/// dangling, odd-length run of backslashes at the end of the output — which then escapes the
1024/// generated template's own closing quote and leaves the
1025/// `callMCPTool('...')` string literal unterminated (critic finding C3, a regression introduced
1026/// by an earlier, post-escape-truncating version of this fix for critic finding S3). Truncating
1027/// the raw input first guarantees every multi-character escape sequence in the output is either
1028/// wholly present or wholly absent, never split.
1029///
1030/// After truncation, the result is escaped, then passed through the shared
1031/// [`mcp_execution_core::untrusted::sanitize_untrusted_text`] with no further length cap
1032/// (`usize::MAX` — the input is already bounded) to neutralize any remaining invisible-payload
1033/// character — the Unicode Tags block, bidi embedding/override/isolate controls, bidi
1034/// directional marks, and zero-width/invisible operators. This runs *after* the escape step,
1035/// not before: `sanitize_untrusted_text` maps `\r`/`\n` to a space, which would defeat this
1036/// function's own `\r`/`\n` -> `\\r`/`\\n` escaping if it ran first, so escaping happens first
1037/// and neutralization happens on the (now `\r`/`\n`-free) escaped result. Closes the gap where
1038/// a tool name or server id containing a Tags-block-smuggled payload (issue #432) would
1039/// otherwise land verbatim inside the `callMCPTool('...')` string literal in generated code,
1040/// regardless of whether the value originated from introspection or was hand-constructed and
1041/// happened to bypass any other sanitization layer.
1042pub(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
1060/// JavaScript/TypeScript reserved words that cannot be used as a function or export
1061/// identifier. Generated tool code is always emitted as an ES module, which is implicitly
1062/// strict mode, so this includes both the unconditional and strict-mode-only reserved words,
1063/// plus `eval`/`arguments`, which strict mode forbids as a `BindingIdentifier` (a function
1064/// declaration's name) even though they are not formally reserved words.
1065const 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
1116/// Output filenames (without extension) that [`ProgressiveGenerator`] itself always emits
1117/// alongside per-tool files, regardless of the introspected tool list. Seeded into
1118/// [`resolve_typescript_names`]'s case-insensitive `used_lower` collision set (unlike
1119/// [`RESERVED_WORDS`], which is checked case-sensitively — see that function's docs for why the
1120/// two differ): a tool whose sanitized name matches one of these would otherwise silently
1121/// overwrite this generator's own fixed output file (issue #312) instead of being disambiguated
1122/// like any other name collision.
1123const RESERVED_OUTPUT_NAMES: &[&str] = &["index"];
1124
1125/// Resolves a collision-free TypeScript identifier for each tool, in tool order.
1126///
1127/// `sanitize_ts_identifier` can map distinct tool names to the same identifier (e.g.
1128/// `foo-bar` and `foo.bar` both become `foo_bar`), and an MCP server is not guaranteed to
1129/// report unique raw tool names in the first place. Since `typescript_name` doubles as the
1130/// generated file's basename and its `index.ts` re-export, an undetected collision would
1131/// silently overwrite one tool's file and produce a duplicate-export compile error.
1132///
1133/// The result is keyed by position rather than by raw tool name: two tools sharing an
1134/// identical raw name would otherwise collapse to a single map entry, losing one of the two
1135/// resolved identifiers even though both were correctly disambiguated. Callers must look up
1136/// entries by the tool's index in the same `tools` slice.
1137///
1138/// Collision detection combines two checks with different case sensitivity, since
1139/// `typescript_name` doubles as both a language-level identifier and an output filename:
1140///
1141/// - JS/TS reserved words (e.g. `delete`) are checked case-*sensitively*, an exact match
1142///   against the lowercase reserved word — reserved words are only reserved in their exact
1143///   lowercase form (`Delete`, `New`, `Import` are all legal identifiers).
1144/// - The fixed output filenames (e.g. `index`) and previously-resolved tool names are checked
1145///   case-*insensitively* (via [`disambiguate_output_filename`]'s case-folded `used_lower`
1146///   set), because filenames collide regardless of case on a case-insensitive filesystem (macOS
1147///   APFS, Windows NTFS by default, this project's primary dev platforms): a tool named `Index`
1148///   collides with the fixed `index.ts` output, and two tools named `getUser`/`GetUser` collide
1149///   with each other (issue #312 S1, N2).
1150///
1151/// The emitted identifier always preserves each tool's original case; only the collision
1152/// *checks* fold or preserve case as described above.
1153fn 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
1168/// Disambiguates `base` against reserved JS/TS words and `used_lower`, appending a numeric
1169/// suffix (`_2`, `_3`, ...) — mirroring [`disambiguate_identifier`]'s suffix scheme — until a
1170/// candidate is found that is neither an exact (case-sensitive) match for a
1171/// [`RESERVED_WORDS`] entry nor a case-insensitive match in `used_lower`. The winning
1172/// candidate's lowercased form is then reserved in `used_lower`. The returned identifier
1173/// preserves `base`'s original casing; only the collision *checks* fold or preserve case (see
1174/// [`resolve_typescript_names`] for why the two checks differ).
1175///
1176/// A dedicated function rather than reusing [`disambiguate_identifier`]: that one is shared with
1177/// property-name disambiguation, which must stay case-sensitive — `name` and `Name` are
1178/// legitimately distinct object keys in the generated `Params` interface. Output *filenames* have
1179/// no such case-sensitive guarantee once a case-insensitive filesystem is in play, which is what
1180/// [`resolve_typescript_names`] uses this for (issue #312 S1/N2).
1181fn 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        // Known limitation: unlike `json_schema_to_typescript` (called once per property via
1199        // `extract_properties`), `create_tool_context` calls this once per tool directly on
1200        // the whole `input_schema` — but this warning still carries no tool/server identifier
1201        // to correlate it back to the originating `generate_with_categories` call.
1202        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
1211/// Sanitizes every `description` field in `value`'s tree in place, recursing into nested
1212/// objects and arrays up to [`MAX_SCHEMA_RECURSION_DEPTH`] — see that constant's docs for what
1213/// this cap actually defends against (this crate's `pub` API surface, not a reachable wire-path
1214/// schema). `cap_hit` is set (never cleared) the first time any branch trips the cap, so the
1215/// public wrapper can log once per call rather than once per clipped branch.
1216///
1217/// `value` is (a clone of) a tool's `input_schema`, called on the schema's true top level with
1218/// no depth peeled off beforehand (unlike [`typescript::extract_properties`]'s call path into
1219/// `json_schema_to_typescript`) — so `depth` here always matches the schema's real JSON
1220/// nesting. Beyond the depth cap, remaining branches are left unsanitized rather than
1221/// continuing to recurse.
1222///
1223/// [`typescript::extract_properties`]: crate::common::typescript::extract_properties
1224fn 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        // Should generate:
1329        // - 2 tool files
1330        // - 1 index.ts
1331        // - 1 runtime bridge
1332        // - 1 package.json
1333        // - 1 tsconfig.json
1334        // - 1 _meta.json
1335        assert_eq!(code.file_count(), 7);
1336
1337        // Check tool files exist
1338        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    /// Regression guard for #279: `generate` delegates to `generate_with_categories` with an
1350    /// empty categorization map, so `create_index_context` must gate its category-grouping
1351    /// branch on emptiness, not just `Option`-ness — otherwise `Some(&HashMap::new())` produces
1352    /// a spurious "uncategorized" `CategoryInfo` group in `index.ts` that `generate` never
1353    /// produced before the delegation.
1354    #[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    /// Regression guard for #183: tool files import the runtime bridge with an explicit `.ts`
1370    /// extension, which `tsc` only accepts under `allowImportingTsExtensions`; that option in
1371    /// turn requires `noEmit` (or a declaration/emit setting) to be internally consistent.
1372    /// `types` must explicitly name `node` — TypeScript 7's automatic `@types/*` acquisition
1373    /// does not extend `@types/node`'s ambient globals the way TypeScript 5.x's does, so
1374    /// `tsc --noEmit` fails under TS 7 without this even with `@types/node` installed.
1375    #[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    /// Regression guard for #183: `tsconfig.json` alone does not make the generated package
1398    /// pass `tsc --noEmit` — the runtime bridge imports Node builtins and references ambient
1399    /// globals (`process`, `NodeJS`) that only resolve with `@types/node` installed. Without a
1400    /// declared devDependency, a consumer running `tsc --noEmit` "out of the box" still fails.
1401    #[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        // Issue #141 regression: the old regex-based skill parser could not recover
1426        // parameter descriptions from generated TypeScript at all. The `_meta.json`
1427        // sidecar must carry them through faithfully.
1428        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    /// The `_meta.json` sidecar carries `schema_version: 2` and 64-hex-char provenance fields,
1457    /// and two runs against identical input agree on the fingerprint and digest — only
1458    /// `generated_at` is allowed to differ between them.
1459    #[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        // Guarantees the two `generated_at` timestamps differ (rather than merely being
1470        // "unlikely to collide"), so the assertion below actually exercises "only generated_at
1471        // differs" instead of passing vacuously if both calls happened to land in the same
1472        // clock tick.
1473        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        // Issue #141 regression (critic S1): the sidecar is JSON consumed by Rust, not a JS
1562        // comment, so its parameter descriptions must NOT go through `sanitize_jsdoc`'s
1563        // truncation/escaping/newline-flattening — only the `.ts` template's JSDoc comment
1564        // needs that treatment.
1565        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        // The sidecar carries the raw, untruncated, unescaped, non-flattened description.
1603        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        // The `.ts` template's JSDoc comment still uses the sanitized form, since it IS
1618        // embedded in a JS comment.
1619        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        // The property-extraction error raised deep in `extract_property_data` is generic
1689        // (`Error::ValidationError`) and has no `tool` field; this wrapper attributes the
1690        // failure back to the specific tool being generated.
1691        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                // `message` must not duplicate the source's Display text (only one of the two
1716                // should carry it, per this crate's error-chain-printing convention).
1717                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        // #185's real (reachable) attribution gap: a template-render failure or an
1728        // output-tracking failure that happens while processing one tool out of many must
1729        // still name that tool, not just the (structurally unreachable) schema-extraction path.
1730        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                // The nested `ResourceLimitExceeded` must survive intact so
1765                // `classify_exit_code` (mcp-cli) can still recurse into it.
1766                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        // The header JSDoc must emit @description even without LLM categorization.
1808        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    /// Regression guard for #312: a tool whose raw MCP name sanitizes to `index` must not
1865    /// collide with the always-emitted `index.ts` re-export. `resolve_typescript_names` seeds
1866    /// its collision set with this generator's own reserved output filenames, so the tool gets
1867    /// disambiguated (`index_2`) exactly like a JS/TS reserved-word collision would.
1868    #[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        // Exactly one file at each path: the collision never happened.
1931        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    /// Regression guard for #312 S1: `RESERVED_OUTPUT_NAMES` membership alone only catches an
1942    /// exact-case match, but `index.ts`/`Index.ts` are the same file on a case-insensitive
1943    /// filesystem (macOS APFS, Windows NTFS by default — this project's primary dev platforms).
1944    /// A tool named `Index` must be disambiguated the same way a tool literally named `index`
1945    /// is, via `disambiguate_output_filename`'s case-insensitive collision check.
1946    #[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        // Exactly one file at each path: on a case-insensitive filesystem, "index.ts" and
1985        // "Index.ts" would otherwise be the same file.
1986        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    /// Regression guard for #312 N2: a server exposing BOTH `Index` and `index` as distinct
1994    /// tools must not have their resolved output filenames collide case-insensitively with
1995    /// EACH OTHER, not just with the fixed `index.ts`. Both are already disambiguated away
1996    /// from the reserved `index` name; they must additionally be disambiguated from each
1997    /// other, since `Index_2`/`index_2` would themselves collide case-insensitively.
1998    #[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    /// Regression guard for #312 N2's side effect: two DIFFERENT tools whose sanitized names
2049    /// differ only by case (not involving the reserved `index` name at all) must also be
2050    /// disambiguated from each other, since a case-insensitive filesystem would otherwise merge
2051    /// their output files exactly like the `Index`/`index` case.
2052    #[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        // Find name property
2110        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        // Find age property
2116        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        // The description lookup must still succeed even though the property
2139        // name used for the lookup differs from the sanitized display name.
2140        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        // "a-b" and "a.b" both sanitize to "a_b"; since both become fields of the same
2146        // top-level `Params` interface, the collision must be disambiguated rather than
2147        // producing a duplicate, non-compiling field.
2148        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        // Before issue #192's collapsing fix, "a-b" -> "a_b" and "a--b" -> "a__b" were
2167        // distinct identifiers; collapsing consecutive invalid runs now sanitizes both to
2168        // "a_b", introducing a *new* collision that must still be disambiguated rather than
2169        // producing a duplicate, non-compiling field.
2170        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        // U+2028 (LINE SEPARATOR) and U+2029 (PARAGRAPH SEPARATOR) are treated as line
2281        // terminators by ECMAScript, so they terminate a `//` line comment (e.g.
2282        // `index.ts.hbs`'s `// --- {{name}} ---` category header) even though they are not
2283        // ASCII `\r`/`\n` and were never covered by Handlebars' HTML-escaping either.
2284        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        // The sanitized category text (including the literal "export const pwned = 1;"
2311        // substring) is expected to still appear, harmlessly, inside the `// --- ... ---`
2312        // comment. What must NOT happen is U+2028 terminating that comment early and
2313        // letting "export const pwned" start a fresh, live top-level statement.
2314        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        // Issue #300: this project's documented workflow is `cat` on the generated `.ts`
2343        // file, so a raw ESC (`\x1b`)-led ANSI escape sequence in a tool description must
2344        // not survive into the JSDoc comment verbatim. The control character becomes a
2345        // space (not deleted), so adjacent text is not glued together.
2346        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    /// Critic C1 regression: a control character sitting directly between `*` and `/`
2358    /// must not survive as a live `JSDoc` comment terminator. The old (buggy) order
2359    /// escaped `*/` before neutralizing control characters, so `*\u{0}/` had no `*/`
2360    /// substring at escape time, then the control character was deleted, reconstituting
2361    /// a bare `*/` that closed the comment early and let the trailing text become live
2362    /// top-level TypeScript.
2363    #[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        // A trailing backslash followed by a quote must not become `\\\'`
2390        // (which would re-open the string); backslash escaping happens first.
2391        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        // U+2028/U+2029 are legal-but-unescaped inside a string literal only since ES2019;
2405        // a raw occurrence is a syntax error for consumers targeting an older ES target.
2406        assert_eq!(
2407            sanitize_ts_string_literal("line1\u{2028}line2\u{2029}line3"),
2408            "line1\\u2028line2\\u2029line3"
2409        );
2410    }
2411
2412    /// Regression test for #432: a Unicode-Tags-block-smuggled invisible payload (the
2413    /// technique #425 hardened `sanitize_untrusted_text` against) must not survive into a
2414    /// generated `callMCPTool('...')` string literal verbatim. `ToolName::new` now also
2415    /// rejects this outright (see `mcp-core`'s `test_tool_name_rejects_unicode_tags_block_payload`),
2416    /// but this asserts the codegen-layer defense independently, since it applies regardless
2417    /// of which validated or unvalidated source produced the string being embedded.
2418    #[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    /// Regression test for #432: a bidi-override character must also be neutralized at the
2431    /// codegen boundary, not just escaped for quote/backslash breakout.
2432    #[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    /// Regression test for critic finding C3: an earlier version of this function's
2440    /// `MAX_UNTRUSTED_FIELD_LEN` bound truncated the *escaped* string rather than the raw
2441    /// input before escaping. Escaping expands a single `char` into a multi-character
2442    /// sequence (a lone `\` becomes `\\`), so a post-escape cut could land mid-sequence and
2443    /// leave a dangling, odd-length run of trailing backslashes — which then escapes the
2444    /// generated template's own closing quote and leaves the `callMCPTool('...')` string
2445    /// literal unterminated. Sweeps raw input lengths straddling the cap on both sides, for
2446    /// every character whose escaping doubles it, and asserts the output never ends in an odd
2447    /// backslash run.
2448    #[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    // `sanitize_ts_identifier`'s core behavior (invalid-char replacement, leading-digit
2470    // and empty-string prefixing) is unit-tested in `common::typescript`, its canonical
2471    // home now that it's a shared `pub fn`; this test covers the passthrough case that's
2472    // specific to how this module uses it (already-valid camelCase tool names).
2473    #[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        // `ToolName::new`'s Unicode-identifier allowlist (issue #433) now rejects every
2481        // string-literal-breakout character except the apostrophe, which is a legitimate
2482        // identifier character (UTS #39 Identifier_Status=Allowed) — so this test's raw
2483        // tool name is trimmed to just an embedded apostrophe rather than a full
2484        // `'); alert('pwned` payload; that is still exactly the character
2485        // `sanitize_ts_string_literal` must escape to keep `name_literal` from breaking out
2486        // of the single-quoted string literal passed to `callMCPTool`.
2487        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        // Scoped to the `callMCPTool(...)` call site itself: the JSDoc header above it
2505        // legitimately echoes the raw tool name inside a `/** */` block comment (quotes
2506        // there are inert, not code), so a whole-file substring check would false-positive
2507        // on that comment. The security property under test is that `name_literal`
2508        // (escaped via `sanitize_ts_string_literal`) can't break out of the single-quoted
2509        // string literal actually passed to `callMCPTool`.
2510        // Matches the actual invocation (`return (await callMCPTool(...`) rather than any
2511        // line merely containing "callMCPTool(" — a future `@example` line in the JSDoc
2512        // above (which legitimately shows `callMCPTool(...)` usage in prose) would otherwise
2513        // silently redirect this assertion to the wrong line.
2514        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    /// End-to-end regression test for critic finding C3: a hostile tool name shaped like the
2530    /// critic's `PoC` — long enough that escaping alone pushes a naive post-escape truncation
2531    /// at `MAX_UNTRUSTED_FIELD_LEN` into the middle of an escape sequence — must still produce
2532    /// a syntactically closed `callMCPTool(...)` call site, not an unterminated string literal
2533    /// that swallows the rest of the call (uses `'`, not `\`, so the name stays a valid
2534    /// `ToolName` on every platform `Path`'s separator rules run on, e.g. Windows).
2535    #[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        // The call site must still be a syntactically closed invocation: `params))` and the
2561        // trailing `as ...Result;` cast must survive as live code, not be swallowed into an
2562        // unterminated string literal along with everything after it.
2563        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                // `ToolName::new`'s Unicode-identifier allowlist (issue #433) rejects a raw
2593                // space, so this uses `:` (also non-alphanumeric, also sanitized to `_` by
2594                // `sanitize_ts_identifier`) as the third distinct separator that collides
2595                // with the other two after sanitization.
2596                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        // All three distinct tool names must resolve to distinct identifiers.
2608        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        // Two tools with the exact same raw name are invalid per the MCP spec but must
2621        // not be rejected upstream; each must still get a distinct resolved identifier
2622        // instead of one silently losing its slot in a raw-name-keyed map.
2623        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            // Must be a hyphen, not an underscore: `to_camel_case` only acts on `_` (it
2711            // capitalizes the following character and drops the underscore), so a raw name
2712            // of "class_2" would sanitize to "class2", never colliding with the "class"
2713            // tool's reserved-word fallback "class_2" and making this test vacuous.
2714            // `sanitize_ts_identifier` replaces the hyphen in "class-2" with "_" verbatim
2715            // (untouched by `to_camel_case`), so it genuinely sanitizes to the literal
2716            // identifier "class_2", producing a real collision to test against.
2717            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        // Issue #320: JS/TS reserved words are reserved only in their exact lowercase form —
2737        // `Delete`, `New`, `Import` are all legal identifiers — so a tool named `Delete` must
2738        // not be treated as colliding with the reserved word `delete`.
2739        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        // Issue #320 follow-up: `delete` and `Delete` in the same tool list must both resolve
2769        // to distinct names regardless of order. A reserved-word candidate is rejected via
2770        // `&&` short-circuit before it ever claims its lowercase slot in `used_lower`
2771        // (`disambiguate_output_filename`), so `delete` being suffixed away must not block a
2772        // later `Delete` from keeping its unsuffixed name (or vice versa).
2773        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        // Issue #192: a non-ASCII tool name used to produce one `_` per invalid character
2810        // (`café_menu_日本語` -> `caf_Menu___`), losing more information than necessary.
2811        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        // Same collapsing-introduced collision as
2826        // `test_extract_property_infos_disambiguates_collision_introduced_by_collapsing`, but
2827        // for tool names rather than property names: "a-b" and "a--b" used to sanitize to
2828        // distinct identifiers and now both sanitize to "a_b".
2829        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        // Both tools must produce distinct files: no silent overwrite.
2893        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        // An MCP server reporting two tools with the exact same raw `name` is invalid per
2921        // spec but is not currently rejected upstream; generation must not let the second
2922        // tool silently overwrite the first tool's file.
2923        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    /// Builds a schema with `depth` nested `type: "array"` levels, each carrying its own
3009    /// `description`, wrapping a `string` leaf that also has a `description`.
3010    ///
3011    /// Assembled directly via `serde_json::Map`/`Value` (like
3012    /// `typescript::tests::nested_array_schema`) so building this fixture can't itself
3013    /// overflow the stack, and using `"array"`/`"items"` rather than `"object"`/`"properties"`
3014    /// so each `depth` step costs exactly one recursion level in
3015    /// [`sanitize_schema_jsdoc_value`] — unlike an `"object"`/`"properties"`-nested schema,
3016    /// where the intermediate `properties` map itself consumes a level (see
3017    /// [`sanitize_schema_jsdoc_value`]'s doc comment) — making it possible to predict exactly
3018    /// which nesting level lands on either side of [`MAX_SCHEMA_RECURSION_DEPTH`].
3019    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    /// Walks `depth` `"items"` hops into `value`, returning the schema found there.
3038    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        // Issue #303: `input_schema` is attacker-controlled and this sanitizer used to
3049        // recurse into every nested object/array with no depth limit; this is
3050        // defense-in-depth for the pub API surface (see `MAX_SCHEMA_RECURSION_DEPTH`'s docs
3051        // in `typescript.rs`), not a fix for a wire-reachable schema. Asserts the cap's actual
3052        // observable effect rather than just "doesn't panic": a `description` one level below
3053        // the cap is still sanitized, while one at the cap is left untouched because the
3054        // function stops recursing before ever reaching it.
3055        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        // Issue #303: a caller of this crate's `pub` API could hand it a `Value` nested far
3084        // beyond any depth reachable via introspection (see `MAX_SCHEMA_RECURSION_DEPTH`'s
3085        // docs). Must not stack overflow at 5,000+ levels, however that `Value` was built.
3086        //
3087        // Runs on a dedicated large-stack thread because `serde_json::Value`'s own `Drop` is
3088        // recursive and unrelated to this fix: dropping a sufficiently deep `Value` overflows
3089        // the *default* thread stack merely by going out of scope, regardless of how it was
3090        // traversed beforehand — see `typescript::tests::run_on_large_stack` for the full
3091        // rationale.
3092        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        // Place the "*/" pair straddling the max_len boundary: '*' is the
3108        // max_len-th character and '/' is the very next one, so a naive
3109        // truncate-then-check could see the split land between them.
3110        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    /// #221 item 3 — real drift guard: reads the names from
3122    /// `mcp_execution_core::forbidden_env_names()` directly (not a hardcoded second copy),
3123    /// so a future addition/removal in `command.rs`'s `FORBIDDEN_ENV_NAMES` is picked up here
3124    /// automatically. Passing is guaranteed by construction (`BridgeContext`'s hand-written
3125    /// `Default` impl renders from that same accessor), which is the point: since the rendered
3126    /// TypeScript literal is generated from the Rust constant rather than hand-copied, the two
3127    /// can no longer silently desynchronize the way a hand-maintained second copy could.
3128    ///
3129    /// This does NOT prove the validator is reachable or enforced at runtime — a
3130    /// `grep`-style assertion can pass even against dead code (e.g. an unreachable function,
3131    /// or one whose result is never checked). The actual behavioral regression guard for
3132    /// #201 — that a hostile `~/.claude/mcp.json` is rejected before any subprocess is
3133    /// spawned — lives in `crates/mcp-codegen/tests/progressive_generation.rs`
3134    /// (`test_runtime_bridge_rejects_forbidden_env_var_before_spawn`), which compiles and
3135    /// actually executes the rendered bridge under Node.
3136    #[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    /// #471/#467 drift guard: the `DoS` size/count ceilings and the env-name charset pattern
3175    /// must be rendered from `mcp_execution_core`'s constants/accessor, not hand-copied
3176    /// literals — same "read from the accessor, not a hardcoded second copy" reasoning as
3177    /// `test_generate_runtime_bridge_declares_forbidden_env_var_list` above, and the same
3178    /// caveat: this proves the values are rendered, not that the checks are reachable or
3179    /// enforced at runtime (see `crates/mcp-codegen/tests/progressive_generation.rs` for the
3180    /// behavioral guards that compile and execute the rendered bridge under Node).
3181    #[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        // Asserts name *and* value together — asserting only the value would pass even with
3194        // `MAX_ENV_COUNT` deleted from the template entirely, since it collides with
3195        // `MAX_ARG_COUNT` (both 256) and `MAX_URL_LEN`/`MAX_HEADER_VALUE_LEN` collide too (both
3196        // 8192); see #471/#467 critique S4.
3197        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        // Issue #204 regression: Handlebars' default HTML-escaping corrupted benign
3235        // punctuation (apostrophes, ampersands, comparison operators) in JSDoc comments,
3236        // turning e.g. `don't` into `don&#x27;t` or `a < b` into `a &lt; b`.
3237        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 ["&lt;", "&gt;", "&amp;", "&quot;", "&#x27;", "&#39;"] {
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        // Raw injected strings must not appear in the output.
3275        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    // ── Resource-exhaustion bounds (issue #198) ──────────────────────────────
3341
3342    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    /// #198 M8 — proves `generate()` itself (not just the private `add_tracked` helper)
3432    /// enforces the byte budget, using a `total_bytes` starting point injected just below the
3433    /// cap rather than materializing a real `MAX_GENERATED_BYTES`-sized (now several hundred
3434    /// MB, after the M1 fix ties it to `mcp_execution_introspector`'s own bounds) `ServerInfo`
3435    /// — which would make this test itself a slow, wasteful multi-hundred-MB allocation for
3436    /// every CI run without proving anything `add_tracked`'s direct boundary tests don't
3437    /// already cover more precisely.
3438    #[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        // The offending file must not have been added — the caller should not be able to
3455        // observe a `GeneratedCode` that already exceeds the bound.
3456        assert_eq!(code.file_count(), 0);
3457    }
3458}