mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-21 04:54:17 +02:00
Merge pull request #164 from jlowin/optional-resource-template-uris
Allow resource templates to have optional / excluded arguments
This commit is contained in:
commit
45f4030183
6 changed files with 303 additions and 45 deletions
|
|
@ -183,40 +183,101 @@ from fastmcp import FastMCP
|
|||
mcp = FastMCP(name="DataServer")
|
||||
|
||||
# Template URI includes {city} placeholder
|
||||
@mcp.resource("data://weather/{city}")
|
||||
# Function accepts 'city' parameter matching the placeholder
|
||||
def get_weather_for_city(city: str) -> dict:
|
||||
@mcp.resource("weather://{city}/current")
|
||||
def get_weather(city: str) -> dict:
|
||||
"""Provides weather information for a specific city."""
|
||||
print(f"Server: Generating weather for city: {city}...")
|
||||
# In reality, call a weather API using the 'city' parameter
|
||||
temp = 20 + len(city) % 5 # Dummy logic
|
||||
condition = "Sunny" if len(city) % 2 == 0 else "Cloudy"
|
||||
return {"city": city.capitalize(), "temperature": temp, "unit": "celsius", "condition": condition}
|
||||
# In a real implementation, this would call a weather API
|
||||
# Here we're using simplified logic for example purposes
|
||||
return {
|
||||
"city": city.capitalize(),
|
||||
"temperature": 22,
|
||||
"condition": "Sunny",
|
||||
"unit": "celsius"
|
||||
}
|
||||
|
||||
# Template with an integer parameter
|
||||
@mcp.resource("users://{user_id}/profile")
|
||||
async def get_user_profile(user_id: int) -> dict:
|
||||
"""Retrieves a user's profile information by ID."""
|
||||
print(f"Server: Generating profile for user ID: {user_id}...")
|
||||
# In reality, fetch from database using user_id
|
||||
# FastMCP uses Pydantic to auto-convert the string URI part to int
|
||||
if user_id == 1:
|
||||
return {"id": user_id, "name": "Alice", "email": "alice@example.com", "status": "active"}
|
||||
elif user_id == 2:
|
||||
return {"id": user_id, "name": "Bob", "email": "bob@example.com", "status": "inactive"}
|
||||
else:
|
||||
# Example of returning an error structure
|
||||
return {"error": f"User with ID {user_id} not found"}
|
||||
# Template with multiple parameters
|
||||
@mcp.resource("repos://{owner}/{repo}/info")
|
||||
def get_repo_info(owner: str, repo: str) -> dict:
|
||||
"""Retrieves information about a GitHub repository."""
|
||||
# In a real implementation, this would call the GitHub API
|
||||
return {
|
||||
"owner": owner,
|
||||
"name": repo,
|
||||
"full_name": f"{owner}/{repo}",
|
||||
"stars": 120,
|
||||
"forks": 48
|
||||
}
|
||||
```
|
||||
|
||||
With these templates defined, clients can request:
|
||||
- `weather://london/current` → Returns weather for London
|
||||
- `repos://fastmcp/docs/info` → Returns info about the fastmcp/docs repository
|
||||
|
||||
### Parameters and Default Values
|
||||
|
||||
When creating resource templates, FastMCP enforces two rules for the relationship between URI template parameters and function parameters:
|
||||
|
||||
1. **Required Function Parameters:** All function parameters without default values (required parameters) must appear in the URI template.
|
||||
2. **URI Parameters:** All URI template parameters must exist as function parameters.
|
||||
|
||||
However, function parameters with default values don't need to be included in the URI template. When a client requests a resource, FastMCP will:
|
||||
|
||||
- Extract parameter values from the URI for parameters included in the template
|
||||
- Use default values for any function parameters not in the URI template
|
||||
|
||||
This allows for flexible API designs. For example, a simple search template with optional parameters:
|
||||
|
||||
```python
|
||||
@mcp.resource("search://{query}")
|
||||
def search_resources(query: str, max_results: int = 10, include_archived: bool = False) -> dict:
|
||||
"""Search for resources matching the query string."""
|
||||
# Only 'query' is required in the URI, the other parameters use their defaults
|
||||
results = perform_search(query, limit=max_results, archived=include_archived)
|
||||
return {
|
||||
"query": query,
|
||||
"max_results": max_results,
|
||||
"include_archived": include_archived,
|
||||
"results": results
|
||||
}
|
||||
```
|
||||
|
||||
With this template, clients can request `search://python` and the function will be called with `query="python", max_results=10, include_archived=False`. MCP Developers can still call the underlying `search_resources` function directly with more specific parameters.
|
||||
|
||||
An even more powerful pattern is registering a single function with multiple URI templates, allowing different ways to access the same data:
|
||||
|
||||
```python
|
||||
# Define a user lookup function that can be accessed by different identifiers
|
||||
@mcp.resource("users://email/{email}")
|
||||
@mcp.resource("users://name/{name}")
|
||||
def lookup_user(name: str | None = None, email: str | None = None) -> dict:
|
||||
"""Look up a user by either name or email."""
|
||||
if email:
|
||||
return find_user_by_email(email) # pseudocode
|
||||
elif name:
|
||||
return find_user_by_name(name) # pseudocode
|
||||
else:
|
||||
return {"error": "No lookup parameters provided"}
|
||||
```
|
||||
|
||||
Now an LLM or client can retrieve user information in two different ways:
|
||||
- `users://email/alice@example.com` → Looks up user by email (with name=None)
|
||||
- `users://name/Bob` → Looks up user by name (with email=None)
|
||||
|
||||
In this stacked decorator pattern:
|
||||
- The `name` parameter is only provided when using the `users://name/{name}` template
|
||||
- The `email` parameter is only provided when using the `users://email/{email}` template
|
||||
- Each parameter defaults to `None` when not included in the URI
|
||||
- The function logic handles whichever parameter is provided
|
||||
|
||||
**How Templates Work:**
|
||||
|
||||
1. **Definition:** When FastMCP sees `{...}` placeholders in the `@resource` URI and matching function parameters, it registers a `ResourceTemplate`.
|
||||
2. **Discovery:** Clients list templates via `resources/listResourceTemplates`.
|
||||
3. **Request & Matching:** A client requests a specific URI, e.g., `data://weather/london`. FastMCP matches this to the `data://weather/{city}` template.
|
||||
3. **Request & Matching:** A client requests a specific URI, e.g., `weather://london/current`. FastMCP matches this to the `weather://{city}/current` template.
|
||||
4. **Parameter Extraction:** It extracts the parameter value: `city="london"`.
|
||||
5. **Type Conversion & Function Call:** It converts the extracted string `"london"` to the type hinted in the function (`str` in this case) and calls `get_weather_for_city(city="london")`. For `users://1/profile`, it converts `"1"` to `int` before calling `get_user_profile(user_id=1)`.
|
||||
6. **Response:** The function's return value is formatted (e.g., dict to JSON) and sent back as the content of the requested resource URI (`data://weather/london`).
|
||||
5. **Type Conversion & Function Call:** It converts extracted values to the types hinted in the function and calls `get_weather(city="london")`.
|
||||
6. **Default Values:** For any function parameters with default values not included in the URI template, FastMCP uses the default values.
|
||||
7. **Response:** The function's return value is formatted (e.g., dict to JSON) and sent back as the resource content.
|
||||
|
||||
Templates provide a powerful way to expose parameterized data access points following REST-like principles.
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import copy
|
||||
import inspect
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -52,7 +51,7 @@ class ResourceManager:
|
|||
has_uri_params = "{" in uri and "}" in uri
|
||||
has_func_params = bool(inspect.signature(fn).parameters)
|
||||
|
||||
if has_uri_params and has_func_params:
|
||||
if has_uri_params or has_func_params:
|
||||
return self.add_template_from_fn(
|
||||
fn, uri, name, description, mime_type, tags
|
||||
)
|
||||
|
|
@ -138,16 +137,6 @@ class ResourceManager:
|
|||
) -> ResourceTemplate:
|
||||
"""Create a template from a function."""
|
||||
|
||||
# Validate that URI params match function params
|
||||
uri_params = set(re.findall(r"{(\w+)}", uri_template))
|
||||
func_params = set(inspect.signature(fn).parameters.keys())
|
||||
|
||||
if uri_params != func_params:
|
||||
raise ValueError(
|
||||
f"Mismatch between URI parameters {uri_params} "
|
||||
f"and function parameters {func_params}"
|
||||
)
|
||||
|
||||
template = ResourceTemplate.from_function(
|
||||
fn,
|
||||
uri_template=uri_template,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ from fastmcp.resources.types import FunctionResource, Resource
|
|||
from fastmcp.utilities.types import _convert_set_defaults
|
||||
|
||||
|
||||
class MyModel(BaseModel):
|
||||
key: str
|
||||
value: int
|
||||
|
||||
|
||||
class ResourceTemplate(BaseModel):
|
||||
"""A template for dynamically creating resources."""
|
||||
|
||||
|
|
@ -47,6 +52,30 @@ class ResourceTemplate(BaseModel):
|
|||
if func_name == "<lambda>":
|
||||
raise ValueError("You must provide a name for lambda functions")
|
||||
|
||||
# Validate that URI params match function params
|
||||
uri_params = set(re.findall(r"{(\w+)}", uri_template))
|
||||
if not uri_params:
|
||||
raise ValueError("URI template must contain at least one parameter")
|
||||
|
||||
func_params = set(inspect.signature(fn).parameters.keys())
|
||||
|
||||
# get the parameters that are required
|
||||
required_params = {
|
||||
p
|
||||
for p in func_params
|
||||
if inspect.signature(fn).parameters[p].default is inspect.Parameter.empty
|
||||
}
|
||||
|
||||
if not required_params.issubset(uri_params):
|
||||
raise ValueError(
|
||||
f"URI parameters {uri_params} must be a subset of the required function arguments: {required_params}"
|
||||
)
|
||||
|
||||
if not uri_params.issubset(func_params):
|
||||
raise ValueError(
|
||||
f"URI parameters {uri_params} must be a subset of the function arguments: {func_params}"
|
||||
)
|
||||
|
||||
# Get schema from TypeAdapter - will fail if function isn't properly typed
|
||||
parameters = TypeAdapter(fn).json_schema()
|
||||
|
||||
|
|
|
|||
|
|
@ -363,7 +363,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
|
||||
def decorator(fn: AnyFunction) -> AnyFunction:
|
||||
self.add_tool(fn, name=name, description=description, tags=tags)
|
||||
return DecoratedFunction(fn)
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
|
|
@ -469,7 +469,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|||
mime_type=mime_type,
|
||||
tags=tags,
|
||||
)
|
||||
return DecoratedFunction(fn)
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,99 @@ class TestResourceTemplate:
|
|||
assert template.matches("test://foo") is None
|
||||
assert template.matches("other://foo/123") is None
|
||||
|
||||
def test_template_uri_validation(self):
|
||||
"""Test validation rule: URI template must have at least one parameter."""
|
||||
|
||||
def my_func() -> dict:
|
||||
return {"data": "value"}
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="URI template must contain at least one parameter"
|
||||
):
|
||||
ResourceTemplate.from_function(
|
||||
fn=my_func,
|
||||
uri_template="test://no-params",
|
||||
name="test",
|
||||
)
|
||||
|
||||
def test_template_uri_params_subset_of_function_params(self):
|
||||
"""Test validation rule: URI parameters must be a subset of function parameters."""
|
||||
|
||||
def my_func(key: str, value: int) -> dict:
|
||||
return {"key": key, "value": value}
|
||||
|
||||
# This should work - URI params are a subset of function params
|
||||
template = ResourceTemplate.from_function(
|
||||
fn=my_func,
|
||||
uri_template="test://{key}/{value}",
|
||||
name="test",
|
||||
)
|
||||
assert template.uri_template == "test://{key}/{value}"
|
||||
|
||||
# This should fail - 'unknown' is not a function parameter
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="URI parameters .* must be a subset of the required function arguments",
|
||||
):
|
||||
ResourceTemplate.from_function(
|
||||
fn=my_func,
|
||||
uri_template="test://{key}/{unknown}",
|
||||
name="test",
|
||||
)
|
||||
|
||||
def test_required_params_subset_of_uri_params(self):
|
||||
"""Test validation rule: Required function parameters must be in URI parameters."""
|
||||
|
||||
# Function with required parameters
|
||||
def func_with_required(
|
||||
required_param: str, optional_param: str = "default"
|
||||
) -> dict:
|
||||
return {"required": required_param, "optional": optional_param}
|
||||
|
||||
# This should work - required param is in URI
|
||||
template = ResourceTemplate.from_function(
|
||||
fn=func_with_required,
|
||||
uri_template="test://{required_param}",
|
||||
name="test",
|
||||
)
|
||||
assert template.uri_template == "test://{required_param}"
|
||||
|
||||
# This should fail - required param is not in URI
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="URI parameters .* must be a subset of the required function arguments",
|
||||
):
|
||||
ResourceTemplate.from_function(
|
||||
fn=func_with_required,
|
||||
uri_template="test://{optional_param}",
|
||||
name="test",
|
||||
)
|
||||
|
||||
def test_multiple_required_params(self):
|
||||
"""Test validation with multiple required parameters."""
|
||||
|
||||
def multi_required(param1: str, param2: int, optional: str = "default") -> dict:
|
||||
return {"p1": param1, "p2": param2, "opt": optional}
|
||||
|
||||
# This works - all required params in URI
|
||||
template = ResourceTemplate.from_function(
|
||||
fn=multi_required,
|
||||
uri_template="test://{param1}/{param2}",
|
||||
name="test",
|
||||
)
|
||||
assert template.uri_template == "test://{param1}/{param2}"
|
||||
|
||||
# This fails - missing one required param
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="URI parameters .* must be a subset of the required function arguments",
|
||||
):
|
||||
ResourceTemplate.from_function(
|
||||
fn=multi_required,
|
||||
uri_template="test://{param1}",
|
||||
name="test",
|
||||
)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_resource(self):
|
||||
"""Test creating a resource from a template."""
|
||||
|
|
|
|||
|
|
@ -840,22 +840,28 @@ class TestServerResources:
|
|||
|
||||
|
||||
class TestServerResourceTemplates:
|
||||
async def test_resource_with_params(self):
|
||||
async def test_resource_with_params_not_in_uri(self):
|
||||
"""Test that a resource with function parameters raises an error if the URI
|
||||
parameters don't match"""
|
||||
mcp = FastMCP()
|
||||
|
||||
with pytest.raises(ValueError, match="mismatch between URI parameters"):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="URI template must contain at least one parameter",
|
||||
):
|
||||
|
||||
@mcp.resource("resource://data")
|
||||
def get_data_fn(param: str) -> str:
|
||||
return f"Data: {param}"
|
||||
|
||||
async def test_resource_with_uri_params(self):
|
||||
async def test_resource_with_uri_params_without_args(self):
|
||||
"""Test that a resource with URI parameters is automatically a template"""
|
||||
mcp = FastMCP()
|
||||
|
||||
with pytest.raises(ValueError, match="mismatch between URI parameters"):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="URI parameters .* must be a subset of the function arguments",
|
||||
):
|
||||
|
||||
@mcp.resource("resource://{param}")
|
||||
def get_data() -> str:
|
||||
|
|
@ -886,7 +892,10 @@ class TestServerResourceTemplates:
|
|||
"""Test that mismatched parameters raise an error"""
|
||||
mcp = FastMCP()
|
||||
|
||||
with pytest.raises(ValueError, match="Mismatch between URI parameters"):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="URI parameters .* must be a subset of the required function arguments",
|
||||
):
|
||||
|
||||
@mcp.resource("resource://{name}/data")
|
||||
def get_data(user: str) -> str:
|
||||
|
|
@ -911,7 +920,10 @@ class TestServerResourceTemplates:
|
|||
"""Test that mismatched parameters raise an error"""
|
||||
mcp = FastMCP()
|
||||
|
||||
with pytest.raises(ValueError, match="Mismatch between URI parameters"):
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="URI parameters .* must be a subset of the required function arguments",
|
||||
):
|
||||
|
||||
@mcp.resource("resource://{org}/{repo}/data")
|
||||
def get_data_mismatched(org: str, repo_2: str) -> str:
|
||||
|
|
@ -929,6 +941,32 @@ class TestServerResourceTemplates:
|
|||
assert isinstance(result[0], TextResourceContents)
|
||||
assert result[0].text == "Static data"
|
||||
|
||||
async def test_template_with_default_params(self):
|
||||
"""Test that a template with default function parameters works when those parameters
|
||||
are not in the URI template"""
|
||||
mcp = FastMCP()
|
||||
|
||||
@mcp.resource("math://add/{x}")
|
||||
def add(x: int, y: int = 10) -> int:
|
||||
return x + y
|
||||
|
||||
# Verify it's registered as a template
|
||||
templates = mcp.list_resource_templates()
|
||||
assert len(templates) == 1
|
||||
assert templates[0].uri_template == "math://add/{x}"
|
||||
|
||||
# Call the template and verify it uses the default value
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource(AnyUrl("math://add/5"))
|
||||
assert isinstance(result[0], TextResourceContents)
|
||||
assert result[0].text == "15" # 5 + default 10
|
||||
|
||||
# Can also call with explicit params
|
||||
resource = await mcp._resource_manager.get_resource("math://add/7")
|
||||
assert isinstance(resource, FunctionResource)
|
||||
result = await resource.read()
|
||||
assert result == "17" # 7 + default 10
|
||||
|
||||
async def test_template_to_resource_conversion(self):
|
||||
"""Test that templates are properly converted to resources when accessed"""
|
||||
mcp = FastMCP()
|
||||
|
|
@ -947,6 +985,54 @@ class TestServerResourceTemplates:
|
|||
result = await resource.read()
|
||||
assert result == "Data for test"
|
||||
|
||||
async def test_stacked_resource_template_decorators(self):
|
||||
"""Test that multiple resource decorators can be stacked on the same function."""
|
||||
mcp = FastMCP()
|
||||
|
||||
# Define a function with multiple stacked resource decorators
|
||||
@mcp.resource("users://email/{email}")
|
||||
@mcp.resource("users://name/{name}")
|
||||
def lookup_user(name: str | None = None, email: str | None = None) -> dict:
|
||||
"""Look up a user by either name or email."""
|
||||
# In a real implementation, this would query a database
|
||||
if email:
|
||||
return {
|
||||
"found_by": "email",
|
||||
"name": f"User for {email}",
|
||||
"email": email,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"found_by": "name",
|
||||
"name": name,
|
||||
"email": f"{name.lower()}@example.com" if name else None,
|
||||
}
|
||||
|
||||
# Verify both templates are registered
|
||||
templates = mcp.list_resource_templates()
|
||||
assert len(templates) == 2
|
||||
template_uris = {t.uri_template for t in templates}
|
||||
assert "users://email/{email}" in template_uris
|
||||
assert "users://name/{name}" in template_uris
|
||||
|
||||
# Test lookup by email
|
||||
async with Client(mcp) as client:
|
||||
email_result = await client.read_resource(
|
||||
AnyUrl("users://email/user@example.com")
|
||||
)
|
||||
assert isinstance(email_result[0], TextResourceContents)
|
||||
email_data = json.loads(email_result[0].text)
|
||||
assert email_data["found_by"] == "email"
|
||||
assert email_data["email"] == "user@example.com"
|
||||
|
||||
# Test lookup by name
|
||||
name_result = await client.read_resource(AnyUrl("users://name/John"))
|
||||
assert isinstance(name_result[0], TextResourceContents)
|
||||
name_data = json.loads(name_result[0].text)
|
||||
assert name_data["found_by"] == "name"
|
||||
assert name_data["name"] == "John"
|
||||
assert name_data["email"] == "john@example.com"
|
||||
|
||||
|
||||
class TestContextInjection:
|
||||
"""Test context injection in tools."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue