diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dd3574432..3a193a5d3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,8 +13,11 @@ repos: types_or: [yaml, json5] - repo: https://github.com/astral-sh/ruff-pre-commit - # Ruff version. - rev: v0.14.10 + # Ruff version. Keep in sync with the `ruff` pin in uv.lock so + # `uv run ruff format` locally and `prek run` / CI use the same + # ruleset — otherwise minor-version drift produces line-join and + # trailing-comma diffs that only show up in CI. + rev: v0.15.8 hooks: # Run the linter. - id: ruff-check diff --git a/src/fastmcp/server/plugins/skills/__init__.py b/src/fastmcp/server/plugins/skills/__init__.py new file mode 100644 index 000000000..518507a31 --- /dev/null +++ b/src/fastmcp/server/plugins/skills/__init__.py @@ -0,0 +1,15 @@ +"""Skills plugin — expose agent skill folders as MCP resources. + + from fastmcp import FastMCP + from fastmcp.server.plugins.skills import Skills, SkillsConfig + + mcp = FastMCP("skills", plugins=[Skills(SkillsConfig(vendor="claude"))]) + +The underlying `SkillProvider` and `SkillsDirectoryProvider` classes +live on `.skill_provider` and `.directory_provider` submodules for +direct-composition use cases; the plugin is the canonical entry point. +""" + +from fastmcp.server.plugins.skills.plugin import Skills, SkillsConfig + +__all__ = ["Skills", "SkillsConfig"] diff --git a/src/fastmcp/server/providers/skills/_common.py b/src/fastmcp/server/plugins/skills/_common.py similarity index 100% rename from src/fastmcp/server/providers/skills/_common.py rename to src/fastmcp/server/plugins/skills/_common.py diff --git a/src/fastmcp/server/plugins/skills/claude_provider.py b/src/fastmcp/server/plugins/skills/claude_provider.py new file mode 100644 index 000000000..f7a96caff --- /dev/null +++ b/src/fastmcp/server/plugins/skills/claude_provider.py @@ -0,0 +1,44 @@ +"""Claude-specific skills provider for Claude Code skills.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider + + +class ClaudeSkillsProvider(SkillsDirectoryProvider): + """Provider for Claude Code skills from ~/.claude/skills/. + + A convenience subclass that sets the default root to Claude's skills location. + + Args: + reload: If True, re-scan on every request. Defaults to False. + supporting_files: How supporting files are exposed: + - "template": Accessed via ResourceTemplate, hidden from list_resources(). + - "resources": Each file exposed as individual Resource in list_resources(). + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.skills import ClaudeSkillsProvider + + mcp = FastMCP("Claude Skills") + mcp.add_provider(ClaudeSkillsProvider()) # Uses default location + ``` + """ + + def __init__( + self, + reload: bool = False, + supporting_files: Literal["template", "resources"] = "template", + ) -> None: + root = Path.home() / ".claude" / "skills" + + super().__init__( + roots=[root], + reload=reload, + main_file_name="SKILL.md", + supporting_files=supporting_files, + ) diff --git a/src/fastmcp/server/plugins/skills/directory_provider.py b/src/fastmcp/server/plugins/skills/directory_provider.py new file mode 100644 index 000000000..673d562b0 --- /dev/null +++ b/src/fastmcp/server/plugins/skills/directory_provider.py @@ -0,0 +1,153 @@ +"""Directory scanning provider for discovering multiple skills.""" + +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import Literal + +from fastmcp.resources.base import Resource +from fastmcp.resources.template import ResourceTemplate +from fastmcp.server.plugins.skills.skill_provider import SkillProvider +from fastmcp.server.providers.aggregate import AggregateProvider +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.versions import VersionSpec + +logger = get_logger(__name__) + + +class SkillsDirectoryProvider(AggregateProvider): + """Provider that scans directories and creates a SkillProvider per skill folder. + + This extends AggregateProvider to combine multiple SkillProviders into one. + Each subdirectory containing a main file (default: SKILL.md) becomes a skill. + Can scan multiple root directories - if a skill name appears in multiple roots, + the first one found wins. + + Args: + roots: Root directory(ies) containing skill folders. Can be a single path + or a sequence of paths. + reload: If True, re-discover skills on each request. Defaults to False. + main_file_name: Name of the main skill file. Defaults to "SKILL.md". + supporting_files: How supporting files are exposed in child SkillProviders: + - "template": Accessed via ResourceTemplate, hidden from list_resources(). + - "resources": Each file exposed as individual Resource in list_resources(). + + Example: + ```python + from pathlib import Path + from fastmcp import FastMCP + from fastmcp.server.plugins.skills import SkillsDirectoryProvider + + mcp = FastMCP("Skills") + # Single directory + mcp.add_provider(SkillsDirectoryProvider( + roots=Path.home() / ".claude" / "skills", + reload=True, # Re-scan on each request + )) + # Multiple directories + mcp.add_provider(SkillsDirectoryProvider( + roots=[Path("/etc/skills"), Path.home() / ".local" / "skills"], + )) + ``` + """ + + def __init__( + self, + roots: str | Path | Sequence[str | Path], + reload: bool = False, + main_file_name: str = "SKILL.md", + supporting_files: Literal["template", "resources"] = "template", + ) -> None: + super().__init__() + # Normalize to sequence: single path becomes list + if isinstance(roots, (str, Path)): + roots = [roots] + + self._roots = [Path(r).resolve() for r in roots] + self._reload = reload + self._main_file_name = main_file_name + self._supporting_files = supporting_files + self._discovered = False + + # Discover skills at init + self._discover_skills() + + def _discover_skills(self) -> None: + """Scan root directories and create SkillProvider per valid skill folder.""" + # Clear existing providers if reloading + self.providers.clear() + + seen_skill_names: set[str] = set() + + for root in self._roots: + if not root.exists(): + logger.debug(f"Skills root does not exist: {root}") + continue + + for skill_dir in root.iterdir(): + if not skill_dir.is_dir(): + continue + + main_file = skill_dir / self._main_file_name + if not main_file.exists(): + continue + + skill_name = skill_dir.name + # Skip if we've already seen this skill name (first wins) + if skill_name in seen_skill_names: + logger.debug( + f"Skipping duplicate skill '{skill_name}' from {root} " + f"(already found in earlier root)" + ) + continue + + try: + provider = SkillProvider( + skill_path=skill_dir, + main_file_name=self._main_file_name, + supporting_files=self._supporting_files, + ) + self.providers.append(provider) + seen_skill_names.add(skill_name) + except (FileNotFoundError, PermissionError, OSError): + logger.exception(f"Failed to load skill: {skill_dir.name}") + + self._discovered = True + logger.debug( + f"SkillsDirectoryProvider loaded {len(self.providers)} skills " + f"from {len(self._roots)} root(s)" + ) + + async def _ensure_discovered(self) -> None: + """Ensure skills are discovered, rediscovering if reload is enabled.""" + if self._reload or not self._discovered: + self._discover_skills() + + # Override list methods to support reload + async def _list_resources(self) -> Sequence[Resource]: + await self._ensure_discovered() + return await super()._list_resources() + + async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: + await self._ensure_discovered() + return await super()._list_resource_templates() + + async def _get_resource( + self, uri: str, version: VersionSpec | None = None + ) -> Resource | None: + await self._ensure_discovered() + return await super()._get_resource(uri, version) + + async def _get_resource_template( + self, uri: str, version: VersionSpec | None = None + ) -> ResourceTemplate | None: + await self._ensure_discovered() + return await super()._get_resource_template(uri, version) + + def __repr__(self) -> str: + roots_repr = self._roots[0] if len(self._roots) == 1 else self._roots + return ( + f"SkillsDirectoryProvider(roots={roots_repr!r}, " + f"reload={self._reload}, skills={len(self.providers)})" + ) diff --git a/src/fastmcp/server/plugins/skills/plugin.py b/src/fastmcp/server/plugins/skills/plugin.py new file mode 100644 index 000000000..3176d123a --- /dev/null +++ b/src/fastmcp/server/plugins/skills/plugin.py @@ -0,0 +1,159 @@ +"""Skills plugin: expose agent skill folders as MCP resources.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict + +from fastmcp.server.plugins.base import Plugin +from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider +from fastmcp.server.plugins.skills.skill_provider import SkillProvider +from fastmcp.server.providers import Provider + +# Vendor-name → list of skill-root paths. Captures the same preset +# paths the vendor subclasses (`ClaudeSkillsProvider`, `CursorSkillsProvider`, +# etc.) used to hardcode. The dict lets `Skills(SkillsConfig(vendor="claude"))` +# replace seven separate subclass names with one plugin + an enum value. +VENDOR_PATHS: dict[str, list[Path]] = { + "claude": [Path.home() / ".claude" / "skills"], + "cursor": [Path.home() / ".cursor" / "skills"], + # VSCode and Copilot both resolve to ~/.copilot/skills in the pre-plugin + # vendor subclasses; preserved verbatim for backcompat. + "vscode": [Path.home() / ".copilot" / "skills"], + "copilot": [Path.home() / ".copilot" / "skills"], + "codex": [Path("/etc/codex/skills"), Path.home() / ".codex" / "skills"], + "gemini": [Path.home() / ".gemini" / "skills"], + "goose": [Path.home() / ".config" / "agents" / "skills"], + "opencode": [Path.home() / ".config" / "opencode" / "skills"], +} + +Vendor = Literal[ + "claude", + "copilot", + "codex", + "cursor", + "gemini", + "goose", + "opencode", + "vscode", +] + + +class SkillsConfig(BaseModel): + """Config model for the `Skills` plugin. + + Exactly one of `path`, `directory`, or `vendor` must be set. The + check fires when the plugin builds its provider, not at config + construction, so `SkillsConfig()` with no args still satisfies the + plugin-framework's defaults-are-instantiable contract. + """ + + model_config = ConfigDict(extra="forbid") + + path: str | None = None + """Path to a single skill folder. Equivalent to the old + `SkillProvider(path)` construction.""" + + directory: str | list[str] | None = None + """One or more directories to scan for skill subfolders. Equivalent + to `SkillsDirectoryProvider(roots=...)`.""" + + vendor: Vendor | None = None + """Preset for a known vendor tool — resolves to that tool's + conventional skills directory. Covers the set that the old + `ClaudeSkillsProvider`, `CursorSkillsProvider`, etc. subclasses + hardcoded.""" + + reload: bool = False + """Re-scan on each request. Useful in development; leave off in + production where the skill catalog doesn't change.""" + + main_file_name: str = "SKILL.md" + """Name of the main file inside a skill folder.""" + + supporting_files: Literal["template", "resources"] = "template" + """How non-main files inside a skill folder are exposed. + + - `"template"`: accessed via a single `ResourceTemplate`, hidden + from `list_resources()`. + - `"resources"`: each file becomes its own `Resource` in + `list_resources()`. + """ + + +class Skills(Plugin[SkillsConfig]): + """Mount agent skill folders as MCP resources. + + One plugin covers all three entry points the pre-plugin API + exposed as separate provider classes: single-folder, + scan-a-directory, and vendor-preset. + + Example: + ```python + from fastmcp import FastMCP + from fastmcp.server.plugins.skills import Skills, SkillsConfig + + # Vendor preset — the common case: + mcp = FastMCP( + "skills", + plugins=[Skills(SkillsConfig(vendor="claude"))], + ) + + # Custom directory: + mcp = FastMCP( + "skills", + plugins=[Skills(SkillsConfig(directory="./skills"))], + ) + + # Single skill folder: + mcp = FastMCP( + "skills", + plugins=[Skills(SkillsConfig(path="./skills/pdf-processing"))], + ) + ``` + """ + + def providers(self) -> list[Provider]: + return [self._build_provider()] + + def _build_provider(self) -> Provider: + sources_set = sum( + bool(x) + for x in (self.config.path, self.config.directory, self.config.vendor) + ) + if sources_set == 0: + raise ValueError( + "SkillsConfig requires one of `path`, `directory`, or `vendor`." + ) + if sources_set > 1: + raise ValueError( + "SkillsConfig requires exactly one of `path`, `directory`, or " + "`vendor` — got multiple." + ) + + if self.config.path is not None: + return SkillProvider( + skill_path=self.config.path, + main_file_name=self.config.main_file_name, + supporting_files=self.config.supporting_files, + ) + + if self.config.vendor is not None: + roots: Any = VENDOR_PATHS[self.config.vendor] + else: + # directory mode — accept str or list[str] + assert self.config.directory is not None + roots = ( + [self.config.directory] + if isinstance(self.config.directory, str) + else list(self.config.directory) + ) + + return SkillsDirectoryProvider( + roots=roots, + reload=self.config.reload, + main_file_name=self.config.main_file_name, + supporting_files=self.config.supporting_files, + ) diff --git a/src/fastmcp/server/plugins/skills/skill_provider.py b/src/fastmcp/server/plugins/skills/skill_provider.py new file mode 100644 index 000000000..c8eca8071 --- /dev/null +++ b/src/fastmcp/server/plugins/skills/skill_provider.py @@ -0,0 +1,449 @@ +"""Basic skill provider for handling a single skill folder.""" + +from __future__ import annotations + +import json +import mimetypes +from collections.abc import Sequence +from pathlib import Path +from typing import Any, Literal, cast + +from pydantic import AnyUrl + +from fastmcp.resources.base import Resource, ResourceResult +from fastmcp.resources.template import ResourceTemplate +from fastmcp.server.plugins.skills._common import ( + SkillInfo, + parse_frontmatter, + scan_skill_files, +) +from fastmcp.server.providers.base import Provider +from fastmcp.utilities.logging import get_logger +from fastmcp.utilities.versions import VersionSpec + +logger = get_logger(__name__) + +# Ensure .md is recognized as text/markdown on all platforms (Windows may not have this) +mimetypes.add_type("text/markdown", ".md") + + +# ----------------------------------------------------------------------------- +# Skill-specific Resource and ResourceTemplate subclasses +# ----------------------------------------------------------------------------- + + +class SkillResource(Resource): + """A resource representing a skill's main file or manifest.""" + + skill_info: SkillInfo + is_manifest: bool = False + + def get_meta(self) -> dict[str, Any]: + meta = super().get_meta() + fastmcp = cast(dict[str, Any], meta["fastmcp"]) + fastmcp["skill"] = { + "name": self.skill_info.name, + "is_manifest": self.is_manifest, + } + return meta + + async def read(self) -> str | bytes | ResourceResult: + """Read the resource content.""" + if self.is_manifest: + return self._generate_manifest() + else: + main_file_path = self.skill_info.path / self.skill_info.main_file + return main_file_path.read_text() + + def _generate_manifest(self) -> str: + """Generate JSON manifest for the skill.""" + manifest = { + "skill": self.skill_info.name, + "files": [ + {"path": f.path, "size": f.size, "hash": f.hash} + for f in self.skill_info.files + ], + } + return json.dumps(manifest, indent=2) + + +class SkillFileTemplate(ResourceTemplate): + """A template for accessing files within a skill.""" + + skill_info: SkillInfo + + async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult: + """Read a file from the skill directory.""" + file_path = arguments.get("path", "") + full_path = self.skill_info.path / file_path + + # Security: ensure path doesn't escape skill directory + try: + full_path = full_path.resolve() + if not full_path.is_relative_to(self.skill_info.path): + raise ValueError(f"Path {file_path} escapes skill directory") + except ValueError as e: + raise ValueError(f"Invalid path: {e}") from e + + if not full_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + if not full_path.is_file(): + raise ValueError(f"Not a file: {file_path}") + + # Determine if binary or text based on mime type + mime_type, _ = mimetypes.guess_type(str(full_path)) + if mime_type and mime_type.startswith("text/"): + return full_path.read_text() + else: + return full_path.read_bytes() + + async def _read( # type: ignore[override] + self, + uri: str, + params: dict[str, Any], + task_meta: Any = None, + ) -> ResourceResult: # ty:ignore[invalid-method-override] + """Server entry point - read file directly without creating ephemeral resource. + + Note: task_meta is ignored - this template doesn't support background tasks. + """ + # Call read() directly and convert to ResourceResult + result = await self.read(arguments=params) + return self.convert_result(result) + + async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: + """Create a resource for the given URI and parameters. + + Note: This is not typically used since _read() handles file reading directly. + Provided for compatibility with the ResourceTemplate interface. + """ + file_path = params.get("path", "") + full_path = (self.skill_info.path / file_path).resolve() + + # Security: ensure path doesn't escape skill directory + if not full_path.is_relative_to(self.skill_info.path): + raise ValueError(f"Path {file_path} escapes skill directory") + + mime_type, _ = mimetypes.guess_type(str(full_path)) + + # Create a SkillFileResource that can read the file + return SkillFileResource( + uri=AnyUrl(uri), + name=f"{self.skill_info.name}/{file_path}", + description=f"File from {self.skill_info.name} skill", + mime_type=mime_type or "application/octet-stream", + skill_info=self.skill_info, + file_path=file_path, + ) + + +class SkillFileResource(Resource): + """A resource representing a specific file within a skill.""" + + skill_info: SkillInfo + file_path: str + + def get_meta(self) -> dict[str, Any]: + meta = super().get_meta() + fastmcp = cast(dict[str, Any], meta["fastmcp"]) + fastmcp["skill"] = { + "name": self.skill_info.name, + } + return meta + + async def read(self) -> str | bytes | ResourceResult: + """Read the file content.""" + full_path = self.skill_info.path / self.file_path + + # Security check + full_path = full_path.resolve() + if not full_path.is_relative_to(self.skill_info.path): + raise ValueError(f"Path {self.file_path} escapes skill directory") + + if not full_path.exists(): + raise FileNotFoundError(f"File not found: {self.file_path}") + + mime_type, _ = mimetypes.guess_type(str(full_path)) + if mime_type and mime_type.startswith("text/"): + return full_path.read_text() + else: + return full_path.read_bytes() + + +# ----------------------------------------------------------------------------- +# SkillProvider - handles a SINGLE skill folder +# ----------------------------------------------------------------------------- + + +class SkillProvider(Provider): + """Provider that exposes a single skill folder as MCP resources. + + Each skill folder must contain a main file (default: SKILL.md) and may + contain additional supporting files. + + Exposes: + - A Resource for the main file (skill://{name}/SKILL.md) + - A Resource for the synthetic manifest (skill://{name}/_manifest) + - Supporting files via ResourceTemplate or Resources (configurable) + + Args: + skill_path: Path to the skill directory. + main_file_name: Name of the main skill file. Defaults to "SKILL.md". + supporting_files: How supporting files (everything except main file and + manifest) are exposed to clients: + - "template": Accessed via ResourceTemplate, hidden from list_resources(). + Clients discover files by reading the manifest first. + - "resources": Each file exposed as individual Resource in list_resources(). + Full enumeration upfront. + + Example: + ```python + from pathlib import Path + from fastmcp import FastMCP + from fastmcp.server.plugins.skills import SkillProvider + + mcp = FastMCP("My Skill") + mcp.add_provider(SkillProvider( + Path.home() / ".claude/skills/pdf-processing" + )) + ``` + """ + + def __init__( + self, + skill_path: str | Path, + main_file_name: str = "SKILL.md", + supporting_files: Literal["template", "resources"] = "template", + ) -> None: + super().__init__() + self._skill_path = Path(skill_path).resolve() + self._main_file_name = main_file_name + self._supporting_files = supporting_files + self._skill_info: SkillInfo | None = None + + # Load at init to catch errors early + self._load_skill() + + def _load_skill(self) -> None: + """Load and parse the skill directory.""" + main_file = self._skill_path / self._main_file_name + + if not self._skill_path.exists(): + raise FileNotFoundError(f"Skill directory not found: {self._skill_path}") + + if not main_file.exists(): + raise FileNotFoundError( + f"Main skill file not found: {main_file}. " + f"Expected {self._main_file_name} in {self._skill_path}" + ) + + content = main_file.read_text() + frontmatter, body = parse_frontmatter(content) + + # Get description from frontmatter or first non-empty line + description = frontmatter.get("description", "") + if not description: + for line in body.strip().split("\n"): + line = line.strip() + if line and not line.startswith("#"): + description = line[:200] + break + elif line.startswith("#"): + description = line.lstrip("#").strip()[:200] + break + + # Scan all files in the skill directory + files = scan_skill_files(self._skill_path) + + self._skill_info = SkillInfo( + name=self._skill_path.name, + description=description or f"Skill: {self._skill_path.name}", + path=self._skill_path, + main_file=self._main_file_name, + files=files, + frontmatter=frontmatter, + ) + + logger.debug(f"SkillProvider loaded skill: {self._skill_info.name}") + + @property + def skill_info(self) -> SkillInfo: + """Get the loaded skill info.""" + if self._skill_info is None: + raise RuntimeError("Skill not loaded") + return self._skill_info + + # ------------------------------------------------------------------------- + # Provider interface implementation + # ------------------------------------------------------------------------- + + async def _list_resources(self) -> Sequence[Resource]: + """List skill resources.""" + skill = self.skill_info + resources: list[Resource] = [] + + # Main skill file + resources.append( + SkillResource( + uri=AnyUrl(f"skill://{skill.name}/{self._main_file_name}"), + name=f"{skill.name}/{self._main_file_name}", + description=skill.description, + mime_type="text/markdown", + skill_info=skill, + is_manifest=False, + ) + ) + + # Synthetic manifest + resources.append( + SkillResource( + uri=AnyUrl(f"skill://{skill.name}/_manifest"), + name=f"{skill.name}/_manifest", + description=f"File listing for {skill.name}", + mime_type="application/json", + skill_info=skill, + is_manifest=True, + ) + ) + + # If supporting_files="resources", add all supporting files as resources + if self._supporting_files == "resources": + for file_info in skill.files: + # Skip main file and manifest (already added) + if file_info.path == self._main_file_name: + continue + + mime_type, _ = mimetypes.guess_type(file_info.path) + resources.append( + SkillFileResource( + uri=AnyUrl(f"skill://{skill.name}/{file_info.path}"), + name=f"{skill.name}/{file_info.path}", + description=f"File from {skill.name} skill", + mime_type=mime_type or "application/octet-stream", + skill_info=skill, + file_path=file_info.path, + ) + ) + + return resources + + async def _get_resource( + self, uri: str, version: VersionSpec | None = None + ) -> Resource | None: + """Get a resource by URI.""" + skill = self.skill_info + + # Parse URI: skill://{skill_name}/{file_path} + if not uri.startswith("skill://"): + return None + + path_part = uri[len("skill://") :] + parts = path_part.split("/", 1) + if len(parts) != 2: + return None + + skill_name, file_path = parts + if skill_name != skill.name: + return None + + if file_path == "_manifest": + return SkillResource( + uri=AnyUrl(uri), + name=f"{skill_name}/_manifest", + description=f"File listing for {skill_name}", + mime_type="application/json", + skill_info=skill, + is_manifest=True, + ) + elif file_path == self._main_file_name: + return SkillResource( + uri=AnyUrl(uri), + name=f"{skill_name}/{self._main_file_name}", + description=skill.description, + mime_type="text/markdown", + skill_info=skill, + is_manifest=False, + ) + elif self._supporting_files == "resources": + # Check if it's a known supporting file + for file_info in skill.files: + if file_info.path == file_path: + mime_type, _ = mimetypes.guess_type(file_path) + return SkillFileResource( + uri=AnyUrl(uri), + name=f"{skill_name}/{file_path}", + description=f"File from {skill_name} skill", + mime_type=mime_type or "application/octet-stream", + skill_info=skill, + file_path=file_path, + ) + + return None + + async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: + """List resource templates for accessing files within the skill.""" + # Only expose template if supporting_files="template" + if self._supporting_files != "template": + return [] + + skill = self.skill_info + return [ + SkillFileTemplate( + uri_template=f"skill://{skill.name}/{{path*}}", + name=f"{skill.name}_files", + description=f"Access files within {skill.name}", + mime_type="application/octet-stream", + parameters={ + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + }, + skill_info=skill, + ) + ] + + async def _get_resource_template( + self, uri: str, version: VersionSpec | None = None + ) -> ResourceTemplate | None: + """Get a resource template that matches the given URI.""" + # Only match if supporting_files="template" + if self._supporting_files != "template": + return None + + skill = self.skill_info + + if not uri.startswith("skill://"): + return None + + path_part = uri[len("skill://") :] + parts = path_part.split("/", 1) + if len(parts) != 2: + return None + + skill_name, file_path = parts + if skill_name != skill.name: + return None + + # Don't match known resources (main file, manifest) + if file_path == "_manifest" or file_path == self._main_file_name: + return None + + return SkillFileTemplate( + uri_template=f"skill://{skill.name}/{{path*}}", + name=f"{skill.name}_files", + description=f"Access files within {skill.name}", + mime_type="application/octet-stream", + parameters={ + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + }, + skill_info=skill, + ) + + def __repr__(self) -> str: + return ( + f"SkillProvider(skill_path={self._skill_path!r}, " + f"supporting_files={self._supporting_files!r})" + ) diff --git a/src/fastmcp/server/plugins/skills/vendor_providers.py b/src/fastmcp/server/plugins/skills/vendor_providers.py new file mode 100644 index 000000000..b11e72839 --- /dev/null +++ b/src/fastmcp/server/plugins/skills/vendor_providers.py @@ -0,0 +1,142 @@ +"""Vendor-specific skills providers for various AI coding platforms.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider + + +class CursorSkillsProvider(SkillsDirectoryProvider): + """Cursor skills from ~/.cursor/skills/.""" + + def __init__( + self, + reload: bool = False, + supporting_files: Literal["template", "resources"] = "template", + ) -> None: + root = Path.home() / ".cursor" / "skills" + + super().__init__( + roots=[root], + reload=reload, + main_file_name="SKILL.md", + supporting_files=supporting_files, + ) + + +class VSCodeSkillsProvider(SkillsDirectoryProvider): + """VS Code skills from ~/.copilot/skills/.""" + + def __init__( + self, + reload: bool = False, + supporting_files: Literal["template", "resources"] = "template", + ) -> None: + root = Path.home() / ".copilot" / "skills" + + super().__init__( + roots=[root], + reload=reload, + main_file_name="SKILL.md", + supporting_files=supporting_files, + ) + + +class CodexSkillsProvider(SkillsDirectoryProvider): + """Codex skills from /etc/codex/skills/ and ~/.codex/skills/. + + Scans both system-level and user-level directories. System skills take + precedence if duplicates exist. + """ + + def __init__( + self, + reload: bool = False, + supporting_files: Literal["template", "resources"] = "template", + ) -> None: + system_root = Path("/etc/codex/skills") + user_root = Path.home() / ".codex" / "skills" + + # Include both paths (system first, then user) + roots = [system_root, user_root] + + super().__init__( + roots=roots, + reload=reload, + main_file_name="SKILL.md", + supporting_files=supporting_files, + ) + + +class GeminiSkillsProvider(SkillsDirectoryProvider): + """Gemini skills from ~/.gemini/skills/.""" + + def __init__( + self, + reload: bool = False, + supporting_files: Literal["template", "resources"] = "template", + ) -> None: + root = Path.home() / ".gemini" / "skills" + + super().__init__( + roots=[root], + reload=reload, + main_file_name="SKILL.md", + supporting_files=supporting_files, + ) + + +class GooseSkillsProvider(SkillsDirectoryProvider): + """Goose skills from ~/.config/agents/skills/.""" + + def __init__( + self, + reload: bool = False, + supporting_files: Literal["template", "resources"] = "template", + ) -> None: + root = Path.home() / ".config" / "agents" / "skills" + + super().__init__( + roots=[root], + reload=reload, + main_file_name="SKILL.md", + supporting_files=supporting_files, + ) + + +class CopilotSkillsProvider(SkillsDirectoryProvider): + """GitHub Copilot skills from ~/.copilot/skills/.""" + + def __init__( + self, + reload: bool = False, + supporting_files: Literal["template", "resources"] = "template", + ) -> None: + root = Path.home() / ".copilot" / "skills" + + super().__init__( + roots=[root], + reload=reload, + main_file_name="SKILL.md", + supporting_files=supporting_files, + ) + + +class OpenCodeSkillsProvider(SkillsDirectoryProvider): + """OpenCode skills from ~/.config/opencode/skills/.""" + + def __init__( + self, + reload: bool = False, + supporting_files: Literal["template", "resources"] = "template", + ) -> None: + root = Path.home() / ".config" / "opencode" / "skills" + + super().__init__( + roots=[root], + reload=reload, + main_file_name="SKILL.md", + supporting_files=supporting_files, + ) diff --git a/src/fastmcp/server/providers/skills/__init__.py b/src/fastmcp/server/providers/skills/__init__.py index b15c1c636..9c1f31408 100644 --- a/src/fastmcp/server/providers/skills/__init__.py +++ b/src/fastmcp/server/providers/skills/__init__.py @@ -1,35 +1,23 @@ -"""Skills providers for exposing agent skills as MCP resources. +"""Backwards-compatibility shim — skills providers moved to `fastmcp.server.plugins.skills`. -This module provides a two-layer architecture for skill discovery: +The preferred entry point is now the `Skills` plugin: -- **SkillProvider**: Handles a single skill folder, exposing its files as resources. -- **SkillsDirectoryProvider**: Scans a directory, creates a SkillProvider per folder. -- **Vendor providers**: Platform-specific providers for Claude, Cursor, VS Code, Codex, - Gemini, Goose, Copilot, and OpenCode. - -Example: - ```python - from pathlib import Path from fastmcp import FastMCP - from fastmcp.server.providers.skills import ClaudeSkillsProvider, SkillProvider + from fastmcp.server.plugins.skills import Skills, SkillsConfig - mcp = FastMCP("Skills Server") + mcp = FastMCP("skills", plugins=[Skills(SkillsConfig(vendor="claude"))]) - # Load a single skill - mcp.add_provider(SkillProvider(Path.home() / ".claude/skills/pdf-processing")) - - # Or load all skills in a directory - mcp.add_provider(ClaudeSkillsProvider()) # Uses ~/.claude/skills/ - ``` +The underlying `SkillProvider`, `SkillsDirectoryProvider`, and the +vendor subclasses (`ClaudeSkillsProvider`, `CursorSkillsProvider`, etc.) +remain importable from this package for direct composition. The +top-level import path is silent; importing from the leaf submodules +emits a `FastMCPDeprecationWarning`. """ -from __future__ import annotations - -# Import providers -from fastmcp.server.providers.skills.claude_provider import ClaudeSkillsProvider -from fastmcp.server.providers.skills.directory_provider import SkillsDirectoryProvider -from fastmcp.server.providers.skills.skill_provider import SkillProvider -from fastmcp.server.providers.skills.vendor_providers import ( +from fastmcp.server.plugins.skills.claude_provider import ClaudeSkillsProvider +from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider +from fastmcp.server.plugins.skills.skill_provider import SkillProvider +from fastmcp.server.plugins.skills.vendor_providers import ( CodexSkillsProvider, CopilotSkillsProvider, CursorSkillsProvider, @@ -39,11 +27,9 @@ from fastmcp.server.providers.skills.vendor_providers import ( VSCodeSkillsProvider, ) - -# Backwards compatibility alias +# Backwards-compatibility alias preserved from the original module. SkillsProvider = SkillsDirectoryProvider - __all__ = [ "ClaudeSkillsProvider", "CodexSkillsProvider", @@ -54,6 +40,6 @@ __all__ = [ "OpenCodeSkillsProvider", "SkillProvider", "SkillsDirectoryProvider", - "SkillsProvider", # Backwards compatibility alias + "SkillsProvider", "VSCodeSkillsProvider", ] diff --git a/src/fastmcp/server/providers/skills/claude_provider.py b/src/fastmcp/server/providers/skills/claude_provider.py index b7264953c..75ab8d8ad 100644 --- a/src/fastmcp/server/providers/skills/claude_provider.py +++ b/src/fastmcp/server/providers/skills/claude_provider.py @@ -1,44 +1,17 @@ -"""Claude-specific skills provider for Claude Code skills.""" +"""Deprecation shim — `ClaudeSkillsProvider` moved to `fastmcp.server.plugins.skills.claude_provider`.""" -from __future__ import annotations +import warnings -from pathlib import Path -from typing import Literal +from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp.server.plugins.skills.claude_provider import ClaudeSkillsProvider -from fastmcp.server.providers.skills.directory_provider import SkillsDirectoryProvider +warnings.warn( + "fastmcp.server.providers.skills.claude_provider has moved to " + "fastmcp.server.plugins.skills.claude_provider. Prefer the Skills " + 'plugin: `Skills(SkillsConfig(vendor="claude"))`. This old ' + "leaf-submodule import path will be removed in a future release.", + FastMCPDeprecationWarning, + stacklevel=2, +) - -class ClaudeSkillsProvider(SkillsDirectoryProvider): - """Provider for Claude Code skills from ~/.claude/skills/. - - A convenience subclass that sets the default root to Claude's skills location. - - Args: - reload: If True, re-scan on every request. Defaults to False. - supporting_files: How supporting files are exposed: - - "template": Accessed via ResourceTemplate, hidden from list_resources(). - - "resources": Each file exposed as individual Resource in list_resources(). - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.providers.skills import ClaudeSkillsProvider - - mcp = FastMCP("Claude Skills") - mcp.add_provider(ClaudeSkillsProvider()) # Uses default location - ``` - """ - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - root = Path.home() / ".claude" / "skills" - - super().__init__( - roots=[root], - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) +__all__ = ["ClaudeSkillsProvider"] diff --git a/src/fastmcp/server/providers/skills/directory_provider.py b/src/fastmcp/server/providers/skills/directory_provider.py index c390b42f5..80b807927 100644 --- a/src/fastmcp/server/providers/skills/directory_provider.py +++ b/src/fastmcp/server/providers/skills/directory_provider.py @@ -1,153 +1,18 @@ -"""Directory scanning provider for discovering multiple skills.""" +"""Deprecation shim — `SkillsDirectoryProvider` moved to `fastmcp.server.plugins.skills.directory_provider`.""" -from __future__ import annotations +import warnings -from collections.abc import Sequence -from pathlib import Path -from typing import Literal +from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider -from fastmcp.resources.base import Resource -from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.providers.aggregate import AggregateProvider -from fastmcp.server.providers.skills.skill_provider import SkillProvider -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.versions import VersionSpec +warnings.warn( + "fastmcp.server.providers.skills.directory_provider has moved to " + "fastmcp.server.plugins.skills.directory_provider. Prefer the " + "Skills plugin: `from fastmcp.server.plugins.skills import Skills`. " + "This old leaf-submodule import path will be removed in a future " + "release.", + FastMCPDeprecationWarning, + stacklevel=2, +) -logger = get_logger(__name__) - - -class SkillsDirectoryProvider(AggregateProvider): - """Provider that scans directories and creates a SkillProvider per skill folder. - - This extends AggregateProvider to combine multiple SkillProviders into one. - Each subdirectory containing a main file (default: SKILL.md) becomes a skill. - Can scan multiple root directories - if a skill name appears in multiple roots, - the first one found wins. - - Args: - roots: Root directory(ies) containing skill folders. Can be a single path - or a sequence of paths. - reload: If True, re-discover skills on each request. Defaults to False. - main_file_name: Name of the main skill file. Defaults to "SKILL.md". - supporting_files: How supporting files are exposed in child SkillProviders: - - "template": Accessed via ResourceTemplate, hidden from list_resources(). - - "resources": Each file exposed as individual Resource in list_resources(). - - Example: - ```python - from pathlib import Path - from fastmcp import FastMCP - from fastmcp.server.providers.skills import SkillsDirectoryProvider - - mcp = FastMCP("Skills") - # Single directory - mcp.add_provider(SkillsDirectoryProvider( - roots=Path.home() / ".claude" / "skills", - reload=True, # Re-scan on each request - )) - # Multiple directories - mcp.add_provider(SkillsDirectoryProvider( - roots=[Path("/etc/skills"), Path.home() / ".local" / "skills"], - )) - ``` - """ - - def __init__( - self, - roots: str | Path | Sequence[str | Path], - reload: bool = False, - main_file_name: str = "SKILL.md", - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - super().__init__() - # Normalize to sequence: single path becomes list - if isinstance(roots, (str, Path)): - roots = [roots] - - self._roots = [Path(r).resolve() for r in roots] - self._reload = reload - self._main_file_name = main_file_name - self._supporting_files = supporting_files - self._discovered = False - - # Discover skills at init - self._discover_skills() - - def _discover_skills(self) -> None: - """Scan root directories and create SkillProvider per valid skill folder.""" - # Clear existing providers if reloading - self.providers.clear() - - seen_skill_names: set[str] = set() - - for root in self._roots: - if not root.exists(): - logger.debug(f"Skills root does not exist: {root}") - continue - - for skill_dir in root.iterdir(): - if not skill_dir.is_dir(): - continue - - main_file = skill_dir / self._main_file_name - if not main_file.exists(): - continue - - skill_name = skill_dir.name - # Skip if we've already seen this skill name (first wins) - if skill_name in seen_skill_names: - logger.debug( - f"Skipping duplicate skill '{skill_name}' from {root} " - f"(already found in earlier root)" - ) - continue - - try: - provider = SkillProvider( - skill_path=skill_dir, - main_file_name=self._main_file_name, - supporting_files=self._supporting_files, - ) - self.providers.append(provider) - seen_skill_names.add(skill_name) - except (FileNotFoundError, PermissionError, OSError): - logger.exception(f"Failed to load skill: {skill_dir.name}") - - self._discovered = True - logger.debug( - f"SkillsDirectoryProvider loaded {len(self.providers)} skills " - f"from {len(self._roots)} root(s)" - ) - - async def _ensure_discovered(self) -> None: - """Ensure skills are discovered, rediscovering if reload is enabled.""" - if self._reload or not self._discovered: - self._discover_skills() - - # Override list methods to support reload - async def _list_resources(self) -> Sequence[Resource]: - await self._ensure_discovered() - return await super()._list_resources() - - async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: - await self._ensure_discovered() - return await super()._list_resource_templates() - - async def _get_resource( - self, uri: str, version: VersionSpec | None = None - ) -> Resource | None: - await self._ensure_discovered() - return await super()._get_resource(uri, version) - - async def _get_resource_template( - self, uri: str, version: VersionSpec | None = None - ) -> ResourceTemplate | None: - await self._ensure_discovered() - return await super()._get_resource_template(uri, version) - - def __repr__(self) -> str: - roots_repr = self._roots[0] if len(self._roots) == 1 else self._roots - return ( - f"SkillsDirectoryProvider(roots={roots_repr!r}, " - f"reload={self._reload}, skills={len(self.providers)})" - ) +__all__ = ["SkillsDirectoryProvider"] diff --git a/src/fastmcp/server/providers/skills/skill_provider.py b/src/fastmcp/server/providers/skills/skill_provider.py index 8e8d2cf4b..27eecdb18 100644 --- a/src/fastmcp/server/providers/skills/skill_provider.py +++ b/src/fastmcp/server/providers/skills/skill_provider.py @@ -1,449 +1,17 @@ -"""Basic skill provider for handling a single skill folder.""" +"""Deprecation shim — `SkillProvider` moved to `fastmcp.server.plugins.skills.skill_provider`.""" -from __future__ import annotations +import warnings -import json -import mimetypes -from collections.abc import Sequence -from pathlib import Path -from typing import Any, Literal, cast +from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp.server.plugins.skills.skill_provider import SkillProvider -from pydantic import AnyUrl - -from fastmcp.resources.base import Resource, ResourceResult -from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.providers.base import Provider -from fastmcp.server.providers.skills._common import ( - SkillInfo, - parse_frontmatter, - scan_skill_files, +warnings.warn( + "fastmcp.server.providers.skills.skill_provider has moved to " + "fastmcp.server.plugins.skills.skill_provider. Prefer the Skills " + "plugin: `from fastmcp.server.plugins.skills import Skills`. This " + "old leaf-submodule import path will be removed in a future release.", + FastMCPDeprecationWarning, + stacklevel=2, ) -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.versions import VersionSpec -logger = get_logger(__name__) - -# Ensure .md is recognized as text/markdown on all platforms (Windows may not have this) -mimetypes.add_type("text/markdown", ".md") - - -# ----------------------------------------------------------------------------- -# Skill-specific Resource and ResourceTemplate subclasses -# ----------------------------------------------------------------------------- - - -class SkillResource(Resource): - """A resource representing a skill's main file or manifest.""" - - skill_info: SkillInfo - is_manifest: bool = False - - def get_meta(self) -> dict[str, Any]: - meta = super().get_meta() - fastmcp = cast(dict[str, Any], meta["fastmcp"]) - fastmcp["skill"] = { - "name": self.skill_info.name, - "is_manifest": self.is_manifest, - } - return meta - - async def read(self) -> str | bytes | ResourceResult: - """Read the resource content.""" - if self.is_manifest: - return self._generate_manifest() - else: - main_file_path = self.skill_info.path / self.skill_info.main_file - return main_file_path.read_text() - - def _generate_manifest(self) -> str: - """Generate JSON manifest for the skill.""" - manifest = { - "skill": self.skill_info.name, - "files": [ - {"path": f.path, "size": f.size, "hash": f.hash} - for f in self.skill_info.files - ], - } - return json.dumps(manifest, indent=2) - - -class SkillFileTemplate(ResourceTemplate): - """A template for accessing files within a skill.""" - - skill_info: SkillInfo - - async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult: - """Read a file from the skill directory.""" - file_path = arguments.get("path", "") - full_path = self.skill_info.path / file_path - - # Security: ensure path doesn't escape skill directory - try: - full_path = full_path.resolve() - if not full_path.is_relative_to(self.skill_info.path): - raise ValueError(f"Path {file_path} escapes skill directory") - except ValueError as e: - raise ValueError(f"Invalid path: {e}") from e - - if not full_path.exists(): - raise FileNotFoundError(f"File not found: {file_path}") - - if not full_path.is_file(): - raise ValueError(f"Not a file: {file_path}") - - # Determine if binary or text based on mime type - mime_type, _ = mimetypes.guess_type(str(full_path)) - if mime_type and mime_type.startswith("text/"): - return full_path.read_text() - else: - return full_path.read_bytes() - - async def _read( # type: ignore[override] - self, - uri: str, - params: dict[str, Any], - task_meta: Any = None, - ) -> ResourceResult: # ty:ignore[invalid-method-override] - """Server entry point - read file directly without creating ephemeral resource. - - Note: task_meta is ignored - this template doesn't support background tasks. - """ - # Call read() directly and convert to ResourceResult - result = await self.read(arguments=params) - return self.convert_result(result) - - async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: - """Create a resource for the given URI and parameters. - - Note: This is not typically used since _read() handles file reading directly. - Provided for compatibility with the ResourceTemplate interface. - """ - file_path = params.get("path", "") - full_path = (self.skill_info.path / file_path).resolve() - - # Security: ensure path doesn't escape skill directory - if not full_path.is_relative_to(self.skill_info.path): - raise ValueError(f"Path {file_path} escapes skill directory") - - mime_type, _ = mimetypes.guess_type(str(full_path)) - - # Create a SkillFileResource that can read the file - return SkillFileResource( - uri=AnyUrl(uri), - name=f"{self.skill_info.name}/{file_path}", - description=f"File from {self.skill_info.name} skill", - mime_type=mime_type or "application/octet-stream", - skill_info=self.skill_info, - file_path=file_path, - ) - - -class SkillFileResource(Resource): - """A resource representing a specific file within a skill.""" - - skill_info: SkillInfo - file_path: str - - def get_meta(self) -> dict[str, Any]: - meta = super().get_meta() - fastmcp = cast(dict[str, Any], meta["fastmcp"]) - fastmcp["skill"] = { - "name": self.skill_info.name, - } - return meta - - async def read(self) -> str | bytes | ResourceResult: - """Read the file content.""" - full_path = self.skill_info.path / self.file_path - - # Security check - full_path = full_path.resolve() - if not full_path.is_relative_to(self.skill_info.path): - raise ValueError(f"Path {self.file_path} escapes skill directory") - - if not full_path.exists(): - raise FileNotFoundError(f"File not found: {self.file_path}") - - mime_type, _ = mimetypes.guess_type(str(full_path)) - if mime_type and mime_type.startswith("text/"): - return full_path.read_text() - else: - return full_path.read_bytes() - - -# ----------------------------------------------------------------------------- -# SkillProvider - handles a SINGLE skill folder -# ----------------------------------------------------------------------------- - - -class SkillProvider(Provider): - """Provider that exposes a single skill folder as MCP resources. - - Each skill folder must contain a main file (default: SKILL.md) and may - contain additional supporting files. - - Exposes: - - A Resource for the main file (skill://{name}/SKILL.md) - - A Resource for the synthetic manifest (skill://{name}/_manifest) - - Supporting files via ResourceTemplate or Resources (configurable) - - Args: - skill_path: Path to the skill directory. - main_file_name: Name of the main skill file. Defaults to "SKILL.md". - supporting_files: How supporting files (everything except main file and - manifest) are exposed to clients: - - "template": Accessed via ResourceTemplate, hidden from list_resources(). - Clients discover files by reading the manifest first. - - "resources": Each file exposed as individual Resource in list_resources(). - Full enumeration upfront. - - Example: - ```python - from pathlib import Path - from fastmcp import FastMCP - from fastmcp.server.providers.skills import SkillProvider - - mcp = FastMCP("My Skill") - mcp.add_provider(SkillProvider( - Path.home() / ".claude/skills/pdf-processing" - )) - ``` - """ - - def __init__( - self, - skill_path: str | Path, - main_file_name: str = "SKILL.md", - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - super().__init__() - self._skill_path = Path(skill_path).resolve() - self._main_file_name = main_file_name - self._supporting_files = supporting_files - self._skill_info: SkillInfo | None = None - - # Load at init to catch errors early - self._load_skill() - - def _load_skill(self) -> None: - """Load and parse the skill directory.""" - main_file = self._skill_path / self._main_file_name - - if not self._skill_path.exists(): - raise FileNotFoundError(f"Skill directory not found: {self._skill_path}") - - if not main_file.exists(): - raise FileNotFoundError( - f"Main skill file not found: {main_file}. " - f"Expected {self._main_file_name} in {self._skill_path}" - ) - - content = main_file.read_text() - frontmatter, body = parse_frontmatter(content) - - # Get description from frontmatter or first non-empty line - description = frontmatter.get("description", "") - if not description: - for line in body.strip().split("\n"): - line = line.strip() - if line and not line.startswith("#"): - description = line[:200] - break - elif line.startswith("#"): - description = line.lstrip("#").strip()[:200] - break - - # Scan all files in the skill directory - files = scan_skill_files(self._skill_path) - - self._skill_info = SkillInfo( - name=self._skill_path.name, - description=description or f"Skill: {self._skill_path.name}", - path=self._skill_path, - main_file=self._main_file_name, - files=files, - frontmatter=frontmatter, - ) - - logger.debug(f"SkillProvider loaded skill: {self._skill_info.name}") - - @property - def skill_info(self) -> SkillInfo: - """Get the loaded skill info.""" - if self._skill_info is None: - raise RuntimeError("Skill not loaded") - return self._skill_info - - # ------------------------------------------------------------------------- - # Provider interface implementation - # ------------------------------------------------------------------------- - - async def _list_resources(self) -> Sequence[Resource]: - """List skill resources.""" - skill = self.skill_info - resources: list[Resource] = [] - - # Main skill file - resources.append( - SkillResource( - uri=AnyUrl(f"skill://{skill.name}/{self._main_file_name}"), - name=f"{skill.name}/{self._main_file_name}", - description=skill.description, - mime_type="text/markdown", - skill_info=skill, - is_manifest=False, - ) - ) - - # Synthetic manifest - resources.append( - SkillResource( - uri=AnyUrl(f"skill://{skill.name}/_manifest"), - name=f"{skill.name}/_manifest", - description=f"File listing for {skill.name}", - mime_type="application/json", - skill_info=skill, - is_manifest=True, - ) - ) - - # If supporting_files="resources", add all supporting files as resources - if self._supporting_files == "resources": - for file_info in skill.files: - # Skip main file and manifest (already added) - if file_info.path == self._main_file_name: - continue - - mime_type, _ = mimetypes.guess_type(file_info.path) - resources.append( - SkillFileResource( - uri=AnyUrl(f"skill://{skill.name}/{file_info.path}"), - name=f"{skill.name}/{file_info.path}", - description=f"File from {skill.name} skill", - mime_type=mime_type or "application/octet-stream", - skill_info=skill, - file_path=file_info.path, - ) - ) - - return resources - - async def _get_resource( - self, uri: str, version: VersionSpec | None = None - ) -> Resource | None: - """Get a resource by URI.""" - skill = self.skill_info - - # Parse URI: skill://{skill_name}/{file_path} - if not uri.startswith("skill://"): - return None - - path_part = uri[len("skill://") :] - parts = path_part.split("/", 1) - if len(parts) != 2: - return None - - skill_name, file_path = parts - if skill_name != skill.name: - return None - - if file_path == "_manifest": - return SkillResource( - uri=AnyUrl(uri), - name=f"{skill_name}/_manifest", - description=f"File listing for {skill_name}", - mime_type="application/json", - skill_info=skill, - is_manifest=True, - ) - elif file_path == self._main_file_name: - return SkillResource( - uri=AnyUrl(uri), - name=f"{skill_name}/{self._main_file_name}", - description=skill.description, - mime_type="text/markdown", - skill_info=skill, - is_manifest=False, - ) - elif self._supporting_files == "resources": - # Check if it's a known supporting file - for file_info in skill.files: - if file_info.path == file_path: - mime_type, _ = mimetypes.guess_type(file_path) - return SkillFileResource( - uri=AnyUrl(uri), - name=f"{skill_name}/{file_path}", - description=f"File from {skill_name} skill", - mime_type=mime_type or "application/octet-stream", - skill_info=skill, - file_path=file_path, - ) - - return None - - async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: - """List resource templates for accessing files within the skill.""" - # Only expose template if supporting_files="template" - if self._supporting_files != "template": - return [] - - skill = self.skill_info - return [ - SkillFileTemplate( - uri_template=f"skill://{skill.name}/{{path*}}", - name=f"{skill.name}_files", - description=f"Access files within {skill.name}", - mime_type="application/octet-stream", - parameters={ - "type": "object", - "properties": {"path": {"type": "string"}}, - "required": ["path"], - }, - skill_info=skill, - ) - ] - - async def _get_resource_template( - self, uri: str, version: VersionSpec | None = None - ) -> ResourceTemplate | None: - """Get a resource template that matches the given URI.""" - # Only match if supporting_files="template" - if self._supporting_files != "template": - return None - - skill = self.skill_info - - if not uri.startswith("skill://"): - return None - - path_part = uri[len("skill://") :] - parts = path_part.split("/", 1) - if len(parts) != 2: - return None - - skill_name, file_path = parts - if skill_name != skill.name: - return None - - # Don't match known resources (main file, manifest) - if file_path == "_manifest" or file_path == self._main_file_name: - return None - - return SkillFileTemplate( - uri_template=f"skill://{skill.name}/{{path*}}", - name=f"{skill.name}_files", - description=f"Access files within {skill.name}", - mime_type="application/octet-stream", - parameters={ - "type": "object", - "properties": {"path": {"type": "string"}}, - "required": ["path"], - }, - skill_info=skill, - ) - - def __repr__(self) -> str: - return ( - f"SkillProvider(skill_path={self._skill_path!r}, " - f"supporting_files={self._supporting_files!r})" - ) +__all__ = ["SkillProvider"] diff --git a/src/fastmcp/server/providers/skills/vendor_providers.py b/src/fastmcp/server/providers/skills/vendor_providers.py index df870f161..d39d7930b 100644 --- a/src/fastmcp/server/providers/skills/vendor_providers.py +++ b/src/fastmcp/server/providers/skills/vendor_providers.py @@ -1,142 +1,38 @@ -"""Vendor-specific skills providers for various AI coding platforms.""" +"""Deprecation shim — vendor skills providers moved to `fastmcp.server.plugins.skills.vendor_providers`. -from __future__ import annotations +Prefer `Skills(SkillsConfig(vendor=""))` over the individual +vendor subclasses — one plugin entry replaces the seven hardcoded +classes. +""" -from pathlib import Path -from typing import Literal +import warnings -from fastmcp.server.providers.skills.directory_provider import SkillsDirectoryProvider +from fastmcp.exceptions import FastMCPDeprecationWarning +from fastmcp.server.plugins.skills.vendor_providers import ( + CodexSkillsProvider, + CopilotSkillsProvider, + CursorSkillsProvider, + GeminiSkillsProvider, + GooseSkillsProvider, + OpenCodeSkillsProvider, + VSCodeSkillsProvider, +) +warnings.warn( + "fastmcp.server.providers.skills.vendor_providers has moved to " + "fastmcp.server.plugins.skills.vendor_providers. Prefer the Skills " + 'plugin: `Skills(SkillsConfig(vendor=""))`. This old ' + "leaf-submodule import path will be removed in a future release.", + FastMCPDeprecationWarning, + stacklevel=2, +) -class CursorSkillsProvider(SkillsDirectoryProvider): - """Cursor skills from ~/.cursor/skills/.""" - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - root = Path.home() / ".cursor" / "skills" - - super().__init__( - roots=[root], - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) - - -class VSCodeSkillsProvider(SkillsDirectoryProvider): - """VS Code skills from ~/.copilot/skills/.""" - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - root = Path.home() / ".copilot" / "skills" - - super().__init__( - roots=[root], - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) - - -class CodexSkillsProvider(SkillsDirectoryProvider): - """Codex skills from /etc/codex/skills/ and ~/.codex/skills/. - - Scans both system-level and user-level directories. System skills take - precedence if duplicates exist. - """ - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - system_root = Path("/etc/codex/skills") - user_root = Path.home() / ".codex" / "skills" - - # Include both paths (system first, then user) - roots = [system_root, user_root] - - super().__init__( - roots=roots, - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) - - -class GeminiSkillsProvider(SkillsDirectoryProvider): - """Gemini skills from ~/.gemini/skills/.""" - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - root = Path.home() / ".gemini" / "skills" - - super().__init__( - roots=[root], - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) - - -class GooseSkillsProvider(SkillsDirectoryProvider): - """Goose skills from ~/.config/agents/skills/.""" - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - root = Path.home() / ".config" / "agents" / "skills" - - super().__init__( - roots=[root], - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) - - -class CopilotSkillsProvider(SkillsDirectoryProvider): - """GitHub Copilot skills from ~/.copilot/skills/.""" - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - root = Path.home() / ".copilot" / "skills" - - super().__init__( - roots=[root], - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) - - -class OpenCodeSkillsProvider(SkillsDirectoryProvider): - """OpenCode skills from ~/.config/opencode/skills/.""" - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - root = Path.home() / ".config" / "opencode" / "skills" - - super().__init__( - roots=[root], - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) +__all__ = [ + "CodexSkillsProvider", + "CopilotSkillsProvider", + "CursorSkillsProvider", + "GeminiSkillsProvider", + "GooseSkillsProvider", + "OpenCodeSkillsProvider", + "VSCodeSkillsProvider", +] diff --git a/tests/server/plugins/test_skills_plugin.py b/tests/server/plugins/test_skills_plugin.py new file mode 100644 index 000000000..c3a82b4dd --- /dev/null +++ b/tests/server/plugins/test_skills_plugin.py @@ -0,0 +1,133 @@ +"""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 + + 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 SkillsConfig() # must not raise + + def test_unknown_config_key_rejected(self): + with pytest.raises((ValidationError, Exception), match="forbid|extra"): + SkillsConfig(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(SkillsConfig(path=str(skill))) + providers = plugin.providers() + assert isinstance(providers[0], SkillProvider) + + def test_directory_source_builds_directory_provider(self, tmp_path: Path): + plugin = Skills(SkillsConfig(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(SkillsConfig(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(SkillsConfig(vendor=cast(Vendor, vendor))) + providers = plugin.providers() + assert isinstance(providers[0], SkillsDirectoryProvider) + + def test_no_source_fails_at_build_time(self): + plugin = Skills(SkillsConfig()) + with pytest.raises(ValueError, match="path.*directory.*vendor"): + plugin.providers() + + def test_multiple_sources_rejected(self, tmp_path: Path): + plugin = Skills(SkillsConfig(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 diff --git a/tests/server/providers/test_skills_provider.py b/tests/server/plugins/test_skills_provider.py similarity index 97% rename from tests/server/providers/test_skills_provider.py rename to tests/server/plugins/test_skills_provider.py index 97732f308..9b0534101 100644 --- a/tests/server/providers/test_skills_provider.py +++ b/tests/server/plugins/test_skills_provider.py @@ -8,13 +8,14 @@ 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 +from fastmcp.server.plugins.skills._common import parse_frontmatter +from fastmcp.server.plugins.skills.claude_provider import ClaudeSkillsProvider +from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider +from fastmcp.server.plugins.skills.skill_provider import SkillProvider + +# `SkillsProvider` was a backcompat alias for `SkillsDirectoryProvider` +# in the old providers/ package — preserve that shape for these tests. +SkillsProvider = SkillsDirectoryProvider class TestParseFrontmatter: diff --git a/tests/server/providers/test_skills_vendor_providers.py b/tests/server/plugins/test_skills_vendor_providers.py similarity index 98% rename from tests/server/providers/test_skills_vendor_providers.py rename to tests/server/plugins/test_skills_vendor_providers.py index 65959ef1d..0c897466e 100644 --- a/tests/server/providers/test_skills_vendor_providers.py +++ b/tests/server/plugins/test_skills_vendor_providers.py @@ -4,8 +4,8 @@ from __future__ import annotations from pathlib import Path -from fastmcp.server.providers.skills import ( - ClaudeSkillsProvider, +from fastmcp.server.plugins.skills.claude_provider import ClaudeSkillsProvider +from fastmcp.server.plugins.skills.vendor_providers import ( CodexSkillsProvider, CopilotSkillsProvider, CursorSkillsProvider, diff --git a/tests/utilities/test_skills.py b/tests/utilities/test_skills.py index 46170529d..3fbba3d45 100644 --- a/tests/utilities/test_skills.py +++ b/tests/utilities/test_skills.py @@ -7,7 +7,7 @@ from pathlib import Path import pytest from fastmcp import Client, FastMCP -from fastmcp.server.providers.skills import SkillsDirectoryProvider +from fastmcp.server.plugins.skills.directory_provider import SkillsDirectoryProvider from fastmcp.utilities.skills import ( SkillFile, SkillManifest,