fastmcp/tests/skills/test_models.py
Bill Easton 92df6bad4f
Align Skills caching with the extension spec
Generated with OpenAI Codex.
2026-09-10 12:50:23 -05:00

251 lines
7.4 KiB
Python

from __future__ import annotations
import math
import pytest
from pydantic import ValidationError
from fastmcp.skills.models import (
GetSkillResult,
ListSkillsResult,
Skill,
SkillFrontmatter,
SkillResource,
)
DIGEST = f"sha256:{'a' * 64}"
def make_skill(**overrides: object) -> Skill:
values: dict[str, object] = {
"uri": "skill://acme/review/SKILL.md",
"frontmatter": {
"name": "review",
"description": "Review a change",
"metadata": {"author": "acme"},
"future": {"modes": ["quick", "deep"], "enabled": True},
},
"resources": [
{
"uri": "skill://acme/review/SKILL.md",
"digest": DIGEST,
"size": 42,
}
],
}
values.update(overrides)
return Skill.model_validate(values)
def test_frontmatter_preserves_unknown_json_fields() -> None:
skill = make_skill()
assert skill.frontmatter.model_dump() == {
"name": "review",
"description": "Review a change",
"metadata": {"author": "acme"},
"future": {"modes": ["quick", "deep"], "enabled": True},
}
def test_frontmatter_preserves_allowed_tools_wire_name() -> None:
frontmatter = SkillFrontmatter(
{
"name": "review",
"description": "Review a change",
"allowed-tools": "Read Grep",
}
)
assert frontmatter.model_dump()["allowed-tools"] == "Read Grep"
@pytest.mark.parametrize("name", ["Review", "-review", "review-", "deep--review"])
def test_frontmatter_rejects_invalid_names(name: str) -> None:
with pytest.raises(ValidationError, match="name must be"):
SkillFrontmatter({"name": name, "description": "Review a change"})
def test_frontmatter_rejects_whitespace_description() -> None:
with pytest.raises(ValidationError, match="description must contain"):
SkillFrontmatter({"name": "review", "description": " "})
def test_frontmatter_rejects_non_json_extra_values() -> None:
with pytest.raises(ValidationError):
SkillFrontmatter.model_validate(
{"name": "review", "description": "Review a change", "future": object()}
)
def test_frontmatter_rejects_non_finite_extra_values() -> None:
with pytest.raises(ValidationError):
SkillFrontmatter(
{"name": "review", "description": "Review a change", "future": math.inf}
)
@pytest.mark.parametrize(
("field", "value", "message"),
[
("license", None, "license must be a string"),
("compatibility", None, "compatibility must be a string"),
("metadata", None, "metadata must map"),
("metadata", {"version": 1}, "metadata must map"),
("allowed-tools", None, "allowed-tools must be a string"),
],
)
def test_frontmatter_rejects_invalid_known_optional_fields(
field: str, value: object, message: str
) -> None:
with pytest.raises(ValidationError, match=message):
SkillFrontmatter.model_validate(
{"name": "review", "description": "Review a change", field: value}
)
def test_frontmatter_rejects_empty_compatibility() -> None:
with pytest.raises(ValidationError, match="compatibility must contain"):
SkillFrontmatter.model_validate(
{
"name": "review",
"description": "Review a change",
"compatibility": "",
}
)
def test_frontmatter_dump_does_not_add_absent_optional_fields() -> None:
frontmatter = SkillFrontmatter({"name": "review", "description": "Review a change"})
assert frontmatter.model_dump() == {
"name": "review",
"description": "Review a change",
}
def test_skill_accepts_dynamic_resources() -> None:
skill = make_skill(resources="dynamic")
assert skill.resources == "dynamic"
def test_skill_requires_uri_name_to_match_frontmatter() -> None:
with pytest.raises(ValidationError, match="must equal frontmatter.name"):
make_skill(uri="skill://acme/other/SKILL.md")
def test_skill_accepts_non_skill_scheme_and_nested_path() -> None:
uri = "github://acme/repository/skills/review/SKILL.md"
skill = make_skill(
uri=uri,
resources=[
{"uri": uri, "digest": DIGEST, "size": 42},
{
"uri": "github://acme/repository/skills/review/examples/good.md",
"digest": DIGEST,
"size": 8,
},
],
)
assert skill.uri == uri
def test_static_manifest_requires_skill_md() -> None:
with pytest.raises(ValidationError, match="must include"):
make_skill(
resources=[
{
"uri": "skill://acme/review/reference.md",
"digest": DIGEST,
"size": 42,
}
]
)
def test_static_manifest_rejects_duplicate_uris() -> None:
resource = SkillResource(uri="skill://acme/review/SKILL.md", digest=DIGEST, size=42)
with pytest.raises(ValidationError, match="exactly once"):
make_skill(resources=[resource, resource])
@pytest.mark.parametrize(
"uri",
[
"skill://other/reference.md",
"https://acme/review/reference.md",
"skill://acme/outside/reference.md",
"skill://acme/review/../outside.md",
"skill://acme/review/%2e%2e/outside.md",
],
)
def test_static_manifest_rejects_resources_outside_skill(uri: str) -> None:
with pytest.raises(
ValidationError, match="within the skill directory|invalid segment"
):
make_skill(
resources=[
{
"uri": "skill://acme/review/SKILL.md",
"digest": DIGEST,
"size": 42,
},
{"uri": uri, "digest": DIGEST, "size": 8},
]
)
@pytest.mark.parametrize(
"digest",
[
"sha256:abc",
f"sha256:{'A' * 64}",
f"sha512:{'a' * 64}",
],
)
def test_resource_requires_canonical_sha256(digest: str) -> None:
with pytest.raises(ValidationError):
SkillResource(uri="skill://review/SKILL.md", digest=digest, size=1)
def test_resource_rejects_negative_size() -> None:
with pytest.raises(ValidationError):
SkillResource(uri="skill://review/SKILL.md", digest=DIGEST, size=-1)
@pytest.mark.parametrize("size", [True, "1", 1.5, math.inf])
def test_resource_rejects_non_integer_size_types(size: object) -> None:
with pytest.raises(ValidationError, match="size must be a non-negative integer"):
SkillResource.model_validate(
{"uri": "skill://review/SKILL.md", "digest": DIGEST, "size": size}
)
def test_resource_accepts_integral_json_number() -> None:
resource = SkillResource.model_validate(
{"uri": "skill://review/SKILL.md", "digest": DIGEST, "size": 1.0}
)
assert resource.size == 1
def test_list_result_uses_protocol_wire_names() -> None:
wire = ListSkillsResult(skills=[make_skill()]).model_dump(
by_alias=True, mode="json", exclude_none=True
)
assert wire["resultType"] == "complete"
assert wire["ttlMs"] == 0
assert wire["cacheScope"] == "private"
def test_get_result_uses_protocol_cache_fields() -> None:
wire = GetSkillResult(skill=make_skill()).model_dump(
by_alias=True, mode="json", exclude_none=True
)
assert wire["resultType"] == "complete"
assert wire["ttlMs"] == 0
assert wire["cacheScope"] == "private"