Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/servers/providers/skills.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,55 @@ async def main():
if __name__ == "__main__":
asyncio.run(main())
```

## Client Utilities

FastMCP provides utilities for downloading skills from any MCP server that exposes them. These are standalone functions in `fastmcp.utilities.skills`.

### Discovering Skills

Use `list_skills()` to see what skills are available on a server.

```python
from fastmcp import Client
from fastmcp.utilities.skills import list_skills

async with Client("http://skills-server/mcp") as client:
skills = await list_skills(client)
for skill in skills:
print(f"{skill.name}: {skill.description}")
```

### Downloading Skills

Use `download_skill()` to download a single skill, or `sync_skills()` to download all available skills.

```python
from pathlib import Path

from fastmcp import Client
from fastmcp.utilities.skills import download_skill, sync_skills

async with Client("http://skills-server/mcp") as client:
# Download one skill
path = await download_skill(client, "pdf-processing", Path.home() / ".claude" / "skills")

# Or download all skills
paths = await sync_skills(client, Path.home() / ".claude" / "skills")
```

Both functions accept an `overwrite` parameter. When `False` (default), existing skills are skipped. When `True`, existing files are replaced.

### Inspecting Manifests

Use `get_skill_manifest()` to see what files a skill contains before downloading.

```python
from fastmcp import Client
from fastmcp.utilities.skills import get_skill_manifest

async with Client("http://skills-server/mcp") as client:
manifest = await get_skill_manifest(client, "pdf-processing")
for file in manifest.files:
print(f"{file.path} ({file.size} bytes, {file.hash})")
```
82 changes: 82 additions & 0 deletions examples/skills/download_skills.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Example: Downloading skills from an MCP server.

This example shows how to use the skills client utilities to discover
and download skills from any MCP server that exposes them via SkillsProvider.

Run this script:
uv run python examples/skills/download_skills.py

This example creates an in-memory server with sample skills. In practice,
you would connect to a remote server URL instead.
"""

import asyncio
import tempfile
from pathlib import Path

from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.tree import Tree

from fastmcp import Client, FastMCP
from fastmcp.server.providers.skills import SkillsDirectoryProvider
from fastmcp.utilities.skills import list_skills, sync_skills

console = Console()


async def main():
# For this example, we'll create an in-memory server with skills.
# In practice, you'd connect to a remote server URL.
skills_dir = Path(__file__).parent / "sample_skills"
mcp = FastMCP("Skills Server")
mcp.add_provider(SkillsDirectoryProvider(roots=skills_dir))

async with Client(mcp) as client:
# 1. Discover what skills are available on the server
console.print()
console.print(
Panel.fit(
"[bold]Discovering skills on MCP server...[/bold]",
border_style="blue",
)
)

skills = await list_skills(client)

table = Table(title="Skills Available on Server", show_header=True)
table.add_column("Skill", style="cyan")
table.add_column("Description")
for skill in sorted(skills, key=lambda s: s.name):
table.add_row(skill.name, skill.description)
console.print(table)

# 2. Download all skills to a local directory
console.print()
console.print(
Panel.fit(
"[bold]Downloading all skills to local directory...[/bold]",
border_style="green",
)
)

with tempfile.TemporaryDirectory() as tmp:
paths = await sync_skills(client, tmp)

tree = Tree(f"[bold]{tmp}[/bold]")
for skill_path in sorted(paths, key=lambda p: p.name):
skill_branch = tree.add(f"[cyan]{skill_path.name}/[/cyan]")
for f in sorted(skill_path.rglob("*")):
if f.is_file():
rel = f.relative_to(skill_path)
skill_branch.add(str(rel))

console.print(tree)
console.print(
f"\n[green]✓[/green] Downloaded {len(paths)} skills to local directory"
)


if __name__ == "__main__":
asyncio.run(main())
253 changes: 253 additions & 0 deletions src/fastmcp/utilities/skills.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
"""Client utilities for discovering and downloading skills from MCP servers."""

from __future__ import annotations

import json
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from fastmcp.client import Client


@dataclass
class SkillSummary:
"""Summary information about a skill available on a server."""

name: str
description: str
uri: str


@dataclass
class SkillFile:
"""Information about a file within a skill."""

path: str
size: int
hash: str


@dataclass
class SkillManifest:
"""Full manifest of a skill including all files."""

name: str
files: list[SkillFile]


async def list_skills(client: Client) -> list[SkillSummary]:
"""List all available skills from an MCP server.

Discovers skills by finding resources with URIs matching the
`skill://{name}/SKILL.md` pattern.

Args:
client: Connected FastMCP client

Returns:
List of SkillSummary objects with name, description, and URI

