Merge pull request #164 from jlowin/optional-resource-template-uris

Allow resource templates to have optional / excluded arguments
This commit is contained in:
Jeremiah Lowin 2025-04-15 01:22:33 -04:00 committed by GitHub
commit 45f4030183
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 303 additions and 45 deletions

View file

@ -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.

View file

@ -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,

View file

@ -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()

View file

@ -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

View file

@ -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."""

View file

@ -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."""