1use mcp_execution_core::metadata::{METADATA_FILE_NAME, METADATA_SCHEMA_VERSION, ServerMetadata};
16use regex::Regex;
17use std::path::Path;
18use std::sync::LazyLock;
19use thiserror::Error;
20
21pub const MAX_TOOL_FILES: usize = 500;
23
24pub const MAX_FILE_SIZE: u64 = 1024 * 1024;
26
27static FRONTMATTER_REGEX: LazyLock<Regex> =
29 LazyLock::new(|| Regex::new(r"^---\s*\n([\s\S]*?)\n---").expect("valid regex"));
30static NAME_REGEX: LazyLock<Regex> =
31 LazyLock::new(|| Regex::new(r"name:\s*(.+)").expect("valid regex"));
32static SKILL_DESC_REGEX: LazyLock<Regex> =
33 LazyLock::new(|| Regex::new(r"description:\s*(.+)").expect("valid regex"));
34
35fn sanitize_path_for_error(path: &Path) -> String {
40 dirs::home_dir().map_or_else(
41 || path.display().to_string(),
42 |home| {
43 let path_str = path.display().to_string();
44 path_str.replace(&home.display().to_string(), "~")
45 },
46 )
47}
48
49#[derive(Debug, Error)]
51pub enum ScanError {
52 #[error("I/O error: {0}")]
54 Io(#[from] std::io::Error),
55
56 #[error("directory does not exist: {path}")]
58 DirectoryNotFound { path: String },
59
60 #[error("metadata sidecar not found: {path} (was the server directory regenerated?)")]
62 MissingMetadata { path: String },
63
64 #[error("failed to parse metadata sidecar {path}: {source}")]
66 MetadataParse {
67 path: String,
68 #[source]
69 source: serde_json::Error,
70 },
71
72 #[error("unsupported metadata schema version: found {found}, expected {expected}")]
74 UnsupportedSchema { found: u32, expected: u32 },
75
76 #[error("too many tools: {count} exceeds limit of {limit}")]
78 TooManyFiles { count: usize, limit: usize },
79
80 #[error("file too large: {path} ({size} bytes exceeds {limit} limit)")]
82 FileTooLarge { path: String, size: u64, limit: u64 },
83
84 #[error(
91 "stale metadata: tool '{tool}' is listed in {sidecar_path} but its file '{expected_file}' \
92 is missing (re-run 'generate' to regenerate this server)"
93 )]
94 StaleMetadata {
95 tool: String,
96 expected_file: String,
97 sidecar_path: String,
98 },
99}
100
101#[derive(Debug, Clone, Default)]
109pub struct ScanResult {
110 pub tools: Vec<ParsedToolFile>,
112
113 pub warnings: Vec<String>,
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
158impl From<mcp_execution_core::metadata::ParameterMetadata> for ParsedParameter {
159 fn from(meta: mcp_execution_core::metadata::ParameterMetadata) -> Self {
160 Self {
161 name: meta.name,
162 typescript_type: meta.typescript_type,
163 required: meta.required,
164 description: meta.description,
165 }
166 }
167}
168
169impl From<mcp_execution_core::metadata::ToolMetadata> for ParsedToolFile {
170 fn from(meta: mcp_execution_core::metadata::ToolMetadata) -> Self {
171 Self {
172 name: meta.name,
173 typescript_name: meta.typescript_name,
174 server_id: String::new(),
175 category: meta.category,
176 keywords: meta.keywords,
177 description: meta.description,
178 parameters: meta.parameters.into_iter().map(Into::into).collect(),
179 }
180 }
181}
182
183pub async fn scan_tools_directory(dir: &Path) -> Result<ScanResult, ScanError> {
225 let canonical_base =
227 tokio::fs::canonicalize(dir)
228 .await
229 .map_err(|_| ScanError::DirectoryNotFound {
230 path: sanitize_path_for_error(dir),
231 })?;
232
233 let meta_path = canonical_base.join(METADATA_FILE_NAME);
234
235 let canonical_meta = match tokio::fs::canonicalize(&meta_path).await {
238 Ok(path) if path.starts_with(&canonical_base) => path,
239 _ => {
240 return Err(ScanError::MissingMetadata {
241 path: sanitize_path_for_error(&meta_path),
242 });
243 }
244 };
245
246 let file_metadata = tokio::fs::metadata(&canonical_meta).await?;
247 if file_metadata.len() > MAX_FILE_SIZE {
248 return Err(ScanError::FileTooLarge {
249 path: sanitize_path_for_error(&meta_path),
250 size: file_metadata.len(),
251 limit: MAX_FILE_SIZE,
252 });
253 }
254
255 let content = tokio::fs::read_to_string(&canonical_meta).await?;
256
257 let meta: ServerMetadata =
258 serde_json::from_str(&content).map_err(|source| ScanError::MetadataParse {
259 path: sanitize_path_for_error(&meta_path),
260 source,
261 })?;
262
263 if meta.schema_version != METADATA_SCHEMA_VERSION {
264 return Err(ScanError::UnsupportedSchema {
265 found: meta.schema_version,
266 expected: METADATA_SCHEMA_VERSION,
267 });
268 }
269
270 if meta.tools.len() > MAX_TOOL_FILES {
271 return Err(ScanError::TooManyFiles {
272 count: meta.tools.len(),
273 limit: MAX_TOOL_FILES,
274 });
275 }
276
277 let warnings = verify_tool_files_on_disk(&canonical_base, &meta.tools, &meta_path).await?;
278
279 let server_id = meta.server_id.clone();
280 let mut tools: Vec<ParsedToolFile> = meta
281 .tools
282 .into_iter()
283 .map(|tool| {
284 let mut parsed: ParsedToolFile = tool.into();
285 parsed.server_id.clone_from(&server_id);
286 parsed
287 })
288 .collect();
289
290 tools.sort_by(|a, b| a.name.cmp(&b.name));
292
293 Ok(ScanResult { tools, warnings })
294}
295
296async fn verify_tool_files_on_disk(
313 dir: &Path,
314 tools: &[mcp_execution_core::metadata::ToolMetadata],
315 meta_path: &Path,
316) -> Result<Vec<String>, ScanError> {
317 const INDEX_FILE_NAME: &str = "index.ts";
319
320 let mut expected_files: std::collections::HashSet<String> =
321 std::collections::HashSet::with_capacity(tools.len());
322
323 for tool in tools {
324 let file_name = format!("{}.ts", tool.typescript_name);
325 if !dir.join(&file_name).is_file() {
326 return Err(ScanError::StaleMetadata {
327 tool: tool.name.clone(),
328 expected_file: file_name,
329 sidecar_path: sanitize_path_for_error(meta_path),
330 });
331 }
332 expected_files.insert(file_name);
333 }
334
335 let mut warnings = Vec::new();
336 let mut entries = tokio::fs::read_dir(dir).await?;
337 while let Some(entry) = entries.next_entry().await? {
338 let path = entry.path();
339 if path.extension().and_then(std::ffi::OsStr::to_str) != Some("ts") {
340 continue;
341 }
342 let Some(file_name) = path.file_name().and_then(std::ffi::OsStr::to_str) else {
343 continue;
344 };
345 if file_name == INDEX_FILE_NAME || expected_files.contains(file_name) {
346 continue;
347 }
348 tracing::warn!(
349 file = %file_name,
350 "found .ts tool file not referenced by _meta.json; it will be omitted from SKILL.md \
351 (re-run 'generate' to refresh the sidecar)"
352 );
353 warnings.push(format!(
354 "'{file_name}' is not referenced by _meta.json and was excluded from SKILL.md \
355 (re-run 'generate' to refresh the sidecar)"
356 ));
357 }
358
359 Ok(warnings)
360}
361
362pub fn extract_skill_metadata(content: &str) -> Result<crate::types::SkillMetadata, String> {
401 use crate::types::SkillMetadata;
402
403 let frontmatter = FRONTMATTER_REGEX
405 .captures(content)
406 .and_then(|c| c.get(1))
407 .map(|m| m.as_str())
408 .ok_or("YAML frontmatter not found")?;
409
410 let name = NAME_REGEX
412 .captures(frontmatter)
413 .and_then(|c| c.get(1))
414 .map(|m| m.as_str().trim().to_string())
415 .ok_or("'name' field not found in frontmatter")?;
416
417 let description = SKILL_DESC_REGEX
419 .captures(frontmatter)
420 .and_then(|c| c.get(1))
421 .map(|m| m.as_str().trim().to_string())
422 .ok_or("'description' field not found in frontmatter")?;
423
424 let section_count = content.lines().filter(|l| l.starts_with("## ")).count();
426
427 let word_count = content.split_whitespace().count();
429
430 Ok(SkillMetadata {
431 name,
432 description,
433 section_count,
434 word_count,
435 })
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use mcp_execution_core::metadata::{ParameterMetadata, ToolMetadata};
442 use tempfile::TempDir;
443
444 fn sample_metadata(tool_count: usize) -> ServerMetadata {
445 ServerMetadata {
446 schema_version: METADATA_SCHEMA_VERSION,
447 server_id: "github".to_string(),
448 server_name: "GitHub".to_string(),
449 server_version: "1.0.0".to_string(),
450 tools: (0..tool_count)
451 .map(|i| ToolMetadata {
452 name: format!("tool_{i}"),
453 typescript_name: format!("tool{i}"),
454 category: Some("test".to_string()),
455 keywords: vec!["test".to_string()],
456 description: Some(format!("Tool {i}")),
457 parameters: vec![ParameterMetadata {
458 name: "param".to_string(),
459 typescript_type: "string".to_string(),
460 required: true,
461 description: Some("A parameter".to_string()),
462 }],
463 })
464 .collect(),
465 }
466 }
467
468 async fn write_metadata(dir: &Path, meta: &ServerMetadata) {
471 let content = serde_json::to_string_pretty(meta).unwrap();
472 tokio::fs::write(dir.join(METADATA_FILE_NAME), content)
473 .await
474 .unwrap();
475
476 for tool in &meta.tools {
477 tokio::fs::write(
478 dir.join(format!("{}.ts", tool.typescript_name)),
479 "export {}",
480 )
481 .await
482 .unwrap();
483 }
484 }
485
486 #[tokio::test]
487 async fn test_scan_tools_directory_round_trip_preserves_parameter_descriptions() {
488 let temp_dir = TempDir::new().unwrap();
491 let meta = sample_metadata(2);
492 write_metadata(temp_dir.path(), &meta).await;
493
494 let result = scan_tools_directory(temp_dir.path()).await.unwrap();
495 let tools = result.tools;
496
497 assert_eq!(tools.len(), 2);
498 assert_eq!(tools[0].name, "tool_0");
499 assert_eq!(tools[0].server_id, "github");
500 assert_eq!(tools[0].parameters.len(), 1);
501 assert_eq!(
502 tools[0].parameters[0].description,
503 Some("A parameter".to_string()),
504 "parameter descriptions must survive the sidecar round-trip"
505 );
506 assert!(result.warnings.is_empty());
507 }
508
509 #[tokio::test]
510 async fn test_scan_tools_directory_sorts_by_name() {
511 let temp_dir = TempDir::new().unwrap();
512 let mut meta = sample_metadata(0);
513 meta.tools = vec![
514 ToolMetadata {
515 name: "zebra".to_string(),
516 typescript_name: "zebra".to_string(),
517 category: None,
518 keywords: vec![],
519 description: None,
520 parameters: vec![],
521 },
522 ToolMetadata {
523 name: "alpha".to_string(),
524 typescript_name: "alpha".to_string(),
525 category: None,
526 keywords: vec![],
527 description: None,
528 parameters: vec![],
529 },
530 ];
531 write_metadata(temp_dir.path(), &meta).await;
532
533 let tools = scan_tools_directory(temp_dir.path()).await.unwrap().tools;
534
535 assert_eq!(tools[0].name, "alpha");
536 assert_eq!(tools[1].name, "zebra");
537 }
538
539 #[tokio::test]
540 async fn test_scan_tools_directory_stale_metadata_missing_ts_file() {
541 let temp_dir = TempDir::new().unwrap();
545 let meta = sample_metadata(1);
546 let content = serde_json::to_string_pretty(&meta).unwrap();
548 tokio::fs::write(temp_dir.path().join(METADATA_FILE_NAME), content)
549 .await
550 .unwrap();
551
552 let result = scan_tools_directory(temp_dir.path()).await;
553
554 match result {
555 Err(ScanError::StaleMetadata {
556 tool,
557 expected_file,
558 ..
559 }) => {
560 assert_eq!(tool, "tool_0");
561 assert_eq!(expected_file, "tool0.ts");
562 }
563 other => panic!("expected StaleMetadata, got: {other:?}"),
564 }
565 }
566
567 #[tokio::test]
568 async fn test_scan_tools_directory_stale_metadata_reports_first_missing_in_sidecar_order() {
569 let temp_dir = TempDir::new().unwrap();
573 let meta = sample_metadata(3);
574 let content = serde_json::to_string_pretty(&meta).unwrap();
575 tokio::fs::write(temp_dir.path().join(METADATA_FILE_NAME), content)
576 .await
577 .unwrap();
578
579 tokio::fs::write(temp_dir.path().join("tool1.ts"), "export {}")
582 .await
583 .unwrap();
584
585 let result = scan_tools_directory(temp_dir.path()).await;
586
587 match result {
588 Err(ScanError::StaleMetadata {
589 tool,
590 expected_file,
591 ..
592 }) => {
593 assert_eq!(tool, "tool_0");
594 assert_eq!(expected_file, "tool0.ts");
595 }
596 other => panic!("expected StaleMetadata for tool_0, got: {other:?}"),
597 }
598 }
599
600 #[tokio::test]
601 async fn test_scan_tools_directory_extra_ts_file_excluded_from_result() {
602 let temp_dir = TempDir::new().unwrap();
610 let meta = sample_metadata(1);
611 write_metadata(temp_dir.path(), &meta).await;
612
613 tokio::fs::write(temp_dir.path().join("orphan.ts"), "export {}")
614 .await
615 .unwrap();
616
617 let result = scan_tools_directory(temp_dir.path()).await.unwrap();
618
619 assert_eq!(
620 result.tools.len(),
621 1,
622 "the orphaned .ts file must not be reported as a tool"
623 );
624 assert_eq!(result.tools[0].name, "tool_0");
625 assert_eq!(
626 result.warnings.len(),
627 1,
628 "the orphaned .ts file must be surfaced as a warning"
629 );
630 assert!(
631 result.warnings[0].contains("orphan.ts"),
632 "warning must name the excluded file: {:?}",
633 result.warnings[0]
634 );
635 }
636
637 #[tokio::test]
638 async fn test_scan_tools_directory_index_ts_not_treated_as_extra() {
639 let temp_dir = TempDir::new().unwrap();
642 let meta = sample_metadata(1);
643 write_metadata(temp_dir.path(), &meta).await;
644
645 tokio::fs::write(temp_dir.path().join("index.ts"), "export * from './tool0';")
646 .await
647 .unwrap();
648
649 let result = scan_tools_directory(temp_dir.path()).await.unwrap();
650
651 assert_eq!(result.tools.len(), 1);
652 assert_eq!(result.tools[0].name, "tool_0");
653 assert!(
654 result.warnings.is_empty(),
655 "index.ts must not be reported as a warning"
656 );
657 }
658
659 #[test]
660 fn test_stale_metadata_error_message_tells_user_to_regenerate() {
661 let err = ScanError::StaleMetadata {
662 tool: "create_issue".to_string(),
663 expected_file: "createIssue.ts".to_string(),
664 sidecar_path: "~/.claude/servers/github/_meta.json".to_string(),
665 };
666
667 let message = err.to_string();
668 assert!(
669 message.contains("create_issue"),
670 "message must name the affected tool"
671 );
672 assert!(
673 message.contains("createIssue.ts"),
674 "message must name the missing file"
675 );
676 assert!(
677 message.contains("re-run 'generate'"),
678 "message must tell the user how to fix it: {message}"
679 );
680 }
681
682 #[tokio::test]
683 async fn test_scan_tools_directory_missing_metadata() {
684 let temp_dir = TempDir::new().unwrap();
685
686 let result = scan_tools_directory(temp_dir.path()).await;
687
688 assert!(matches!(result, Err(ScanError::MissingMetadata { .. })));
689 }
690
691 #[tokio::test]
692 async fn test_scan_tools_directory_corrupt_json() {
693 let temp_dir = TempDir::new().unwrap();
694 tokio::fs::write(temp_dir.path().join(METADATA_FILE_NAME), "{not valid json")
695 .await
696 .unwrap();
697
698 let result = scan_tools_directory(temp_dir.path()).await;
699
700 assert!(matches!(result, Err(ScanError::MetadataParse { .. })));
701 }
702
703 #[tokio::test]
704 async fn test_scan_tools_directory_unsupported_schema() {
705 let temp_dir = TempDir::new().unwrap();
706 let mut meta = sample_metadata(1);
707 meta.schema_version = METADATA_SCHEMA_VERSION + 1;
708 write_metadata(temp_dir.path(), &meta).await;
709
710 let result = scan_tools_directory(temp_dir.path()).await;
711
712 match result {
713 Err(ScanError::UnsupportedSchema { found, expected }) => {
714 assert_eq!(found, METADATA_SCHEMA_VERSION + 1);
715 assert_eq!(expected, METADATA_SCHEMA_VERSION);
716 }
717 other => panic!("expected UnsupportedSchema, got: {other:?}"),
718 }
719 }
720
721 #[tokio::test]
722 async fn test_scan_tools_directory_too_many_tools() {
723 let temp_dir = TempDir::new().unwrap();
724 let meta = sample_metadata(MAX_TOOL_FILES + 1);
725 write_metadata(temp_dir.path(), &meta).await;
726
727 let result = scan_tools_directory(temp_dir.path()).await;
728
729 match result {
730 Err(ScanError::TooManyFiles { count, limit }) => {
731 assert_eq!(count, MAX_TOOL_FILES + 1);
732 assert_eq!(limit, MAX_TOOL_FILES);
733 }
734 other => panic!("expected TooManyFiles, got: {other:?}"),
735 }
736 }
737
738 #[tokio::test]
739 async fn test_scan_tools_directory_file_too_large() {
740 let temp_dir = TempDir::new().unwrap();
741 let mut meta = sample_metadata(1);
742 #[allow(clippy::cast_possible_truncation)]
744 let padding = "a".repeat((MAX_FILE_SIZE as usize) + 1);
745 meta.tools[0].description = Some(padding);
746 write_metadata(temp_dir.path(), &meta).await;
747
748 let result = scan_tools_directory(temp_dir.path()).await;
749
750 match result {
751 Err(ScanError::FileTooLarge { size, limit, .. }) => {
752 assert!(size > MAX_FILE_SIZE);
753 assert_eq!(limit, MAX_FILE_SIZE);
754 }
755 other => panic!("expected FileTooLarge, got: {other:?}"),
756 }
757 }
758
759 #[tokio::test]
760 async fn test_scan_tools_directory_nonexistent() {
761 let result = scan_tools_directory(Path::new("/nonexistent/path/for/testing")).await;
762
763 assert!(matches!(result, Err(ScanError::DirectoryNotFound { .. })));
764 }
765
766 #[test]
771 fn test_extract_skill_metadata_valid() {
772 let content = r"---
773name: github-progressive
774description: GitHub MCP server operations
775---
776
777# GitHub Progressive
778
779## Quick Start
780
781Content here.
782
783## Common Tasks
784
785More content.
786";
787
788 let result = extract_skill_metadata(content);
789 assert!(result.is_ok());
790
791 let metadata = result.unwrap();
792 assert_eq!(metadata.name, "github-progressive");
793 assert_eq!(metadata.description, "GitHub MCP server operations");
794 assert_eq!(metadata.section_count, 2);
795 assert!(metadata.word_count > 0);
796 }
797
798 #[test]
799 fn test_extract_skill_metadata_no_frontmatter() {
800 let content = "# Test\n\nNo frontmatter";
801
802 let result = extract_skill_metadata(content);
803 assert!(result.is_err());
804 assert!(result.unwrap_err().contains("YAML frontmatter not found"));
805 }
806
807 #[test]
808 fn test_extract_skill_metadata_missing_name() {
809 let content = "---\ndescription: test\n---\n# Test";
810
811 let result = extract_skill_metadata(content);
812 assert!(result.is_err());
813 assert!(result.unwrap_err().contains("'name' field not found"));
814 }
815
816 #[test]
817 fn test_extract_skill_metadata_missing_description() {
818 let content = "---\nname: test\n---\n# Test";
819
820 let result = extract_skill_metadata(content);
821 assert!(result.is_err());
822 assert!(
823 result
824 .unwrap_err()
825 .contains("'description' field not found")
826 );
827 }
828
829 #[test]
830 fn test_extract_skill_metadata_with_extra_fields() {
831 let content = r"---
832name: test-skill
833description: Test description
834version: 1.0.0
835author: Test Author
836---
837
838# Test
839";
840
841 let result = extract_skill_metadata(content);
842 assert!(result.is_ok());
843
844 let metadata = result.unwrap();
845 assert_eq!(metadata.name, "test-skill");
846 assert_eq!(metadata.description, "Test description");
847 }
848
849 #[test]
850 fn test_extract_skill_metadata_multiline_description() {
851 let content = r"---
852name: test
853description: This is a long description that contains multiple words
854---
855
856# Test
857";
858
859 let result = extract_skill_metadata(content);
860 assert!(result.is_ok());
861
862 let metadata = result.unwrap();
863 assert!(metadata.description.contains("multiple words"));
864 }
865}