Update examples and tests

This commit is contained in:
Jeremiah Lowin 2024-11-29 20:32:15 -05:00
commit 4d7c7fe052
7 changed files with 175 additions and 265 deletions

View file

@ -4,18 +4,17 @@ FastMCP Desktop Example
A simple example that exposes the desktop directory as a resource.
"""
import asyncio
from pathlib import Path
from fastmcp.server import FastMCP
# Create server
mcp = FastMCP("desktop")
mcp = FastMCP("Demo")
@mcp.resource("desktop")
@mcp.resource("dir://desktop")
def desktop() -> list[str]:
"""List the files in the desktop directory"""
"""List the files in the user's desktop"""
desktop = Path.home() / "Desktop"
return [str(f) for f in desktop.iterdir()]
@ -27,4 +26,4 @@ def add(a: int, b: int) -> int:
if __name__ == "__main__":
asyncio.run(FastMCP.run_stdio(mcp))
mcp.run()

View file

@ -1,130 +0,0 @@
"""
FastMCP Weather Server Example
"""
import os
import httpx
from pydantic import BaseModel, Field
from fastmcp.server import FastMCP
from fastmcp.utilities.logging import configure_logging
# 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 = FastMCP("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
# Configure logging
configure_logging(level="INFO")
# Run the server
asyncio.run(FastMCP.run_stdio(app))
if __name__ == "__main__":
main()

View file

@ -1,5 +1,6 @@
"""FastMCP - A more ergonomic interface for MCP servers."""
import asyncio
import base64
import functools
import json
@ -12,12 +13,11 @@ from mcp.types import Resource as MCPResource
from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource
from pydantic import BaseModel
from pydantic_settings import BaseSettings
from pydantic.networks import _BaseUrl
from .exceptions import ResourceError
from .resources import Resource, FunctionResource, ResourceManager
from .tools import ToolManager
from .utilities.logging import get_logger, configure_logging
from pydantic.networks import _BaseUrl
logger = get_logger(__name__)
@ -67,9 +67,18 @@ class FastMCP:
def name(self) -> str:
return self._mcp_server.name
async def run(self, *args, **kwargs) -> None:
"""Run the FastMCP server."""
await self._mcp_server.run(*args, **kwargs)
def run(self, transport: Literal["stdio", "sse"] = "stdio") -> None:
"""Run the FastMCP server. Note this is a synchronous function.
Args:
transport: Transport protocol to use ("stdio" or "sse")
"""
if transport == "stdio":
asyncio.run(self.run_stdio_async())
elif transport == "sse":
asyncio.run(self.run_sse_async())
else:
raise ValueError(f"Unknown transport: {transport}")
def _setup_handlers(self) -> None:
"""Set up core MCP protocol handlers."""
@ -220,21 +229,16 @@ class FastMCP:
return decorator
@classmethod
async def run_stdio(cls, app: "FastMCP") -> None:
async def run_stdio_async(self) -> None:
"""Run the server using stdio transport."""
async with stdio_server() as (read_stream, write_stream):
await app.run(
await self._mcp_server.run(
read_stream,
write_stream,
app._mcp_server.create_initialization_options(),
self._mcp_server.create_initialization_options(),
)
@classmethod
async def run_sse(
cls,
app: "FastMCP",
) -> None:
async def run_sse_async(self) -> None:
"""Run the server using SSE transport."""
from starlette.applications import Starlette
from starlette.routing import Route
@ -246,17 +250,17 @@ class FastMCP:
async with sse.connect_sse(
request.scope, request.receive, request._send
) as streams:
await app.run(
await self._mcp_server.run(
streams[0],
streams[1],
app._mcp_server.create_initialization_options(),
self._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=app.settings.debug,
debug=self.settings.debug,
routes=[
Route("/sse", endpoint=handle_sse),
Route("/messages", endpoint=handle_messages, methods=["POST"]),
@ -265,7 +269,7 @@ class FastMCP:
uvicorn.run(
starlette_app,
host=app.settings.host,
port=app.settings.port,
log_level=app.settings.log_level,
host=self.settings.host,
port=self.settings.port,
log_level=self.settings.log_level,
)

View file

View file

@ -0,0 +1,109 @@
import pytest
from pathlib import Path
from tempfile import NamedTemporaryFile, TemporaryDirectory
from fastmcp.resources import FileResource
@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_dir_with_files():
"""Create a temporary directory with test files."""
with TemporaryDirectory() as d:
path = Path(d).resolve()
# Create some test files
(path / "file1.txt").write_text("content1")
(path / "file2.txt").write_text("content2")
(path / "subdir").mkdir()
(path / "subdir/file3.txt").write_text("content3")
(path / "test.json").write_text('{"key": "value"}')
yield path
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 str(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_file: Path):
"""Test reading a non-existent file."""
temp_file.unlink()
resource = FileResource(
uri=f"file://{temp_file}",
name="test",
path=temp_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

View file

@ -0,0 +1,38 @@
from fastmcp.resources import FunctionResource
class TestFunctionResource:
"""Test FunctionResource functionality."""
def test_function_resource_creation(self):
"""Test creating a FunctionResource."""
def my_func(x: str = "") -> str:
return f"Content: {x}"
resource = FunctionResource(
uri="fn://test",
name="test",
description="test function",
mime_type="text/plain",
func=my_func,
)
assert str(resource.uri) == "fn://test"
assert resource.name == "test"
assert resource.description == "test function"
assert resource.mime_type == "text/plain"
assert resource.func == my_func
async def test_function_resource_read(self):
"""Test reading a FunctionResource with no parameters."""
def my_func() -> str:
return "test content"
resource = FunctionResource(
uri="fn://test",
name="test",
func=my_func,
)
content = await resource.read()
assert content == "test content"

View file

@ -73,116 +73,6 @@ class TestResourceValidation:
)
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 str(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 TestFunctionResource:
"""Test FunctionResource functionality."""
def test_function_resource_creation(self):
"""Test creating a FunctionResource."""
def my_func(x: str = "") -> str:
return f"Content: {x}"
resource = FunctionResource(
uri="fn://test",
name="test",
description="test function",
mime_type="text/plain",
func=my_func,
)
assert str(resource.uri) == "fn://test"
assert resource.name == "test"
assert resource.description == "test function"
assert resource.mime_type == "text/plain"
assert resource.func == my_func
async def test_function_resource_read(self):
"""Test reading a FunctionResource with no parameters."""
def my_func() -> str:
return "test content"
resource = FunctionResource(
uri="fn://test",
name="test",
func=my_func,
)
content = await resource.read()
assert content == "test content"
class TestResourceManagerAdd:
"""Test ResourceManager add functionality."""