use anyhow::{Context, Result, bail};
use mcp_execution_core::Error as CoreError;
use mcp_execution_core::cli::{ExitCode, OutputFormat};
use mcp_execution_skill::{
GenerateSkillResult, ParsedToolFile, ScanResult, build_skill_context, render_skill_md,
scan_tools_directory, validate_server_id, validate_skill_name,
};
use serde::Serialize;
use std::path::{Path, PathBuf};
use tracing::{debug, info};
#[derive(Debug, Serialize)]
struct SkillWriteResult {
success: bool,
output_path: String,
bytes_written: usize,
tool_count: usize,
warnings: Vec<String>,
}
const DEFAULT_SERVERS_DIR: &str = ".claude/servers";
const DEFAULT_SKILLS_DIR: &str = ".claude/skills";
pub async fn run(
server: String,
servers_dir: Option<PathBuf>,
output_path: Option<PathBuf>,
skill_name: Option<String>,
hints: Vec<String>,
overwrite: bool,
output_format: OutputFormat,
) -> Result<ExitCode> {
debug!("Generating skill for server: {}", server);
debug!("Servers directory: {:?}", servers_dir);
debug!("Output path: {:?}", output_path);
debug!("Skill name: {:?}", skill_name);
debug!("Hints: {:?}", hints);
debug!("Overwrite: {}", overwrite);
debug!("Output format: {}", output_format);
validate_server_id(&server)
.map_err(|e| CoreError::InvalidArgument(format!("Invalid server ID: {e}")))?;
info!("Server ID validated: {}", server);
let tool_dir = resolve_tool_dir(&server, servers_dir.as_deref())?;
let scan_result = scan_server_tools(&tool_dir, &server).await?;
let (context, custom_output_path) = prepare_skill_context(
&server,
&scan_result.tools,
hints,
skill_name.as_deref(),
output_path,
)?;
let had_custom_output_path = custom_output_path.is_some();
let output_path = if let Some(path) = custom_output_path {
path
} else {
let skills_dir = resolve_skills_dir()?;
resolve_default_output_path(&skills_dir, &server).await?
};
if output_path.exists() && !overwrite {
bail!(
"Output file already exists: {}\n\
Use --overwrite to replace existing file.",
output_path.display()
);
}
let rendered = render_skill_md(&context).context("failed to render SKILL.md template")?;
if had_custom_output_path && let Some(parent) = output_path.parent() {
tokio::fs::create_dir_all(parent)
.await
.with_context(|| format!("failed to create directory: {}", parent.display()))?;
}
write_skill_md(&rendered, &output_path).await?;
let bytes_written = rendered.len();
info!(
"SKILL.md written to {} ({} bytes, {} tools)",
output_path.display(),
bytes_written,
context.tool_count,
);
let mut warnings = scan_result.warnings;
warnings.extend(context.warnings.iter().cloned());
let result = SkillWriteResult {
success: true,
output_path: output_path.display().to_string(),
bytes_written,
tool_count: context.tool_count,
warnings,
};
crate::formatters::emit(&result, output_format, ExitCode::SUCCESS)
}
fn resolve_tool_dir(server: &str, servers_dir: Option<&Path>) -> Result<PathBuf> {
let servers_base = resolve_servers_dir(servers_dir)?;
debug!("Servers base directory: {}", servers_base.display());
let tool_dir = servers_base.join(server);
let tool_dir = validate_path_security(&tool_dir, &servers_base)?;
debug!("Server directory: {}", tool_dir.display());
if !tool_dir.exists() {
bail!(
"Server directory not found: {}\n\
Run 'mcp-execution-cli generate --from-config {}' first to generate TypeScript files.",
tool_dir.display(),
server
);
}
Ok(tool_dir)
}
async fn scan_server_tools(tool_dir: &Path, server: &str) -> Result<ScanResult> {
info!("Scanning TypeScript files in {}", tool_dir.display());
let scan_result = scan_tools_directory(tool_dir)
.await
.context("Failed to scan tools directory")?;
if scan_result.tools.is_empty() {
bail!(
"No TypeScript tool files found in {}\n\
Run 'mcp-execution-cli generate --from-config {}' first.",
tool_dir.display(),
server
);
}
info!(
"Verified {} tool files against sidecar",
scan_result.tools.len()
);
Ok(scan_result)
}
fn prepare_skill_context(
server: &str,
tools: &[ParsedToolFile],
hints: Vec<String>,
skill_name: Option<&str>,
output_path: Option<PathBuf>,
) -> Result<(GenerateSkillResult, Option<PathBuf>)> {
let hints_ref: Option<Vec<String>> = if hints.is_empty() { None } else { Some(hints) };
if let Some(name) = skill_name {
validate_skill_name(name)
.map_err(|e| CoreError::InvalidArgument(format!("Invalid skill name: {e}")))?;
}
let context = build_skill_context(server, tools, hints_ref.as_deref(), skill_name);
if let Some(path) = &output_path {
validate_output_path(path)?;
}
Ok((context, output_path))
}
async fn write_skill_md(rendered: &str, output_path: &Path) -> Result<()> {
let tmp_path = output_path.with_added_extension("tmp");
mcp_execution_core::write_confined_file(&tmp_path, rendered.as_bytes())
.await
.with_context(|| format!("failed to write temp file: {}", tmp_path.display()))?;
std::fs::rename(&tmp_path, output_path)
.with_context(|| format!("failed to rename to: {}", output_path.display()))?;
Ok(())
}
async fn resolve_default_output_path(skills_dir: &Path, server: &str) -> Result<PathBuf> {
let segment_dir =
mcp_execution_core::resolve_confined_path(skills_dir, server, Path::new(""), None)
.await
.with_context(|| {
format!("failed to resolve default skills directory for server: {server}")
})?;
Ok(segment_dir.join("SKILL.md"))
}
fn resolve_servers_dir(servers_dir: Option<&Path>) -> Result<PathBuf> {
if let Some(dir) = servers_dir {
if let Some(stripped) = dir.to_str().and_then(|s| s.strip_prefix("~/")) {
let home = dirs::home_dir().context("Could not determine home directory")?;
Ok(home.join(stripped))
} else {
Ok(dir.to_path_buf())
}
} else {
let home = dirs::home_dir().context("Could not determine home directory")?;
Ok(home.join(DEFAULT_SERVERS_DIR))
}
}
fn resolve_skills_dir() -> Result<PathBuf> {
let home = dirs::home_dir().context("Could not determine home directory")?;
Ok(home.join(DEFAULT_SKILLS_DIR))
}
fn validate_path_security(path: &Path, base: &Path) -> Result<PathBuf> {
if has_path_traversal(path) {
return Err(CoreError::SecurityViolation {
reason: format!("path traversal detected: {}", path.display()),
}
.into());
}
if !path.exists() {
return Ok(path.to_path_buf());
}
let canonical_path = path
.canonicalize()
.with_context(|| format!("Failed to canonicalize path: {}", path.display()))?;
let canonical_base = if base.exists() {
base.canonicalize()
.with_context(|| format!("Failed to canonicalize base: {}", base.display()))?
} else {
return Ok(path.to_path_buf());
};
if !canonical_path.starts_with(&canonical_base) {
return Err(CoreError::SecurityViolation {
reason: format!(
"path {} is outside base directory {}",
canonical_path.display(),
canonical_base.display()
),
}
.into());
}
Ok(canonical_path)
}
fn validate_output_path(path: &Path) -> Result<()> {
if has_path_traversal(path) {
return Err(CoreError::SecurityViolation {
reason: format!(
"invalid output path (path traversal detected): {}",
path.display()
),
}
.into());
}
Ok(())
}
fn has_path_traversal(path: &Path) -> bool {
mcp_execution_core::contains_parent_dir(path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::formatters::format_output;
use mcp_execution_core::metadata::{
METADATA_FILE_NAME, METADATA_SCHEMA_VERSION, ParameterMetadata, ServerMetadata,
ToolMetadata,
};
use mcp_execution_core::provenance::GenerationProvenance;
use mcp_execution_core::{ServerConfig, ServerId, ToolName};
use tempfile::TempDir;
fn test_provenance() -> GenerationProvenance {
let config = ServerConfig::builder()
.command("test-command".to_string())
.build()
.unwrap();
GenerationProvenance::capture(&config, &[])
}
fn write_meta_sidecar(server_dir: &Path, server_id: &str, tool_name: &str) {
let meta = ServerMetadata {
schema_version: METADATA_SCHEMA_VERSION,
server_id: ServerId::new(server_id).unwrap(),
server_name: server_id.to_string(),
server_version: "1.0.0".to_string(),
tools: vec![ToolMetadata {
name: ToolName::new(tool_name).unwrap(),
typescript_name: tool_name.to_string(),
category: Some("testing".to_string()),
keywords: vec!["test".to_string()],
description: Some(format!("Test tool: {tool_name}")),
parameters: vec![ParameterMetadata {
name: "input".to_string(),
typescript_type: "string".to_string(),
required: true,
description: Some("Test input".to_string()),
}],
}],
provenance: test_provenance(),
};
let content = serde_json::to_string_pretty(&meta).unwrap();
std::fs::write(server_dir.join(METADATA_FILE_NAME), content).unwrap();
std::fs::write(server_dir.join(format!("{tool_name}.ts")), "export {}").unwrap();
}
#[test]
fn test_resolve_servers_dir_default() {
let result = resolve_servers_dir(None);
assert!(result.is_ok());
let path = result.unwrap();
assert!(path.to_string_lossy().contains(".claude/servers"));
}
#[test]
fn test_resolve_servers_dir_custom() {
let custom = PathBuf::from("/custom/servers");
let result = resolve_servers_dir(Some(&custom));
assert!(result.is_ok());
assert_eq!(result.unwrap(), custom);
}
#[test]
fn test_resolve_servers_dir_tilde() {
let custom = PathBuf::from("~/custom/servers");
let result = resolve_servers_dir(Some(&custom));
assert!(result.is_ok());
let path = result.unwrap();
assert!(!path.to_string_lossy().starts_with('~'));
assert!(path.to_string_lossy().contains("custom/servers"));
}
#[test]
fn test_validate_path_security_valid() {
let temp = TempDir::new().unwrap();
let base = temp.path();
let subdir = base.join("server");
std::fs::create_dir(&subdir).unwrap();
let result = validate_path_security(&subdir, base);
assert!(result.is_ok());
}
#[test]
fn test_validate_path_security_traversal() {
let temp = TempDir::new().unwrap();
let base = temp.path();
let evil_path = base.join("..").join("etc").join("passwd");
let result = validate_path_security(&evil_path, base);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("traversal"));
assert!(matches!(
err.downcast_ref::<CoreError>(),
Some(CoreError::SecurityViolation { .. })
));
}
#[test]
fn test_validate_path_security_nonexistent() {
let temp = TempDir::new().unwrap();
let base = temp.path();
let new_path = base.join("new-server");
let result = validate_path_security(&new_path, base);
assert!(result.is_ok());
}
#[test]
fn test_resolve_skills_dir() {
let result = resolve_skills_dir();
assert!(result.is_ok());
let path = result.unwrap();
assert!(path.to_string_lossy().contains(".claude/skills"));
}
#[test]
fn test_has_path_traversal() {
assert!(has_path_traversal(Path::new("../etc/passwd")));
assert!(has_path_traversal(Path::new("/tmp/../etc/passwd")));
assert!(has_path_traversal(Path::new("foo/../../bar")));
assert!(!has_path_traversal(Path::new("/etc/passwd")));
assert!(!has_path_traversal(Path::new("foo/bar/baz")));
assert!(!has_path_traversal(Path::new("./foo/bar")));
assert!(!has_path_traversal(Path::new("...")));
assert!(!has_path_traversal(Path::new("..foo")));
}
#[test]
fn test_validate_output_path_valid() {
assert!(validate_output_path(Path::new("/tmp/skill.md")).is_ok());
assert!(validate_output_path(Path::new("~/.claude/skills/github/SKILL.md")).is_ok());
assert!(validate_output_path(Path::new("./output.md")).is_ok());
}
#[test]
fn test_validate_output_path_traversal() {
let result = validate_output_path(Path::new("../../../etc/passwd"));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("path traversal"));
let result = validate_output_path(Path::new("/tmp/../etc/passwd"));
assert!(result.is_err());
}
#[tokio::test]
async fn test_run_output_path_traversal() {
let temp = TempDir::new().unwrap();
let server_dir = temp.path().join("github");
std::fs::create_dir(&server_dir).unwrap();
write_meta_sidecar(&server_dir, "github", "test");
let evil_output = temp
.path()
.join("..")
.join("..")
.join("etc")
.join("evil.md");
let result = run(
"github".to_string(),
Some(temp.path().to_path_buf()),
Some(evil_output),
None,
vec![],
false,
OutputFormat::Json,
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("path traversal"));
}
#[tokio::test]
async fn test_run_invalid_server_id() {
let result = run(
"INVALID_ID".to_string(), None,
None,
None,
vec![],
false,
OutputFormat::Json,
)
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Invalid server ID"));
assert!(matches!(
err.downcast_ref::<CoreError>(),
Some(CoreError::InvalidArgument(_))
));
}
#[tokio::test]
async fn test_run_server_not_found() {
let temp = TempDir::new().unwrap();
let result = run(
"nonexistent-server".to_string(),
Some(temp.path().to_path_buf()),
None,
None,
vec![],
false,
OutputFormat::Json,
)
.await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Server directory not found")
);
}
#[tokio::test]
async fn test_run_no_typescript_files() {
let temp = TempDir::new().unwrap();
let server_dir = temp.path().join("empty-server");
std::fs::create_dir(&server_dir).unwrap();
let result = run(
"empty-server".to_string(),
Some(temp.path().to_path_buf()),
None,
None,
vec![],
false,
OutputFormat::Json,
)
.await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Failed to scan tools directory")
);
}
#[tokio::test]
async fn test_run_with_valid_typescript_files() {
let temp = TempDir::new().unwrap();
let server_dir = temp.path().join("test-server");
std::fs::create_dir(&server_dir).unwrap();
write_meta_sidecar(&server_dir, "test-server", "test_tool");
let output_path = temp.path().join("SKILL.md");
let result = run(
"test-server".to_string(),
Some(temp.path().to_path_buf()),
Some(output_path.clone()),
None,
vec![],
false,
OutputFormat::Json,
)
.await;
assert!(
result.is_ok(),
"Expected success but got: {:?}",
result.err()
);
assert!(output_path.exists(), "SKILL.md must be written to disk");
let content = std::fs::read_to_string(&output_path).unwrap();
assert!(
content.starts_with("---\n"),
"SKILL.md must start with YAML frontmatter"
);
}
#[tokio::test]
async fn test_run_creates_nested_parent_directory_for_custom_output_path() {
let temp = TempDir::new().unwrap();
let server_dir = temp.path().join("test-server");
std::fs::create_dir(&server_dir).unwrap();
write_meta_sidecar(&server_dir, "test-server", "test_tool");
let output_path = temp.path().join("nested").join("dir").join("SKILL.md");
assert!(!output_path.parent().unwrap().exists());
let result = run(
"test-server".to_string(),
Some(temp.path().to_path_buf()),
Some(output_path.clone()),
None,
vec![],
false,
OutputFormat::Json,
)
.await;
assert!(
result.is_ok(),
"Expected success but got: {:?}",
result.err()
);
assert!(output_path.exists(), "SKILL.md must be written to disk");
}
#[tokio::test]
async fn test_run_with_orphan_ts_file_succeeds() {
let temp = TempDir::new().unwrap();
let server_dir = temp.path().join("test-server");
std::fs::create_dir(&server_dir).unwrap();
write_meta_sidecar(&server_dir, "test-server", "test_tool");
std::fs::write(server_dir.join("orphanTool.ts"), "export {}").unwrap();
let output_path = temp.path().join("SKILL.md");
let result = run(
"test-server".to_string(),
Some(temp.path().to_path_buf()),
Some(output_path.clone()),
None,
vec![],
false,
OutputFormat::Json,
)
.await;
assert!(
result.is_ok(),
"an orphaned .ts file must not fail the run: {:?}",
result.err()
);
assert!(output_path.exists(), "SKILL.md must still be written");
}
#[test]
fn test_skill_write_result_json_includes_warnings() {
let result = SkillWriteResult {
success: true,
output_path: "/tmp/SKILL.md".to_string(),
bytes_written: 42,
tool_count: 1,
warnings: vec![
"'orphanTool.ts' is not referenced by _meta.json and was excluded from SKILL.md \
(re-run 'generate' to refresh the sidecar)"
.to_string(),
],
};
let output = format_output(&result, OutputFormat::Json).unwrap();
assert!(
output.contains("\"warnings\""),
"JSON output must contain a warnings field: {output}"
);
assert!(
output.contains("orphanTool.ts"),
"warnings must name the excluded file: {output}"
);
}
#[tokio::test]
async fn test_run_with_custom_skill_name() {
let temp = TempDir::new().unwrap();
let server_dir = temp.path().join("github");
std::fs::create_dir(&server_dir).unwrap();
write_meta_sidecar(&server_dir, "github", "create_issue");
let output_path = temp.path().join("SKILL.md");
let result = run(
"github".to_string(),
Some(temp.path().to_path_buf()),
Some(output_path.clone()),
Some("github-advanced".to_string()),
vec![],
false,
OutputFormat::Json,
)
.await;
assert!(
result.is_ok(),
"Expected success but got: {:?}",
result.err()
);
let written = std::fs::read_to_string(&output_path).unwrap();
assert!(
written.contains("name: github-advanced"),
"written SKILL.md must use the custom skill name: {written}"
);
}
#[test]
fn test_prepare_skill_context_with_custom_skill_name_reflects_it_in_generation_prompt() {
let tools = vec![];
let (context, _output_path) =
prepare_skill_context("github", &tools, vec![], Some("github-advanced"), None).unwrap();
assert_eq!(context.skill_name, "github-advanced");
assert!(
context.generation_prompt.contains("github-advanced"),
"generation_prompt must reflect the custom skill_name, not the default: {}",
context.generation_prompt
);
assert!(!context.generation_prompt.contains("github-progressive"));
}
#[test]
fn test_prepare_skill_context_does_not_overwrite_default_output_path_hint() {
let tools = vec![];
let custom_output = PathBuf::from("/tmp/custom/SKILL.md");
let (context, resolved_output_path) =
prepare_skill_context("github", &tools, vec![], None, Some(custom_output.clone()))
.unwrap();
assert_eq!(resolved_output_path, Some(custom_output));
assert_eq!(
context.default_output_path_hint, "~/.claude/skills/github/SKILL.md",
"default_output_path_hint must stay build_skill_context's own default, not be \
overwritten with the resolved write path"
);
}
#[test]
fn test_prepare_skill_context_surfaces_use_case_hint_cap_warning() {
let tools = vec![];
let hints: Vec<String> = (0..(mcp_execution_skill::types::MAX_USE_CASE_HINTS + 2))
.map(|i| format!("hint-{i}"))
.collect();
let (context, _output_path) =
prepare_skill_context("github", &tools, hints, None, None).unwrap();
assert_eq!(context.warnings.len(), 1, "{:?}", context.warnings);
assert!(
context.warnings[0].contains("dropped"),
"{:?}",
context.warnings
);
}
#[tokio::test]
async fn test_run_rejects_oversized_skill_name() {
let temp = TempDir::new().unwrap();
let server_dir = temp.path().join("github");
std::fs::create_dir(&server_dir).unwrap();
write_meta_sidecar(&server_dir, "github", "create_issue");
let output_path = temp.path().join("SKILL.md");
let oversized_name = "a".repeat(mcp_execution_skill::MAX_SKILL_NAME_LENGTH + 1);
let result = run(
"github".to_string(),
Some(temp.path().to_path_buf()),
Some(output_path.clone()),
Some(oversized_name),
vec![],
false,
OutputFormat::Json,
)
.await;
assert!(result.is_err(), "oversized skill_name must be rejected");
assert!(
!output_path.exists(),
"no SKILL.md should be written when skill_name validation fails"
);
}
#[tokio::test]
async fn test_run_with_hints() {
let temp = TempDir::new().unwrap();
let server_dir = temp.path().join("github");
std::fs::create_dir(&server_dir).unwrap();
write_meta_sidecar(&server_dir, "github", "list_prs");
let output_path = temp.path().join("SKILL.md");
let result = run(
"github".to_string(),
Some(temp.path().to_path_buf()),
Some(output_path.clone()),
None,
vec!["code review".to_string(), "CI/CD".to_string()],
false,
OutputFormat::Json,
)
.await;
assert!(
result.is_ok(),
"Expected success but got: {:?}",
result.err()
);
let written = std::fs::read_to_string(&output_path).unwrap();
assert!(
written.contains("## Use Cases"),
"written SKILL.md must include a Use Cases section: {written}"
);
assert!(written.contains("code review"), "{written}");
assert!(written.contains("CI/CD"), "{written}");
}
#[tokio::test]
async fn test_run_without_hints_omits_use_cases_section() {
let temp = TempDir::new().unwrap();
let server_dir = temp.path().join("github");
std::fs::create_dir(&server_dir).unwrap();
write_meta_sidecar(&server_dir, "github", "list_prs");
let output_path = temp.path().join("SKILL.md");
let result = run(
"github".to_string(),
Some(temp.path().to_path_buf()),
Some(output_path.clone()),
None,
vec![],
false,
OutputFormat::Json,
)
.await;
assert!(
result.is_ok(),
"Expected success but got: {:?}",
result.err()
);
let written = std::fs::read_to_string(&output_path).unwrap();
assert!(
!written.contains("## Use Cases"),
"written SKILL.md must not have a Use Cases section without --hint: {written}"
);
}
#[tokio::test]
async fn test_run_output_exists_no_overwrite() {
let temp = TempDir::new().unwrap();
let server_dir = temp.path().join("github");
std::fs::create_dir(&server_dir).unwrap();
write_meta_sidecar(&server_dir, "github", "test");
let output_path = temp.path().join("SKILL.md");
std::fs::write(&output_path, "existing content").unwrap();
let result = run(
"github".to_string(),
Some(temp.path().to_path_buf()),
Some(output_path),
None,
vec![],
false, OutputFormat::Json,
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("already exists"));
}
#[tokio::test]
async fn test_run_output_exists_with_overwrite() {
let temp = TempDir::new().unwrap();
let server_dir = temp.path().join("github");
std::fs::create_dir(&server_dir).unwrap();
write_meta_sidecar(&server_dir, "github", "test");
let output_path = temp.path().join("SKILL.md");
std::fs::write(&output_path, "existing content").unwrap();
let result = run(
"github".to_string(),
Some(temp.path().to_path_buf()),
Some(output_path),
None,
vec![],
true, OutputFormat::Json,
)
.await;
assert!(
result.is_ok(),
"Expected success but got: {:?}",
result.err()
);
}
#[tokio::test]
async fn test_run_all_output_formats() {
let temp = TempDir::new().unwrap();
let server_dir = temp.path().join("test");
std::fs::create_dir(&server_dir).unwrap();
write_meta_sidecar(&server_dir, "test", "test");
for format in [OutputFormat::Json, OutputFormat::Text, OutputFormat::Pretty] {
let output_path = temp.path().join(format!("SKILL-{format}.md"));
let result = run(
"test".to_string(),
Some(temp.path().to_path_buf()),
Some(output_path),
None,
vec![],
false,
format,
)
.await;
assert!(
result.is_ok(),
"Format {:?} should succeed: {:?}",
format,
result.err()
);
}
}
#[tokio::test]
async fn test_run_stale_metadata_fails_instead_of_silently_succeeding() {
let temp = TempDir::new().unwrap();
let server_dir = temp.path().join("github");
std::fs::create_dir(&server_dir).unwrap();
let meta = ServerMetadata {
schema_version: METADATA_SCHEMA_VERSION,
server_id: ServerId::new("github").unwrap(),
server_name: "GitHub".to_string(),
server_version: "1.0.0".to_string(),
tools: vec![
ToolMetadata {
name: ToolName::new("create_issue").unwrap(),
typescript_name: "createIssue".to_string(),
category: Some("issues".to_string()),
keywords: vec!["create".to_string()],
description: Some("Create an issue".to_string()),
parameters: vec![ParameterMetadata {
name: "title".to_string(),
typescript_type: "string".to_string(),
required: true,
description: Some("Issue title".to_string()),
}],
},
ToolMetadata {
name: ToolName::new("list_repos").unwrap(),
typescript_name: "listRepos".to_string(),
category: Some("repos".to_string()),
keywords: vec!["list".to_string()],
description: Some("List repos".to_string()),
parameters: vec![],
},
],
provenance: test_provenance(),
};
let content = serde_json::to_string_pretty(&meta).unwrap();
std::fs::write(server_dir.join(METADATA_FILE_NAME), content).unwrap();
std::fs::write(server_dir.join("listRepos.ts"), "export {}").unwrap();
std::fs::write(server_dir.join("orphanTool.ts"), "export {}").unwrap();
let output_path = temp.path().join("SKILL.md");
let result = run(
"github".to_string(),
Some(temp.path().to_path_buf()),
Some(output_path.clone()),
None,
vec![],
false,
OutputFormat::Json,
)
.await;
assert!(
result.is_err(),
"drifted sidecar must fail instead of silently succeeding"
);
let err = result.unwrap_err();
let message = format!("{err:?}");
assert!(
message.contains("create_issue") || message.contains("createIssue.ts"),
"error must identify the tool/file with the missing .ts: {message}"
);
assert!(
!output_path.exists(),
"SKILL.md must not be written when the sidecar is stale"
);
}
#[tokio::test]
async fn test_run_path_traversal_server_id() {
let temp = TempDir::new().unwrap();
let result = run(
"../etc".to_string(),
Some(temp.path().to_path_buf()),
None,
None,
vec![],
false,
OutputFormat::Json,
)
.await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Invalid server ID")
);
}
#[tokio::test]
async fn test_write_skill_md_writes_content() {
let base = TempDir::new().unwrap();
let output_path = base.path().join("SKILL.md");
write_skill_md("rendered content", &output_path)
.await
.unwrap();
assert_eq!(
std::fs::read_to_string(&output_path).unwrap(),
"rendered content"
);
assert!(!output_path.with_added_extension("tmp").exists());
}
#[tokio::test]
async fn test_write_skill_md_overwrites_existing_regular_file() {
let base = TempDir::new().unwrap();
let output_path = base.path().join("SKILL.md");
std::fs::write(&output_path, "old content").unwrap();
write_skill_md("new content", &output_path).await.unwrap();
assert_eq!(
std::fs::read_to_string(&output_path).unwrap(),
"new content"
);
}
#[tokio::test]
#[cfg(unix)]
async fn test_write_skill_md_rejects_symlink_planted_at_tmp_path() {
let base = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let outside_file = outside.path().join("real.md");
let output_path = base.path().join("SKILL.md");
let tmp_path = output_path.with_added_extension("tmp");
std::os::unix::fs::symlink(&outside_file, &tmp_path).unwrap();
let result = write_skill_md("attacker-controlled", &output_path).await;
assert!(result.is_err());
assert!(!outside_file.exists());
assert!(!output_path.exists());
}
#[tokio::test]
async fn test_resolve_default_output_path_creates_and_confines_segment_directory() {
let skills_dir = TempDir::new().unwrap();
let resolved = resolve_default_output_path(skills_dir.path(), "my-server")
.await
.unwrap();
let canonical_base = skills_dir.path().canonicalize().unwrap();
assert_eq!(resolved, canonical_base.join("my-server").join("SKILL.md"));
assert!(canonical_base.join("my-server").is_dir());
}
#[tokio::test]
#[cfg(unix)]
async fn test_resolve_default_output_path_rejects_symlinked_segment_directory() {
let skills_dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
std::os::unix::fs::symlink(outside.path(), skills_dir.path().join("evil-server")).unwrap();
let err = resolve_default_output_path(skills_dir.path(), "evil-server")
.await
.unwrap_err();
assert!(format!("{err:#}").contains("symlink"), "{err:?}");
assert!(!outside.path().join("SKILL.md").exists());
}
#[tokio::test]
#[cfg(unix)]
async fn test_default_path_symlinked_skill_md_is_replaced_not_rejected() {
let skills_dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let outside_file = outside.path().join("real.md");
std::fs::write(&outside_file, "linked content").unwrap();
let server_dir = skills_dir.path().join("my-server");
std::fs::create_dir_all(&server_dir).unwrap();
std::os::unix::fs::symlink(&outside_file, server_dir.join("SKILL.md")).unwrap();
let output_path = resolve_default_output_path(skills_dir.path(), "my-server")
.await
.unwrap();
write_skill_md("new content", &output_path).await.unwrap();
assert!(!output_path.is_symlink());
assert_eq!(
std::fs::read_to_string(&output_path).unwrap(),
"new content"
);
assert_eq!(
std::fs::read_to_string(&outside_file).unwrap(),
"linked content"
);
}
}