Add Skills Provider for exposing agent skills as MCP resources (#2944)

This commit is contained in:
Jeremiah Lowin 2026-01-19 18:29:16 -05:00 committed by GitHub
commit 16ffc9432f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 2398 additions and 0 deletions

View file

@ -672,6 +672,49 @@ Documentation: [FileSystemProvider](/servers/providers/filesystem)
---
## SkillsProvider
v3.0 introduces `SkillsProvider` for exposing agent skills as MCP resources ([#2944](https://github.com/jlowin/fastmcp/pull/2944)). Skills are directories containing instructions and supporting files that teach AI assistants how to perform tasks—used by Claude Code, Cursor, VS Code Copilot, and other AI coding tools.
**Usage**:
```python
from pathlib import Path
from fastmcp import FastMCP
from fastmcp.server.providers.skills import SkillsDirectoryProvider
mcp = FastMCP("Skills Server")
mcp.add_provider(SkillsDirectoryProvider(roots=Path.home() / ".claude" / "skills"))
```
Each subdirectory with a `SKILL.md` file becomes a discoverable skill. Clients see:
- `skill://{name}/SKILL.md` - Main instruction file
- `skill://{name}/_manifest` - JSON listing of all files with sizes and hashes
- `skill://{name}/{path}` - Supporting files (via template or resources)
**Two-layer architecture**:
- `SkillProvider` - Handles a single skill folder
- `SkillsDirectoryProvider` - Scans directories, creates a `SkillProvider` per valid skill
**Vendor providers** with locked default paths:
| Provider | Directory |
|----------|-----------|
| `ClaudeSkillsProvider` | `~/.claude/skills/` |
| `CursorSkillsProvider` | `~/.cursor/skills/` |
| `VSCodeSkillsProvider` | `~/.copilot/skills/` |
| `CodexSkillsProvider` | `/etc/codex/skills/`, `~/.codex/skills/` |
| `GeminiSkillsProvider` | `~/.gemini/skills/` |
| `GooseSkillsProvider` | `~/.config/agents/skills/` |
| `CopilotSkillsProvider` | `~/.copilot/skills/` |
| `OpenCodeSkillsProvider` | `~/.config/opencode/skills/` |
**Progressive disclosure**: By default, supporting files are hidden from `list_resources()` and accessed via template. Set `supporting_files="resources"` for full enumeration.
Documentation: [Skills Provider](/servers/providers/skills)
---
## OpenTelemetry Tracing
v3.0 adds OpenTelemetry instrumentation for observability into server and client operations ([#2869](https://github.com/jlowin/fastmcp/pull/2869)).

View file

@ -113,6 +113,7 @@
"servers/providers/prompts-as-tools",
"servers/providers/local",
"servers/providers/filesystem",
"servers/providers/skills",
"servers/providers/mounting",
"servers/providers/proxy",
"servers/providers/custom"

View file

@ -0,0 +1,287 @@
---
title: Skills Provider
sidebarTitle: Skills
description: Expose agent skills as MCP resources
icon: wand-magic-sparkles
---
import { VersionBadge } from '/snippets/version-badge.mdx'
<VersionBadge version="3.0.0" />
Agent skills are directories containing instructions and supporting files that teach an AI assistant how to perform specific tasks. Tools like Claude Code, Cursor, and VS Code Copilot each have their own skills directories where users can add custom capabilities. The Skills Provider exposes these skill directories as MCP resources, making skills discoverable and shareable across different AI tools and clients.
## Why Skills as Resources
Skills live in platform-specific directories (`~/.claude/skills/`, `~/.cursor/skills/`, etc.) and typically contain a main instruction file plus supporting reference materials. When you want to share skills between tools or access them from a custom client, you need a way to discover and retrieve these files programmatically.
The Skills Provider solves this by exposing each skill as a set of MCP resources. A client can list available skills, read the main instruction file, check the manifest to see what supporting files exist, and fetch any file it needs. This transforms local skill directories into a standardized API that works with any MCP client.
## Quick Start
Create a provider pointing to your skills directory, then add it to your server.
```python
from pathlib import Path
from fastmcp import FastMCP
from fastmcp.server.providers.skills import SkillsDirectoryProvider
mcp = FastMCP("Skills Server")
mcp.add_provider(SkillsDirectoryProvider(roots=Path.home() / ".claude" / "skills"))
```
Each subdirectory containing a `SKILL.md` file becomes a discoverable skill. Clients can then list resources to see available skills and read them as needed.
```python
from fastmcp import Client
async with Client(mcp) as client:
# List all skill resources
resources = await client.list_resources()
for r in resources:
print(r.uri) # skill://my-skill/SKILL.md, skill://my-skill/_manifest, ...
# Read a skill's main instruction file
result = await client.read_resource("skill://my-skill/SKILL.md")
print(result[0].text)
```
## Skill Structure
A skill is a directory containing a main instruction file (default: `SKILL.md`) and optionally supporting files. The directory name becomes the skill's identifier.
```
~/.claude/skills/
├── pdf-processing/
│ ├── SKILL.md # Main instructions
│ ├── reference.md # Supporting documentation
│ └── examples/
│ └── sample.pdf
└── code-review/
└── SKILL.md
```
The main file can include YAML frontmatter to provide metadata. If no frontmatter exists, the provider extracts a description from the first meaningful line of content.
```markdown
---
description: Process and extract information from PDF documents
---
# PDF Processing
Instructions for handling PDFs...
```
## Resource URIs
Each skill exposes three types of resources, all using the `skill://` URI scheme.
The main instruction file contains the primary skill content. This is the resource clients read to understand what a skill does and how to use it.
```
skill://pdf-processing/SKILL.md
```
The manifest is a synthetic JSON resource listing all files in the skill directory with their sizes and SHA256 hashes. Clients use this to discover supporting files and verify content integrity.
```
skill://pdf-processing/_manifest
```
Reading the manifest returns structured file information.
```json
{
"skill": "pdf-processing",
"files": [
{"path": "SKILL.md", "size": 1234, "hash": "sha256:abc123..."},
{"path": "reference.md", "size": 567, "hash": "sha256:def456..."},
{"path": "examples/sample.pdf", "size": 89012, "hash": "sha256:ghi789..."}
]
}
```
Supporting files are any additional files in the skill directory. These might be reference documentation, code examples, or binary assets.
```
skill://pdf-processing/reference.md
skill://pdf-processing/examples/sample.pdf
```
## Provider Architecture
The Skills Provider uses a two-layer architecture to handle both single skills and skill directories.
### SkillProvider
`SkillProvider` handles a single skill directory. It loads the main file, parses any frontmatter, scans for supporting files, and creates the appropriate resources.
```python
from pathlib import Path
from fastmcp import FastMCP
from fastmcp.server.providers.skills import SkillProvider
mcp = FastMCP("Single Skill")
mcp.add_provider(SkillProvider(Path.home() / ".claude" / "skills" / "pdf-processing"))
```
Use `SkillProvider` when you want to expose exactly one skill, or when you need fine-grained control over individual skill configuration.
### SkillsDirectoryProvider
`SkillsDirectoryProvider` scans one or more root directories and creates a `SkillProvider` for each valid skill folder it finds. A folder is considered a valid skill if it contains the main file (default: `SKILL.md`).
```python
from pathlib import Path
from fastmcp import FastMCP
from fastmcp.server.providers.skills import SkillsDirectoryProvider
mcp = FastMCP("Skills")
mcp.add_provider(SkillsDirectoryProvider(roots=Path.home() / ".claude" / "skills"))
```
When scanning multiple root directories, provide them as a list. The first directory takes precedence if the same skill name appears in multiple roots.
```python
from pathlib import Path
from fastmcp import FastMCP
from fastmcp.server.providers.skills import SkillsDirectoryProvider
mcp = FastMCP("Skills")
mcp.add_provider(SkillsDirectoryProvider(roots=[
Path.cwd() / ".claude" / "skills", # Project-level skills first
Path.home() / ".claude" / "skills", # User-level fallback
]))
```
## Vendor Providers
FastMCP includes pre-configured providers for popular AI coding tools. Each vendor provider extends `SkillsDirectoryProvider` with the appropriate default directory for that platform.
| Provider | Default Directory |
|----------|-------------------|
| `ClaudeSkillsProvider` | `~/.claude/skills/` |
| `CursorSkillsProvider` | `~/.cursor/skills/` |
| `VSCodeSkillsProvider` | `~/.copilot/skills/` |
| `CodexSkillsProvider` | `/etc/codex/skills/` and `~/.codex/skills/` |
| `GeminiSkillsProvider` | `~/.gemini/skills/` |
| `GooseSkillsProvider` | `~/.config/agents/skills/` |
| `CopilotSkillsProvider` | `~/.copilot/skills/` |
| `OpenCodeSkillsProvider` | `~/.config/opencode/skills/` |
Vendor providers accept the same configuration options as `SkillsDirectoryProvider` (except for `roots`, which is locked to the platform default).
```python
from fastmcp import FastMCP
from fastmcp.server.providers.skills import ClaudeSkillsProvider
mcp = FastMCP("Claude Skills")
mcp.add_provider(ClaudeSkillsProvider()) # Uses ~/.claude/skills/
```
`CodexSkillsProvider` scans both system-level (`/etc/codex/skills/`) and user-level (`~/.codex/skills/`) directories, with system skills taking precedence.
## Supporting Files Disclosure
The `supporting_files` parameter controls how supporting files (everything except the main file and manifest) appear to clients.
### Template Mode (Default)
With `supporting_files="template"`, supporting files are accessed through a `ResourceTemplate` rather than being listed as individual resources. Clients see only the main file and manifest in `list_resources()`, then discover supporting files by reading the manifest.
```python
from pathlib import Path
from fastmcp.server.providers.skills import SkillsDirectoryProvider
# Default behavior - supporting files hidden from list_resources()
provider = SkillsDirectoryProvider(
roots=Path.home() / ".claude" / "skills",
supporting_files="template", # This is the default
)
```
This keeps the resource list compact when skills contain many files. Clients that need supporting files read the manifest first, then request specific files by URI.
### Resources Mode
With `supporting_files="resources"`, every file in every skill appears as an individual resource in `list_resources()`. Clients get full enumeration upfront without needing to read manifests.
```python
from pathlib import Path
from fastmcp.server.providers.skills import SkillsDirectoryProvider
# All files visible as individual resources
provider = SkillsDirectoryProvider(
roots=Path.home() / ".claude" / "skills",
supporting_files="resources",
)
```
Use this mode when clients need to discover all available files without additional round trips, or when integrating with tools that expect flat resource lists.
## Reload Mode
Enable reload mode to re-scan the skills directory on every request. Changes to skills take effect immediately without restarting the server.
```python
from pathlib import Path
from fastmcp.server.providers.skills import SkillsDirectoryProvider
provider = SkillsDirectoryProvider(
roots=Path.home() / ".claude" / "skills",
reload=True,
)
```
With `reload=True`, the provider re-discovers skills on each `list_resources()` or `read_resource()` call. New skills appear, removed skills disappear, and modified content reflects current file state.
<Warning>
Reload mode adds overhead to every request. Use it during development when you're actively editing skills, but disable it in production.
</Warning>
## Complete Example
This example creates a server that exposes Claude Code skills, then uses a client to discover and read them.
```python
import asyncio
import json
from fastmcp import Client, FastMCP
from fastmcp.server.providers.skills import ClaudeSkillsProvider
async def main():
# Create server with Claude skills
mcp = FastMCP("Skills Demo")
mcp.add_provider(ClaudeSkillsProvider(reload=True))
async with Client(mcp) as client:
# List available skill resources
resources = await client.list_resources()
print("Available skills:")
for r in resources:
if r.uri.path and r.uri.path.endswith("SKILL.md"):
print(f" - {r.name}: {r.description}")
# Read a specific skill's manifest
manifest_result = await client.read_resource("skill://pdf-processing/_manifest")
manifest = json.loads(manifest_result[0].text)
print(f"\nFiles in pdf-processing skill:")
for f in manifest["files"]:
print(f" - {f['path']} ({f['size']} bytes)")
# Read the main skill file
skill_result = await client.read_resource("skill://pdf-processing/SKILL.md")
print(f"\nSkill content:\n{skill_result[0].text[:500]}...")
if __name__ == "__main__":
asyncio.run(main())
```

104
examples/skills/README.md Normal file
View file

@ -0,0 +1,104 @@
# Skills Provider Example
This example demonstrates how to expose agent skills (like Claude Code skills) as MCP resources.
## Structure
```
skills/
├── README.md # This file
├── server.py # MCP server that exposes skills
├── client.py # Example client that discovers and reads skills
└── sample_skills/ # Example skills directory
├── pdf-processing/
│ ├── SKILL.md # Main skill file
│ └── reference.md # Supporting documentation
└── code-review/
└── SKILL.md # Main skill file
```
## Running the Example
1. Start the server:
```bash
uv run python examples/skills/server.py
```
2. In another terminal, run the client:
```bash
uv run python examples/skills/client.py
```
## How It Works
The skills provider system has a two-layer architecture:
- **`SkillProvider`** - Handles a single skill folder, exposing its files as resources
- **`SkillsDirectoryProvider`** - Scans a directory, creates a `SkillProvider` per folder
- **`ClaudeSkillsProvider`** - Convenience subclass for Claude Code skills (~/.claude/skills/)
For each skill, the provider exposes:
- A **Resource** for the main file (`skill://{name}/SKILL.md`)
- A **Resource** for a synthetic manifest (`skill://{name}/_manifest`)
- Supporting files via **ResourceTemplate** or **Resources** (configurable)
### Progressive Disclosure
When a client lists resources, they see skill names and descriptions (from frontmatter) without fetching the full content. This keeps the discovery cost low.
By default, supporting files are exposed via ResourceTemplate (hidden from `list_resources()`). Set `supporting_files="resources"` to make them visible:
```python
SkillsDirectoryProvider(roots=skills_dir, supporting_files="resources")
```
### The Manifest
The `_manifest` resource provides a JSON listing of all files in a skill:
```json
{
"skill": "pdf-processing",
"files": [
{"path": "SKILL.md", "size": 1234, "hash": "sha256:abc..."},
{"path": "reference.md", "size": 5678, "hash": "sha256:def..."}
]
}
```
This enables clients to download entire skills for local use.
## Usage Examples
### Single Skill
```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"))
mcp.run()
```
### All Skills in a Directory
```python
from fastmcp.server.providers.skills import SkillsDirectoryProvider
mcp = FastMCP("Skills")
mcp.add_provider(SkillsDirectoryProvider(roots=Path.home() / ".claude" / "skills"))
mcp.run()
```
### Claude Code Skills (default location)
```python
from fastmcp import FastMCP
from fastmcp.server.providers.skills import ClaudeSkillsProvider
mcp = FastMCP("My Skills")
mcp.add_provider(ClaudeSkillsProvider()) # Uses ~/.claude/skills/
mcp.run()
```

66
examples/skills/client.py Normal file
View file

@ -0,0 +1,66 @@
"""Example: Skills Client
This example shows how to discover and download skills from a skills server.
Run this client (it starts its own server internally):
uv run python examples/skills/client.py
"""
import asyncio
import json
from pathlib import Path
from fastmcp import Client
from fastmcp.server.providers.skills import SkillsDirectoryProvider
async def main():
# Create a skills provider pointing at our sample skills
skills_dir = Path(__file__).parent / "sample_skills"
provider = SkillsDirectoryProvider(roots=skills_dir)
# Connect to a FastMCP server with this provider
from fastmcp import FastMCP
mcp = FastMCP("Skills Server")
mcp.add_provider(provider)
async with Client(mcp) as client:
print("Connected to skills server\n")
# List available resources
print("=== Available Resources ===")
resources = await client.list_resources()
for r in resources:
print(f" {r.uri}")
if r.description:
print(f" Description: {r.description}")
print()
# List resource templates
print("=== Resource Templates ===")
templates = await client.list_resource_templates()
for t in templates:
print(f" {t.uriTemplate}")
print()
# Read a skill's main file
print("=== Reading pdf-processing/SKILL.md ===")
result = await client.read_resource("skill://pdf-processing/SKILL.md")
print(result[0].text[:500] + "...\n")
# Read the manifest to see all files
print("=== Reading pdf-processing/_manifest ===")
result = await client.read_resource("skill://pdf-processing/_manifest")
manifest = json.loads(result[0].text)
print(json.dumps(manifest, indent=2))
print()
# Read a supporting file via template
print("=== Reading pdf-processing/reference.md ===")
result = await client.read_resource("skill://pdf-processing/reference.md")
print(result[0].text[:500] + "...\n")
if __name__ == "__main__":
asyncio.run(main())

View file

@ -0,0 +1,40 @@
---
description: Review code for quality, maintainability, and correctness
version: "1.0.0"
tags: [code, review, quality]
---
# Code Review Skill
This skill guides you through conducting thorough code reviews.
## Review Checklist
When reviewing code, consider:
### Correctness
- Does the code do what it's supposed to do?
- Are edge cases handled?
- Are there any obvious bugs?
### Maintainability
- Is the code easy to understand?
- Are variable and function names descriptive?
- Is there appropriate documentation?
### Performance
- Are there any obvious performance issues?
- Are expensive operations cached when appropriate?
- Are database queries efficient?
### Security
- Is user input validated?
- Are there any injection vulnerabilities?
- Are secrets properly managed?
## Giving Feedback
- Be specific and actionable
- Explain *why* something should change
- Suggest alternatives, don't just criticize
- Acknowledge good work too

View file

@ -0,0 +1,28 @@
---
description: Extract text from PDFs, fill forms, and merge documents
version: "1.0.0"
tags: [document, pdf, extraction]
---
# PDF Processing Skill
This skill helps you work with PDF documents.
## Capabilities
- Extract text content from PDF files
- Fill form fields in PDF documents
- Merge multiple PDFs into one
- Split PDFs into separate pages
## Usage
When working with PDFs, use the appropriate tools:
1. For text extraction: Read the PDF and parse its content
2. For forms: Identify form fields and fill them programmatically
3. For merging: Combine multiple documents in the desired order
## Additional Resources
See [reference.md](reference.md) for detailed API documentation.

View file

@ -0,0 +1,47 @@
# PDF Processing Reference
## Text Extraction
To extract text from a PDF:
```python
from pypdf import PdfReader
reader = PdfReader("document.pdf")
for page in reader.pages:
text = page.extract_text()
print(text)
```
## Form Filling
PDF forms can be filled using the form fields API:
```python
from pypdf import PdfReader, PdfWriter
reader = PdfReader("form.pdf")
writer = PdfWriter()
writer.append(reader)
writer.update_page_form_field_values(
writer.pages[0],
{"field_name": "field_value"}
)
with open("filled_form.pdf", "wb") as output:
writer.write(output)
```
## Merging PDFs
```python
from pypdf import PdfWriter
writer = PdfWriter()
writer.append("doc1.pdf")
writer.append("doc2.pdf")
with open("merged.pdf", "wb") as output:
writer.write(output)
```

49
examples/skills/server.py Normal file
View file

@ -0,0 +1,49 @@
"""Example: Skills Provider Server
This example shows how to expose agent skills as MCP resources.
Skills can be discovered, browsed, and downloaded by any MCP client.
Run this server:
uv run python examples/skills/server.py
Then use the client example to interact with it:
uv run python examples/skills/client.py
"""
from pathlib import Path
from fastmcp import FastMCP
from fastmcp.server.providers.skills import (
SkillsDirectoryProvider,
)
# Create server
mcp = FastMCP("Skills Server")
# Option 1: Load a single skill
# mcp.add_provider(SkillProvider(Path.home() / ".claude/skills/pdf-processing"))
# Option 2: Load all skills from a custom directory
skills_dir = Path(__file__).parent / "sample_skills"
mcp.add_provider(SkillsDirectoryProvider(roots=skills_dir, reload=True))
# Option 3: Load skills from a platform's default location
# mcp.add_provider(ClaudeSkillsProvider()) # ~/.claude/skills/
# Option 4: Load from multiple directories (in precedence order)
# mcp.add_provider(SkillsDirectoryProvider(roots=[
# Path.cwd() / ".claude/skills", # Project-level first
# Path.home() / ".claude/skills", # User-level fallback
# ]))
# Other vendor providers available:
# - CursorSkillsProvider() → ~/.cursor/skills/
# - VSCodeSkillsProvider() → ~/.copilot/skills/
# - CodexSkillsProvider() → ~/.codex/skills/
# - GeminiSkillsProvider() → ~/.gemini/skills/
# - GooseSkillsProvider() → ~/.config/agents/skills/
# - CopilotSkillsProvider() → ~/.copilot/skills/
# - OpenCodeSkillsProvider() → ~/.config/opencode/skills/
if __name__ == "__main__":
mcp.run()

View file

@ -32,6 +32,12 @@ from fastmcp.server.providers.base import Provider
from fastmcp.server.providers.fastmcp_provider import FastMCPProvider
from fastmcp.server.providers.filesystem import FileSystemProvider
from fastmcp.server.providers.local_provider import LocalProvider
from fastmcp.server.providers.skills import (
ClaudeSkillsProvider,
SkillProvider,
SkillsDirectoryProvider,
SkillsProvider,
)
if TYPE_CHECKING:
from fastmcp.server.providers.openapi import OpenAPIProvider as OpenAPIProvider
@ -39,12 +45,16 @@ if TYPE_CHECKING:
__all__ = [
"AggregateProvider",
"ClaudeSkillsProvider",
"FastMCPProvider",
"FileSystemProvider",
"LocalProvider",
"OpenAPIProvider",
"Provider",
"ProxyProvider",
"SkillProvider",
"SkillsDirectoryProvider",
"SkillsProvider", # Backwards compatibility alias for SkillsDirectoryProvider
]

View file

@ -0,0 +1,59 @@
"""Skills providers for exposing agent skills as MCP resources.
This module provides a two-layer architecture for skill discovery:
- **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
mcp = FastMCP("Skills Server")
# 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/
```
"""
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 (
CodexSkillsProvider,
CopilotSkillsProvider,
CursorSkillsProvider,
GeminiSkillsProvider,
GooseSkillsProvider,
OpenCodeSkillsProvider,
VSCodeSkillsProvider,
)
# Backwards compatibility alias
SkillsProvider = SkillsDirectoryProvider
__all__ = [
"ClaudeSkillsProvider",
"CodexSkillsProvider",
"CopilotSkillsProvider",
"CursorSkillsProvider",
"GeminiSkillsProvider",
"GooseSkillsProvider",
"OpenCodeSkillsProvider",
"SkillProvider",
"SkillsDirectoryProvider",
"SkillsProvider", # Backwards compatibility alias
"VSCodeSkillsProvider",
]

View file

@ -0,0 +1,101 @@
"""Shared utilities and data structures for skills providers."""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@dataclass
class SkillFileInfo:
"""Information about a file within a skill."""
path: str # Relative path within skill directory
size: int
hash: str # sha256 hash
@dataclass
class SkillInfo:
"""Parsed information about a skill."""
name: str # Directory name (canonical identifier)
description: str # From frontmatter or first line
path: Path # Absolute path to skill directory
main_file: str # Name of main file (e.g., "SKILL.md")
files: list[SkillFileInfo] = field(default_factory=list)
frontmatter: dict[str, Any] = field(default_factory=dict)
def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
"""Parse YAML frontmatter from markdown content.
Args:
content: Markdown content potentially starting with ---
Returns:
Tuple of (frontmatter dict, remaining content)
"""
if not content.startswith("---"):
return {}, content
# Find the closing ---
end_match = re.search(r"\n---\s*\n", content[3:])
if not end_match:
return {}, content
frontmatter_text = content[3 : 3 + end_match.start()]
remaining = content[3 + end_match.end() :]
# Parse YAML (simple key: value parsing, no complex types)
frontmatter: dict[str, Any] = {}
for line in frontmatter_text.strip().split("\n"):
if ":" in line:
key, _, value = line.partition(":")
key = key.strip()
value = value.strip()
# Handle quoted strings
if (value.startswith('"') and value.endswith('"')) or (
value.startswith("'") and value.endswith("'")
):
value = value[1:-1]
# Handle lists [a, b, c]
if value.startswith("[") and value.endswith("]"):
items = value[1:-1].split(",")
value = [item.strip().strip("\"'") for item in items if item.strip()]
frontmatter[key] = value
return frontmatter, remaining
def compute_file_hash(path: Path) -> str:
"""Compute SHA256 hash of a file."""
sha256 = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256.update(chunk)
return f"sha256:{sha256.hexdigest()}"
def scan_skill_files(skill_dir: Path) -> list[SkillFileInfo]:
"""Scan a skill directory for all files."""
files = []
# Sort for deterministic ordering across platforms
for file_path in sorted(skill_dir.rglob("*")):
if file_path.is_file():
rel_path = file_path.relative_to(skill_dir)
files.append(
SkillFileInfo(
# Use POSIX paths for cross-platform URI consistency
path=rel_path.as_posix(),
size=file_path.stat().st_size,
hash=compute_file_hash(file_path),
)
)
return files

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.providers.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.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,
)

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.resource 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
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)})"
)

