mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Add duplicate tests
This commit is contained in:
parent
18aaf41e8e
commit
156ecc8bae
9 changed files with 477 additions and 92 deletions
|
|
@ -6,6 +6,7 @@ authors = [{ name = "Jeremiah Lowin" }]
|
|||
dependencies = [
|
||||
"httpx>=0.26.0",
|
||||
"mcp>=1.0.0",
|
||||
"pydantic-settings>=2.6.1",
|
||||
"pydantic>=2.5.3",
|
||||
"typer>=0.9.0",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -12,5 +12,5 @@ __version__: str
|
|||
__version_tuple__: VERSION_TUPLE
|
||||
version_tuple: VERSION_TUPLE
|
||||
|
||||
__version__ = version = '0.1.dev0+d20241129'
|
||||
__version_tuple__ = version_tuple = (0, 1, 'dev0', 'd20241129')
|
||||
__version__ = version = '0.1.dev4+g18aaf41.d20241129'
|
||||
__version_tuple__ = version_tuple = (0, 1, 'dev4', 'g18aaf41.d20241129')
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ import abc
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
from typing import Dict, Optional, Callable, Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
logger = logging.getLogger("mcp")
|
||||
|
||||
|
||||
|
|
@ -28,6 +29,49 @@ class Resource(BaseModel):
|
|||
return ""
|
||||
|
||||
|
||||
class FunctionResource(Resource):
|
||||
"""A resource that is generated by a function call.
|
||||
|
||||
The function is called with kwargs parsed from the URI query string.
|
||||
For example, a URI of "fn://my_func?x=1&y=2" will call the function with
|
||||
kwargs {"x": "1", "y": "2"}.
|
||||
"""
|
||||
|
||||
func: Callable[..., Any]
|
||||
|
||||
@field_validator("uri")
|
||||
@classmethod
|
||||
def validate_uri(cls, uri: str) -> str:
|
||||
"""Ensure URI starts with fn://."""
|
||||
if not uri.startswith("fn://"):
|
||||
raise ValueError(f"URI must start with fn://: {uri}")
|
||||
return uri
|
||||
|
||||
def _parse_uri_params(self) -> Dict[str, str]:
|
||||
"""Parse URI query string into kwargs."""
|
||||
parsed = urlparse(self.uri)
|
||||
if not parsed.query:
|
||||
return {}
|
||||
# parse_qs returns Dict[str, List[str]], we want Dict[str, str]
|
||||
params = parse_qs(parsed.query)
|
||||
return {k: v[0] for k, v in params.items()}
|
||||
|
||||
async def read(self) -> str:
|
||||
"""Read the resource content by calling the function with URI params."""
|
||||
try:
|
||||
kwargs = self._parse_uri_params()
|
||||
result = await asyncio.to_thread(self.func, **kwargs)
|
||||
if isinstance(result, Resource):
|
||||
return await result.read()
|
||||
if isinstance(result, bytes):
|
||||
return result.decode()
|
||||
if not isinstance(result, str):
|
||||
return str(result)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise ValueError(f"Error calling function {self.func.__name__}: {e}")
|
||||
|
||||
|
||||
class FileResource(Resource):
|
||||
"""A file resource."""
|
||||
|
||||
|
|
@ -123,16 +167,38 @@ class DirectoryResource(Resource):
|
|||
class ResourceManager:
|
||||
"""Manages FastMCP resources."""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, warn_on_duplicate_resources: bool = True):
|
||||
self._resources: Dict[str, Resource] = {}
|
||||
self.warn_on_duplicate_resources = warn_on_duplicate_resources
|
||||
|
||||
def get_resource(self, uri: str) -> Optional[Resource]:
|
||||
"""Get resource by URI."""
|
||||
"""Get resource by URI.
|
||||
|
||||
First tries to find an exact match for the URI. If none is found,
|
||||
tries to match against any FunctionResources with wildcard patterns.
|
||||
"""
|
||||
logger.debug("Getting resource", extra={"uri": uri})
|
||||
resource = self._resources.get(uri)
|
||||
if not resource:
|
||||
raise ValueError(f"Unknown resource: {uri}")
|
||||
return resource
|
||||
|
||||
# First try exact match
|
||||
if resource := self._resources.get(uri):
|
||||
return resource
|
||||
|
||||
# Then try pattern matching for FunctionResources
|
||||
for resource in self._resources.values():
|
||||
if isinstance(resource, FunctionResource) and hasattr(
|
||||
resource, "uri_regex"
|
||||
):
|
||||
if resource.uri_regex.match(uri):
|
||||
# Create a new instance with the actual URI
|
||||
return FunctionResource(
|
||||
uri=uri, # Use actual URI
|
||||
name=resource.name,
|
||||
description=resource.description,
|
||||
mime_type=resource.mime_type,
|
||||
func=resource.func,
|
||||
)
|
||||
|
||||
raise ValueError(f"Unknown resource: {uri}")
|
||||
|
||||
def list_resources(self) -> list[Resource]:
|
||||
"""List all registered resources."""
|
||||
|
|
@ -159,6 +225,12 @@ class ResourceManager:
|
|||
)
|
||||
existing = self._resources.get(resource.uri)
|
||||
if existing:
|
||||
if self.warn_on_duplicate_resources:
|
||||
warnings.warn(
|
||||
f"Resource already exists: {resource.uri}",
|
||||
ResourceWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return existing
|
||||
self._resources[resource.uri] = resource
|
||||
return resource
|
||||
|
|
|
|||
|
|
@ -1,30 +1,74 @@
|
|||
"""FastMCP - A more ergonomic interface for MCP servers."""
|
||||
|
||||
import base64
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Callable, Dict, Optional, Sequence, Union
|
||||
from typing import Any, Callable, Dict, Optional, Sequence, Union, Literal
|
||||
|
||||
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 pydantic_settings import BaseSettings
|
||||
|
||||
from .exceptions import ResourceError
|
||||
from .resources import Resource, ResourceManager
|
||||
from .resources import Resource, FunctionResource, ResourceManager
|
||||
from .tools import ToolManager
|
||||
|
||||
logger = logging.getLogger("mcp")
|
||||
|
||||
logger = logging.getLogger("fastmcp")
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""FastMCP server settings.
|
||||
|
||||
All settings can be configured via environment variables with the prefix FASTMCP_.
|
||||
For example, FASTMCP_DEBUG=true will set debug=True.
|
||||
"""
|
||||
|
||||
model_config: dict = dict(env_prefix="FASTMCP_")
|
||||
|
||||
# Server settings
|
||||
debug: bool = False
|
||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
|
||||
|
||||
# HTTP settings
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
|
||||
# resource settings
|
||||
warn_on_duplicate_resources: bool = True
|
||||
|
||||
# tool settings
|
||||
warn_on_duplicate_tools: bool = True
|
||||
|
||||
|
||||
class FastMCPServer:
|
||||
def __init__(self, name: str):
|
||||
self._mcp_server = MCPServer(name)
|
||||
self._tool_manager = ToolManager()
|
||||
self._resource_manager = ResourceManager()
|
||||
def __init__(self, name=None, **settings: Optional[Settings]):
|
||||
self.settings = Settings(**settings)
|
||||
self._mcp_server = MCPServer(name=name or "FastMCPServer")
|
||||
self._tool_manager = ToolManager(
|
||||
warn_on_duplicate_tools=self.settings.warn_on_duplicate_tools
|
||||
)
|
||||
self._resource_manager = ResourceManager(
|
||||
warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, self.settings.log_level.upper()),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger.setLevel(getattr(logging, self.settings.log_level.upper()))
|
||||
|
||||
self._setup_handlers()
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._mcp_server.name
|
||||
|
||||
def _setup_handlers(self) -> None:
|
||||
"""Set up core MCP protocol handlers."""
|
||||
|
||||
|
|
@ -206,6 +250,48 @@ class FastMCPServer:
|
|||
)
|
||||
self.add_resource(resource)
|
||||
|
||||
def resource(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
description: Optional[str] = None,
|
||||
mime_type: Optional[str] = None,
|
||||
) -> Callable:
|
||||
"""Decorator to register a function as a dynamic resource.
|
||||
|
||||
The function will be called with kwargs parsed from the URI query string.
|
||||
For example, a URI of "fn://my_func?x=1&y=2" will call the function with
|
||||
kwargs {"x": "1", "y": "2"}.
|
||||
|
||||
Args:
|
||||
name: Name for the resource (used in fn:// URI)
|
||||
description: Optional description of the resource
|
||||
mime_type: Optional MIME type for the resource
|
||||
|
||||
Example:
|
||||
@server.resource("my_func")
|
||||
def get_data(x: str, y: str) -> str:
|
||||
# Called with fn://my_func?x=1&y=2
|
||||
return f"x={x}, y={y}"
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
def wrapper(**kwargs) -> Any:
|
||||
return func(**kwargs)
|
||||
|
||||
resource = FunctionResource(
|
||||
uri=f"fn://{name}", # Base URI, params added when called
|
||||
name=name,
|
||||
description=description,
|
||||
mime_type=mime_type or "text/plain",
|
||||
func=wrapper,
|
||||
)
|
||||
self.add_resource(resource)
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
async def run(self, *args, **kwargs) -> None:
|
||||
"""Run the FastMCP server."""
|
||||
await self._mcp_server.run(*args, **kwargs)
|
||||
|
|
@ -222,7 +308,8 @@ class FastMCPServer:
|
|||
|
||||
@classmethod
|
||||
async def run_sse(
|
||||
cls, app: "FastMCPServer", host: str = "0.0.0.0", port: int = 8000
|
||||
cls,
|
||||
app: "FastMCPServer",
|
||||
) -> None:
|
||||
"""Run the server using SSE transport."""
|
||||
from mcp.server.sse import SseServerTransport
|
||||
|
|
@ -246,11 +333,16 @@ class FastMCPServer:
|
|||
await sse.handle_post_message(request.scope, request.receive, request._send)
|
||||
|
||||
starlette_app = Starlette(
|
||||
debug=True,
|
||||
debug=app.settings.debug,
|
||||
routes=[
|
||||
Route("/sse", endpoint=handle_sse),
|
||||
Route("/messages", endpoint=handle_messages, methods=["POST"]),
|
||||
],
|
||||
)
|
||||
|
||||
uvicorn.run(starlette_app, host=host, port=port)
|
||||
uvicorn.run(
|
||||
starlette_app,
|
||||
host=app.settings.host,
|
||||
port=app.settings.port,
|
||||
log_level=app.settings.log_level,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
"""Tool management for FastMCP."""
|
||||
|
||||
import inspect
|
||||
import warnings
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, TypeAdapter
|
||||
|
||||
from .exceptions import ToolError
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("fastmcp")
|
||||
|
||||
|
||||
class Tool(BaseModel):
|
||||
|
|
@ -54,8 +58,9 @@ class Tool(BaseModel):
|
|||
class ToolManager:
|
||||
"""Manages FastMCP tools."""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, warn_on_duplicate_tools: bool = True):
|
||||
self._tools: Dict[str, Tool] = {}
|
||||
self.warn_on_duplicate_tools = warn_on_duplicate_tools
|
||||
|
||||
def get_tool(self, name: str) -> Optional[Tool]:
|
||||
"""Get tool by name."""
|
||||
|
|
@ -70,10 +75,20 @@ class ToolManager:
|
|||
func: Callable,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> None:
|
||||
) -> Tool:
|
||||
"""Add a tool to the server."""
|
||||
tool = Tool.from_function(func, name=name, description=description)
|
||||
existing = self._tools.get(tool.name)
|
||||
if existing:
|
||||
if self.warn_on_duplicate_tools:
|
||||
warnings.warn(
|
||||
f"Tool already exists: {tool.name}",
|
||||
ResourceWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return existing
|
||||
self._tools[tool.name] = tool
|
||||
return tool
|
||||
|
||||
async def call_tool(self, name: str, arguments: dict) -> Any:
|
||||
"""Call a tool by name with arguments."""
|
||||
|
|
|
|||
|
|
@ -1,16 +1,9 @@
|
|||
"""Tests for resource management."""
|
||||
|
||||
import warnings
|
||||
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()
|
||||
from fastmcp.resources import FileResource, FunctionResource, ResourceManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -123,13 +116,142 @@ class TestFileResource:
|
|||
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 resource.uri == "fn://test"
|
||||
assert resource.name == "test"
|
||||
assert resource.description == "test function"
|
||||
assert resource.mime_type == "text/plain"
|
||||
assert resource.func == my_func
|
||||
|
||||
def test_function_resource_invalid_uri(self):
|
||||
"""Test FunctionResource rejects invalid URIs."""
|
||||
|
||||
def my_func() -> str:
|
||||
return "test"
|
||||
|
||||
with pytest.raises(ValueError, match="URI must start with fn://"):
|
||||
FunctionResource(
|
||||
uri="invalid://test",
|
||||
name="test",
|
||||
func=my_func,
|
||||
)
|
||||
|
||||
async def test_function_resource_read_no_params(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"
|
||||
|
||||
async def test_function_resource_read_with_params(self):
|
||||
"""Test reading a FunctionResource with query parameters."""
|
||||
|
||||
def my_func(x: str, y: str) -> str:
|
||||
return f"x={x}, y={y}"
|
||||
|
||||
resource = FunctionResource(
|
||||
uri="fn://test?x=1&y=2",
|
||||
name="test",
|
||||
func=my_func,
|
||||
)
|
||||
content = await resource.read()
|
||||
assert content == "x=1, y=2"
|
||||
|
||||
async def test_function_resource_read_returns_resource(self, temp_file: Path):
|
||||
"""Test reading a FunctionResource that returns another Resource."""
|
||||
|
||||
def my_func(name: str = "test") -> FileResource:
|
||||
return FileResource(
|
||||
uri=f"file://{temp_file}",
|
||||
name=name,
|
||||
path=temp_file,
|
||||
)
|
||||
|
||||
resource = FunctionResource(
|
||||
uri="fn://test?name=example",
|
||||
name="test",
|
||||
func=my_func,
|
||||
)
|
||||
content = await resource.read()
|
||||
assert content == "test content"
|
||||
|
||||
async def test_function_resource_read_error(self):
|
||||
"""Test error handling when reading a FunctionResource."""
|
||||
|
||||
def my_func(x: str) -> str:
|
||||
raise ValueError(f"test error: {x}")
|
||||
|
||||
resource = FunctionResource(
|
||||
uri="fn://test?x=bad",
|
||||
name="test",
|
||||
func=my_func,
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError, match="Error calling function my_func: test error: bad"
|
||||
):
|
||||
await resource.read()
|
||||
|
||||
def test_parse_uri_params(self):
|
||||
"""Test parsing URI parameters."""
|
||||
|
||||
def my_func() -> str:
|
||||
return "test"
|
||||
|
||||
resource = FunctionResource(
|
||||
uri="fn://test?x=1&y=hello&z=true",
|
||||
name="test",
|
||||
func=my_func,
|
||||
)
|
||||
params = resource._parse_uri_params()
|
||||
assert params == {
|
||||
"x": "1",
|
||||
"y": "hello",
|
||||
"z": "true",
|
||||
}
|
||||
|
||||
def test_parse_uri_no_params(self):
|
||||
"""Test parsing URI with no parameters."""
|
||||
|
||||
def my_func() -> str:
|
||||
return "test"
|
||||
|
||||
resource = FunctionResource(
|
||||
uri="fn://test",
|
||||
name="test",
|
||||
func=my_func,
|
||||
)
|
||||
params = resource._parse_uri_params()
|
||||
assert params == {}
|
||||
|
||||
|
||||
class TestResourceManagerAdd:
|
||||
"""Test ResourceManager add functionality."""
|
||||
|
||||
def test_add_file_resource(
|
||||
self, resource_manager: ResourceManager, temp_file: Path
|
||||
):
|
||||
def test_add_file_resource(self, temp_file: Path):
|
||||
"""Test adding a file resource."""
|
||||
manager = ResourceManager()
|
||||
resource = FileResource(
|
||||
uri=f"file://{temp_file}",
|
||||
name="test",
|
||||
|
|
@ -137,7 +259,7 @@ class TestResourceManagerAdd:
|
|||
mime_type="text/plain",
|
||||
path=temp_file,
|
||||
)
|
||||
added = resource_manager.add_resource(resource)
|
||||
added = manager.add_resource(resource)
|
||||
assert isinstance(added, FileResource)
|
||||
assert added.uri == f"file://{temp_file}"
|
||||
assert added.name == "test"
|
||||
|
|
@ -145,65 +267,90 @@ class TestResourceManagerAdd:
|
|||
assert added.mime_type == "text/plain"
|
||||
assert added.path == temp_file
|
||||
|
||||
def test_add_file_resource_relative_path_error(
|
||||
self, resource_manager: ResourceManager
|
||||
):
|
||||
def test_add_file_resource_relative_path_error(self):
|
||||
"""Test ResourceManager rejects relative paths."""
|
||||
with pytest.raises(ValueError, match="Path must be absolute"):
|
||||
resource = FileResource(
|
||||
uri="file://test.txt",
|
||||
FileResource(
|
||||
uri="file:///test.txt",
|
||||
name="test",
|
||||
path=Path("test.txt"),
|
||||
)
|
||||
resource_manager.add_resource(resource)
|
||||
|
||||
def test_warn_on_duplicate_resources(self):
|
||||
"""Test warning on duplicate resources."""
|
||||
manager = ResourceManager()
|
||||
resource = FileResource(
|
||||
uri="file:///test.txt",
|
||||
name="test",
|
||||
path=Path("/test.txt"),
|
||||
)
|
||||
manager.add_resource(resource)
|
||||
with pytest.warns(ResourceWarning):
|
||||
manager.add_resource(resource)
|
||||
|
||||
def test_disable_warn_on_duplicate_resources(self):
|
||||
"""Test disabling warning on duplicate resources."""
|
||||
manager = ResourceManager()
|
||||
resource = FileResource(
|
||||
uri="file:///test.txt",
|
||||
name="test",
|
||||
path=Path("/test.txt"),
|
||||
)
|
||||
manager.add_resource(resource)
|
||||
manager.warn_on_duplicate_resources = False
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error")
|
||||
manager.add_resource(resource)
|
||||
|
||||
|
||||
class TestResourceManagerRead:
|
||||
"""Test ResourceManager read functionality."""
|
||||
|
||||
def test_get_resource_unknown_uri(self, resource_manager: ResourceManager):
|
||||
def test_get_resource_unknown_uri(self):
|
||||
"""Test getting a non-existent resource."""
|
||||
manager = ResourceManager()
|
||||
with pytest.raises(ValueError, match="Unknown resource"):
|
||||
resource_manager.get_resource("file://unknown")
|
||||
manager.get_resource("file://unknown")
|
||||
|
||||
def test_get_resource(self, resource_manager: ResourceManager, temp_file: Path):
|
||||
def test_get_resource(self, temp_file: Path):
|
||||
"""Test getting a resource by URI."""
|
||||
manager = ResourceManager()
|
||||
resource = FileResource(
|
||||
uri=f"file://{temp_file}",
|
||||
name="test",
|
||||
path=temp_file,
|
||||
)
|
||||
added = resource_manager.add_resource(resource)
|
||||
retrieved = resource_manager.get_resource(added.uri)
|
||||
added = manager.add_resource(resource)
|
||||
retrieved = manager.get_resource(added.uri)
|
||||
assert retrieved == added
|
||||
|
||||
async def test_resource_read_through_manager(
|
||||
self, resource_manager: ResourceManager, temp_file: Path
|
||||
):
|
||||
async def test_resource_read_through_manager(self, temp_file: Path):
|
||||
"""Test reading a resource through the manager."""
|
||||
manager = ResourceManager()
|
||||
resource = FileResource(
|
||||
uri=f"file://{temp_file}",
|
||||
name="test",
|
||||
path=temp_file,
|
||||
)
|
||||
added = resource_manager.add_resource(resource)
|
||||
retrieved = resource_manager.get_resource(added.uri)
|
||||
added = manager.add_resource(resource)
|
||||
retrieved = manager.get_resource(added.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
|
||||
self, temp_file_no_cleanup: Path
|
||||
):
|
||||
"""Test error handling when reading through manager."""
|
||||
manager = ResourceManager()
|
||||
# Create resource while file exists
|
||||
resource = FileResource(
|
||||
uri=f"file://{temp_file_no_cleanup}",
|
||||
name="test",
|
||||
path=temp_file_no_cleanup,
|
||||
)
|
||||
added = resource_manager.add_resource(resource)
|
||||
retrieved = resource_manager.get_resource(added.uri)
|
||||
added = manager.add_resource(resource)
|
||||
retrieved = manager.get_resource(added.uri)
|
||||
assert retrieved is not None
|
||||
|
||||
# Delete file and verify read fails
|
||||
|
|
@ -215,42 +362,38 @@ class TestResourceManagerRead:
|
|||
class TestResourceManagerList:
|
||||
"""Test ResourceManager list functionality."""
|
||||
|
||||
def test_list_resources(self, resource_manager: ResourceManager, temp_file: Path):
|
||||
def test_list_resources(self, temp_file: Path):
|
||||
"""Test listing all resources."""
|
||||
manager = ResourceManager()
|
||||
resource = FileResource(
|
||||
uri=f"file://{temp_file}",
|
||||
name="test",
|
||||
path=temp_file,
|
||||
)
|
||||
added = resource_manager.add_resource(resource)
|
||||
resources = resource_manager.list_resources()
|
||||
added = manager.add_resource(resource)
|
||||
resources = manager.list_resources()
|
||||
assert len(resources) == 1
|
||||
assert resources[0] == added
|
||||
|
||||
def test_list_resources_duplicate(
|
||||
self, resource_manager: ResourceManager, temp_file: Path
|
||||
):
|
||||
def test_list_resources_duplicate(self, temp_file: Path):
|
||||
"""Test that adding the same resource twice only stores it once."""
|
||||
manager = ResourceManager()
|
||||
resource = FileResource(
|
||||
uri=f"file://{temp_file}",
|
||||
name="test",
|
||||
path=temp_file,
|
||||
)
|
||||
resource1 = resource_manager.add_resource(resource)
|
||||
resource2 = resource_manager.add_resource(resource)
|
||||
resource1 = manager.add_resource(resource)
|
||||
resource2 = manager.add_resource(resource)
|
||||
|
||||
resources = resource_manager.list_resources()
|
||||
resources = manager.list_resources()
|
||||
assert len(resources) == 1
|
||||
assert resources[0] == resource1
|
||||
assert resource1 == resource2
|
||||
|
||||
def test_list_multiple_resources(
|
||||
self,
|
||||
resource_manager: ResourceManager,
|
||||
temp_file: Path,
|
||||
temp_file_no_cleanup: Path,
|
||||
):
|
||||
def test_list_multiple_resources(self, temp_file: Path, temp_file_no_cleanup: Path):
|
||||
"""Test listing multiple different resources."""
|
||||
manager = ResourceManager()
|
||||
resource1 = FileResource(
|
||||
uri=f"file://{temp_file}",
|
||||
name="test1",
|
||||
|
|
@ -261,10 +404,10 @@ class TestResourceManagerList:
|
|||
name="test2",
|
||||
path=temp_file_no_cleanup,
|
||||
)
|
||||
added1 = resource_manager.add_resource(resource1)
|
||||
added2 = resource_manager.add_resource(resource2)
|
||||
added1 = manager.add_resource(resource1)
|
||||
added2 = manager.add_resource(resource2)
|
||||
|
||||
resources = resource_manager.list_resources()
|
||||
resources = manager.list_resources()
|
||||
assert len(resources) == 2
|
||||
assert resources[0] == added1
|
||||
assert resources[1] == added2
|
||||
|
|
|
|||
|
|
@ -1,23 +1,32 @@
|
|||
from mcp.shared.memory import create_connected_server_and_client_session
|
||||
from mcp.shared.memory import (
|
||||
create_connected_server_and_client_session as client_session,
|
||||
)
|
||||
from fastmcp.server import FastMCPServer
|
||||
|
||||
|
||||
async def test_list_tools():
|
||||
server = FastMCPServer("test_server")
|
||||
server.add_tool(lambda x: x)
|
||||
async with create_connected_server_and_client_session(
|
||||
server._mcp_server
|
||||
) as client_session:
|
||||
tools = await client_session.list_tools()
|
||||
assert len(tools.tools) == 1
|
||||
class TestServer:
|
||||
async def test_create_server(self):
|
||||
server = FastMCPServer()
|
||||
assert server.name == "FastMCPServer"
|
||||
|
||||
|
||||
async def test_call_tool():
|
||||
server = FastMCPServer("test_server")
|
||||
server.add_tool(lambda x: x)
|
||||
async with create_connected_server_and_client_session(
|
||||
server._mcp_server
|
||||
) as client_session:
|
||||
result = await client_session.call_tool("my_tool", {"arg1": "value"})
|
||||
assert "error" not in result
|
||||
assert len(result.content) > 0
|
||||
class TestServerTools:
|
||||
async def test_add_tool(self):
|
||||
server = FastMCPServer()
|
||||
server.add_tool(lambda x: x)
|
||||
assert len(server._tool_manager.list_tools()) == 1
|
||||
|
||||
async def test_list_tools(self):
|
||||
server = FastMCPServer()
|
||||
server.add_tool(lambda x: x)
|
||||
async with client_session(server._mcp_server) as client:
|
||||
tools = await client.list_tools()
|
||||
assert len(tools.tools) == 1
|
||||
|
||||
async def test_call_tool(self):
|
||||
server = FastMCPServer()
|
||||
server.add_tool(lambda x: x)
|
||||
async with client_session(server._mcp_server) as client:
|
||||
result = await client.call_tool("my_tool", {"arg1": "value"})
|
||||
assert "error" not in result
|
||||
assert len(result.content) > 0
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Test tool registration and execution."""
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
|
@ -71,6 +71,35 @@ class TestAddTools:
|
|||
with pytest.raises(AttributeError):
|
||||
manager.add_tool(1)
|
||||
|
||||
def test_add_lambda(self):
|
||||
manager = ToolManager()
|
||||
manager.add_tool(lambda x: x)
|
||||
assert len(manager.list_tools()) == 1
|
||||
|
||||
def test_warn_on_duplicate_tools(self):
|
||||
"""Test warning on duplicate tools."""
|
||||
|
||||
def f(x: int) -> int:
|
||||
return x
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(f)
|
||||
with pytest.warns(ResourceWarning):
|
||||
manager.add_tool(f)
|
||||
|
||||
def test_disable_warn_on_duplicate_tools(self):
|
||||
"""Test disabling warning on duplicate tools."""
|
||||
|
||||
def f(x: int) -> int:
|
||||
return x
|
||||
|
||||
manager = ToolManager()
|
||||
manager.add_tool(f)
|
||||
manager.warn_on_duplicate_tools = False
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error")
|
||||
manager.add_tool(f)
|
||||
|
||||
|
||||
class TestCallTools:
|
||||
async def test_call_tool(self):
|
||||
|
|
|
|||
26
uv.lock
generated
26
uv.lock
generated
|
|
@ -222,12 +222,13 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "fastmcp"
|
||||
version = "0.1.dev0+d20241129"
|
||||
version = "0.1.dev4+g18aaf41.d20241129"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "mcp" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "typer" },
|
||||
]
|
||||
|
||||
|
|
@ -245,6 +246,7 @@ requires-dist = [
|
|||
{ name = "httpx", specifier = ">=0.26.0" },
|
||||
{ name = "mcp", specifier = ">=1.0.0" },
|
||||
{ name = "pydantic", specifier = ">=2.5.3" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.6.1" },
|
||||
{ name = "typer", specifier = ">=0.9.0" },
|
||||
]
|
||||
|
||||
|
|
@ -620,6 +622,19 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/33/72/f881b5e18fbb67cf2fb4ab253660de3c6899dbb2dba409d0b757e3559e3d/pydantic_core-2.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:981fb88516bd1ae8b0cbbd2034678a39dedc98752f264ac9bc5839d3923fa04c", size = 2001864 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/d4/9dfbe238f45ad8b168f5c96ee49a3df0598ce18a0795a983b419949ce65b/pydantic_settings-2.6.1.tar.gz", hash = "sha256:e0f92546d8a9923cb8941689abf85d6601a8c19a23e97a34b2964a2e3f813ca0", size = 75646 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/f9/ff95fd7d760af42f647ea87f9b8a383d891cdb5e5dbd4613edaeb094252a/pydantic_settings-2.6.1-py3-none-any.whl", hash = "sha256:7fb0637c786a558d3103436278a7c4f1cfd29ba8973238a50c5bb9a55387da87", size = 28595 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.18.0"
|
||||
|
|
@ -676,6 +691,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "2024.11.6"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue