Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
1e938b9
1
Parent(s): d7fad77
Update examples and tests
Browse files
examples/desktop.py
CHANGED
|
@@ -4,18 +4,17 @@ FastMCP Desktop Example
|
|
| 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 FastMCP
|
| 11 |
|
| 12 |
# Create server
|
| 13 |
-
mcp = FastMCP("
|
| 14 |
|
| 15 |
|
| 16 |
-
@mcp.resource("desktop")
|
| 17 |
def desktop() -> list[str]:
|
| 18 |
-
"""List the files in the
|
| 19 |
desktop = Path.home() / "Desktop"
|
| 20 |
return [str(f) for f in desktop.iterdir()]
|
| 21 |
|
|
@@ -27,4 +26,4 @@ def add(a: int, b: int) -> int:
|
|
| 27 |
|
| 28 |
|
| 29 |
if __name__ == "__main__":
|
| 30 |
-
|
|
|
|
| 4 |
A simple example that exposes the desktop directory as a resource.
|
| 5 |
"""
|
| 6 |
|
|
|
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
from fastmcp.server import FastMCP
|
| 10 |
|
| 11 |
# Create server
|
| 12 |
+
mcp = FastMCP("Demo")
|
| 13 |
|
| 14 |
|
| 15 |
+
@mcp.resource("dir://desktop")
|
| 16 |
def desktop() -> list[str]:
|
| 17 |
+
"""List the files in the user's desktop"""
|
| 18 |
desktop = Path.home() / "Desktop"
|
| 19 |
return [str(f) for f in desktop.iterdir()]
|
| 20 |
|
|
|
|
| 26 |
|
| 27 |
|
| 28 |
if __name__ == "__main__":
|
| 29 |
+
mcp.run()
|
examples/weather.py
DELETED
|
@@ -1,130 +0,0 @@
|
|
| 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 FastMCP
|
| 9 |
-
from fastmcp.utilities.logging import configure_logging
|
| 10 |
-
|
| 11 |
-
# Load env vars
|
| 12 |
-
API_KEY = os.getenv("OPENWEATHER_API_KEY")
|
| 13 |
-
if not API_KEY:
|
| 14 |
-
raise ValueError("OPENWEATHER_API_KEY environment variable required")
|
| 15 |
-
|
| 16 |
-
# API configuration
|
| 17 |
-
API_BASE = "http://api.openweathermap.org/data/2.5"
|
| 18 |
-
DEFAULT_PARAMS = {"appid": API_KEY, "units": "metric"}
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
# Pydantic models for parameters
|
| 22 |
-
class ForecastParams(BaseModel):
|
| 23 |
-
city: str = Field(..., description="City name")
|
| 24 |
-
days: int = Field(default=5, ge=1, le=10, description="Number of days to forecast")
|
| 25 |
-
units: str = Field(
|
| 26 |
-
default="metric", pattern="^(metric|imperial)$", description="Temperature units"
|
| 27 |
-
)
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
class AlertParams(BaseModel):
|
| 31 |
-
lat: float = Field(..., description="Latitude")
|
| 32 |
-
lon: float = Field(..., description="Longitude")
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
# Create server
|
| 36 |
-
app = FastMCP("weather-service")
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
# Tools using Pydantic models
|
| 40 |
-
@app.tool(description="Get detailed weather forecast for a city")
|
| 41 |
-
async def get_forecast(params: ForecastParams) -> dict:
|
| 42 |
-
"""Get a multi-day weather forecast for a city."""
|
| 43 |
-
async with httpx.AsyncClient() as client:
|
| 44 |
-
response = await client.get(
|
| 45 |
-
f"{API_BASE}/forecast",
|
| 46 |
-
params={
|
| 47 |
-
"q": params.city,
|
| 48 |
-
"cnt": params.days * 8, # API returns 3-hour intervals
|
| 49 |
-
"units": params.units,
|
| 50 |
-
**DEFAULT_PARAMS,
|
| 51 |
-
},
|
| 52 |
-
)
|
| 53 |
-
response.raise_for_status()
|
| 54 |
-
data = response.json()
|
| 55 |
-
|
| 56 |
-
# Process into daily forecasts
|
| 57 |
-
forecasts = []
|
| 58 |
-
for i in range(0, len(data["list"]), 8): # Every 8th entry is a new day
|
| 59 |
-
day_data = data["list"][i]
|
| 60 |
-
forecasts.append(
|
| 61 |
-
{
|
| 62 |
-
"date": day_data["dt_txt"].split()[0],
|
| 63 |
-
"temperature": {
|
| 64 |
-
"high": day_data["main"]["temp_max"],
|
| 65 |
-
"low": day_data["main"]["temp_min"],
|
| 66 |
-
},
|
| 67 |
-
"conditions": day_data["weather"][0]["description"],
|
| 68 |
-
"humidity": day_data["main"]["humidity"],
|
| 69 |
-
"wind_speed": day_data["wind"]["speed"],
|
| 70 |
-
}
|
| 71 |
-
)
|
| 72 |
-
|
| 73 |
-
return {
|
| 74 |
-
"city": data["city"]["name"],
|
| 75 |
-
"country": data["city"]["country"],
|
| 76 |
-
"forecasts": forecasts,
|
| 77 |
-
}
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
# Tools using simple kwargs
|
| 81 |
-
@app.tool()
|
| 82 |
-
async def get_alerts(lat: float, lon: float) -> list:
|
| 83 |
-
"""Get weather alerts and warnings for a location."""
|
| 84 |
-
async with httpx.AsyncClient() as client:
|
| 85 |
-
response = await client.get(
|
| 86 |
-
f"{API_BASE}/onecall",
|
| 87 |
-
params={
|
| 88 |
-
"lat": lat,
|
| 89 |
-
"lon": lon,
|
| 90 |
-
"exclude": "current,minutely,hourly,daily",
|
| 91 |
-
**DEFAULT_PARAMS,
|
| 92 |
-
},
|
| 93 |
-
)
|
| 94 |
-
response.raise_for_status()
|
| 95 |
-
data = response.json()
|
| 96 |
-
|
| 97 |
-
return data.get("alerts", [])
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
# Add HTTP resources
|
| 101 |
-
app.add_http_resource(
|
| 102 |
-
f"{API_BASE}/weather?q=London&units=metric&appid={API_KEY}",
|
| 103 |
-
name="London Weather",
|
| 104 |
-
description="Current weather in London",
|
| 105 |
-
mime_type="application/json",
|
| 106 |
-
)
|
| 107 |
-
|
| 108 |
-
# Add local data resources
|
| 109 |
-
app.add_file_resource("weather_stations/*.json", description="Weather station metadata")
|
| 110 |
-
|
| 111 |
-
app.add_dir_resource(
|
| 112 |
-
"~/Developer/fastmcp/historical_data",
|
| 113 |
-
pattern="*.csv",
|
| 114 |
-
recursive=True,
|
| 115 |
-
description="Historical weather data",
|
| 116 |
-
)
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
def main():
|
| 120 |
-
import asyncio
|
| 121 |
-
|
| 122 |
-
# Configure logging
|
| 123 |
-
configure_logging(level="INFO")
|
| 124 |
-
|
| 125 |
-
# Run the server
|
| 126 |
-
asyncio.run(FastMCP.run_stdio(app))
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
if __name__ == "__main__":
|
| 130 |
-
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/fastmcp/server.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""FastMCP - A more ergonomic interface for MCP servers."""
|
| 2 |
|
|
|
|
| 3 |
import base64
|
| 4 |
import functools
|
| 5 |
import json
|
|
@@ -12,12 +13,11 @@ from mcp.types import Resource as MCPResource
|
|
| 12 |
from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource
|
| 13 |
from pydantic import BaseModel
|
| 14 |
from pydantic_settings import BaseSettings
|
| 15 |
-
|
| 16 |
from .exceptions import ResourceError
|
| 17 |
from .resources import Resource, FunctionResource, ResourceManager
|
| 18 |
from .tools import ToolManager
|
| 19 |
from .utilities.logging import get_logger, configure_logging
|
| 20 |
-
from pydantic.networks import _BaseUrl
|
| 21 |
|
| 22 |
logger = get_logger(__name__)
|
| 23 |
|
|
@@ -67,9 +67,18 @@ class FastMCP:
|
|
| 67 |
def name(self) -> str:
|
| 68 |
return self._mcp_server.name
|
| 69 |
|
| 70 |
-
|
| 71 |
-
"""Run the FastMCP server.
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
def _setup_handlers(self) -> None:
|
| 75 |
"""Set up core MCP protocol handlers."""
|
|
@@ -220,21 +229,16 @@ class FastMCP:
|
|
| 220 |
|
| 221 |
return decorator
|
| 222 |
|
| 223 |
-
|
| 224 |
-
async def run_stdio(cls, app: "FastMCP") -> None:
|
| 225 |
"""Run the server using stdio transport."""
|
| 226 |
async with stdio_server() as (read_stream, write_stream):
|
| 227 |
-
await
|
| 228 |
read_stream,
|
| 229 |
write_stream,
|
| 230 |
-
|
| 231 |
)
|
| 232 |
|
| 233 |
-
|
| 234 |
-
async def run_sse(
|
| 235 |
-
cls,
|
| 236 |
-
app: "FastMCP",
|
| 237 |
-
) -> None:
|
| 238 |
"""Run the server using SSE transport."""
|
| 239 |
from starlette.applications import Starlette
|
| 240 |
from starlette.routing import Route
|
|
@@ -246,17 +250,17 @@ class FastMCP:
|
|
| 246 |
async with sse.connect_sse(
|
| 247 |
request.scope, request.receive, request._send
|
| 248 |
) as streams:
|
| 249 |
-
await
|
| 250 |
streams[0],
|
| 251 |
streams[1],
|
| 252 |
-
|
| 253 |
)
|
| 254 |
|
| 255 |
async def handle_messages(request):
|
| 256 |
await sse.handle_post_message(request.scope, request.receive, request._send)
|
| 257 |
|
| 258 |
starlette_app = Starlette(
|
| 259 |
-
debug=
|
| 260 |
routes=[
|
| 261 |
Route("/sse", endpoint=handle_sse),
|
| 262 |
Route("/messages", endpoint=handle_messages, methods=["POST"]),
|
|
@@ -265,7 +269,7 @@ class FastMCP:
|
|
| 265 |
|
| 266 |
uvicorn.run(
|
| 267 |
starlette_app,
|
| 268 |
-
host=
|
| 269 |
-
port=
|
| 270 |
-
log_level=
|
| 271 |
)
|
|
|
|
| 1 |
"""FastMCP - A more ergonomic interface for MCP servers."""
|
| 2 |
|
| 3 |
+
import asyncio
|
| 4 |
import base64
|
| 5 |
import functools
|
| 6 |
import json
|
|
|
|
| 13 |
from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource
|
| 14 |
from pydantic import BaseModel
|
| 15 |
from pydantic_settings import BaseSettings
|
| 16 |
+
from pydantic.networks import _BaseUrl
|
| 17 |
from .exceptions import ResourceError
|
| 18 |
from .resources import Resource, FunctionResource, ResourceManager
|
| 19 |
from .tools import ToolManager
|
| 20 |
from .utilities.logging import get_logger, configure_logging
|
|
|
|
| 21 |
|
| 22 |
logger = get_logger(__name__)
|
| 23 |
|
|
|
|
| 67 |
def name(self) -> str:
|
| 68 |
return self._mcp_server.name
|
| 69 |
|
| 70 |
+
def run(self, transport: Literal["stdio", "sse"] = "stdio") -> None:
|
| 71 |
+
"""Run the FastMCP server. Note this is a synchronous function.
|
| 72 |
+
|
| 73 |
+
Args:
|
| 74 |
+
transport: Transport protocol to use ("stdio" or "sse")
|
| 75 |
+
"""
|
| 76 |
+
if transport == "stdio":
|
| 77 |
+
asyncio.run(self.run_stdio_async())
|
| 78 |
+
elif transport == "sse":
|
| 79 |
+
asyncio.run(self.run_sse_async())
|
| 80 |
+
else:
|
| 81 |
+
raise ValueError(f"Unknown transport: {transport}")
|
| 82 |
|
| 83 |
def _setup_handlers(self) -> None:
|
| 84 |
"""Set up core MCP protocol handlers."""
|
|
|
|
| 229 |
|
| 230 |
return decorator
|
| 231 |
|
| 232 |
+
async def run_stdio_async(self) -> None:
|
|
|
|
| 233 |
"""Run the server using stdio transport."""
|
| 234 |
async with stdio_server() as (read_stream, write_stream):
|
| 235 |
+
await self._mcp_server.run(
|
| 236 |
read_stream,
|
| 237 |
write_stream,
|
| 238 |
+
self._mcp_server.create_initialization_options(),
|
| 239 |
)
|
| 240 |
|
| 241 |
+
async def run_sse_async(self) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
"""Run the server using SSE transport."""
|
| 243 |
from starlette.applications import Starlette
|
| 244 |
from starlette.routing import Route
|
|
|
|
| 250 |
async with sse.connect_sse(
|
| 251 |
request.scope, request.receive, request._send
|
| 252 |
) as streams:
|
| 253 |
+
await self._mcp_server.run(
|
| 254 |
streams[0],
|
| 255 |
streams[1],
|
| 256 |
+
self._mcp_server.create_initialization_options(),
|
| 257 |
)
|
| 258 |
|
| 259 |
async def handle_messages(request):
|
| 260 |
await sse.handle_post_message(request.scope, request.receive, request._send)
|
| 261 |
|
| 262 |
starlette_app = Starlette(
|
| 263 |
+
debug=self.settings.debug,
|
| 264 |
routes=[
|
| 265 |
Route("/sse", endpoint=handle_sse),
|
| 266 |
Route("/messages", endpoint=handle_messages, methods=["POST"]),
|
|
|
|
| 269 |
|
| 270 |
uvicorn.run(
|
| 271 |
starlette_app,
|
| 272 |
+
host=self.settings.host,
|
| 273 |
+
port=self.settings.port,
|
| 274 |
+
log_level=self.settings.log_level,
|
| 275 |
)
|
tests/resources/__init__.py
ADDED
|
File without changes
|
tests/resources/test_file_resources.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
| 4 |
+
|
| 5 |
+
from fastmcp.resources import FileResource
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@pytest.fixture
|
| 9 |
+
def temp_file():
|
| 10 |
+
"""Create a temporary file for testing.
|
| 11 |
+
|
| 12 |
+
File is automatically cleaned up after the test if it still exists.
|
| 13 |
+
"""
|
| 14 |
+
content = "test content"
|
| 15 |
+
with NamedTemporaryFile(mode="w", delete=False) as f:
|
| 16 |
+
f.write(content)
|
| 17 |
+
path = Path(f.name).resolve()
|
| 18 |
+
yield path
|
| 19 |
+
try:
|
| 20 |
+
path.unlink()
|
| 21 |
+
except FileNotFoundError:
|
| 22 |
+
pass # File was already deleted by the test
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@pytest.fixture
|
| 26 |
+
def temp_dir_with_files():
|
| 27 |
+
"""Create a temporary directory with test files."""
|
| 28 |
+
with TemporaryDirectory() as d:
|
| 29 |
+
path = Path(d).resolve()
|
| 30 |
+
# Create some test files
|
| 31 |
+
(path / "file1.txt").write_text("content1")
|
| 32 |
+
(path / "file2.txt").write_text("content2")
|
| 33 |
+
(path / "subdir").mkdir()
|
| 34 |
+
(path / "subdir/file3.txt").write_text("content3")
|
| 35 |
+
(path / "test.json").write_text('{"key": "value"}')
|
| 36 |
+
yield path
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class TestFileResource:
|
| 40 |
+
"""Test FileResource functionality."""
|
| 41 |
+
|
| 42 |
+
def test_file_resource_creation(self, temp_file: Path):
|
| 43 |
+
"""Test creating a FileResource."""
|
| 44 |
+
resource = FileResource(
|
| 45 |
+
uri=f"file://{temp_file}",
|
| 46 |
+
name="test",
|
| 47 |
+
description="test file",
|
| 48 |
+
mime_type="text/plain",
|
| 49 |
+
path=temp_file,
|
| 50 |
+
)
|
| 51 |
+
assert str(resource.uri) == f"file://{temp_file}"
|
| 52 |
+
assert resource.name == "test"
|
| 53 |
+
assert resource.description == "test file"
|
| 54 |
+
assert resource.mime_type == "text/plain"
|
| 55 |
+
assert resource.path == temp_file
|
| 56 |
+
|
| 57 |
+
def test_file_resource_relative_path_error(self):
|
| 58 |
+
"""Test FileResource rejects relative paths."""
|
| 59 |
+
with pytest.raises(ValueError, match="Path must be absolute"):
|
| 60 |
+
FileResource(
|
| 61 |
+
uri="file://test.txt",
|
| 62 |
+
name="test",
|
| 63 |
+
path=Path("test.txt"),
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
def test_file_resource_str_path_conversion(self, temp_file: Path):
|
| 67 |
+
"""Test FileResource handles string paths."""
|
| 68 |
+
resource = FileResource(
|
| 69 |
+
uri=f"file://{temp_file}",
|
| 70 |
+
name="test",
|
| 71 |
+
path=str(temp_file),
|
| 72 |
+
)
|
| 73 |
+
assert isinstance(resource.path, Path)
|
| 74 |
+
assert resource.path.is_absolute()
|
| 75 |
+
|
| 76 |
+
async def test_file_resource_read(self, temp_file: Path):
|
| 77 |
+
"""Test reading a FileResource."""
|
| 78 |
+
resource = FileResource(
|
| 79 |
+
uri=f"file://{temp_file}",
|
| 80 |
+
name="test",
|
| 81 |
+
path=temp_file,
|
| 82 |
+
)
|
| 83 |
+
content = await resource.read()
|
| 84 |
+
assert content == "test content"
|
| 85 |
+
|
| 86 |
+
async def test_file_resource_read_missing_file(self, temp_file: Path):
|
| 87 |
+
"""Test reading a non-existent file."""
|
| 88 |
+
temp_file.unlink()
|
| 89 |
+
resource = FileResource(
|
| 90 |
+
uri=f"file://{temp_file}",
|
| 91 |
+
name="test",
|
| 92 |
+
path=temp_file,
|
| 93 |
+
)
|
| 94 |
+
with pytest.raises(FileNotFoundError):
|
| 95 |
+
await resource.read()
|
| 96 |
+
|
| 97 |
+
async def test_file_resource_read_permission_error(self, temp_file: Path):
|
| 98 |
+
"""Test reading a file without permissions."""
|
| 99 |
+
temp_file.chmod(0o000) # Remove all permissions
|
| 100 |
+
try:
|
| 101 |
+
resource = FileResource(
|
| 102 |
+
uri=f"file://{temp_file}",
|
| 103 |
+
name="test",
|
| 104 |
+
path=temp_file,
|
| 105 |
+
)
|
| 106 |
+
with pytest.raises(PermissionError):
|
| 107 |
+
await resource.read()
|
| 108 |
+
finally:
|
| 109 |
+
temp_file.chmod(0o644) # Restore permissions
|
tests/resources/test_function_resources.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastmcp.resources import FunctionResource
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class TestFunctionResource:
|
| 5 |
+
"""Test FunctionResource functionality."""
|
| 6 |
+
|
| 7 |
+
def test_function_resource_creation(self):
|
| 8 |
+
"""Test creating a FunctionResource."""
|
| 9 |
+
|
| 10 |
+
def my_func(x: str = "") -> str:
|
| 11 |
+
return f"Content: {x}"
|
| 12 |
+
|
| 13 |
+
resource = FunctionResource(
|
| 14 |
+
uri="fn://test",
|
| 15 |
+
name="test",
|
| 16 |
+
description="test function",
|
| 17 |
+
mime_type="text/plain",
|
| 18 |
+
func=my_func,
|
| 19 |
+
)
|
| 20 |
+
assert str(resource.uri) == "fn://test"
|
| 21 |
+
assert resource.name == "test"
|
| 22 |
+
assert resource.description == "test function"
|
| 23 |
+
assert resource.mime_type == "text/plain"
|
| 24 |
+
assert resource.func == my_func
|
| 25 |
+
|
| 26 |
+
async def test_function_resource_read(self):
|
| 27 |
+
"""Test reading a FunctionResource with no parameters."""
|
| 28 |
+
|
| 29 |
+
def my_func() -> str:
|
| 30 |
+
return "test content"
|
| 31 |
+
|
| 32 |
+
resource = FunctionResource(
|
| 33 |
+
uri="fn://test",
|
| 34 |
+
name="test",
|
| 35 |
+
func=my_func,
|
| 36 |
+
)
|
| 37 |
+
content = await resource.read()
|
| 38 |
+
assert content == "test content"
|
tests/{test_resource_manager.py → resources/test_resource_manager.py}
RENAMED
|
@@ -73,116 +73,6 @@ class TestResourceValidation:
|
|
| 73 |
)
|
| 74 |
|
| 75 |
|
| 76 |
-
class TestFileResource:
|
| 77 |
-
"""Test FileResource functionality."""
|
| 78 |
-
|
| 79 |
-
def test_file_resource_creation(self, temp_file: Path):
|
| 80 |
-
"""Test creating a FileResource."""
|
| 81 |
-
resource = FileResource(
|
| 82 |
-
uri=f"file://{temp_file}",
|
| 83 |
-
name="test",
|
| 84 |
-
description="test file",
|
| 85 |
-
mime_type="text/plain",
|
| 86 |
-
path=temp_file,
|
| 87 |
-
)
|
| 88 |
-
assert str(resource.uri) == f"file://{temp_file}"
|
| 89 |
-
assert resource.name == "test"
|
| 90 |
-
assert resource.description == "test file"
|
| 91 |
-
assert resource.mime_type == "text/plain"
|
| 92 |
-
assert resource.path == temp_file
|
| 93 |
-
|
| 94 |
-
def test_file_resource_relative_path_error(self):
|
| 95 |
-
"""Test FileResource rejects relative paths."""
|
| 96 |
-
with pytest.raises(ValueError, match="Path must be absolute"):
|
| 97 |
-
FileResource(
|
| 98 |
-
uri="file://test.txt",
|
| 99 |
-
name="test",
|
| 100 |
-
path=Path("test.txt"),
|
| 101 |
-
)
|
| 102 |
-
|
| 103 |
-
def test_file_resource_str_path_conversion(self, temp_file: Path):
|
| 104 |
-
"""Test FileResource handles string paths."""
|
| 105 |
-
resource = FileResource(
|
| 106 |
-
uri=f"file://{temp_file}",
|
| 107 |
-
name="test",
|
| 108 |
-
path=str(temp_file),
|
| 109 |
-
)
|
| 110 |
-
assert isinstance(resource.path, Path)
|
| 111 |
-
assert resource.path.is_absolute()
|
| 112 |
-
|
| 113 |
-
async def test_file_resource_read(self, temp_file: Path):
|
| 114 |
-
"""Test reading a FileResource."""
|
| 115 |
-
resource = FileResource(
|
| 116 |
-
uri=f"file://{temp_file}",
|
| 117 |
-
name="test",
|
| 118 |
-
path=temp_file,
|
| 119 |
-
)
|
| 120 |
-
content = await resource.read()
|
| 121 |
-
assert content == "test content"
|
| 122 |
-
|
| 123 |
-
async def test_file_resource_read_missing_file(self, temp_dir: Path):
|
| 124 |
-
"""Test reading a non-existent file."""
|
| 125 |
-
missing_file = temp_dir / "missing.txt"
|
| 126 |
-
resource = FileResource(
|
| 127 |
-
uri=f"file://{missing_file}",
|
| 128 |
-
name="test",
|
| 129 |
-
path=missing_file,
|
| 130 |
-
)
|
| 131 |
-
with pytest.raises(FileNotFoundError):
|
| 132 |
-
await resource.read()
|
| 133 |
-
|
| 134 |
-
async def test_file_resource_read_permission_error(self, temp_file: Path):
|
| 135 |
-
"""Test reading a file without permissions."""
|
| 136 |
-
temp_file.chmod(0o000) # Remove all permissions
|
| 137 |
-
try:
|
| 138 |
-
resource = FileResource(
|
| 139 |
-
uri=f"file://{temp_file}",
|
| 140 |
-
name="test",
|
| 141 |
-
path=temp_file,
|
| 142 |
-
)
|
| 143 |
-
with pytest.raises(PermissionError):
|
| 144 |
-
await resource.read()
|
| 145 |
-
finally:
|
| 146 |
-
temp_file.chmod(0o644) # Restore permissions
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
class TestFunctionResource:
|
| 150 |
-
"""Test FunctionResource functionality."""
|
| 151 |
-
|
| 152 |
-
def test_function_resource_creation(self):
|
| 153 |
-
"""Test creating a FunctionResource."""
|
| 154 |
-
|
| 155 |
-
def my_func(x: str = "") -> str:
|
| 156 |
-
return f"Content: {x}"
|
| 157 |
-
|
| 158 |
-
resource = FunctionResource(
|
| 159 |
-
uri="fn://test",
|
| 160 |
-
name="test",
|
| 161 |
-
description="test function",
|
| 162 |
-
mime_type="text/plain",
|
| 163 |
-
func=my_func,
|
| 164 |
-
)
|
| 165 |
-
assert str(resource.uri) == "fn://test"
|
| 166 |
-
assert resource.name == "test"
|
| 167 |
-
assert resource.description == "test function"
|
| 168 |
-
assert resource.mime_type == "text/plain"
|
| 169 |
-
assert resource.func == my_func
|
| 170 |
-
|
| 171 |
-
async def test_function_resource_read(self):
|
| 172 |
-
"""Test reading a FunctionResource with no parameters."""
|
| 173 |
-
|
| 174 |
-
def my_func() -> str:
|
| 175 |
-
return "test content"
|
| 176 |
-
|
| 177 |
-
resource = FunctionResource(
|
| 178 |
-
uri="fn://test",
|
| 179 |
-
name="test",
|
| 180 |
-
func=my_func,
|
| 181 |
-
)
|
| 182 |
-
content = await resource.read()
|
| 183 |
-
assert content == "test content"
|
| 184 |
-
|
| 185 |
-
|
| 186 |
class TestResourceManagerAdd:
|
| 187 |
"""Test ResourceManager add functionality."""
|
| 188 |
|
|
|
|
| 73 |
)
|
| 74 |
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
class TestResourceManagerAdd:
|
| 77 |
"""Test ResourceManager add functionality."""
|
| 78 |
|