View file

@ -0,0 +1,432 @@
"""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
from pydantic import AnyUrl
from fastmcp.resources.resource 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,
)
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
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:
"""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
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})"
)

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.providers.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

@ -0,0 +1,591 @@
"""Tests for SkillProvider, SkillsDirectoryProvider, and ClaudeSkillsProvider."""
import json
from pathlib import Path
import pytest
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
class TestParseFrontmatter:
def test_no_frontmatter(self):
content = "# Just markdown\n\nSome content."
frontmatter, body = parse_frontmatter(content)
assert frontmatter == {}
assert body == content
def test_basic_frontmatter(self):
content = """---
description: A test skill
version: "1.0.0"
---
# Skill Content
"""
frontmatter, body = parse_frontmatter(content)
assert frontmatter["description"] == "A test skill"
assert frontmatter["version"] == "1.0.0"
assert body.strip().startswith("# Skill Content")
def test_frontmatter_with_tags_list(self):
content = """---
description: Test
tags: [tag1, tag2, tag3]
---
Content
"""
frontmatter, body = parse_frontmatter(content)
assert frontmatter["tags"] == ["tag1", "tag2", "tag3"]
def test_frontmatter_with_quoted_strings(self):
content = """---
description: "A skill with quotes"
version: '2.0.0'
---
Content
"""
frontmatter, body = parse_frontmatter(content)
assert frontmatter["description"] == "A skill with quotes"
assert frontmatter["version"] == "2.0.0"
class TestSkillProvider:
"""Tests for SkillProvider - single skill folder."""
@pytest.fixture
def single_skill_dir(self, tmp_path: Path) -> Path:
"""Create a single skill directory with files."""
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
"""---
description: A test skill
version: "1.0.0"
---
# My Skill
This is my skill content.
"""
)
(skill_dir / "reference.md").write_text("# Reference\n\nExtra docs.")
(skill_dir / "scripts").mkdir()
(skill_dir / "scripts" / "helper.py").write_text('print("helper")')
return skill_dir
def test_loads_skill_at_init(self, single_skill_dir: Path):
provider = SkillProvider(skill_path=single_skill_dir)
assert provider.skill_info.name == "my-skill"
assert provider.skill_info.description == "A test skill"
assert len(provider.skill_info.files) == 3
def test_raises_if_directory_missing(self, tmp_path: Path):
with pytest.raises(FileNotFoundError, match="Skill directory not found"):
SkillProvider(skill_path=tmp_path / "nonexistent")
def test_raises_if_main_file_missing(self, tmp_path: Path):
skill_dir = tmp_path / "no-main"
skill_dir.mkdir()
with pytest.raises(FileNotFoundError, match="Main skill file not found"):
SkillProvider(skill_path=skill_dir)
async def test_list_resources_default_template_mode(self, single_skill_dir: Path):
"""In template mode (default), only main file and manifest are resources."""
provider = SkillProvider(skill_path=single_skill_dir)
resources = await provider.list_resources()
assert len(resources) == 2
names = {r.name for r in resources}
assert "my-skill/SKILL.md" in names
assert "my-skill/_manifest" in names
async def test_list_resources_supporting_files_as_resources(
self, single_skill_dir: Path
):
"""In resources mode, supporting files are also exposed as resources."""
provider = SkillProvider(
skill_path=single_skill_dir, supporting_files="resources"
)
resources = await provider.list_resources()
# 2 standard + 2 supporting files
assert len(resources) == 4
names = {r.name for r in resources}
assert "my-skill/SKILL.md" in names
assert "my-skill/_manifest" in names
assert "my-skill/reference.md" in names
assert "my-skill/scripts/helper.py" in names
async def test_list_templates_default_mode(self, single_skill_dir: Path):
"""In template mode (default), one template is exposed."""
provider = SkillProvider(skill_path=single_skill_dir)
templates = await provider.list_resource_templates()
assert len(templates) == 1
assert templates[0].name == "my-skill_files"
async def test_list_templates_resources_mode(self, single_skill_dir: Path):
"""In resources mode, no templates are exposed."""
provider = SkillProvider(
skill_path=single_skill_dir, supporting_files="resources"
)
templates = await provider.list_resource_templates()
assert templates == []
async def test_read_main_file(self, single_skill_dir: Path):
mcp = FastMCP("Test")
mcp.add_provider(SkillProvider(skill_path=single_skill_dir))
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("skill://my-skill/SKILL.md"))
assert len(result) == 1
assert isinstance(result[0], TextResourceContents)
assert "# My Skill" in result[0].text
async def test_read_manifest(self, single_skill_dir: Path):
mcp = FastMCP("Test")
mcp.add_provider(SkillProvider(skill_path=single_skill_dir))
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("skill://my-skill/_manifest"))
manifest = json.loads(result[0].text)
assert manifest["skill"] == "my-skill"
assert len(manifest["files"]) == 3
paths = {f["path"] for f in manifest["files"]}
assert "SKILL.md" in paths
assert "reference.md" in paths
assert "scripts/helper.py" in paths
async def test_read_supporting_file_via_template(self, single_skill_dir: Path):
mcp = FastMCP("Test")
mcp.add_provider(SkillProvider(skill_path=single_skill_dir))
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("skill://my-skill/reference.md"))
assert "# Reference" in result[0].text
async def test_read_supporting_file_via_resource_mode(self, single_skill_dir: Path):
mcp = FastMCP("Test")
mcp.add_provider(
SkillProvider(skill_path=single_skill_dir, supporting_files="resources")
)
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("skill://my-skill/reference.md"))
assert "# Reference" in result[0].text
class TestSkillsDirectoryProvider:
"""Tests for SkillsDirectoryProvider - scans directory for skill folders."""
@pytest.fixture
def skills_dir(self, tmp_path: Path) -> Path:
"""Create a test skills directory with sample skills."""
skills_root = tmp_path / "skills"
skills_root.mkdir()
# Create a simple skill
simple_skill = skills_root / "simple-skill"
simple_skill.mkdir()
(simple_skill / "SKILL.md").write_text(
"""---
description: A simple test skill
version: "1.0.0"
---
# Simple Skill
This is a simple skill for testing.
"""
)
# Create a skill with supporting files
complex_skill = skills_root / "complex-skill"
complex_skill.mkdir()
(complex_skill / "SKILL.md").write_text(
"""---
description: A complex skill with supporting files
---
# Complex Skill
See [reference](reference.md) for more details.
"""
)
(complex_skill / "reference.md").write_text(
"""# Reference
Additional documentation.
"""
)
(complex_skill / "scripts").mkdir()
(complex_skill / "scripts" / "helper.py").write_text(
'print("Hello from helper")'
)
return skills_root
async def test_list_resources_discovers_skills(self, skills_dir: Path):
provider = SkillsDirectoryProvider(roots=skills_dir)
resources = await provider.list_resources()
# Should have 2 resources per skill (main file + manifest)
assert len(resources) == 4
# Check resource names
resource_names = {r.name for r in resources}
assert "simple-skill/SKILL.md" in resource_names
assert "simple-skill/_manifest" in resource_names
assert "complex-skill/SKILL.md" in resource_names
assert "complex-skill/_manifest" in resource_names
async def test_list_resources_includes_descriptions(self, skills_dir: Path):
provider = SkillsDirectoryProvider(roots=skills_dir)
resources = await provider.list_resources()
# Find the simple-skill main resource
simple_skill = next(r for r in resources if r.name == "simple-skill/SKILL.md")
assert simple_skill.description == "A simple test skill"
async def test_read_main_skill_file(self, skills_dir: Path):
mcp = FastMCP("Test")
mcp.add_provider(SkillsDirectoryProvider(roots=skills_dir))
async with Client(mcp) as client:
result = await client.read_resource(AnyUrl("skill://simple-skill/SKILL.md"))
assert len(result) == 1
assert isinstance(result[0], TextResourceContents)
assert "# Simple Skill" in result[0].text
async def test_read_manifest(self, skills_dir: Path):
mcp = FastMCP("Test")
mcp.add_provider(SkillsDirectoryProvider(roots=skills_dir))
async with Client(mcp) as client:
result = await client.read_resource(
AnyUrl("skill://complex-skill/_manifest")
)
assert len(result) == 1
assert isinstance(result[0], TextResourceContents)
manifest = json.loads(result[0].text)
assert manifest["skill"] == "complex-skill"
assert len(manifest["files"]) == 3 # SKILL.md, reference.md, helper.py
# Check file paths
paths = {f["path"] for f in manifest["files"]}
assert "SKILL.md" in paths
assert "reference.md" in paths
assert "scripts/helper.py" in paths
# Check hashes are present
for file_info in manifest["files"]:
assert file_info["hash"].startswith("sha256:")
assert file_info["size"] > 0
async def test_list_resource_templates(self, skills_dir: Path):
provider = SkillsDirectoryProvider(roots=skills_dir)
templates = await provider.list_resource_templates()
# One template per skill
assert len(templates) == 2
template_names = {t.name for t in templates}
assert "simple-skill_files" in template_names
assert "complex-skill_files" in template_names
async def test_read_supporting_file_via_template(self, skills_dir: Path):
mcp = FastMCP("Test")
mcp.add_provider(SkillsDirectoryProvider(roots=skills_dir))
async with Client(mcp) as client:
result = await client.read_resource(
AnyUrl("skill://complex-skill/reference.md")
)
assert len(result) == 1
assert isinstance(result[0], TextResourceContents)
assert "# Reference" in result[0].text
async def test_read_nested_file_via_template(self, skills_dir: Path):
mcp = FastMCP("Test")
mcp.add_provider(SkillsDirectoryProvider(roots=skills_dir))
async with Client(mcp) as client:
result = await client.read_resource(
AnyUrl("skill://complex-skill/scripts/helper.py")
)
assert len(result) == 1
assert isinstance(result[0], TextResourceContents)
assert "Hello from helper" in result[0].text
async def test_empty_skills_directory(self, tmp_path: Path):
empty_dir = tmp_path / "empty"
empty_dir.mkdir()
provider = SkillsDirectoryProvider(roots=empty_dir)
resources = await provider.list_resources()
assert resources == []
templates = await provider.list_resource_templates()
assert templates == []
async def test_nonexistent_skills_directory(self, tmp_path: Path):
nonexistent = tmp_path / "does-not-exist"
provider = SkillsDirectoryProvider(roots=nonexistent)
resources = await provider.list_resources()
assert resources == []
async def test_reload_mode(self, skills_dir: Path):
provider = SkillsDirectoryProvider(roots=skills_dir, reload=True)
# Initial load
resources = await provider.list_resources()
assert len(resources) == 4
# Add a new skill
new_skill = skills_dir / "new-skill"
new_skill.mkdir()
(new_skill / "SKILL.md").write_text(
"""---
description: A new skill
---
# New Skill
"""
)
# Reload should pick up the new skill
resources = await provider.list_resources()
assert len(resources) == 6
async def test_skill_without_frontmatter_uses_header_as_description(
self, tmp_path: Path
):
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
skill = skills_dir / "no-frontmatter"
skill.mkdir()
(skill / "SKILL.md").write_text("# My Skill Title\n\nSome content.")
provider = SkillsDirectoryProvider(roots=skills_dir)
resources = await provider.list_resources()
main_resource = next(
r for r in resources if r.name == "no-frontmatter/SKILL.md"
)
assert main_resource.description == "My Skill Title"
async def test_supporting_files_as_resources(self, skills_dir: Path):
"""Test that supporting_files='resources' shows all files."""
provider = SkillsDirectoryProvider(
roots=skills_dir, supporting_files="resources"
)
resources = await provider.list_resources()
# 2 skills * 2 standard resources + complex skill has 2 supporting files
# simple-skill: SKILL.md, _manifest (2)
# complex-skill: SKILL.md, _manifest, reference.md, scripts/helper.py (4)
assert len(resources) == 6
names = {r.name for r in resources}
assert "complex-skill/reference.md" in names
assert "complex-skill/scripts/helper.py" in names
async def test_supporting_files_as_resources_no_templates(self, skills_dir: Path):
"""In resources mode, no templates should be exposed."""
provider = SkillsDirectoryProvider(
roots=skills_dir, supporting_files="resources"
)
templates = await provider.list_resource_templates()
assert templates == []
class TestMultiDirectoryProvider:
"""Tests for multi-directory support in SkillsDirectoryProvider."""
@pytest.fixture
def multi_skills_dirs(self, tmp_path: Path) -> tuple[Path, Path]:
"""Create two separate skills directories."""
root1 = tmp_path / "skills1"
root1.mkdir()
skill1 = root1 / "skill-a"
skill1.mkdir()
(skill1 / "SKILL.md").write_text(
"""---
description: Skill A from root 1
---
# Skill A
"""
)
root2 = tmp_path / "skills2"
root2.mkdir()
skill2 = root2 / "skill-b"
skill2.mkdir()
(skill2 / "SKILL.md").write_text(
"""---
description: Skill B from root 2
---
# Skill B
"""
)
return root1, root2
async def test_multiple_roots_discover_all_skills(self, multi_skills_dirs):
"""Test that skills from multiple roots are all discovered."""
root1, root2 = multi_skills_dirs
provider = SkillsDirectoryProvider(roots=[root1, root2])
resources = await provider.list_resources()
# 2 skills * 2 resources each = 4 total
assert len(resources) == 4
resource_names = {r.name for r in resources}
assert "skill-a/SKILL.md" in resource_names
assert "skill-a/_manifest" in resource_names
assert "skill-b/SKILL.md" in resource_names
assert "skill-b/_manifest" in resource_names
async def test_duplicate_skill_names_first_wins(self, tmp_path: Path):
"""Test that if a skill appears in multiple roots, first one wins."""
root1 = tmp_path / "root1"
root1.mkdir()
skill1 = root1 / "duplicate-skill"
skill1.mkdir()
(skill1 / "SKILL.md").write_text(
"""---
description: First occurrence
---
# First
"""
)
root2 = tmp_path / "root2"
root2.mkdir()
skill2 = root2 / "duplicate-skill"
skill2.mkdir()
(skill2 / "SKILL.md").write_text(
"""---
description: Second occurrence
---
# Second
"""
)
provider = SkillsDirectoryProvider(roots=[root1, root2])
resources = await provider.list_resources()
# Should only have one skill (first one)
assert len(resources) == 2 # SKILL.md + _manifest
# Should be the first one
main_resource = next(
r for r in resources if r.name == "duplicate-skill/SKILL.md"
)
assert main_resource.description == "First occurrence"
async def test_single_path_as_list(self, multi_skills_dirs):
"""Test that single path can be passed as a list."""
root1, _ = multi_skills_dirs
provider = SkillsDirectoryProvider(roots=[root1])
resources = await provider.list_resources()
assert len(resources) == 2 # skill-a has 2 resources
async def test_single_path_as_string(self, multi_skills_dirs):
"""Test that single path can be passed as string."""
root1, _ = multi_skills_dirs
provider = SkillsDirectoryProvider(roots=str(root1))
resources = await provider.list_resources()
assert len(resources) == 2
async def test_nonexistent_roots_handled_gracefully(self, tmp_path: Path):
"""Test that non-existent roots don't cause errors."""
existent = tmp_path / "exists"
existent.mkdir()
skill = existent / "test-skill"
skill.mkdir()
(skill / "SKILL.md").write_text("# Test\n\nContent")
nonexistent = tmp_path / "does-not-exist"
provider = SkillsDirectoryProvider(roots=[existent, nonexistent])
resources = await provider.list_resources()
# Should still find skills from existing root
assert len(resources) == 2
async def test_empty_roots_list(self, tmp_path: Path):
"""Test that empty roots list results in no skills."""
provider = SkillsDirectoryProvider(roots=[])
resources = await provider.list_resources()
assert resources == []
class TestSkillsProviderAlias:
"""Test that SkillsProvider is a backwards-compatible alias."""
def test_skills_provider_is_alias(self):
assert SkillsProvider is SkillsDirectoryProvider
class TestClaudeSkillsProvider:
def test_default_root_is_claude_skills_dir(self, tmp_path: Path, monkeypatch):
# Mock Path.home() to return a temp path (use tmp_path for cross-platform compatibility)
monkeypatch.setattr(Path, "home", lambda: tmp_path)
provider = ClaudeSkillsProvider()
assert provider._roots == [tmp_path / ".claude" / "skills"]
def test_main_file_name_is_skill_md(self):
provider = ClaudeSkillsProvider()
assert provider._main_file_name == "SKILL.md"
def test_supporting_files_parameter(self):
provider = ClaudeSkillsProvider(supporting_files="resources")
assert provider._supporting_files == "resources"
class TestPathTraversalPrevention:
async def test_path_traversal_blocked(self, tmp_path: Path):
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
skill = skills_dir / "test-skill"
skill.mkdir()
(skill / "SKILL.md").write_text("# Test\n\nContent")
# Create a file outside the skill directory
secret_file = tmp_path / "secret.txt"
secret_file.write_text("SECRET DATA")
mcp = FastMCP("Test")
mcp.add_provider(SkillsDirectoryProvider(roots=skills_dir))
async with Client(mcp) as client:
# Path traversal attempts should fail (either normalized away or blocked)
# The important thing is that SECRET DATA is never returned
with pytest.raises(Exception):
result = await client.read_resource(
AnyUrl("skill://test-skill/../../../secret.txt")
)
# If we somehow got here, ensure we didn't get the secret
if result:
for content in result:
if hasattr(content, "text"):
assert "SECRET DATA" not in content.text

