Initial commit

This commit is contained in:
Jeremiah Lowin 2024-11-29 16:42:40 -05:00
commit b6ade5e72c
15 changed files with 2092 additions and 0 deletions

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
.DS_Store

1
.python-version Normal file
View file

@ -0,0 +1 @@
3.12

2
README.md Normal file
View file

@ -0,0 +1,2 @@
Notes:
- uv must be installed with brew to run local servers

31
examples/desktop.py Normal file
View file

@ -0,0 +1,31 @@
"""
FastMCP Desktop Example
A simple example that exposes the desktop directory as a resource.
"""
import asyncio
from pathlib import Path
from fastmcp.server import FastMCPServer
# Create server
app = FastMCPServer("desktop")
# Add desktop as a directory resource
desktop = Path.home() / "Desktop"
app.add_dir_resource(
str(desktop),
recursive=True,
name="Desktop",
description="Files on the desktop",
)
def main123():
# Run the server
asyncio.run(FastMCPServer.run_stdio(app))
if __name__ == "__main__":
main123()

133
examples/weather.py Normal file
View file

@ -0,0 +1,133 @@
"""
FastMCP Weather Server Example
"""
import os
import httpx
from pydantic import BaseModel, Field
from fastmcp.server import FastMCPServer
# 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 = FastMCPServer("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
import logging
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
# Run the server
asyncio.run(FastMCPServer.run_stdio(app))
if __name__ == "__main__":
main()

36
pyproject.toml Normal file
View file

