1use 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
34const MAX_SKILL_CONTENT_SIZE: usize = 100 * 1024;
36
37#[derive(Debug, Clone)]
62pub struct GeneratorService {
63 state: Arc<StateManager>,
65
66 introspector: Arc<Mutex<Introspector>>,
68
69 tool_router: ToolRouter<Self>,
71}
72
73impl GeneratorService {
74 #[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 #[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(¶ms.server_id).map_err(|e| McpError::invalid_params(e, None))?;
107
108 let server_id_str = params.server_id;
110 let server_id = ServerId::new(&server_id_str);
111
112 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 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 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 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 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 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 #[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 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 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 ¶ms.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 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 ¶ms.categorized_tools {
227 categorization.insert(tool.name.clone(), tool);
228 *categories.entry(tool.category.clone()).or_default() += 1;
229 }
230
231 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 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 let files_generated = vfs.file_count();
246
247 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 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 #[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 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 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 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 #[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(¶ms.server_id).map_err(|e| McpError::invalid_params(e, None))?;
377
378 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(¶ms.server_id);
387
388 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 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 let mut result =
416 build_skill_context(¶ms.server_id, &tools, params.use_case_hints.as_deref());
417
418 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 #[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(¶ms.server_id).map_err(|e| McpError::invalid_params(e, None))?;
443
444 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 if !params.content.starts_with("---") {
458 return Err(McpError::invalid_params(
459 "Content must start with YAML frontmatter (---)",
460 None,
461 ));
462 }
463
464 let metadata = extract_skill_metadata(¶ms.content)
466 .map_err(|e| McpError::invalid_params(format!("Invalid SKILL.md format: {e}"), None))?;
467
468 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(¶ms.server_id)
475 .join("SKILL.md")
476 });
477
478 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 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 tokio::fs::write(&output_path, ¶ms.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
534fn 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
547fn 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 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 #[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 #[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 #[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(), 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); }
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(), 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(), 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(), command: "echo".to_string(),
855 args: vec!["test".to_string()],
856 env: HashMap::new(),
857 output_dir: None,
858 };
859
860 let result = service.introspect_server(Parameters(params)).await;
862
863 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(), 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 if let Err(err) = result {
889 assert_ne!(err.code, ErrorCode::INVALID_PARAMS);
890 }
891 }
892
893 #[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(), 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); 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 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 let params = SaveCategorizedToolsParams {
948 session_id,
949 categorized_tools: vec![CategorizedTool {
950 name: "tool2".to_string(), 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 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 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 #[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 assert!(result.is_ok());
1042 }
1043
1044 #[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(), 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(), 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 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 #[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(), 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 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 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 assert!(output_path.exists());
1304 }
1305}