fastmcp/tests/server/plugins/test_skills_plugin.py
2026-05-10 09:13:02 -04:00

134 lines
5.1 KiB
Python

"""Tests for the Skills plugin wrapper.
Provider behavior (skill discovery, file exposure, etc.) is covered by
`test_skills_provider.py` and `test_skills_vendor_providers.py`. This
file only covers plugin-layer concerns — config validation, meta,
vendor→path resolution, and the deprecation shim at the old import path.
"""
from __future__ import annotations
import warnings
from pathlib import Path
from typing import cast
import pytest
from pydantic import ValidationError
from fastmcp.server.plugins.skills import Skills, SkillsConfig
from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider
from fastmcp.server.plugins.skills.plugin import VENDOR_PATHS, Vendor
from fastmcp.server.plugins.skills.skill_provider import SkillProvider
class TestSkillsConfig:
def test_config_generic_binding(self):
assert Skills._config_cls is SkillsConfig
assert Skills.Config is SkillsConfig
def test_default_config_instantiable(self):
"""Defaults must pass the plugin framework's instantiate-with-no-args
contract; the source check fires at providers() time."""
assert Skills.Config() # must not raise
def test_unknown_config_key_rejected(self):
with pytest.raises((ValidationError, Exception), match="forbid|extra"):
Skills.Config(not_a_real_option=True) # ty: ignore[unknown-argument]
def test_default_meta(self):
assert Skills.meta.name == "skills"
assert Skills.meta.version is None
class TestSourceResolution:
def test_path_source_builds_skill_provider(self, tmp_path: Path):
skill = tmp_path / "my-skill"
skill.mkdir()
(skill / "SKILL.md").write_text("# My Skill")
plugin = Skills(Skills.Config(path=str(skill)))
providers = plugin.providers()
assert isinstance(providers[0], SkillProvider)
def test_directory_source_builds_directory_provider(self, tmp_path: Path):
plugin = Skills(Skills.Config(directory=str(tmp_path)))
providers = plugin.providers()
assert isinstance(providers[0], SkillsDirectoryProvider)
def test_directory_source_accepts_list(self, tmp_path: Path):
a, b = tmp_path / "a", tmp_path / "b"
a.mkdir()
b.mkdir()
plugin = Skills(Skills.Config(directory=[str(a), str(b)]))
providers = plugin.providers()
assert isinstance(providers[0], SkillsDirectoryProvider)
@pytest.mark.parametrize("vendor", list(VENDOR_PATHS))
def test_vendor_presets_resolve_to_known_paths(self, vendor: str):
"""Every vendor string must produce a directory provider rooted
at the paths the old vendor subclass used to hardcode."""
plugin = Skills(Skills.Config(vendor=cast(Vendor, vendor)))
providers = plugin.providers()
assert isinstance(providers[0], SkillsDirectoryProvider)
def test_no_source_fails_at_build_time(self):
plugin = Skills(Skills.Config())
with pytest.raises(ValueError, match="path.*directory.*vendor"):
plugin.providers()
def test_multiple_sources_rejected(self, tmp_path: Path):
plugin = Skills(Skills.Config(directory=str(tmp_path), vendor="claude"))
with pytest.raises(ValueError, match="exactly one"):
plugin.providers()
class TestDeprecationShim:
"""The old `fastmcp.server.providers.skills` package shims back to the
new plugin package. Top-level stays silent; leaf submodule imports
emit `FastMCPDeprecationWarning`."""
def test_top_level_is_silent(self):
import importlib
import sys
from fastmcp.exceptions import FastMCPDeprecationWarning
sys.modules.pop("fastmcp.server.providers.skills", None)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
importlib.import_module("fastmcp.server.providers.skills")
fastmcp_warns = [
w for w in caught if issubclass(w.category, FastMCPDeprecationWarning)
]
assert not fastmcp_warns
def test_leaf_submodule_import_emits_deprecation_warning(self):
import importlib
import sys
from fastmcp.exceptions import FastMCPDeprecationWarning
sys.modules.pop("fastmcp.server.providers.skills.vendor_providers", None)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
importlib.import_module("fastmcp.server.providers.skills.vendor_providers")
fastmcp_warns = [
w for w in caught if issubclass(w.category, FastMCPDeprecationWarning)
]
assert any("plugins.skills" in str(w.message) for w in fastmcp_warns)
def test_old_import_path_symbols_still_resolve(self):
"""`ClaudeSkillsProvider` and friends keep resolving through the
silent package-level shim."""
from fastmcp.server.plugins.skills.claude_provider import (
ClaudeSkillsProvider as NewClass,
)
from fastmcp.server.providers.skills import (
ClaudeSkillsProvider as OldClass,
)
assert OldClass is NewClass