test_zip_skills.pyβ’15.6 kB
"""Tests for zip-based skills support."""
import base64
from pathlib import Path
import zipfile
import pytest
from skillz import SkillRegistry, build_server
def create_zip_skill(
zip_path: Path, name: str = "TestSkill", with_resources: bool = True
) -> None:
"""Create a test skill in a zip file."""
with zipfile.ZipFile(zip_path, "w") as z:
# Create SKILL.md
skill_md_content = f"""---
name: {name}
description: Test skill from zip
---
Test skill instructions from zip file.
"""
z.writestr("SKILL.md", skill_md_content)
if with_resources:
# Create text file
z.writestr("text/hello.txt", "Hello from zip!")
# Create binary file
z.writestr("bin/data.bin", b"\xff\xfe\x00\x01\x80\x90")
# Create Python script
z.writestr("scripts/run.py", "print('hello')")
def test_zip_skill_loads_and_parses_skill_md(tmp_path: Path) -> None:
"""Test that a zip with SKILL.md at root is loaded correctly."""
zip_path = tmp_path / "my-skill.zip"
create_zip_skill(zip_path, name="MySkill")
registry = SkillRegistry(tmp_path)
registry.load()
assert len(registry.skills) == 1
skill = registry.get("myskill")
assert skill.metadata.name == "MySkill"
assert skill.metadata.description == "Test skill from zip"
assert skill.is_zip
assert skill.zip_path == zip_path.resolve()
def test_zip_skill_resources_are_discovered(tmp_path: Path) -> None:
"""Test that resources in zip are discovered with correct URIs."""
zip_path = tmp_path / "my-skill.zip"
create_zip_skill(zip_path, name="MySkill")
registry = SkillRegistry(tmp_path)
registry.load()
skill = registry.get("myskill")
from fastmcp import FastMCP
mcp = FastMCP()
from skillz._server import register_skill_resources
metadata = register_skill_resources(mcp, skill)
# Should have 3 resources
assert len(metadata) == 3
# Check URIs
uris = {m["uri"] for m in metadata}
assert "resource://skillz/myskill/text/hello.txt" in uris
assert "resource://skillz/myskill/bin/data.bin" in uris
assert "resource://skillz/myskill/scripts/run.py" in uris
# Check names
names = {m["name"] for m in metadata}
assert "myskill/text/hello.txt" in names
assert "myskill/bin/data.bin" in names
assert "myskill/scripts/run.py" in names
# SKILL.md should NOT be in resources
for m in metadata:
assert "SKILL.md" not in m["name"]
@pytest.mark.asyncio
async def test_zip_skill_text_resource_read(tmp_path: Path) -> None:
"""Test reading text resource from zip-based skill."""
zip_path = tmp_path / "test-skill.zip"
create_zip_skill(zip_path, name="TestSkill")
registry = SkillRegistry(tmp_path)
registry.load()
server = build_server(registry)
tools = await server.get_tools()
fetch_tool = tools["fetch_resource"]
result = await fetch_tool.fn(
resource_uri="resource://skillz/testskill/text/hello.txt"
)
assert result["uri"] == "resource://skillz/testskill/text/hello.txt"
assert result["name"] == "testskill/text/hello.txt"
assert result["mime_type"] == "text/plain"
assert result["encoding"] == "utf-8"
assert result["content"] == "Hello from zip!"
@pytest.mark.asyncio
async def test_zip_skill_binary_resource_read(tmp_path: Path) -> None:
"""Test reading binary resource from zip-based skill."""
zip_path = tmp_path / "test-skill.zip"
create_zip_skill(zip_path, name="TestSkill")
registry = SkillRegistry(tmp_path)
registry.load()
server = build_server(registry)
tools = await server.get_tools()
fetch_tool = tools["fetch_resource"]
result = await fetch_tool.fn(
resource_uri="resource://skillz/testskill/bin/data.bin"
)
assert result["uri"] == "resource://skillz/testskill/bin/data.bin"
assert result["name"] == "testskill/bin/data.bin"
assert result["encoding"] == "base64"
# Verify content can be decoded
decoded = base64.b64decode(result["content"])
assert decoded == b"\xff\xfe\x00\x01\x80\x90"
def test_zip_missing_skill_md_is_ignored(tmp_path: Path) -> None:
"""Test that zip without SKILL.md at root is ignored."""
zip_path = tmp_path / "invalid.zip"
with zipfile.ZipFile(zip_path, "w") as z:
z.writestr("README.md", "# Not a skill")
z.writestr("some/nested/file.txt", "content")
registry = SkillRegistry(tmp_path)
registry.load()
# Should not be loaded
assert len(registry.skills) == 0
def test_corrupt_zip_is_ignored(tmp_path: Path) -> None:
"""Test that corrupt zip file is ignored gracefully."""
zip_path = tmp_path / "corrupt.zip"
zip_path.write_bytes(b"This is not a valid zip file at all!")
registry = SkillRegistry(tmp_path)
registry.load()
# Should not crash, just ignore the invalid zip
assert len(registry.skills) == 0
def test_zip_inside_dir_skill_is_ignored(tmp_path: Path) -> None:
"""Test that zip files inside directory skills are ignored."""
# Create directory skill
skill_dir = tmp_path / "myskill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
"""---
name: DirectorySkill
description: A directory-based skill
---
Directory skill content.
""",
encoding="utf-8",
)
# Place a zip file inside the skill directory
zip_path = skill_dir / "nested.zip"
create_zip_skill(zip_path, name="NestedSkill")
registry = SkillRegistry(tmp_path)
registry.load()
# Only the directory skill should be loaded
assert len(registry.skills) == 1
skill = registry.get("directoryskill")
assert skill.metadata.name == "DirectorySkill"
assert not skill.is_zip
def test_zips_in_non_skill_subdirectories_are_loaded(tmp_path: Path) -> None:
"""Test that zips in subdirectories without SKILL.md are loaded."""
# Create subdirectory structure
packs_a = tmp_path / "packs" / "a"
packs_a.mkdir(parents=True)
packs_b = tmp_path / "packs" / "b"
packs_b.mkdir(parents=True)
# Create zip skills in subdirectories
create_zip_skill(packs_a / "skill-a.zip", name="SkillA")
create_zip_skill(packs_b / "skill-b.zip", name="SkillB")
registry = SkillRegistry(tmp_path)
registry.load()
# Both should be loaded
assert len(registry.skills) == 2
skill_a = registry.get("skilla")
skill_b = registry.get("skillb")
assert skill_a.metadata.name == "SkillA"
assert skill_b.metadata.name == "SkillB"
assert skill_a.is_zip
assert skill_b.is_zip
def test_nested_zip_not_treated_as_skill(tmp_path: Path) -> None:
"""Test that zip files inside zip-based skills are just resources."""
zip_path = tmp_path / "outer.zip"
with zipfile.ZipFile(zip_path, "w") as z:
# Create SKILL.md at root
z.writestr(
"SKILL.md",
"""---
name: OuterSkill
description: Outer skill
---
Outer skill content.
""",
)
# Add a nested zip as a resource
inner_zip_data = b"PK\x03\x04\x00\x00\x00\x00\x00\x00\x00\x00"
z.writestr("resources/inner.zip", inner_zip_data)
registry = SkillRegistry(tmp_path)
registry.load()
# Only outer skill should be loaded
assert len(registry.skills) == 1
skill = registry.get("outerskill")
assert skill.metadata.name == "OuterSkill"
assert skill.is_zip
# The inner.zip should be a resource, not a separate skill
from fastmcp import FastMCP
from skillz._server import register_skill_resources
mcp = FastMCP()
metadata = register_skill_resources(mcp, skill)
resource_names = {m["name"] for m in metadata}
assert "outerskill/resources/inner.zip" in resource_names
def test_skill_name_collision_skips_zip(tmp_path: Path) -> None:
"""Test that zip with duplicate name is skipped."""
# Create directory skill first
skill_dir = tmp_path / "foo"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
"""---
name: Foo
description: Directory skill
---
Content.
""",
encoding="utf-8",
)
# Create zip skill with same name
zip_path = tmp_path / "foo.zip"
create_zip_skill(zip_path, name="Foo")
registry = SkillRegistry(tmp_path)
registry.load()
# Only directory skill should be loaded
assert len(registry.skills) == 1
skill = registry.get("foo")
assert not skill.is_zip
assert skill.metadata.name == "Foo"
@pytest.mark.asyncio
async def test_zip_skill_instructions_read_correctly(tmp_path: Path) -> None:
"""Test that skill instructions are read correctly from zip."""
zip_path = tmp_path / "test.zip"
with zipfile.ZipFile(zip_path, "w") as z:
z.writestr(
"SKILL.md",
"""---
name: TestInstructions
description: Test reading instructions
---
These are the skill instructions.
With multiple lines.
""",
)
registry = SkillRegistry(tmp_path)
registry.load()
server = build_server(registry)
tools = await server.get_tools()
skill_tool = tools["testinstructions"]
result = await skill_tool.fn(task="test task")
assert "instructions" in result
assert "These are the skill instructions." in result["instructions"]
assert "With multiple lines." in result["instructions"]
def test_zip_skill_with_macos_metadata_filtered(tmp_path: Path) -> None:
"""Test that __MACOSX and .DS_Store files are filtered out."""
zip_path = tmp_path / "mac-skill.zip"
with zipfile.ZipFile(zip_path, "w") as z:
z.writestr(
"SKILL.md",
"""---
name: MacSkill
description: Skill with macOS metadata
---
Content.
""",
)
z.writestr("script.py", "print('hello')")
z.writestr("__MACOSX/._script.py", b"\x00\x01\x02") # macOS metadata
z.writestr(".DS_Store", b"DS_Store content") # macOS metadata
registry = SkillRegistry(tmp_path)
registry.load()
skill = registry.get("macskill")
from fastmcp import FastMCP
from skillz._server import register_skill_resources
mcp = FastMCP()
metadata = register_skill_resources(mcp, skill)
# Should only have script.py, not macOS metadata files
assert len(metadata) == 1
assert metadata[0]["name"] == "macskill/script.py"
@pytest.mark.asyncio
async def test_zip_path_traversal_rejected(tmp_path: Path) -> None:
"""Test that path traversal attempts are rejected."""
zip_path = tmp_path / "test.zip"
create_zip_skill(zip_path, name="TestSkill")
registry = SkillRegistry(tmp_path)
registry.load()
server = build_server(registry)
tools = await server.get_tools()
fetch_tool = tools["fetch_resource"]
# Try path traversal
result = await fetch_tool.fn(
resource_uri="resource://skillz/testskill/../../../etc/passwd"
)
# Should return error
assert "Error" in result["content"]
assert "path traversal" in result["content"]
def test_mixed_directory_and_zip_skills(tmp_path: Path) -> None:
"""Test that both directory and zip skills can coexist."""
# Create directory skill
dir_skill = tmp_path / "dir-skill"
dir_skill.mkdir()
(dir_skill / "SKILL.md").write_text(
"""---
name: DirSkill
description: Directory skill
---
Dir content.
""",
encoding="utf-8",
)
# Create zip skill
zip_path = tmp_path / "zip-skill.zip"
create_zip_skill(zip_path, name="ZipSkill")
registry = SkillRegistry(tmp_path)
registry.load()
# Both should be loaded
assert len(registry.skills) == 2
dir_skill_obj = registry.get("dirskill")
zip_skill_obj = registry.get("zipskill")
assert not dir_skill_obj.is_zip
assert zip_skill_obj.is_zip
assert dir_skill_obj.metadata.name == "DirSkill"
assert zip_skill_obj.metadata.name == "ZipSkill"
def test_zip_with_top_level_directory(tmp_path: Path) -> None:
"""Test zip with single top-level directory containing SKILL.md."""
# Create a zip with structure: my-skill.zip/my-skill/SKILL.md
zip_path = tmp_path / "my-skill.zip"
with zipfile.ZipFile(zip_path, "w") as z:
z.writestr(
"my-skill/SKILL.md",
"""---
name: MySkill
description: Test skill in top-level dir
---
Instructions.
""",
)
z.writestr("my-skill/resource.txt", "Hello from nested structure!")
z.writestr("my-skill/scripts/run.py", "print('test')")
registry = SkillRegistry(tmp_path)
registry.load()
# Should be loaded
assert len(registry.skills) == 1
skill = registry.get("myskill")
assert skill.metadata.name == "MySkill"
assert skill.is_zip
assert skill.zip_root_prefix == "my-skill/"
@pytest.mark.asyncio
async def test_zip_with_top_level_directory_resources(
tmp_path: Path,
) -> None:
"""Test reading resources from zip with top-level directory."""
zip_path = tmp_path / "test-skill.zip"
with zipfile.ZipFile(zip_path, "w") as z:
z.writestr(
"test-skill/SKILL.md",
"""---
name: TestSkill
description: Test
---
Content.
""",
)
z.writestr("test-skill/data.txt", "Test data from nested structure")
registry = SkillRegistry(tmp_path)
registry.load()
server = build_server(registry)
tools = await server.get_tools()
fetch_tool = tools["fetch_resource"]
result = await fetch_tool.fn(
resource_uri="resource://skillz/testskill/data.txt"
)
assert result["encoding"] == "utf-8"
assert result["content"] == "Test data from nested structure"
def test_skill_extension_loads_like_zip(tmp_path: Path) -> None:
"""Test that files with .skill extension are loaded as zip files."""
skill_path = tmp_path / "my-skill.skill"
create_zip_skill(skill_path, name="SkillExtension")
registry = SkillRegistry(tmp_path)
registry.load()
assert len(registry.skills) == 1
skill = registry.get("skillextension")
assert skill.metadata.name == "SkillExtension"
assert skill.is_zip
assert skill.zip_path == skill_path.resolve()
@pytest.mark.asyncio
async def test_skill_extension_resources_readable(tmp_path: Path) -> None:
"""Test that resources in .skill files can be read correctly."""
skill_path = tmp_path / "test.skill"
create_zip_skill(skill_path, name="TestSkillExt")
registry = SkillRegistry(tmp_path)
registry.load()
server = build_server(registry)
tools = await server.get_tools()
fetch_tool = tools["fetch_resource"]
result = await fetch_tool.fn(
resource_uri="resource://skillz/testskillext/text/hello.txt"
)
assert result["uri"] == "resource://skillz/testskillext/text/hello.txt"
assert result["content"] == "Hello from zip!"
assert result["encoding"] == "utf-8"
def test_mixed_zip_and_skill_extensions(tmp_path: Path) -> None:
"""Test that both .zip and .skill files can coexist."""
# Create a .zip file
zip_path = tmp_path / "skill-one.zip"
create_zip_skill(zip_path, name="SkillOne")
# Create a .skill file
skill_path = tmp_path / "skill-two.skill"
create_zip_skill(skill_path, name="SkillTwo")
registry = SkillRegistry(tmp_path)
registry.load()
# Both should be loaded
assert len(registry.skills) == 2
skill_one = registry.get("skillone")
skill_two = registry.get("skilltwo")
assert skill_one.metadata.name == "SkillOne"
assert skill_two.metadata.name == "SkillTwo"
assert skill_one.is_zip
assert skill_two.is_zip
assert skill_one.zip_path == zip_path.resolve()
assert skill_two.zip_path == skill_path.resolve()