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