mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Initial commit
This commit is contained in:
commit
b6ade5e72c
15 changed files with 2092 additions and 0 deletions
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Python-generated files
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
|
.DS_Store
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
3.12
|
||||||
2
README.md
Normal file
2
README.md
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
Notes:
|
||||||
|
- uv must be installed with brew to run local servers
|
||||||
31
examples/desktop.py
Normal file
31
examples/desktop.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""
|
||||||
|
FastMCP Desktop Example
|
||||||
|
|
||||||
|
A simple example that exposes the desktop directory as a resource.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastmcp.server import FastMCPServer
|
||||||
|
|
||||||
|
# Create server
|
||||||
|
app = FastMCPServer("desktop")
|
||||||
|
|
||||||
|
# Add desktop as a directory resource
|
||||||
|
desktop = Path.home() / "Desktop"
|
||||||
|
app.add_dir_resource(
|
||||||
|
str(desktop),
|
||||||
|
recursive=True,
|
||||||
|
name="Desktop",
|
||||||
|
description="Files on the desktop",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main123():
|
||||||
|
# Run the server
|
||||||
|
asyncio.run(FastMCPServer.run_stdio(app))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main123()
|
||||||
133
examples/weather.py
Normal file
133
examples/weather.py
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
"""
|
||||||
|
FastMCP Weather Server Example
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import httpx
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from fastmcp.server import FastMCPServer
|
||||||
|
|
||||||
|
# Load env vars
|
||||||
|
API_KEY = os.getenv("OPENWEATHER_API_KEY")
|
||||||
|
if not API_KEY:
|
||||||
|
raise ValueError("OPENWEATHER_API_KEY environment variable required")
|
||||||
|
|
||||||
|
# API configuration
|
||||||
|
API_BASE = "http://api.openweathermap.org/data/2.5"
|
||||||
|
DEFAULT_PARAMS = {"appid": API_KEY, "units": "metric"}
|
||||||
|
|
||||||
|
|
||||||
|
# Pydantic models for parameters
|
||||||
|
class ForecastParams(BaseModel):
|
||||||
|
city: str = Field(..., description="City name")
|
||||||
|
days: int = Field(default=5, ge=1, le=10, description="Number of days to forecast")
|
||||||
|
units: str = Field(
|
||||||
|
default="metric", pattern="^(metric|imperial)$", description="Temperature units"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AlertParams(BaseModel):
|
||||||
|
lat: float = Field(..., description="Latitude")
|
||||||
|
lon: float = Field(..., description="Longitude")
|
||||||
|
|
||||||
|
|
||||||
|
# Create server
|
||||||
|
app = FastMCPServer("weather-service")
|
||||||
|
|
||||||
|
|
||||||
|
# Tools using Pydantic models
|
||||||
|
@app.tool(description="Get detailed weather forecast for a city")
|
||||||
|
async def get_forecast(params: ForecastParams) -> dict:
|
||||||
|
"""Get a multi-day weather forecast for a city."""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{API_BASE}/forecast",
|
||||||
|
params={
|
||||||
|
"q": params.city,
|
||||||
|
"cnt": params.days * 8, # API returns 3-hour intervals
|
||||||
|
"units": params.units,
|
||||||
|
**DEFAULT_PARAMS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Process into daily forecasts
|
||||||
|
forecasts = []
|
||||||
|
for i in range(0, len(data["list"]), 8): # Every 8th entry is a new day
|
||||||
|
day_data = data["list"][i]
|
||||||
|
forecasts.append(
|
||||||
|
{
|
||||||
|
"date": day_data["dt_txt"].split()[0],
|
||||||
|
"temperature": {
|
||||||
|
"high": day_data["main"]["temp_max"],
|
||||||
|
"low": day_data["main"]["temp_min"],
|
||||||
|
},
|
||||||
|
"conditions": day_data["weather"][0]["description"],
|
||||||
|
"humidity": day_data["main"]["humidity"],
|
||||||
|
"wind_speed": day_data["wind"]["speed"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"city": data["city"]["name"],
|
||||||
|
"country": data["city"]["country"],
|
||||||
|
"forecasts": forecasts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Tools using simple kwargs
|
||||||
|
@app.tool()
|
||||||
|
async def get_alerts(lat: float, lon: float) -> list:
|
||||||
|
"""Get weather alerts and warnings for a location."""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{API_BASE}/onecall",
|
||||||
|
params={
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"exclude": "current,minutely,hourly,daily",
|
||||||
|
**DEFAULT_PARAMS,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
return data.get("alerts", [])
|
||||||
|
|
||||||
|
|
||||||
|
# Add HTTP resources
|
||||||
|
app.add_http_resource(
|
||||||
|
f"{API_BASE}/weather?q=London&units=metric&appid={API_KEY}",
|
||||||
|
name="London Weather",
|
||||||
|
description="Current weather in London",
|
||||||
|
mime_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add local data resources
|
||||||
|
app.add_file_resource("weather_stations/*.json", description="Weather station metadata")
|
||||||
|
|
||||||
|
app.add_dir_resource(
|
||||||
|
"~/Developer/fastmcp/historical_data",
|
||||||
|
pattern="*.csv",
|
||||||
|
recursive=True,
|
||||||
|
description="Historical weather data",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run the server
|
||||||
|
asyncio.run(FastMCPServer.run_stdio(app))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
36
pyproject.toml
Normal file
36
pyproject.toml
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
[project]
|
||||||
|
name = "fastmcp"
|
||||||
|
dynamic = ["version"]
|
||||||
|
description = "A more ergonomic interface for MCP servers"
|
||||||
|
authors = [{ name = "Jeremiah Lowin" }]
|
||||||
|
dependencies = [
|
||||||
|
"httpx>=0.26.0",
|
||||||
|
"mcp>=1.0.0",
|
||||||
|
"pydantic>=2.5.3",
|
||||||
|
"typer>=0.9.0",
|
||||||
|
]
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
readme = "README.md"
|
||||||
|
license = { text = "Apache-2.0" }
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
fastmcp = "fastmcp.cli:app"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools_scm]
|
||||||
|
write_to = "src/fastmcp/_version.py"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"copychat>=0.5.2",
|
||||||
|
"ipython>=8.12.3",
|
||||||
|
"pdbpp>=0.10.3",
|
||||||
|
"pytest>=8.3.3",
|
||||||
|
"pytest-asyncio>=0.23.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
16
src/fastmcp/_version.py
Normal file
16
src/fastmcp/_version.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# file generated by setuptools_scm
|
||||||
|
# don't change, don't track in version control
|
||||||
|
TYPE_CHECKING = False
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from typing import Tuple, Union
|
||||||
|
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
||||||
|
else:
|
||||||
|
VERSION_TUPLE = object
|
||||||
|
|
||||||
|
version: str
|
||||||
|
__version__: str
|
||||||
|
__version_tuple__: VERSION_TUPLE
|
||||||
|
version_tuple: VERSION_TUPLE
|
||||||
|
|
||||||
|
__version__ = version = '0.1.dev0+d20241129'
|
||||||
|
__version_tuple__ = version_tuple = (0, 1, 'dev0', 'd20241129')
|
||||||
72
src/fastmcp/cli.py
Normal file
72
src/fastmcp/cli.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
"""FastMCP CLI tools."""
|
||||||
|
|
||||||
|
import importlib.metadata
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logger = logging.getLogger("mcp")
|
||||||
|
|
||||||
|
app = typer.Typer(
|
||||||
|
name="fastmcp",
|
||||||
|
help="FastMCP development tools",
|
||||||
|
add_completion=False,
|
||||||
|
no_args_is_help=True, # Show help if no args provided
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def version() -> None:
|
||||||
|
"""Show the FastMCP version."""
|
||||||
|
try:
|
||||||
|
version = importlib.metadata.version("fastmcp")
|
||||||
|
print(f"FastMCP version {version}")
|
||||||
|
except importlib.metadata.PackageNotFoundError:
|
||||||
|
print("FastMCP version unknown (package not installed)")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def dev(
|
||||||
|
file: Path = typer.Argument(
|
||||||
|
...,
|
||||||
|
help="Python file to run",
|
||||||
|
exists=True,
|
||||||
|
dir_okay=False,
|
||||||
|
resolve_path=True,
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""Run a FastMCP server with the MCP Inspector."""
|
||||||
|
logger.debug("Starting dev server", extra={"file": str(file)})
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run the MCP Inspector command
|
||||||
|
process = subprocess.run(
|
||||||
|
["npx", "@modelcontextprotocol/inspector", "uv", "run", str(file)],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
sys.exit(process.returncode)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error(
|
||||||
|
"Dev server failed",
|
||||||
|
extra={
|
||||||
|
"file": str(file),
|
||||||
|
"error": str(e),
|
||||||
|
"returncode": e.returncode,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
sys.exit(e.returncode)
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error(
|
||||||
|
"npx not found. Please install Node.js and npm.",
|
||||||
|
extra={"file": str(file)},
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app()
|
||||||
17
src/fastmcp/exceptions.py
Normal file
17
src/fastmcp/exceptions.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
"""Custom exceptions for FastMCP."""
|
||||||
|
|
||||||
|
|
||||||
|
class FastMCPError(Exception):
|
||||||
|
"""Base error for FastMCP."""
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(FastMCPError):
|
||||||
|
"""Error in validating parameters or return values."""
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceError(FastMCPError):
|
||||||
|
"""Error in resource operations."""
|
||||||
|
|
||||||
|
|
||||||
|
class ToolError(FastMCPError):
|
||||||
|
"""Error in tool operations."""
|
||||||
18
src/fastmcp/models.py
Normal file
18
src/fastmcp/models.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
"""Pydantic models for FastMCP."""
|
||||||
|
|
||||||
|
from typing import Callable, Optional, Type
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Tool(BaseModel):
|
||||||
|
"""Internal tool registration info."""
|
||||||
|
|
||||||
|
model_config: dict = dict(arbitrary_types_allowed=True)
|
||||||
|
|
||||||
|
func: Callable
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
input_schema: dict
|
||||||
|
is_async: bool
|
||||||
|
pydantic_model: Optional[Type[BaseModel]] = None
|
||||||
249
src/fastmcp/resources.py
Normal file
249
src/fastmcp/resources.py
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
"""Resource management for FastMCP."""
|
||||||
|
|
||||||
|
import abc
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("mcp")
|
||||||
|
|
||||||
|
|
||||||
|
class Resource(BaseModel):
|
||||||
|
"""Base class for all resources."""
|
||||||
|
|
||||||
|
uri: str
|
||||||
|
name: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
mime_type: str = "text/plain"
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
async def read(self) -> str:
|
||||||
|
"""Read the resource content."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class FileResource(Resource):
|
||||||
|
"""A file resource."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
|
||||||
|
@field_validator("path")
|
||||||
|
@classmethod
|
||||||
|
def validate_absolute_path(cls, path: Path) -> Path:
|
||||||
|
"""Ensure path is absolute."""
|
||||||
|
if not path.is_absolute():
|
||||||
|
raise ValueError(f"Path must be absolute: {path}")
|
||||||
|
return path
|
||||||
|
|
||||||
|
async def read(self) -> str:
|
||||||
|
"""Read the file content."""
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(self.path.read_text)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise FileNotFoundError(f"File not found: {self.path}")
|
||||||
|
except PermissionError:
|
||||||
|
raise PermissionError(f"Permission denied: {self.path}")
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Error reading file {self.path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class HttpResource(Resource):
|
||||||
|
"""An HTTP resource."""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
headers: Optional[Dict[str, str]] = None
|
||||||
|
|
||||||
|
async def read(self) -> str:
|
||||||
|
"""Read the HTTP resource content."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(self.url, headers=self.headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.text
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
raise ValueError(f"HTTP error {e.response.status_code}: {e}")
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
raise ValueError(f"Request failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class DirectoryResource(Resource):
|
||||||
|
"""A directory resource."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
recursive: bool = False
|
||||||
|
pattern: Optional[str] = None
|
||||||
|
mime_type: str = "application/json"
|
||||||
|
|
||||||
|
@field_validator("path")
|
||||||
|
@classmethod
|
||||||
|
def validate_absolute_path(cls, path: Path) -> Path:
|
||||||
|
"""Ensure path is absolute."""
|
||||||
|
if not path.is_absolute():
|
||||||
|
raise ValueError(f"Path must be absolute: {path}")
|
||||||
|
return path
|
||||||
|
|
||||||
|
def list_files(self) -> list[Path]:
|
||||||
|
"""List files in the directory."""
|
||||||
|
if not self.path.exists():
|
||||||
|
raise FileNotFoundError(f"Directory not found: {self.path}")
|
||||||
|
if not self.path.is_dir():
|
||||||
|
raise NotADirectoryError(f"Not a directory: {self.path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.pattern:
|
||||||
|
return (
|
||||||
|
list(self.path.glob(self.pattern))
|
||||||
|
if not self.recursive
|
||||||
|
else list(self.path.rglob(self.pattern))
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
list(self.path.glob("*"))
|
||||||
|
if not self.recursive
|
||||||
|
else list(self.path.rglob("*"))
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Error listing directory {self.path}: {e}")
|
||||||
|
|
||||||
|
async def read(self) -> str:
|
||||||
|
"""Read the directory listing."""
|
||||||
|
try:
|
||||||
|
files = await asyncio.to_thread(self.list_files)
|
||||||
|
file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
|
||||||
|
return json.dumps({"files": file_list}, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"Error reading directory {self.path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceManager:
|
||||||
|
"""Manages FastMCP resources."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._resources: Dict[str, Resource] = {}
|
||||||
|
|
||||||
|
def get_resource(self, uri: str) -> Optional[Resource]:
|
||||||
|
"""Get resource by URI."""
|
||||||
|
logger.debug("Getting resource", extra={"uri": uri})
|
||||||
|
resource = self._resources.get(uri)
|
||||||
|
if not resource:
|
||||||
|
raise ValueError(f"Unknown resource: {uri}")
|
||||||
|
return resource
|
||||||
|
|
||||||
|
def list_resources(self) -> list[Resource]:
|
||||||
|
"""List all registered resources."""
|
||||||
|
logger.debug("Listing resources", extra={"count": len(self._resources)})
|
||||||
|
return list(self._resources.values())
|
||||||
|
|
||||||
|
def add_file_resource(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
mime_type: Optional[str] = None,
|
||||||
|
) -> FileResource:
|
||||||
|
"""Add a file as a resource.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Absolute path to the file
|
||||||
|
name: Optional name for the resource
|
||||||
|
description: Optional description of the resource
|
||||||
|
mime_type: Optional MIME type for the resource
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created resource
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the path is not absolute or the file does not exist
|
||||||
|
"""
|
||||||
|
logger.debug(
|
||||||
|
"Adding file resource",
|
||||||
|
extra={
|
||||||
|
"path": path,
|
||||||
|
"name": name,
|
||||||
|
"mime_type": mime_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
file = Path(path)
|
||||||
|
if not file.is_absolute():
|
||||||
|
raise ValueError(f"Path must be absolute: {path}")
|
||||||
|
if not file.is_file():
|
||||||
|
raise FileNotFoundError(f"File does not exist: {path}")
|
||||||
|
|
||||||
|
resource = FileResource(
|
||||||
|
uri=f"file://{str(file)}",
|
||||||
|
name=name or file.name,
|
||||||
|
description=description,
|
||||||
|
mime_type=mime_type or "text/plain",
|
||||||
|
path=file,
|
||||||
|
)
|
||||||
|
self._resources[resource.uri] = resource
|
||||||
|
return resource
|
||||||
|
|
||||||
|
def add_http_resource(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
mime_type: Optional[str] = None,
|
||||||
|
headers: Optional[Dict[str, str]] = None,
|
||||||
|
) -> HttpResource:
|
||||||
|
"""Add an HTTP endpoint as a resource."""
|
||||||
|
logger.debug(
|
||||||
|
"Adding HTTP resource",
|
||||||
|
extra={
|
||||||
|
"url": url,
|
||||||
|
"name": name,
|
||||||
|
"mime_type": mime_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resource = HttpResource(
|
||||||
|
uri=f"http://{url}",
|
||||||
|
name=name or url.split("/")[-1],
|
||||||
|
description=description,
|
||||||
|
mime_type=mime_type or "text/plain",
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
self._resources[resource.uri] = resource
|
||||||
|
return resource
|
||||||
|
|
||||||
|
def add_dir_resource(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
recursive: bool = False,
|
||||||
|
pattern: Optional[str] = None,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
) -> DirectoryResource:
|
||||||
|
"""Add a directory as a resource."""
|
||||||
|
logger.debug(
|
||||||
|
"Adding directory resource",
|
||||||
|
extra={
|
||||||
|
"path": path,
|
||||||
|
"recursive": recursive,
|
||||||
|
"pattern": pattern,
|
||||||
|
"name": name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
dir_path = Path(path).expanduser().resolve()
|
||||||
|
if not dir_path.is_dir():
|
||||||
|
raise ValueError(f"Directory does not exist: {path}")
|
||||||
|
|
||||||
|
resource = DirectoryResource(
|
||||||
|
uri=f"dir://{str(dir_path)}",
|
||||||
|
name=name or dir_path.name,
|
||||||
|
description=description,
|
||||||
|
path=dir_path,
|
||||||
|
recursive=recursive,
|
||||||
|
pattern=pattern,
|
||||||
|
)
|
||||||
|
self._resources[resource.uri] = resource
|
||||||
|
return resource
|
||||||
212
src/fastmcp/server.py
Normal file
212
src/fastmcp/server.py
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
"""FastMCP - A more ergonomic interface for MCP servers."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any, Callable, Dict, Optional, Sequence, Union
|
||||||
|
|
||||||
|
from mcp.server import Server as MCPServer
|
||||||
|
from mcp.server.stdio import stdio_server
|
||||||
|
from mcp.types import Resource as MCPResource
|
||||||
|
from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .exceptions import ResourceError
|
||||||
|
from .resources import ResourceManager
|
||||||
|
from .tools import ToolManager
|
||||||
|
|
||||||
|
logger = logging.getLogger("mcp")
|
||||||
|
|
||||||
|
|
||||||
|
class FastMCPServer:
|
||||||
|
def __init__(self, name: str):
|
||||||
|
self._mcp_server = MCPServer(name)
|
||||||
|
self._tool_manager = ToolManager()
|
||||||
|
self._resource_manager = ResourceManager()
|
||||||
|
self._setup_handlers()
|
||||||
|
|
||||||
|
def _setup_handlers(self) -> None:
|
||||||
|
"""Set up core MCP protocol handlers."""
|
||||||
|
|
||||||
|
@self._mcp_server.list_tools()
|
||||||
|
async def handle_list_tools() -> list[Tool]:
|
||||||
|
tools = self._tool_manager.list_tools()
|
||||||
|
return [
|
||||||
|
Tool(
|
||||||
|
name=info.name,
|
||||||
|
description=info.description,
|
||||||
|
inputSchema=info.input_schema,
|
||||||
|
)
|
||||||
|
for info in tools
|
||||||
|
]
|
||||||
|
|
||||||
|
@self._mcp_server.call_tool()
|
||||||
|
async def handle_call_tool(
|
||||||
|
name: str, arguments: dict
|
||||||
|
) -> Sequence[Union[TextContent, ImageContent, EmbeddedResource]]:
|
||||||
|
result = await self._tool_manager.call_tool(name, arguments)
|
||||||
|
return [self._convert_to_content(result)]
|
||||||
|
|
||||||
|
@self._mcp_server.list_resources()
|
||||||
|
async def handle_list_resources() -> list[MCPResource]:
|
||||||
|
resources = self._resource_manager.list_resources()
|
||||||
|
return [
|
||||||
|
MCPResource(
|
||||||
|
uri=resource.uri,
|
||||||
|
name=resource.name,
|
||||||
|
description=resource.description,
|
||||||
|
mimeType=resource.mime_type,
|
||||||
|
)
|
||||||
|
for resource in resources
|
||||||
|
]
|
||||||
|
|
||||||
|
@self._mcp_server.read_resource()
|
||||||
|
async def handle_read_resource(uri: str) -> Union[str, bytes]:
|
||||||
|
resource = self._resource_manager.get_resource(uri)
|
||||||
|
if not resource:
|
||||||
|
raise ResourceError(f"Unknown resource: {uri}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await resource.read()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading resource {uri}: {e}")
|
||||||
|
raise ResourceError(str(e))
|
||||||
|
|
||||||
|
def _convert_to_content(
|
||||||
|
self, value: Any
|
||||||
|
) -> Union[TextContent, ImageContent, EmbeddedResource]:
|
||||||
|
"""Convert Python values to MCP content types."""
|
||||||
|
if isinstance(value, (dict, list)):
|
||||||
|
return TextContent(type="text", text=json.dumps(value, indent=2))
|
||||||
|
if isinstance(value, str):
|
||||||
|
return TextContent(type="text", text=value)
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
return ImageContent(
|
||||||
|
type="image",
|
||||||
|
data=base64.b64encode(value).decode(),
|
||||||
|
mimeType="application/octet-stream",
|
||||||
|
)
|
||||||
|
if isinstance(value, BaseModel):
|
||||||
|
return TextContent(type="text", text=value.model_dump_json(indent=2))
|
||||||
|
return TextContent(type="text", text=str(value))
|
||||||
|
|
||||||
|
def add_tool(
|
||||||
|
self,
|
||||||
|
func: Callable,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Add a tool to the server."""
|
||||||
|
self._tool_manager.add_tool(func, name=name, description=description)
|
||||||
|
|
||||||
|
def tool(
|
||||||
|
self, name: Optional[str] = None, description: Optional[str] = None
|
||||||
|
) -> Callable:
|
||||||
|
"""Decorator to register a tool."""
|
||||||
|
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
self.add_tool(func, name=name, description=description)
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
def add_file_resource(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
mime_type: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Add a file as a resource."""
|
||||||
|
self._resource_manager.add_file_resource(
|
||||||
|
path,
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
mime_type=mime_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_http_resource(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
mime_type: Optional[str] = None,
|
||||||
|
headers: Optional[Dict[str, str]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Add an HTTP endpoint as a resource."""
|
||||||
|
self._resource_manager.add_http_resource(
|
||||||
|
url,
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
mime_type=mime_type,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_dir_resource(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
recursive: bool = False,
|
||||||
|
pattern: Optional[str] = None,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Add a directory as a resource."""
|
||||||
|
self._resource_manager.add_dir_resource(
|
||||||
|
path,
|
||||||
|
recursive=recursive,
|
||||||
|
pattern=pattern,
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run(self, *args, **kwargs) -> None:
|
||||||
|
"""Run the FastMCP server."""
|
||||||
|
await self._mcp_server.run(*args, **kwargs)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def run_stdio(cls, app: "FastMCPServer") -> None:
|
||||||
|
"""Run the server using stdio transport."""
|
||||||
|
async with stdio_server() as (read_stream, write_stream):
|
||||||
|
await app.run(
|
||||||
|
read_stream,
|
||||||
|
write_stream,
|
||||||
|
app._mcp_server.create_initialization_options(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def run_sse(
|
||||||
|
cls, app: "FastMCPServer", host: str = "0.0.0.0", port: int = 8000
|
||||||
|
) -> None:
|
||||||
|
"""Run the server using SSE transport."""
|
||||||
|
from mcp.server.sse import SseServerTransport
|
||||||
|
from starlette.applications import Starlette
|
||||||
|
from starlette.routing import Route
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
sse = SseServerTransport("/messages")
|
||||||
|
|
||||||
|
async def handle_sse(request):
|
||||||
|
async with sse.connect_sse(
|
||||||
|
request.scope, request.receive, request._send
|
||||||
|
) as streams:
|
||||||
|
await app.run(
|
||||||
|
streams[0],
|
||||||
|
streams[1],
|
||||||
|
app._mcp_server.create_initialization_options(),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_messages(request):
|
||||||
|
await sse.handle_post_message(request.scope, request.receive, request._send)
|
||||||
|
|
||||||
|
starlette_app = Starlette(
|
||||||
|
debug=True,
|
||||||
|
routes=[
|
||||||
|
Route("/sse", endpoint=handle_sse),
|
||||||
|
Route("/messages", endpoint=handle_messages, methods=["POST"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
uvicorn.run(starlette_app, host=host, port=port)
|
||||||
90
src/fastmcp/tools.py
Normal file
90
src/fastmcp/tools.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
"""Tool management for FastMCP."""
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
from typing import Any, Callable, Dict, Optional, get_type_hints
|
||||||
|
|
||||||
|
from pydantic import BaseModel, create_model
|
||||||
|
|
||||||
|
from .exceptions import ToolError
|
||||||
|
from .models import Tool
|
||||||
|
|
||||||
|
|
||||||
|
class ToolManager:
|
||||||
|
"""Manages FastMCP tools."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._tools: Dict[str, Tool] = {}
|
||||||
|
|
||||||
|
def get_tool(self, name: str) -> Optional[Tool]:
|
||||||
|
"""Get tool by name."""
|
||||||
|
return self._tools.get(name)
|
||||||
|
|
||||||
|
def list_tools(self) -> list[Tool]:
|
||||||
|
"""List all registered tools."""
|
||||||
|
return list(self._tools.values())
|
||||||
|
|
||||||
|
def add_tool(
|
||||||
|
self,
|
||||||
|
func: Callable,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Add a tool to the server."""
|
||||||
|
func_name = name or func.__name__
|
||||||
|
func_doc = description or func.__doc__ or ""
|
||||||
|
is_async = inspect.iscoroutinefunction(func)
|
||||||
|
|
||||||
|
# Get type hints for parameters
|
||||||
|
hints = get_type_hints(func)
|
||||||
|
if "return" in hints:
|
||||||
|
del hints["return"]
|
||||||
|
|
||||||
|
# Check for Pydantic model parameter
|
||||||
|
if len(hints) == 1 and issubclass(next(iter(hints.values())), BaseModel):
|
||||||
|
model = next(iter(hints.values()))
|
||||||
|
schema = model.model_json_schema()
|
||||||
|
pydantic_model = model
|
||||||
|
else:
|
||||||
|
# Create parameter schema from type hints
|
||||||
|
fields = {}
|
||||||
|
sig = inspect.signature(func)
|
||||||
|
for param_name, param in sig.parameters.items():
|
||||||
|
param_type = hints.get(param_name, Any)
|
||||||
|
default = (
|
||||||
|
... if param.default is inspect.Parameter.empty else param.default
|
||||||
|
)
|
||||||
|
fields[param_name] = (param_type, default)
|
||||||
|
|
||||||
|
model = create_model(f"{func_name}Args", **fields)
|
||||||
|
schema = model.model_json_schema()
|
||||||
|
pydantic_model = model
|
||||||
|
|
||||||
|
self._tools[func_name] = Tool(
|
||||||
|
func=func,
|
||||||
|
name=func_name,
|
||||||
|
description=func_doc,
|
||||||
|
input_schema=schema,
|
||||||
|
is_async=is_async,
|
||||||
|
pydantic_model=pydantic_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def call_tool(self, name: str, arguments: dict) -> Any:
|
||||||
|
"""Call a tool by name with arguments."""
|
||||||
|
tool = self.get_tool(name)
|
||||||
|
if not tool:
|
||||||
|
raise ToolError(f"Unknown tool: {name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Validate arguments using schema
|
||||||
|
if tool.pydantic_model:
|
||||||
|
validated_args = tool.pydantic_model(**arguments)
|
||||||
|
args_dict = validated_args.model_dump()
|
||||||
|
else:
|
||||||
|
args_dict = arguments
|
||||||
|
|
||||||
|
# Call function with proper async handling
|
||||||
|
if tool.is_async:
|
||||||
|
return await tool.func(**args_dict)
|
||||||
|
return tool.func(**args_dict)
|
||||||
|
except Exception as e:
|
||||||
|
raise ToolError(f"Error executing tool {name}: {e}") from e
|
||||||
201
tests/test_resource_manager.py
Normal file
201
tests/test_resource_manager.py
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
"""Tests for resource management."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
||||||
|
|
||||||
|
from fastmcp.resources import FileResource, ResourceManager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def resource_manager():
|
||||||
|
"""Create a resource manager for testing."""
|
||||||
|
return ResourceManager()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_file():
|
||||||
|
"""Create a temporary file for testing.
|
||||||
|
|
||||||
|
File is automatically cleaned up after the test if it still exists.
|
||||||
|
"""
|
||||||
|
content = "test content"
|
||||||
|
with NamedTemporaryFile(mode="w", delete=False) as f:
|
||||||
|
f.write(content)
|
||||||
|
path = Path(f.name).resolve()
|
||||||
|
yield path
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass # File was already deleted by the test
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_file_no_cleanup():
|
||||||
|
"""Create a temporary file for testing.
|
||||||
|
|
||||||
|
File is NOT automatically cleaned up - tests must handle cleanup.
|
||||||
|
"""
|
||||||
|
content = "test content"
|
||||||
|
with NamedTemporaryFile(mode="w", delete=False) as f:
|
||||||
|
f.write(content)
|
||||||
|
path = Path(f.name).resolve()
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_dir():
|
||||||
|
"""Create a temporary directory for testing."""
|
||||||
|
with TemporaryDirectory() as d:
|
||||||
|
yield Path(d).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileResource:
|
||||||
|
"""Test FileResource functionality."""
|
||||||
|
|
||||||
|
def test_file_resource_creation(self, temp_file: Path):
|
||||||
|
"""Test creating a FileResource."""
|
||||||
|
resource = FileResource(
|
||||||
|
uri=f"file://{temp_file}",
|
||||||
|
name="test",
|
||||||
|
description="test file",
|
||||||
|
mime_type="text/plain",
|
||||||
|
path=temp_file,
|
||||||
|
)
|
||||||
|
assert resource.uri == f"file://{temp_file}"
|
||||||
|
assert resource.name == "test"
|
||||||
|
assert resource.description == "test file"
|
||||||
|
assert resource.mime_type == "text/plain"
|
||||||
|
assert resource.path == temp_file
|
||||||
|
|
||||||
|
def test_file_resource_relative_path_error(self):
|
||||||
|
"""Test FileResource rejects relative paths."""
|
||||||
|
with pytest.raises(ValueError, match="Path must be absolute"):
|
||||||
|
FileResource(
|
||||||
|
uri="file://test.txt",
|
||||||
|
name="test",
|
||||||
|
path=Path("test.txt"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_file_resource_str_path_conversion(self, temp_file: Path):
|
||||||
|
"""Test FileResource handles string paths."""
|
||||||
|
resource = FileResource(
|
||||||
|
uri=f"file://{temp_file}",
|
||||||
|
name="test",
|
||||||
|
path=str(temp_file),
|
||||||
|
)
|
||||||
|
assert isinstance(resource.path, Path)
|
||||||
|
assert resource.path.is_absolute()
|
||||||
|
|
||||||
|
async def test_file_resource_read(self, temp_file: Path):
|
||||||
|
"""Test reading a FileResource."""
|
||||||
|
resource = FileResource(
|
||||||
|
uri=f"file://{temp_file}",
|
||||||
|
name="test",
|
||||||
|
path=temp_file,
|
||||||
|
)
|
||||||
|
content = await resource.read()
|
||||||
|
assert content == "test content"
|
||||||
|
|
||||||
|
async def test_file_resource_read_missing_file(self, temp_dir: Path):
|
||||||
|
"""Test reading a non-existent file."""
|
||||||
|
missing_file = temp_dir / "missing.txt"
|
||||||
|
resource = FileResource(
|
||||||
|
uri=f"file://{missing_file}",
|
||||||
|
name="test",
|
||||||
|
path=missing_file,
|
||||||
|
)
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
await resource.read()
|
||||||
|
|
||||||
|
async def test_file_resource_read_permission_error(self, temp_file: Path):
|
||||||
|
"""Test reading a file without permissions."""
|
||||||
|
temp_file.chmod(0o000) # Remove all permissions
|
||||||
|
try:
|
||||||
|
resource = FileResource(
|
||||||
|
uri=f"file://{temp_file}",
|
||||||
|
name="test",
|
||||||
|
path=temp_file,
|
||||||
|
)
|
||||||
|
with pytest.raises(PermissionError):
|
||||||
|
await resource.read()
|
||||||
|
finally:
|
||||||
|
temp_file.chmod(0o644) # Restore permissions
|
||||||
|
|
||||||
|
|
||||||
|
class TestResourceManager:
|
||||||
|
"""Test ResourceManager functionality."""
|
||||||
|
|
||||||
|
def test_add_file_resource(
|
||||||
|
self, resource_manager: ResourceManager, temp_file: Path
|
||||||
|
):
|
||||||
|
"""Test adding a file resource."""
|
||||||
|
resource = resource_manager.add_file_resource(
|
||||||
|
str(temp_file),
|
||||||
|
name="test",
|
||||||
|
description="test file",
|
||||||
|
mime_type="text/plain",
|
||||||
|
)
|
||||||
|
assert isinstance(resource, FileResource)
|
||||||
|
assert resource.uri == f"file://{temp_file}"
|
||||||
|
assert resource.name == "test"
|
||||||
|
assert resource.description == "test file"
|
||||||
|
assert resource.mime_type == "text/plain"
|
||||||
|
assert resource.path == temp_file
|
||||||
|
|
||||||
|
def test_add_file_resource_relative_path_error(
|
||||||
|
self, resource_manager: ResourceManager
|
||||||
|
):
|
||||||
|
"""Test ResourceManager rejects relative paths."""
|
||||||
|
with pytest.raises(ValueError, match="Path must be absolute"):
|
||||||
|
resource_manager.add_file_resource("test.txt")
|
||||||
|
|
||||||
|
def test_add_file_resource_missing_file_error(
|
||||||
|
self, resource_manager: ResourceManager, temp_dir: Path
|
||||||
|
):
|
||||||
|
"""Test ResourceManager rejects non-existent files."""
|
||||||
|
missing_file = temp_dir / "missing.txt"
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
resource_manager.add_file_resource(str(missing_file))
|
||||||
|
|
||||||
|
def test_get_resource_unknown_uri(self, resource_manager: ResourceManager):
|
||||||
|
"""Test getting a non-existent resource."""
|
||||||
|
with pytest.raises(ValueError, match="Unknown resource"):
|
||||||
|
resource_manager.get_resource("file://unknown")
|
||||||
|
|
||||||
|
def test_get_resource(self, resource_manager: ResourceManager, temp_file: Path):
|
||||||
|
"""Test getting a resource by URI."""
|
||||||
|
added = resource_manager.add_file_resource(str(temp_file))
|
||||||
|
retrieved = resource_manager.get_resource(added.uri)
|
||||||
|
assert retrieved == added
|
||||||
|
|
||||||
|
def test_list_resources(self, resource_manager: ResourceManager, temp_file: Path):
|
||||||
|
"""Test listing all resources."""
|
||||||
|
resource = resource_manager.add_file_resource(str(temp_file))
|
||||||
|
resources = resource_manager.list_resources()
|
||||||
|
assert len(resources) == 1
|
||||||
|
assert resources[0] == resource
|
||||||
|
|
||||||
|
async def test_resource_read_through_manager(
|
||||||
|
self, resource_manager: ResourceManager, temp_file: Path
|
||||||
|
):
|
||||||
|
"""Test reading a resource through the manager."""
|
||||||
|
resource = resource_manager.add_file_resource(str(temp_file))
|
||||||
|
retrieved = resource_manager.get_resource(resource.uri)
|
||||||
|
assert retrieved is not None
|
||||||
|
content = await retrieved.read()
|
||||||
|
assert content == "test content"
|
||||||
|
|
||||||
|
async def test_resource_read_error_through_manager(
|
||||||
|
self, resource_manager: ResourceManager, temp_file_no_cleanup: Path
|
||||||
|
):
|
||||||
|
"""Test error handling when reading through manager."""
|
||||||
|
# Create resource while file exists
|
||||||
|
resource = resource_manager.add_file_resource(str(temp_file_no_cleanup))
|
||||||
|
retrieved = resource_manager.get_resource(resource.uri)
|
||||||
|
assert retrieved is not None
|
||||||
|
|
||||||
|
# Delete file and verify read fails
|
||||||
|
temp_file_no_cleanup.unlink()
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
await retrieved.read()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue