mcp_execution_codegen/progressive/
generator.rs1use 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#[derive(Debug)]
61pub struct ProgressiveGenerator<'a> {
62 engine: TemplateEngine<'a>,
63}
64
65impl<'a> ProgressiveGenerator<'a> {
66 pub fn new() -> Result<Self> {
84 let engine = TemplateEngine::new()?;
85 Ok(Self { engine })
86 }
87
88 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(code.file_count(), 4);
538
539 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 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 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}