Convert skills providers to the Skills plugin (#4017)

This commit is contained in:
Jeremiah Lowin 2026-04-22 15:43:53 -04:00 committed by GitHub
commit 18ddf28b79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1203 additions and 816 deletions

View file

@ -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

View file

@ -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"]

View file

@ -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,
)

View file

@ -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)})"
)

View file

@ -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,
)

View file

@ -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})"
)

View file

@ -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,
)

View file

@ -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",
]

View file

@ -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"]

View file

@ -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"]

View file

@ -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"]

View file

@ -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="<name>"))` 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="<name>"))`. 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",
]

View file

@ -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,
vendorpath 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

View file

@ -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:

View file

@ -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,

View file

@ -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,