mcp_execution_codegen/progressive/
generator.rs

1//! Progressive loading code generator.
2//!
3//! Generates TypeScript files for progressive loading where each tool
4//! is in a separate file, enabling Claude Code to load only what it needs.
5//!
6//! # Examples
7//!
8//! ```no_run
9//! use mcp_execution_codegen::progressive::ProgressiveGenerator;
10//! use mcp_execution_introspector::{Introspector, ServerInfo};
11//! use mcp_execution_core::{ServerId, ServerConfig};
12//!
13//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
14//! let mut introspector = Introspector::new();
15//! let server_id = ServerId::new("github");
16//! let config = ServerConfig::builder().command("/path/to/server".to_string()).build();
17//! let info = introspector.discover_server(server_id, &config).await?;
18//!
19//! let generator = ProgressiveGenerator::new()?;
20//! let code = generator.generate(&info)?;
21//!
22//! // Generated files:
23//! // - index.ts (re-exports)
24//! // - createIssue.ts
25//! // - updateIssue.ts
26//! // - ...
27//! // - _runtime/mcp-bridge.ts
28//! println!("Generated {} files", code.file_count());
29//! # Ok(())
30//! # }
31//! ```
32
33use crate::common::types::{GeneratedCode, GeneratedFile};
34use crate::common::typescript::{extract_properties, to_camel_case};
35use crate::progressive::types::{
36    BridgeContext, CategoryInfo, IndexContext, PropertyInfo, ToolCategorization, ToolContext,
37    ToolSummary,
38};
39use crate::template_engine::TemplateEngine;
40use mcp_execution_core::{Error, Result};
41use mcp_execution_introspector::ServerInfo;
42use std::collections::HashMap;
43
44/// Generator for progressive loading TypeScript files.
45///
46/// Creates one file per tool plus an index file and runtime bridge,
47/// enabling progressive loading where only needed tools are loaded.
48///
49/// # Thread Safety
50///
51/// This type is `Send` and `Sync`, allowing safe use across threads.
52///
53/// # Examples
54///
55/// ```
56/// use mcp_execution_codegen::progressive::ProgressiveGenerator;
57///
58/// let generator = ProgressiveGenerator::new().unwrap();
59/// ```
60#[derive(Debug)]
61pub struct ProgressiveGenerator<'a> {
62    engine: TemplateEngine<'a>,
63}
64
65impl<'a> ProgressiveGenerator<'a> {
66    /// Creates a new progressive generator.
67    ///
68    /// Initializes the template engine and registers all progressive
69    /// loading templates.
70    ///
71    /// # Errors
72    ///
73    /// Returns error if template registration fails (should not happen
74    /// with valid built-in templates).
75    ///
76    /// # Examples
77    ///
78    /// ```
79    /// use mcp_execution_codegen::progressive::ProgressiveGenerator;
80    ///
81    /// let generator = ProgressiveGenerator::new().unwrap();
82    /// ```
83    pub fn new() -> Result<Self> {
84        let engine = TemplateEngine::new()?;
85        Ok(Self { engine })
86    }
87
88    /// Generates progressive loading files for a server.
89    ///
90    /// Creates one TypeScript file per tool, plus:
91    /// - `index.ts`: Re-exports all tools
92    /// - `_runtime/mcp-bridge.ts`: Runtime bridge for calling MCP tools
93    ///
94    /// # Arguments
95    ///
96    /// * `server_info` - MCP server introspection data
97    ///
98    /// # Returns
99    ///
100    /// Generated code with one file per tool plus index and runtime bridge.
101    ///
102    /// # Errors
103    ///
104    /// Returns error if:
105    /// - Template rendering fails
106    /// - Type conversion fails
107    ///
108    /// # Examples
109    ///
110    /// ```no_run
111    /// use mcp_execution_codegen::progressive::ProgressiveGenerator;
112    /// use mcp_execution_introspector::{ServerInfo, ServerCapabilities};
113    /// use mcp_execution_core::ServerId;
114    ///
115    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
116    /// let generator = ProgressiveGenerator::new()?;
117    ///
118    /// let info = ServerInfo {
119    ///     id: ServerId::new("github"),
120    ///     name: "GitHub".to_string(),
121    ///     version: "1.0.0".to_string(),
122    ///     tools: vec![],
123    ///     capabilities: ServerCapabilities {
124    ///         supports_tools: true,
125    ///         supports_resources: false,
126    ///         supports_prompts: false,
127    ///     },
128    /// };
129    ///
130    /// let code = generator.generate(&info)?;
131    ///
132    /// // Files generated:
133    /// // - index.ts
134    /// // - _runtime/mcp-bridge.ts
135    /// // - one file per tool
136    /// println!("Generated {} files", code.file_count());
137    /// # Ok(())
138    /// # }
139    /// ```
140    pub fn generate(&self, server_info: &ServerInfo) -> Result<GeneratedCode> {
141        tracing::info!(
142            "Generating progressive loading code for server: {}",
143            server_info.name
144        );
145
146        let mut code = GeneratedCode::new();
147        let server_id = server_info.id.as_str();
148
149        // Generate tool files (one per tool)
150        for tool in &server_info.tools {
151            let tool_context = self.create_tool_context(server_id, tool, None)?;
152            let tool_code = self.engine.render("progressive/tool", &tool_context)?;
153
154            code.add_file(GeneratedFile {
155                path: format!("{}.ts", tool_context.typescript_name),
156                content: tool_code,
157            });
158
159            tracing::debug!("Generated tool file: {}.ts", tool_context.typescript_name);
160        }
161
162        // Generate index.ts
163        let index_context = self.create_index_context(server_info, None)?;
164        let index_code = self.engine.render("progressive/index", &index_context)?;
165
166        code.add_file(GeneratedFile {
167            path: "index.ts".to_string(),
168            content: index_code,
169        });
170
171        tracing::debug!("Generated index.ts");
172
173        // Generate runtime bridge
174        let bridge_context = BridgeContext::default();
175        let bridge_code = self
176            .engine
177            .render("progressive/runtime-bridge", &bridge_context)?;
178
179        code.add_file(GeneratedFile {
180            path: "_runtime/mcp-bridge.ts".to_string(),
181            content: bridge_code,
182        });
183
184        tracing::debug!("Generated _runtime/mcp-bridge.ts");
185
186        tracing::info!(
187            "Successfully generated {} files for {} (progressive loading)",
188            code.file_count(),
189            server_info.name
190        );
191
192        Ok(code)
193    }
194
195    /// Generates progressive loading files with categorization metadata.
196    ///
197    /// Like `generate`, but includes full categorization information from Claude's
198    /// analysis. Categories, keywords, and short descriptions are displayed in
199    /// the index file and included in individual tool file headers.
200    ///
201    /// # Arguments
202    ///
203    /// * `server_info` - MCP server introspection data
204    /// * `categorizations` - Map of tool name to categorization metadata
205    ///
206    /// # Returns
207    ///
208    /// Generated code with categorization metadata included.
209    ///
210    /// # Errors
211    ///
212    /// Returns error if template rendering fails.
213    ///
214    /// # Examples
215    ///
216    /// ```no_run
217    /// use mcp_execution_codegen::progressive::{ProgressiveGenerator, ToolCategorization};
218    /// use mcp_execution_introspector::{ServerInfo, ServerCapabilities};
219    /// use mcp_execution_core::ServerId;
220    /// use std::collections::HashMap;
221    ///
222    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
223    /// let generator = ProgressiveGenerator::new()?;
224    ///
225    /// let info = ServerInfo {
226    ///     id: ServerId::new("github"),
227    ///     name: "GitHub".to_string(),
228    ///     version: "1.0.0".to_string(),
229    ///     tools: vec![],
230    ///     capabilities: ServerCapabilities {
231    ///         supports_tools: true,
232    ///         supports_resources: false,
233    ///         supports_prompts: false,
234    ///     },
235    /// };
236    ///
237    /// let mut categorizations = HashMap::new();
238    /// categorizations.insert("create_issue".to_string(), ToolCategorization {
239    ///     category: "issues".to_string(),
240    ///     keywords: "create,issue,new,bug".to_string(),
241    ///     short_description: "Create a new issue".to_string(),
242    /// });
243    ///
244    /// let code = generator.generate_with_categories(&info, &categorizations)?;
245    /// # Ok(())
246    /// # }
247    /// ```
248    pub fn generate_with_categories(
249        &self,
250        server_info: &ServerInfo,
251        categorizations: &HashMap<String, ToolCategorization>,
252    ) -> Result<GeneratedCode> {
253        tracing::info!(
254            "Generating progressive loading code with categorizations for server: {}",
255            server_info.name
256        );
257
258        let mut code = GeneratedCode::new();
259        let server_id = server_info.id.as_str();
260
261        // Generate tool files (one per tool) with categorization metadata
262        for tool in &server_info.tools {
263            let tool_name = tool.name.as_str();
264            let categorization = categorizations.get(tool_name);
265            let tool_context = self.create_tool_context(server_id, tool, categorization)?;
266            let tool_code = self.engine.render("progressive/tool", &tool_context)?;
267
268            code.add_file(GeneratedFile {
269                path: format!("{}.ts", tool_context.typescript_name),
270                content: tool_code,
271            });
272
273            tracing::debug!(
274                "Generated tool file: {}.ts (category: {:?})",
275                tool_context.typescript_name,
276                categorization.map(|c| &c.category)
277            );
278        }
279
280        // Generate index.ts with category grouping
281        let index_context = self.create_index_context(server_info, Some(categorizations))?;
282        let index_code = self.engine.render("progressive/index", &index_context)?;
283
284        code.add_file(GeneratedFile {
285            path: "index.ts".to_string(),
286            content: index_code,
287        });
288
289        tracing::debug!(
290            "Generated index.ts with {} categorizations",
291            categorizations.len()
292        );
293
294        // Generate runtime bridge (same as non-categorized)
295        let bridge_context = BridgeContext::default();
296        let bridge_code = self
297            .engine
298            .render("progressive/runtime-bridge", &bridge_context)?;
299
300        code.add_file(GeneratedFile {
301            path: "_runtime/mcp-bridge.ts".to_string(),
302            content: bridge_code,
303        });
304
305        tracing::debug!("Generated _runtime/mcp-bridge.ts");
306
307        tracing::info!(
308            "Successfully generated {} files for {} with categorizations (progressive loading)",
309            code.file_count(),
310            server_info.name
311        );
312
313        Ok(code)
314    }
315
316    /// Creates tool context from MCP tool information.
317    ///
318    /// Converts MCP tool schema to the format needed for template rendering.
319    ///
320    /// # Errors
321    ///
322    /// Returns error if schema conversion fails.
323    fn create_tool_context(
324        &self,
325        server_id: &str,
326        tool: &mcp_execution_introspector::ToolInfo,
327        categorization: Option<&ToolCategorization>,
328    ) -> Result<ToolContext> {
329        let typescript_name = to_camel_case(tool.name.as_str());
330
331        // Extract properties from input schema
332        let properties = self.extract_property_infos(&tool.input_schema)?;
333
334        Ok(ToolContext {
335            server_id: server_id.to_string(),
336            name: tool.name.as_str().to_string(),
337            typescript_name,
338            description: tool.description.clone(),
339            input_schema: tool.input_schema.clone(),
340            properties,
341            category: categorization.map(|c| c.category.clone()),
342            keywords: categorization.map(|c| c.keywords.clone()),
343            short_description: categorization.map(|c| c.short_description.clone()),
344        })
345    }
346
347    /// Creates index context from server information.
348    fn create_index_context(
349        &self,
350        server_info: &ServerInfo,
351        categorizations: Option<&HashMap<String, ToolCategorization>>,
352    ) -> Result<IndexContext> {
353        let tools: Vec<ToolSummary> = server_info
354            .tools
355            .iter()
356            .map(|tool| {
357                let tool_name = tool.name.as_str();
358                let cat = categorizations.and_then(|c| c.get(tool_name));
359                ToolSummary {
360                    typescript_name: to_camel_case(tool_name),
361                    description: tool.description.clone(),
362                    category: cat.map(|c| c.category.clone()),
363                    keywords: cat.map(|c| c.keywords.clone()),
364                    short_description: cat.map(|c| c.short_description.clone()),
365                }
366            })
367            .collect();
368
369        // Build category groups if categorizations are provided
370        let category_groups = categorizations.map(|_| {
371            let mut groups: HashMap<String, Vec<ToolSummary>> = HashMap::new();
372
373            for tool in &tools {
374                let cat_name = tool
375                    .category
376                    .clone()
377                    .unwrap_or_else(|| "uncategorized".to_string());
378                groups.entry(cat_name).or_default().push(tool.clone());
379            }
380
381            let mut result: Vec<CategoryInfo> = groups
382                .into_iter()
383                .map(|(name, tools)| CategoryInfo { name, tools })
384                .collect();
385
386            // Sort categories alphabetically, but keep "uncategorized" last
387            result.sort_by(|a, b| {
388                if a.name == "uncategorized" {
389                    std::cmp::Ordering::Greater
390                } else if b.name == "uncategorized" {
391                    std::cmp::Ordering::Less
392                } else {
393                    a.name.cmp(&b.name)
394                }
395            });
396
397            result
398        });
399
400        Ok(IndexContext {
401            server_name: server_info.name.clone(),
402            server_version: server_info.version.clone(),
403            tool_count: server_info.tools.len(),
404            tools,
405            categories: category_groups,
406        })
407    }
408
409    /// Extracts property information from JSON Schema.
410    ///
411    /// Converts JSON Schema properties into `PropertyInfo` structures
412    /// suitable for template rendering.
413    ///
414    /// # Errors
415    ///
416    /// Returns error if schema is malformed or type conversion fails.
417    fn extract_property_infos(&self, schema: &serde_json::Value) -> Result<Vec<PropertyInfo>> {
418        let raw_properties = extract_properties(schema);
419
420        let mut properties = Vec::new();
421        for prop in raw_properties {
422            let name = prop["name"]
423                .as_str()
424                .ok_or_else(|| Error::ValidationError {
425                    field: "name".to_string(),
426                    reason: "Property name is not a string".to_string(),
427                })?
428                .to_string();
429
430            let typescript_type = prop["type"]
431                .as_str()
432                .ok_or_else(|| Error::ValidationError {
433                    field: "type".to_string(),
434                    reason: "Property type is not a string".to_string(),
435                })?
436                .to_string();
437
438            let required = prop["required"].as_bool().unwrap_or(false);
439
440            // Extract description if available
441            let description = if let Some(obj) = schema.as_object() {
442                obj.get("properties")
443                    .and_then(|props| props.as_object())
444                    .and_then(|props| props.get(&name))
445                    .and_then(|prop_schema| prop_schema.as_object())
446                    .and_then(|obj| obj.get("description"))
447                    .and_then(|desc| desc.as_str())
448                    .map(String::from)
449            } else {
450                None
451            };
452
453            properties.push(PropertyInfo {
454                name,
455                typescript_type,
456                description,
457                required,
458            });
459        }
460
461        Ok(properties)
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use mcp_execution_core::{ServerId, ToolName};
469    use mcp_execution_introspector::{ServerCapabilities, ToolInfo};
470    use serde_json::json;
471
472    fn create_test_server_info() -> ServerInfo {
473        ServerInfo {
474            id: ServerId::new("test-server"),
475            name: "Test Server".to_string(),
476            version: "1.0.0".to_string(),
477            tools: vec![
478                ToolInfo {
479                    name: ToolName::new("create_issue"),
480                    description: "Creates a new issue".to_string(),
481                    input_schema: json!({
482                        "type": "object",
483                        "properties": {
484                            "title": {
485                                "type": "string",
486                                "description": "Issue title"
487                            },
488                            "body": {
489                                "type": "string",
490                                "description": "Issue body"
491                            }
492                        },
493                        "required": ["title"]
494                    }),
495                    output_schema: None,
496                },
497                ToolInfo {
498                    name: ToolName::new("update_issue"),
499                    description: "Updates an existing issue".to_string(),
500                    input_schema: json!({
501                        "type": "object",
502                        "properties": {
503                            "id": {
504                                "type": "number"
505                            }
506                        },
507                        "required": ["id"]
508                    }),
509                    output_schema: None,
510                },
511            ],
512            capabilities: ServerCapabilities {
513                supports_tools: true,
514                supports_resources: false,
515                supports_prompts: false,
516            },
517        }
518    }
519
520    #[test]
521    fn test_progressive_generator_new() {
522        let generator = ProgressiveGenerator::new();
523        assert!(generator.is_ok());
524    }
525
526    #[test]
527    fn test_generate_progressive_files() {
528        let generator = ProgressiveGenerator::new().unwrap();
529        let server_info = create_test_server_info();
530
531        let code = generator.generate(&server_info).unwrap();
532
533        // Should generate:
534        // - 2 tool files
535        // - 1 index.ts
536        // - 1 runtime bridge
537        assert_eq!(code.file_count(), 4);
538
539        // Check tool files exist
540        let tool_files: Vec<_> = code.files.iter().map(|f| f.path.as_str()).collect();
541
542        assert!(tool_files.contains(&"createIssue.ts"));
543        assert!(tool_files.contains(&"updateIssue.ts"));
544        assert!(tool_files.contains(&"index.ts"));
545        assert!(tool_files.contains(&"_runtime/mcp-bridge.ts"));
546    }
547
548    #[test]
549    fn test_create_tool_context() {
550        let generator = ProgressiveGenerator::new().unwrap();
551        let tool = ToolInfo {
552            name: ToolName::new("send_message"),
553            description: "Sends a message".to_string(),
554            input_schema: json!({
555                "type": "object",
556                "properties": {
557                    "text": {"type": "string"}
558                },
559                "required": ["text"]
560            }),
561            output_schema: None,
562        };
563
564        let categorization = ToolCategorization {
565            category: "messaging".to_string(),
566            keywords: "send,message,chat".to_string(),
567            short_description: "Send a message".to_string(),
568        };
569        let context = generator
570            .create_tool_context("test-server", &tool, Some(&categorization))
571            .unwrap();
572
573        assert_eq!(context.server_id, "test-server");
574        assert_eq!(context.name, "send_message");
575        assert_eq!(context.typescript_name, "sendMessage");
576        assert_eq!(context.description, "Sends a message");
577        assert_eq!(context.properties.len(), 1);
578        assert_eq!(context.properties[0].name, "text");
579        assert_eq!(context.category, Some("messaging".to_string()));
580        assert_eq!(context.keywords, Some("send,message,chat".to_string()));
581        assert_eq!(
582            context.short_description,
583            Some("Send a message".to_string())
584        );
585    }
586
587    #[test]
588    fn test_create_index_context() {
589        let generator = ProgressiveGenerator::new().unwrap();
590        let server_info = create_test_server_info();
591
592        let context = generator.create_index_context(&server_info, None).unwrap();
593
594        assert_eq!(context.server_name, "Test Server");
595        assert_eq!(context.server_version, "1.0.0");
596        assert_eq!(context.tool_count, 2);
597        assert_eq!(context.tools.len(), 2);
598        assert_eq!(context.tools[0].typescript_name, "createIssue");
599        assert!(context.categories.is_none());
600    }
601
602    #[test]
603    fn test_extract_property_infos() {
604        let generator = ProgressiveGenerator::new().unwrap();
605        let schema = json!({
606            "type": "object",
607            "properties": {
608                "name": {
609                    "type": "string",
610                    "description": "User name"
611                },
612                "age": {
613                    "type": "number"
614                }
615            },
616            "required": ["name"]
617        });
618
619        let props = generator.extract_property_infos(&schema).unwrap();
620
621        assert_eq!(props.len(), 2);
622
623        // Find name property
624        let name_prop = props.iter().find(|p| p.name == "name").unwrap();
625        assert_eq!(name_prop.typescript_type, "string");
626        assert_eq!(name_prop.description, Some("User name".to_string()));
627        assert!(name_prop.required);
628
629        // Find age property
630        let age_prop = props.iter().find(|p| p.name == "age").unwrap();
631        assert_eq!(age_prop.typescript_type, "number");
632        assert!(!age_prop.required);
633    }
634}