fastmcp/tests/server/providers/test_skills_provider.py
Jeremiah Lowin 3a9717e6be
Publish docs for v3.2.0 (#3713)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jeremiah Lowin <jlowin@users.noreply.github.com>
Co-authored-by: Marvin Context Protocol <41898282+Marvin Context Protocol@users.noreply.github.com>
Co-authored-by: voidborne-d <voidborne-d@users.noreply.github.com>
Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com>
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Co-authored-by: nightcityblade <nightcityblade@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Bill Easton <strawgate@users.noreply.github.com>
Co-authored-by: Sumanshu Nankana <sumanshunankana@gmail.com>
Co-authored-by: Eric Robinson <ericrobinson@indeed.com>
Co-authored-by: Martim Santos <martimfasantos@gmail.com>
Co-authored-by: d 🔹 <liusway405@gmail.com>
Co-authored-by: Matthieu B <66959271+mtthidoteu@users.noreply.github.com>
Co-authored-by: Sascha Buehrle <47737812+saschabuehrle@users.noreply.github.com>
Co-authored-by: Hakancan <142545736+hkc5@users.noreply.github.com>
Co-authored-by: nightcityblade <jackchen@haloailabs.com>
Co-authored-by: Matt Hallowell <17804673+mhallo@users.noreply.github.com>
Co-authored-by: nate nowack <thrast36@gmail.com>
Co-authored-by: Bill Easton <williamseaston@gmail.com>
Co-authored-by: Marcus Shu <46469249+shulkx@users.noreply.github.com>
Co-authored-by: Rushabh Doshi <radoshi@gmail.com>
Co-authored-by: AIKAWA Shigechika <shige@aikawa.jp>
Co-authored-by: Jeremy Simon <simonjer805@gmail.com>
Co-authored-by: Miguel Miranda Dias <7780875+pandego@users.noreply.github.com>
Co-authored-by: Anthony James Padavano <padavano.anthony@gmail.com>
Co-authored-by: Mostafa Kamal <hiremostafa@gmail.com>
Fix auto-close MRE script posting comment without closing (#3386)
Fix WorkOS token scope verification bypass 🤖 Generated with Codex (#3407)
Fix initialize McpError fallthrough 🤖 Generated with Codex (#3413)
Fix transform arg collisions with passthrough params (#3431)
Fix get_* returning None when latest version is disabled (#3439)
Fix get_* returning None when latest version is disabled (#3421)
Fix server lifespan overlap teardown (#3415)
Fix $ref output schema object detection regression (#3420)
resolved annotations (#3429)
Fix async partial callables rejected by iscoroutinefunction (#3438)
Fix async partial callables rejected by iscoroutinefunction (#3423)
fix: add version to components (#3458)
fix: use intent-based flag for OIDC scope patch in load_access_token (#3465)
Fixes #3461
fix: normalize Google scope shorthands and surface valid_scopes (#3477)
fix: resolve ty 0.0.23 type-checking errors and bump pin (#3481)
fix: shield lifespan teardown from cancellation (#3480)
fix: forward custom_route endpoints from mounted servers (#3462)
fix updates _get_additional_http_routes() to traverse providers,
Fixes #3457
fix: remove hardcoded version from CLI help text (#3456)
fix: monty 0.0.8 compatibility, drop external_functions from constructor (#3468)
fix: task test teardown hanging 5s per test (#3499)
Closes #3498
fix: validate workspace path is a directory before cursor install (#3440)
Fixes #3426
fix: handle re.error from malformed URI templates in build_regex (#3501)
fix: reject empty/OIDC-only required_scopes in AzureProvider (#3503)
fix: restrict $ref resolution to local refs only (SSRF/LFI) (#3502)
fix warnings and timeouts (#3504)
close upgrade check issue when build passes (#3505)
Closes #3484
fix: URL-encode path params to prevent SSRF/path traversal (GHSA-vv7q-7jx5-f767) (#3507)
fix: prevent path traversal in skill download (#3493)
fix: prefer IdP-granted scopes over client-requested scopes in OAuthProxy (#3492)
fix: remove unrelated transform and http.py changes from PR scope
fix: remove forced follow_redirects from httpx_client_factory calls (#3496)
fix: stop passing follow_redirects to httpx_client_factory
fix: restore follow_redirects=True for custom httpx client factories
Closes #3509
fix: CSRF double-submit cookie check in consent flow (#3519)
fix: validate server names in install commands (#3522)
fix: use raw strings for regex in pytest.raises match (#3523)
fix: reject refresh tokens used as Bearer access tokens (#3524)
fix: route ResourcesAsTools/PromptsAsTools through server middleware (#3495)
fix: resolve Pyright "Module is not callable" on @tool, @resource, @prompt decorators (#3540)
fix: filter warnings by message in KEY_PREFIX test (#3549)
fix: suppress output schema for ToolResult subclass annotations (#3548)
fix: increase sleep duration in proxy cache tests (#3567)
fix: store absolute token expiry to prevent stale expires_in on reload (#3572)
fix: preserve tool properties named 'title' during schema compression (#3582)
Fix loopback redirect URI port matching per RFC 8252 §7.3 (#3589)
Fix app tool routing: visibility check and middleware propagation (#3591)
Fix query parameter serialization to respect OpenAPI explode/style settings (#3595)
Fix dev apps form: union types, textarea support, JSON parsing (#3597)
fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo (#3603)
fix: resolve EntraOBOToken dependency injection through MultiAuth (#3609)
fix(docs): correct misleading stateless_http header (#3622)
fix: filesystem provider import machinery (#3626)
Closes #3625 (issues 2, 3, 6)
fix: recover StdioTransport after subprocess exits (#3630)
fix(server): preserve mounted tool task metadata (#3632)
fix: scope deprecation warning filter to FastMCPDeprecationWarning (#3649)
fix imports, add PrefabAppConfig (#3650)
fix: resolve CurrentFastMCP/ctx.fastmcp to child server in mounted background tasks (#3651)
Fix blocking docs issues: chart imports, Select API, Rx consistency (#3652)
closed by default (#3657)
Fix prompt caching middleware missing wrap/unwrap round-trip (#3666)
fix: serialize object query params per OpenAPI style/explode rules (#3662)
Fixes #2857
fix: HTTP request headers not accessible in background task workers (#3631)
fix: restore HTTP headers in worker execution path for background tasks (#3681)
fix: strip discriminator after dereferencing schemas (#3682)
fix: remove stale ty:ignore directives for ty 0.0.26 (#3684)
Fix docs gaps in app provider pages (#3690)
fix: dev apps log panel UX improvements (#3698)
fix dev server empty string args (#3700)
2026-03-30 16:48:30 -04:00

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