Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
d92cb3f
1
Parent(s): 60e7e71
Update resource URI handling
Browse files- src/fastmcp/resources.py +16 -8
- src/fastmcp/server.py +13 -90
- tests/test_resource_manager.py +30 -13
- tests/test_server.py +32 -5
src/fastmcp/resources.py
CHANGED
|
@@ -23,6 +23,22 @@ class Resource(BaseModel):
|
|
| 23 |
description: Optional[str] = None
|
| 24 |
mime_type: str = "text/plain"
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
@abc.abstractmethod
|
| 27 |
async def read(self) -> str:
|
| 28 |
"""Read the resource content."""
|
|
@@ -39,14 +55,6 @@ class FunctionResource(Resource):
|
|
| 39 |
|
| 40 |
func: Callable[..., Any]
|
| 41 |
|
| 42 |
-
@field_validator("uri")
|
| 43 |
-
@classmethod
|
| 44 |
-
def validate_uri(cls, uri: str) -> str:
|
| 45 |
-
"""Ensure URI starts with fn://."""
|
| 46 |
-
if not uri.startswith("fn://"):
|
| 47 |
-
raise ValueError(f"URI must start with fn://: {uri}")
|
| 48 |
-
return uri
|
| 49 |
-
|
| 50 |
def _parse_uri_params(self) -> Dict[str, str]:
|
| 51 |
"""Parse URI query string into kwargs."""
|
| 52 |
parsed = urlparse(self.uri)
|
|
|
|
| 23 |
description: Optional[str] = None
|
| 24 |
mime_type: str = "text/plain"
|
| 25 |
|
| 26 |
+
@field_validator("uri")
|
| 27 |
+
@classmethod
|
| 28 |
+
def validate_uri_format(cls, uri: str) -> str:
|
| 29 |
+
"""Validate URI follows [protocol]://[host]/[path] format."""
|
| 30 |
+
parsed = urlparse(uri)
|
| 31 |
+
|
| 32 |
+
# Check protocol exists and is not empty
|
| 33 |
+
if not parsed.scheme:
|
| 34 |
+
raise ValueError("URI must have a protocol (e.g., 'http://', 'file://')")
|
| 35 |
+
|
| 36 |
+
# Check host exists and is not empty
|
| 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:
|
| 44 |
"""Read the resource content."""
|
|
|
|
| 55 |
|
| 56 |
func: Callable[..., Any]
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
def _parse_uri_params(self) -> Dict[str, str]:
|
| 59 |
"""Parse URI query string into kwargs."""
|
| 60 |
parsed = urlparse(self.uri)
|
src/fastmcp/server.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
import base64
|
| 4 |
import functools
|
| 5 |
import json
|
| 6 |
-
from typing import Any, Callable,
|
| 7 |
|
| 8 |
from mcp.server import Server as MCPServer
|
| 9 |
from mcp.server.stdio import stdio_server
|
|
@@ -147,7 +147,12 @@ class FastMCP:
|
|
| 147 |
self, name: Optional[str] = None, description: Optional[str] = None
|
| 148 |
) -> Callable:
|
| 149 |
"""Decorator to register a tool."""
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
def decorator(func: Callable) -> Callable:
|
| 153 |
self.add_tool(func, name=name, description=description)
|
|
@@ -163,94 +168,6 @@ class FastMCP:
|
|
| 163 |
"""
|
| 164 |
self._resource_manager.add_resource(resource)
|
| 165 |
|
| 166 |
-
def add_file_resource(
|
| 167 |
-
self,
|
| 168 |
-
path: str,
|
| 169 |
-
*,
|
| 170 |
-
name: Optional[str] = None,
|
| 171 |
-
description: Optional[str] = None,
|
| 172 |
-
mime_type: Optional[str] = None,
|
| 173 |
-
) -> None:
|
| 174 |
-
"""Add a file as a resource.
|
| 175 |
-
|
| 176 |
-
This is a convenience method that constructs and adds a FileResource.
|
| 177 |
-
For more control, use add_resource() directly.
|
| 178 |
-
"""
|
| 179 |
-
from pathlib import Path
|
| 180 |
-
from .resources import FileResource
|
| 181 |
-
|
| 182 |
-
file = Path(path)
|
| 183 |
-
if not file.is_absolute():
|
| 184 |
-
raise ValueError(f"Path must be absolute: {path}")
|
| 185 |
-
if not file.is_file():
|
| 186 |
-
raise FileNotFoundError(f"File does not exist: {path}")
|
| 187 |
-
|
| 188 |
-
resource = FileResource(
|
| 189 |
-
uri=f"file://{str(file)}",
|
| 190 |
-
name=name or file.name,
|
| 191 |
-
description=description,
|
| 192 |
-
mime_type=mime_type or "text/plain",
|
| 193 |
-
path=file,
|
| 194 |
-
)
|
| 195 |
-
self.add_resource(resource)
|
| 196 |
-
|
| 197 |
-
def add_http_resource(
|
| 198 |
-
self,
|
| 199 |
-
url: str,
|
| 200 |
-
*,
|
| 201 |
-
name: Optional[str] = None,
|
| 202 |
-
description: Optional[str] = None,
|
| 203 |
-
mime_type: Optional[str] = None,
|
| 204 |
-
headers: Optional[Dict[str, str]] = None,
|
| 205 |
-
) -> None:
|
| 206 |
-
"""Add an HTTP endpoint as a resource.
|
| 207 |
-
|
| 208 |
-
This is a convenience method that constructs and adds an HttpResource.
|
| 209 |
-
For more control, use add_resource() directly.
|
| 210 |
-
"""
|
| 211 |
-
from .resources import HttpResource
|
| 212 |
-
|
| 213 |
-
resource = HttpResource(
|
| 214 |
-
uri=f"http://{url}",
|
| 215 |
-
name=name or url.split("/")[-1],
|
| 216 |
-
description=description,
|
| 217 |
-
mime_type=mime_type or "text/plain",
|
| 218 |
-
url=url,
|
| 219 |
-
headers=headers,
|
| 220 |
-
)
|
| 221 |
-
self.add_resource(resource)
|
| 222 |
-
|
| 223 |
-
def add_dir_resource(
|
| 224 |
-
self,
|
| 225 |
-
path: str,
|
| 226 |
-
*,
|
| 227 |
-
recursive: bool = False,
|
| 228 |
-
pattern: Optional[str] = None,
|
| 229 |
-
name: Optional[str] = None,
|
| 230 |
-
description: Optional[str] = None,
|
| 231 |
-
) -> None:
|
| 232 |
-
"""Add a directory as a resource.
|
| 233 |
-
|
| 234 |
-
This is a convenience method that constructs and adds a DirectoryResource.
|
| 235 |
-
For more control, use add_resource() directly.
|
| 236 |
-
"""
|
| 237 |
-
from pathlib import Path
|
| 238 |
-
from .resources import DirectoryResource
|
| 239 |
-
|
| 240 |
-
dir_path = Path(path).expanduser().resolve()
|
| 241 |
-
if not dir_path.is_dir():
|
| 242 |
-
raise ValueError(f"Directory does not exist: {path}")
|
| 243 |
-
|
| 244 |
-
resource = DirectoryResource(
|
| 245 |
-
uri=f"dir://{str(dir_path)}",
|
| 246 |
-
name=name or dir_path.name,
|
| 247 |
-
description=description,
|
| 248 |
-
path=dir_path,
|
| 249 |
-
recursive=recursive,
|
| 250 |
-
pattern=pattern,
|
| 251 |
-
)
|
| 252 |
-
self.add_resource(resource)
|
| 253 |
-
|
| 254 |
def resource(
|
| 255 |
self,
|
| 256 |
name: str,
|
|
@@ -275,6 +192,12 @@ class FastMCP:
|
|
| 275 |
# Called with fn://my_func?x=1&y=2
|
| 276 |
return f"x={x}, y={y}"
|
| 277 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
|
| 279 |
def decorator(func: Callable) -> Callable:
|
| 280 |
@functools.wraps(func)
|
|
|
|
| 3 |
import base64
|
| 4 |
import functools
|
| 5 |
import json
|
| 6 |
+
from typing import Any, Callable, Optional, Sequence, Union, Literal
|
| 7 |
|
| 8 |
from mcp.server import Server as MCPServer
|
| 9 |
from mcp.server.stdio import stdio_server
|
|
|
|
| 147 |
self, name: Optional[str] = None, description: Optional[str] = None
|
| 148 |
) -> Callable:
|
| 149 |
"""Decorator to register a tool."""
|
| 150 |
+
# Check if user passed function directly instead of calling decorator
|
| 151 |
+
if callable(name):
|
| 152 |
+
raise TypeError(
|
| 153 |
+
"The @tool decorator was used incorrectly. "
|
| 154 |
+
"Did you forget to call it? Use @tool() instead of @tool"
|
| 155 |
+
)
|
| 156 |
|
| 157 |
def decorator(func: Callable) -> Callable:
|
| 158 |
self.add_tool(func, name=name, description=description)
|
|
|
|
| 168 |
"""
|
| 169 |
self._resource_manager.add_resource(resource)
|
| 170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
def resource(
|
| 172 |
self,
|
| 173 |
name: str,
|
|
|
|
| 192 |
# Called with fn://my_func?x=1&y=2
|
| 193 |
return f"x={x}, y={y}"
|
| 194 |
"""
|
| 195 |
+
# Check if user passed function directly instead of calling decorator
|
| 196 |
+
if callable(name):
|
| 197 |
+
raise TypeError(
|
| 198 |
+
"The @resource decorator was used incorrectly. "
|
| 199 |
+
"Did you forget to call it? Use @resource('name') instead of @resource"
|
| 200 |
+
)
|
| 201 |
|
| 202 |
def decorator(func: Callable) -> Callable:
|
| 203 |
@functools.wraps(func)
|
tests/test_resource_manager.py
CHANGED
|
@@ -43,6 +43,36 @@ def temp_dir():
|
|
| 43 |
yield Path(d).resolve()
|
| 44 |
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
class TestFileResource:
|
| 47 |
"""Test FileResource functionality."""
|
| 48 |
|
|
@@ -138,19 +168,6 @@ class TestFunctionResource:
|
|
| 138 |
assert resource.mime_type == "text/plain"
|
| 139 |
assert resource.func == my_func
|
| 140 |
|
| 141 |
-
def test_function_resource_invalid_uri(self):
|
| 142 |
-
"""Test FunctionResource rejects invalid URIs."""
|
| 143 |
-
|
| 144 |
-
def my_func() -> str:
|
| 145 |
-
return "test"
|
| 146 |
-
|
| 147 |
-
with pytest.raises(ValueError, match="URI must start with fn://"):
|
| 148 |
-
FunctionResource(
|
| 149 |
-
uri="invalid://test",
|
| 150 |
-
name="test",
|
| 151 |
-
func=my_func,
|
| 152 |
-
)
|
| 153 |
-
|
| 154 |
async def test_function_resource_read_no_params(self):
|
| 155 |
"""Test reading a FunctionResource with no parameters."""
|
| 156 |
|
|
|
|
| 43 |
yield Path(d).resolve()
|
| 44 |
|
| 45 |
|
| 46 |
+
class TestResourceValidation:
|
| 47 |
+
def test_resource_uri_validation(self):
|
| 48 |
+
def dummy_func() -> str:
|
| 49 |
+
return "data"
|
| 50 |
+
|
| 51 |
+
# Valid URI
|
| 52 |
+
resource = FunctionResource(
|
| 53 |
+
uri="http://example.com/data",
|
| 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="URI must have a protocol"):
|
| 61 |
+
FunctionResource(
|
| 62 |
+
uri="invalid",
|
| 63 |
+
name="test",
|
| 64 |
+
func=dummy_func,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
# Missing host
|
| 68 |
+
with pytest.raises(ValueError, match="URI must have a host"):
|
| 69 |
+
FunctionResource(
|
| 70 |
+
uri="http://",
|
| 71 |
+
name="test",
|
| 72 |
+
func=dummy_func,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
class TestFileResource:
|
| 77 |
"""Test FileResource functionality."""
|
| 78 |
|
|
|
|
| 168 |
assert resource.mime_type == "text/plain"
|
| 169 |
assert resource.func == my_func
|
| 170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
async def test_function_resource_read_no_params(self):
|
| 172 |
"""Test reading a FunctionResource with no parameters."""
|
| 173 |
|
tests/test_server.py
CHANGED
|
@@ -2,6 +2,7 @@ from mcp.shared.memory import (
|
|
| 2 |
create_connected_server_and_client_session as client_session,
|
| 3 |
)
|
| 4 |
from fastmcp import FastMCP
|
|
|
|
| 5 |
|
| 6 |
|
| 7 |
class TestServer:
|
|
@@ -12,14 +13,40 @@ class TestServer:
|
|
| 12 |
async def test_add_tool_decorator(self):
|
| 13 |
mcp = FastMCP()
|
| 14 |
|
| 15 |
-
@mcp.tool
|
| 16 |
def add(x: int, y: int) -> int:
|
| 17 |
return x + y
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
def tool_fn(x: int, y: int) -> int:
|
|
|
|
| 2 |
create_connected_server_and_client_session as client_session,
|
| 3 |
)
|
| 4 |
from fastmcp import FastMCP
|
| 5 |
+
import pytest
|
| 6 |
|
| 7 |
|
| 8 |
class TestServer:
|
|
|
|
| 13 |
async def test_add_tool_decorator(self):
|
| 14 |
mcp = FastMCP()
|
| 15 |
|
| 16 |
+
@mcp.tool()
|
| 17 |
def add(x: int, y: int) -> int:
|
| 18 |
return x + y
|
| 19 |
|
| 20 |
+
assert len(mcp._tool_manager.list_tools()) == 1
|
| 21 |
+
|
| 22 |
+
async def test_add_tool_decorator_incorrect_usage(self):
|
| 23 |
+
mcp = FastMCP()
|
| 24 |
+
|
| 25 |
+
with pytest.raises(TypeError, match="The @tool decorator was used incorrectly"):
|
| 26 |
+
|
| 27 |
+
@mcp.tool # Missing parentheses
|
| 28 |
+
def add(x: int, y: int) -> int:
|
| 29 |
+
return x + y
|
| 30 |
+
|
| 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 |
+
|
| 38 |
+
assert len(mcp._resource_manager.list_resources()) == 1
|
| 39 |
+
|
| 40 |
+
async def test_add_resource_decorator_incorrect_usage(self):
|
| 41 |
+
mcp = FastMCP()
|
| 42 |
+
|
| 43 |
+
with pytest.raises(
|
| 44 |
+
TypeError, match="The @resource decorator was used incorrectly"
|
| 45 |
+
):
|
| 46 |
+
|
| 47 |
+
@mcp.resource # Missing parentheses
|
| 48 |
+
def get_data(x: str) -> str:
|
| 49 |
+
return f"Data: {x}"
|
| 50 |
|
| 51 |
|
| 52 |
def tool_fn(x: int, y: int) -> int:
|