@ -0,0 +1,36 @@
[project]
name = "fastmcp"
dynamic = ["version"]
description = "A more ergonomic interface for MCP servers"
authors = [{ name = "Jeremiah Lowin" }]
dependencies = [
"httpx>=0.26.0",
"mcp>=1.0.0",
"pydantic>=2.5.3",
"typer>=0.9.0",
]
requires-python = ">=3.10"
readme = "README.md"
license = { text = "Apache-2.0" }
[project.scripts]
fastmcp = "fastmcp.cli:app"
[build-system]
requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2"]
build-backend = "setuptools.build_meta"
[tool.setuptools_scm]
write_to = "src/fastmcp/_version.py"
[dependency-groups]
dev = [
"copychat>=0.5.2",
"ipython>=8.12.3",
"pdbpp>=0.10.3",
"pytest>=8.3.3",
"pytest-asyncio>=0.23.5",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"

16
src/fastmcp/_version.py Normal file
View file

@ -0,0 +1,16 @@
# file generated by setuptools_scm
# don't change, don't track in version control
TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import Tuple, Union
VERSION_TUPLE = Tuple[Union[int, str], ...]
else:
VERSION_TUPLE = object
version: str
__version__: str
__version_tuple__: VERSION_TUPLE
version_tuple: VERSION_TUPLE
__version__ = version = '0.1.dev0+d20241129'
__version_tuple__ = version_tuple = (0, 1, 'dev0', 'd20241129')

72
src/fastmcp/cli.py Normal file
View file

@ -0,0 +1,72 @@
"""FastMCP CLI tools."""
import importlib.metadata
import logging
import subprocess
import sys
from pathlib import Path
import typer
# Configure logging
logger = logging.getLogger("mcp")
app = typer.Typer(
name="fastmcp",
help="FastMCP development tools",
add_completion=False,
no_args_is_help=True, # Show help if no args provided
)
@app.command()
def version() -> None:
"""Show the FastMCP version."""
try:
version = importlib.metadata.version("fastmcp")
print(f"FastMCP version {version}")
except importlib.metadata.PackageNotFoundError:
print("FastMCP version unknown (package not installed)")
sys.exit(1)
@app.command()
def dev(
file: Path = typer.Argument(
...,
help="Python file to run",
exists=True,
dir_okay=False,
resolve_path=True,
),
) -> None:
"""Run a FastMCP server with the MCP Inspector."""
logger.debug("Starting dev server", extra={"file": str(file)})
try:
# Run the MCP Inspector command
process = subprocess.run(
["npx", "@modelcontextprotocol/inspector", "uv", "run", str(file)],
check=True,
)
sys.exit(process.returncode)
except subprocess.CalledProcessError as e:
logger.error(
"Dev server failed",
extra={
"file": str(file),
"error": str(e),
"returncode": e.returncode,
},
)
sys.exit(e.returncode)
except FileNotFoundError:
logger.error(
"npx not found. Please install Node.js and npm.",
extra={"file": str(file)},
)
sys.exit(1)
if __name__ == "__main__":
app()

17
src/fastmcp/exceptions.py Normal file
View file

@ -0,0 +1,17 @@
"""Custom exceptions for FastMCP."""
class FastMCPError(Exception):
"""Base error for FastMCP."""
class ValidationError(FastMCPError):
"""Error in validating parameters or return values."""
class ResourceError(FastMCPError):
"""Error in resource operations."""
class ToolError(FastMCPError):
"""Error in tool operations."""

18
src/fastmcp/models.py Normal file
View file

@ -0,0 +1,18 @@
"""Pydantic models for FastMCP."""
from typing import Callable, Optional, Type
from pydantic import BaseModel
class Tool(BaseModel):
"""Internal tool registration info."""
model_config: dict = dict(arbitrary_types_allowed=True)
func: Callable
name: str
description: str
input_schema: dict
is_async: bool
pydantic_model: Optional[Type[BaseModel]] = None

249
src/fastmcp/resources.py Normal file
View file

@ -0,0 +1,249 @@
"""Resource management for FastMCP."""
import abc
import asyncio
import json
import logging
from pathlib import Path
from typing import Dict, Optional
import httpx
from pydantic import BaseModel, field_validator
logger = logging.getLogger("mcp")
class Resource(BaseModel):
"""Base class for all resources."""
uri: str
name: str
description: Optional[str] = None
mime_type: str = "text/plain"
@abc.abstractmethod
async def read(self) -> str:
"""Read the resource content."""
...
class FileResource(Resource):
"""A file resource."""
path: Path
@field_validator("path")
@classmethod
def validate_absolute_path(cls, path: Path) -> Path:
"""Ensure path is absolute."""
if not path.is_absolute():
raise ValueError(f"Path must be absolute: {path}")
return path
async def read(self) -> str:
"""Read the file content."""
try:
return await asyncio.to_thread(self.path.read_text)
except FileNotFoundError:
raise FileNotFoundError(f"File not found: {self.path}")
except PermissionError:
raise PermissionError(f"Permission denied: {self.path}")
except Exception as e:
raise ValueError(f"Error reading file {self.path}: {e}")
class HttpResource(Resource):
"""An HTTP resource."""
url: str
headers: Optional[Dict[str, str]] = None
async def read(self) -> str:
"""Read the HTTP resource content."""
try:
async with httpx.AsyncClient() as client:
response = await client.get(self.url, headers=self.headers)
response.raise_for_status()
return response.text
except httpx.HTTPStatusError as e:
raise ValueError(f"HTTP error {e.response.status_code}: {e}")
except httpx.RequestError as e:
raise ValueError(f"Request failed: {e}")
class DirectoryResource(Resource):
"""A directory resource."""
path: Path
recursive: bool = False
pattern: Optional[str] = None
mime_type: str = "application/json"
@field_validator("path")
@classmethod
def validate_absolute_path(cls, path: Path) -> Path:
"""Ensure path is absolute."""
if not path.is_absolute():
raise ValueError(f"Path must be absolute: {path}")
return path
def list_files(self) -> list[Path]:
"""List files in the directory."""
if not self.path.exists():
raise FileNotFoundError(f"Directory not found: {self.path}")
if not self.path.is_dir():
raise NotADirectoryError(f"Not a directory: {self.path}")
try:
if self.pattern:
return (
list(self.path.glob(self.pattern))
if not self.recursive
else list(self.path.rglob(self.pattern))
)
return (
list(self.path.glob("*"))
if not self.recursive
else list(self.path.rglob("*"))
)
except Exception as e:
raise ValueError(f"Error listing directory {self.path}: {e}")
async def read(self) -> str:
"""Read the directory listing."""
try:
files = await asyncio.to_thread(self.list_files)
file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
return json.dumps({"files": file_list}, indent=2)
except Exception as e:
raise ValueError(f"Error reading directory {self.path}: {e}")
class ResourceManager:
"""Manages FastMCP resources."""
def __init__(self):
self._resources: Dict[str, Resource] = {}
def get_resource(self, uri: str) -> Optional[Resource]:
"""Get resource by URI."""
logger.debug("Getting resource", extra={"uri": uri})
resource = self._resources.get(uri)
if not resource:
raise ValueError(f"Unknown resource: {uri}")
return resource
def list_resources(self) -> list[Resource]:
"""List all registered resources."""
logger.debug("Listing resources", extra={"count": len(self._resources)})
return list(self._resources.values())
def add_file_resource(
self,
path: str,
*,
name: Optional[str] = None,
description: Optional[str] = None,
mime_type: Optional[str] = None,
) -> FileResource:
"""Add a file as a resource.
Args:
path: Absolute path to the file
name: Optional name for the resource
description: Optional description of the resource
mime_type: Optional MIME type for the resource
Returns:
The created resource
Raises:
ValueError: If the path is not absolute or the file does not exist
"""
logger.debug(
"Adding file resource",
extra={
"path": path,
"name": name,
"mime_type": mime_type,
},
)
file = Path(path)
if not file.is_absolute():
raise ValueError(f"Path must be absolute: {path}")
if not file.is_file():
raise FileNotFoundError(f"File does not exist: {path}")
resource = FileResource(
uri=f"file://{str(file)}",
name=name or file.name,
description=description,
mime_type=mime_type or "text/plain",
path=file,
)
self._resources[resource.uri] = resource
return resource
def add_http_resource(
self,
url: str,
*,
name: Optional[str] = None,
description: Optional[str] = None,
mime_type: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
) -> HttpResource:
"""Add an HTTP endpoint as a resource."""
logger.debug(
"Adding HTTP resource",
extra={
"url": url,
"name": name,
"mime_type": mime_type,
},
)
resource = HttpResource(
uri=f"http://{url}",
name=name or url.split("/")[-1],
description=description,
mime_type=mime_type or "text/plain",
url=url,
headers=headers,
)
self._resources[resource.uri] = resource
return resource
def add_dir_resource(
self,
path: str,
*,
recursive: bool = False,
pattern: Optional[str] = None,
name: Optional[str] = None,
description: Optional[str] = None,
) -> DirectoryResource:
"""Add a directory as a resource."""
logger.debug(
"Adding directory resource",
extra={
"path": path,
"recursive": recursive,
"pattern": pattern,
"name": name,
},
)
dir_path = Path(path).expanduser().resolve()
if not dir_path.is_dir():
raise ValueError(f"Directory does not exist: {path}")
resource = DirectoryResource(
uri=f"dir://{str(dir_path)}",
name=name or dir_path.name,
description=description,
path=dir_path,
recursive=recursive,
pattern=pattern,
)
self._resources[resource.uri] = resource
return resource

212
src/fastmcp/server.py Normal file
View file

@ -0,0 +1,212 @@
"""FastMCP - A more ergonomic interface for MCP servers."""
import base64
import json
import logging
from typing import Any, Callable, Dict, Optional, Sequence, Union
from mcp.server import Server as MCPServer
from mcp.server.stdio import stdio_server
from mcp.types import Resource as MCPResource
from mcp.types import Tool, TextContent, ImageContent, EmbeddedResource
from pydantic import BaseModel
from .exceptions import ResourceError
from .resources import ResourceManager
from .tools import ToolManager
logger = logging.getLogger("mcp")
class FastMCPServer:
def __init__(self, name: str):
self._mcp_server = MCPServer(name)
self._tool_manager = ToolManager()
self._resource_manager = ResourceManager()
self._setup_handlers()
def _setup_handlers(self) -> None:
"""Set up core MCP protocol handlers."""
@self._mcp_server.list_tools()
async def handle_list_tools() -> list[Tool]:
tools = self._tool_manager.list_tools()
return [
Tool(
name=info.name,
description=info.description,
inputSchema=info.input_schema,
)
for info in tools
]
@self._mcp_server.call_tool()
async def handle_call_tool(
name: str, arguments: dict
) -> Sequence[Union[TextContent, ImageContent, EmbeddedResource]]:
result = await self._tool_manager.call_tool(name, arguments)
return [self._convert_to_content(result)]
@self._mcp_server.list_resources()
async def handle_list_resources() -> list[MCPResource]:
resources = self._resource_manager.list_resources()
return [
MCPResource(
uri=resource.uri,
name=resource.name,
description=resource.description,
mimeType=resource.mime_type,
)
for resource in resources
]
@self._mcp_server.read_resource()
async def handle_read_resource(uri: str) -> Union[str, bytes]:
resource = self._resource_manager.get_resource(uri)
if not resource:
raise ResourceError(f"Unknown resource: {uri}")
try:
return await resource.read()
except Exception as e:
logger.error(f"Error reading resource {uri}: {e}")
raise ResourceError(str(e))
def _convert_to_content(
self, value: Any
) -> Union[TextContent, ImageContent, EmbeddedResource]:
"""Convert Python values to MCP content types."""
if isinstance(value, (dict, list)):
return TextContent(type="text", text=json.dumps(value, indent=2))
if isinstance(value, str):
return TextContent(type="text", text=value)
if isinstance(value, bytes):
return ImageContent(
type="image",
data=base64.b64encode(value).decode(),
mimeType="application/octet-stream",
)
if isinstance(value, BaseModel):
return TextContent(type="text", text=value.model_dump_json(indent=2))
return TextContent(type="text", text=str(value))
def add_tool(
self,
func: Callable,
name: Optional[str] = None,
description: Optional[str] = None,
) -> None:
"""Add a tool to the server."""
self._tool_manager.add_tool(func, name=name, description=description)
def tool(
self, name: Optional[str] = None, description: Optional[str] = None
) -> Callable:
"""Decorator to register a tool."""
def decorator(func: Callable) -> Callable:
self.add_tool(func, name=name, description=description)
return func
return decorator
def add_file_resource(
self,
path: str,
*,
name: Optional[str] = None,
description: Optional[str] = None,
mime_type: Optional[str] = None,
) -> None:
"""Add a file as a resource."""
self._resource_manager.add_file_resource(
path,
name=name,
description=description,
mime_type=mime_type,
)
def add_http_resource(
self,
url: str,
*,
name: Optional[str] = None,
description: Optional[str] = None,
mime_type: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
) -> None:
"""Add an HTTP endpoint as a resource."""
self._resource_manager.add_http_resource(
url,
name=name,
description=description,
mime_type=mime_type,
headers=headers,
)
def add_dir_resource(
self,
path: str,
*,
recursive: bool = False,
pattern: Optional[str] = None,
name: Optional[str] = None,
description: Optional[str] = None,
) -> None:
"""Add a directory as a resource."""
self._resource_manager.add_dir_resource(
path,
recursive=recursive,
pattern=pattern,
name=name,
description=description,
)
async def run(self, *args, **kwargs) -> None:
"""Run the FastMCP server."""
await self._mcp_server.run(*args, **kwargs)
@classmethod
async def run_stdio(cls, app: "FastMCPServer") -> None:
"""Run the server using stdio transport."""
async with stdio_server() as (read_stream, write_stream):
await app.run(
read_stream,
write_stream,
app._mcp_server.create_initialization_options(),
)
@classmethod
async def run_sse(
cls, app: "FastMCPServer", host: str = "0.0.0.0", port: int = 8000
) -> None:
"""Run the server using SSE transport."""
from mcp.server.sse import SseServerTransport
from starlette.applications import Starlette
from starlette.routing import Route
import uvicorn
sse = SseServerTransport("/messages")
async def handle_sse(request):
async with sse.connect_sse(
request.scope, request.receive, request._send
) as streams:
await app.run(
streams[0],
streams[1],
app._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=True,
routes=[
Route("/sse", endpoint=handle_sse),
Route("/messages", endpoint=handle_messages, methods=["POST"]),
],
)
uvicorn.run(starlette_app, host=host, port=port)

90
src/fastmcp/tools.py Normal file
View file

@ -0,0 +1,90 @@
"""Tool management for FastMCP."""
import inspect
from typing import Any, Callable, Dict, Optional, get_type_hints
from pydantic import BaseModel, create_model
from .exceptions import ToolError
from .models import Tool
class ToolManager:
"""Manages FastMCP tools."""
def __init__(self):
self._tools: Dict[str, Tool] = {}
def get_tool(self, name: str) -> Optional[Tool]:
"""Get tool by name."""
return self._tools.get(name)
def list_tools(self) -> list[Tool]:
"""List all registered tools."""
return list(self._tools.values())
def add_tool(
self,
func: Callable,
name: Optional[str] = None,
description: Optional[str] = None,
) -> None:
"""Add a tool to the server."""
func_name = name or func.__name__
func_doc = description or func.__doc__ or ""
is_async = inspect.iscoroutinefunction(func)
# Get type hints for parameters
hints = get_type_hints(func)
if "return" in hints:
del hints["return"]
# Check for Pydantic model parameter
if len(hints) == 1 and issubclass(next(iter(hints.values())), BaseModel):
model = next(iter(hints.values()))
schema = model.model_json_schema()
pydantic_model = model
else:
# Create parameter schema from type hints
fields = {}
sig = inspect.signature(func)
for param_name, param in sig.parameters.items():
param_type = hints.get(param_name, Any)
default = (
... if param.default is inspect.Parameter.empty else param.default
)
fields[param_name] = (param_type, default)
model = create_model(f"{func_name}Args", **fields)
schema = model.model_json_schema()
pydantic_model = model
self._tools[func_name] = Tool(
func=func,
name=func_name,
description=func_doc,
input_schema=schema,
is_async=is_async,
pydantic_model=pydantic_model,
)
async def call_tool(self, name: str, arguments: dict) -> Any:
"""Call a tool by name with arguments."""
tool = self.get_tool(name)
if not tool:
raise ToolError(f"Unknown tool: {name}")
try:
# Validate arguments using schema
if tool.pydantic_model:
validated_args = tool.pydantic_model(**arguments)
args_dict = validated_args.model_dump()
else:
args_dict = arguments
# Call function with proper async handling
if tool.is_async:
return await tool.func(**args_dict)
return tool.func(**args_dict)
except Exception as e:
raise ToolError(f"Error executing tool {name}: {e}") from e

View file

@ -0,0 +1,201 @@
"""Tests for resource management."""
import pytest
from pathlib import Path
from tempfile import NamedTemporaryFile, TemporaryDirectory
from fastmcp.resources import FileResource, ResourceManager
@pytest.fixture
def resource_manager():
"""Create a resource manager for testing."""
return ResourceManager()
@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_file_no_cleanup():
"""Create a temporary file for testing.
File is NOT automatically cleaned up - tests must handle cleanup.
"""
content = "test content"
with NamedTemporaryFile(mode="w", delete=False) as f:
f.write(content)
path = Path(f.name).resolve()
return path
@pytest.fixture
def temp_dir():
"""Create a temporary directory for testing."""
with TemporaryDirectory() as d:
yield Path(d).resolve()
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 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 TestResourceManager:
"""Test ResourceManager functionality."""
def test_add_file_resource(
self, resource_manager: ResourceManager, temp_file: Path
):
"""Test adding a file resource."""
resource = resource_manager.add_file_resource(
str(temp_file),
name="test",
description="test file",
mime_type="text/plain",
)
assert isinstance(resource, FileResource)
assert 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_add_file_resource_relative_path_error(
self, resource_manager: ResourceManager
):
"""Test ResourceManager rejects relative paths."""
with pytest.raises(ValueError, match="Path must be absolute"):
resource_manager.add_file_resource("test.txt")
def test_add_file_resource_missing_file_error(
self, resource_manager: ResourceManager, temp_dir: Path
):
"""Test ResourceManager rejects non-existent files."""
missing_file = temp_dir / "missing.txt"
with pytest.raises(FileNotFoundError):
resource_manager.add_file_resource(str(missing_file))
def test_get_resource_unknown_uri(self, resource_manager: ResourceManager):
"""Test getting a non-existent resource."""
with pytest.raises(ValueError, match="Unknown resource"):
resource_manager.get_resource("file://unknown")
def test_get_resource(self, resource_manager: ResourceManager, temp_file: Path):
"""Test getting a resource by URI."""
added = resource_manager.add_file_resource(str(temp_file))
retrieved = resource_manager.get_resource(added.uri)
assert retrieved == added
def test_list_resources(self, resource_manager: ResourceManager, temp_file: Path):
"""Test listing all resources."""
resource = resource_manager.add_file_resource(str(temp_file))
resources = resource_manager.list_resources()
assert len(resources) == 1
assert resources[0] == resource
async def test_resource_read_through_manager(
self, resource_manager: ResourceManager, temp_file: Path
):
"""Test reading a resource through the manager."""
resource = resource_manager.add_file_resource(str(temp_file))
retrieved = resource_manager.get_resource(resource.uri)
assert retrieved is not None
content = await retrieved.read()
assert content == "test content"
async def test_resource_read_error_through_manager(
self, resource_manager: ResourceManager, temp_file_no_cleanup: Path
):
"""Test error handling when reading through manager."""
# Create resource while file exists
resource = resource_manager.add_file_resource(str(temp_file_no_cleanup))
retrieved = resource_manager.get_resource(resource.uri)
assert retrieved is not None
# Delete file and verify read fails
temp_file_no_cleanup.unlink()
with pytest.raises(FileNotFoundError):
await retrieved.read()

1003
uv.lock generated Normal file

File diff suppressed because it is too large Load diff