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 #[allow(dead_code)]
71 tool_router: ToolRouter<Self>,
72}
73
74impl GeneratorService {
75 #[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 #[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(¶ms.server_id).map_err(|e| McpError::invalid_params(e, None))?;
108
109 let server_id_str = params.server_id;
111 let server_id = ServerId::new(&server_id_str);
112
113 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 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 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 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 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 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 #[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 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 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 ¶ms.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 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 ¶ms.categorized_tools {
228 categorization.insert(tool.name.clone(), tool);
229 *categories.entry(tool.category.clone()).or_default() += 1;
230 }
231
232 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 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 let files_generated = vfs.file_count();
247
248 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 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 #[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 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 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 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 #[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(¶ms.server_id).map_err(|e| McpError::invalid_params(e, None))?;
376
377 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(¶ms.server_id);
386
387 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 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 let mut result =
415 build_skill_context(¶ms.server_id, &tools, params.use_case_hints.as_deref());
416
417 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 #[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(¶ms.server_id).map_err(|e| McpError::invalid_params(e, None))?;
442
443 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 if !params.content.starts_with("---") {
457 return Err(McpError::invalid_params(
458 "Content must start with YAML frontmatter (---)",
459 None,
460 ));
461 }
462
463 let metadata = extract_skill_metadata(¶ms.content)
465 .map_err(|e| McpError::invalid_params(format!("Invalid SKILL.md format: {e}"), None))?;
466
467 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(¶ms.server_id)
474 .join("SKILL.md")
475 });
476
477 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 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 tokio::fs::write(&output_path, ¶ms.content)
498 .await
499 .map_err(|e| McpError::internal_error(format!("Failed to write file: {e}"), None))?;
500
501 let result = SaveSkillResult {
502 success: true,
503 output_path: output_path.display().to_string(),
504 overwritten,
505 metadata,
506 };
507
508 Ok(CallToolResult::success(vec![Content::text(
509 serde_json::to_string_pretty(&result).map_err(|e| {
510 McpError::internal_error(format!("Failed to serialize result: {e}"), None)
511 })?,
512 )]))
513 }
514}
515
516#[tool_handler]
517impl ServerHandler for GeneratorService {
518 fn get_info(&self) -> ServerInfo {
519 let mut info = ServerInfo::default();
520 info.protocol_version = ProtocolVersion::V_2025_06_18;
521 info.capabilities = ServerCapabilities::builder().enable_tools().build();
522 info.server_info = Implementation::from_build_env();
523 info.instructions = Some(
524 "Generate progressive loading TypeScript files for MCP servers. \
525 Use introspect_server to discover tools, then save_categorized_tools \
526 with your categorization."
527 .to_string(),
528 );
529 info
530 }
531}
532
533fn 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
546fn 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 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 #[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 #[test]
767 fn test_generator_service_new() {
768 let service = GeneratorService::new();
769 assert!(service.introspector.try_lock().is_ok());
770 }
771
772 #[test]
773 fn test_generator_service_default() {
774 let service = GeneratorService::default();
775 assert!(service.introspector.try_lock().is_ok());
776 }
777
778 #[test]
779 fn test_get_info() {
780 let service = GeneratorService::new();
781 let info = service.get_info();
782
783 assert_eq!(info.protocol_version, ProtocolVersion::V_2025_06_18);
784 assert!(info.capabilities.tools.is_some());
785 assert!(info.instructions.is_some());
786 }
787
788 #[tokio::test]
793 async fn test_introspect_server_invalid_server_id_uppercase() {
794 let service = GeneratorService::new();
795
796 let params = IntrospectServerParams {
797 server_id: "GitHub".to_string(), command: "echo".to_string(),
799 args: vec![],
800 env: HashMap::new(),
801 output_dir: None,
802 };
803
804 let result = service.introspect_server(Parameters(params)).await;
805
806 assert!(result.is_err());
807 let err = result.unwrap_err();
808 assert_eq!(err.code, ErrorCode::INVALID_PARAMS); }
810
811 #[tokio::test]
812 async fn test_introspect_server_invalid_server_id_underscore() {
813 let service = GeneratorService::new();
814
815 let params = IntrospectServerParams {
816 server_id: "git_hub".to_string(), command: "echo".to_string(),
818 args: vec![],
819 env: HashMap::new(),
820 output_dir: None,
821 };
822
823 let result = service.introspect_server(Parameters(params)).await;
824
825 assert!(result.is_err());
826 let err = result.unwrap_err();
827 assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
828 }
829
830 #[tokio::test]
831 async fn test_introspect_server_invalid_server_id_special_chars() {
832 let service = GeneratorService::new();
833
834 let params = IntrospectServerParams {
835 server_id: "git@hub".to_string(), command: "echo".to_string(),
837 args: vec![],
838 env: HashMap::new(),
839 output_dir: None,
840 };
841
842 let result = service.introspect_server(Parameters(params)).await;
843
844 assert!(result.is_err());
845 }
846
847 #[tokio::test]
848 async fn test_introspect_server_valid_server_id_with_hyphens() {
849 let service = GeneratorService::new();
850
851 let params = IntrospectServerParams {
852 server_id: "git-hub-server".to_string(), command: "echo".to_string(),
854 args: vec!["test".to_string()],
855 env: HashMap::new(),
856 output_dir: None,
857 };
858
859 let result = service.introspect_server(Parameters(params)).await;
861
862 if let Err(err) = result {
864 assert_ne!(
865 err.code,
866 ErrorCode::INVALID_PARAMS,
867 "Should not be invalid params error"
868 );
869 }
870 }
871
872 #[tokio::test]
873 async fn test_introspect_server_valid_server_id_digits() {
874 let service = GeneratorService::new();
875
876 let params = IntrospectServerParams {
877 server_id: "server123".to_string(), command: "echo".to_string(),
879 args: vec![],
880 env: HashMap::new(),
881 output_dir: None,
882 };
883
884 let result = service.introspect_server(Parameters(params)).await;
885
886 if let Err(err) = result {
888 assert_ne!(err.code, ErrorCode::INVALID_PARAMS);
889 }
890 }
891
892 #[tokio::test]
897 async fn test_save_categorized_tools_invalid_session() {
898 let service = GeneratorService::new();
899
900 let params = SaveCategorizedToolsParams {
901 session_id: Uuid::new_v4(), categorized_tools: vec![],
903 };
904
905 let result = service.save_categorized_tools(Parameters(params)).await;
906
907 assert!(result.is_err());
908 let err = result.unwrap_err();
909 assert_eq!(err.code, ErrorCode::INVALID_PARAMS); assert!(err.message.contains("Session not found"));
911 }
912
913 #[tokio::test]
914 async fn test_save_categorized_tools_tool_mismatch() {
915 let service = GeneratorService::new();
916
917 let server_id = ServerId::new("test");
919 let server_info = mcp_execution_introspector::ServerInfo {
920 id: server_id.clone(),
921 name: "Test".to_string(),
922 version: "1.0.0".to_string(),
923 capabilities: ServerCapabilities {
924 supports_tools: true,
925 supports_resources: false,
926 supports_prompts: false,
927 },
928 tools: vec![ToolInfo {
929 name: ToolName::new("tool1"),
930 description: "Tool 1".to_string(),
931 input_schema: serde_json::json!({"type": "object"}),
932 output_schema: None,
933 }],
934 };
935
936 let pending = PendingGeneration::new(
937 server_id,
938 server_info,
939 ServerConfig::builder().command("echo".to_string()).build(),
940 PathBuf::from("/tmp/test"),
941 );
942
943 let session_id = service.state.store(pending).await;
944
945 let params = SaveCategorizedToolsParams {
947 session_id,
948 categorized_tools: vec![CategorizedTool {
949 name: "tool2".to_string(), category: "test".to_string(),
951 keywords: "test".to_string(),
952 short_description: "Test".to_string(),
953 }],
954 };
955
956 let result = service.save_categorized_tools(Parameters(params)).await;
957
958 assert!(result.is_err());
959 let err = result.unwrap_err();
960 assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
961 assert!(err.message.contains("not found in introspected tools"));
962 }
963
964 #[tokio::test]
965 async fn test_save_categorized_tools_expired_session() {
966 use chrono::Duration;
967
968 let service = GeneratorService::new();
969
970 let server_id = ServerId::new("test");
972 let server_info = mcp_execution_introspector::ServerInfo {
973 id: server_id.clone(),
974 name: "Test".to_string(),
975 version: "1.0.0".to_string(),
976 capabilities: ServerCapabilities {
977 supports_tools: true,
978 supports_resources: false,
979 supports_prompts: false,
980 },
981 tools: vec![],
982 };
983
984 let mut pending = PendingGeneration::new(
985 server_id,
986 server_info,
987 ServerConfig::builder().command("echo".to_string()).build(),
988 PathBuf::from("/tmp/test"),
989 );
990
991 pending.expires_at = Utc::now() - Duration::hours(1);
993
994 let session_id = service.state.store(pending).await;
995
996 let params = SaveCategorizedToolsParams {
997 session_id,
998 categorized_tools: vec![],
999 };
1000
1001 let result = service.save_categorized_tools(Parameters(params)).await;
1002
1003 assert!(result.is_err());
1004 let err = result.unwrap_err();
1005 assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1006 }
1007
1008 #[tokio::test]
1013 async fn test_list_generated_servers_nonexistent_dir() {
1014 let service = GeneratorService::new();
1015
1016 let params = ListGeneratedServersParams {
1017 base_dir: Some("/nonexistent/path/that/does/not/exist".to_string()),
1018 };
1019
1020 let result = service.list_generated_servers(Parameters(params)).await;
1021
1022 assert!(result.is_ok());
1023 let content = result.unwrap();
1024 let text_content = content.content[0].as_text().unwrap();
1025 let parsed: ListGeneratedServersResult = serde_json::from_str(&text_content.text).unwrap();
1026
1027 assert_eq!(parsed.total_servers, 0);
1028 assert_eq!(parsed.servers.len(), 0);
1029 }
1030
1031 #[tokio::test]
1032 async fn test_list_generated_servers_default_dir() {
1033 let service = GeneratorService::new();
1034
1035 let params = ListGeneratedServersParams { base_dir: None };
1036
1037 let result = service.list_generated_servers(Parameters(params)).await;
1038
1039 assert!(result.is_ok());
1041 }
1042
1043 #[tokio::test]
1048 async fn test_generate_skill_invalid_server_id_uppercase() {
1049 let service = GeneratorService::new();
1050
1051 let params = GenerateSkillParams {
1052 server_id: "GitHub".to_string(), skill_name: None,
1054 use_case_hints: None,
1055 servers_dir: None,
1056 };
1057
1058 let result = service.generate_skill(Parameters(params)).await;
1059
1060 assert!(result.is_err());
1061 let err = result.unwrap_err();
1062 assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1063 assert!(err.message.contains("lowercase"));
1064 }
1065
1066 #[tokio::test]
1067 async fn test_generate_skill_invalid_server_id_special_chars() {
1068 let service = GeneratorService::new();
1069
1070 let params = GenerateSkillParams {
1071 server_id: "git@hub".to_string(), skill_name: None,
1073 use_case_hints: None,
1074 servers_dir: None,
1075 };
1076
1077 let result = service.generate_skill(Parameters(params)).await;
1078
1079 assert!(result.is_err());
1080 let err = result.unwrap_err();
1081 assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1082 }
1083
1084 #[tokio::test]
1085 async fn test_generate_skill_server_directory_not_found() {
1086 let service = GeneratorService::new();
1087
1088 let params = GenerateSkillParams {
1089 server_id: "nonexistent-server".to_string(),
1090 skill_name: None,
1091 use_case_hints: None,
1092 servers_dir: Some(PathBuf::from("/nonexistent/path")),
1093 };
1094
1095 let result = service.generate_skill(Parameters(params)).await;
1096
1097 assert!(result.is_err());
1098 let err = result.unwrap_err();
1099 assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1100 assert!(err.message.contains("not found"));
1101 }
1102
1103 #[tokio::test]
1104 async fn test_generate_skill_empty_directory() {
1105 use tempfile::TempDir;
1106
1107 let service = GeneratorService::new();
1108 let temp_dir = TempDir::new().unwrap();
1109 let base_dir = temp_dir.path().to_path_buf();
1110
1111 let target_dir = base_dir.join("test-server");
1113 tokio::fs::create_dir_all(&target_dir).await.unwrap();
1114
1115 let params = GenerateSkillParams {
1116 server_id: "test-server".to_string(),
1117 skill_name: None,
1118 use_case_hints: None,
1119 servers_dir: Some(base_dir),
1120 };
1121
1122 let result = service.generate_skill(Parameters(params)).await;
1123
1124 assert!(result.is_err());
1125 let err = result.unwrap_err();
1126 assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1127 assert!(err.message.contains("No tool files found"));
1128 }
1129
1130 #[tokio::test]
1135 async fn test_save_skill_invalid_server_id() {
1136 let service = GeneratorService::new();
1137
1138 let params = SaveSkillParams {
1139 server_id: "Invalid_Server".to_string(), content: "---\nname: test\ndescription: test\n---\n# Test".to_string(),
1141 output_path: None,
1142 overwrite: false,
1143 };
1144
1145 let result = service.save_skill(Parameters(params)).await;
1146
1147 assert!(result.is_err());
1148 let err = result.unwrap_err();
1149 assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1150 assert!(err.message.contains("lowercase"));
1151 }
1152
1153 #[tokio::test]
1154 async fn test_save_skill_missing_yaml_frontmatter() {
1155 let service = GeneratorService::new();
1156
1157 let params = SaveSkillParams {
1158 server_id: "test".to_string(),
1159 content: "# Test Skill\n\nNo YAML frontmatter here.".to_string(),
1160 output_path: None,
1161 overwrite: false,
1162 };
1163
1164 let result = service.save_skill(Parameters(params)).await;
1165
1166 assert!(result.is_err());
1167 let err = result.unwrap_err();
1168 assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1169 assert!(err.message.contains("YAML frontmatter"));
1170 }
1171
1172 #[tokio::test]
1173 async fn test_save_skill_invalid_frontmatter_no_name() {
1174 let service = GeneratorService::new();
1175
1176 let params = SaveSkillParams {
1177 server_id: "test".to_string(),
1178 content: "---\ndescription: test\n---\n# Test".to_string(),
1179 output_path: None,
1180 overwrite: false,
1181 };
1182
1183 let result = service.save_skill(Parameters(params)).await;
1184
1185 assert!(result.is_err());
1186 let err = result.unwrap_err();
1187 assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1188 assert!(err.message.contains("Invalid SKILL.md format"));
1189 }
1190
1191 #[tokio::test]
1192 async fn test_save_skill_invalid_frontmatter_no_description() {
1193 let service = GeneratorService::new();
1194
1195 let params = SaveSkillParams {
1196 server_id: "test".to_string(),
1197 content: "---\nname: test-skill\n---\n# Test".to_string(),
1198 output_path: None,
1199 overwrite: false,
1200 };
1201
1202 let result = service.save_skill(Parameters(params)).await;
1203
1204 assert!(result.is_err());
1205 let err = result.unwrap_err();
1206 assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1207 assert!(err.message.contains("Invalid SKILL.md format"));
1208 }
1209
1210 #[tokio::test]
1211 async fn test_save_skill_file_exists_no_overwrite() {
1212 use tempfile::TempDir;
1213
1214 let service = GeneratorService::new();
1215 let temp_dir = TempDir::new().unwrap();
1216 let output_path = temp_dir.path().join("SKILL.md");
1217
1218 tokio::fs::write(&output_path, "existing content")
1220 .await
1221 .unwrap();
1222
1223 let params = SaveSkillParams {
1224 server_id: "test".to_string(),
1225 content: "---\nname: test\ndescription: test\n---\n# Test".to_string(),
1226 output_path: Some(output_path),
1227 overwrite: false,
1228 };
1229
1230 let result = service.save_skill(Parameters(params)).await;
1231
1232 assert!(result.is_err());
1233 let err = result.unwrap_err();
1234 assert_eq!(err.code, ErrorCode::INVALID_PARAMS);
1235 assert!(err.message.contains("already exists"));
1236 assert!(err.message.contains("overwrite=true"));
1237 }
1238
1239 #[tokio::test]
1240 async fn test_save_skill_file_exists_with_overwrite() {
1241 use tempfile::TempDir;
1242
1243 let service = GeneratorService::new();
1244 let temp_dir = TempDir::new().unwrap();
1245 let output_path = temp_dir.path().join("SKILL.md");
1246
1247 tokio::fs::write(&output_path, "existing content")
1249 .await
1250 .unwrap();
1251
1252 let params = SaveSkillParams {
1253 server_id: "test".to_string(),
1254 content: "---\nname: test\ndescription: test skill\n---\n# Test".to_string(),
1255 output_path: Some(output_path.clone()),
1256 overwrite: true,
1257 };
1258
1259 let result = service.save_skill(Parameters(params)).await;
1260
1261 assert!(result.is_ok());
1262 let content = result.unwrap();
1263 let text = content.content[0].as_text().unwrap();
1264 let parsed: SaveSkillResult = serde_json::from_str(&text.text).unwrap();
1265
1266 assert!(parsed.success);
1267 assert!(parsed.overwritten);
1268 assert_eq!(parsed.metadata.name, "test");
1269 assert_eq!(parsed.metadata.description, "test skill");
1270 }
1271
1272 #[tokio::test]
1273 async fn test_save_skill_valid_content() {
1274 use tempfile::TempDir;
1275
1276 let service = GeneratorService::new();
1277 let temp_dir = TempDir::new().unwrap();
1278 let output_path = temp_dir.path().join("SKILL.md");
1279
1280 let params = SaveSkillParams {
1281 server_id: "test".to_string(),
1282 content: "---\nname: test-skill\ndescription: A test skill\n---\n\n# Test Skill\n\n## Section 1\n\nContent here.".to_string(),
1283 output_path: Some(output_path.clone()),
1284 overwrite: false,
1285 };
1286
1287 let result = service.save_skill(Parameters(params)).await;
1288
1289 assert!(result.is_ok());
1290 let content = result.unwrap();
1291 let text = content.content[0].as_text().unwrap();
1292 let parsed: SaveSkillResult = serde_json::from_str(&text.text).unwrap();
1293
1294 assert!(parsed.success);
1295 assert!(!parsed.overwritten);
1296 assert_eq!(parsed.metadata.name, "test-skill");
1297 assert_eq!(parsed.metadata.description, "A test skill");
1298 assert!(parsed.metadata.section_count >= 1);
1299 assert!(parsed.metadata.word_count > 0);
1300
1301 assert!(output_path.exists());
1303 }
1304}