Skip to main content

mcp_execution_server/
service.rs

1//! MCP server implementation for progressive loading generation.
2//!
3//! The `GeneratorService` provides three main tools:
4//! 1. `introspect_server` - Connect to and introspect an MCP server
5//! 2. `save_categorized_tools` - Generate TypeScript files with categorization
6//! 3. `list_generated_servers` - List all servers with generated files
7
8use crate::state::StateManager;
9use crate::types::{
10    CategorizedTool, GeneratedServerInfo, IntrospectServerParams, IntrospectServerResult,
11    ListGeneratedServersParams, ListGeneratedServersResult, PendingGeneration,
12    SaveCategorizedToolsParams, SaveCategorizedToolsResult, ToolMetadata,
13};
14use mcp_execution_codegen::progressive::ProgressiveGenerator;
15use mcp_execution_core::{ServerConfig, ServerId};
16use mcp_execution_files::FilesBuilder;
17use mcp_execution_introspector::Introspector;
18use mcp_execution_skill::{
19    GenerateSkillParams, SaveSkillParams, SaveSkillResult, build_skill_context,
20    extract_skill_metadata, scan_tools_directory, validate_server_id,
21};
22use rmcp::handler::server::ServerHandler;
23use rmcp::handler::server::tool::ToolRouter;
24use rmcp::handler::server::wrapper::Parameters;
25use rmcp::model::{
26    CallToolResult, Content, Implementation, ProtocolVersion, ServerCapabilities, ServerInfo,
27};
28use rmcp::{ErrorData as McpError, tool, tool_handler, tool_router};
29use std::collections::{HashMap, HashSet};
30use std::path::PathBuf;
31use std::sync::Arc;
32use tokio::sync::Mutex;
33
34/// Maximum SKILL.md content size in bytes (100KB).
35const MAX_SKILL_CONTENT_SIZE: usize = 100 * 1024;
36
37/// MCP server for progressive loading generation.
38///
39/// This service helps generate progressive loading TypeScript files for other
40/// MCP servers. Claude provides the categorization intelligence through natural
41/// language understanding - no separate LLM API needed.
42///
43/// # Workflow
44///
45/// 1. Call `introspect_server` to discover tools from a target MCP server
46/// 2. Claude analyzes the tools and assigns categories, keywords, descriptions
47/// 3. Call `save_categorized_tools` to generate TypeScript files
48/// 4. Use `list_generated_servers` to see all generated servers
49///
50/// # Examples
51///
52/// ```no_run
53/// use mcp_execution_server::service::GeneratorService;
54/// use rmcp::transport::stdio;
55///
56/// # async fn example() {
57/// let service = GeneratorService::new();
58/// // Service implements rmcp ServerHandler trait
59/// # }
60/// ```
61#[derive(Debug, Clone)]
62pub struct GeneratorService {
63    /// State manager for pending generations
64    state: Arc<StateManager>,
65
66    /// MCP server introspector
67    introspector: Arc<Mutex<Introspector>>,
68
69    /// Tool router for MCP protocol
70    #[allow(dead_code)]
71    tool_router: ToolRouter<Self>,
72}
73
74impl GeneratorService {
75    /// Creates a new generator service.
76    #[must_use]
77    pub fn new() -> Self {
78        Self {
79            state: Arc::new(StateManager::new()),
80            introspector: Arc::new(Mutex::new(Introspector::new())),
81            tool_router: Self::tool_router(),
82        }
83    }
84}
85
86impl Default for GeneratorService {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91
92#[tool_router]
93impl GeneratorService {
94    /// Introspect an MCP server and prepare for categorization.
95    ///
96    /// Connects to the target MCP server, discovers its tools, and returns
97    /// metadata for Claude to categorize. Returns a session ID for use with
98    /// `save_categorized_tools`.
99    #[tool(
100        description = "Connect to an MCP server, discover its tools, and return metadata for categorization. Returns a session ID for use with save_categorized_tools."
101    )]
102    async fn introspect_server(
103        &self,
104        Parameters(params): Parameters<IntrospectServerParams>,
105    ) -> Result<CallToolResult, McpError> {
106        // Validate server_id format
107        validate_server_id(&params.server_id).map_err(|e| McpError::invalid_params(e, None))?;
108
109        // Extract server_id before consuming params
110        let server_id_str = params.server_id;
111        let server_id = ServerId::new(&server_id_str);
112
113        // Determine output directory (needs server_id_str)
114        let output_dir = params.output_dir.unwrap_or_else(|| {
115            dirs::home_dir()
116                .unwrap_or_else(|| PathBuf::from("."))
117                .join(".claude")
118                .join("servers")
119                .join(&server_id_str)
120        });
121
122        // Build server config (consume args and env to avoid clones)
123        let mut config_builder = ServerConfig::builder().command(params.command);
124
125        for arg in params.args {
126            config_builder = config_builder.arg(arg);
127        }
128
129        for (key, value) in params.env {
130            config_builder = config_builder.env(key, value);
131        }
132
133        let config = config_builder.build();
134
135        // Connect and introspect
136        let server_info = {
137            let mut introspector = self.introspector.lock().await;
138            introspector
139                .discover_server(server_id.clone(), &config)
140                .await
141                .map_err(|e| {
142                    McpError::internal_error(format!("Failed to introspect server: {e}"), None)
143                })?
144        };
145
146        // Extract tool metadata for Claude
147        let tools: Vec<ToolMetadata> = server_info
148            .tools
149            .iter()
150            .map(|tool| {
151                let parameters = extract_parameter_names(&tool.input_schema);
152
153                ToolMetadata {
154                    name: tool.name.as_str().to_string(),
155                    description: tool.description.clone(),
156                    parameters,
157                }
158            })
159            .collect();
160
161        // Store pending generation
162        let pending =
163            PendingGeneration::new(server_id, server_info.clone(), config, output_dir.clone());
164
165        let session_id = self.state.store(pending.clone()).await;
166
167        // Build result
168        let result = IntrospectServerResult {
169            server_id: server_id_str,
170            server_name: server_info.name,
171            tools_found: tools.len(),
172            tools,
173            session_id,
174            expires_at: pending.expires_at,
175        };
176
177        Ok(CallToolResult::success(vec![Content::text(
178            serde_json::to_string_pretty(&result).map_err(|e| {
179                McpError::internal_error(format!("Failed to serialize result: {e}"), None)
180            })?,
181        )]))
182    }
183
184    /// Save categorized tools as TypeScript files.
185    ///
186    /// Generates progressive loading TypeScript files using Claude's
187    /// categorization. Requires `session_id` from a previous `introspect_server`
188    /// call.
189    #[tool(
190        description = "Generate progressive loading TypeScript files using Claude's categorization. Requires session_id from a previous introspect_server call."
191    )]
192    async fn save_categorized_tools(
193        &self,
194        Parameters(params): Parameters<SaveCategorizedToolsParams>,
195    ) -> Result<CallToolResult, McpError> {
196        // Retrieve pending generation
197        let pending = self.state.take(params.session_id).await.ok_or_else(|| {
198            McpError::invalid_params(
199                "Session not found or expired. Please run introspect_server again.",
200                None,
201            )
202        })?;
203
204        // Validate categorized tools match introspected tools
205        let introspected_names: HashSet<_> = pending
206            .server_info
207            .tools
208            .iter()
209            .map(|t| t.name.as_str())
210            .collect();
211
212        for cat_tool in &params.categorized_tools {
213            if !introspected_names.contains(cat_tool.name.as_str()) {
214                return Err(McpError::invalid_params(
215                    format!("Tool '{}' not found in introspected tools", cat_tool.name),
216                    None,
217                ));
218            }
219        }
220
221        // Build categorization map and category stats in single pass (avoid double iteration)
222        let tool_count = params.categorized_tools.len();
223        let mut categorization: HashMap<String, &CategorizedTool> =
224            HashMap::with_capacity(tool_count);
225        let mut categories: HashMap<String, usize> = HashMap::with_capacity(tool_count);
226
227        for tool in &params.categorized_tools {
228            categorization.insert(tool.name.clone(), tool);
229            *categories.entry(tool.category.clone()).or_default() += 1;
230        }
231
232        // Generate code with categorization
233        let generator = ProgressiveGenerator::new().map_err(|e| {
234            McpError::internal_error(format!("Failed to create generator: {e}"), None)
235        })?;
236
237        let code = generate_with_categorization(&generator, &pending.server_info, &categorization)
238            .map_err(|e| McpError::internal_error(format!("Failed to generate code: {e}"), None))?;
239
240        // Build virtual filesystem
241        let vfs = FilesBuilder::from_generated_code(code, "/")
242            .build()
243            .map_err(|e| McpError::internal_error(format!("Failed to build VFS: {e}"), None))?;
244
245        // Capture file count before moving vfs
246        let files_generated = vfs.file_count();
247
248        // Ensure output directory exists (async)
249        tokio::fs::create_dir_all(&pending.output_dir)
250            .await
251            .map_err(|e| {
252                McpError::internal_error(format!("Failed to create output directory: {e}"), None)
253            })?;
254
255        // Export to filesystem (blocking operation wrapped in spawn_blocking)
256        let output_dir = pending.output_dir.clone();
257        tokio::task::spawn_blocking(move || vfs.export_to_filesystem(&output_dir))
258            .await
259            .map_err(|e| McpError::internal_error(format!("Task join error: {e}"), None))?
260            .map_err(|e| McpError::internal_error(format!("Failed to export files: {e}"), None))?;
261
262        let result = SaveCategorizedToolsResult {
263            success: true,
264            files_generated,
265            output_dir: pending.output_dir.display().to_string(),
266            categories,
267            errors: vec![],
268        };
269
270        Ok(CallToolResult::success(vec![Content::text(
271            serde_json::to_string_pretty(&result).map_err(|e| {
272                McpError::internal_error(format!("Failed to serialize result: {e}"), None)
273            })?,
274        )]))
275    }
276
277    /// List all servers with generated progressive loading files.
278    ///
279    /// Scans the output directory (default: `~/.claude/servers`) for servers
280    /// that have generated TypeScript files.
281    #[tool(
282        description = "List all MCP servers that have generated progressive loading files in ~/.claude/servers/"
283    )]
284    async fn list_generated_servers(
285        &self,
286        Parameters(params): Parameters<ListGeneratedServersParams>,
287    ) -> Result<CallToolResult, McpError> {
288        let base_dir = params.base_dir.map_or_else(
289            || {
290                dirs::home_dir()
291                    .unwrap_or_else(|| PathBuf::from("."))
292                    .join(".claude")
293                    .join("servers")
294            },
295            PathBuf::from,
296        );
297
298        // Scan directories (blocking operation wrapped in spawn_blocking)
299        let servers = tokio::task::spawn_blocking(move || {
300            let mut servers = Vec::new();
301
302            if base_dir.exists()
303                && base_dir.is_dir()
304                && let Ok(entries) = std::fs::read_dir(&base_dir)
305            {
306                for entry in entries.flatten() {
307                    if entry.path().is_dir() {
308                        let id = entry.file_name().to_string_lossy().to_string();
309
310                        // Count .ts files (excluding _runtime and starting with _)
311                        let tool_count = std::fs::read_dir(entry.path()).map_or(0, |e| {
312                            e.flatten()
313                                .filter(|f| {
314                                    let name = f.file_name();
315                                    let name = name.to_string_lossy();
316                                    name.ends_with(".ts") && !name.starts_with('_')
317                                })
318                                .count()
319                        });
320
321                        // Get modification time
322                        let generated_at = entry
323                            .metadata()
324                            .and_then(|m| m.modified())
325                            .ok()
326                            .map(chrono::DateTime::<chrono::Utc>::from);
327
328                        servers.push(GeneratedServerInfo {
329                            id,
330                            tool_count,
331                            generated_at,
332                            output_dir: entry.path().display().to_string(),
333                        });
334                    }
335                }
336            }
337
338            servers.sort_by(|a, b| a.id.cmp(&b.id));
339            servers
340        })
341        .await
342        .map_err(|e| McpError::internal_error(format!("Task join error: {e}"), None))?;
343
344        let result = ListGeneratedServersResult {
345            total_servers: servers.len(),
346            servers,
347        };
348
349        Ok(CallToolResult::success(vec![Content::text(
350            serde_json::to_string_pretty(&result).map_err(|e| {
351                McpError::internal_error(format!("Failed to serialize result: {e}"), None)
352            })?,
353        )]))
354    }
355
356    /// Generate context for creating a Claude Code skill.
357    ///
358    /// Analyzes generated TypeScript files and returns structured context
359    /// that Claude uses to generate an optimal SKILL.md file.
360    ///
361    /// # Workflow
362    ///
363    /// 1. Call `generate_skill` with `server_id`
364    /// 2. Claude receives context and `generation_prompt`
365    /// 3. Claude generates SKILL.md content
366    /// 4. Call `save_skill` with the generated content
367    #[tool(
368        description = "Analyze generated TypeScript files and return context for Claude to create a SKILL.md file. Returns tool metadata, categories, and a generation prompt."
369    )]
370    async fn generate_skill(
371        &self,
372        Parameters(params): Parameters<GenerateSkillParams>,
373    ) -> Result<CallToolResult, McpError> {
374        // Validate server_id format and length
375        validate_server_id(&params.server_id).map_err(|e| McpError::invalid_params(e, None))?;
376
377        // Determine servers directory
378        let servers_dir = params.servers_dir.unwrap_or_else(|| {
379            dirs::home_dir()
380                .unwrap_or_else(|| PathBuf::from("."))
381                .join(".claude")
382                .join("servers")
383        });
384
385        let server_dir = servers_dir.join(&params.server_id);
386
387        // Check if server directory exists
388        if !server_dir.exists() {
389            return Err(McpError::invalid_params(
390                format!(
391                    "Server directory not found: {}. Run generate first.",
392                    server_dir.display()
393                ),
394                None,
395            ));
396        }
397
398        // Scan and parse tool files
399        let tools = scan_tools_directory(&server_dir).await.map_err(|e| {
400            McpError::internal_error(format!("Failed to scan tools directory: {e}"), None)
401        })?;
402
403        if tools.is_empty() {
404            return Err(McpError::invalid_params(
405                format!(
406                    "No tool files found in {}. Run generate first.",
407                    server_dir.display()
408                ),
409                None,
410            ));
411        }
412
413        // Build context
414        let mut result =
415            build_skill_context(&params.server_id, &tools, params.use_case_hints.as_deref());
416
417        // Override skill name if provided
418        if let Some(name) = params.skill_name {
419            result.skill_name = name;
420        }
421
422        Ok(CallToolResult::success(vec![Content::text(
423            serde_json::to_string_pretty(&result).map_err(|e| {
424                McpError::internal_error(format!("Failed to serialize result: {e}"), None)
425            })?,
426        )]))
427    }
428
429    /// Save a generated skill to the filesystem.
430    ///
431    /// Writes SKILL.md content to `~/.claude/skills/{server_id}/SKILL.md`.
432    /// Validates that the content contains required YAML frontmatter.
433    #[tool(
434        description = "Save generated SKILL.md content to ~/.claude/skills/{server_id}/. Use after Claude generates skill content from generate_skill context."
435    )]
436    async fn save_skill(
437        &self,
438        Parameters(params): Parameters<SaveSkillParams>,
439    ) -> Result<CallToolResult, McpError> {
440        // Validate server_id format and length
441        validate_server_id(&params.server_id).map_err(|e| McpError::invalid_params(e, None))?;
442
443        // Validate content size (DoS protection)
444        if params.content.len() > MAX_SKILL_CONTENT_SIZE {
445            return Err(McpError::invalid_params(
446                format!(
447                    "content too large: {} bytes exceeds {} limit",
448                    params.content.len(),
449                    MAX_SKILL_CONTENT_SIZE
450                ),
451                None,
452            ));
453        }
454
455        // Validate content has YAML frontmatter
456        if !params.content.starts_with("---") {
457            return Err(McpError::invalid_params(
458                "Content must start with YAML frontmatter (---)",
459                None,
460            ));
461        }
462
463        // Extract metadata from frontmatter
464        let metadata = extract_skill_metadata(&params.content)
465            .map_err(|e| McpError::invalid_params(format!("Invalid SKILL.md format: {e}"), None))?;
466
467        // Determine output path
468        let output_path = params.output_path.unwrap_or_else(|| {
469            dirs::home_dir()
470                .unwrap_or_else(|| PathBuf::from("."))
471                .join(".claude")
472                .join("skills")
473                .join(&params.server_id)
474                .join("SKILL.md")
475        });
476
477        // Check if file exists
478        let overwritten = output_path.exists();
479        if overwritten && !params.overwrite {
480            return Err(McpError::invalid_params(
481                format!(
482                    "Skill file already exists: {}. Use overwrite=true to replace.",
483                    output_path.display()
484                ),
485                None,
486            ));
487        }
488
489        // Create parent directory
490        if let Some(parent) = output_path.parent() {
491            tokio::fs::create_dir_all(parent).await.map_err(|e| {
492                McpError::internal_error(format!("Failed to create directory: {e}"), None)
493            })?;
494        }
495
496        // Write file
497        tokio::fs::write(&output_path, &params.content)
498            .await
499            .map_err(|e| McpError::internal_error(format!("Failed to write file: {e}"), None))?;
500
501        let result = SaveSkillResult {
502            success: true,
503            output_path: output_path.display().to_string(),
504            overwritten,
505            metadata,
506        };
507
508        Ok(CallToolResult::success(vec![Content::text(
509            serde_json::to_string_pretty(&result).map_err(|e| {
510                McpError::internal_error(format!("Failed to serialize result: {e}"), None)
511            })?,
512        )]))
513    }
514}
515
516#[tool_handler]
517impl ServerHandler for GeneratorService {
518    fn get_info(&self) -> ServerInfo {
519        let mut info = ServerInfo::default();
520        info.protocol_version = ProtocolVersion::V_2025_06_18;
521        info.capabilities = ServerCapabilities::builder().enable_tools().build();
522        info.server_info = Implementation::from_build_env();
523        info.instructions = Some(
524            "Generate progressive loading TypeScript files for MCP servers. \
525             Use introspect_server to discover tools, then save_categorized_tools \
526             with your categorization."
527                .to_string(),
528        );
529        info
530    }
531}
532
533// ============================================================================
534// Helper functions
535// ============================================================================
536
537/// Extracts parameter names from a JSON Schema.
538fn extract_parameter_names(schema: &serde_json::Value) -> Vec<String> {
539    schema
540        .get("properties")
541        .and_then(|p| p.as_object())
542        .map(|props| props.keys().cloned().collect())
543        .unwrap_or_default()
544}
545
546/// Generates code with categorization metadata.
547///
548/// Converts the categorization map to the format expected by the generator
549/// and calls `generate_with_categories`.
550fn generate_with_categorization(
551    generator: &ProgressiveGenerator,
552    server_info: &mcp_execution_introspector::ServerInfo,
553    categorization: &HashMap<String, &CategorizedTool>,
554) -> mcp_execution_core::Result<mcp_execution_codegen::GeneratedCode> {
555    use mcp_execution_codegen::progressive::ToolCategorization;
556
557    // Convert CategorizedTool map to ToolCategorization map
558    let categorizations: HashMap<String, ToolCategorization> = categorization
559        .iter()
560        .map(|(tool_name, cat_tool)| {
561            (
562                tool_name.clone(),
563                ToolCategorization {
564                    category: cat_tool.category.clone(),
565                    keywords: cat_tool.keywords.clone(),
566                    short_description: cat_tool.short_description.clone(),
567                },
568            )
569        })
570        .collect();
571
572    generator.generate_with_categories(server_info, &categorizations)
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use chrono::Utc;
579    use mcp_execution_core::ToolName;
580    use mcp_execution_introspector::{ServerCapabilities, ToolInfo};
581    use rmcp::model::ErrorCode;
582    use uuid::Uuid;
583
584    // ========================================================================
585    // Helper Functions Tests
586    // ========================================================================
587
588    #[test]
589    fn test_extract_parameter_names() {
590        let schema = serde_json::json!({
591            "type": "object",
592            "properties": {
593                "name": { "type": "string" },
594                "age": { "type": "number" }
595            }
596        });
597
598        let params = extract_parameter_names(&schema);
599        assert_eq!(params.len(), 2);
600        assert!(params.contains(&"name".to_string()));
601        assert!(params.contains(&"age".to_string()));
602    }
603
604    #[test]
605    fn test_extract_parameter_names_empty() {
606        let schema = serde_json::json!({
607            "type": "object"
608        });
609
610        let params = extract_parameter_names(&schema);
611        assert_eq!(params.len(), 0);
612    }
613
614    #[test]
615    fn test_extract_parameter_names_no_properties() {
616        let schema = serde_json::json!({
617            "type": "string"
618        });
619
620        let params = extract_parameter_names(&schema);
621        assert_eq!(params.len(), 0);
622    }
623
624    #[test]
625    fn test_extract_parameter_names_nested_object() {
626        let schema = serde_json::json!({
627            "type": "object",
628            "properties": {
629                "user": {
630                    "type": "object",
631                    "properties": {
632                        "name": { "type": "string" }
633                    }
634                },
635                "age": { "type": "number" }
636            }
637        });
638
639        let params = extract_parameter_names(&schema);
640        assert_eq!(params.len(), 2);
641        assert!(params.contains(&"user".to_string()));
642        assert!(params.contains(&"age".to_string()));
643    }
644
645    #[test]
646    fn test_generate_with_categorization() {
647        let generator = ProgressiveGenerator::new().unwrap();
648
649        let server_info = mcp_execution_introspector::ServerInfo {
650            id: ServerId::new("test"),
651            name: "Test Server".to_string(),
652            version: "1.0.0".to_string(),
653            capabilities: ServerCapabilities {
654                supports_tools: true,
655                supports_resources: false,
656                supports_prompts: false,
657            },
658            tools: vec![ToolInfo {
659                name: ToolName::new("test_tool"),
660                description: "Test tool description".to_string(),
661                input_schema: serde_json::json!({
662                    "type": "object",
663                    "properties": {
664                        "param1": { "type": "string" }
665                    }
666                }),
667                output_schema: None,
668            }],
669        };
670
671        let categorized_tool = CategorizedTool {
672            name: "test_tool".to_string(),
673            category: "testing".to_string(),
674            keywords: "test,tool".to_string(),
675            short_description: "Test tool for testing".to_string(),
676        };
677
678        let mut categorization = HashMap::new();
679        categorization.insert("test_tool".to_string(), &categorized_tool);
680
681        let result = generate_with_categorization(&generator, &server_info, &categorization);
682        assert!(result.is_ok());
683
684        let code = result.unwrap();
685        assert!(code.file_count() > 0, "Should generate at least one file");
686    }
687
688    #[test]
689    fn test_generate_with_categorization_multiple_tools() {
690        let generator = ProgressiveGenerator::new().unwrap();
691
692        let server_info = mcp_execution_introspector::ServerInfo {
693            id: ServerId::new("test"),
694            name: "Test Server".to_string(),
695            version: "1.0.0".to_string(),
696            capabilities: ServerCapabilities {
697                supports_tools: true,
698                supports_resources: false,
699                supports_prompts: false,
700            },
701            tools: vec![
702                ToolInfo {
703                    name: ToolName::new("tool1"),
704                    description: "First tool".to_string(),
705                    input_schema: serde_json::json!({"type": "object"}),
706                    output_schema: None,
707                },
708                ToolInfo {
709                    name: ToolName::new("tool2"),
710                    description: "Second tool".to_string(),
711                    input_schema: serde_json::json!({"type": "object"}),
712                    output_schema: None,
713                },
714            ],
715        };
716
717        let tool1 = CategorizedTool {
718            name: "tool1".to_string(),
719            category: "category1".to_string(),
720            keywords: "test".to_string(),
721            short_description: "Tool 1".to_string(),
722        };
723
724        let tool2 = CategorizedTool {
725            name: "tool2".to_string(),
726            category: "category2".to_string(),
727            keywords: "test".to_string(),
728            short_description: "Tool 2".to_string(),
729        };
730
731        let mut categorization = HashMap::new();
732        categorization.insert("tool1".to_string(), &tool1);
733        categorization.insert("tool2".to_string(), &tool2);
734
735        let result = generate_with_categorization(&generator, &server_info, &categorization);
736        assert!(result.is_ok());
737    }
738
739    #[test]
740    fn test_generate_with_categorization_empty_tools() {
741        let generator = ProgressiveGenerator::new().unwrap();
742
743        let server_id = ServerId::new("test");
744        let server_info = mcp_execution_introspector::ServerInfo {
745            id: server_id,
746            name: "Empty Server".to_string(),
747            version: "1.0.0".to_string(),
748            capabilities: ServerCapabilities {
749                supports_tools: true,
750                supports_resources: false,
751                supports_prompts: false,
752            },
753            tools: vec![],
754        };
755
756        let categorization = HashMap::new();
757
758        let result = generate_with_categorization(&generator, &server_info, &categorization);
759        assert!(result.is_ok());
760    }
761
762    // ========================================================================
763    // Service Tests
764    // ========================================================================
765
766    #[test]
767    fn test_generator_service_new() {
768        let service = GeneratorService::new();
769        assert!(service.introspector.try_lock().is_ok());
770    }
771
772    #[test]
773    fn test_generator_service_default() {
774        let service = GeneratorService::default();
775        assert!(service.introspector.try_lock().is_ok());
776    }
777
778    #[test]
779    fn test_get_info() {
780        let service = GeneratorService::new();
781        let info = service.get_info();
782
783        assert_eq!(info.protocol_version, ProtocolVersion::V_2025_06_18);
784        assert!(info.capabilities.tools.is_some());
785        assert!(info.instructions.is_some());
786    }
787
788    // ========================================================================
789    // Input Validation Tests
790    // ========================================================================
791
792    #[tokio::test]
793    async fn test_introspect_server_invalid_server_id_uppercase() {
794        let service = GeneratorService::new();
795
796        let params = IntrospectServerParams {
797            server_id: "GitHub".to_string(), // Invalid: contains uppercase
798            command: "echo".to_string(),
799            args: vec![],
800            env: HashMap::new(),
801            output_dir: None,
802        };
803
804        let result = service.introspect_server(Parameters(params)).await;
805
806        assert!(result.is_err());
807        let err = result.unwrap_err();
808        assert_eq!(err.code, ErrorCode::INVALID_PARAMS); // Invalid params error code
809    }
810
811    #[tokio::test]
812    async fn test_introspect_server_invalid_server_id_underscore() {
813        let service = GeneratorService::new();
814
815        let params = IntrospectServerParams {
816            server_id: "git_hub".to_string(), // Invalid: contains underscore
817            command: "echo".to_string(),
818            args: vec![],
819            env: HashMap::new(),
820            output_dir: None,
821        };
822
823        let result = service.introspect_server(Parameters(params)).await;
824
825        assert!(result.is_err());
826        let err = result.unwrap_err();
827        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
828    }
829
830    #[tokio::test]
831    async fn test_introspect_server_invalid_server_id_special_chars() {
832        let service = GeneratorService::new();
833
834        let params = IntrospectServerParams {
835            server_id: "git@hub".to_string(), // Invalid: contains @
836            command: "echo".to_string(),
837            args: vec![],
838            env: HashMap::new(),
839            output_dir: None,
840        };
841
842        let result = service.introspect_server(Parameters(params)).await;
843
844        assert!(result.is_err());
845    }
846
847    #[tokio::test]
848    async fn test_introspect_server_valid_server_id_with_hyphens() {
849        let service = GeneratorService::new();
850
851        let params = IntrospectServerParams {
852            server_id: "git-hub-server".to_string(), // Valid
853            command: "echo".to_string(),
854            args: vec!["test".to_string()],
855            env: HashMap::new(),
856            output_dir: None,
857        };
858
859        // This will fail because echo is not an MCP server, but validation should pass
860        let result = service.introspect_server(Parameters(params)).await;
861
862        // Should fail with internal error (connection), not invalid params
863        if let Err(err) = result {
864            assert_ne!(
865                err.code,
866                ErrorCode::INVALID_PARAMS,
867                "Should not be invalid params error"
868            );
869        }
870    }
871
872    #[tokio::test]
873    async fn test_introspect_server_valid_server_id_digits() {
874        let service = GeneratorService::new();
875
876        let params = IntrospectServerParams {
877            server_id: "server123".to_string(), // Valid: lowercase + digits
878            command: "echo".to_string(),
879            args: vec![],
880            env: HashMap::new(),
881            output_dir: None,
882        };
883
884        let result = service.introspect_server(Parameters(params)).await;
885
886        // Should fail with internal error (connection), not invalid params
887        if let Err(err) = result {
888            assert_ne!(err.code, ErrorCode::INVALID_PARAMS);
889        }
890    }
891
892    // ========================================================================
893    // save_categorized_tools Error Tests
894    // ========================================================================
895
896    #[tokio::test]
897    async fn test_save_categorized_tools_invalid_session() {
898        let service = GeneratorService::new();
899
900        let params = SaveCategorizedToolsParams {
901            session_id: Uuid::new_v4(), // Random UUID not in state
902            categorized_tools: vec![],
903        };
904
905        let result = service.save_categorized_tools(Parameters(params)).await;
906
907        assert!(result.is_err());
908        let err = result.unwrap_err();
909        assert_eq!(err.code, ErrorCode::INVALID_PARAMS); // Invalid params
910        assert!(err.message.contains("Session not found"));
911    }
912
913    #[tokio::test]
914    async fn test_save_categorized_tools_tool_mismatch() {
915        let service = GeneratorService::new();
916
917        // Create a pending generation with tool1
918        let server_id = ServerId::new("test");
919        let server_info = mcp_execution_introspector::ServerInfo {
920            id: server_id.clone(),
921            name: "Test".to_string(),
922            version: "1.0.0".to_string(),
923            capabilities: ServerCapabilities {
924                supports_tools: true,
925                supports_resources: false,
926                supports_prompts: false,
927            },
928            tools: vec![ToolInfo {
929                name: ToolName::new("tool1"),
930                description: "Tool 1".to_string(),
931                input_schema: serde_json::json!({"type": "object"}),
932                output_schema: None,
933            }],
934        };
935
936        let pending = PendingGeneration::new(
937            server_id,
938            server_info,
939            ServerConfig::builder().command("echo".to_string()).build(),
940            PathBuf::from("/tmp/test"),
941        );
942
943        let session_id = service.state.store(pending).await;
944
945        // Try to save with tool2 (doesn't exist)
946        let params = SaveCategorizedToolsParams {
947            session_id,
948            categorized_tools: vec![CategorizedTool {
949                name: "tool2".to_string(), // Mismatch!
950                category: "test".to_string(),
951                keywords: "test".to_string(),
952                short_description: "Test".to_string(),
953            }],
954        };
955
956        let result = service.save_categorized_tools(Parameters(params)).await;
957
958        assert!(result.is_err());
959        let err = result.unwrap_err();
960        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
961        assert!(err.message.contains("not found in introspected tools"));
962    }
963
964    #[tokio::test]
965    async fn test_save_categorized_tools_expired_session() {
966        use chrono::Duration;
967
968        let service = GeneratorService::new();
969
970        // Create an expired pending generation
971        let server_id = ServerId::new("test");
972        let server_info = mcp_execution_introspector::ServerInfo {
973            id: server_id.clone(),
974            name: "Test".to_string(),
975            version: "1.0.0".to_string(),
976            capabilities: ServerCapabilities {
977                supports_tools: true,
978                supports_resources: false,
979                supports_prompts: false,
980            },
981            tools: vec![],
982        };
983
984        let mut pending = PendingGeneration::new(
985            server_id,
986            server_info,
987            ServerConfig::builder().command("echo".to_string()).build(),
988            PathBuf::from("/tmp/test"),
989        );
990
991        // Manually expire it
992        pending.expires_at = Utc::now() - Duration::hours(1);
993
994        let session_id = service.state.store(pending).await;
995
996        let params = SaveCategorizedToolsParams {
997            session_id,
998            categorized_tools: vec![],
999        };
1000
1001        let result = service.save_categorized_tools(Parameters(params)).await;
1002
1003        assert!(result.is_err());
1004        let err = result.unwrap_err();
1005        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1006    }
1007
1008    // ========================================================================
1009    // list_generated_servers Tests
1010    // ========================================================================
1011
1012    #[tokio::test]
1013    async fn test_list_generated_servers_nonexistent_dir() {
1014        let service = GeneratorService::new();
1015
1016        let params = ListGeneratedServersParams {
1017            base_dir: Some("/nonexistent/path/that/does/not/exist".to_string()),
1018        };
1019
1020        let result = service.list_generated_servers(Parameters(params)).await;
1021
1022        assert!(result.is_ok());
1023        let content = result.unwrap();
1024        let text_content = content.content[0].as_text().unwrap();
1025        let parsed: ListGeneratedServersResult = serde_json::from_str(&text_content.text).unwrap();
1026
1027        assert_eq!(parsed.total_servers, 0);
1028        assert_eq!(parsed.servers.len(), 0);
1029    }
1030
1031    #[tokio::test]
1032    async fn test_list_generated_servers_default_dir() {
1033        let service = GeneratorService::new();
1034
1035        let params = ListGeneratedServersParams { base_dir: None };
1036
1037        let result = service.list_generated_servers(Parameters(params)).await;
1038
1039        // Should succeed even if directory doesn't exist
1040        assert!(result.is_ok());
1041    }
1042
1043    // ========================================================================
1044    // generate_skill Error Tests
1045    // ========================================================================
1046
1047    #[tokio::test]
1048    async fn test_generate_skill_invalid_server_id_uppercase() {
1049        let service = GeneratorService::new();
1050
1051        let params = GenerateSkillParams {
1052            server_id: "GitHub".to_string(), // Invalid: uppercase
1053            skill_name: None,
1054            use_case_hints: None,
1055            servers_dir: None,
1056        };
1057
1058        let result = service.generate_skill(Parameters(params)).await;
1059
1060        assert!(result.is_err());
1061        let err = result.unwrap_err();
1062        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1063        assert!(err.message.contains("lowercase"));
1064    }
1065
1066    #[tokio::test]
1067    async fn test_generate_skill_invalid_server_id_special_chars() {
1068        let service = GeneratorService::new();
1069
1070        let params = GenerateSkillParams {
1071            server_id: "git@hub".to_string(), // Invalid: special chars
1072            skill_name: None,
1073            use_case_hints: None,
1074            servers_dir: None,
1075        };
1076
1077        let result = service.generate_skill(Parameters(params)).await;
1078
1079        assert!(result.is_err());
1080        let err = result.unwrap_err();
1081        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1082    }
1083
1084    #[tokio::test]
1085    async fn test_generate_skill_server_directory_not_found() {
1086        let service = GeneratorService::new();
1087
1088        let params = GenerateSkillParams {
1089            server_id: "nonexistent-server".to_string(),
1090            skill_name: None,
1091            use_case_hints: None,
1092            servers_dir: Some(PathBuf::from("/nonexistent/path")),
1093        };
1094
1095        let result = service.generate_skill(Parameters(params)).await;
1096
1097        assert!(result.is_err());
1098        let err = result.unwrap_err();
1099        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1100        assert!(err.message.contains("not found"));
1101    }
1102
1103    #[tokio::test]
1104    async fn test_generate_skill_empty_directory() {
1105        use tempfile::TempDir;
1106
1107        let service = GeneratorService::new();
1108        let temp_dir = TempDir::new().unwrap();
1109        let base_dir = temp_dir.path().to_path_buf();
1110
1111        // Create server directory but no tool files
1112        let target_dir = base_dir.join("test-server");
1113        tokio::fs::create_dir_all(&target_dir).await.unwrap();
1114
1115        let params = GenerateSkillParams {
1116            server_id: "test-server".to_string(),
1117            skill_name: None,
1118            use_case_hints: None,
1119            servers_dir: Some(base_dir),
1120        };
1121
1122        let result = service.generate_skill(Parameters(params)).await;
1123
1124        assert!(result.is_err());
1125        let err = result.unwrap_err();
1126        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1127        assert!(err.message.contains("No tool files found"));
1128    }
1129
1130    // ========================================================================
1131    // save_skill Error Tests
1132    // ========================================================================
1133
1134    #[tokio::test]
1135    async fn test_save_skill_invalid_server_id() {
1136        let service = GeneratorService::new();
1137
1138        let params = SaveSkillParams {
1139            server_id: "Invalid_Server".to_string(), // Invalid: uppercase and underscore
1140            content: "---\nname: test\ndescription: test\n---\n# Test".to_string(),
1141            output_path: None,
1142            overwrite: false,
1143        };
1144
1145        let result = service.save_skill(Parameters(params)).await;
1146
1147        assert!(result.is_err());
1148        let err = result.unwrap_err();
1149        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1150        assert!(err.message.contains("lowercase"));
1151    }
1152
1153    #[tokio::test]
1154    async fn test_save_skill_missing_yaml_frontmatter() {
1155        let service = GeneratorService::new();
1156
1157        let params = SaveSkillParams {
1158            server_id: "test".to_string(),
1159            content: "# Test Skill\n\nNo YAML frontmatter here.".to_string(),
1160            output_path: None,
1161            overwrite: false,
1162        };
1163
1164        let result = service.save_skill(Parameters(params)).await;
1165
1166        assert!(result.is_err());
1167        let err = result.unwrap_err();
1168        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1169        assert!(err.message.contains("YAML frontmatter"));
1170    }
1171
1172    #[tokio::test]
1173    async fn test_save_skill_invalid_frontmatter_no_name() {
1174        let service = GeneratorService::new();
1175
1176        let params = SaveSkillParams {
1177            server_id: "test".to_string(),
1178            content: "---\ndescription: test\n---\n# Test".to_string(),
1179            output_path: None,
1180            overwrite: false,
1181        };
1182
1183        let result = service.save_skill(Parameters(params)).await;
1184
1185        assert!(result.is_err());
1186        let err = result.unwrap_err();
1187        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1188        assert!(err.message.contains("Invalid SKILL.md format"));
1189    }
1190
1191    #[tokio::test]
1192    async fn test_save_skill_invalid_frontmatter_no_description() {
1193        let service = GeneratorService::new();
1194
1195        let params = SaveSkillParams {
1196            server_id: "test".to_string(),
1197            content: "---\nname: test-skill\n---\n# Test".to_string(),
1198            output_path: None,
1199            overwrite: false,
1200        };
1201
1202        let result = service.save_skill(Parameters(params)).await;
1203
1204        assert!(result.is_err());
1205        let err = result.unwrap_err();
1206        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1207        assert!(err.message.contains("Invalid SKILL.md format"));
1208    }
1209
1210    #[tokio::test]
1211    async fn test_save_skill_file_exists_no_overwrite() {
1212        use tempfile::TempDir;
1213
1214        let service = GeneratorService::new();
1215        let temp_dir = TempDir::new().unwrap();
1216        let output_path = temp_dir.path().join("SKILL.md");
1217
1218        // Create existing file
1219        tokio::fs::write(&output_path, "existing content")
1220            .await
1221            .unwrap();
1222
1223        let params = SaveSkillParams {
1224            server_id: "test".to_string(),
1225            content: "---\nname: test\ndescription: test\n---\n# Test".to_string(),
1226            output_path: Some(output_path),
1227            overwrite: false,
1228        };
1229
1230        let result = service.save_skill(Parameters(params)).await;
1231
1232        assert!(result.is_err());
1233        let err = result.unwrap_err();
1234        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1235        assert!(err.message.contains("already exists"));
1236        assert!(err.message.contains("overwrite=true"));
1237    }
1238
1239    #[tokio::test]
1240    async fn test_save_skill_file_exists_with_overwrite() {
1241        use tempfile::TempDir;
1242
1243        let service = GeneratorService::new();
1244        let temp_dir = TempDir::new().unwrap();
1245        let output_path = temp_dir.path().join("SKILL.md");
1246
1247        // Create existing file
1248        tokio::fs::write(&output_path, "existing content")
1249            .await
1250            .unwrap();
1251
1252        let params = SaveSkillParams {
1253            server_id: "test".to_string(),
1254            content: "---\nname: test\ndescription: test skill\n---\n# Test".to_string(),
1255            output_path: Some(output_path.clone()),
1256            overwrite: true,
1257        };
1258
1259        let result = service.save_skill(Parameters(params)).await;
1260
1261        assert!(result.is_ok());
1262        let content = result.unwrap();
1263        let text = content.content[0].as_text().unwrap();
1264        let parsed: SaveSkillResult = serde_json::from_str(&text.text).unwrap();
1265
1266        assert!(parsed.success);
1267        assert!(parsed.overwritten);
1268        assert_eq!(parsed.metadata.name, "test");
1269        assert_eq!(parsed.metadata.description, "test skill");
1270    }
1271
1272    #[tokio::test]
1273    async fn test_save_skill_valid_content() {
1274        use tempfile::TempDir;
1275
1276        let service = GeneratorService::new();
1277        let temp_dir = TempDir::new().unwrap();
1278        let output_path = temp_dir.path().join("SKILL.md");
1279
1280        let params = SaveSkillParams {
1281            server_id: "test".to_string(),
1282            content: "---\nname: test-skill\ndescription: A test skill\n---\n\n# Test Skill\n\n## Section 1\n\nContent here.".to_string(),
1283            output_path: Some(output_path.clone()),
1284            overwrite: false,
1285        };
1286
1287        let result = service.save_skill(Parameters(params)).await;
1288
1289        assert!(result.is_ok());
1290        let content = result.unwrap();
1291        let text = content.content[0].as_text().unwrap();
1292        let parsed: SaveSkillResult = serde_json::from_str(&text.text).unwrap();
1293
1294        assert!(parsed.success);
1295        assert!(!parsed.overwritten);
1296        assert_eq!(parsed.metadata.name, "test-skill");
1297        assert_eq!(parsed.metadata.description, "A test skill");
1298        assert!(parsed.metadata.section_count >= 1);
1299        assert!(parsed.metadata.word_count > 0);
1300
1301        // Verify file was written
1302        assert!(output_path.exists());
1303    }
1304}