Example:
```python
from fastmcp import Client
from fastmcp.utilities.skills import list_skills

async with Client("http://skills-server/mcp") as client:
skills = await list_skills(client)
for skill in skills:
print(f"{skill.name}: {skill.description}")
```
"""
resources = await client.list_resources()
skills = []

for resource in resources:
uri = str(resource.uri)
# Match skill://{name}/SKILL.md pattern
if uri.startswith("skill://") and uri.endswith("/SKILL.md"):
# Extract skill name from URI
path_part = uri[len("skill://") :]
name = path_part.rsplit("/", 1)[0]
skills.append(
SkillSummary(
name=name,
description=resource.description or "",
uri=uri,
)
)

return skills


async def get_skill_manifest(client: Client, skill_name: str) -> SkillManifest:
"""Get the manifest for a specific skill.

Args:
client: Connected FastMCP client
skill_name: Name of the skill

Returns:
SkillManifest with file listing

Raises:
ValueError: If manifest cannot be read or parsed
"""
manifest_uri = f"skill://{skill_name}/_manifest"
result = await client.read_resource(manifest_uri)

if not result:
raise ValueError(f"Could not read manifest for skill: {skill_name}")

content = result[0]
if hasattr(content, "text"):
try:
manifest_data = json.loads(content.text)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid manifest JSON for skill: {skill_name}") from e
else:
raise ValueError(f"Unexpected manifest format for skill: {skill_name}")

try:
return SkillManifest(
name=manifest_data["skill"],
files=[
SkillFile(path=f["path"], size=f["size"], hash=f["hash"])
for f in manifest_data["files"]
],
)
except (KeyError, TypeError) as e:
raise ValueError(f"Invalid manifest format for skill: {skill_name}") from e


async def download_skill(
client: Client,
skill_name: str,
target_dir: str | Path,
*,
overwrite: bool = False,
) -> Path:
"""Download a skill and all its files to a local directory.

Creates a subdirectory named after the skill containing all files.

Args:
client: Connected FastMCP client
skill_name: Name of the skill to download
target_dir: Directory where skill folder will be created
overwrite: If True, overwrite existing skill directory. If False
(default), raise FileExistsError if directory exists.

Returns:
Path to the downloaded skill directory

Raises:
ValueError: If skill cannot be found or downloaded
FileExistsError: If skill directory exists and overwrite=False

Example:
```python
from fastmcp import Client
from fastmcp.utilities.skills import download_skill

async with Client("http://skills-server/mcp") as client:
skill_path = await download_skill(
client,
"pdf-processing",
"~/.claude/skills"
)
print(f"Downloaded to: {skill_path}")
```
"""
target_dir = Path(target_dir).expanduser().resolve()
skill_dir = target_dir / skill_name

# Check if directory exists
if skill_dir.exists() and not overwrite:
raise FileExistsError(
f"Skill directory already exists: {skill_dir}. "
"Use overwrite=True to replace."
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Get manifest to know what files to download
manifest = await get_skill_manifest(client, skill_name)

# Create skill directory
skill_dir.mkdir(parents=True, exist_ok=True)

# Download each file
for file_info in manifest.files:
# Security: reject absolute paths and paths that escape skill_dir
if Path(file_info.path).is_absolute():
continue
file_path = (skill_dir / file_info.path).resolve()
if not file_path.is_relative_to(skill_dir):
continue

file_uri = f"skill://{skill_name}/{file_info.path}"
result = await client.read_resource(file_uri)

if not result:
continue

content = result[0]

# Create parent directories if needed
file_path.parent.mkdir(parents=True, exist_ok=True)

# Write content
if hasattr(content, "text"):
file_path.write_text(content.text)
elif hasattr(content, "blob"):
# Handle base64-encoded binary content
import base64

file_path.write_bytes(base64.b64decode(content.blob))
else:
# Skip unknown content types
continue

return skill_dir


async def sync_skills(
client: Client,
target_dir: str | Path,
*,
overwrite: bool = False,
) -> list[Path]:
"""Download all available skills from a server.

Args:
client: Connected FastMCP client
target_dir: Directory where skill folders will be created
overwrite: If True, overwrite existing files

Returns:
List of paths to downloaded skill directories

Example:
```python
from fastmcp import Client
from fastmcp.utilities.skills import sync_skills

async with Client("http://skills-server/mcp") as client:
paths = await sync_skills(client, "~/.claude/skills")
print(f"Downloaded {len(paths)} skills")
```
"""
skills = await list_skills(client)
downloaded = []

for skill in skills:
try:
path = await download_skill(
client, skill.name, target_dir, overwrite=overwrite
)
downloaded.append(path)
except FileExistsError:
# Skip existing skills when not overwriting
continue

return downloaded
Loading