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::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
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 assert_eq!(info.server_info.name, env!("CARGO_PKG_NAME"));
787 assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
788 }
789
790 #[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(), 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); }
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(), 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(), 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(), command: "echo".to_string(),
856 args: vec!["test".to_string()],
857 env: HashMap::new(),
858 output_dir: None,
859 };
860
861 let result = service.introspect_server(Parameters(params)).await;
863
864 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(), 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 if let Err(err) = result {
890 assert_ne!(err.code, ErrorCode::INVALID_PARAMS);
891 }
892 }
893
894 #[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(), 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); 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 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 let params = SaveCategorizedToolsParams {
949 session_id,
950 categorized_tools: vec![CategorizedTool {
951 name: "tool2".to_string(), 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 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 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 #[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 assert!(result.is_ok());
1043 }
1044
1045 #[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(), 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(), 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 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 #[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(), 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 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 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 assert!(output_path.exists());
1305 }
1306}