Spaces:
Running
Running
Jeremiah Lowin commited on
Commit ·
bf2f475
1
Parent(s): fb71cef
permit optional resource template args
Browse files
docs/servers/resources.mdx
CHANGED
|
@@ -183,40 +183,90 @@ from fastmcp import FastMCP
|
|
| 183 |
mcp = FastMCP(name="DataServer")
|
| 184 |
|
| 185 |
# Template URI includes {city} placeholder
|
| 186 |
-
@mcp.resource("
|
| 187 |
-
|
| 188 |
-
def get_weather_for_city(city: str) -> dict:
|
| 189 |
"""Provides weather information for a specific city."""
|
| 190 |
-
|
| 191 |
-
#
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
else:
|
| 208 |
-
|
| 209 |
-
return {"error": f"User with ID {user_id} not found"}
|
| 210 |
```
|
| 211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
**How Templates Work:**
|
| 213 |
|
| 214 |
1. **Definition:** When FastMCP sees `{...}` placeholders in the `@resource` URI and matching function parameters, it registers a `ResourceTemplate`.
|
| 215 |
2. **Discovery:** Clients list templates via `resources/listResourceTemplates`.
|
| 216 |
-
3. **Request & Matching:** A client requests a specific URI, e.g., `
|
| 217 |
4. **Parameter Extraction:** It extracts the parameter value: `city="london"`.
|
| 218 |
-
5. **Type Conversion & Function Call:** It converts
|
| 219 |
-
6. **
|
|
|
|
| 220 |
|
| 221 |
Templates provide a powerful way to expose parameterized data access points following REST-like principles.
|
| 222 |
|
|
|
|
| 183 |
mcp = FastMCP(name="DataServer")
|
| 184 |
|
| 185 |
# Template URI includes {city} placeholder
|
| 186 |
+
@mcp.resource("weather://{city}/current")
|
| 187 |
+
def get_weather(city: str) -> dict:
|
|
|
|
| 188 |
"""Provides weather information for a specific city."""
|
| 189 |
+
# In a real implementation, this would call a weather API
|
| 190 |
+
# Here we're using simplified logic for example purposes
|
| 191 |
+
return {
|
| 192 |
+
"city": city.capitalize(),
|
| 193 |
+
"temperature": 22,
|
| 194 |
+
"condition": "Sunny",
|
| 195 |
+
"unit": "celsius"
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
# Template with multiple parameters
|
| 199 |
+
@mcp.resource("repos://{owner}/{repo}/info")
|
| 200 |
+
def get_repo_info(owner: str, repo: str) -> dict:
|
| 201 |
+
"""Retrieves information about a GitHub repository."""
|
| 202 |
+
# In a real implementation, this would call the GitHub API
|
| 203 |
+
return {
|
| 204 |
+
"owner": owner,
|
| 205 |
+
"name": repo,
|
| 206 |
+
"full_name": f"{owner}/{repo}",
|
| 207 |
+
"stars": 120,
|
| 208 |
+
"forks": 48
|
| 209 |
+
}
|
| 210 |
+
```
|
| 211 |
+
|
| 212 |
+
With these templates defined, clients can request:
|
| 213 |
+
- `weather://london/current` → Returns weather for London
|
| 214 |
+
- `repos://fastmcp/docs/info` → Returns info about the fastmcp/docs repository
|
| 215 |
+
|
| 216 |
+
### Parameters and Default Values
|
| 217 |
+
|
| 218 |
+
When creating resource templates, FastMCP enforces two rules for the relationship between URI template parameters and function parameters:
|
| 219 |
+
|
| 220 |
+
1. **Required Function Parameters:** All function parameters without default values (required parameters) must appear in the URI template.
|
| 221 |
+
2. **URI Parameters:** All URI template parameters must exist as function parameters.
|
| 222 |
+
|
| 223 |
+
However, function parameters with default values don't need to be included in the URI template. When a client requests a resource, FastMCP will:
|
| 224 |
+
|
| 225 |
+
- Extract parameter values from the URI for parameters included in the template
|
| 226 |
+
- Use default values for any function parameters not in the URI template
|
| 227 |
+
|
| 228 |
+
This allows for flexible API designs where some parameters are embedded in the URI path while others use their default values.
|
| 229 |
+
|
| 230 |
+
#### Multiple URI Templates for the Same Function
|
| 231 |
+
|
| 232 |
+
A powerful pattern is registering a single function with multiple URI templates, allowing different ways to access the same data:
|
| 233 |
+
|
| 234 |
+
```python
|
| 235 |
+
from fastmcp import FastMCP
|
| 236 |
+
|
| 237 |
+
mcp = FastMCP(name="DataServer")
|
| 238 |
+
|
| 239 |
+
# Define a user lookup function that can be accessed by different identifiers
|
| 240 |
+
@mcp.resource("users://email/{email}")
|
| 241 |
+
@mcp.resource("users://name/{name}")
|
| 242 |
+
def lookup_user(name: str | None = None, email: str | None = None) -> dict:
|
| 243 |
+
"""Look up a user by either name or email."""
|
| 244 |
+
if email:
|
| 245 |
+
return find_user_by_email(email) # pseudocode
|
| 246 |
+
elif name:
|
| 247 |
+
return find_user_by_name(name) # pseudocode
|
| 248 |
else:
|
| 249 |
+
return {"error": "No lookup parameters provided"}
|
|
|
|
| 250 |
```
|
| 251 |
|
| 252 |
+
Now an LLM or client can retrieve user information in two different ways:
|
| 253 |
+
- `users://email/alice@example.com` → Looks up user by email
|
| 254 |
+
- `users://name/Bob` → Looks up user by name
|
| 255 |
+
|
| 256 |
+
In this pattern:
|
| 257 |
+
- The `name` parameter is only provided in the URI when using the `users://name/{name}` template
|
| 258 |
+
- The `email` parameter is only provided in the URI when using the `users://email/{email}` template
|
| 259 |
+
- Each parameter has a default value of `None` for when it's not in the URI
|
| 260 |
+
|
| 261 |
**How Templates Work:**
|
| 262 |
|
| 263 |
1. **Definition:** When FastMCP sees `{...}` placeholders in the `@resource` URI and matching function parameters, it registers a `ResourceTemplate`.
|
| 264 |
2. **Discovery:** Clients list templates via `resources/listResourceTemplates`.
|
| 265 |
+
3. **Request & Matching:** A client requests a specific URI, e.g., `weather://london/current`. FastMCP matches this to the `weather://{city}/current` template.
|
| 266 |
4. **Parameter Extraction:** It extracts the parameter value: `city="london"`.
|
| 267 |
+
5. **Type Conversion & Function Call:** It converts extracted values to the types hinted in the function and calls `get_weather(city="london")`.
|
| 268 |
+
6. **Default Values:** For any function parameters with default values not included in the URI template, FastMCP uses the default values.
|
| 269 |
+
7. **Response:** The function's return value is formatted (e.g., dict to JSON) and sent back as the resource content.
|
| 270 |
|
| 271 |
Templates provide a powerful way to expose parameterized data access points following REST-like principles.
|
| 272 |
|
src/fastmcp/resources/resource_manager.py
CHANGED
|
@@ -2,7 +2,6 @@
|
|
| 2 |
|
| 3 |
import copy
|
| 4 |
import inspect
|
| 5 |
-
import re
|
| 6 |
from collections.abc import Callable
|
| 7 |
from typing import Any
|
| 8 |
|
|
@@ -52,7 +51,7 @@ class ResourceManager:
|
|
| 52 |
has_uri_params = "{" in uri and "}" in uri
|
| 53 |
has_func_params = bool(inspect.signature(fn).parameters)
|
| 54 |
|
| 55 |
-
if has_uri_params
|
| 56 |
return self.add_template_from_fn(
|
| 57 |
fn, uri, name, description, mime_type, tags
|
| 58 |
)
|
|
@@ -138,16 +137,6 @@ class ResourceManager:
|
|
| 138 |
) -> ResourceTemplate:
|
| 139 |
"""Create a template from a function."""
|
| 140 |
|
| 141 |
-
# Validate that URI params match function params
|
| 142 |
-
uri_params = set(re.findall(r"{(\w+)}", uri_template))
|
| 143 |
-
func_params = set(inspect.signature(fn).parameters.keys())
|
| 144 |
-
|
| 145 |
-
if uri_params != func_params:
|
| 146 |
-
raise ValueError(
|
| 147 |
-
f"Mismatch between URI parameters {uri_params} "
|
| 148 |
-
f"and function parameters {func_params}"
|
| 149 |
-
)
|
| 150 |
-
|
| 151 |
template = ResourceTemplate.from_function(
|
| 152 |
fn,
|
| 153 |
uri_template=uri_template,
|
|
|
|
| 2 |
|
| 3 |
import copy
|
| 4 |
import inspect
|
|
|
|
| 5 |
from collections.abc import Callable
|
| 6 |
from typing import Any
|
| 7 |
|
|
|
|
| 51 |
has_uri_params = "{" in uri and "}" in uri
|
| 52 |
has_func_params = bool(inspect.signature(fn).parameters)
|
| 53 |
|
| 54 |
+
if has_uri_params or has_func_params:
|
| 55 |
return self.add_template_from_fn(
|
| 56 |
fn, uri, name, description, mime_type, tags
|
| 57 |
)
|
|
|
|
| 137 |
) -> ResourceTemplate:
|
| 138 |
"""Create a template from a function."""
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
template = ResourceTemplate.from_function(
|
| 141 |
fn,
|
| 142 |
uri_template=uri_template,
|
src/fastmcp/resources/template.py
CHANGED
|
@@ -13,6 +13,11 @@ from fastmcp.resources.types import FunctionResource, Resource
|
|
| 13 |
from fastmcp.utilities.types import _convert_set_defaults
|
| 14 |
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
class ResourceTemplate(BaseModel):
|
| 17 |
"""A template for dynamically creating resources."""
|
| 18 |
|
|
@@ -47,6 +52,30 @@ class ResourceTemplate(BaseModel):
|
|
| 47 |
if func_name == "<lambda>":
|
| 48 |
raise ValueError("You must provide a name for lambda functions")
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
# Get schema from TypeAdapter - will fail if function isn't properly typed
|
| 51 |
parameters = TypeAdapter(fn).json_schema()
|
| 52 |
|
|
|
|
| 13 |
from fastmcp.utilities.types import _convert_set_defaults
|
| 14 |
|
| 15 |
|
| 16 |
+
class MyModel(BaseModel):
|
| 17 |
+
key: str
|
| 18 |
+
value: int
|
| 19 |
+
|
| 20 |
+
|
| 21 |
class ResourceTemplate(BaseModel):
|
| 22 |
"""A template for dynamically creating resources."""
|
| 23 |
|
|
|
|
| 52 |
if func_name == "<lambda>":
|
| 53 |
raise ValueError("You must provide a name for lambda functions")
|
| 54 |
|
| 55 |
+
# Validate that URI params match function params
|
| 56 |
+
uri_params = set(re.findall(r"{(\w+)}", uri_template))
|
| 57 |
+
if not uri_params:
|
| 58 |
+
raise ValueError("URI template must contain at least one parameter")
|
| 59 |
+
|
| 60 |
+
func_params = set(inspect.signature(fn).parameters.keys())
|
| 61 |
+
|
| 62 |
+
# get the parameters that are required
|
| 63 |
+
required_params = {
|
| 64 |
+
p
|
| 65 |
+
for p in func_params
|
| 66 |
+
if inspect.signature(fn).parameters[p].default is inspect.Parameter.empty
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
if not required_params.issubset(uri_params):
|
| 70 |
+
raise ValueError(
|
| 71 |
+
f"URI parameters {uri_params} must be a subset of the required function arguments: {required_params}"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
if not uri_params.issubset(func_params):
|
| 75 |
+
raise ValueError(
|
| 76 |
+
f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
# Get schema from TypeAdapter - will fail if function isn't properly typed
|
| 80 |
parameters = TypeAdapter(fn).json_schema()
|
| 81 |
|
src/fastmcp/server/server.py
CHANGED
|
@@ -363,7 +363,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 363 |
|
| 364 |
def decorator(fn: AnyFunction) -> AnyFunction:
|
| 365 |
self.add_tool(fn, name=name, description=description, tags=tags)
|
| 366 |
-
return
|
| 367 |
|
| 368 |
return decorator
|
| 369 |
|
|
@@ -469,7 +469,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 469 |
mime_type=mime_type,
|
| 470 |
tags=tags,
|
| 471 |
)
|
| 472 |
-
return
|
| 473 |
|
| 474 |
return decorator
|
| 475 |
|
|
|
|
| 363 |
|
| 364 |
def decorator(fn: AnyFunction) -> AnyFunction:
|
| 365 |
self.add_tool(fn, name=name, description=description, tags=tags)
|
| 366 |
+
return fn
|
| 367 |
|
| 368 |
return decorator
|
| 369 |
|
|
|
|
| 469 |
mime_type=mime_type,
|
| 470 |
tags=tags,
|
| 471 |
)
|
| 472 |
+
return fn
|
| 473 |
|
| 474 |
return decorator
|
| 475 |
|
tests/resources/test_resource_template.py
CHANGED
|
@@ -46,6 +46,99 @@ class TestResourceTemplate:
|
|
| 46 |
assert template.matches("test://foo") is None
|
| 47 |
assert template.matches("other://foo/123") is None
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
@pytest.mark.anyio
|
| 50 |
async def test_create_resource(self):
|
| 51 |
"""Test creating a resource from a template."""
|
|
|
|
| 46 |
assert template.matches("test://foo") is None
|
| 47 |
assert template.matches("other://foo/123") is None
|
| 48 |
|
| 49 |
+
def test_template_uri_validation(self):
|
| 50 |
+
"""Test validation rule: URI template must have at least one parameter."""
|
| 51 |
+
|
| 52 |
+
def my_func() -> dict:
|
| 53 |
+
return {"data": "value"}
|
| 54 |
+
|
| 55 |
+
with pytest.raises(
|
| 56 |
+
ValueError, match="URI template must contain at least one parameter"
|
| 57 |
+
):
|
| 58 |
+
ResourceTemplate.from_function(
|
| 59 |
+
fn=my_func,
|
| 60 |
+
uri_template="test://no-params",
|
| 61 |
+
name="test",
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
def test_template_uri_params_subset_of_function_params(self):
|
| 65 |
+
"""Test validation rule: URI parameters must be a subset of function parameters."""
|
| 66 |
+
|
| 67 |
+
def my_func(key: str, value: int) -> dict:
|
| 68 |
+
return {"key": key, "value": value}
|
| 69 |
+
|
| 70 |
+
# This should work - URI params are a subset of function params
|
| 71 |
+
template = ResourceTemplate.from_function(
|
| 72 |
+
fn=my_func,
|
| 73 |
+
uri_template="test://{key}/{value}",
|
| 74 |
+
name="test",
|
| 75 |
+
)
|
| 76 |
+
assert template.uri_template == "test://{key}/{value}"
|
| 77 |
+
|
| 78 |
+
# This should fail - 'unknown' is not a function parameter
|
| 79 |
+
with pytest.raises(
|
| 80 |
+
ValueError,
|
| 81 |
+
match="URI parameters .* must be a subset of the required function arguments",
|
| 82 |
+
):
|
| 83 |
+
ResourceTemplate.from_function(
|
| 84 |
+
fn=my_func,
|
| 85 |
+
uri_template="test://{key}/{unknown}",
|
| 86 |
+
name="test",
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
def test_required_params_subset_of_uri_params(self):
|
| 90 |
+
"""Test validation rule: Required function parameters must be in URI parameters."""
|
| 91 |
+
|
| 92 |
+
# Function with required parameters
|
| 93 |
+
def func_with_required(
|
| 94 |
+
required_param: str, optional_param: str = "default"
|
| 95 |
+
) -> dict:
|
| 96 |
+
return {"required": required_param, "optional": optional_param}
|
| 97 |
+
|
| 98 |
+
# This should work - required param is in URI
|
| 99 |
+
template = ResourceTemplate.from_function(
|
| 100 |
+
fn=func_with_required,
|
| 101 |
+
uri_template="test://{required_param}",
|
| 102 |
+
name="test",
|
| 103 |
+
)
|
| 104 |
+
assert template.uri_template == "test://{required_param}"
|
| 105 |
+
|
| 106 |
+
# This should fail - required param is not in URI
|
| 107 |
+
with pytest.raises(
|
| 108 |
+
ValueError,
|
| 109 |
+
match="URI parameters .* must be a subset of the required function arguments",
|
| 110 |
+
):
|
| 111 |
+
ResourceTemplate.from_function(
|
| 112 |
+
fn=func_with_required,
|
| 113 |
+
uri_template="test://{optional_param}",
|
| 114 |
+
name="test",
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
def test_multiple_required_params(self):
|
| 118 |
+
"""Test validation with multiple required parameters."""
|
| 119 |
+
|
| 120 |
+
def multi_required(param1: str, param2: int, optional: str = "default") -> dict:
|
| 121 |
+
return {"p1": param1, "p2": param2, "opt": optional}
|
| 122 |
+
|
| 123 |
+
# This works - all required params in URI
|
| 124 |
+
template = ResourceTemplate.from_function(
|
| 125 |
+
fn=multi_required,
|
| 126 |
+
uri_template="test://{param1}/{param2}",
|
| 127 |
+
name="test",
|
| 128 |
+
)
|
| 129 |
+
assert template.uri_template == "test://{param1}/{param2}"
|
| 130 |
+
|
| 131 |
+
# This fails - missing one required param
|
| 132 |
+
with pytest.raises(
|
| 133 |
+
ValueError,
|
| 134 |
+
match="URI parameters .* must be a subset of the required function arguments",
|
| 135 |
+
):
|
| 136 |
+
ResourceTemplate.from_function(
|
| 137 |
+
fn=multi_required,
|
| 138 |
+
uri_template="test://{param1}",
|
| 139 |
+
name="test",
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
@pytest.mark.anyio
|
| 143 |
async def test_create_resource(self):
|
| 144 |
"""Test creating a resource from a template."""
|
tests/server/test_server.py
CHANGED
|
@@ -840,22 +840,28 @@ class TestServerResources:
|
|
| 840 |
|
| 841 |
|
| 842 |
class TestServerResourceTemplates:
|
| 843 |
-
async def
|
| 844 |
"""Test that a resource with function parameters raises an error if the URI
|
| 845 |
parameters don't match"""
|
| 846 |
mcp = FastMCP()
|
| 847 |
|
| 848 |
-
with pytest.raises(
|
|
|
|
|
|
|
|
|
|
| 849 |
|
| 850 |
@mcp.resource("resource://data")
|
| 851 |
def get_data_fn(param: str) -> str:
|
| 852 |
return f"Data: {param}"
|
| 853 |
|
| 854 |
-
async def
|
| 855 |
"""Test that a resource with URI parameters is automatically a template"""
|
| 856 |
mcp = FastMCP()
|
| 857 |
|
| 858 |
-
with pytest.raises(
|
|
|
|
|
|
|
|
|
|
| 859 |
|
| 860 |
@mcp.resource("resource://{param}")
|
| 861 |
def get_data() -> str:
|
|
@@ -886,7 +892,10 @@ class TestServerResourceTemplates:
|
|
| 886 |
"""Test that mismatched parameters raise an error"""
|
| 887 |
mcp = FastMCP()
|
| 888 |
|
| 889 |
-
with pytest.raises(
|
|
|
|
|
|
|
|
|
|
| 890 |
|
| 891 |
@mcp.resource("resource://{name}/data")
|
| 892 |
def get_data(user: str) -> str:
|
|
@@ -911,7 +920,10 @@ class TestServerResourceTemplates:
|
|
| 911 |
"""Test that mismatched parameters raise an error"""
|
| 912 |
mcp = FastMCP()
|
| 913 |
|
| 914 |
-
with pytest.raises(
|
|
|
|
|
|
|
|
|
|
| 915 |
|
| 916 |
@mcp.resource("resource://{org}/{repo}/data")
|
| 917 |
def get_data_mismatched(org: str, repo_2: str) -> str:
|
|
@@ -929,6 +941,32 @@ class TestServerResourceTemplates:
|
|
| 929 |
assert isinstance(result[0], TextResourceContents)
|
| 930 |
assert result[0].text == "Static data"
|
| 931 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 932 |
async def test_template_to_resource_conversion(self):
|
| 933 |
"""Test that templates are properly converted to resources when accessed"""
|
| 934 |
mcp = FastMCP()
|
|
@@ -947,6 +985,54 @@ class TestServerResourceTemplates:
|
|
| 947 |
result = await resource.read()
|
| 948 |
assert result == "Data for test"
|
| 949 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 950 |
|
| 951 |
class TestContextInjection:
|
| 952 |
"""Test context injection in tools."""
|
|
|
|
| 840 |
|
| 841 |
|
| 842 |
class TestServerResourceTemplates:
|
| 843 |
+
async def test_resource_with_params_not_in_uri(self):
|
| 844 |
"""Test that a resource with function parameters raises an error if the URI
|
| 845 |
parameters don't match"""
|
| 846 |
mcp = FastMCP()
|
| 847 |
|
| 848 |
+
with pytest.raises(
|
| 849 |
+
ValueError,
|
| 850 |
+
match="URI template must contain at least one parameter",
|
| 851 |
+
):
|
| 852 |
|
| 853 |
@mcp.resource("resource://data")
|
| 854 |
def get_data_fn(param: str) -> str:
|
| 855 |
return f"Data: {param}"
|
| 856 |
|
| 857 |
+
async def test_resource_with_uri_params_without_args(self):
|
| 858 |
"""Test that a resource with URI parameters is automatically a template"""
|
| 859 |
mcp = FastMCP()
|
| 860 |
|
| 861 |
+
with pytest.raises(
|
| 862 |
+
ValueError,
|
| 863 |
+
match="URI parameters .* must be a subset of the function arguments",
|
| 864 |
+
):
|
| 865 |
|
| 866 |
@mcp.resource("resource://{param}")
|
| 867 |
def get_data() -> str:
|
|
|
|
| 892 |
"""Test that mismatched parameters raise an error"""
|
| 893 |
mcp = FastMCP()
|
| 894 |
|
| 895 |
+
with pytest.raises(
|
| 896 |
+
ValueError,
|
| 897 |
+
match="URI parameters .* must be a subset of the required function arguments",
|
| 898 |
+
):
|
| 899 |
|
| 900 |
@mcp.resource("resource://{name}/data")
|
| 901 |
def get_data(user: str) -> str:
|
|
|
|
| 920 |
"""Test that mismatched parameters raise an error"""
|
| 921 |
mcp = FastMCP()
|
| 922 |
|
| 923 |
+
with pytest.raises(
|
| 924 |
+
ValueError,
|
| 925 |
+
match="URI parameters .* must be a subset of the required function arguments",
|
| 926 |
+
):
|
| 927 |
|
| 928 |
@mcp.resource("resource://{org}/{repo}/data")
|
| 929 |
def get_data_mismatched(org: str, repo_2: str) -> str:
|
|
|
|
| 941 |
assert isinstance(result[0], TextResourceContents)
|
| 942 |
assert result[0].text == "Static data"
|
| 943 |
|
| 944 |
+
async def test_template_with_default_params(self):
|
| 945 |
+
"""Test that a template with default function parameters works when those parameters
|
| 946 |
+
are not in the URI template"""
|
| 947 |
+
mcp = FastMCP()
|
| 948 |
+
|
| 949 |
+
@mcp.resource("math://add/{x}")
|
| 950 |
+
def add(x: int, y: int = 10) -> int:
|
| 951 |
+
return x + y
|
| 952 |
+
|
| 953 |
+
# Verify it's registered as a template
|
| 954 |
+
templates = mcp.list_resource_templates()
|
| 955 |
+
assert len(templates) == 1
|
| 956 |
+
assert templates[0].uri_template == "math://add/{x}"
|
| 957 |
+
|
| 958 |
+
# Call the template and verify it uses the default value
|
| 959 |
+
async with Client(mcp) as client:
|
| 960 |
+
result = await client.read_resource(AnyUrl("math://add/5"))
|
| 961 |
+
assert isinstance(result[0], TextResourceContents)
|
| 962 |
+
assert result[0].text == "15" # 5 + default 10
|
| 963 |
+
|
| 964 |
+
# Can also call with explicit params
|
| 965 |
+
resource = await mcp._resource_manager.get_resource("math://add/7")
|
| 966 |
+
assert isinstance(resource, FunctionResource)
|
| 967 |
+
result = await resource.read()
|
| 968 |
+
assert result == "17" # 7 + default 10
|
| 969 |
+
|
| 970 |
async def test_template_to_resource_conversion(self):
|
| 971 |
"""Test that templates are properly converted to resources when accessed"""
|
| 972 |
mcp = FastMCP()
|
|
|
|
| 985 |
result = await resource.read()
|
| 986 |
assert result == "Data for test"
|
| 987 |
|
| 988 |
+
async def test_stacked_resource_template_decorators(self):
|
| 989 |
+
"""Test that multiple resource decorators can be stacked on the same function."""
|
| 990 |
+
mcp = FastMCP()
|
| 991 |
+
|
| 992 |
+
# Define a function with multiple stacked resource decorators
|
| 993 |
+
@mcp.resource("users://email/{email}")
|
| 994 |
+
@mcp.resource("users://name/{name}")
|
| 995 |
+
def lookup_user(name: str | None = None, email: str | None = None) -> dict:
|
| 996 |
+
"""Look up a user by either name or email."""
|
| 997 |
+
# In a real implementation, this would query a database
|
| 998 |
+
if email:
|
| 999 |
+
return {
|
| 1000 |
+
"found_by": "email",
|
| 1001 |
+
"name": f"User for {email}",
|
| 1002 |
+
"email": email,
|
| 1003 |
+
}
|
| 1004 |
+
else:
|
| 1005 |
+
return {
|
| 1006 |
+
"found_by": "name",
|
| 1007 |
+
"name": name,
|
| 1008 |
+
"email": f"{name.lower()}@example.com" if name else None,
|
| 1009 |
+
}
|
| 1010 |
+
|
| 1011 |
+
# Verify both templates are registered
|
| 1012 |
+
templates = mcp.list_resource_templates()
|
| 1013 |
+
assert len(templates) == 2
|
| 1014 |
+
template_uris = {t.uri_template for t in templates}
|
| 1015 |
+
assert "users://email/{email}" in template_uris
|
| 1016 |
+
assert "users://name/{name}" in template_uris
|
| 1017 |
+
|
| 1018 |
+
# Test lookup by email
|
| 1019 |
+
async with Client(mcp) as client:
|
| 1020 |
+
email_result = await client.read_resource(
|
| 1021 |
+
AnyUrl("users://email/user@example.com")
|
| 1022 |
+
)
|
| 1023 |
+
assert isinstance(email_result[0], TextResourceContents)
|
| 1024 |
+
email_data = json.loads(email_result[0].text)
|
| 1025 |
+
assert email_data["found_by"] == "email"
|
| 1026 |
+
assert email_data["email"] == "user@example.com"
|
| 1027 |
+
|
| 1028 |
+
# Test lookup by name
|
| 1029 |
+
name_result = await client.read_resource(AnyUrl("users://name/John"))
|
| 1030 |
+
assert isinstance(name_result[0], TextResourceContents)
|
| 1031 |
+
name_data = json.loads(name_result[0].text)
|
| 1032 |
+
assert name_data["found_by"] == "name"
|
| 1033 |
+
assert name_data["name"] == "John"
|
| 1034 |
+
assert name_data["email"] == "john@example.com"
|
| 1035 |
+
|
| 1036 |
|
| 1037 |
class TestContextInjection:
|
| 1038 |
"""Test context injection in tools."""
|