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