mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-24 06:24:18 +02:00
Add Skills Provider for exposing agent skills as MCP resources (#2944)
This commit is contained in:
parent
c0ef90e713
commit
16ffc9432f
18 changed files with 2398 additions and 0 deletions
591
tests/server/providers/test_skills_provider.py
Normal file
591
tests/server/providers/test_skills_provider.py
Normal file
|
|
@ -0,0 +1,591 @@
|
|||
"""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_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
|
||||
|
||||
|
||||
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
|
||||
201
tests/server/providers/test_skills_vendor_providers.py
Normal file
201
tests/server/providers/test_skills_vendor_providers.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
"""Tests for vendor-specific skills providers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastmcp.server.providers.skills import (
|
||||
ClaudeSkillsProvider,
|
||||
CodexSkillsProvider,
|
||||
CopilotSkillsProvider,
|
||||
CursorSkillsProvider,
|
||||
GeminiSkillsProvider,
|
||||
GooseSkillsProvider,
|
||||
OpenCodeSkillsProvider,
|
||||
VSCodeSkillsProvider,
|
||||
)
|
||||
|
||||
|
||||
class TestVendorProviders:
|
||||
"""Tests for vendor-specific skills providers."""
|
||||
|
||||
def test_cursor_skills_provider_path(self, tmp_path: Path, monkeypatch):
|
||||
"""Test CursorSkillsProvider uses correct path."""
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
provider = CursorSkillsProvider()
|
||||
assert provider._roots == [tmp_path / ".cursor" / "skills"]
|
||||
|
||||
def test_vscode_skills_provider_path(self, tmp_path: Path, monkeypatch):
|
||||
"""Test VSCodeSkillsProvider uses correct path."""
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
provider = VSCodeSkillsProvider()
|
||||
assert provider._roots == [tmp_path / ".copilot" / "skills"]
|
||||
|
||||
def test_codex_skills_provider_paths(self, tmp_path: Path, monkeypatch):
|
||||
"""Test CodexSkillsProvider uses both system and user paths."""
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
provider = CodexSkillsProvider()
|
||||
# Path.resolve() may add /private on macOS, so compare resolved paths
|
||||
expected_roots = [
|
||||
Path("/etc/codex/skills").resolve(),
|
||||
(tmp_path / ".codex" / "skills").resolve(),
|
||||
]
|
||||
assert provider._roots == expected_roots
|
||||
|
||||
def test_gemini_skills_provider_path(self, tmp_path: Path, monkeypatch):
|
||||
"""Test GeminiSkillsProvider uses correct path."""
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
provider = GeminiSkillsProvider()
|
||||
assert provider._roots == [tmp_path / ".gemini" / "skills"]
|
||||
|
||||
def test_goose_skills_provider_path(self, tmp_path: Path, monkeypatch):
|
||||
"""Test GooseSkillsProvider uses correct path."""
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
provider = GooseSkillsProvider()
|
||||
assert provider._roots == [tmp_path / ".config" / "agents" / "skills"]
|
||||
|
||||
def test_copilot_skills_provider_path(self, tmp_path: Path, monkeypatch):
|
||||
"""Test CopilotSkillsProvider uses correct path."""
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
provider = CopilotSkillsProvider()
|
||||
assert provider._roots == [tmp_path / ".copilot" / "skills"]
|
||||
|
||||
def test_opencode_skills_provider_path(self, tmp_path: Path, monkeypatch):
|
||||
"""Test OpenCodeSkillsProvider uses correct path."""
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
provider = OpenCodeSkillsProvider()
|
||||
assert provider._roots == [tmp_path / ".config" / "opencode" / "skills"]
|
||||
|
||||
def test_claude_skills_provider_path(self, tmp_path: Path, monkeypatch):
|
||||
"""Test ClaudeSkillsProvider uses correct path."""
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
provider = ClaudeSkillsProvider()
|
||||
assert provider._roots == [tmp_path / ".claude" / "skills"]
|
||||
|
||||
def test_all_providers_instantiable(self):
|
||||
"""Test that all vendor providers can be instantiated."""
|
||||
providers = [
|
||||
ClaudeSkillsProvider(),
|
||||
CursorSkillsProvider(),
|
||||
VSCodeSkillsProvider(),
|
||||
CodexSkillsProvider(),
|
||||
GeminiSkillsProvider(),
|
||||
GooseSkillsProvider(),
|
||||
CopilotSkillsProvider(),
|
||||
OpenCodeSkillsProvider(),
|
||||
]
|
||||
|
||||
for provider in providers:
|
||||
assert provider is not None
|
||||
assert provider._main_file_name == "SKILL.md"
|
||||
|
||||
def test_all_providers_support_reload(self):
|
||||
"""Test that all providers support reload parameter."""
|
||||
providers = [
|
||||
ClaudeSkillsProvider(reload=True),
|
||||
CursorSkillsProvider(reload=True),
|
||||
VSCodeSkillsProvider(reload=True),
|
||||
CodexSkillsProvider(reload=True),
|
||||
GeminiSkillsProvider(reload=True),
|
||||
GooseSkillsProvider(reload=True),
|
||||
CopilotSkillsProvider(reload=True),
|
||||
OpenCodeSkillsProvider(reload=True),
|
||||
]
|
||||
|
||||
for provider in providers:
|
||||
assert provider._reload is True
|
||||
|
||||
def test_all_providers_support_supporting_files(self):
|
||||
"""Test that all providers support supporting_files parameter."""
|
||||
providers = [
|
||||
ClaudeSkillsProvider(supporting_files="resources"),
|
||||
CursorSkillsProvider(supporting_files="resources"),
|
||||
VSCodeSkillsProvider(supporting_files="resources"),
|
||||
CodexSkillsProvider(supporting_files="resources"),
|
||||
GeminiSkillsProvider(supporting_files="resources"),
|
||||
GooseSkillsProvider(supporting_files="resources"),
|
||||
CopilotSkillsProvider(supporting_files="resources"),
|
||||
OpenCodeSkillsProvider(supporting_files="resources"),
|
||||
]
|
||||
|
||||
for provider in providers:
|
||||
assert provider._supporting_files == "resources"
|
||||
|
||||
async def test_codex_scans_both_paths(self, tmp_path: Path, monkeypatch):
|
||||
"""Test that CodexSkillsProvider scans both system and user paths."""
|
||||
# Mock system path
|
||||
system_skills = tmp_path / "etc" / "codex" / "skills"
|
||||
system_skills.mkdir(parents=True)
|
||||
system_skill = system_skills / "system-skill"
|
||||
system_skill.mkdir()
|
||||
(system_skill / "SKILL.md").write_text(
|
||||
"""---
|
||||
description: System skill
|
||||
---
|
||||
# System
|
||||
"""
|
||||
)
|
||||
|
||||
# Mock user path
|
||||
fake_home = tmp_path / "home" / "user"
|
||||
fake_home.mkdir(parents=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: fake_home)
|
||||
|
||||
user_skills = fake_home / ".codex" / "skills"
|
||||
user_skills.mkdir(parents=True)
|
||||
user_skill = user_skills / "user-skill"
|
||||
user_skill.mkdir()
|
||||
(user_skill / "SKILL.md").write_text(
|
||||
"""---
|
||||
description: User skill
|
||||
---
|
||||
# User
|
||||
"""
|
||||
)
|
||||
|
||||
# Create provider with mocked paths
|
||||
# Override roots before discovery
|
||||
provider = CodexSkillsProvider()
|
||||
provider._roots = [system_skills, user_skills]
|
||||
# Trigger re-discovery with new roots
|
||||
provider._discover_skills()
|
||||
|
||||
resources = await provider.list_resources()
|
||||
# Should find both skills
|
||||
assert len(resources) == 4 # 2 skills * 2 resources each
|
||||
|
||||
resource_names = {r.name for r in resources}
|
||||
assert "system-skill/SKILL.md" in resource_names
|
||||
assert "user-skill/SKILL.md" in resource_names
|
||||
|
||||
async def test_nonexistent_paths_handled_gracefully(
|
||||
self, tmp_path: Path, monkeypatch
|
||||
):
|
||||
"""Test that non-existent paths don't cause errors."""
|
||||
# Use a path that definitely doesn't exist
|
||||
nonexistent_home = tmp_path / "nonexistent" / "home"
|
||||
monkeypatch.setattr(Path, "home", lambda: nonexistent_home)
|
||||
|
||||
# All providers should handle non-existent paths gracefully
|
||||
providers = [
|
||||
ClaudeSkillsProvider(),
|
||||
CursorSkillsProvider(),
|
||||
VSCodeSkillsProvider(),
|
||||
GeminiSkillsProvider(),
|
||||
GooseSkillsProvider(),
|
||||
CopilotSkillsProvider(),
|
||||
OpenCodeSkillsProvider(),
|
||||
]
|
||||
|
||||
for provider in providers:
|
||||
resources = await provider.list_resources()
|
||||
# Should return empty list, not raise exception
|
||||
assert isinstance(resources, list)
|
||||
Loading…
Add table
Add a link
Reference in a new issue