mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-18 03:29:11 +02:00
666 lines
23 KiB
Python
666 lines
23 KiB
Python
"""Tests for SkillProvider, SkillsDirectoryProvider, and ClaudeSkillsProvider."""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from mcp.types import TextResourceContents
|
|
from pydantic import AnyUrl
|
|
|
|
from fastmcp import Client, FastMCP
|
|
from fastmcp.server.providers.skills import (
|
|
ClaudeSkillsProvider,
|
|
SkillProvider,
|
|
SkillsDirectoryProvider,
|
|
SkillsProvider,
|
|
)
|
|
from fastmcp.server.providers.skills._common import parse_frontmatter
|
|
|
|
|
|
class TestParseFrontmatter:
|
|
def test_no_frontmatter(self):
|
|
content = "# Just markdown\n\nSome content."
|
|
frontmatter, body = parse_frontmatter(content)
|
|
assert frontmatter == {}
|
|
assert body == content
|
|
|
|
def test_basic_frontmatter(self):
|
|
content = """---
|
|
description: A test skill
|
|
version: "1.0.0"
|
|
---
|
|
|
|
# Skill Content
|
|
"""
|
|
frontmatter, body = parse_frontmatter(content)
|
|
assert frontmatter["description"] == "A test skill"
|
|
assert frontmatter["version"] == "1.0.0"
|
|
assert body.strip().startswith("# Skill Content")
|
|
|
|
def test_frontmatter_with_tags_list(self):
|
|
content = """---
|
|
description: Test
|
|
tags: [tag1, tag2, tag3]
|
|
---
|
|
|
|
Content
|
|
"""
|
|
frontmatter, body = parse_frontmatter(content)
|
|
assert frontmatter["tags"] == ["tag1", "tag2", "tag3"]
|
|
|
|
def test_frontmatter_with_quoted_strings(self):
|
|
content = """---
|
|
description: "A skill with quotes"
|
|
version: '2.0.0'
|
|
---
|
|
|
|
Content
|
|
"""
|
|
frontmatter, body = parse_frontmatter(content)
|
|
assert frontmatter["description"] == "A skill with quotes"
|
|
assert frontmatter["version"] == "2.0.0"
|
|
|
|
|
|
class TestSkillProvider:
|
|
"""Tests for SkillProvider - single skill folder."""
|
|
|
|
@pytest.fixture
|
|
def single_skill_dir(self, tmp_path: Path) -> Path:
|
|
"""Create a single skill directory with files."""
|
|
skill_dir = tmp_path / "my-skill"
|
|
skill_dir.mkdir()
|
|
(skill_dir / "SKILL.md").write_text(
|
|
"""---
|
|
description: A test skill
|
|
version: "1.0.0"
|
|
---
|
|
|
|
# My Skill
|
|
|
|
This is my skill content.
|
|
"""
|
|
)
|
|
(skill_dir / "reference.md").write_text("# Reference\n\nExtra docs.")
|
|
(skill_dir / "scripts").mkdir()
|
|
(skill_dir / "scripts" / "helper.py").write_text('print("helper")')
|
|
return skill_dir
|
|
|
|
def test_loads_skill_at_init(self, single_skill_dir: Path):
|
|
provider = SkillProvider(skill_path=single_skill_dir)
|
|
assert provider.skill_info.name == "my-skill"
|
|
assert provider.skill_info.description == "A test skill"
|
|
assert len(provider.skill_info.files) == 3
|
|
|
|
def test_raises_if_directory_missing(self, tmp_path: Path):
|
|
with pytest.raises(FileNotFoundError, match="Skill directory not found"):
|
|
SkillProvider(skill_path=tmp_path / "nonexistent")
|
|
|
|
def test_raises_if_main_file_missing(self, tmp_path: Path):
|
|
skill_dir = tmp_path / "no-main"
|
|
skill_dir.mkdir()
|
|
with pytest.raises(FileNotFoundError, match="Main skill file not found"):
|
|
SkillProvider(skill_path=skill_dir)
|
|
|
|
async def test_list_resources_default_template_mode(self, single_skill_dir: Path):
|
|
"""In template mode (default), only main file and manifest are resources."""
|
|
provider = SkillProvider(skill_path=single_skill_dir)
|
|
resources = await provider.list_resources()
|
|
|
|
assert len(resources) == 2
|
|
names = {r.name for r in resources}
|
|
assert "my-skill/SKILL.md" in names
|
|
assert "my-skill/_manifest" in names
|
|
|
|
async def test_list_resources_supporting_files_as_resources(
|
|
self, single_skill_dir: Path
|
|
):
|
|
"""In resources mode, supporting files are also exposed as resources."""
|
|
provider = SkillProvider(
|
|
skill_path=single_skill_dir, supporting_files="resources"
|
|
)
|
|
resources = await provider.list_resources()
|
|
|
|
# 2 standard + 2 supporting files
|
|
assert len(resources) == 4
|
|
names = {r.name for r in resources}
|
|
assert "my-skill/SKILL.md" in names
|
|
assert "my-skill/_manifest" in names
|
|
assert "my-skill/reference.md" in names
|
|
assert "my-skill/scripts/helper.py" in names
|
|
|
|
async def test_list_templates_default_mode(self, single_skill_dir: Path):
|
|
"""In template mode (default), one template is exposed."""
|
|
provider = SkillProvider(skill_path=single_skill_dir)
|
|
templates = await provider.list_resource_templates()
|
|
|
|
assert len(templates) == 1
|
|
assert templates[0].name == "my-skill_files"
|
|
|
|
async def test_list_templates_resources_mode(self, single_skill_dir: Path):
|
|
"""In resources mode, no templates are exposed."""
|
|
provider = SkillProvider(
|
|
skill_path=single_skill_dir, supporting_files="resources"
|
|
)
|
|
templates = await provider.list_resource_templates()
|
|
|
|
assert templates == []
|
|
|
|
async def test_read_main_file(self, single_skill_dir: Path):
|
|
mcp = FastMCP("Test")
|
|
mcp.add_provider(SkillProvider(skill_path=single_skill_dir))
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.read_resource(AnyUrl("skill://my-skill/SKILL.md"))
|
|
assert len(result) == 1
|
|
assert isinstance(result[0], TextResourceContents)
|
|
assert "# My Skill" in result[0].text
|
|
|
|
async def test_read_manifest(self, single_skill_dir: Path):
|
|
mcp = FastMCP("Test")
|
|
mcp.add_provider(SkillProvider(skill_path=single_skill_dir))
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.read_resource(AnyUrl("skill://my-skill/_manifest"))
|
|
manifest = json.loads(result[0].text)
|
|
assert manifest["skill"] == "my-skill"
|
|
assert len(manifest["files"]) == 3
|
|
paths = {f["path"] for f in manifest["files"]}
|
|
assert "SKILL.md" in paths
|
|
assert "reference.md" in paths
|
|
assert "scripts/helper.py" in paths
|
|
|
|
async def test_manifest_ignores_symlink_target_outside_skill(self, tmp_path: Path):
|
|
skill_dir = tmp_path / "symlinked-skill"
|
|
skill_dir.mkdir()
|
|
(skill_dir / "SKILL.md").write_text("# Skill\n")
|
|
|
|
outside_file = tmp_path / "outside.txt"
|
|
outside_file.write_text("secret")
|
|
(skill_dir / "leak.txt").symlink_to(outside_file)
|
|
|
|
mcp = FastMCP("Test")
|
|
mcp.add_provider(SkillProvider(skill_path=skill_dir))
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.read_resource(
|
|
AnyUrl("skill://symlinked-skill/_manifest")
|
|
)
|
|
manifest = json.loads(result[0].text)
|
|
|
|
paths = {f["path"] for f in manifest["files"]}
|
|
assert "SKILL.md" in paths
|
|
assert "leak.txt" not in paths
|
|
|
|
async def test_read_supporting_file_via_template(self, single_skill_dir: Path):
|
|
mcp = FastMCP("Test")
|
|
mcp.add_provider(SkillProvider(skill_path=single_skill_dir))
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.read_resource(AnyUrl("skill://my-skill/reference.md"))
|
|
assert "# Reference" in result[0].text
|
|
|
|
async def test_read_supporting_file_via_resource_mode(self, single_skill_dir: Path):
|
|
mcp = FastMCP("Test")
|
|
mcp.add_provider(
|
|
SkillProvider(skill_path=single_skill_dir, supporting_files="resources")
|
|
)
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.read_resource(AnyUrl("skill://my-skill/reference.md"))
|
|
assert "# Reference" in result[0].text
|
|
|
|
async def test_skill_resource_meta(self, single_skill_dir: Path):
|
|
"""SkillResource populates meta with skill name and is_manifest."""
|
|
provider = SkillProvider(skill_path=single_skill_dir)
|
|
resources = await provider.list_resources()
|
|
|
|
by_name = {r.name: r for r in resources}
|
|
|
|
main_meta = by_name["my-skill/SKILL.md"].get_meta()
|
|
assert main_meta["fastmcp"]["skill"] == {
|
|
"name": "my-skill",
|
|
"is_manifest": False,
|
|
}
|
|
|
|
manifest_meta = by_name["my-skill/_manifest"].get_meta()
|
|
assert manifest_meta["fastmcp"]["skill"] == {
|
|
"name": "my-skill",
|
|
"is_manifest": True,
|
|
}
|
|
|
|
async def test_skill_file_resource_meta(self, single_skill_dir: Path):
|
|
"""SkillFileResource populates meta with skill name."""
|
|
provider = SkillProvider(
|
|
skill_path=single_skill_dir, supporting_files="resources"
|
|
)
|
|
resources = await provider.list_resources()
|
|
|
|
by_name = {r.name: r for r in resources}
|
|
file_meta = by_name["my-skill/reference.md"].get_meta()
|
|
assert file_meta["fastmcp"]["skill"] == {"name": "my-skill"}
|
|
|
|
async def test_skill_meta_survives_mounting(self, single_skill_dir: Path):
|
|
"""Skill metadata in _meta is preserved when accessed through a mounted server."""
|
|
child = FastMCP("child")
|
|
child.add_provider(SkillProvider(skill_path=single_skill_dir))
|
|
|
|
parent = FastMCP("parent")
|
|
parent.mount(child, "skills")
|
|
|
|
resources = await parent.list_resources()
|
|
by_name = {r.name: r for r in resources}
|
|
|
|
main_meta = by_name["my-skill/SKILL.md"].get_meta()
|
|
assert main_meta["fastmcp"]["skill"] == {
|
|
"name": "my-skill",
|
|
"is_manifest": False,
|
|
}
|
|
|
|
manifest_meta = by_name["my-skill/_manifest"].get_meta()
|
|
assert manifest_meta["fastmcp"]["skill"] == {
|
|
"name": "my-skill",
|
|
"is_manifest": True,
|
|
}
|
|
|
|
|
|
class TestSkillsDirectoryProvider:
|
|
"""Tests for SkillsDirectoryProvider - scans directory for skill folders."""
|
|
|
|
@pytest.fixture
|
|
def skills_dir(self, tmp_path: Path) -> Path:
|
|
"""Create a test skills directory with sample skills."""
|
|
skills_root = tmp_path / "skills"
|
|
skills_root.mkdir()
|
|
|
|
# Create a simple skill
|
|
simple_skill = skills_root / "simple-skill"
|
|
simple_skill.mkdir()
|
|
(simple_skill / "SKILL.md").write_text(
|
|
"""---
|
|
description: A simple test skill
|
|
version: "1.0.0"
|
|
---
|
|
|
|
# Simple Skill
|
|
|
|
This is a simple skill for testing.
|
|
"""
|
|
)
|
|
|
|
# Create a skill with supporting files
|
|
complex_skill = skills_root / "complex-skill"
|
|
complex_skill.mkdir()
|
|
(complex_skill / "SKILL.md").write_text(
|
|
"""---
|
|
description: A complex skill with supporting files
|
|
---
|
|
|
|
# Complex Skill
|
|
|
|
See [reference](reference.md) for more details.
|
|
"""
|
|
)
|
|
(complex_skill / "reference.md").write_text(
|
|
"""# Reference
|
|
|
|
Additional documentation.
|
|
"""
|
|
)
|
|
(complex_skill / "scripts").mkdir()
|
|
(complex_skill / "scripts" / "helper.py").write_text(
|
|
'print("Hello from helper")'
|
|
)
|
|
|
|
return skills_root
|
|
|
|
async def test_list_resources_discovers_skills(self, skills_dir: Path):
|
|
provider = SkillsDirectoryProvider(roots=skills_dir)
|
|
resources = await provider.list_resources()
|
|
|
|
# Should have 2 resources per skill (main file + manifest)
|
|
assert len(resources) == 4
|
|
|
|
# Check resource names
|
|
resource_names = {r.name for r in resources}
|
|
assert "simple-skill/SKILL.md" in resource_names
|
|
assert "simple-skill/_manifest" in resource_names
|
|
assert "complex-skill/SKILL.md" in resource_names
|
|
assert "complex-skill/_manifest" in resource_names
|
|
|
|
async def test_list_resources_includes_descriptions(self, skills_dir: Path):
|
|
provider = SkillsDirectoryProvider(roots=skills_dir)
|
|
resources = await provider.list_resources()
|
|
|
|
# Find the simple-skill main resource
|
|
simple_skill = next(r for r in resources if r.name == "simple-skill/SKILL.md")
|
|
assert simple_skill.description == "A simple test skill"
|
|
|
|
async def test_read_main_skill_file(self, skills_dir: Path):
|
|
mcp = FastMCP("Test")
|
|
mcp.add_provider(SkillsDirectoryProvider(roots=skills_dir))
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.read_resource(AnyUrl("skill://simple-skill/SKILL.md"))
|
|
assert len(result) == 1
|
|
assert isinstance(result[0], TextResourceContents)
|
|
assert "# Simple Skill" in result[0].text
|
|
|
|
async def test_read_manifest(self, skills_dir: Path):
|
|
mcp = FastMCP("Test")
|
|
mcp.add_provider(SkillsDirectoryProvider(roots=skills_dir))
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.read_resource(
|
|
AnyUrl("skill://complex-skill/_manifest")
|
|
)
|
|
assert len(result) == 1
|
|
assert isinstance(result[0], TextResourceContents)
|
|
|
|
manifest = json.loads(result[0].text)
|
|
assert manifest["skill"] == "complex-skill"
|
|
assert len(manifest["files"]) == 3 # SKILL.md, reference.md, helper.py
|
|
|
|
# Check file paths
|
|
paths = {f["path"] for f in manifest["files"]}
|
|
assert "SKILL.md" in paths
|
|
assert "reference.md" in paths
|
|
assert "scripts/helper.py" in paths
|
|
|
|
# Check hashes are present
|
|
for file_info in manifest["files"]:
|
|
assert file_info["hash"].startswith("sha256:")
|
|
assert file_info["size"] > 0
|
|
|
|
async def test_list_resource_templates(self, skills_dir: Path):
|
|
provider = SkillsDirectoryProvider(roots=skills_dir)
|
|
templates = await provider.list_resource_templates()
|
|
|
|
# One template per skill
|
|
assert len(templates) == 2
|
|
|
|
template_names = {t.name for t in templates}
|
|
assert "simple-skill_files" in template_names
|
|
assert "complex-skill_files" in template_names
|
|
|
|
async def test_read_supporting_file_via_template(self, skills_dir: Path):
|
|
mcp = FastMCP("Test")
|
|
mcp.add_provider(SkillsDirectoryProvider(roots=skills_dir))
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.read_resource(
|
|
AnyUrl("skill://complex-skill/reference.md")
|
|
)
|
|
assert len(result) == 1
|
|
assert isinstance(result[0], TextResourceContents)
|
|
assert "# Reference" in result[0].text
|
|
|
|
async def test_read_nested_file_via_template(self, skills_dir: Path):
|
|
mcp = FastMCP("Test")
|
|
mcp.add_provider(SkillsDirectoryProvider(roots=skills_dir))
|
|
|
|
async with Client(mcp) as client:
|
|
result = await client.read_resource(
|
|
AnyUrl("skill://complex-skill/scripts/helper.py")
|
|
)
|
|
assert len(result) == 1
|
|
assert isinstance(result[0], TextResourceContents)
|
|
assert "Hello from helper" in result[0].text
|
|
|
|
async def test_empty_skills_directory(self, tmp_path: Path):
|
|
empty_dir = tmp_path / "empty"
|
|
empty_dir.mkdir()
|
|
|
|
provider = SkillsDirectoryProvider(roots=empty_dir)
|
|
resources = await provider.list_resources()
|
|
assert resources == []
|
|
|
|
templates = await provider.list_resource_templates()
|
|
assert templates == []
|
|
|
|
async def test_nonexistent_skills_directory(self, tmp_path: Path):
|
|
nonexistent = tmp_path / "does-not-exist"
|
|
provider = SkillsDirectoryProvider(roots=nonexistent)
|
|
|
|
resources = await provider.list_resources()
|
|
assert resources == []
|
|
|
|
async def test_reload_mode(self, skills_dir: Path):
|
|
provider = SkillsDirectoryProvider(roots=skills_dir, reload=True)
|
|
|
|
# Initial load
|
|
resources = await provider.list_resources()
|
|
assert len(resources) == 4
|
|
|
|
# Add a new skill
|
|
new_skill = skills_dir / "new-skill"
|
|
new_skill.mkdir()
|
|
(new_skill / "SKILL.md").write_text(
|
|
"""---
|
|
description: A new skill
|
|
---
|
|
|
|
# New Skill
|
|
"""
|
|
)
|
|
|
|
# Reload should pick up the new skill
|
|
resources = await provider.list_resources()
|
|
assert len(resources) == 6
|
|
|
|
async def test_skill_without_frontmatter_uses_header_as_description(
|
|
self, tmp_path: Path
|
|
):
|
|
skills_dir = tmp_path / "skills"
|
|
skills_dir.mkdir()
|
|
skill = skills_dir / "no-frontmatter"
|
|
skill.mkdir()
|
|
(skill / "SKILL.md").write_text("# My Skill Title\n\nSome content.")
|
|
|
|
provider = SkillsDirectoryProvider(roots=skills_dir)
|
|
resources = await provider.list_resources()
|
|
|
|
main_resource = next(
|
|
r for r in resources if r.name == "no-frontmatter/SKILL.md"
|
|
)
|
|
assert main_resource.description == "My Skill Title"
|
|
|
|
async def test_supporting_files_as_resources(self, skills_dir: Path):
|
|
"""Test that supporting_files='resources' shows all files."""
|
|
provider = SkillsDirectoryProvider(
|
|
roots=skills_dir, supporting_files="resources"
|
|
)
|
|
resources = await provider.list_resources()
|
|
|
|
# 2 skills * 2 standard resources + complex skill has 2 supporting files
|
|
# simple-skill: SKILL.md, _manifest (2)
|
|
# complex-skill: SKILL.md, _manifest, reference.md, scripts/helper.py (4)
|
|
assert len(resources) == 6
|
|
|
|
names = {r.name for r in resources}
|
|
assert "complex-skill/reference.md" in names
|
|
assert "complex-skill/scripts/helper.py" in names
|
|
|
|
async def test_supporting_files_as_resources_no_templates(self, skills_dir: Path):
|
|
"""In resources mode, no templates should be exposed."""
|
|
provider = SkillsDirectoryProvider(
|
|
roots=skills_dir, supporting_files="resources"
|
|
)
|
|
templates = await provider.list_resource_templates()
|
|
assert templates == []
|
|
|
|
|
|
class TestMultiDirectoryProvider:
|
|
"""Tests for multi-directory support in SkillsDirectoryProvider."""
|
|
|
|
@pytest.fixture
|
|
def multi_skills_dirs(self, tmp_path: Path) -> tuple[Path, Path]:
|
|
"""Create two separate skills directories."""
|
|
root1 = tmp_path / "skills1"
|
|
root1.mkdir()
|
|
skill1 = root1 / "skill-a"
|
|
skill1.mkdir()
|
|
(skill1 / "SKILL.md").write_text(
|
|
"""---
|
|
description: Skill A from root 1
|
|
---
|
|
# Skill A
|
|
"""
|
|
)
|
|
|
|
root2 = tmp_path / "skills2"
|
|
root2.mkdir()
|
|
skill2 = root2 / "skill-b"
|
|
skill2.mkdir()
|
|
(skill2 / "SKILL.md").write_text(
|
|
"""---
|
|
description: Skill B from root 2
|
|
---
|
|
# Skill B
|
|
"""
|
|
)
|
|
|
|
return root1, root2
|
|
|
|
async def test_multiple_roots_discover_all_skills(self, multi_skills_dirs):
|
|
"""Test that skills from multiple roots are all discovered."""
|
|
root1, root2 = multi_skills_dirs
|
|
provider = SkillsDirectoryProvider(roots=[root1, root2])
|
|
|
|
resources = await provider.list_resources()
|
|
# 2 skills * 2 resources each = 4 total
|
|
assert len(resources) == 4
|
|
|
|
resource_names = {r.name for r in resources}
|
|
assert "skill-a/SKILL.md" in resource_names
|
|
assert "skill-a/_manifest" in resource_names
|
|
assert "skill-b/SKILL.md" in resource_names
|
|
assert "skill-b/_manifest" in resource_names
|
|
|
|
async def test_duplicate_skill_names_first_wins(self, tmp_path: Path):
|
|
"""Test that if a skill appears in multiple roots, first one wins."""
|
|
root1 = tmp_path / "root1"
|
|
root1.mkdir()
|
|
skill1 = root1 / "duplicate-skill"
|
|
skill1.mkdir()
|
|
(skill1 / "SKILL.md").write_text(
|
|
"""---
|
|
description: First occurrence
|
|
---
|
|
# First
|
|
"""
|
|
)
|
|
|
|
root2 = tmp_path / "root2"
|
|
root2.mkdir()
|
|
skill2 = root2 / "duplicate-skill"
|
|
skill2.mkdir()
|
|
(skill2 / "SKILL.md").write_text(
|
|
"""---
|
|
description: Second occurrence
|
|
---
|
|
# Second
|
|
"""
|
|
)
|
|
|
|
provider = SkillsDirectoryProvider(roots=[root1, root2])
|
|
resources = await provider.list_resources()
|
|
|
|
# Should only have one skill (first one)
|
|
assert len(resources) == 2 # SKILL.md + _manifest
|
|
|
|
# Should be the first one
|
|
main_resource = next(
|
|
r for r in resources if r.name == "duplicate-skill/SKILL.md"
|
|
)
|
|
assert main_resource.description == "First occurrence"
|
|
|
|
async def test_single_path_as_list(self, multi_skills_dirs):
|
|
"""Test that single path can be passed as a list."""
|
|
root1, _ = multi_skills_dirs
|
|
provider = SkillsDirectoryProvider(roots=[root1])
|
|
|
|
resources = await provider.list_resources()
|
|
assert len(resources) == 2 # skill-a has 2 resources
|
|
|
|
async def test_single_path_as_string(self, multi_skills_dirs):
|
|
"""Test that single path can be passed as string."""
|
|
root1, _ = multi_skills_dirs
|
|
provider = SkillsDirectoryProvider(roots=str(root1))
|
|
|
|
resources = await provider.list_resources()
|
|
assert len(resources) == 2
|
|
|
|
async def test_nonexistent_roots_handled_gracefully(self, tmp_path: Path):
|
|
"""Test that non-existent roots don't cause errors."""
|
|
existent = tmp_path / "exists"
|
|
existent.mkdir()
|
|
skill = existent / "test-skill"
|
|
skill.mkdir()
|
|
(skill / "SKILL.md").write_text("# Test\n\nContent")
|
|
|
|
nonexistent = tmp_path / "does-not-exist"
|
|
|
|
provider = SkillsDirectoryProvider(roots=[existent, nonexistent])
|
|
resources = await provider.list_resources()
|
|
|
|
# Should still find skills from existing root
|
|
assert len(resources) == 2
|
|
|
|
async def test_empty_roots_list(self, tmp_path: Path):
|
|
"""Test that empty roots list results in no skills."""
|
|
provider = SkillsDirectoryProvider(roots=[])
|
|
resources = await provider.list_resources()
|
|
assert resources == []
|
|
|
|
|
|
class TestSkillsProviderAlias:
|
|
"""Test that SkillsProvider is a backwards-compatible alias."""
|
|
|
|
def test_skills_provider_is_alias(self):
|
|
assert SkillsProvider is SkillsDirectoryProvider
|
|
|
|
|
|
class TestClaudeSkillsProvider:
|
|
def test_default_root_is_claude_skills_dir(self, tmp_path: Path, monkeypatch):
|
|
# Mock Path.home() to return a temp path (use tmp_path for cross-platform compatibility)
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
|
|
provider = ClaudeSkillsProvider()
|
|
assert provider._roots == [tmp_path / ".claude" / "skills"]
|
|
|
|
def test_main_file_name_is_skill_md(self):
|
|
provider = ClaudeSkillsProvider()
|
|
assert provider._main_file_name == "SKILL.md"
|
|
|
|
def test_supporting_files_parameter(self):
|
|
provider = ClaudeSkillsProvider(supporting_files="resources")
|
|
assert provider._supporting_files == "resources"
|
|
|
|
|
|
class TestPathTraversalPrevention:
|
|
async def test_path_traversal_blocked(self, tmp_path: Path):
|
|
skills_dir = tmp_path / "skills"
|
|
skills_dir.mkdir()
|
|
skill = skills_dir / "test-skill"
|
|
skill.mkdir()
|
|
(skill / "SKILL.md").write_text("# Test\n\nContent")
|
|
|
|
# Create a file outside the skill directory
|
|
secret_file = tmp_path / "secret.txt"
|
|
secret_file.write_text("SECRET DATA")
|
|
|
|
mcp = FastMCP("Test")
|
|
mcp.add_provider(SkillsDirectoryProvider(roots=skills_dir))
|
|
|
|
async with Client(mcp) as client:
|
|
# Path traversal attempts should fail (either normalized away or blocked)
|
|
# The important thing is that SECRET DATA is never returned
|
|
with pytest.raises(Exception):
|
|
result = await client.read_resource(
|
|
AnyUrl("skill://test-skill/../../../secret.txt")
|
|
)
|
|
# If we somehow got here, ensure we didn't get the secret
|
|
if result:
|
|
for content in result:
|
|
if hasattr(content, "text"):
|
|
assert "SECRET DATA" not in content.text
|