From 4d7c7fe05203b427ae072494b29019ea8ce62e48 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 29 Nov 2024 20:32:15 -0500 Subject: [PATCH] Update examples and tests --- examples/desktop.py | 9 +- examples/weather.py | 130 ------------------ src/fastmcp/server.py | 44 +++--- tests/resources/__init__.py | 0 tests/resources/test_file_resources.py | 109 +++++++++++++++ tests/resources/test_function_resources.py | 38 +++++ .../{ => resources}/test_resource_manager.py | 110 --------------- 7 files changed, 175 insertions(+), 265 deletions(-) delete mode 100644 examples/weather.py create mode 100644 tests/resources/__init__.py create mode 100644 tests/resources/test_file_resources.py create mode 100644 tests/resources/test_function_resources.py rename tests/{ => resources}/test_resource_manager.py (68%) diff --git a/examples/desktop.py b/examples/desktop.py index f8460b826..7c708ce9b 100644 --- a/examples/desktop.py +++ b/examples/desktop.py @@ -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() diff --git a/examples/weather.py b/examples/weather.py deleted file mode 100644 index 9f749dc23..000000000 --- a/examples/weather.py +++ /dev/null @@ -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() diff --git a/src/fastmcp/server.py b/src/fastmcp/server.py index e00069b48..5a8973844 100644 --- a/src/fastmcp/server.py +++ b/src/fastmcp/server.py @@ -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, ) diff --git a/tests/resources/__init__.py b/tests/resources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/resources/test_file_resources.py b/tests/resources/test_file_resources.py new file mode 100644 index 000000000..b9691929d --- /dev/null +++ b/tests/resources/test_file_resources.py @@ -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 diff --git a/tests/resources/test_function_resources.py b/tests/resources/test_function_resources.py new file mode 100644 index 000000000..7de95a0c6 --- /dev/null +++ b/tests/resources/test_function_resources.py @@ -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" diff --git a/tests/test_resource_manager.py b/tests/resources/test_resource_manager.py similarity index 68% rename from tests/test_resource_manager.py rename to tests/resources/test_resource_manager.py index 683c95d1d..3bff7c7c2 100644 --- a/tests/test_resource_manager.py +++ b/tests/resources/test_resource_manager.py @@ -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."""