View file

@ -0,0 +1,201 @@
"""Tests for vendor-specific skills providers."""
from __future__ import annotations
from pathlib import Path
from fastmcp.server.providers.skills import (
ClaudeSkillsProvider,
CodexSkillsProvider,
CopilotSkillsProvider,
CursorSkillsProvider,
GeminiSkillsProvider,
GooseSkillsProvider,
OpenCodeSkillsProvider,
VSCodeSkillsProvider,
)
class TestVendorProviders:
"""Tests for vendor-specific skills providers."""
def test_cursor_skills_provider_path(self, tmp_path: Path, monkeypatch):
"""Test CursorSkillsProvider uses correct path."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
provider = CursorSkillsProvider()
assert provider._roots == [tmp_path / ".cursor" / "skills"]
def test_vscode_skills_provider_path(self, tmp_path: Path, monkeypatch):
"""Test VSCodeSkillsProvider uses correct path."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
provider = VSCodeSkillsProvider()
assert provider._roots == [tmp_path / ".copilot" / "skills"]
def test_codex_skills_provider_paths(self, tmp_path: Path, monkeypatch):
"""Test CodexSkillsProvider uses both system and user paths."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
provider = CodexSkillsProvider()
# Path.resolve() may add /private on macOS, so compare resolved paths
expected_roots = [
Path("/etc/codex/skills").resolve(),
(tmp_path / ".codex" / "skills").resolve(),
]
assert provider._roots == expected_roots
def test_gemini_skills_provider_path(self, tmp_path: Path, monkeypatch):
"""Test GeminiSkillsProvider uses correct path."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
provider = GeminiSkillsProvider()
assert provider._roots == [tmp_path / ".gemini" / "skills"]
def test_goose_skills_provider_path(self, tmp_path: Path, monkeypatch):
"""Test GooseSkillsProvider uses correct path."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
provider = GooseSkillsProvider()
assert provider._roots == [tmp_path / ".config" / "agents" / "skills"]
def test_copilot_skills_provider_path(self, tmp_path: Path, monkeypatch):
"""Test CopilotSkillsProvider uses correct path."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
provider = CopilotSkillsProvider()
assert provider._roots == [tmp_path / ".copilot" / "skills"]
def test_opencode_skills_provider_path(self, tmp_path: Path, monkeypatch):
"""Test OpenCodeSkillsProvider uses correct path."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
provider = OpenCodeSkillsProvider()
assert provider._roots == [tmp_path / ".config" / "opencode" / "skills"]
def test_claude_skills_provider_path(self, tmp_path: Path, monkeypatch):
"""Test ClaudeSkillsProvider uses correct path."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
provider = ClaudeSkillsProvider()
assert provider._roots == [tmp_path / ".claude" / "skills"]
def test_all_providers_instantiable(self):
"""Test that all vendor providers can be instantiated."""
providers = [
ClaudeSkillsProvider(),
CursorSkillsProvider(),
VSCodeSkillsProvider(),
CodexSkillsProvider(),
GeminiSkillsProvider(),
GooseSkillsProvider(),
CopilotSkillsProvider(),
OpenCodeSkillsProvider(),
]
for provider in providers:
assert provider is not None
assert provider._main_file_name == "SKILL.md"
def test_all_providers_support_reload(self):
"""Test that all providers support reload parameter."""
providers = [
ClaudeSkillsProvider(reload=True),
CursorSkillsProvider(reload=True),
VSCodeSkillsProvider(reload=True),
CodexSkillsProvider(reload=True),
GeminiSkillsProvider(reload=True),
GooseSkillsProvider(reload=True),
CopilotSkillsProvider(reload=True),
OpenCodeSkillsProvider(reload=True),
]
for provider in providers:
assert provider._reload is True
def test_all_providers_support_supporting_files(self):
"""Test that all providers support supporting_files parameter."""
providers = [
ClaudeSkillsProvider(supporting_files="resources"),
CursorSkillsProvider(supporting_files="resources"),
VSCodeSkillsProvider(supporting_files="resources"),
CodexSkillsProvider(supporting_files="resources"),
GeminiSkillsProvider(supporting_files="resources"),
GooseSkillsProvider(supporting_files="resources"),
CopilotSkillsProvider(supporting_files="resources"),
OpenCodeSkillsProvider(supporting_files="resources"),
]
for provider in providers:
assert provider._supporting_files == "resources"
async def test_codex_scans_both_paths(self, tmp_path: Path, monkeypatch):
"""Test that CodexSkillsProvider scans both system and user paths."""
# Mock system path
system_skills = tmp_path / "etc" / "codex" / "skills"
system_skills.mkdir(parents=True)
system_skill = system_skills / "system-skill"
system_skill.mkdir()
(system_skill / "SKILL.md").write_text(
"""---
description: System skill
---
# System
"""
)
# Mock user path
fake_home = tmp_path / "home" / "user"
fake_home.mkdir(parents=True)
monkeypatch.setattr(Path, "home", lambda: fake_home)
user_skills = fake_home / ".codex" / "skills"
user_skills.mkdir(parents=True)
user_skill = user_skills / "user-skill"
user_skill.mkdir()
(user_skill / "SKILL.md").write_text(
"""---
description: User skill
---
# User
"""
)
# Create provider with mocked paths
# Override roots before discovery
provider = CodexSkillsProvider()
provider._roots = [system_skills, user_skills]
# Trigger re-discovery with new roots
provider._discover_skills()
resources = await provider.list_resources()
# Should find both skills
assert len(resources) == 4 # 2 skills * 2 resources each
resource_names = {r.name for r in resources}
assert "system-skill/SKILL.md" in resource_names
assert "user-skill/SKILL.md" in resource_names
async def test_nonexistent_paths_handled_gracefully(
self, tmp_path: Path, monkeypatch
):
"""Test that non-existent paths don't cause errors."""
# Use a path that definitely doesn't exist
nonexistent_home = tmp_path / "nonexistent" / "home"
monkeypatch.setattr(Path, "home", lambda: nonexistent_home)
# All providers should handle non-existent paths gracefully
providers = [
ClaudeSkillsProvider(),
CursorSkillsProvider(),
VSCodeSkillsProvider(),
GeminiSkillsProvider(),
GooseSkillsProvider(),
CopilotSkillsProvider(),
OpenCodeSkillsProvider(),
]
for provider in providers:
resources = await provider.list_resources()
# Should return empty list, not raise exception
assert isinstance(resources, list)