From 649329351b5fd8919abeeac70c23d463e5143a0a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 15 Apr 2025 01:17:59 -0400 Subject: [PATCH 1/2] permit optional resource template args --- docs/servers/resources.mdx | 100 ++++++++++++++++------ src/fastmcp/resources/resource_manager.py | 13 +-- src/fastmcp/resources/template.py | 29 +++++++ src/fastmcp/server/server.py | 4 +- tests/resources/test_resource_template.py | 93 ++++++++++++++++++++ tests/server/test_server.py | 98 +++++++++++++++++++-- 6 files changed, 292 insertions(+), 45 deletions(-) diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx index 3088b3d7a..889d56c96 100644 --- a/docs/servers/resources.mdx +++ b/docs/servers/resources.mdx @@ -183,40 +183,90 @@ 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 where some parameters are embedded in the URI path while others use their default values. + +#### Multiple URI Templates for the Same Function + +A powerful pattern is registering a single function with multiple URI templates, allowing different ways to access the same data: + +```python +from fastmcp import FastMCP + +mcp = FastMCP(name="DataServer") + +# 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 +- `users://name/Bob` → Looks up user by name + +In this pattern: +- The `name` parameter is only provided in the URI when using the `users://name/{name}` template +- The `email` parameter is only provided in the URI when using the `users://email/{email}` template +- Each parameter has a default value of `None` for when it's not in the URI + **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. diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py index 2cfe2d6cd..d33aa6f54 100644 --- a/src/fastmcp/resources/resource_manager.py +++ b/src/fastmcp/resources/resource_manager.py @@ -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, diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index 022312bc3..aadae3260 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -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 == "": 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() diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 4ac62bcfe..d353a929d 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -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 diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index 2d9d9b78f..6687f582f 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -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.""" diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 004e113cd..56e558e38 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -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.""" From 65c273a65b4b407298209cb96f63ae5ebd03f000 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 15 Apr 2025 01:20:51 -0400 Subject: [PATCH 2/2] Update docs --- docs/servers/resources.mdx | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx index 889d56c96..c9363b7aa 100644 --- a/docs/servers/resources.mdx +++ b/docs/servers/resources.mdx @@ -225,17 +225,27 @@ However, function parameters with default values don't need to be included in th - 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 where some parameters are embedded in the URI path while others use their default values. - -#### Multiple URI Templates for the Same Function - -A powerful pattern is registering a single function with multiple URI templates, allowing different ways to access the same data: +This allows for flexible API designs. For example, a simple search template with optional parameters: ```python -from fastmcp import FastMCP +@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 + } +``` -mcp = FastMCP(name="DataServer") +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}") @@ -250,13 +260,14 @@ def lookup_user(name: str | None = None, email: str | None = None) -> dict: ``` Now an LLM or client can retrieve user information in two different ways: -- `users://email/alice@example.com` → Looks up user by email -- `users://name/Bob` → Looks up user by name +- `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 pattern: -- The `name` parameter is only provided in the URI when using the `users://name/{name}` template -- The `email` parameter is only provided in the URI when using the `users://email/{email}` template -- Each parameter has a default value of `None` for when it's not in the URI +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:**