Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
77d4a9a
0
Parent(s):
Initial commit
Browse files- .gitattributes +1 -0
- .gitignore +11 -0
- .python-version +1 -0
- README.md +2 -0
- examples/desktop.py +31 -0
- examples/weather.py +133 -0
- pyproject.toml +36 -0
- src/fastmcp/_version.py +16 -0
- src/fastmcp/cli.py +72 -0
- src/fastmcp/exceptions.py +17 -0
- src/fastmcp/models.py +18 -0
- src/fastmcp/resources.py +249 -0
- src/fastmcp/server.py +212 -0
- src/fastmcp/tools.py +90 -0
- tests/test_resource_manager.py +201 -0
- uv.lock +0 -0
.gitattributes
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python-generated files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[oc]
|
| 4 |
+
build/
|
| 5 |
+
dist/
|
| 6 |
+
wheels/
|
| 7 |
+
*.egg-info
|
| 8 |
+
|
| 9 |
+
# Virtual environments
|
| 10 |
+
.venv
|
| 11 |
+
.DS_Store
|
.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.12
|
README.md
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Notes:
|
| 2 |
+
- uv must be installed with brew to run local servers
|
examples/desktop.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastMCP Desktop Example
|
| 3 |
+
|
| 4 |
+
A simple example that exposes the desktop directory as a resource.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import asyncio
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
from fastmcp.server import FastMCPServer
|
| 11 |
+
|
| 12 |
+
# Create server
|
| 13 |
+
app = FastMCPServer("desktop")
|
| 14 |
+
|
| 15 |
+
# Add desktop as a directory resource
|
| 16 |
+
desktop = Path.home() / "Desktop"
|
| 17 |
+
app.add_dir_resource(
|
| 18 |
+
str(desktop),
|
| 19 |
+
recursive=True,
|
| 20 |
+
name="Desktop",
|
| 21 |
+
description="Files on the desktop",
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def main123():
|
| 26 |
+
# Run the server
|
| 27 |
+
asyncio.run(FastMCPServer.run_stdio(app))
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
if __name__ == "__main__":
|
| 31 |
+
main123()
|
examples/weather.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastMCP Weather Server Example
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import httpx
|
| 7 |
+
from pydantic import BaseModel, Field
|
| 8 |
+
from fastmcp.server import FastMCPServer
|
| 9 |
+
|
| 10 |
+
# Load env vars
|
| 11 |
+
API_KEY = os.getenv("OPENWEATHER_API_KEY")
|
| 12 |
+
if not API_KEY:
|
| 13 |
+
raise ValueError("OPENWEATHER_API_KEY environment variable required")
|
| 14 |
+
|
| 15 |
+
# API configuration
|
| 16 |
+
API_BASE = "http://api.openweathermap.org/data/2.5"
|
| 17 |
+
DEFAULT_PARAMS = {"appid": API_KEY, "units": "metric"}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# Pydantic models for parameters
|
| 21 |
+
class ForecastParams(BaseModel):
|
| 22 |
+
city: str = Field(..., description="City name")
|
| 23 |
+
days: int = Field(default=5, ge=1, le=10, description="Number of days to forecast")
|
| 24 |
+
units: str = Field(
|
| 25 |
+
default="metric", pattern="^(metric|imperial)$", description="Temperature units"
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class AlertParams(BaseModel):
|
| 30 |
+
lat: float = Field(..., description="Latitude")
|
| 31 |
+
lon: float = Field(..., description="Longitude")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# Create server
|
| 35 |
+
app = FastMCPServer("weather-service")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# Tools using Pydantic models
|
| 39 |
+
@app.tool(description="Get detailed weather forecast for a city")
|
| 40 |
+
async def get_forecast(params: ForecastParams) -> dict:
|
| 41 |
+
"""Get a multi-day weather forecast for a city."""
|
| 42 |
+
async with httpx.AsyncClient() as client:
|
| 43 |
+
response = await client.get(
|
| 44 |
+
f"{API_BASE}/forecast",
|
| 45 |
+
params={
|
| 46 |
+
"q": params.city,
|
| 47 |
+
"cnt": params.days * 8, # API returns 3-hour intervals
|
| 48 |
+
"units": params.units,
|
| 49 |
+
**DEFAULT_PARAMS,
|
| 50 |
+
},
|
| 51 |
+
)
|
| 52 |
+
response.raise_for_status()
|
| 53 |
+
data = response.json()
|
| 54 |
+
|
| 55 |
+
# Process into daily forecasts
|
| 56 |
+
forecasts = []
|
| 57 |
+
for i in range(0, len(data["list"]), 8): # Every 8th entry is a new day
|
| 58 |
+
day_data = data["list"][i]
|
| 59 |
+
forecasts.append(
|
| 60 |
+
{
|
| 61 |
+
"date": day_data["dt_txt"].split()[0],
|
| 62 |
+
"temperature": {
|
| 63 |
+
"high": day_data["main"]["temp_max"],
|
| 64 |
+
"low": day_data["main"]["temp_min"],
|
| 65 |
+
},
|
| 66 |
+
"conditions": day_data["weather"][0]["description"],
|
| 67 |
+
"humidity": day_data["main"]["humidity"],
|
| 68 |
+
"wind_speed": day_data["wind"]["speed"],
|
| 69 |
+
}
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
return {
|
| 73 |
+
"city": data["city"]["name"],
|
| 74 |
+
"country": data["city"]["country"],
|
| 75 |
+
"forecasts": forecasts,
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# Tools using simple kwargs
|
| 80 |
+
@app.tool()
|
| 81 |
+
async def get_alerts(lat: float, lon: float) -> list:
|
| 82 |
+
"""Get weather alerts and warnings for a location."""
|
| 83 |
+
async with httpx.AsyncClient() as client:
|
| 84 |
+
response = await client.get(
|
| 85 |
+
f"{API_BASE}/onecall",
|
| 86 |
+
params={
|
| 87 |
+
"lat": lat,
|
| 88 |
+
"lon": lon,
|
| 89 |
+
"exclude": "current,minutely,hourly,daily",
|
| 90 |
+
**DEFAULT_PARAMS,
|
| 91 |
+
},
|
| 92 |
+
)
|
| 93 |
+
response.raise_for_status()
|
| 94 |
+
data = response.json()
|
| 95 |
+
|
| 96 |
+
return data.get("alerts", [])
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# Add HTTP resources
|
| 100 |
+
app.add_http_resource(
|
| 101 |
+
f"{API_BASE}/weather?q=London&units=metric&appid={API_KEY}",
|
| 102 |
+
name="London Weather",
|
| 103 |
+
description="Current weather in London",
|
| 104 |
+
mime_type="application/json",
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
# Add local data resources
|
| 108 |
+
app.add_file_resource("weather_stations/*.json", description="Weather station metadata")
|
| 109 |
+
|
| 110 |
+
app.add_dir_resource(
|
| 111 |
+
"~/Developer/fastmcp/historical_data",
|
| 112 |
+
pattern="*.csv",
|
| 113 |
+
recursive=True,
|
| 114 |
+
description="Historical weather data",
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def main():
|
| 119 |
+
import asyncio
|
| 120 |
+
import logging
|
| 121 |
+
|
| 122 |
+
# Configure logging
|
| 123 |
+
logging.basicConfig(
|
| 124 |
+
level=logging.INFO,
|
| 125 |
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
# Run the server
|
| 129 |
+
asyncio.run(FastMCPServer.run_stdio(app))
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
if __name__ == "__main__":
|
| 133 |
+
main()
|
pyproject.toml
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "fastmcp"
|
| 3 |
+
dynamic = ["version"]
|
| 4 |
+
description = "A more ergonomic interface for MCP servers"
|
| 5 |
+
authors = [{ name = "Jeremiah Lowin" }]
|
| 6 |
+
dependencies = [
|
| 7 |
+
"httpx>=0.26.0",
|
| 8 |
+
"mcp>=1.0.0",
|
| 9 |
+
"pydantic>=2.5.3",
|
| 10 |
+
"typer>=0.9.0",
|
| 11 |
+
]
|
| 12 |
+
requires-python = ">=3.10"
|
| 13 |
+
readme = "README.md"
|
| 14 |
+
license = { text = "Apache-2.0" }
|
| 15 |
+
|
| 16 |
+
[project.scripts]
|
| 17 |
+
fastmcp = "fastmcp.cli:app"
|
| 18 |
+
|
| 19 |
+
[build-system]
|
| 20 |
+
requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2"]
|
| 21 |
+
build-backend = "setuptools.build_meta"
|
| 22 |
+
|
| 23 |
+
[tool.setuptools_scm]
|
| 24 |
+
write_to = "src/fastmcp/_version.py"
|
| 25 |
+
|
| 26 |
+
[dependency-groups]
|
| 27 |
+
dev = [
|
| 28 |
+
"copychat>=0.5.2",
|
| 29 |
+
"ipython>=8.12.3",
|
| 30 |
+
"pdbpp>=0.10.3",
|
| 31 |
+
"pytest>=8.3.3",
|
| 32 |
+
"pytest-asyncio>=0.23.5",
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
[tool.pytest.ini_options]
|
| 36 |
+
asyncio_mode = "auto"
|
src/fastmcp/_version.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# file generated by setuptools_scm
|
| 2 |
+
# don't change, don't track in version control
|
| 3 |
+
TYPE_CHECKING = False
|
| 4 |
+
if TYPE_CHECKING:
|
| 5 |
+
from typing import Tuple, Union
|
| 6 |
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
| 7 |
+
else:
|
| 8 |
+
VERSION_TUPLE = object
|
| 9 |
+
|
| 10 |
+
version: str
|
| 11 |
+
__version__: str
|
| 12 |
+
__version_tuple__: VERSION_TUPLE
|
| 13 |
+
version_tuple: VERSION_TUPLE
|
| 14 |
+
|
| 15 |
+
__version__ = version = '0.1.dev0+d20241129'
|
| 16 |
+
__version_tuple__ = version_tuple = (0, 1, 'dev0', 'd20241129')
|
src/fastmcp/cli.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastMCP CLI tools."""
|
| 2 |
+
|
| 3 |
+
import importlib.metadata
|
| 4 |
+
import logging
|
| 5 |
+
import subprocess
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import typer
|
| 10 |
+
|
| 11 |
+
# Configure logging
|
| 12 |
+
logger = logging.getLogger("mcp")
|
| 13 |
+
|
| 14 |
+
app = typer.Typer(
|
| 15 |
+
name="fastmcp",
|
| 16 |
+
help="FastMCP development tools",
|
| 17 |
+
add_completion=False,
|
| 18 |
+
no_args_is_help=True, # Show help if no args provided
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@app.command()
|
| 23 |
+
def version() -> None:
|
| 24 |
+
"""Show the FastMCP version."""
|
| 25 |
+
try:
|
| 26 |
+
version = importlib.metadata.version("fastmcp")
|
| 27 |
+
print(f"FastMCP version {version}")
|
| 28 |
+
except importlib.metadata.PackageNotFoundError:
|
| 29 |
+
print("FastMCP version unknown (package not installed)")
|
| 30 |
+
sys.exit(1)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@app.command()
|
| 34 |
+
def dev(
|
| 35 |
+
file: Path = typer.Argument(
|
| 36 |
+
...,
|
| 37 |
+
help="Python file to run",
|
| 38 |
+
exists=True,
|
| 39 |
+
dir_okay=False,
|
| 40 |
+
resolve_path=True,
|
| 41 |
+
),
|
| 42 |
+
) -> None:
|
| 43 |
+
"""Run a FastMCP server with the MCP Inspector."""
|
| 44 |
+
logger.debug("Starting dev server", extra={"file": str(file)})
|
| 45 |
+
|
| 46 |
+
try:
|
| 47 |
+
# Run the MCP Inspector command
|
| 48 |
+
process = subprocess.run(
|
| 49 |
+
["npx", "@modelcontextprotocol/inspector", "uv", "run", str(file)],
|
| 50 |
+
check=True,
|
| 51 |
+
)
|
| 52 |
+
sys.exit(process.returncode)
|
| 53 |
+
except subprocess.CalledProcessError as e:
|
| 54 |
+
logger.error(
|
| 55 |
+
"Dev server failed",
|
| 56 |
+
extra={
|
| 57 |
+
"file": str(file),
|
| 58 |
+
"error": str(e),
|
| 59 |
+
"returncode": e.returncode,
|
| 60 |
+
},
|
| 61 |
+
)
|
| 62 |
+
sys.exit(e.returncode)
|
| 63 |
+
except FileNotFoundError:
|
| 64 |
+
logger.error(
|
| 65 |
+
"npx not found. Please install Node.js and npm.",
|
| 66 |
+
extra={"file": str(file)},
|
| 67 |
+
)
|
| 68 |
+
sys.exit(1)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
if __name__ == "__main__":
|
| 72 |
+
app()
|
src/fastmcp/exceptions.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Custom exceptions for FastMCP."""
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class FastMCPError(Exception):
|
| 5 |
+
"""Base error for FastMCP."""
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ValidationError(FastMCPError):
|
| 9 |
+
"""Error in validating parameters or return values."""
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ResourceError(FastMCPError):
|
| 13 |
+
"""Error in resource operations."""
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ToolError(FastMCPError):
|
| 17 |
+
"""Error in tool operations."""
|
src/fastmcp/models.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic models for FastMCP."""
|
| 2 |
+
|
| 3 |
+
from typing import Callable, Optional, Type
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class Tool(BaseModel):
|
| 9 |
+
"""Internal tool registration info."""
|
| 10 |
+
|
| 11 |
+
model_config: dict = dict(arbitrary_types_allowed=True)
|
| 12 |
+
|
| 13 |
+
func: Callable
|
| 14 |
+
name: str
|
| 15 |
+
description: str
|
| 16 |
+
input_schema: dict
|
| 17 |
+
is_async: bool
|
| 18 |
+
pydantic_model: Optional[Type[BaseModel]] = None
|
src/fastmcp/resources.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Resource management for FastMCP."""
|
| 2 |
+
|
| 3 |
+
import abc
|
| 4 |
+
import asyncio
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Dict, Optional
|
| 9 |
+
|
| 10 |
+
import httpx
|
| 11 |
+
from pydantic import BaseModel, field_validator
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger("mcp")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class Resource(BaseModel):
|
| 18 |
+
"""Base class for all resources."""
|
| 19 |
+
|
| 20 |
+
uri: str
|
| 21 |
+
name: str
|
| 22 |
+
description: Optional[str] = None
|
| 23 |
+
mime_type: str = "text/plain"
|
| 24 |
+
|
| 25 |
+
@abc.abstractmethod
|
| 26 |
+
async def read(self) -> str:
|
| 27 |
+
"""Read the resource content."""
|
| 28 |
+
...
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class FileResource(Resource):
|
| 32 |
+
"""A file resource."""
|
| 33 |
+
|
| 34 |
+
path: Path
|
| 35 |
+
|
| 36 |
+
@field_validator("path")
|
| 37 |
+
@classmethod
|
| 38 |
+
def validate_absolute_path(cls, path: Path) -> Path:
|
| 39 |
+
"""Ensure path is absolute."""
|
| 40 |
+
if not path.is_absolute():
|
| 41 |
+
raise ValueError(f"Path must be absolute: {path}")
|
| 42 |
+
return path
|
| 43 |
+
|
| 44 |
+
async def read(self) -> str:
|
| 45 |
+
"""Read the file content."""
|
| 46 |
+
try:
|
| 47 |
+
return await asyncio.to_thread(self.path.read_text)
|
| 48 |
+
except FileNotFoundError:
|
| 49 |
+
raise FileNotFoundError(f"File not found: {self.path}")
|
| 50 |
+
except PermissionError:
|
| 51 |
+
raise PermissionError(f"Permission denied: {self.path}")
|
| 52 |
+
except Exception as e:
|
| 53 |
+
raise ValueError(f"Error reading file {self.path}: {e}")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class HttpResource(Resource):
|
| 57 |
+
"""An HTTP resource."""
|
| 58 |
+
|
| 59 |
+
url: str
|
| 60 |
+
headers: Optional[Dict[str, str]] = None
|
| 61 |
+
|
| 62 |
+
async def read(self) -> str:
|
| 63 |
+
"""Read the HTTP resource content."""
|
| 64 |
+
try:
|
| 65 |
+
async with httpx.AsyncClient() as client:
|
| 66 |
+
response = await client.get(self.url, headers=self.headers)
|
| 67 |
+
response.raise_for_status()
|
| 68 |
+
return response.text
|
| 69 |
+
except httpx.HTTPStatusError as e:
|
| 70 |
+
raise ValueError(f"HTTP error {e.response.status_code}: {e}")
|
| 71 |
+
except httpx.RequestError as e:
|
| 72 |
+
raise ValueError(f"Request failed: {e}")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class DirectoryResource(Resource):
|
| 76 |
+
"""A directory resource."""
|
| 77 |
+
|
| 78 |
+
path: Path
|
| 79 |
+
recursive: bool = False
|
| 80 |
+
pattern: Optional[str] = None
|
| 81 |
+
mime_type: str = "application/json"
|
| 82 |
+
|
| 83 |
+
@field_validator("path")
|
| 84 |
+
@classmethod
|
| 85 |
+
def validate_absolute_path(cls, path: Path) -> Path:
|
| 86 |
+
"""Ensure path is absolute."""
|
| 87 |
+
if not path.is_absolute():
|
| 88 |
+
raise ValueError(f"Path must be absolute: {path}")
|
| 89 |
+
return path
|
| 90 |
+
|
| 91 |
+
def list_files(self) -> list[Path]:
|
| 92 |
+
"""List files in the directory."""
|
| 93 |
+
if not self.path.exists():
|
| 94 |
+
raise FileNotFoundError(f"Directory not found: {self.path}")
|
| 95 |
+
if not self.path.is_dir():
|
| 96 |
+
raise NotADirectoryError(f"Not a directory: {self.path}")
|
| 97 |
+
|
| 98 |
+
try:
|
| 99 |
+
if self.pattern:
|
| 100 |
+
return (
|
| 101 |
+
list(self.path.glob(self.pattern))
|
| 102 |
+
if not self.recursive
|
| 103 |
+
else list(self.path.rglob(self.pattern))
|
| 104 |
+
)
|
| 105 |
+
return (
|
| 106 |
+
list(self.path.glob("*"))
|
| 107 |
+
if not self.recursive
|
| 108 |
+
else list(self.path.rglob("*"))
|
| 109 |
+
)
|
| 110 |
+
except Exception as e:
|
| 111 |
+
raise ValueError(f"Error listing directory {self.path}: {e}")
|
| 112 |
+
|
| 113 |
+
async def read(self) -> str:
|
| 114 |
+
"""Read the directory listing."""
|
| 115 |
+
try:
|
| 116 |
+
files = await asyncio.to_thread(self.list_files)
|
| 117 |
+
file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
|
| 118 |
+
return json.dumps({"files": file_list}, indent=2)
|
| 119 |
+
except Exception as e:
|
| 120 |
+
raise ValueError(f"Error reading directory {self.path}: {e}")
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class ResourceManager:
|
| 124 |
+
"""Manages FastMCP resources."""
|
| 125 |
+
|
| 126 |
+
def __init__(self):
|
| 127 |
+
self._resources: Dict[str, Resource] = {}
|
| 128 |
+
|
| 129 |
+
def get_resource(self, uri: str) -> Optional[Resource]:
|
| 130 |
+
"""Get resource by URI."""
|
| 131 |
+
logger.debug("Getting resource", extra={"uri": uri})
|
| 132 |
+
resource = self._resources.get(uri)
|
| 133 |
+
if not resource:
|
| 134 |
+
raise ValueError(f"Unknown resource: {uri}")
|
| 135 |
+
return resource
|
| 136 |
+
|
| 137 |
+
def list_resources(self) -> list[Resource]:
|
| 138 |
+
"""List all registered resources."""
|
| 139 |
+
logger.debug("Listing resources", extra={"count": len(self._resources)})
|
| 140 |
+
return list(self._resources.values())
|
| 141 |
+
|
| 142 |
+
def add_file_resource(
|
| 143 |
+
self,
|
| 144 |
+
path: str,
|
| 145 |
+
*,
|
| 146 |
+
name: Optional[str] = None,
|
| 147 |
+
description: Optional[str] = None,
|
| 148 |
+
mime_type: Optional[str] = None,
|
| 149 |
+
) -> FileResource:
|
| 150 |
+
"""Add a file as a resource.
|
| 151 |
+
|
| 152 |
+
Args:
|
| 153 |
+
path: Absolute path to the file
|
| 154 |
+
name: Optional name for the resource
|
| 155 |
+
description: Optional description of the resource
|
| 156 |
+
mime_type: Optional MIME type for the resource
|
| 157 |
+
|
| 158 |
+
Returns:
|
| 159 |
+
The created resource
|
| 160 |
+
|
| 161 |
+
Raises:
|
| 162 |
+
ValueError: If the path is not absolute or the file does not exist
|
| 163 |
+
"""
|
| 164 |
+
logger.debug(
|
| 165 |
+
"Adding file resource",
|
| 166 |
+
extra={
|
| 167 |
+
"path": path,
|
| 168 |
+
"name": name,
|
| 169 |
+
"mime_type": mime_type,
|
| 170 |
+
},
|
| 171 |
+
)
|
| 172 |
+
file = Path(path)
|
| 173 |
+
if not file.is_absolute():
|
| 174 |
+
raise ValueError(f"Path must be absolute: {path}")
|
| 175 |
+
if not file.is_file():
|
| 176 |
+
raise FileNotFoundError(f"File does not exist: {path}")
|
| 177 |
+
|
| 178 |
+
resource = FileResource(
|
| 179 |
+
uri=f"file://{str(file)}",
|
| 180 |
+
name=name or file.name,
|
| 181 |
+
description=description,
|
| 182 |
+
mime_type=mime_type or "text/plain",
|
| 183 |
+
path=file,
|
| 184 |
+
)
|
| 185 |
+
self._resources[resource.uri] = resource
|
| 186 |
+
return resource
|
| 187 |
+
|
| 188 |
+
def add_http_resource(
|
| 189 |
+
self,
|
| 190 |
+
url: str,
|
| 191 |
+
*,
|
| 192 |
+
name: Optional[str] = None,
|
| 193 |
+
description: Optional[str] = None,
|
| 194 |
+
mime_type: Optional[str] = None,
|
| 195 |
+
headers: Optional[Dict[str, str]] = None,
|
| 196 |
+
) -> HttpResource:
|
| 197 |
+
"""Add an HTTP endpoint as a resource."""
|
| 198 |
+
logger.debug(
|
| 199 |
+
"Adding HTTP resource",
|
| 200 |
+
extra={
|
| 201 |
+
"url": url,
|
| 202 |
+
"name": name,
|
| 203 |
+
"mime_type": mime_type,
|
| 204 |
+
},
|
| 205 |
+
)
|
| 206 |
+
resource = HttpResource(
|
| 207 |
+
uri=f"http://{url}",
|
| 208 |
+
name=name or url.split("/")[-1],
|
| 209 |
+
description=description,
|
| 210 |
+
mime_type=mime_type or "text/plain",
|
| 211 |
+
url=url,
|
| 212 |
+
headers=headers,
|
| 213 |
+
)
|
| 214 |
+
self._resources[resource.uri] = resource
|
| 215 |
+
return resource
|
| 216 |
+
|
| 217 |
+
def add_dir_resource(
|
| 218 |
+
self,
|
| 219 |
+
path: str,
|
| 220 |
+
*,
|
| 221 |
+
recursive: bool = False,
|
| 222 |
+
pattern: Optional[str] = None,
|
| 223 |
+
name: Optional[str] = None,
|
| 224 |
+
description: Optional[str] = None,
|
| 225 |
+
) -> DirectoryResource:
|
| 226 |
+
"""Add a directory as a resource."""
|
| 227 |
+
logger.debug(
|
| 228 |
+
"Adding directory resource",
|
| 229 |
+
extra={
|
| 230 |
+
"path": path,
|
| 231 |
+
"recursive": recursive,
|
| 232 |
+
"pattern": pattern,
|
| 233 |
+
"name": name,
|
| 234 |
+
},
|
| 235 |
+
)
|
| 236 |
+
dir_path = Path(path).expanduser().resolve()
|
| 237 |
+
if not dir_path.is_dir():
|
| 238 |
+
raise ValueError(f"Directory does not exist: {path}")
|
| 239 |
+
|
| 240 |
+
resource = DirectoryResource(
|
| 241 |
+
uri=f"dir://{str(dir_path)}",
|
| 242 |
+
name=name or dir_path.name,
|
| 243 |
+
description=description,
|
| 244 |
+
path=dir_path,
|
| 245 |
+
recursive=recursive,
|
| 246 |
+
pattern=pattern,
|
| 247 |
+
)
|
| 248 |
+
self._resources[resource.uri] = resource
|
| 249 |
+
return resource
|
src/fastmcp/server.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastMCP - A more ergonomic interface for MCP servers."""
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Any, Callable, Dict, Optional, Sequence, Union
|
| 7 |
+
|
| 8 |
+
from mcp.server import Server as MCPServer
|
| 9 |
+
from mcp.server.stdio import stdio_server
|
| 10 |
+
from mcp.types import Resource as MCPResource
|
| 11 |
+
from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource
|
| 12 |
+
from pydantic import BaseModel
|
| 13 |
+
|
| 14 |
+
from .exceptions import ResourceError
|
| 15 |
+
from .resources import ResourceManager
|
| 16 |
+
from .tools import ToolManager
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger("mcp")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class FastMCPServer:
|
| 22 |
+
def __init__(self, name: str):
|
| 23 |
+
self._mcp_server = MCPServer(name)
|
| 24 |
+
self._tool_manager = ToolManager()
|
| 25 |
+
self._resource_manager = ResourceManager()
|
| 26 |
+
self._setup_handlers()
|
| 27 |
+
|
| 28 |
+
def _setup_handlers(self) -> None:
|
| 29 |
+
"""Set up core MCP protocol handlers."""
|
| 30 |
+
|
| 31 |
+
@self._mcp_server.list_tools()
|
| 32 |
+
async def handle_list_tools() -> list[Tool]:
|
| 33 |
+
tools = self._tool_manager.list_tools()
|
| 34 |
+
return [
|
| 35 |
+
Tool(
|
| 36 |
+
name=info.name,
|
| 37 |
+
description=info.description,
|
| 38 |
+
inputSchema=info.input_schema,
|
| 39 |
+
)
|
| 40 |
+
for info in tools
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
@self._mcp_server.call_tool()
|
| 44 |
+
async def handle_call_tool(
|
| 45 |
+
name: str, arguments: dict
|
| 46 |
+
) -> Sequence[Union[TextContent, ImageContent, EmbeddedResource]]:
|
| 47 |
+
result = await self._tool_manager.call_tool(name, arguments)
|
| 48 |
+
return [self._convert_to_content(result)]
|
| 49 |
+
|
| 50 |
+
@self._mcp_server.list_resources()
|
| 51 |
+
async def handle_list_resources() -> list[MCPResource]:
|
| 52 |
+
resources = self._resource_manager.list_resources()
|
| 53 |
+
return [
|
| 54 |
+
MCPResource(
|
| 55 |
+
uri=resource.uri,
|
| 56 |
+
name=resource.name,
|
| 57 |
+
description=resource.description,
|
| 58 |
+
mimeType=resource.mime_type,
|
| 59 |
+
)
|
| 60 |
+
for resource in resources
|
| 61 |
+
]
|
| 62 |
+
|
| 63 |
+
@self._mcp_server.read_resource()
|
| 64 |
+
async def handle_read_resource(uri: str) -> Union[str, bytes]:
|
| 65 |
+
resource = self._resource_manager.get_resource(uri)
|
| 66 |
+
if not resource:
|
| 67 |
+
raise ResourceError(f"Unknown resource: {uri}")
|
| 68 |
+
|
| 69 |
+
try:
|
| 70 |
+
return await resource.read()
|
| 71 |
+
except Exception as e:
|
| 72 |
+
logger.error(f"Error reading resource {uri}: {e}")
|
| 73 |
+
raise ResourceError(str(e))
|
| 74 |
+
|
| 75 |
+
def _convert_to_content(
|
| 76 |
+
self, value: Any
|
| 77 |
+
) -> Union[TextContent, ImageContent, EmbeddedResource]:
|
| 78 |
+
"""Convert Python values to MCP content types."""
|
| 79 |
+
if isinstance(value, (dict, list)):
|
| 80 |
+
return TextContent(type="text", text=json.dumps(value, indent=2))
|
| 81 |
+
if isinstance(value, str):
|
| 82 |
+
return TextContent(type="text", text=value)
|
| 83 |
+
if isinstance(value, bytes):
|
| 84 |
+
return ImageContent(
|
| 85 |
+
type="image",
|
| 86 |
+
data=base64.b64encode(value).decode(),
|
| 87 |
+
mimeType="application/octet-stream",
|
| 88 |
+
)
|
| 89 |
+
if isinstance(value, BaseModel):
|
| 90 |
+
return TextContent(type="text", text=value.model_dump_json(indent=2))
|
| 91 |
+
return TextContent(type="text", text=str(value))
|
| 92 |
+
|
| 93 |
+
def add_tool(
|
| 94 |
+
self,
|
| 95 |
+
func: Callable,
|
| 96 |
+
name: Optional[str] = None,
|
| 97 |
+
description: Optional[str] = None,
|
| 98 |
+
) -> None:
|
| 99 |
+
"""Add a tool to the server."""
|
| 100 |
+
self._tool_manager.add_tool(func, name=name, description=description)
|
| 101 |
+
|
| 102 |
+
def tool(
|
| 103 |
+
self, name: Optional[str] = None, description: Optional[str] = None
|
| 104 |
+
) -> Callable:
|
| 105 |
+
"""Decorator to register a tool."""
|
| 106 |
+
|
| 107 |
+
def decorator(func: Callable) -> Callable:
|
| 108 |
+
self.add_tool(func, name=name, description=description)
|
| 109 |
+
return func
|
| 110 |
+
|
| 111 |
+
return decorator
|
| 112 |
+
|
| 113 |
+
def add_file_resource(
|
| 114 |
+
self,
|
| 115 |
+
path: str,
|
| 116 |
+
*,
|
| 117 |
+
name: Optional[str] = None,
|
| 118 |
+
description: Optional[str] = None,
|
| 119 |
+
mime_type: Optional[str] = None,
|
| 120 |
+
) -> None:
|
| 121 |
+
"""Add a file as a resource."""
|
| 122 |
+
self._resource_manager.add_file_resource(
|
| 123 |
+
path,
|
| 124 |
+
name=name,
|
| 125 |
+
description=description,
|
| 126 |
+
mime_type=mime_type,
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
def add_http_resource(
|
| 130 |
+
self,
|
| 131 |
+
url: str,
|
| 132 |
+
*,
|
| 133 |
+
name: Optional[str] = None,
|
| 134 |
+
description: Optional[str] = None,
|
| 135 |
+
mime_type: Optional[str] = None,
|
| 136 |
+
headers: Optional[Dict[str, str]] = None,
|
| 137 |
+
) -> None:
|
| 138 |
+
"""Add an HTTP endpoint as a resource."""
|
| 139 |
+
self._resource_manager.add_http_resource(
|
| 140 |
+
url,
|
| 141 |
+
name=name,
|
| 142 |
+
description=description,
|
| 143 |
+
mime_type=mime_type,
|
| 144 |
+
headers=headers,
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
def add_dir_resource(
|
| 148 |
+
self,
|
| 149 |
+
path: str,
|
| 150 |
+
*,
|
| 151 |
+
recursive: bool = False,
|
| 152 |
+
pattern: Optional[str] = None,
|
| 153 |
+
name: Optional[str] = None,
|
| 154 |
+
description: Optional[str] = None,
|
| 155 |
+
) -> None:
|
| 156 |
+
"""Add a directory as a resource."""
|
| 157 |
+
self._resource_manager.add_dir_resource(
|
| 158 |
+
path,
|
| 159 |
+
recursive=recursive,
|
| 160 |
+
pattern=pattern,
|
| 161 |
+
name=name,
|
| 162 |
+
description=description,
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
async def run(self, *args, **kwargs) -> None:
|
| 166 |
+
"""Run the FastMCP server."""
|
| 167 |
+
await self._mcp_server.run(*args, **kwargs)
|
| 168 |
+
|
| 169 |
+
@classmethod
|
| 170 |
+
async def run_stdio(cls, app: "FastMCPServer") -> None:
|
| 171 |
+
"""Run the server using stdio transport."""
|
| 172 |
+
async with stdio_server() as (read_stream, write_stream):
|
| 173 |
+
await app.run(
|
| 174 |
+
read_stream,
|
| 175 |
+
write_stream,
|
| 176 |
+
app._mcp_server.create_initialization_options(),
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
@classmethod
|
| 180 |
+
async def run_sse(
|
| 181 |
+
cls, app: "FastMCPServer", host: str = "0.0.0.0", port: int = 8000
|
| 182 |
+
) -> None:
|
| 183 |
+
"""Run the server using SSE transport."""
|
| 184 |
+
from mcp.server.sse import SseServerTransport
|
| 185 |
+
from starlette.applications import Starlette
|
| 186 |
+
from starlette.routing import Route
|
| 187 |
+
import uvicorn
|
| 188 |
+
|
| 189 |
+
sse = SseServerTransport("/messages")
|
| 190 |
+
|
| 191 |
+
async def handle_sse(request):
|
| 192 |
+
async with sse.connect_sse(
|
| 193 |
+
request.scope, request.receive, request._send
|
| 194 |
+
) as streams:
|
| 195 |
+
await app.run(
|
| 196 |
+
streams[0],
|
| 197 |
+
streams[1],
|
| 198 |
+
app._mcp_server.create_initialization_options(),
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
async def handle_messages(request):
|
| 202 |
+
await sse.handle_post_message(request.scope, request.receive, request._send)
|
| 203 |
+
|
| 204 |
+
starlette_app = Starlette(
|
| 205 |
+
debug=True,
|
| 206 |
+
routes=[
|
| 207 |
+
Route("/sse", endpoint=handle_sse),
|
| 208 |
+
Route("/messages", endpoint=handle_messages, methods=["POST"]),
|
| 209 |
+
],
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
uvicorn.run(starlette_app, host=host, port=port)
|
src/fastmcp/tools.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tool management for FastMCP."""
|
| 2 |
+
|
| 3 |
+
import inspect
|
| 4 |
+
from typing import Any, Callable, Dict, Optional, get_type_hints
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel, create_model
|
| 7 |
+
|
| 8 |
+
from .exceptions import ToolError
|
| 9 |
+
from .models import Tool
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ToolManager:
|
| 13 |
+
"""Manages FastMCP tools."""
|
| 14 |
+
|
| 15 |
+
def __init__(self):
|
| 16 |
+
self._tools: Dict[str, Tool] = {}
|
| 17 |
+
|
| 18 |
+
def get_tool(self, name: str) -> Optional[Tool]:
|
| 19 |
+
"""Get tool by name."""
|
| 20 |
+
return self._tools.get(name)
|
| 21 |
+
|
| 22 |
+
def list_tools(self) -> list[Tool]:
|
| 23 |
+
"""List all registered tools."""
|
| 24 |
+
return list(self._tools.values())
|
| 25 |
+
|
| 26 |
+
def add_tool(
|
| 27 |
+
self,
|
| 28 |
+
func: Callable,
|
| 29 |
+
name: Optional[str] = None,
|
| 30 |
+
description: Optional[str] = None,
|
| 31 |
+
) -> None:
|
| 32 |
+
"""Add a tool to the server."""
|
| 33 |
+
func_name = name or func.__name__
|
| 34 |
+
func_doc = description or func.__doc__ or ""
|
| 35 |
+
is_async = inspect.iscoroutinefunction(func)
|
| 36 |
+
|
| 37 |
+
# Get type hints for parameters
|
| 38 |
+
hints = get_type_hints(func)
|
| 39 |
+
if "return" in hints:
|
| 40 |
+
del hints["return"]
|
| 41 |
+
|
| 42 |
+
# Check for Pydantic model parameter
|
| 43 |
+
if len(hints) == 1 and issubclass(next(iter(hints.values())), BaseModel):
|
| 44 |
+
model = next(iter(hints.values()))
|
| 45 |
+
schema = model.model_json_schema()
|
| 46 |
+
pydantic_model = model
|
| 47 |
+
else:
|
| 48 |
+
# Create parameter schema from type hints
|
| 49 |
+
fields = {}
|
| 50 |
+
sig = inspect.signature(func)
|
| 51 |
+
for param_name, param in sig.parameters.items():
|
| 52 |
+
param_type = hints.get(param_name, Any)
|
| 53 |
+
default = (
|
| 54 |
+
... if param.default is inspect.Parameter.empty else param.default
|
| 55 |
+
)
|
| 56 |
+
fields[param_name] = (param_type, default)
|
| 57 |
+
|
| 58 |
+
model = create_model(f"{func_name}Args", **fields)
|
| 59 |
+
schema = model.model_json_schema()
|
| 60 |
+
pydantic_model = model
|
| 61 |
+
|
| 62 |
+
self._tools[func_name] = Tool(
|
| 63 |
+
func=func,
|
| 64 |
+
name=func_name,
|
| 65 |
+
description=func_doc,
|
| 66 |
+
input_schema=schema,
|
| 67 |
+
is_async=is_async,
|
| 68 |
+
pydantic_model=pydantic_model,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
async def call_tool(self, name: str, arguments: dict) -> Any:
|
| 72 |
+
"""Call a tool by name with arguments."""
|
| 73 |
+
tool = self.get_tool(name)
|
| 74 |
+
if not tool:
|
| 75 |
+
raise ToolError(f"Unknown tool: {name}")
|
| 76 |
+
|
| 77 |
+
try:
|
| 78 |
+
# Validate arguments using schema
|
| 79 |
+
if tool.pydantic_model:
|
| 80 |
+
validated_args = tool.pydantic_model(**arguments)
|
| 81 |
+
args_dict = validated_args.model_dump()
|
| 82 |
+
else:
|
| 83 |
+
args_dict = arguments
|
| 84 |
+
|
| 85 |
+
# Call function with proper async handling
|
| 86 |
+
if tool.is_async:
|
| 87 |
+
return await tool.func(**args_dict)
|
| 88 |
+
return tool.func(**args_dict)
|
| 89 |
+
except Exception as e:
|
| 90 |
+
raise ToolError(f"Error executing tool {name}: {e}") from e
|
tests/test_resource_manager.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for resource management."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
| 6 |
+
|
| 7 |
+
from fastmcp.resources import FileResource, ResourceManager
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@pytest.fixture
|
| 11 |
+
def resource_manager():
|
| 12 |
+
"""Create a resource manager for testing."""
|
| 13 |
+
return ResourceManager()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@pytest.fixture
|
| 17 |
+
def temp_file():
|
| 18 |
+
"""Create a temporary file for testing.
|
| 19 |
+
|
| 20 |
+
File is automatically cleaned up after the test if it still exists.
|
| 21 |
+
"""
|
| 22 |
+
content = "test content"
|
| 23 |
+
with NamedTemporaryFile(mode="w", delete=False) as f:
|
| 24 |
+
f.write(content)
|
| 25 |
+
path = Path(f.name).resolve()
|
| 26 |
+
yield path
|
| 27 |
+
try:
|
| 28 |
+
path.unlink()
|
| 29 |
+
except FileNotFoundError:
|
| 30 |
+
pass # File was already deleted by the test
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@pytest.fixture
|
| 34 |
+
def temp_file_no_cleanup():
|
| 35 |
+
"""Create a temporary file for testing.
|
| 36 |
+
|
| 37 |
+
File is NOT automatically cleaned up - tests must handle cleanup.
|
| 38 |
+
"""
|
| 39 |
+
content = "test content"
|
| 40 |
+
with NamedTemporaryFile(mode="w", delete=False) as f:
|
| 41 |
+
f.write(content)
|
| 42 |
+
path = Path(f.name).resolve()
|
| 43 |
+
return path
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@pytest.fixture
|
| 47 |
+
def temp_dir():
|
| 48 |
+
"""Create a temporary directory for testing."""
|
| 49 |
+
with TemporaryDirectory() as d:
|
| 50 |
+
yield Path(d).resolve()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class TestFileResource:
|
| 54 |
+
"""Test FileResource functionality."""
|
| 55 |
+
|
| 56 |
+
def test_file_resource_creation(self, temp_file: Path):
|
| 57 |
+
"""Test creating a FileResource."""
|
| 58 |
+
resource = FileResource(
|
| 59 |
+
uri=f"file://{temp_file}",
|
| 60 |
+
name="test",
|
| 61 |
+
description="test file",
|
| 62 |
+
mime_type="text/plain",
|
| 63 |
+
path=temp_file,
|
| 64 |
+
)
|
| 65 |
+
assert resource.uri == f"file://{temp_file}"
|
| 66 |
+
assert resource.name == "test"
|
| 67 |
+
assert resource.description == "test file"
|
| 68 |
+
assert resource.mime_type == "text/plain"
|
| 69 |
+
assert resource.path == temp_file
|
| 70 |
+
|
| 71 |
+
def test_file_resource_relative_path_error(self):
|
| 72 |
+
"""Test FileResource rejects relative paths."""
|
| 73 |
+
with pytest.raises(ValueError, match="Path must be absolute"):
|
| 74 |
+
FileResource(
|
| 75 |
+
uri="file://test.txt",
|
| 76 |
+
name="test",
|
| 77 |
+
path=Path("test.txt"),
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
def test_file_resource_str_path_conversion(self, temp_file: Path):
|
| 81 |
+
"""Test FileResource handles string paths."""
|
| 82 |
+
resource = FileResource(
|
| 83 |
+
uri=f"file://{temp_file}",
|
| 84 |
+
name="test",
|
| 85 |
+
path=str(temp_file),
|
| 86 |
+
)
|
| 87 |
+
assert isinstance(resource.path, Path)
|
| 88 |
+
assert resource.path.is_absolute()
|
| 89 |
+
|
| 90 |
+
async def test_file_resource_read(self, temp_file: Path):
|
| 91 |
+
"""Test reading a FileResource."""
|
| 92 |
+
resource = FileResource(
|
| 93 |
+
uri=f"file://{temp_file}",
|
| 94 |
+
name="test",
|
| 95 |
+
path=temp_file,
|
| 96 |
+
)
|
| 97 |
+
content = await resource.read()
|
| 98 |
+
assert content == "test content"
|
| 99 |
+
|
| 100 |
+
async def test_file_resource_read_missing_file(self, temp_dir: Path):
|
| 101 |
+
"""Test reading a non-existent file."""
|
| 102 |
+
missing_file = temp_dir / "missing.txt"
|
| 103 |
+
resource = FileResource(
|
| 104 |
+
uri=f"file://{missing_file}",
|
| 105 |
+
name="test",
|
| 106 |
+
path=missing_file,
|
| 107 |
+
)
|
| 108 |
+
with pytest.raises(FileNotFoundError):
|
| 109 |
+
await resource.read()
|
| 110 |
+
|
| 111 |
+
async def test_file_resource_read_permission_error(self, temp_file: Path):
|
| 112 |
+
"""Test reading a file without permissions."""
|
| 113 |
+
temp_file.chmod(0o000) # Remove all permissions
|
| 114 |
+
try:
|
| 115 |
+
resource = FileResource(
|
| 116 |
+
uri=f"file://{temp_file}",
|
| 117 |
+
name="test",
|
| 118 |
+
path=temp_file,
|
| 119 |
+
)
|
| 120 |
+
with pytest.raises(PermissionError):
|
| 121 |
+
await resource.read()
|
| 122 |
+
finally:
|
| 123 |
+
temp_file.chmod(0o644) # Restore permissions
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class TestResourceManager:
|
| 127 |
+
"""Test ResourceManager functionality."""
|
| 128 |
+
|
| 129 |
+
def test_add_file_resource(
|
| 130 |
+
self, resource_manager: ResourceManager, temp_file: Path
|
| 131 |
+
):
|
| 132 |
+
"""Test adding a file resource."""
|
| 133 |
+
resource = resource_manager.add_file_resource(
|
| 134 |
+
str(temp_file),
|
| 135 |
+
name="test",
|
| 136 |
+
description="test file",
|
| 137 |
+
mime_type="text/plain",
|
| 138 |
+
)
|
| 139 |
+
assert isinstance(resource, FileResource)
|
| 140 |
+
assert resource.uri == f"file://{temp_file}"
|
| 141 |
+
assert resource.name == "test"
|
| 142 |
+
assert resource.description == "test file"
|
| 143 |
+
assert resource.mime_type == "text/plain"
|
| 144 |
+
assert resource.path == temp_file
|
| 145 |
+
|
| 146 |
+
def test_add_file_resource_relative_path_error(
|
| 147 |
+
self, resource_manager: ResourceManager
|
| 148 |
+
):
|
| 149 |
+
"""Test ResourceManager rejects relative paths."""
|
| 150 |
+
with pytest.raises(ValueError, match="Path must be absolute"):
|
| 151 |
+
resource_manager.add_file_resource("test.txt")
|
| 152 |
+
|
| 153 |
+
def test_add_file_resource_missing_file_error(
|
| 154 |
+
self, resource_manager: ResourceManager, temp_dir: Path
|
| 155 |
+
):
|
| 156 |
+
"""Test ResourceManager rejects non-existent files."""
|
| 157 |
+
missing_file = temp_dir / "missing.txt"
|
| 158 |
+
with pytest.raises(FileNotFoundError):
|
| 159 |
+
resource_manager.add_file_resource(str(missing_file))
|
| 160 |
+
|
| 161 |
+
def test_get_resource_unknown_uri(self, resource_manager: ResourceManager):
|
| 162 |
+
"""Test getting a non-existent resource."""
|
| 163 |
+
with pytest.raises(ValueError, match="Unknown resource"):
|
| 164 |
+
resource_manager.get_resource("file://unknown")
|
| 165 |
+
|
| 166 |
+
def test_get_resource(self, resource_manager: ResourceManager, temp_file: Path):
|
| 167 |
+
"""Test getting a resource by URI."""
|
| 168 |
+
added = resource_manager.add_file_resource(str(temp_file))
|
| 169 |
+
retrieved = resource_manager.get_resource(added.uri)
|
| 170 |
+
assert retrieved == added
|
| 171 |
+
|
| 172 |
+
def test_list_resources(self, resource_manager: ResourceManager, temp_file: Path):
|
| 173 |
+
"""Test listing all resources."""
|
| 174 |
+
resource = resource_manager.add_file_resource(str(temp_file))
|
| 175 |
+
resources = resource_manager.list_resources()
|
| 176 |
+
assert len(resources) == 1
|
| 177 |
+
assert resources[0] == resource
|
| 178 |
+
|
| 179 |
+
async def test_resource_read_through_manager(
|
| 180 |
+
self, resource_manager: ResourceManager, temp_file: Path
|
| 181 |
+
):
|
| 182 |
+
"""Test reading a resource through the manager."""
|
| 183 |
+
resource = resource_manager.add_file_resource(str(temp_file))
|
| 184 |
+
retrieved = resource_manager.get_resource(resource.uri)
|
| 185 |
+
assert retrieved is not None
|
| 186 |
+
content = await retrieved.read()
|
| 187 |
+
assert content == "test content"
|
| 188 |
+
|
| 189 |
+
async def test_resource_read_error_through_manager(
|
| 190 |
+
self, resource_manager: ResourceManager, temp_file_no_cleanup: Path
|
| 191 |
+
):
|
| 192 |
+
"""Test error handling when reading through manager."""
|
| 193 |
+
# Create resource while file exists
|
| 194 |
+
resource = resource_manager.add_file_resource(str(temp_file_no_cleanup))
|
| 195 |
+
retrieved = resource_manager.get_resource(resource.uri)
|
| 196 |
+
assert retrieved is not None
|
| 197 |
+
|
| 198 |
+
# Delete file and verify read fails
|
| 199 |
+
temp_file_no_cleanup.unlink()
|
| 200 |
+
with pytest.raises(FileNotFoundError):
|
| 201 |
+
await retrieved.read()
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|