Skip to main content

mcp_execution_codegen/progressive/
types.rs

1//! Types for progressive loading code generation.
2//!
3//! Defines data structures used during progressive code generation,
4//! where each tool is generated as a separate file.
5
6use serde::{Deserialize, Serialize};
7
8/// Context for rendering a single tool template.
9///
10/// Contains all data needed to generate one tool file in the
11/// progressive loading pattern.
12///
13/// # Examples
14///
15/// ```
16/// use mcp_execution_codegen::progressive::ToolContext;
17/// use serde_json::json;
18///
19/// let context = ToolContext {
20///     server_id: "github".to_string(),
21///     name: "create_issue".to_string(),
22///     name_literal: "create_issue".to_string(),
23///     server_id_literal: "github".to_string(),
24///     typescript_name: "createIssue".to_string(),
25///     description: "Creates a new issue".to_string(),
26///     input_schema: json!({"type": "object"}),
27///     properties: vec![],
28///     category: Some("issues".to_string()),
29///     keywords: Some("create,issue,new,bug".to_string()),
30///     short_description: "Create a new issue".to_string(),
31/// };
32///
33/// assert_eq!(context.server_id, "github");
34/// ```
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ToolContext {
37    /// MCP server identifier, sanitized for safe embedding in a `JSDoc` comment
38    pub server_id: String,
39    /// Original tool name (`snake_case`), sanitized for safe embedding in a `JSDoc` comment
40    pub name: String,
41    /// Original tool name escaped for safe embedding in a single-quoted TS string literal
42    pub name_literal: String,
43    /// Server identifier escaped for safe embedding in a single-quoted TS string literal
44    pub server_id_literal: String,
45    /// TypeScript-friendly name (camelCase), sanitized to a safe identifier
46    pub typescript_name: String,
47    /// Human-readable description
48    pub description: String,
49    /// JSON Schema for input parameters, with `description` fields sanitized
50    /// for safe interpolation into `JSDoc` block comments (see issue #102).
51    pub input_schema: serde_json::Value,
52    /// Extracted properties for template rendering
53    pub properties: Vec<PropertyInfo>,
54    /// Optional category for tool grouping
55    pub category: Option<String>,
56    /// Optional keywords for discovery via grep/search
57    pub keywords: Option<String>,
58    /// Short description for header comment. Always populated: `ProgressiveGenerator`'s only
59    /// constructor falls back to `description` when no categorization short description is
60    /// available, so `None` is not a state this type can represent.
61    pub short_description: String,
62}
63
64/// Information about a single parameter property.
65///
66/// Used in Handlebars templates to render parameter type definitions.
67///
68/// # Examples
69///
70/// ```
71/// use mcp_execution_codegen::progressive::PropertyInfo;
72///
73/// let prop = PropertyInfo {
74///     name: "title".to_string(),
75///     typescript_type: "string".to_string(),
76///     description: Some("Issue title".to_string()),
77///     required: true,
78/// };
79///
80/// assert_eq!(prop.name, "title");
81/// assert!(prop.required);
82/// ```
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct PropertyInfo {
85    /// Property name
86    pub name: String,
87    /// TypeScript type (e.g., "string", "number", "boolean")
88    pub typescript_type: String,
89    /// Optional description from schema
90    pub description: Option<String>,
91    /// Whether the property is required
92    pub required: bool,
93}
94
95/// Context for rendering the index.ts template.
96///
97/// Contains server-level metadata and list of all tools.
98///
99/// # Examples
100///
101/// ```
102/// use mcp_execution_codegen::progressive::IndexContext;
103///
104/// let context = IndexContext {
105///     server_name: "GitHub".to_string(),
106///     server_version: "1.0.0".to_string(),
107///     tool_count: 30,
108///     tools: vec![],
109///     categories: None,
110/// };
111///
112/// assert_eq!(context.tool_count, 30);
113/// ```
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct IndexContext {
116    /// Server name for documentation
117    pub server_name: String,
118    /// Server version
119    pub server_version: String,
120    /// Total number of tools
121    pub tool_count: usize,
122    /// List of tool summaries
123    pub tools: Vec<ToolSummary>,
124    /// Tools grouped by category (optional, for categorized generation)
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub categories: Option<Vec<CategoryInfo>>,
127}
128
129/// Summary of a tool for index file generation.
130///
131/// Lighter-weight than full `ToolContext`, used only for
132/// re-exports and documentation in index.ts.
133///
134/// # Examples
135///
136/// ```
137/// use mcp_execution_codegen::progressive::ToolSummary;
138///
139/// let summary = ToolSummary {
140///     typescript_name: "createIssue".to_string(),
141///     description: "Creates a new issue".to_string(),
142///     category: Some("issues".to_string()),
143///     keywords: Some("create,issue,new".to_string()),
144///     short_description: Some("Create a new issue".to_string()),
145/// };
146///
147/// assert_eq!(summary.typescript_name, "createIssue");
148/// ```
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct ToolSummary {
151    /// TypeScript-friendly name (camelCase)
152    pub typescript_name: String,
153    /// Human-readable description
154    pub description: String,
155    /// Optional category for tool grouping
156    pub category: Option<String>,
157    /// Optional keywords for discovery via grep/search
158    pub keywords: Option<String>,
159    /// Optional short description for header comment
160    pub short_description: Option<String>,
161}
162
163/// Categorization metadata for a single tool.
164///
165/// Contains all categorization data from Claude's analysis.
166///
167/// # Examples
168///
169/// ```
170/// use mcp_execution_codegen::progressive::ToolCategorization;
171///
172/// let cat = ToolCategorization {
173///     category: "issues".to_string(),
174///     keywords: vec!["create".to_string(), "issue".to_string(), "new".to_string(), "bug".to_string()],
175///     short_description: "Create a new issue in a repository".to_string(),
176/// };
177///
178/// assert_eq!(cat.category, "issues");
179/// ```
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct ToolCategorization {
182    /// Category for tool grouping
183    pub category: String,
184    /// Keywords for discovery via grep/search
185    pub keywords: Vec<String>,
186    /// Concise description for header comment
187    pub short_description: String,
188}
189
190/// Category information for grouped tool display in index.
191///
192/// Groups tools by category for organized documentation.
193///
194/// # Examples
195///
196/// ```
197/// use mcp_execution_codegen::progressive::{CategoryInfo, ToolSummary};
198///
199/// let category = CategoryInfo {
200///     name: "issues".to_string(),
201///     tools: vec![
202///         ToolSummary {
203///             typescript_name: "createIssue".to_string(),
204///             description: "Creates a new issue".to_string(),
205///             category: Some("issues".to_string()),
206///             keywords: Some("create,issue".to_string()),
207///             short_description: Some("Create issue".to_string()),
208///         },
209///     ],
210/// };
211///
212/// assert_eq!(category.name, "issues");
213/// ```
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct CategoryInfo {
216    /// Category name
217    pub name: String,
218    /// Tools in this category
219    pub tools: Vec<ToolSummary>,
220}
221
222/// Context for rendering the runtime bridge template.
223///
224/// The forbidden-char/forbidden-env-name/charset-pattern fields are rendered directly from
225/// `mcp_execution_core`'s canonical lists so the generated bridge's copies structurally
226/// cannot drift from the Rust source of truth — see [`BridgeContext::default`], the only way
227/// to construct one, which populates them from `mcp_execution_core::forbidden_chars`/
228/// `forbidden_env_names`/`forbidden_env_prefix`/`env_name_charset_pattern` rather than leaving
229/// them empty. This deliberately does *not* derive `Default`: an empty `forbidden_chars` would
230/// render a bridge whose `validateCommandString` accepts every shell metacharacter, and an
231/// empty `env_name_charset_pattern` would render `new RegExp('')`, which matches every string
232/// (fail-open on exactly the checks these exist to enforce) — so `Default` is hand-written to
233/// make "always populated" a property of the type rather than a convention callers must
234/// remember to uphold.
235///
236/// Those four fields are private with read-only accessors for the same reason: `pub` fields
237/// would let `BridgeContext { forbidden_chars: vec![], .. }` bypass the invariant entirely and
238/// still compile, silently reintroducing the fail-open state `Default` exists to prevent.
239/// `Deserialize` is intentionally not derived — nothing in this codebase deserializes a
240/// `BridgeContext` from external input, and doing so would need to re-validate non-emptiness
241/// rather than trust the wire data.
242///
243/// The remaining fields — the denial-of-service size/count ceilings
244/// (`mcp_execution_core::MAX_ARG_COUNT` and siblings) and `env_name_charset_desc` (the
245/// human-readable charset description used only in a rejection message's text, not in the
246/// enforcement regex above) — are plain `pub` fields: unlike an emptied list or pattern, a
247/// wrong value here cannot fail open — at worst it makes the rendered bridge reject configs it
248/// should accept (a wrong `MAX_*`), or emit a confusing-but-still-rejecting error message (a
249/// wrong `env_name_charset_desc`), never silently accept something it shouldn't — so the extra
250/// accessor/invariant machinery above would be pure ceremony here.
251///
252/// # Examples
253///
254/// ```
255/// use mcp_execution_codegen::progressive::BridgeContext;
256///
257/// let context = BridgeContext::default();
258/// assert!(!context.forbidden_chars().is_empty());
259/// assert!(context.forbidden_chars().contains(&";".to_string()));
260/// assert!(!context.forbidden_env_prefix().is_empty());
261/// assert!(!context.env_name_charset_pattern().is_empty());
262/// assert!(!context.env_name_charset_desc.is_empty());
263/// assert!(context.max_arg_count > 0);
264/// ```
265#[derive(Debug, Clone, Serialize)]
266pub struct BridgeContext {
267    /// Shell metacharacters forbidden in a command or argument string, each pre-escaped for
268    /// safe embedding inside a single-quoted TypeScript string literal.
269    forbidden_chars: Vec<String>,
270    /// Forbidden environment variable names (exact match).
271    forbidden_env_names: Vec<String>,
272    /// Environment-variable-name prefix rejected regardless of exact match (e.g. `DYLD_`).
273    forbidden_env_prefix: String,
274    /// POSIX/Windows environment-variable-name identifier charset, as an anchored JavaScript
275    /// `RegExp`-compatible pattern source (see `mcp_execution_core::env_name_charset_pattern`),
276    /// pre-escaped for safe embedding inside a single-quoted TypeScript string literal — same
277    /// treatment as `forbidden_chars`, and for the same reason: an unescaped pattern containing
278    /// a `'` or `\` would either break the generated `new RegExp('...')` call or silently change
279    /// what it matches.
280    env_name_charset_pattern: String,
281    /// Human-readable description of the charset above (`mcp_execution_core::env_name_charset_desc`,
282    /// e.g. `"[A-Za-z_][A-Za-z0-9_]*"`), pre-escaped like `env_name_charset_pattern` and
283    /// rendered into the bridge's own rejection message so that text isn't a second
284    /// hand-copied literal alongside the pattern.
285    pub env_name_charset_desc: String,
286    /// Maximum number of positional arguments (`mcp_execution_core::MAX_ARG_COUNT`).
287    pub max_arg_count: usize,
288    /// Maximum byte length for a command, argument, env-var name, or header name
289    /// (`mcp_execution_core::MAX_ARG_LEN`).
290    pub max_arg_len: usize,
291    /// Maximum number of environment variables (`mcp_execution_core::MAX_ENV_COUNT`).
292    pub max_env_count: usize,
293    /// Maximum byte length for a single environment variable value
294    /// (`mcp_execution_core::MAX_ENV_VALUE_LEN`).
295    pub max_env_value_len: usize,
296    /// Maximum byte length for the Http/Sse transport `url`
297    /// (`mcp_execution_core::MAX_URL_LEN`).
298    pub max_url_len: usize,
299    /// Maximum number of HTTP headers (`mcp_execution_core::MAX_HEADER_COUNT`).
300    pub max_header_count: usize,
301    /// Maximum byte length for a single HTTP header value
302    /// (`mcp_execution_core::MAX_HEADER_VALUE_LEN`).
303    pub max_header_value_len: usize,
304}
305
306impl BridgeContext {
307    /// Shell metacharacters forbidden in a command or argument string, each pre-escaped for
308    /// safe embedding inside a single-quoted TypeScript string literal. Never empty.
309    ///
310    /// # Examples
311    ///
312    /// ```
313    /// use mcp_execution_codegen::progressive::BridgeContext;
314    ///
315    /// assert!(!BridgeContext::default().forbidden_chars().is_empty());
316    /// ```
317    #[must_use]
318    pub fn forbidden_chars(&self) -> &[String] {
319        &self.forbidden_chars
320    }
321
322    /// Forbidden environment variable names (exact match). Never empty.
323    ///
324    /// # Examples
325    ///
326    /// ```
327    /// use mcp_execution_codegen::progressive::BridgeContext;
328    ///
329    /// assert!(!BridgeContext::default().forbidden_env_names().is_empty());
330    /// ```
331    #[must_use]
332    pub fn forbidden_env_names(&self) -> &[String] {
333        &self.forbidden_env_names
334    }
335
336    /// Environment-variable-name prefix rejected regardless of exact match (e.g. `DYLD_`).
337    /// Never empty.
338    ///
339    /// # Examples
340    ///
341    /// ```
342    /// use mcp_execution_codegen::progressive::BridgeContext;
343    ///
344    /// assert!(!BridgeContext::default().forbidden_env_prefix().is_empty());
345    /// ```
346    #[must_use]
347    pub fn forbidden_env_prefix(&self) -> &str {
348        &self.forbidden_env_prefix
349    }
350
351    /// POSIX/Windows environment-variable-name identifier charset, as an anchored JavaScript
352    /// `RegExp`-compatible pattern source. Never empty.
353    ///
354    /// # Examples
355    ///
356    /// ```
357    /// use mcp_execution_codegen::progressive::BridgeContext;
358    ///
359    /// assert!(!BridgeContext::default().env_name_charset_pattern().is_empty());
360    /// ```
361    #[must_use]
362    pub fn env_name_charset_pattern(&self) -> &str {
363        &self.env_name_charset_pattern
364    }
365}
366
367impl Default for BridgeContext {
368    /// Populates the forbidden-char/forbidden-env-name/charset-pattern fields directly from
369    /// `mcp_execution_core`'s canonical lists/constants, so `BridgeContext::default()` can
370    /// never render a bridge with an empty (fail-open) `FORBIDDEN_CHARS` or
371    /// `ENV_NAME_CHARSET_REGEX`. Each `forbidden_chars` entry and `env_name_charset_pattern`
372    /// itself are passed through `sanitize_ts_string_literal` (this crate's TS-string-literal
373    /// escaper) so they render as syntactically valid single-quoted TypeScript string literals
374    /// regardless of what the Rust source contains — critique #471/#467 S2: without this, a
375    /// future edit introducing a `'`/`\` into the Rust pattern would either break the generated
376    /// `new RegExp('...')` call or silently change what it matches.
377    fn default() -> Self {
378        Self {
379            forbidden_chars: mcp_execution_core::forbidden_chars()
380                .iter()
381                .map(|c| crate::progressive::generator::sanitize_ts_string_literal(&c.to_string()))
382                .collect(),
383            forbidden_env_names: mcp_execution_core::forbidden_env_names()
384                .iter()
385                .map(|&s| s.to_string())
386                .collect(),
387            forbidden_env_prefix: mcp_execution_core::forbidden_env_prefix().to_string(),
388            env_name_charset_pattern: crate::progressive::generator::sanitize_ts_string_literal(
389                mcp_execution_core::env_name_charset_pattern(),
390            ),
391            env_name_charset_desc: crate::progressive::generator::sanitize_ts_string_literal(
392                mcp_execution_core::env_name_charset_desc(),
393            ),
394            max_arg_count: mcp_execution_core::MAX_ARG_COUNT,
395            max_arg_len: mcp_execution_core::MAX_ARG_LEN,
396            max_env_count: mcp_execution_core::MAX_ENV_COUNT,
397            max_env_value_len: mcp_execution_core::MAX_ENV_VALUE_LEN,
398            max_url_len: mcp_execution_core::MAX_URL_LEN,
399            max_header_count: mcp_execution_core::MAX_HEADER_COUNT,
400            max_header_value_len: mcp_execution_core::MAX_HEADER_VALUE_LEN,
401        }
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use serde_json::json;
409
410    #[test]
411    fn test_tool_context() {
412        let context = ToolContext {
413            server_id: "github".to_string(),
414            name: "create_issue".to_string(),
415            name_literal: "create_issue".to_string(),
416            server_id_literal: "github".to_string(),
417            typescript_name: "createIssue".to_string(),
418            description: "Creates an issue".to_string(),
419            input_schema: json!({"type": "object"}),
420            properties: vec![],
421            category: Some("issues".to_string()),
422            keywords: Some("create,issue,new".to_string()),
423            short_description: "Create a new issue".to_string(),
424        };
425
426        assert_eq!(context.server_id, "github");
427        assert_eq!(context.name, "create_issue");
428        assert_eq!(context.typescript_name, "createIssue");
429        assert_eq!(context.category, Some("issues".to_string()));
430        assert_eq!(context.keywords, Some("create,issue,new".to_string()));
431    }
432
433    #[test]
434    fn test_property_info() {
435        let prop = PropertyInfo {
436            name: "title".to_string(),
437            typescript_type: "string".to_string(),
438            description: Some("Issue title".to_string()),
439            required: true,
440        };
441
442        assert_eq!(prop.name, "title");
443        assert_eq!(prop.typescript_type, "string");
444        assert!(prop.required);
445    }
446
447    #[test]
448    fn test_index_context() {
449        let context = IndexContext {
450            server_name: "GitHub".to_string(),
451            server_version: "1.0.0".to_string(),
452            tool_count: 5,
453            tools: vec![],
454            categories: None,
455        };
456
457        assert_eq!(context.server_name, "GitHub");
458        assert_eq!(context.tool_count, 5);
459        assert!(context.categories.is_none());
460    }
461
462    #[test]
463    fn test_tool_summary() {
464        let summary = ToolSummary {
465            typescript_name: "createIssue".to_string(),
466            description: "Creates an issue".to_string(),
467            category: Some("issues".to_string()),
468            keywords: Some("create,issue".to_string()),
469            short_description: Some("Create issue".to_string()),
470        };
471
472        assert_eq!(summary.typescript_name, "createIssue");
473        assert_eq!(summary.category, Some("issues".to_string()));
474        assert_eq!(summary.keywords, Some("create,issue".to_string()));
475    }
476
477    #[test]
478    fn test_bridge_context_default() {
479        let context = BridgeContext::default();
480        let _serialized = serde_json::to_string(&context).unwrap();
481
482        // #221 critique S2: `Default` must never render a fail-open (empty) forbidden-char
483        // list, since an empty `FORBIDDEN_CHARS` in the rendered bridge would make
484        // `validateCommandString` accept every shell metacharacter. Same reasoning applies to
485        // an empty `env_name_charset_pattern`: `new RegExp('')` matches every string.
486        assert!(!context.forbidden_chars().is_empty());
487        assert!(!context.forbidden_env_names().is_empty());
488        assert!(!context.forbidden_env_prefix().is_empty());
489        assert!(!context.env_name_charset_pattern().is_empty());
490
491        // #471: the DoS size/count ceilings must be populated from mcp_execution_core, not
492        // left at zero (which would reject every config, silently breaking every generated
493        // server rather than failing open — a different but still real correctness bug).
494        assert_eq!(context.max_arg_count, mcp_execution_core::MAX_ARG_COUNT);
495        assert_eq!(context.max_arg_len, mcp_execution_core::MAX_ARG_LEN);
496        assert_eq!(context.max_env_count, mcp_execution_core::MAX_ENV_COUNT);
497        assert_eq!(
498            context.max_env_value_len,
499            mcp_execution_core::MAX_ENV_VALUE_LEN
500        );
501        assert_eq!(context.max_url_len, mcp_execution_core::MAX_URL_LEN);
502        assert_eq!(
503            context.max_header_count,
504            mcp_execution_core::MAX_HEADER_COUNT
505        );
506        assert_eq!(
507            context.max_header_value_len,
508            mcp_execution_core::MAX_HEADER_VALUE_LEN
509        );
510        // Escaping is a no-op on this quote/backslash-free pattern, so the sanitized copy
511        // still equals the raw Rust source of truth.
512        assert_eq!(
513            context.env_name_charset_pattern(),
514            mcp_execution_core::env_name_charset_pattern()
515        );
516        assert_eq!(
517            context.env_name_charset_desc,
518            mcp_execution_core::env_name_charset_desc()
519        );
520    }
521}