Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
7ea3e33
1
Parent(s): d92cb3f
Update type handling for resources
Browse files- examples/desktop.py +12 -13
- src/fastmcp/resources.py +37 -58
- src/fastmcp/server.py +22 -22
- src/fastmcp/tools.py +7 -3
- tests/__init__.py +0 -0
- tests/servers/__init__.py +0 -0
- tests/servers/test_file_browser.py +47 -0
- tests/test_resource_manager.py +7 -87
- tests/test_server.py +1 -1
examples/desktop.py
CHANGED
|
@@ -10,22 +10,21 @@ from pathlib import Path
|
|
| 10 |
from fastmcp.server import FastMCP
|
| 11 |
|
| 12 |
# Create server
|
| 13 |
-
|
| 14 |
|
| 15 |
-
# Add desktop as a directory resource
|
| 16 |
-
desktop = Path.home() / "Desktop"
|
| 17 |
-
app.add_dir_resource(
|
| 18 |
-
str(desktop),
|
| 19 |
-
recursive=True,
|
| 20 |
-
name="Desktop",
|
| 21 |
-
description="Files on the desktop",
|
| 22 |
-
)
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
if __name__ == "__main__":
|
| 31 |
-
|
|
|
|
| 10 |
from fastmcp.server import FastMCP
|
| 11 |
|
| 12 |
# Create server
|
| 13 |
+
mcp = FastMCP("desktop")
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
+
@mcp.resource("desktop")
|
| 17 |
+
def desktop() -> list[str]:
|
| 18 |
+
"""List the files in the desktop directory"""
|
| 19 |
+
desktop = Path.home() / "Desktop"
|
| 20 |
+
return [str(f) for f in desktop.iterdir()]
|
| 21 |
|
| 22 |
+
|
| 23 |
+
@mcp.tool()
|
| 24 |
+
def add(a: int, b: int) -> int:
|
| 25 |
+
"""Add two numbers"""
|
| 26 |
+
return a + b
|
| 27 |
|
| 28 |
|
| 29 |
if __name__ == "__main__":
|
| 30 |
+
asyncio.run(FastMCP.run_stdio(mcp))
|
src/fastmcp/resources.py
CHANGED
|
@@ -1,14 +1,14 @@
|
|
| 1 |
-
|
| 2 |
|
| 3 |
import abc
|
| 4 |
import asyncio
|
| 5 |
import json
|
| 6 |
from pathlib import Path
|
| 7 |
-
from typing import Dict, Optional, Callable, Any
|
| 8 |
-
from urllib.parse import parse_qs, urlparse
|
| 9 |
|
| 10 |
import httpx
|
| 11 |
from pydantic import BaseModel, field_validator
|
|
|
|
| 12 |
|
| 13 |
from .utilities.logging import get_logger
|
| 14 |
|
|
@@ -18,26 +18,22 @@ logger = get_logger(__name__)
|
|
| 18 |
class Resource(BaseModel):
|
| 19 |
"""Base class for all resources."""
|
| 20 |
|
| 21 |
-
uri:
|
| 22 |
name: str
|
| 23 |
description: Optional[str] = None
|
| 24 |
mime_type: str = "text/plain"
|
| 25 |
|
| 26 |
-
@field_validator("
|
| 27 |
@classmethod
|
| 28 |
-
def
|
| 29 |
-
"""
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
#
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
if not parsed.netloc and not parsed.path:
|
| 38 |
-
raise ValueError("URI must have a host or path")
|
| 39 |
-
|
| 40 |
-
return uri
|
| 41 |
|
| 42 |
@abc.abstractmethod
|
| 43 |
async def read(self) -> str:
|
|
@@ -48,33 +44,35 @@ class Resource(BaseModel):
|
|
| 48 |
class FunctionResource(Resource):
|
| 49 |
"""A resource that is generated by a function call.
|
| 50 |
|
| 51 |
-
The function
|
| 52 |
-
|
| 53 |
-
kwargs {"x": "1", "y": "2"}.
|
| 54 |
"""
|
| 55 |
|
| 56 |
-
func: Callable[
|
|
|
|
| 57 |
|
| 58 |
-
def
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
if not parsed.query:
|
| 62 |
-
return {}
|
| 63 |
-
# parse_qs returns Dict[str, List[str]], we want Dict[str, str]
|
| 64 |
-
params = parse_qs(parsed.query)
|
| 65 |
-
return {k: v[0] for k, v in params.items()}
|
| 66 |
|
| 67 |
async def read(self) -> str:
|
| 68 |
-
"""Read the resource content by calling the function
|
| 69 |
try:
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
if isinstance(result, Resource):
|
| 73 |
return await result.read()
|
| 74 |
if isinstance(result, bytes):
|
| 75 |
return result.decode()
|
| 76 |
if not isinstance(result, str):
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
| 78 |
return result
|
| 79 |
except Exception as e:
|
| 80 |
raise ValueError(f"Error calling function {self.func.__name__}: {e}")
|
|
@@ -179,33 +177,14 @@ class ResourceManager:
|
|
| 179 |
self._resources: Dict[str, Resource] = {}
|
| 180 |
self.warn_on_duplicate_resources = warn_on_duplicate_resources
|
| 181 |
|
| 182 |
-
def get_resource(self, uri: str) -> Optional[Resource]:
|
| 183 |
-
"""Get resource by URI.
|
| 184 |
-
|
| 185 |
-
First tries to find an exact match for the URI. If none is found,
|
| 186 |
-
tries to match against any FunctionResources with wildcard patterns.
|
| 187 |
-
"""
|
| 188 |
logger.debug("Getting resource", extra={"uri": uri})
|
| 189 |
|
| 190 |
-
# First try exact match
|
| 191 |
if resource := self._resources.get(uri):
|
| 192 |
return resource
|
| 193 |
|
| 194 |
-
# Then try pattern matching for FunctionResources
|
| 195 |
-
for resource in self._resources.values():
|
| 196 |
-
if isinstance(resource, FunctionResource) and hasattr(
|
| 197 |
-
resource, "uri_regex"
|
| 198 |
-
):
|
| 199 |
-
if resource.uri_regex.match(uri):
|
| 200 |
-
# Create a new instance with the actual URI
|
| 201 |
-
return FunctionResource(
|
| 202 |
-
uri=uri, # Use actual URI
|
| 203 |
-
name=resource.name,
|
| 204 |
-
description=resource.description,
|
| 205 |
-
mime_type=resource.mime_type,
|
| 206 |
-
func=resource.func,
|
| 207 |
-
)
|
| 208 |
-
|
| 209 |
raise ValueError(f"Unknown resource: {uri}")
|
| 210 |
|
| 211 |
def list_resources(self) -> list[Resource]:
|
|
@@ -231,10 +210,10 @@ class ResourceManager:
|
|
| 231 |
"name": resource.name,
|
| 232 |
},
|
| 233 |
)
|
| 234 |
-
existing = self._resources.get(resource.uri)
|
| 235 |
if existing:
|
| 236 |
if self.warn_on_duplicate_resources:
|
| 237 |
logger.warning(f"Resource already exists: {resource.uri}")
|
| 238 |
return existing
|
| 239 |
-
self._resources[resource.uri] = resource
|
| 240 |
return resource
|
|
|
|
| 1 |
+
import pydantic.json
|
| 2 |
|
| 3 |
import abc
|
| 4 |
import asyncio
|
| 5 |
import json
|
| 6 |
from pathlib import Path
|
| 7 |
+
from typing import Dict, Optional, Callable, Any, Union, Awaitable
|
|
|
|
| 8 |
|
| 9 |
import httpx
|
| 10 |
from pydantic import BaseModel, field_validator
|
| 11 |
+
from pydantic.networks import _BaseUrl
|
| 12 |
|
| 13 |
from .utilities.logging import get_logger
|
| 14 |
|
|
|
|
| 18 |
class Resource(BaseModel):
|
| 19 |
"""Base class for all resources."""
|
| 20 |
|
| 21 |
+
uri: _BaseUrl
|
| 22 |
name: str
|
| 23 |
description: Optional[str] = None
|
| 24 |
mime_type: str = "text/plain"
|
| 25 |
|
| 26 |
+
@field_validator("name", mode="before")
|
| 27 |
@classmethod
|
| 28 |
+
def set_default_name(cls, name: str | None, info) -> str:
|
| 29 |
+
"""Set default name from URI if not provided."""
|
| 30 |
+
if name is not None:
|
| 31 |
+
return name
|
| 32 |
+
# Extract everything after the protocol (e.g., "desktop" from "resource://desktop")
|
| 33 |
+
uri = info.data.get("uri")
|
| 34 |
+
if uri:
|
| 35 |
+
return str(uri).split("://", 1)[1]
|
| 36 |
+
raise ValueError("Either name or uri must be provided")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
@abc.abstractmethod
|
| 39 |
async def read(self) -> str:
|
|
|
|
| 44 |
class FunctionResource(Resource):
|
| 45 |
"""A resource that is generated by a function call.
|
| 46 |
|
| 47 |
+
The function can be sync or async and must return a string
|
| 48 |
+
or another Resource.
|
|
|
|
| 49 |
"""
|
| 50 |
|
| 51 |
+
func: Union[Callable[[], Any], Callable[[], Awaitable[Any]]]
|
| 52 |
+
is_async: bool = False
|
| 53 |
|
| 54 |
+
def __init__(self, **data):
|
| 55 |
+
super().__init__(**data)
|
| 56 |
+
self.is_async = asyncio.iscoroutinefunction(self.func)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
async def read(self) -> str:
|
| 59 |
+
"""Read the resource content by calling the function."""
|
| 60 |
try:
|
| 61 |
+
result = (
|
| 62 |
+
await self.func()
|
| 63 |
+
if self.is_async
|
| 64 |
+
else await asyncio.to_thread(self.func)
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
if isinstance(result, Resource):
|
| 68 |
return await result.read()
|
| 69 |
if isinstance(result, bytes):
|
| 70 |
return result.decode()
|
| 71 |
if not isinstance(result, str):
|
| 72 |
+
try:
|
| 73 |
+
return json.dumps(result, default=pydantic.json.pydantic_encoder)
|
| 74 |
+
except json.JSONDecodeError:
|
| 75 |
+
return str(result)
|
| 76 |
return result
|
| 77 |
except Exception as e:
|
| 78 |
raise ValueError(f"Error calling function {self.func.__name__}: {e}")
|
|
|
|
| 177 |
self._resources: Dict[str, Resource] = {}
|
| 178 |
self.warn_on_duplicate_resources = warn_on_duplicate_resources
|
| 179 |
|
| 180 |
+
def get_resource(self, uri: Union[_BaseUrl, str]) -> Optional[Resource]:
|
| 181 |
+
"""Get resource by URI."""
|
| 182 |
+
uri = str(uri)
|
|
|
|
|
|
|
|
|
|
| 183 |
logger.debug("Getting resource", extra={"uri": uri})
|
| 184 |
|
|
|
|
| 185 |
if resource := self._resources.get(uri):
|
| 186 |
return resource
|
| 187 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
raise ValueError(f"Unknown resource: {uri}")
|
| 189 |
|
| 190 |
def list_resources(self) -> list[Resource]:
|
|
|
|
| 210 |
"name": resource.name,
|
| 211 |
},
|
| 212 |
)
|
| 213 |
+
existing = self._resources.get(str(resource.uri))
|
| 214 |
if existing:
|
| 215 |
if self.warn_on_duplicate_resources:
|
| 216 |
logger.warning(f"Resource already exists: {resource.uri}")
|
| 217 |
return existing
|
| 218 |
+
self._resources[str(resource.uri)] = resource
|
| 219 |
return resource
|
src/fastmcp/server.py
CHANGED
|
@@ -17,6 +17,7 @@ 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 |
|
| 21 |
logger = get_logger(__name__)
|
| 22 |
|
|
@@ -56,15 +57,20 @@ class FastMCP:
|
|
| 56 |
warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources
|
| 57 |
)
|
| 58 |
|
|
|
|
|
|
|
|
|
|
| 59 |
# Configure logging
|
| 60 |
configure_logging(self.settings.log_level)
|
| 61 |
|
| 62 |
-
self._setup_handlers()
|
| 63 |
-
|
| 64 |
@property
|
| 65 |
def name(self) -> str:
|
| 66 |
return self._mcp_server.name
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
def _setup_handlers(self) -> None:
|
| 69 |
"""Set up core MCP protocol handlers."""
|
| 70 |
self._mcp_server.list_tools()(self.list_tools)
|
|
@@ -104,7 +110,7 @@ class FastMCP:
|
|
| 104 |
for resource in resources
|
| 105 |
]
|
| 106 |
|
| 107 |
-
async def read_resource(self, uri:
|
| 108 |
"""Read a resource by URI."""
|
| 109 |
resource = self._resource_manager.get_resource(uri)
|
| 110 |
if not resource:
|
|
@@ -170,42 +176,40 @@ class FastMCP:
|
|
| 170 |
|
| 171 |
def resource(
|
| 172 |
self,
|
| 173 |
-
|
| 174 |
*,
|
|
|
|
| 175 |
description: Optional[str] = None,
|
| 176 |
mime_type: Optional[str] = None,
|
| 177 |
) -> Callable:
|
| 178 |
-
"""Decorator to register a function as a
|
| 179 |
|
| 180 |
-
The function will be called
|
| 181 |
-
For example, a URI of "fn://my_func?x=1&y=2" will call the function with
|
| 182 |
-
kwargs {"x": "1", "y": "2"}.
|
| 183 |
|
| 184 |
Args:
|
| 185 |
-
|
| 186 |
description: Optional description of the resource
|
| 187 |
mime_type: Optional MIME type for the resource
|
| 188 |
|
| 189 |
Example:
|
| 190 |
-
@server.resource("
|
| 191 |
-
def get_data(
|
| 192 |
-
|
| 193 |
-
return f"x={x}, y={y}"
|
| 194 |
"""
|
| 195 |
# Check if user passed function directly instead of calling decorator
|
| 196 |
-
if callable(
|
| 197 |
raise TypeError(
|
| 198 |
"The @resource decorator was used incorrectly. "
|
| 199 |
-
"Did you forget to call it? Use @resource('
|
| 200 |
)
|
| 201 |
|
| 202 |
def decorator(func: Callable) -> Callable:
|
| 203 |
@functools.wraps(func)
|
| 204 |
-
def wrapper(
|
| 205 |
-
return func(
|
| 206 |
|
| 207 |
resource = FunctionResource(
|
| 208 |
-
uri=
|
| 209 |
name=name,
|
| 210 |
description=description,
|
| 211 |
mime_type=mime_type or "text/plain",
|
|
@@ -216,10 +220,6 @@ class FastMCP:
|
|
| 216 |
|
| 217 |
return decorator
|
| 218 |
|
| 219 |
-
async def run(self, *args, **kwargs) -> None:
|
| 220 |
-
"""Run the FastMCP server."""
|
| 221 |
-
await self._mcp_server.run(*args, **kwargs)
|
| 222 |
-
|
| 223 |
@classmethod
|
| 224 |
async def run_stdio(cls, app: "FastMCP") -> None:
|
| 225 |
"""Run the server using stdio transport."""
|
|
|
|
| 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 |
|
|
|
|
| 57 |
warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources
|
| 58 |
)
|
| 59 |
|
| 60 |
+
# Set up MCP protocol handlers
|
| 61 |
+
self._setup_handlers()
|
| 62 |
+
|
| 63 |
# Configure logging
|
| 64 |
configure_logging(self.settings.log_level)
|
| 65 |
|
|
|
|
|
|
|
| 66 |
@property
|
| 67 |
def name(self) -> str:
|
| 68 |
return self._mcp_server.name
|
| 69 |
|
| 70 |
+
async def run(self, *args, **kwargs) -> None:
|
| 71 |
+
"""Run the FastMCP server."""
|
| 72 |
+
await self._mcp_server.run(*args, **kwargs)
|
| 73 |
+
|
| 74 |
def _setup_handlers(self) -> None:
|
| 75 |
"""Set up core MCP protocol handlers."""
|
| 76 |
self._mcp_server.list_tools()(self.list_tools)
|
|
|
|
| 110 |
for resource in resources
|
| 111 |
]
|
| 112 |
|
| 113 |
+
async def read_resource(self, uri: _BaseUrl) -> Union[str, bytes]:
|
| 114 |
"""Read a resource by URI."""
|
| 115 |
resource = self._resource_manager.get_resource(uri)
|
| 116 |
if not resource:
|
|
|
|
| 176 |
|
| 177 |
def resource(
|
| 178 |
self,
|
| 179 |
+
uri: str,
|
| 180 |
*,
|
| 181 |
+
name: Optional[str] = None,
|
| 182 |
description: Optional[str] = None,
|
| 183 |
mime_type: Optional[str] = None,
|
| 184 |
) -> Callable:
|
| 185 |
+
"""Decorator to register a function as a resource.
|
| 186 |
|
| 187 |
+
The function will be called when the resource is read to generate its content.
|
|
|
|
|
|
|
| 188 |
|
| 189 |
Args:
|
| 190 |
+
uri: URI for the resource (e.g. "resource://my-resource")
|
| 191 |
description: Optional description of the resource
|
| 192 |
mime_type: Optional MIME type for the resource
|
| 193 |
|
| 194 |
Example:
|
| 195 |
+
@server.resource("resource://my-resource")
|
| 196 |
+
def get_data() -> str:
|
| 197 |
+
return "Hello, world!"
|
|
|
|
| 198 |
"""
|
| 199 |
# Check if user passed function directly instead of calling decorator
|
| 200 |
+
if callable(uri):
|
| 201 |
raise TypeError(
|
| 202 |
"The @resource decorator was used incorrectly. "
|
| 203 |
+
"Did you forget to call it? Use @resource('uri') instead of @resource"
|
| 204 |
)
|
| 205 |
|
| 206 |
def decorator(func: Callable) -> Callable:
|
| 207 |
@functools.wraps(func)
|
| 208 |
+
def wrapper() -> Any:
|
| 209 |
+
return func()
|
| 210 |
|
| 211 |
resource = FunctionResource(
|
| 212 |
+
uri=uri,
|
| 213 |
name=name,
|
| 214 |
description=description,
|
| 215 |
mime_type=mime_type or "text/plain",
|
|
|
|
| 220 |
|
| 221 |
return decorator
|
| 222 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
@classmethod
|
| 224 |
async def run_stdio(cls, app: "FastMCP") -> None:
|
| 225 |
"""Run the server using stdio transport."""
|
src/fastmcp/tools.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
import inspect
|
| 4 |
from typing import Any, Callable, Dict, Optional
|
| 5 |
|
| 6 |
-
from pydantic import BaseModel, Field, TypeAdapter
|
| 7 |
|
| 8 |
from .exceptions import ToolError
|
| 9 |
from .utilities.logging import get_logger
|
|
@@ -37,13 +37,16 @@ class Tool(BaseModel):
|
|
| 37 |
is_async = inspect.iscoroutinefunction(func)
|
| 38 |
|
| 39 |
# Get schema from TypeAdapter - will fail if function isn't properly typed
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
return cls(
|
| 43 |
func=func,
|
| 44 |
name=func_name,
|
| 45 |
description=func_doc,
|
| 46 |
-
parameters=
|
| 47 |
is_async=is_async,
|
| 48 |
)
|
| 49 |
|
|
@@ -94,4 +97,5 @@ class ToolManager:
|
|
| 94 |
tool = self.get_tool(name)
|
| 95 |
if not tool:
|
| 96 |
raise ToolError(f"Unknown tool: {name}")
|
|
|
|
| 97 |
return await tool.run(arguments)
|
|
|
|
| 3 |
import inspect
|
| 4 |
from typing import Any, Callable, Dict, Optional
|
| 5 |
|
| 6 |
+
from pydantic import BaseModel, Field, TypeAdapter, validate_call
|
| 7 |
|
| 8 |
from .exceptions import ToolError
|
| 9 |
from .utilities.logging import get_logger
|
|
|
|
| 37 |
is_async = inspect.iscoroutinefunction(func)
|
| 38 |
|
| 39 |
# Get schema from TypeAdapter - will fail if function isn't properly typed
|
| 40 |
+
parameters = TypeAdapter(func).json_schema()
|
| 41 |
+
|
| 42 |
+
# ensure the arguments are properly cast
|
| 43 |
+
func = validate_call(func)
|
| 44 |
|
| 45 |
return cls(
|
| 46 |
func=func,
|
| 47 |
name=func_name,
|
| 48 |
description=func_doc,
|
| 49 |
+
parameters=parameters,
|
| 50 |
is_async=is_async,
|
| 51 |
)
|
| 52 |
|
|
|
|
| 97 |
tool = self.get_tool(name)
|
| 98 |
if not tool:
|
| 99 |
raise ToolError(f"Unknown tool: {name}")
|
| 100 |
+
|
| 101 |
return await tool.run(arguments)
|
tests/__init__.py
ADDED
|
File without changes
|
tests/servers/__init__.py
ADDED
|
File without changes
|
tests/servers/test_file_browser.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from fastmcp import FastMCP
|
| 3 |
+
import pytest
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@pytest.fixture(scope="session")
|
| 8 |
+
def test_dir(tmp_path_factory) -> Path:
|
| 9 |
+
"""Create a temporary directory with test files."""
|
| 10 |
+
tmp = tmp_path_factory.mktemp("test_files")
|
| 11 |
+
|
| 12 |
+
# Create test files
|
| 13 |
+
(tmp / "example.py").write_text("print('hello world')")
|
| 14 |
+
(tmp / "readme.md").write_text("# Test Directory\nThis is a test.")
|
| 15 |
+
(tmp / "config.json").write_text('{"test": true}')
|
| 16 |
+
|
| 17 |
+
return tmp
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@pytest.fixture
|
| 21 |
+
def mcp(test_dir: Path) -> FastMCP:
|
| 22 |
+
mcp = FastMCP()
|
| 23 |
+
|
| 24 |
+
@mcp.resource("fs://test_dir")
|
| 25 |
+
def list_files() -> list[str]:
|
| 26 |
+
"""List the files in the test directory"""
|
| 27 |
+
return [str(f) for f in test_dir.iterdir()]
|
| 28 |
+
|
| 29 |
+
return mcp
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def test_list_resources(mcp: FastMCP):
|
| 33 |
+
resources = await mcp.list_resources()
|
| 34 |
+
assert len(resources) == 1
|
| 35 |
+
assert str(resources[0].uri) == "fs://test_dir"
|
| 36 |
+
assert resources[0].name == "test_dir"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
async def test_read_resource(mcp: FastMCP):
|
| 40 |
+
files = await mcp.read_resource("fs://test_dir")
|
| 41 |
+
files = json.loads(files)
|
| 42 |
+
|
| 43 |
+
assert isinstance(files, list)
|
| 44 |
+
assert len(files) == 3
|
| 45 |
+
assert any("example.py" in f for f in files)
|
| 46 |
+
assert any("readme.md" in f for f in files)
|
| 47 |
+
assert any("config.json" in f for f in files)
|
tests/test_resource_manager.py
CHANGED
|
@@ -54,10 +54,10 @@ class TestResourceValidation:
|
|
| 54 |
name="test",
|
| 55 |
func=dummy_func,
|
| 56 |
)
|
| 57 |
-
assert resource.uri == "http://example.com/data"
|
| 58 |
|
| 59 |
# Missing protocol
|
| 60 |
-
with pytest.raises(ValueError, match="
|
| 61 |
FunctionResource(
|
| 62 |
uri="invalid",
|
| 63 |
name="test",
|
|
@@ -65,7 +65,7 @@ class TestResourceValidation:
|
|
| 65 |
)
|
| 66 |
|
| 67 |
# Missing host
|
| 68 |
-
with pytest.raises(ValueError, match="
|
| 69 |
FunctionResource(
|
| 70 |
uri="http://",
|
| 71 |
name="test",
|
|
@@ -85,7 +85,7 @@ class TestFileResource:
|
|
| 85 |
mime_type="text/plain",
|
| 86 |
path=temp_file,
|
| 87 |
)
|
| 88 |
-
assert resource.uri == f"file://{temp_file}"
|
| 89 |
assert resource.name == "test"
|
| 90 |
assert resource.description == "test file"
|
| 91 |
assert resource.mime_type == "text/plain"
|
|
@@ -162,13 +162,13 @@ class TestFunctionResource:
|
|
| 162 |
mime_type="text/plain",
|
| 163 |
func=my_func,
|
| 164 |
)
|
| 165 |
-
assert 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
|
| 172 |
"""Test reading a FunctionResource with no parameters."""
|
| 173 |
|
| 174 |
def my_func() -> str:
|
|
@@ -182,86 +182,6 @@ class TestFunctionResource:
|
|
| 182 |
content = await resource.read()
|
| 183 |
assert content == "test content"
|
| 184 |
|
| 185 |
-
async def test_function_resource_read_with_params(self):
|
| 186 |
-
"""Test reading a FunctionResource with query parameters."""
|
| 187 |
-
|
| 188 |
-
def my_func(x: str, y: str) -> str:
|
| 189 |
-
return f"x={x}, y={y}"
|
| 190 |
-
|
| 191 |
-
resource = FunctionResource(
|
| 192 |
-
uri="fn://test?x=1&y=2",
|
| 193 |
-
name="test",
|
| 194 |
-
func=my_func,
|
| 195 |
-
)
|
| 196 |
-
content = await resource.read()
|
| 197 |
-
assert content == "x=1, y=2"
|
| 198 |
-
|
| 199 |
-
async def test_function_resource_read_returns_resource(self, temp_file: Path):
|
| 200 |
-
"""Test reading a FunctionResource that returns another Resource."""
|
| 201 |
-
|
| 202 |
-
def my_func(name: str = "test") -> FileResource:
|
| 203 |
-
return FileResource(
|
| 204 |
-
uri=f"file://{temp_file}",
|
| 205 |
-
name=name,
|
| 206 |
-
path=temp_file,
|
| 207 |
-
)
|
| 208 |
-
|
| 209 |
-
resource = FunctionResource(
|
| 210 |
-
uri="fn://test?name=example",
|
| 211 |
-
name="test",
|
| 212 |
-
func=my_func,
|
| 213 |
-
)
|
| 214 |
-
content = await resource.read()
|
| 215 |
-
assert content == "test content"
|
| 216 |
-
|
| 217 |
-
async def test_function_resource_read_error(self):
|
| 218 |
-
"""Test error handling when reading a FunctionResource."""
|
| 219 |
-
|
| 220 |
-
def my_func(x: str) -> str:
|
| 221 |
-
raise ValueError(f"test error: {x}")
|
| 222 |
-
|
| 223 |
-
resource = FunctionResource(
|
| 224 |
-
uri="fn://test?x=bad",
|
| 225 |
-
name="test",
|
| 226 |
-
func=my_func,
|
| 227 |
-
)
|
| 228 |
-
with pytest.raises(
|
| 229 |
-
ValueError, match="Error calling function my_func: test error: bad"
|
| 230 |
-
):
|
| 231 |
-
await resource.read()
|
| 232 |
-
|
| 233 |
-
def test_parse_uri_params(self):
|
| 234 |
-
"""Test parsing URI parameters."""
|
| 235 |
-
|
| 236 |
-
def my_func() -> str:
|
| 237 |
-
return "test"
|
| 238 |
-
|
| 239 |
-
resource = FunctionResource(
|
| 240 |
-
uri="fn://test?x=1&y=hello&z=true",
|
| 241 |
-
name="test",
|
| 242 |
-
func=my_func,
|
| 243 |
-
)
|
| 244 |
-
params = resource._parse_uri_params()
|
| 245 |
-
assert params == {
|
| 246 |
-
"x": "1",
|
| 247 |
-
"y": "hello",
|
| 248 |
-
"z": "true",
|
| 249 |
-
}
|
| 250 |
-
|
| 251 |
-
def test_parse_uri_no_params(self):
|
| 252 |
-
"""Test parsing URI with no parameters."""
|
| 253 |
-
|
| 254 |
-
def my_func() -> str:
|
| 255 |
-
return "test"
|
| 256 |
-
|
| 257 |
-
resource = FunctionResource(
|
| 258 |
-
uri="fn://test",
|
| 259 |
-
name="test",
|
| 260 |
-
func=my_func,
|
| 261 |
-
)
|
| 262 |
-
params = resource._parse_uri_params()
|
| 263 |
-
assert params == {}
|
| 264 |
-
|
| 265 |
|
| 266 |
class TestResourceManagerAdd:
|
| 267 |
"""Test ResourceManager add functionality."""
|
|
@@ -278,7 +198,7 @@ class TestResourceManagerAdd:
|
|
| 278 |
)
|
| 279 |
added = manager.add_resource(resource)
|
| 280 |
assert isinstance(added, FileResource)
|
| 281 |
-
assert added.uri == f"file://{temp_file}"
|
| 282 |
assert added.name == "test"
|
| 283 |
assert added.description == "test file"
|
| 284 |
assert added.mime_type == "text/plain"
|
|
|
|
| 54 |
name="test",
|
| 55 |
func=dummy_func,
|
| 56 |
)
|
| 57 |
+
assert str(resource.uri) == "http://example.com/data"
|
| 58 |
|
| 59 |
# Missing protocol
|
| 60 |
+
with pytest.raises(ValueError, match="Input should be a valid URL"):
|
| 61 |
FunctionResource(
|
| 62 |
uri="invalid",
|
| 63 |
name="test",
|
|
|
|
| 65 |
)
|
| 66 |
|
| 67 |
# Missing host
|
| 68 |
+
with pytest.raises(ValueError, match="Input should be a valid URL"):
|
| 69 |
FunctionResource(
|
| 70 |
uri="http://",
|
| 71 |
name="test",
|
|
|
|
| 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"
|
|
|
|
| 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:
|
|
|
|
| 182 |
content = await resource.read()
|
| 183 |
assert content == "test content"
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
class TestResourceManagerAdd:
|
| 187 |
"""Test ResourceManager add functionality."""
|
|
|
|
| 198 |
)
|
| 199 |
added = manager.add_resource(resource)
|
| 200 |
assert isinstance(added, FileResource)
|
| 201 |
+
assert str(added.uri) == f"file://{temp_file}"
|
| 202 |
assert added.name == "test"
|
| 203 |
assert added.description == "test file"
|
| 204 |
assert added.mime_type == "text/plain"
|
tests/test_server.py
CHANGED
|
@@ -31,7 +31,7 @@ class TestServer:
|
|
| 31 |
async def test_add_resource_decorator(self):
|
| 32 |
mcp = FastMCP()
|
| 33 |
|
| 34 |
-
@mcp.resource("data")
|
| 35 |
def get_data(x: str) -> str:
|
| 36 |
return f"Data: {x}"
|
| 37 |
|
|
|
|
| 31 |
async def test_add_resource_decorator(self):
|
| 32 |
mcp = FastMCP()
|
| 33 |
|
| 34 |
+
@mcp.resource("r://data")
|
| 35 |
def get_data(x: str) -> str:
|
| 36 |
return f"Data: {x}"
|
| 37 |
|