diff --git a/docs/development/v3-notes/v3-features.mdx b/docs/development/v3-notes/v3-features.mdx
index 4f02e705a..86a78afdd 100644
--- a/docs/development/v3-notes/v3-features.mdx
+++ b/docs/development/v3-notes/v3-features.mdx
@@ -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)).
diff --git a/docs/docs.json b/docs/docs.json
index 8e776679a..5391ad2bc 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -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"
diff --git a/docs/servers/providers/skills.mdx b/docs/servers/providers/skills.mdx
new file mode 100644
index 000000000..1ea038f59
--- /dev/null
+++ b/docs/servers/providers/skills.mdx
@@ -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'
+
+
+
+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.
+
+
+Reload mode adds overhead to every request. Use it during development when you're actively editing skills, but disable it in production.
+
+
+## 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())
+```
diff --git a/examples/skills/README.md b/examples/skills/README.md
new file mode 100644
index 000000000..7f5ae14b5
--- /dev/null
+++ b/examples/skills/README.md
@@ -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()
+```
diff --git a/examples/skills/client.py b/examples/skills/client.py
new file mode 100644
index 000000000..a376fe235
--- /dev/null
+++ b/examples/skills/client.py
@@ -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())
diff --git a/examples/skills/sample_skills/code-review/SKILL.md b/examples/skills/sample_skills/code-review/SKILL.md
new file mode 100644
index 000000000..0c57545d8
--- /dev/null
+++ b/examples/skills/sample_skills/code-review/SKILL.md
@@ -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
diff --git a/examples/skills/sample_skills/pdf-processing/SKILL.md b/examples/skills/sample_skills/pdf-processing/SKILL.md
new file mode 100644
index 000000000..2490f6792
--- /dev/null
+++ b/examples/skills/sample_skills/pdf-processing/SKILL.md
@@ -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.
diff --git a/examples/skills/sample_skills/pdf-processing/reference.md b/examples/skills/sample_skills/pdf-processing/reference.md
new file mode 100644
index 000000000..7b7b329d7
--- /dev/null
+++ b/examples/skills/sample_skills/pdf-processing/reference.md
@@ -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)
+```
diff --git a/examples/skills/server.py b/examples/skills/server.py
new file mode 100644
index 000000000..a5cd49947
--- /dev/null
+++ b/examples/skills/server.py
@@ -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()
diff --git a/src/fastmcp/server/providers/__init__.py b/src/fastmcp/server/providers/__init__.py
index d1d8cdcd2..4c673be03 100644
--- a/src/fastmcp/server/providers/__init__.py
+++ b/src/fastmcp/server/providers/__init__.py
@@ -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
]
diff --git a/src/fastmcp/server/providers/skills/__init__.py b/src/fastmcp/server/providers/skills/__init__.py
new file mode 100644
index 000000000..b15c1c636
--- /dev/null
+++ b/src/fastmcp/server/providers/skills/__init__.py
@@ -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",
+]
diff --git a/src/fastmcp/server/providers/skills/_common.py b/src/fastmcp/server/providers/skills/_common.py
new file mode 100644
index 000000000..95e5b2db0
--- /dev/null
+++ b/src/fastmcp/server/providers/skills/_common.py
@@ -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
diff --git a/src/fastmcp/server/providers/skills/claude_provider.py b/src/fastmcp/server/providers/skills/claude_provider.py
new file mode 100644
index 000000000..b7264953c
--- /dev/null
+++ b/src/fastmcp/server/providers/skills/claude_provider.py
@@ -0,0 +1,44 @@
+"""Claude-specific skills provider for Claude Code skills."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Literal
+
+from fastmcp.server.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,
+ )
diff --git a/src/fastmcp/server/providers/skills/directory_provider.py b/src/fastmcp/server/providers/skills/directory_provider.py
new file mode 100644
index 000000000..27f7c1e4c
--- /dev/null
+++ b/src/fastmcp/server/providers/skills/directory_provider.py
@@ -0,0 +1,153 @@
+"""Directory scanning provider for discovering multiple skills."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from pathlib import Path
+from typing import Literal
+
+from fastmcp.resources.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)})"
+ )
diff --git a/src/fastmcp/server/providers/skills/skill_provider.py b/src/fastmcp/server/providers/skills/skill_provider.py
new file mode 100644
index 000000000..5b8392e42
--- /dev/null
+++ b/src/fastmcp/server/providers/skills/skill_provider.py
@@ -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})"
+ )
diff --git a/src/fastmcp/server/providers/skills/vendor_providers.py b/src/fastmcp/server/providers/skills/vendor_providers.py
new file mode 100644
index 000000000..df870f161
--- /dev/null
+++ b/src/fastmcp/server/providers/skills/vendor_providers.py
@@ -0,0 +1,142 @@
+"""Vendor-specific skills providers for various AI coding platforms."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Literal
+
+from fastmcp.server.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,
+ )
diff --git a/tests/server/providers/test_skills_provider.py b/tests/server/providers/test_skills_provider.py
new file mode 100644
index 000000000..36c527308
--- /dev/null
+++ b/tests/server/providers/test_skills_provider.py
@@ -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
diff --git a/tests/server/providers/test_skills_vendor_providers.py b/tests/server/providers/test_skills_vendor_providers.py
new file mode 100644
index 000000000..65959ef1d
--- /dev/null
+++ b/tests/server/providers/test_skills_vendor_providers.py
@@ -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)