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::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
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        assert_eq!(info.server_info.name, env!("CARGO_PKG_NAME"));
787        assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
788    }
789
790    // ========================================================================
791    // Input Validation Tests
792    // ========================================================================
793
794    #[tokio::test]
795    async fn test_introspect_server_invalid_server_id_uppercase() {
796        let service = GeneratorService::new();
797
798        let params = IntrospectServerParams {
799            server_id: "GitHub".to_string(), // Invalid: contains uppercase
800            command: "echo".to_string(),
801            args: vec![],
802            env: HashMap::new(),
803            output_dir: None,
804        };
805
806        let result = service.introspect_server(Parameters(params)).await;
807
808        assert!(result.is_err());
809        let err = result.unwrap_err();
810        assert_eq!(err.code, ErrorCode::INVALID_PARAMS); // Invalid params error code
811    }
812
813    #[tokio::test]
814    async fn test_introspect_server_invalid_server_id_underscore() {
815        let service = GeneratorService::new();
816
817        let params = IntrospectServerParams {
818            server_id: "git_hub".to_string(), // Invalid: contains underscore
819            command: "echo".to_string(),
820            args: vec![],
821            env: HashMap::new(),
822            output_dir: None,
823        };
824
825        let result = service.introspect_server(Parameters(params)).await;
826
827        assert!(result.is_err());
828        let err = result.unwrap_err();
829        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
830    }
831
832    #[tokio::test]
833    async fn test_introspect_server_invalid_server_id_special_chars() {
834        let service = GeneratorService::new();
835
836        let params = IntrospectServerParams {
837            server_id: "git@hub".to_string(), // Invalid: contains @
838            command: "echo".to_string(),
839            args: vec![],
840            env: HashMap::new(),
841            output_dir: None,
842        };
843
844        let result = service.introspect_server(Parameters(params)).await;
845
846        assert!(result.is_err());
847    }
848
849    #[tokio::test]
850    async fn test_introspect_server_valid_server_id_with_hyphens() {
851        let service = GeneratorService::new();
852
853        let params = IntrospectServerParams {
854            server_id: "git-hub-server".to_string(), // Valid
855            command: "echo".to_string(),
856            args: vec!["test".to_string()],
857            env: HashMap::new(),
858            output_dir: None,
859        };
860
861        // This will fail because echo is not an MCP server, but validation should pass
862        let result = service.introspect_server(Parameters(params)).await;
863
864        // Should fail with internal error (connection), not invalid params
865        if let Err(err) = result {
866            assert_ne!(
867                err.code,
868                ErrorCode::INVALID_PARAMS,
869                "Should not be invalid params error"
870            );
871        }
872    }
873
874    #[tokio::test]
875    async fn test_introspect_server_valid_server_id_digits() {
876        let service = GeneratorService::new();
877
878        let params = IntrospectServerParams {
879            server_id: "server123".to_string(), // Valid: lowercase + digits
880            command: "echo".to_string(),
881            args: vec![],
882            env: HashMap::new(),
883            output_dir: None,
884        };
885
886        let result = service.introspect_server(Parameters(params)).await;
887
888        // Should fail with internal error (connection), not invalid params
889        if let Err(err) = result {
890            assert_ne!(err.code, ErrorCode::INVALID_PARAMS);
891        }
892    }
893
894    // ========================================================================
895    // save_categorized_tools Error Tests
896    // ========================================================================
897
898    #[tokio::test]
899    async fn test_save_categorized_tools_invalid_session() {
900        let service = GeneratorService::new();
901
902        let params = SaveCategorizedToolsParams {
903            session_id: Uuid::new_v4(), // Random UUID not in state
904            categorized_tools: vec![],
905        };
906
907        let result = service.save_categorized_tools(Parameters(params)).await;
908
909        assert!(result.is_err());
910        let err = result.unwrap_err();
911        assert_eq!(err.code, ErrorCode::INVALID_PARAMS); // Invalid params
912        assert!(err.message.contains("Session not found"));
913    }
914
915    #[tokio::test]
916    async fn test_save_categorized_tools_tool_mismatch() {
917        let service = GeneratorService::new();
918
919        // Create a pending generation with tool1
920        let server_id = ServerId::new("test");
921        let server_info = mcp_execution_introspector::ServerInfo {
922            id: server_id.clone(),
923            name: "Test".to_string(),
924            version: "1.0.0".to_string(),
925            capabilities: ServerCapabilities {
926                supports_tools: true,
927                supports_resources: false,
928                supports_prompts: false,
929            },
930            tools: vec![ToolInfo {
931                name: ToolName::new("tool1"),
932                description: "Tool 1".to_string(),
933                input_schema: serde_json::json!({"type": "object"}),
934                output_schema: None,
935            }],
936        };
937
938        let pending = PendingGeneration::new(
939            server_id,
940            server_info,
941            ServerConfig::builder().command("echo".to_string()).build(),
942            PathBuf::from("/tmp/test"),
943        );
944
945        let session_id = service.state.store(pending).await;
946
947        // Try to save with tool2 (doesn't exist)
948        let params = SaveCategorizedToolsParams {
949            session_id,
950            categorized_tools: vec![CategorizedTool {
951                name: "tool2".to_string(), // Mismatch!
952                category: "test".to_string(),
953                keywords: "test".to_string(),
954                short_description: "Test".to_string(),
955            }],
956        };
957
958        let result = service.save_categorized_tools(Parameters(params)).await;
959
960        assert!(result.is_err());
961        let err = result.unwrap_err();
962        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
963        assert!(err.message.contains("not found in introspected tools"));
964    }
965
966    #[tokio::test]
967    async fn test_save_categorized_tools_expired_session() {
968        use chrono::Duration;
969
970        let service = GeneratorService::new();
971
972        // Create an expired pending generation
973        let server_id = ServerId::new("test");
974        let server_info = mcp_execution_introspector::ServerInfo {
975            id: server_id.clone(),
976            name: "Test".to_string(),
977            version: "1.0.0".to_string(),
978            capabilities: ServerCapabilities {
979                supports_tools: true,
980                supports_resources: false,
981                supports_prompts: false,
982            },
983            tools: vec![],
984        };
985
986        let mut pending = PendingGeneration::new(
987            server_id,
988            server_info,
989            ServerConfig::builder().command("echo".to_string()).build(),
990            PathBuf::from("/tmp/test"),
991        );
992
993        // Manually expire it
994        pending.expires_at = Utc::now() - Duration::hours(1);
995
996        let session_id = service.state.store(pending).await;
997
998        let params = SaveCategorizedToolsParams {
999            session_id,
1000            categorized_tools: vec![],
1001        };
1002
1003        let result = service.save_categorized_tools(Parameters(params)).await;
1004
1005        assert!(result.is_err());
1006        let err = result.unwrap_err();
1007        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1008    }
1009
1010    // ========================================================================
1011    // list_generated_servers Tests
1012    // ========================================================================
1013
1014    #[tokio::test]
1015    async fn test_list_generated_servers_nonexistent_dir() {
1016        let service = GeneratorService::new();
1017
1018        let params = ListGeneratedServersParams {
1019            base_dir: Some("/nonexistent/path/that/does/not/exist".to_string()),
1020        };
1021
1022        let result = service.list_generated_servers(Parameters(params)).await;
1023
1024        assert!(result.is_ok());
1025        let content = result.unwrap();
1026        let text_content = content.content[0].as_text().unwrap();
1027        let parsed: ListGeneratedServersResult = serde_json::from_str(&text_content.text).unwrap();
1028
1029        assert_eq!(parsed.total_servers, 0);
1030        assert_eq!(parsed.servers.len(), 0);
1031    }
1032
1033    #[tokio::test]
1034    async fn test_list_generated_servers_default_dir() {
1035        let service = GeneratorService::new();
1036
1037        let params = ListGeneratedServersParams { base_dir: None };
1038
1039        let result = service.list_generated_servers(Parameters(params)).await;
1040
1041        // Should succeed even if directory doesn't exist
1042        assert!(result.is_ok());
1043    }
1044
1045    // ========================================================================
1046    // generate_skill Error Tests
1047    // ========================================================================
1048
1049    #[tokio::test]
1050    async fn test_generate_skill_invalid_server_id_uppercase() {
1051        let service = GeneratorService::new();
1052
1053        let params = GenerateSkillParams {
1054            server_id: "GitHub".to_string(), // Invalid: uppercase
1055            skill_name: None,
1056            use_case_hints: None,
1057            servers_dir: None,
1058        };
1059
1060        let result = service.generate_skill(Parameters(params)).await;
1061
1062        assert!(result.is_err());
1063        let err = result.unwrap_err();
1064        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1065        assert!(err.message.contains("lowercase"));
1066    }
1067
1068    #[tokio::test]
1069    async fn test_generate_skill_invalid_server_id_special_chars() {
1070        let service = GeneratorService::new();
1071
1072        let params = GenerateSkillParams {
1073            server_id: "git@hub".to_string(), // Invalid: special chars
1074            skill_name: None,
1075            use_case_hints: None,
1076            servers_dir: None,
1077        };
1078
1079        let result = service.generate_skill(Parameters(params)).await;
1080
1081        assert!(result.is_err());
1082        let err = result.unwrap_err();
1083        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1084    }
1085
1086    #[tokio::test]
1087    async fn test_generate_skill_server_directory_not_found() {
1088        let service = GeneratorService::new();
1089
1090        let params = GenerateSkillParams {
1091            server_id: "nonexistent-server".to_string(),
1092            skill_name: None,
1093            use_case_hints: None,
1094            servers_dir: Some(PathBuf::from("/nonexistent/path")),
1095        };
1096
1097        let result = service.generate_skill(Parameters(params)).await;
1098
1099        assert!(result.is_err());
1100        let err = result.unwrap_err();
1101        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1102        assert!(err.message.contains("not found"));
1103    }
1104
1105    #[tokio::test]
1106    async fn test_generate_skill_empty_directory() {
1107        use tempfile::TempDir;
1108
1109        let service = GeneratorService::new();
1110        let temp_dir = TempDir::new().unwrap();
1111        let base_dir = temp_dir.path().to_path_buf();
1112
1113        // Create server directory but no tool files
1114        let target_dir = base_dir.join("test-server");
1115        tokio::fs::create_dir_all(&target_dir).await.unwrap();
1116
1117        let params = GenerateSkillParams {
1118            server_id: "test-server".to_string(),
1119            skill_name: None,
1120            use_case_hints: None,
1121            servers_dir: Some(base_dir),
1122        };
1123
1124        let result = service.generate_skill(Parameters(params)).await;
1125
1126        assert!(result.is_err());
1127        let err = result.unwrap_err();
1128        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1129        assert!(err.message.contains("No tool files found"));
1130    }
1131
1132    // ========================================================================
1133    // save_skill Error Tests
1134    // ========================================================================
1135
1136    #[tokio::test]
1137    async fn test_save_skill_invalid_server_id() {
1138        let service = GeneratorService::new();
1139
1140        let params = SaveSkillParams {
1141            server_id: "Invalid_Server".to_string(), // Invalid: uppercase and underscore
1142            content: "---\nname: test\ndescription: test\n---\n# Test".to_string(),
1143            output_path: None,
1144            overwrite: false,
1145        };
1146
1147        let result = service.save_skill(Parameters(params)).await;
1148
1149        assert!(result.is_err());
1150        let err = result.unwrap_err();
1151        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1152        assert!(err.message.contains("lowercase"));
1153    }
1154
1155    #[tokio::test]
1156    async fn test_save_skill_missing_yaml_frontmatter() {
1157        let service = GeneratorService::new();
1158
1159        let params = SaveSkillParams {
1160            server_id: "test".to_string(),
1161            content: "# Test Skill\n\nNo YAML frontmatter here.".to_string(),
1162            output_path: None,
1163            overwrite: false,
1164        };
1165
1166        let result = service.save_skill(Parameters(params)).await;
1167
1168        assert!(result.is_err());
1169        let err = result.unwrap_err();
1170        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1171        assert!(err.message.contains("YAML frontmatter"));
1172    }
1173
1174    #[tokio::test]
1175    async fn test_save_skill_invalid_frontmatter_no_name() {
1176        let service = GeneratorService::new();
1177
1178        let params = SaveSkillParams {
1179            server_id: "test".to_string(),
1180            content: "---\ndescription: test\n---\n# Test".to_string(),
1181            output_path: None,
1182            overwrite: false,
1183        };
1184
1185        let result = service.save_skill(Parameters(params)).await;
1186
1187        assert!(result.is_err());
1188        let err = result.unwrap_err();
1189        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1190        assert!(err.message.contains("Invalid SKILL.md format"));
1191    }
1192
1193    #[tokio::test]
1194    async fn test_save_skill_invalid_frontmatter_no_description() {
1195        let service = GeneratorService::new();
1196
1197        let params = SaveSkillParams {
1198            server_id: "test".to_string(),
1199            content: "---\nname: test-skill\n---\n# Test".to_string(),
1200            output_path: None,
1201            overwrite: false,
1202        };
1203
1204        let result = service.save_skill(Parameters(params)).await;
1205
1206        assert!(result.is_err());
1207        let err = result.unwrap_err();
1208        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1209        assert!(err.message.contains("Invalid SKILL.md format"));
1210    }
1211
1212    #[tokio::test]
1213    async fn test_save_skill_file_exists_no_overwrite() {
1214        use tempfile::TempDir;
1215
1216        let service = GeneratorService::new();
1217        let temp_dir = TempDir::new().unwrap();
1218        let output_path = temp_dir.path().join("SKILL.md");
1219
1220        // Create existing file
1221        tokio::fs::write(&output_path, "existing content")
1222            .await
1223            .unwrap();
1224
1225        let params = SaveSkillParams {
1226            server_id: "test".to_string(),
1227            content: "---\nname: test\ndescription: test\n---\n# Test".to_string(),
1228            output_path: Some(output_path),
1229            overwrite: false,
1230        };
1231
1232        let result = service.save_skill(Parameters(params)).await;
1233
1234        assert!(result.is_err());
1235        let err = result.unwrap_err();
1236        assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1237        assert!(err.message.contains("already exists"));
1238        assert!(err.message.contains("overwrite=true"));
1239    }
1240
1241    #[tokio::test]
1242    async fn test_save_skill_file_exists_with_overwrite() {
1243        use tempfile::TempDir;
1244
1245        let service = GeneratorService::new();
1246        let temp_dir = TempDir::new().unwrap();
1247        let output_path = temp_dir.path().join("SKILL.md");
1248
1249        // Create existing file
1250        tokio::fs::write(&output_path, "existing content")
1251            .await
1252            .unwrap();
1253
1254        let params = SaveSkillParams {
1255            server_id: "test".to_string(),
1256            content: "---\nname: test\ndescription: test skill\n---\n# Test".to_string(),
1257            output_path: Some(output_path.clone()),
1258            overwrite: true,
1259        };
1260
1261        let result = service.save_skill(Parameters(params)).await;
1262
1263        assert!(result.is_ok());
1264        let content = result.unwrap();
1265        let text = content.content[0].as_text().unwrap();
1266        let parsed: SaveSkillResult = serde_json::from_str(&text.text).unwrap();
1267
1268        assert!(parsed.success);
1269        assert!(parsed.overwritten);
1270        assert_eq!(parsed.metadata.name, "test");
1271        assert_eq!(parsed.metadata.description, "test skill");
1272    }
1273
1274    #[tokio::test]
1275    async fn test_save_skill_valid_content() {
1276        use tempfile::TempDir;
1277
1278        let service = GeneratorService::new();
1279        let temp_dir = TempDir::new().unwrap();
1280        let output_path = temp_dir.path().join("SKILL.md");
1281
1282        let params = SaveSkillParams {
1283            server_id: "test".to_string(),
1284            content: "---\nname: test-skill\ndescription: A test skill\n---\n\n# Test Skill\n\n## Section 1\n\nContent here.".to_string(),
1285            output_path: Some(output_path.clone()),
1286            overwrite: false,
1287        };
1288
1289        let result = service.save_skill(Parameters(params)).await;
1290
1291        assert!(result.is_ok());
1292        let content = result.unwrap();
1293        let text = content.content[0].as_text().unwrap();
1294        let parsed: SaveSkillResult = serde_json::from_str(&text.text).unwrap();
1295
1296        assert!(parsed.success);
1297        assert!(!parsed.overwritten);
1298        assert_eq!(parsed.metadata.name, "test-skill");
1299        assert_eq!(parsed.metadata.description, "A test skill");
1300        assert!(parsed.metadata.section_count >= 1);
1301        assert!(parsed.metadata.word_count > 0);
1302
1303        // Verify file was written
1304        assert!(output_path.exists());
1305    }
1306}