1use regex::Regex;
23use std::path::Path;
24use std::sync::LazyLock;
25use thiserror::Error;
26
27pub const MAX_TOOL_FILES: usize = 500;
29
30pub const MAX_FILE_SIZE: u64 = 1024 * 1024;
32
33static JSDOC_REGEX: LazyLock<Regex> =
35 LazyLock::new(|| Regex::new(r"/\*\*[\s\S]*?\*/").expect("valid regex"));
36static TOOL_REGEX: LazyLock<Regex> =
37 LazyLock::new(|| Regex::new(r"@tool\s+(\S+)").expect("valid regex"));
38static SERVER_REGEX: LazyLock<Regex> =
39 LazyLock::new(|| Regex::new(r"@server\s+(\S+)").expect("valid regex"));
40static CATEGORY_REGEX: LazyLock<Regex> =
41 LazyLock::new(|| Regex::new(r"@category\s+(\S+)").expect("valid regex"));
42static KEYWORDS_REGEX: LazyLock<Regex> =
43 LazyLock::new(|| Regex::new(r"@keywords[ \t]+(.+)").expect("valid regex"));
44static DESC_REGEX: LazyLock<Regex> =
45 LazyLock::new(|| Regex::new(r"@description[ \t]+(.+)").expect("valid regex"));
46static INTERFACE_REGEX: LazyLock<Regex> =
47 LazyLock::new(|| Regex::new(r"interface\s+\w+Params\s*\{([^}]*)\}").expect("valid regex"));
48static PROP_REGEX: LazyLock<Regex> =
49 LazyLock::new(|| Regex::new(r"(\w+)(\?)?:\s*([^;]+);").expect("valid regex"));
50
51static FRONTMATTER_REGEX: LazyLock<Regex> =
53 LazyLock::new(|| Regex::new(r"^---\s*\n([\s\S]*?)\n---").expect("valid regex"));
54static NAME_REGEX: LazyLock<Regex> =
55 LazyLock::new(|| Regex::new(r"name:\s*(.+)").expect("valid regex"));
56static SKILL_DESC_REGEX: LazyLock<Regex> =
57 LazyLock::new(|| Regex::new(r"description:\s*(.+)").expect("valid regex"));
58
59fn sanitize_path_for_error(path: &Path) -> String {
64 dirs::home_dir().map_or_else(
65 || path.display().to_string(),
66 |home| {
67 let path_str = path.display().to_string();
68 path_str.replace(&home.display().to_string(), "~")
69 },
70 )
71}
72
73#[derive(Debug, Error)]
75pub enum ParseError {
76 #[error("JSDoc block not found in file")]
78 MissingJsDoc,
79
80 #[error("required tag '@{tag}' not found")]
82 MissingTag { tag: &'static str },
83
84 #[error("failed to parse file: {message}")]
86 ParseFailed { message: String },
87}
88
89#[derive(Debug, Error)]
91pub enum ScanError {
92 #[error("I/O error: {0}")]
94 Io(#[from] std::io::Error),
95
96 #[error("failed to parse {path}: {source}")]
98 ParseFailed {
99 path: String,
100 #[source]
101 source: ParseError,
102 },
103
104 #[error("directory does not exist: {path}")]
106 DirectoryNotFound { path: String },
107
108 #[error("too many files: {count} exceeds limit of {limit}")]
110 TooManyFiles { count: usize, limit: usize },
111
112 #[error("file too large: {path} ({size} bytes exceeds {limit} limit)")]
114 FileTooLarge { path: String, size: u64, limit: u64 },
115}
116
117#[derive(Debug, Clone)]
119pub struct ParsedToolFile {
120 pub name: String,
122
123 pub typescript_name: String,
125
126 pub server_id: String,
128
129 pub category: Option<String>,
131
132 pub keywords: Vec<String>,
134
135 pub description: Option<String>,
137
138 pub parameters: Vec<ParsedParameter>,
140}
141
142#[derive(Debug, Clone)]
144pub struct ParsedParameter {
145 pub name: String,
147
148 pub typescript_type: String,
150
151 pub required: bool,
153
154 pub description: Option<String>,
156}
157
158pub fn parse_tool_file(content: &str, filename: &str) -> Result<ParsedToolFile, ParseError> {
196 let jsdoc = JSDOC_REGEX
198 .find(content)
199 .map(|m| m.as_str())
200 .ok_or(ParseError::MissingJsDoc)?;
201
202 let name = TOOL_REGEX
204 .captures(jsdoc)
205 .and_then(|c| c.get(1))
206 .map(|m| m.as_str().to_string())
207 .ok_or(ParseError::MissingTag { tag: "tool" })?;
208
209 let server_id = SERVER_REGEX
211 .captures(jsdoc)
212 .and_then(|c| c.get(1))
213 .map(|m| m.as_str().to_string())
214 .ok_or(ParseError::MissingTag { tag: "server" })?;
215
216 let category = CATEGORY_REGEX
218 .captures(jsdoc)
219 .and_then(|c| c.get(1))
220 .map(|m| m.as_str().to_string());
221
222 let keywords = KEYWORDS_REGEX
224 .captures(jsdoc)
225 .and_then(|c| c.get(1))
226 .map(|m| {
227 m.as_str()
228 .split(',')
229 .map(|s| s.trim().to_string())
230 .filter(|s| !s.is_empty())
231 .collect()
232 })
233 .unwrap_or_default();
234
235 let description = DESC_REGEX
237 .captures(jsdoc)
238 .and_then(|c| c.get(1))
239 .map(|m| m.as_str().trim().to_string());
240
241 let typescript_name = filename.strip_suffix(".ts").unwrap_or(filename).to_string();
243
244 let parameters = parse_parameters(content);
246
247 Ok(ParsedToolFile {
248 name,
249 typescript_name,
250 server_id,
251 category,
252 keywords,
253 description,
254 parameters,
255 })
256}
257
258fn parse_parameters(content: &str) -> Vec<ParsedParameter> {
270 let mut parameters = Vec::new();
271
272 if let Some(captures) = INTERFACE_REGEX.captures(content)
274 && let Some(body) = captures.get(1)
275 {
276 for cap in PROP_REGEX.captures_iter(body.as_str()) {
278 let name = cap
279 .get(1)
280 .map(|m| m.as_str().to_string())
281 .unwrap_or_default();
282 let optional = cap.get(2).is_some();
283 let typescript_type = cap
284 .get(3)
285 .map_or_else(|| "unknown".to_string(), |m| m.as_str().trim().to_string());
286
287 parameters.push(ParsedParameter {
288 name,
289 typescript_type,
290 required: !optional,
291 description: None,
292 });
293 }
294 }
295
296 parameters
297}
298
299pub async fn scan_tools_directory(dir: &Path) -> Result<Vec<ParsedToolFile>, ScanError> {
331 let canonical_base =
333 tokio::fs::canonicalize(dir)
334 .await
335 .map_err(|_| ScanError::DirectoryNotFound {
336 path: sanitize_path_for_error(dir),
337 })?;
338
339 let mut tools = Vec::new();
340 let mut file_count = 0usize;
341
342 let mut entries = tokio::fs::read_dir(&canonical_base).await?;
343
344 while let Some(entry) = entries.next_entry().await? {
345 let path = entry.path();
346
347 if path.is_dir() {
349 continue;
350 }
351
352 let Some(filename) = path.file_name().and_then(|n| n.to_str()) else {
354 continue;
355 };
356
357 if !std::path::Path::new(filename)
359 .extension()
360 .is_some_and(|ext| ext.eq_ignore_ascii_case("ts"))
361 {
362 continue;
363 }
364
365 if filename == "index.ts" || filename.starts_with('_') {
367 continue;
368 }
369
370 let Ok(canonical_file) = tokio::fs::canonicalize(&path).await else {
373 tracing::warn!(
374 "Skipping file with invalid path: {}",
375 sanitize_path_for_error(&path)
376 );
377 continue;
378 };
379
380 if !canonical_file.starts_with(&canonical_base) {
382 tracing::warn!(
383 "Skipping file outside base directory: {} (symlink to {})",
384 sanitize_path_for_error(&path),
385 sanitize_path_for_error(&canonical_file)
386 );
387 continue;
388 }
389
390 file_count += 1;
392 if file_count > MAX_TOOL_FILES {
393 return Err(ScanError::TooManyFiles {
394 count: file_count,
395 limit: MAX_TOOL_FILES,
396 });
397 }
398
399 let metadata = tokio::fs::metadata(&canonical_file).await?;
401 if metadata.len() > MAX_FILE_SIZE {
402 return Err(ScanError::FileTooLarge {
403 path: sanitize_path_for_error(&path),
404 size: metadata.len(),
405 limit: MAX_FILE_SIZE,
406 });
407 }
408
409 let content = tokio::fs::read_to_string(&canonical_file).await?;
411
412 match parse_tool_file(&content, filename) {
413 Ok(tool) => tools.push(tool),
414 Err(e) => {
415 tracing::warn!("Failed to parse {}: {}", sanitize_path_for_error(&path), e);
417 }
418 }
419 }
420
421 tools.sort_by(|a, b| a.name.cmp(&b.name));
423
424 Ok(tools)
425}
426
427pub fn extract_skill_metadata(content: &str) -> Result<crate::types::SkillMetadata, String> {
466 use crate::types::SkillMetadata;
467
468 let frontmatter = FRONTMATTER_REGEX
470 .captures(content)
471 .and_then(|c| c.get(1))
472 .map(|m| m.as_str())
473 .ok_or("YAML frontmatter not found")?;
474
475 let name = NAME_REGEX
477 .captures(frontmatter)
478 .and_then(|c| c.get(1))
479 .map(|m| m.as_str().trim().to_string())
480 .ok_or("'name' field not found in frontmatter")?;
481
482 let description = SKILL_DESC_REGEX
484 .captures(frontmatter)
485 .and_then(|c| c.get(1))
486 .map(|m| m.as_str().trim().to_string())
487 .ok_or("'description' field not found in frontmatter")?;
488
489 let section_count = content.lines().filter(|l| l.starts_with("## ")).count();
491
492 let word_count = content.split_whitespace().count();
494
495 Ok(SkillMetadata {
496 name,
497 description,
498 section_count,
499 word_count,
500 })
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn test_parse_tool_file_complete() {
509 let content = r"
510/**
511 * @tool create_issue
512 * @server github
513 * @category issues
514 * @keywords create,issue,new,bug,feature
515 * @description Create a new issue in a repository
516 */
517
518interface CreateIssueParams {
519 owner: string;
520 repo: string;
521 title: string;
522 body?: string;
523 labels?: string[];
524}
525";
526
527 let result = parse_tool_file(content, "createIssue.ts").unwrap();
528
529 assert_eq!(result.name, "create_issue");
530 assert_eq!(result.typescript_name, "createIssue");
531 assert_eq!(result.server_id, "github");
532 assert_eq!(result.category, Some("issues".to_string()));
533 assert_eq!(
534 result.keywords,
535 vec!["create", "issue", "new", "bug", "feature"]
536 );
537 assert_eq!(
538 result.description,
539 Some("Create a new issue in a repository".to_string())
540 );
541 assert_eq!(result.parameters.len(), 5);
542
543 let owner = result
545 .parameters
546 .iter()
547 .find(|p| p.name == "owner")
548 .unwrap();
549 assert!(owner.required);
550 assert_eq!(owner.typescript_type, "string");
551
552 let body = result.parameters.iter().find(|p| p.name == "body").unwrap();
554 assert!(!body.required);
555 }
556
557 #[test]
558 fn test_parse_tool_file_minimal() {
559 let content = r"
560/**
561 * @tool get_user
562 * @server github
563 */
564";
565
566 let result = parse_tool_file(content, "getUser.ts").unwrap();
567
568 assert_eq!(result.name, "get_user");
569 assert_eq!(result.server_id, "github");
570 assert!(result.category.is_none());
571 assert!(result.keywords.is_empty());
572 assert!(result.description.is_none());
573 }
574
575 #[test]
576 fn test_parse_tool_file_missing_jsdoc() {
577 let content = r"
578// No JSDoc block
579function test() {}
580";
581
582 let result = parse_tool_file(content, "test.ts");
583 assert!(matches!(result, Err(ParseError::MissingJsDoc)));
584 }
585
586 #[test]
587 fn test_parse_tool_file_missing_tool_tag() {
588 let content = r"
589/**
590 * @server github
591 */
592";
593
594 let result = parse_tool_file(content, "test.ts");
595 assert!(matches!(
596 result,
597 Err(ParseError::MissingTag { tag: "tool" })
598 ));
599 }
600
601 #[test]
602 fn test_parse_parameters() {
603 let content = r"
604interface TestParams {
605 required: string;
606 optional?: number;
607 array: string[];
608 complex?: Record<string, unknown>;
609}
610";
611
612 let params = parse_parameters(content);
613
614 assert_eq!(params.len(), 4);
615
616 let required = params.iter().find(|p| p.name == "required").unwrap();
617 assert!(required.required);
618 assert_eq!(required.typescript_type, "string");
619
620 let optional = params.iter().find(|p| p.name == "optional").unwrap();
621 assert!(!optional.required);
622 assert_eq!(optional.typescript_type, "number");
623 }
624
625 #[test]
626 fn test_parse_keywords_with_spaces() {
627 let content = r"
628/**
629 * @tool test
630 * @server test
631 * @keywords create , update, delete
632 */
633";
634
635 let result = parse_tool_file(content, "test.ts").unwrap();
636 assert_eq!(result.keywords, vec!["create", "update", "delete"]);
637 }
638
639 #[test]
644 fn test_parse_tool_file_missing_server_tag() {
645 let content = r"
646/**
647 * @tool test_tool
648 */
649";
650
651 let result = parse_tool_file(content, "test.ts");
652 assert!(matches!(
653 result,
654 Err(ParseError::MissingTag { tag: "server" })
655 ));
656 }
657
658 #[test]
659 fn test_parse_tool_file_malformed_jsdoc() {
660 let content = r"
661/**
662 * @tool
663 * @server github
664 */
665";
666
667 let result = parse_tool_file(content, "test.ts");
670 assert!(result.is_ok());
673 }
674
675 #[test]
676 fn test_parse_tool_file_multiline_description() {
677 let content = r"
678/**
679 * @tool test
680 * @server github
681 * @description This is a very long description that spans
682 */
683";
684
685 let result = parse_tool_file(content, "test.ts").unwrap();
686 assert!(result.description.is_some());
687 assert!(
688 result
689 .description
690 .unwrap()
691 .contains("This is a very long description")
692 );
693 }
694
695 #[test]
696 fn test_parse_tool_file_empty_keywords() {
697 let content = r"
698/**
699 * @tool test
700 * @server github
701 * @keywords
702 */
703";
704
705 let result = parse_tool_file(content, "test.ts").unwrap();
707 assert!(result.keywords.is_empty());
709 }
710
711 #[test]
712 fn test_parse_tool_file_single_keyword() {
713 let content = r"
714/**
715 * @tool test
716 * @server github
717 * @keywords single
718 */
719";
720
721 let result = parse_tool_file(content, "test.ts").unwrap();
722 assert_eq!(result.keywords, vec!["single"]);
723 }
724
725 #[test]
726 fn test_parse_tool_file_with_hyphens_in_names() {
727 let content = r"
728/**
729 * @tool create-pull-request
730 * @server git-hub
731 * @category pull-requests
732 */
733";
734
735 let result = parse_tool_file(content, "test.ts").unwrap();
736 assert_eq!(result.name, "create-pull-request");
737 assert_eq!(result.server_id, "git-hub");
738 assert_eq!(result.category, Some("pull-requests".to_string()));
739 }
740
741 #[test]
742 fn test_parse_parameters_no_interface() {
743 let content = r"
744export async function test(): Promise<void> {
745 // No interface
746}
747";
748
749 let params = parse_parameters(content);
750 assert_eq!(params.len(), 0);
751 }
752
753 #[test]
754 fn test_parse_parameters_empty_interface() {
755 let content = r"
756interface TestParams {
757}
758";
759
760 let params = parse_parameters(content);
761 assert_eq!(params.len(), 0);
762 }
763
764 #[test]
765 fn test_parse_parameters_complex_types() {
766 let content = r"
767interface TestParams {
768 callback?: (arg: string) => void;
769 union: string | number;
770 generic: Array<string>;
771 nested: { foo: string };
772}
773";
774
775 let params = parse_parameters(content);
776 assert!(params.len() >= 3);
779
780 if let Some(callback) = params.iter().find(|p| p.name == "callback") {
781 assert!(!callback.required);
782 }
783
784 if let Some(union) = params.iter().find(|p| p.name == "union") {
785 assert!(union.required);
786 }
787 }
788
789 #[test]
790 fn test_parse_parameters_with_comments() {
791 let content = r"
792interface TestParams {
793 // This is a comment
794 param1: string;
795 /* Another comment */
796 param2: number;
797}
798";
799
800 let params = parse_parameters(content);
801 assert_eq!(params.len(), 2);
802 }
803
804 #[test]
805 fn test_parse_tool_file_special_chars_in_description() {
806 let content = r#"
808/**
809 * @tool test
810 * @server github
811 * @description Create & update <items> with "quotes" and 'apostrophes'
812 */
813"#;
814
815 let result = parse_tool_file(content, "test.ts").unwrap();
816 assert!(result.description.is_some());
817 let description = result.description.unwrap();
818 assert!(description.contains('&'));
819 assert!(description.contains('"'));
820 }
821
822 #[test]
823 fn test_parse_tool_file_numeric_category() {
824 let content = r"
825/**
826 * @tool test
827 * @server github
828 * @category v2-api
829 */
830";
831
832 let result = parse_tool_file(content, "test.ts").unwrap();
833 assert_eq!(result.category, Some("v2-api".to_string()));
834 }
835
836 #[test]
837 fn test_parse_tool_file_unicode_in_description() {
838 let content = r"
839/**
840 * @tool test
841 * @server github
842 * @description Create issue with emoji 🚀 and unicode ™
843 */
844";
845
846 let result = parse_tool_file(content, "test.ts").unwrap();
847 assert!(result.description.is_some());
848 let description = result.description.unwrap();
849 assert!(description.contains("🚀"));
850 }
851
852 #[test]
853 fn test_parse_tool_file_duplicate_tags() {
854 let content = r"
855/**
856 * @tool first_tool
857 * @tool second_tool
858 * @server github
859 */
860";
861
862 let result = parse_tool_file(content, "test.ts").unwrap();
864 assert_eq!(result.name, "first_tool");
865 }
866
867 #[test]
868 fn test_parse_parameters_readonly_modifier() {
869 let content = r"
870interface TestParams {
871 readonly id: string;
872 readonly count?: number;
873}
874";
875
876 let params = parse_parameters(content);
877 let _ = params; }
883
884 #[test]
885 fn test_parse_tool_file_filename_without_extension() {
886 let content = r"
887/**
888 * @tool test
889 * @server github
890 */
891";
892
893 let result = parse_tool_file(content, "testFile").unwrap();
894 assert_eq!(result.typescript_name, "testFile");
895 }
896
897 #[test]
898 fn test_parse_keywords_trailing_commas() {
899 let content = r"
900/**
901 * @tool test
902 * @server test
903 * @keywords create,update,delete,
904 */
905";
906
907 let result = parse_tool_file(content, "test.ts").unwrap();
908 assert_eq!(result.keywords, vec!["create", "update", "delete"]);
910 }
911
912 #[test]
917 fn test_extract_skill_metadata_valid() {
918 let content = r"---
919name: github-progressive
920description: GitHub MCP server operations
921---
922
923# GitHub Progressive
924
925## Quick Start
926
927Content here.
928
929## Common Tasks
930
931More content.
932";
933
934 let result = extract_skill_metadata(content);
935 assert!(result.is_ok());
936
937 let metadata = result.unwrap();
938 assert_eq!(metadata.name, "github-progressive");
939 assert_eq!(metadata.description, "GitHub MCP server operations");
940 assert_eq!(metadata.section_count, 2);
941 assert!(metadata.word_count > 0);
942 }
943
944 #[test]
945 fn test_extract_skill_metadata_no_frontmatter() {
946 let content = "# Test\n\nNo frontmatter";
947
948 let result = extract_skill_metadata(content);
949 assert!(result.is_err());
950 assert!(result.unwrap_err().contains("YAML frontmatter not found"));
951 }
952
953 #[test]
954 fn test_extract_skill_metadata_missing_name() {
955 let content = "---\ndescription: test\n---\n# Test";
956
957 let result = extract_skill_metadata(content);
958 assert!(result.is_err());
959 assert!(result.unwrap_err().contains("'name' field not found"));
960 }
961
962 #[test]
963 fn test_extract_skill_metadata_missing_description() {
964 let content = "---\nname: test\n---\n# Test";
965
966 let result = extract_skill_metadata(content);
967 assert!(result.is_err());
968 assert!(
969 result
970 .unwrap_err()
971 .contains("'description' field not found")
972 );
973 }
974
975 #[test]
976 fn test_extract_skill_metadata_with_extra_fields() {
977 let content = r"---
978name: test-skill
979description: Test description
980version: 1.0.0
981author: Test Author
982---
983
984# Test
985";
986
987 let result = extract_skill_metadata(content);
988 assert!(result.is_ok());
989
990 let metadata = result.unwrap();
991 assert_eq!(metadata.name, "test-skill");
992 assert_eq!(metadata.description, "Test description");
993 }
994
995 #[test]
996 fn test_extract_skill_metadata_multiline_description() {
997 let content = r"---
998name: test
999description: This is a long description that contains multiple words
1000---
1001
1002# Test
1003";
1004
1005 let result = extract_skill_metadata(content);
1006 assert!(result.is_ok());
1007
1008 let metadata = result.unwrap();
1009 assert!(metadata.description.contains("multiple words"));
1010 }
1011}