mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-10-09 22:43:20 +02:00
154 lines
5.1 KiB
Python
154 lines
5.1 KiB
Python
"""Main files and directory entries in skill providers."""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from mcp.shared.path_security import PathEscapeError, safe_join
|
|
from mcp_types import TextResourceContents
|
|
|
|
from fastmcp import Client, FastMCP
|
|
from fastmcp.server.providers.skills import SkillProvider, SkillsDirectoryProvider
|
|
|
|
|
|
def make_skill(path: Path, content: str = "# Instructions") -> Path:
|
|
path.mkdir()
|
|
(path / "SKILL.md").write_text(content, encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def test_main_file_must_resolve_inside_skill_directory(tmp_path: Path) -> None:
|
|
skill = make_skill(tmp_path / "skill")
|
|
outside = tmp_path / "instructions.md"
|
|
outside.write_text("# Other instructions", encoding="utf-8")
|
|
(skill / "SKILL.md").unlink()
|
|
(skill / "SKILL.md").symlink_to(outside)
|
|
|
|
with pytest.raises(PathEscapeError):
|
|
SkillProvider(skill)
|
|
|
|
|
|
async def test_main_file_is_checked_again_when_read(tmp_path: Path) -> None:
|
|
skill = make_skill(tmp_path / "skill")
|
|
provider = SkillProvider(skill)
|
|
resource = await provider.get_resource("skill://skill/SKILL.md")
|
|
assert resource is not None
|
|
outside = tmp_path / "instructions.md"
|
|
outside.write_text("# Other instructions", encoding="utf-8")
|
|
(skill / "SKILL.md").unlink()
|
|
(skill / "SKILL.md").symlink_to(outside)
|
|
|
|
with pytest.raises(PathEscapeError):
|
|
await resource.read()
|
|
|
|
|
|
@pytest.mark.parametrize("entry_link", [False, True])
|
|
async def test_directory_keeps_other_skills_when_an_entry_is_outside(
|
|
tmp_path: Path, entry_link: bool
|
|
) -> None:
|
|
root = tmp_path / "skills"
|
|
root.mkdir()
|
|
make_skill(root / "ordinary")
|
|
outside = make_skill(tmp_path / "other")
|
|
if entry_link:
|
|
(root / "linked").symlink_to(outside, target_is_directory=True)
|
|
else:
|
|
linked = make_skill(root / "linked")
|
|
(linked / "SKILL.md").unlink()
|
|
(linked / "SKILL.md").symlink_to(outside / "SKILL.md")
|
|
|
|
provider = SkillsDirectoryProvider(root)
|
|
resources = await provider.list_resources()
|
|
|
|
assert {resource.name for resource in resources} == {
|
|
"ordinary/SKILL.md",
|
|
"ordinary/_manifest",
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("main_file_name", ["SKILL.md", "docs/MAIN.md"])
|
|
async def test_main_file_links_within_skill_directory_remain_readable(
|
|
tmp_path: Path, main_file_name: str
|
|
) -> None:
|
|
skill = tmp_path / "skill"
|
|
skill.mkdir()
|
|
content = "# Instructions\n\nRésumé"
|
|
target = skill / "instructions.md"
|
|
target.write_text(content, encoding="utf-8")
|
|
main_file = skill / main_file_name
|
|
main_file.parent.mkdir(exist_ok=True)
|
|
main_file.symlink_to(target)
|
|
server = FastMCP()
|
|
server.add_provider(SkillProvider(skill, main_file_name=main_file_name))
|
|
|
|
async with Client(server) as client:
|
|
result = await client.read_resource(f"skill://skill/{main_file_name}")
|
|
|
|
assert isinstance(result[0], TextResourceContents)
|
|
assert result[0].text == content
|
|
|
|
|
|
async def test_explicit_skill_root_can_be_a_directory_link(tmp_path: Path) -> None:
|
|
actual = make_skill(tmp_path / "actual")
|
|
link = tmp_path / "selected"
|
|
link.symlink_to(actual, target_is_directory=True)
|
|
resources = await SkillProvider(link).list_resources()
|
|
|
|
assert {resource.name for resource in resources} == {
|
|
"actual/SKILL.md",
|
|
"actual/_manifest",
|
|
}
|
|
|
|
|
|
async def test_directory_links_within_root_remain_discoverable(tmp_path: Path) -> None:
|
|
root = tmp_path / "skills"
|
|
root.mkdir()
|
|
actual = make_skill(root / "actual")
|
|
(root / "linked").symlink_to(actual, target_is_directory=True)
|
|
resources = await SkillsDirectoryProvider(root).list_resources()
|
|
|
|
assert {resource.name for resource in resources} == {
|
|
"actual/SKILL.md",
|
|
"actual/_manifest",
|
|
}
|
|
|
|
|
|
async def test_directory_keeps_the_selected_target_when_a_link_changes(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
root = tmp_path / "skills"
|
|
root.mkdir()
|
|
selected = make_skill(root / "selected", "# Selected instructions")
|
|
other = make_skill(tmp_path / "other", "# Other instructions")
|
|
link = root / "linked"
|
|
link.symlink_to(selected, target_is_directory=True)
|
|
|
|
def select_path(base: Path, name: str) -> Path:
|
|
resolved = safe_join(base, name)
|
|
if name == "linked":
|
|
link.unlink()
|
|
link.symlink_to(other, target_is_directory=True)
|
|
return resolved
|
|
|
|
monkeypatch.setattr(
|
|
"fastmcp.server.providers.skills.directory_provider.safe_join", select_path
|
|
)
|
|
resources = await SkillsDirectoryProvider(root).list_resources()
|
|
assert {resource.name for resource in resources} == {
|
|
"selected/SKILL.md",
|
|
"selected/_manifest",
|
|
}
|
|
|
|
|
|
async def test_directory_keeps_valid_skills_when_a_link_cannot_resolve(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
root = tmp_path / "skills"
|
|
root.mkdir()
|
|
make_skill(root / "ordinary")
|
|
unresolved = root / "unresolved"
|
|
unresolved.symlink_to(unresolved, target_is_directory=True)
|
|
resources = await SkillsDirectoryProvider(root).list_resources()
|
|
assert {resource.name for resource in resources} == {
|
|
"ordinary/SKILL.md",
|
|
"ordinary/_manifest",
|
|
}
|