"""Client utilities for discovering and downloading skills from MCP servers.""" from __future__ import annotations import base64 import json from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING import mcp.types 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 isinstance(content, mcp.types.TextResourceContents): 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).resolve() # Security: ensure skill_dir stays within target_dir if not skill_dir.is_relative_to(target_dir): raise ValueError(f"Skill name {skill_name!r} would escape the target directory") # 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." ) # 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 isinstance(content, mcp.types.TextResourceContents): file_path.write_text(content.text) elif isinstance(content, mcp.types.BlobResourceContents): 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