mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 14:04:18 +02:00
Update examples and tests
This commit is contained in:
parent
f1be14d780
commit
4d7c7fe052
7 changed files with 175 additions and 265 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
0
tests/resources/__init__.py
Normal file
0
tests/resources/__init__.py
Normal file
109
tests/resources/test_file_resources.py
Normal file
109
tests/resources/test_file_resources.py
Normal 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
|
||||
38
tests/resources/test_function_resources.py
Normal file
38
tests/resources/test_function_resources.py
Normal 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"
|
||||
|
|
@ -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."""
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue