1use anyhow::{Context, Result, bail};
11use mcp_execution_core::cli::{ExitCode, OutputFormat};
12use mcp_execution_skill::{
13 build_skill_context, render_skill_md, scan_tools_directory, validate_server_id,
14};
15use serde::Serialize;
16use std::path::{Path, PathBuf};
17use tracing::{debug, info};
18
19use crate::formatters::format_output;
20
21#[derive(Debug, Serialize)]
23struct SkillWriteResult {
24 success: bool,
25 output_path: String,
26 bytes_written: usize,
27 tool_count: usize,
28 warnings: Vec<String>,
31}
32
33const DEFAULT_SERVERS_DIR: &str = ".claude/servers";
35
36const DEFAULT_SKILLS_DIR: &str = ".claude/skills";
38
39#[allow(clippy::too_many_arguments)]
95pub async fn run(
96 server: String,
97 servers_dir: Option<PathBuf>,
98 output_path: Option<PathBuf>,
99 skill_name: Option<String>,
100 hints: Vec<String>,
101 overwrite: bool,
102 output_format: OutputFormat,
103) -> Result<ExitCode> {
104 debug!("Generating skill for server: {}", server);
105 debug!("Servers directory: {:?}", servers_dir);
106 debug!("Output path: {:?}", output_path);
107 debug!("Skill name: {:?}", skill_name);
108 debug!("Hints: {:?}", hints);
109 debug!("Overwrite: {}", overwrite);
110 debug!("Output format: {}", output_format);
111
112 validate_server_id(&server).map_err(|e| anyhow::anyhow!("Invalid server ID: {e}"))?;
114 info!("Server ID validated: {}", server);
115
116 let servers_base = resolve_servers_dir(servers_dir.as_deref())?;
118 debug!("Servers base directory: {}", servers_base.display());
119
120 let tool_dir = servers_base.join(&server);
122 let tool_dir = validate_path_security(&tool_dir, &servers_base)?;
123 debug!("Server directory: {}", tool_dir.display());
124
125 if !tool_dir.exists() {
127 bail!(
128 "Server directory not found: {}\n\
129 Run 'mcp-execution-cli generate --from-config {}' first to generate TypeScript files.",
130 tool_dir.display(),
131 server
132 );
133 }
134
135 info!("Scanning TypeScript files in {}", tool_dir.display());
137 let scan_result = scan_tools_directory(&tool_dir)
138 .await
139 .context("Failed to scan tools directory")?;
140
141 if scan_result.tools.is_empty() {
142 bail!(
143 "No TypeScript tool files found in {}\n\
144 Run 'mcp-execution-cli generate --from-config {}' first.",
145 tool_dir.display(),
146 server
147 );
148 }
149
150 info!(
154 "Verified {} tool files against sidecar",
155 scan_result.tools.len()
156 );
157
158 let hints_ref: Option<Vec<String>> = if hints.is_empty() { None } else { Some(hints) };
160
161 let mut context = build_skill_context(&server, &scan_result.tools, hints_ref.as_deref());
162
163 if let Some(name) = skill_name {
165 context.skill_name = name;
166 }
167
168 if let Some(path) = output_path {
170 validate_output_path(&path)?;
172 context.output_path = path.display().to_string();
173 } else {
174 let skills_dir = resolve_skills_dir()?;
176 let default_output = skills_dir.join(&server).join("SKILL.md");
177 context.output_path = default_output.display().to_string();
178 }
179
180 let output_path = PathBuf::from(&context.output_path);
182 if output_path.exists() && !overwrite {
183 bail!(
184 "Output file already exists: {}\n\
185 Use --overwrite to replace existing file.",
186 output_path.display()
187 );
188 }
189
190 let rendered = render_skill_md(&context).context("failed to render SKILL.md template")?;
192
193 if let Some(parent) = output_path.parent() {
194 std::fs::create_dir_all(parent)
195 .with_context(|| format!("failed to create directory: {}", parent.display()))?;
196 }
197
198 let tmp_path = output_path.with_added_extension("tmp");
201 std::fs::write(&tmp_path, &rendered)
202 .with_context(|| format!("failed to write temp file: {}", tmp_path.display()))?;
203 std::fs::rename(&tmp_path, &output_path)
204 .with_context(|| format!("failed to rename to: {}", output_path.display()))?;
205
206 let bytes_written = rendered.len();
207 info!(
208 "SKILL.md written to {} ({} bytes, {} tools)",
209 output_path.display(),
210 bytes_written,
211 context.tool_count,
212 );
213
214 let result = SkillWriteResult {
215 success: true,
216 output_path: output_path.display().to_string(),
217 bytes_written,
218 tool_count: context.tool_count,
219 warnings: scan_result.warnings,
220 };
221
222 let output = format_output(&result, output_format)?;
223 println!("{output}");
224
225 Ok(ExitCode::SUCCESS)
226}
227
228fn resolve_servers_dir(servers_dir: Option<&Path>) -> Result<PathBuf> {
242 if let Some(dir) = servers_dir {
243 if let Some(stripped) = dir.to_str().and_then(|s| s.strip_prefix("~/")) {
245 let home = dirs::home_dir().context("Could not determine home directory")?;
246 Ok(home.join(stripped))
247 } else {
248 Ok(dir.to_path_buf())
249 }
250 } else {
251 let home = dirs::home_dir().context("Could not determine home directory")?;
253 Ok(home.join(DEFAULT_SERVERS_DIR))
254 }
255}
256
257fn resolve_skills_dir() -> Result<PathBuf> {
267 let home = dirs::home_dir().context("Could not determine home directory")?;
268 Ok(home.join(DEFAULT_SKILLS_DIR))
269}
270
271fn validate_path_security(path: &Path, base: &Path) -> Result<PathBuf> {
290 if has_path_traversal(path) {
292 bail!("Path traversal detected: {}", path.display());
293 }
294
295 if !path.exists() {
297 return Ok(path.to_path_buf());
298 }
299
300 let canonical_path = path
302 .canonicalize()
303 .with_context(|| format!("Failed to canonicalize path: {}", path.display()))?;
304
305 let canonical_base = if base.exists() {
306 base.canonicalize()
307 .with_context(|| format!("Failed to canonicalize base: {}", base.display()))?
308 } else {
309 return Ok(path.to_path_buf());
311 };
312
313 if !canonical_path.starts_with(&canonical_base) {
315 bail!(
316 "Security error: path {} is outside base directory {}",
317 canonical_path.display(),
318 canonical_base.display()
319 );
320 }
321
322 Ok(canonical_path)
323}
324
325fn validate_output_path(path: &Path) -> Result<()> {
335 if has_path_traversal(path) {
336 bail!(
337 "Invalid output path (path traversal detected): {}",
338 path.display()
339 );
340 }
341 Ok(())
342}
343
344fn has_path_traversal(path: &Path) -> bool {
348 use std::path::Component;
349 path.components().any(|c| matches!(c, Component::ParentDir))
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use mcp_execution_core::metadata::{
356 METADATA_FILE_NAME, METADATA_SCHEMA_VERSION, ParameterMetadata, ServerMetadata,
357 ToolMetadata,
358 };
359 use tempfile::TempDir;
360
361 fn write_meta_sidecar(server_dir: &Path, server_id: &str, tool_name: &str) {
367 let meta = ServerMetadata {
368 schema_version: METADATA_SCHEMA_VERSION,
369 server_id: server_id.to_string(),
370 server_name: server_id.to_string(),
371 server_version: "1.0.0".to_string(),
372 tools: vec![ToolMetadata {
373 name: tool_name.to_string(),
374 typescript_name: tool_name.to_string(),
375 category: Some("testing".to_string()),
376 keywords: vec!["test".to_string()],
377 description: Some(format!("Test tool: {tool_name}")),
378 parameters: vec![ParameterMetadata {
379 name: "input".to_string(),
380 typescript_type: "string".to_string(),
381 required: true,
382 description: Some("Test input".to_string()),
383 }],
384 }],
385 };
386
387 let content = serde_json::to_string_pretty(&meta).unwrap();
388 std::fs::write(server_dir.join(METADATA_FILE_NAME), content).unwrap();
389 std::fs::write(server_dir.join(format!("{tool_name}.ts")), "export {}").unwrap();
390 }
391
392 #[test]
393 fn test_resolve_servers_dir_default() {
394 let result = resolve_servers_dir(None);
395 assert!(result.is_ok());
396 let path = result.unwrap();
397 assert!(path.to_string_lossy().contains(".claude/servers"));
398 }
399
400 #[test]
401 fn test_resolve_servers_dir_custom() {
402 let custom = PathBuf::from("/custom/servers");
403 let result = resolve_servers_dir(Some(&custom));
404 assert!(result.is_ok());
405 assert_eq!(result.unwrap(), custom);
406 }
407
408 #[test]
409 fn test_resolve_servers_dir_tilde() {
410 let custom = PathBuf::from("~/custom/servers");
411 let result = resolve_servers_dir(Some(&custom));
412 assert!(result.is_ok());
413 let path = result.unwrap();
414 assert!(!path.to_string_lossy().starts_with('~'));
416 assert!(path.to_string_lossy().contains("custom/servers"));
417 }
418
419 #[test]
420 fn test_validate_path_security_valid() {
421 let temp = TempDir::new().unwrap();
422 let base = temp.path();
423 let subdir = base.join("server");
424 std::fs::create_dir(&subdir).unwrap();
425
426 let result = validate_path_security(&subdir, base);
427 assert!(result.is_ok());
428 }
429
430 #[test]
431 fn test_validate_path_security_traversal() {
432 let temp = TempDir::new().unwrap();
433 let base = temp.path();
434 let evil_path = base.join("..").join("etc").join("passwd");
435
436 let result = validate_path_security(&evil_path, base);
437 assert!(result.is_err());
438 assert!(result.unwrap_err().to_string().contains("traversal"));
439 }
440
441 #[test]
442 fn test_validate_path_security_nonexistent() {
443 let temp = TempDir::new().unwrap();
444 let base = temp.path();
445 let new_path = base.join("new-server");
446
447 let result = validate_path_security(&new_path, base);
449 assert!(result.is_ok());
450 }
451
452 #[test]
453 fn test_resolve_skills_dir() {
454 let result = resolve_skills_dir();
455 assert!(result.is_ok());
456 let path = result.unwrap();
457 assert!(path.to_string_lossy().contains(".claude/skills"));
458 }
459
460 #[test]
461 fn test_has_path_traversal() {
462 assert!(has_path_traversal(Path::new("../etc/passwd")));
464 assert!(has_path_traversal(Path::new("/tmp/../etc/passwd")));
465 assert!(has_path_traversal(Path::new("foo/../../bar")));
466
467 assert!(!has_path_traversal(Path::new("/etc/passwd")));
469 assert!(!has_path_traversal(Path::new("foo/bar/baz")));
470 assert!(!has_path_traversal(Path::new("./foo/bar")));
471 assert!(!has_path_traversal(Path::new("...")));
472 assert!(!has_path_traversal(Path::new("..foo")));
473 }
474
475 #[test]
476 fn test_validate_output_path_valid() {
477 assert!(validate_output_path(Path::new("/tmp/skill.md")).is_ok());
478 assert!(validate_output_path(Path::new("~/.claude/skills/github/SKILL.md")).is_ok());
479 assert!(validate_output_path(Path::new("./output.md")).is_ok());
480 }
481
482 #[test]
483 fn test_validate_output_path_traversal() {
484 let result = validate_output_path(Path::new("../../../etc/passwd"));
485 assert!(result.is_err());
486 assert!(result.unwrap_err().to_string().contains("path traversal"));
487
488 let result = validate_output_path(Path::new("/tmp/../etc/passwd"));
489 assert!(result.is_err());
490 }
491
492 #[tokio::test]
493 async fn test_run_output_path_traversal() {
494 let temp = TempDir::new().unwrap();
495 let server_dir = temp.path().join("github");
496 std::fs::create_dir(&server_dir).unwrap();
497 write_meta_sidecar(&server_dir, "github", "test");
498
499 let evil_output = temp
501 .path()
502 .join("..")
503 .join("..")
504 .join("etc")
505 .join("evil.md");
506
507 let result = run(
508 "github".to_string(),
509 Some(temp.path().to_path_buf()),
510 Some(evil_output),
511 None,
512 vec![],
513 false,
514 OutputFormat::Json,
515 )
516 .await;
517
518 assert!(result.is_err());
519 assert!(result.unwrap_err().to_string().contains("path traversal"));
520 }
521
522 #[tokio::test]
523 async fn test_run_invalid_server_id() {
524 let result = run(
525 "INVALID_ID".to_string(), None,
527 None,
528 None,
529 vec![],
530 false,
531 OutputFormat::Json,
532 )
533 .await;
534
535 assert!(result.is_err());
536 assert!(
537 result
538 .unwrap_err()
539 .to_string()
540 .contains("Invalid server ID")
541 );
542 }
543
544 #[tokio::test]
545 async fn test_run_server_not_found() {
546 let temp = TempDir::new().unwrap();
547 let result = run(
548 "nonexistent-server".to_string(),
549 Some(temp.path().to_path_buf()),
550 None,
551 None,
552 vec![],
553 false,
554 OutputFormat::Json,
555 )
556 .await;
557
558 assert!(result.is_err());
559 assert!(
560 result
561 .unwrap_err()
562 .to_string()
563 .contains("Server directory not found")
564 );
565 }
566
567 #[tokio::test]
568 async fn test_run_no_typescript_files() {
569 let temp = TempDir::new().unwrap();
570 let server_dir = temp.path().join("empty-server");
571 std::fs::create_dir(&server_dir).unwrap();
572
573 let result = run(
576 "empty-server".to_string(),
577 Some(temp.path().to_path_buf()),
578 None,
579 None,
580 vec![],
581 false,
582 OutputFormat::Json,
583 )
584 .await;
585
586 assert!(result.is_err());
587 assert!(
588 result
589 .unwrap_err()
590 .to_string()
591 .contains("Failed to scan tools directory")
592 );
593 }
594
595 #[tokio::test]
596 async fn test_run_with_valid_typescript_files() {
597 let temp = TempDir::new().unwrap();
598 let server_dir = temp.path().join("test-server");
599 std::fs::create_dir(&server_dir).unwrap();
600 write_meta_sidecar(&server_dir, "test-server", "test_tool");
601
602 let output_path = temp.path().join("SKILL.md");
603
604 let result = run(
605 "test-server".to_string(),
606 Some(temp.path().to_path_buf()),
607 Some(output_path.clone()),
608 None,
609 vec![],
610 false,
611 OutputFormat::Json,
612 )
613 .await;
614
615 assert!(
616 result.is_ok(),
617 "Expected success but got: {:?}",
618 result.err()
619 );
620 assert!(output_path.exists(), "SKILL.md must be written to disk");
621 let content = std::fs::read_to_string(&output_path).unwrap();
622 assert!(
623 content.starts_with("---\n"),
624 "SKILL.md must start with YAML frontmatter"
625 );
626 }
627
628 #[tokio::test]
629 async fn test_run_with_orphan_ts_file_succeeds() {
630 let temp = TempDir::new().unwrap();
634 let server_dir = temp.path().join("test-server");
635 std::fs::create_dir(&server_dir).unwrap();
636 write_meta_sidecar(&server_dir, "test-server", "test_tool");
637 std::fs::write(server_dir.join("orphanTool.ts"), "export {}").unwrap();
638
639 let output_path = temp.path().join("SKILL.md");
640
641 let result = run(
642 "test-server".to_string(),
643 Some(temp.path().to_path_buf()),
644 Some(output_path.clone()),
645 None,
646 vec![],
647 false,
648 OutputFormat::Json,
649 )
650 .await;
651
652 assert!(
653 result.is_ok(),
654 "an orphaned .ts file must not fail the run: {:?}",
655 result.err()
656 );
657 assert!(output_path.exists(), "SKILL.md must still be written");
658 }
659
660 #[test]
661 fn test_skill_write_result_json_includes_warnings() {
662 let result = SkillWriteResult {
666 success: true,
667 output_path: "/tmp/SKILL.md".to_string(),
668 bytes_written: 42,
669 tool_count: 1,
670 warnings: vec![
671 "'orphanTool.ts' is not referenced by _meta.json and was excluded from SKILL.md \
672 (re-run 'generate' to refresh the sidecar)"
673 .to_string(),
674 ],
675 };
676
677 let output = format_output(&result, OutputFormat::Json).unwrap();
678
679 assert!(
680 output.contains("\"warnings\""),
681 "JSON output must contain a warnings field: {output}"
682 );
683 assert!(
684 output.contains("orphanTool.ts"),
685 "warnings must name the excluded file: {output}"
686 );
687 }
688
689 #[tokio::test]
690 async fn test_run_with_custom_skill_name() {
691 let temp = TempDir::new().unwrap();
692 let server_dir = temp.path().join("github");
693 std::fs::create_dir(&server_dir).unwrap();
694 write_meta_sidecar(&server_dir, "github", "create_issue");
695
696 let output_path = temp.path().join("SKILL.md");
698
699 let result = run(
700 "github".to_string(),
701 Some(temp.path().to_path_buf()),
702 Some(output_path),
703 Some("github-advanced".to_string()),
704 vec![],
705 false,
706 OutputFormat::Json,
707 )
708 .await;
709
710 assert!(
711 result.is_ok(),
712 "Expected success but got: {:?}",
713 result.err()
714 );
715 }
716
717 #[tokio::test]
718 async fn test_run_with_hints() {
719 let temp = TempDir::new().unwrap();
720 let server_dir = temp.path().join("github");
721 std::fs::create_dir(&server_dir).unwrap();
722 write_meta_sidecar(&server_dir, "github", "list_prs");
723
724 let output_path = temp.path().join("SKILL.md");
726
727 let result = run(
728 "github".to_string(),
729 Some(temp.path().to_path_buf()),
730 Some(output_path),
731 None,
732 vec!["code review".to_string(), "CI/CD".to_string()],
733 false,
734 OutputFormat::Json,
735 )
736 .await;
737
738 assert!(
739 result.is_ok(),
740 "Expected success but got: {:?}",
741 result.err()
742 );
743 }
744
745 #[tokio::test]
746 async fn test_run_output_exists_no_overwrite() {
747 let temp = TempDir::new().unwrap();
748 let server_dir = temp.path().join("github");
749 std::fs::create_dir(&server_dir).unwrap();
750 write_meta_sidecar(&server_dir, "github", "test");
751
752 let output_path = temp.path().join("SKILL.md");
754 std::fs::write(&output_path, "existing content").unwrap();
755
756 let result = run(
757 "github".to_string(),
758 Some(temp.path().to_path_buf()),
759 Some(output_path),
760 None,
761 vec![],
762 false, OutputFormat::Json,
764 )
765 .await;
766
767 assert!(result.is_err());
768 assert!(result.unwrap_err().to_string().contains("already exists"));
769 }
770
771 #[tokio::test]
772 async fn test_run_output_exists_with_overwrite() {
773 let temp = TempDir::new().unwrap();
774 let server_dir = temp.path().join("github");
775 std::fs::create_dir(&server_dir).unwrap();
776 write_meta_sidecar(&server_dir, "github", "test");
777
778 let output_path = temp.path().join("SKILL.md");
780 std::fs::write(&output_path, "existing content").unwrap();
781
782 let result = run(
783 "github".to_string(),
784 Some(temp.path().to_path_buf()),
785 Some(output_path),
786 None,
787 vec![],
788 true, OutputFormat::Json,
790 )
791 .await;
792
793 assert!(
794 result.is_ok(),
795 "Expected success but got: {:?}",
796 result.err()
797 );
798 }
799
800 #[tokio::test]
801 async fn test_run_all_output_formats() {
802 let temp = TempDir::new().unwrap();
803 let server_dir = temp.path().join("test");
804 std::fs::create_dir(&server_dir).unwrap();
805 write_meta_sidecar(&server_dir, "test", "test");
806
807 for format in [OutputFormat::Json, OutputFormat::Text, OutputFormat::Pretty] {
808 let output_path = temp.path().join(format!("SKILL-{format}.md"));
809 let result = run(
810 "test".to_string(),
811 Some(temp.path().to_path_buf()),
812 Some(output_path),
813 None,
814 vec![],
815 false,
816 format,
817 )
818 .await;
819
820 assert!(
821 result.is_ok(),
822 "Format {:?} should succeed: {:?}",
823 format,
824 result.err()
825 );
826 }
827 }
828
829 #[tokio::test]
830 async fn test_run_stale_metadata_fails_instead_of_silently_succeeding() {
831 let temp = TempDir::new().unwrap();
836 let server_dir = temp.path().join("github");
837 std::fs::create_dir(&server_dir).unwrap();
838
839 let meta = ServerMetadata {
840 schema_version: METADATA_SCHEMA_VERSION,
841 server_id: "github".to_string(),
842 server_name: "GitHub".to_string(),
843 server_version: "1.0.0".to_string(),
844 tools: vec![
845 ToolMetadata {
846 name: "create_issue".to_string(),
847 typescript_name: "createIssue".to_string(),
848 category: Some("issues".to_string()),
849 keywords: vec!["create".to_string()],
850 description: Some("Create an issue".to_string()),
851 parameters: vec![ParameterMetadata {
852 name: "title".to_string(),
853 typescript_type: "string".to_string(),
854 required: true,
855 description: Some("Issue title".to_string()),
856 }],
857 },
858 ToolMetadata {
859 name: "list_repos".to_string(),
860 typescript_name: "listRepos".to_string(),
861 category: Some("repos".to_string()),
862 keywords: vec!["list".to_string()],
863 description: Some("List repos".to_string()),
864 parameters: vec![],
865 },
866 ],
867 };
868 let content = serde_json::to_string_pretty(&meta).unwrap();
869 std::fs::write(server_dir.join(METADATA_FILE_NAME), content).unwrap();
870
871 std::fs::write(server_dir.join("listRepos.ts"), "export {}").unwrap();
875 std::fs::write(server_dir.join("orphanTool.ts"), "export {}").unwrap();
878
879 let output_path = temp.path().join("SKILL.md");
880
881 let result = run(
882 "github".to_string(),
883 Some(temp.path().to_path_buf()),
884 Some(output_path.clone()),
885 None,
886 vec![],
887 false,
888 OutputFormat::Json,
889 )
890 .await;
891
892 assert!(
893 result.is_err(),
894 "drifted sidecar must fail instead of silently succeeding"
895 );
896 let err = result.unwrap_err();
897 let message = format!("{err:?}");
900 assert!(
901 message.contains("create_issue") || message.contains("createIssue.ts"),
902 "error must identify the tool/file with the missing .ts: {message}"
903 );
904 assert!(
905 !output_path.exists(),
906 "SKILL.md must not be written when the sidecar is stale"
907 );
908 }
909
910 #[tokio::test]
911 async fn test_run_path_traversal_server_id() {
912 let temp = TempDir::new().unwrap();
913
914 let result = run(
916 "../etc".to_string(),
917 Some(temp.path().to_path_buf()),
918 None,
919 None,
920 vec![],
921 false,
922 OutputFormat::Json,
923 )
924 .await;
925
926 assert!(result.is_err());
927 assert!(
929 result
930 .unwrap_err()
931 .to_string()
932 .contains("Invalid server ID")
933 );
934 }
935}