mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 22:14:18 +02:00
Convert skills providers to the Skills plugin (#4017)
This commit is contained in:
parent
a82979e433
commit
18ddf28b79
17 changed files with 1203 additions and 816 deletions
|
|
@ -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
|
||||
|
|
|
|||
15
src/fastmcp/server/plugins/skills/__init__.py
Normal file
15
src/fastmcp/server/plugins/skills/__init__.py
Normal 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"]
|
||||
44
src/fastmcp/server/plugins/skills/claude_provider.py
Normal file
44
src/fastmcp/server/plugins/skills/claude_provider.py
Normal 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,
|
||||
)
|
||||
153
src/fastmcp/server/plugins/skills/directory_provider.py
Normal file
153
src/fastmcp/server/plugins/skills/directory_provider.py
Normal 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)})"
|
||||
)
|
||||
159
src/fastmcp/server/plugins/skills/plugin.py
Normal file
159
src/fastmcp/server/plugins/skills/plugin.py
Normal 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,
|
||||
)
|
||||
449
src/fastmcp/server/plugins/skills/skill_provider.py
Normal file
449
src/fastmcp/server/plugins/skills/skill_provider.py
Normal 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})"
|
||||
)
|
||||
142
src/fastmcp/server/plugins/skills/vendor_providers.py
Normal file
142
src/fastmcp/server/plugins/skills/vendor_providers.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
133
tests/server/plugins/test_skills_plugin.py
Normal file
133
tests/server/plugins/test_skills_plugin.py
Normal 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,
|
||||
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
|
||||
|
|
@ -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:
|
||||
|
|
@ -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,
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue