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 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` rather than leaving them empty. This
229/// deliberately does *not* derive `Default`: an empty `forbidden_chars` would render a bridge
230/// whose `validateCommandString` accepts every shell metacharacter (fail-open on exactly the
231/// check this exists to enforce), so `Default` is hand-written to make "always populated" a
232/// property of the type rather than a convention callers must remember to uphold.
233///
234/// The three fields are private with read-only accessors for the same reason: `pub` fields
235/// would let `BridgeContext { forbidden_chars: vec![], .. }` bypass the invariant entirely and
236/// still compile, silently reintroducing the fail-open state `Default` exists to prevent.
237/// `Deserialize` is intentionally not derived — nothing in this codebase deserializes a
238/// `BridgeContext` from external input, and doing so would need to re-validate non-emptiness
239/// rather than trust the wire data.
240///
241/// # Examples
242///
243/// ```
244/// use mcp_execution_codegen::progressive::BridgeContext;
245///
246/// let context = BridgeContext::default();
247/// assert!(!context.forbidden_chars().is_empty());
248/// assert!(context.forbidden_chars().contains(&";".to_string()));
249/// assert!(!context.forbidden_env_prefix().is_empty());
250/// ```
251// The shared `forbidden_` prefix mirrors the three distinct `mcp_execution_core` accessors
252// (`forbidden_chars`/`forbidden_env_names`/`forbidden_env_prefix`) these fields are populated
253// from; dropping it would obscure that correspondence for no clarity gain.
254#[allow(clippy::struct_field_names)]
255#[derive(Debug, Clone, Serialize)]
256pub struct BridgeContext {
257    /// Shell metacharacters forbidden in a command or argument string, each pre-escaped for
258    /// safe embedding inside a single-quoted TypeScript string literal.
259    forbidden_chars: Vec<String>,
260    /// Forbidden environment variable names (exact match).
261    forbidden_env_names: Vec<String>,
262    /// Environment-variable-name prefix rejected regardless of exact match (e.g. `DYLD_`).
263    forbidden_env_prefix: String,
264}
265
266impl 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. Never empty.
269    ///
270    /// # Examples
271    ///
272    /// ```
273    /// use mcp_execution_codegen::progressive::BridgeContext;
274    ///
275    /// assert!(!BridgeContext::default().forbidden_chars().is_empty());
276    /// ```
277    #[must_use]
278    pub fn forbidden_chars(&self) -> &[String] {
279        &self.forbidden_chars
280    }
281
282    /// Forbidden environment variable names (exact match). Never empty.
283    ///
284    /// # Examples
285    ///
286    /// ```
287    /// use mcp_execution_codegen::progressive::BridgeContext;
288    ///
289    /// assert!(!BridgeContext::default().forbidden_env_names().is_empty());
290    /// ```
291    #[must_use]
292    pub fn forbidden_env_names(&self) -> &[String] {
293        &self.forbidden_env_names
294    }
295
296    /// Environment-variable-name prefix rejected regardless of exact match (e.g. `DYLD_`).
297    /// Never empty.
298    ///
299    /// # Examples
300    ///
301    /// ```
302    /// use mcp_execution_codegen::progressive::BridgeContext;
303    ///
304    /// assert!(!BridgeContext::default().forbidden_env_prefix().is_empty());
305    /// ```
306    #[must_use]
307    pub fn forbidden_env_prefix(&self) -> &str {
308        &self.forbidden_env_prefix
309    }
310}
311
312impl Default for BridgeContext {
313    /// Populates the forbidden-char/forbidden-env-name fields directly from
314    /// `mcp_execution_core`'s canonical lists, so `BridgeContext::default()` can never render
315    /// a bridge with an empty (fail-open) `FORBIDDEN_CHARS`. Each character is passed through
316    /// `sanitize_ts_string_literal` (this crate's TS-string-literal escaper) so it renders as
317    /// a syntactically valid single-quoted TypeScript string literal regardless of what the
318    /// Rust list contains.
319    fn default() -> Self {
320        Self {
321            forbidden_chars: mcp_execution_core::forbidden_chars()
322                .iter()
323                .map(|c| crate::progressive::generator::sanitize_ts_string_literal(&c.to_string()))
324                .collect(),
325            forbidden_env_names: mcp_execution_core::forbidden_env_names()
326                .iter()
327                .map(|&s| s.to_string())
328                .collect(),
329            forbidden_env_prefix: mcp_execution_core::forbidden_env_prefix().to_string(),
330        }
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use serde_json::json;
338
339    #[test]
340    fn test_tool_context() {
341        let context = ToolContext {
342            server_id: "github".to_string(),
343            name: "create_issue".to_string(),
344            name_literal: "create_issue".to_string(),
345            server_id_literal: "github".to_string(),
346            typescript_name: "createIssue".to_string(),
347            description: "Creates an issue".to_string(),
348            input_schema: json!({"type": "object"}),
349            properties: vec![],
350            category: Some("issues".to_string()),
351            keywords: Some("create,issue,new".to_string()),
352            short_description: "Create a new issue".to_string(),
353        };
354
355        assert_eq!(context.server_id, "github");
356        assert_eq!(context.name, "create_issue");
357        assert_eq!(context.typescript_name, "createIssue");
358        assert_eq!(context.category, Some("issues".to_string()));
359        assert_eq!(context.keywords, Some("create,issue,new".to_string()));
360    }
361
362    #[test]
363    fn test_property_info() {
364        let prop = PropertyInfo {
365            name: "title".to_string(),
366            typescript_type: "string".to_string(),
367            description: Some("Issue title".to_string()),
368            required: true,
369        };
370
371        assert_eq!(prop.name, "title");
372        assert_eq!(prop.typescript_type, "string");
373        assert!(prop.required);
374    }
375
376    #[test]
377    fn test_index_context() {
378        let context = IndexContext {
379            server_name: "GitHub".to_string(),
380            server_version: "1.0.0".to_string(),
381            tool_count: 5,
382            tools: vec![],
383            categories: None,
384        };
385
386        assert_eq!(context.server_name, "GitHub");
387        assert_eq!(context.tool_count, 5);
388        assert!(context.categories.is_none());
389    }
390
391    #[test]
392    fn test_tool_summary() {
393        let summary = ToolSummary {
394            typescript_name: "createIssue".to_string(),
395            description: "Creates an issue".to_string(),
396            category: Some("issues".to_string()),
397            keywords: Some("create,issue".to_string()),
398            short_description: Some("Create issue".to_string()),
399        };
400
401        assert_eq!(summary.typescript_name, "createIssue");
402        assert_eq!(summary.category, Some("issues".to_string()));
403        assert_eq!(summary.keywords, Some("create,issue".to_string()));
404    }
405
406    #[test]
407    fn test_bridge_context_default() {
408        let context = BridgeContext::default();
409        let _serialized = serde_json::to_string(&context).unwrap();
410
411        // #221 critique S2: `Default` must never render a fail-open (empty) forbidden-char
412        // list, since an empty `FORBIDDEN_CHARS` in the rendered bridge would make
413        // `validateCommandString` accept every shell metacharacter.
414        assert!(!context.forbidden_chars().is_empty());
415        assert!(!context.forbidden_env_names().is_empty());
416        assert!(!context.forbidden_env_prefix().is_empty());
417    }
418}