From ed1463632de171ad6dab7f5691c0968269bfc04f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 15 Apr 2025 09:50:44 -0400 Subject: [PATCH 1/6] Add `key` for storing resources --- src/fastmcp/resources/resource_manager.py | 74 ++++++++++++----------- src/fastmcp/server/server.py | 4 +- tests/resources/test_resource_manager.py | 4 +- tests/server/test_openapi.py | 18 ++++-- 4 files changed, 54 insertions(+), 46 deletions(-) diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py index a646bac94..6b170ae77 100644 --- a/src/fastmcp/resources/resource_manager.py +++ b/src/fastmcp/resources/resource_manager.py @@ -1,6 +1,5 @@ """Resource manager functionality.""" -import copy import inspect from collections.abc import Callable from typing import Any @@ -109,34 +108,35 @@ class ResourceManager: ) return self.add_resource(resource) - def add_resource(self, resource: Resource) -> Resource: + def add_resource(self, resource: Resource, key: str | None = None) -> Resource: """Add a resource to the manager. Args: resource: A Resource instance to add + key: Optional URI to use as the storage key (if different from resource.uri) """ - uri_str = str(resource.uri) + storage_key = key or str(resource.uri) logger.debug( "Adding resource", extra={ - "uri": uri_str, + "uri": resource.uri, + "storage_key": storage_key, "type": type(resource).__name__, "resource_name": resource.name, }, ) - existing = self._resources.get(uri_str) + existing = self._resources.get(storage_key) if existing: if self.duplicate_behavior == "warn": - logger.warning(f"Resource already exists: {uri_str}") - self._resources[uri_str] = resource + logger.warning(f"Resource already exists: {storage_key}") + self._resources[storage_key] = resource elif self.duplicate_behavior == "replace": - self._resources[uri_str] = resource + self._resources[storage_key] = resource elif self.duplicate_behavior == "error": - raise ValueError(f"Resource already exists: {uri_str}") + raise ValueError(f"Resource already exists: {storage_key}") elif self.duplicate_behavior == "ignore": return existing - else: - self._resources[uri_str] = resource + self._resources[storage_key] = resource return resource def add_template_from_fn( @@ -160,38 +160,42 @@ class ResourceManager: ) return self.add_template(template) - def add_template(self, template: ResourceTemplate) -> ResourceTemplate: + def add_template( + self, template: ResourceTemplate, key: str | None = None + ) -> ResourceTemplate: """Add a template to the manager. Args: template: A ResourceTemplate instance to add + key: Optional URI template to use as the storage key (if different from template.uri_template) Returns: The added template. If a template with the same URI already exists, returns the existing template. """ uri_template_str = str(template.uri_template) + storage_key = key or uri_template_str logger.debug( - "Adding resource", + "Adding template", extra={ - "uri": uri_template_str, + "uri_template": uri_template_str, + "storage_key": storage_key, "type": type(template).__name__, - "resource_name": template.name, + "template_name": template.name, }, ) - existing = self._templates.get(uri_template_str) + existing = self._templates.get(storage_key) if existing: if self.duplicate_behavior == "warn": - logger.warning(f"Resource already exists: {uri_template_str}") - self._templates[uri_template_str] = template + logger.warning(f"Template already exists: {storage_key}") + self._templates[storage_key] = template elif self.duplicate_behavior == "replace": - self._templates[uri_template_str] = template + self._templates[storage_key] = template elif self.duplicate_behavior == "error": - raise ValueError(f"Resource already exists: {uri_template_str}") + raise ValueError(f"Template already exists: {storage_key}") elif self.duplicate_behavior == "ignore": return existing - else: - self._templates[uri_template_str] = template + self._templates[storage_key] = template return template async def get_resource(self, uri: AnyUrl | str) -> Resource | None: @@ -213,11 +217,19 @@ class ResourceManager: raise ResourceError(f"Unknown resource: {uri}") + def get_resources(self) -> dict[str, Resource]: + """Get all registered resources, keyed by URI.""" + return self._resources + def list_resources(self) -> list[Resource]: """List all registered resources.""" logger.debug("Listing resources", extra={"count": len(self._resources)}) return list(self._resources.values()) + def get_templates(self) -> dict[str, ResourceTemplate]: + """Get all registered templates, keyed by URI template.""" + return self._templates + def list_templates(self) -> list[ResourceTemplate]: """List all registered templates.""" logger.debug("Listing templates", extra={"count": len(self._templates)}) @@ -240,14 +252,9 @@ class ResourceManager: If None, the original URI is used. """ for uri, resource in manager._resources.items(): - # Create prefixed URI and copy the resource with the new URI + # Create prefixed URI and import the resource with the new URI as the storage key prefixed_uri = f"{prefix}{uri}" if prefix else uri - - new_resource = copy.copy(resource) - new_resource.uri = AnyUrl(prefixed_uri) - - # Store directly in resources dictionary - self.add_resource(new_resource) + self.add_resource(resource, key=prefixed_uri) logger.debug(f'Imported resource "{uri}" as "{prefixed_uri}"') def import_templates( @@ -267,16 +274,11 @@ class ResourceManager: If None, the original URI template is used. """ for uri_template, template in manager._templates.items(): - # Create prefixed URI template and copy the template with the new URI template + # Create prefixed URI template and import the template with the new URI as the storage key prefixed_uri_template = ( f"{prefix}{uri_template}" if prefix else uri_template ) - - new_template = copy.copy(template) - new_template.uri_template = prefixed_uri_template - - # Store directly in templates dictionary - self.add_template(new_template) + self.add_template(template, key=prefixed_uri_template) logger.debug( f'Imported template "{uri_template}" as "{prefixed_uri_template}"' ) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index eca3fe4e1..723652386 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -362,14 +362,14 @@ class FastMCP(Generic[LifespanResultT]): return decorator - def add_resource(self, resource: Resource) -> None: + def add_resource(self, resource: Resource, key: str | None = None) -> None: """Add a resource to the server. Args: resource: A Resource instance to add """ - self._resource_manager.add_resource(resource) + self._resource_manager.add_resource(resource, key=key) def add_resource_fn( self, diff --git a/tests/resources/test_resource_manager.py b/tests/resources/test_resource_manager.py index 284ac1055..ede30eec4 100644 --- a/tests/resources/test_resource_manager.py +++ b/tests/resources/test_resource_manager.py @@ -168,7 +168,7 @@ class TestResourceManager: manager.add_template(template) manager.add_template(template) - assert "Resource already exists" in caplog.text + assert "Template already exists" in caplog.text # Should have the template assert len(manager.list_templates()) == 1 @@ -187,7 +187,7 @@ class TestResourceManager: manager.add_template(template) - with pytest.raises(ValueError, match="Resource already exists"): + with pytest.raises(ValueError, match="Template already exists"): manager.add_template(template) def test_replace_duplicate_templates(self): diff --git a/tests/server/test_openapi.py b/tests/server/test_openapi.py index b06a40ee1..d1c7c91b4 100644 --- a/tests/server/test_openapi.py +++ b/tests/server/test_openapi.py @@ -696,20 +696,26 @@ class TestMountFastMCP: mcp.mount("fastapi", fastmcp_openapi_server) + # Check that resources are available with prefixed URIs resources = await mcp._mcp_list_resources() assert len(resources) == 1 - assert resources[0].uri == AnyUrl( - "fastapi+resource://openapi/get_users_users_get" - ) + # We're checking the key used by mcp to store the resource + # The prefixed URI is used as the key, but the resource's original uri is preserved + prefixed_uri = "fastapi+resource://openapi/get_users_users_get" + resource = mcp._resource_manager.get_resources().get(prefixed_uri) + assert resource is not None + # Check that templates are available with prefixed URIs templates = await mcp._mcp_list_resource_templates() assert len(templates) == 1 assert templates[0].name == "get_user_users__user_id__get" - assert ( - templates[0].uriTemplate - == r"fastapi+resource://openapi/get_user_users__user_id__get/{user_id}" + prefixed_template_uri = ( + r"fastapi+resource://openapi/get_user_users__user_id__get/{user_id}" ) + template = mcp._resource_manager.get_templates().get(prefixed_template_uri) + assert template is not None + # Check that tools are available with prefixed names tools = await mcp._mcp_list_tools() assert len(tools) == 2 assert tools[0].name == "fastapi_create_user_users_post" From 6fcd333e52c0cd3cb2f93465361e5eb58eb68147 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 15 Apr 2025 09:52:31 -0400 Subject: [PATCH 2/6] Update tool manager param to key --- src/fastmcp/server/server.py | 2 +- src/fastmcp/tools/tool_manager.py | 28 ++++++++++++++-------------- tests/tools/test_tool_manager.py | 4 ++-- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 723652386..c53e1ee4e 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -186,7 +186,7 @@ class FastMCP(Generic[LifespanResultT]): self._mcp_server.list_resource_templates()(self._mcp_list_resource_templates) def get_tools(self) -> dict[str, Tool]: - """Get all registered tools, keyed by registered name.""" + """Get all registered tools, indexed by registered key.""" return self._tool_manager.get_tools() def list_tools(self) -> list[Tool]: diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py index 764804058..9e0f1ac42 100644 --- a/src/fastmcp/tools/tool_manager.py +++ b/src/fastmcp/tools/tool_manager.py @@ -41,7 +41,7 @@ class ToolManager: return self._tools.get(name) def get_tools(self) -> dict[str, Tool]: - """Get all registered tools, keyed by registered name.""" + """Get all registered tools, indexed by registered key.""" return self._tools def list_tools(self) -> list[Tool]: @@ -50,7 +50,7 @@ class ToolManager: def list_mcp_tools(self) -> list[MCPTool]: """List all registered tools in the format expected by the low-level MCP server.""" - return [tool.to_mcp_tool(name=name) for name, tool in self._tools.items()] + return [tool.to_mcp_tool(name=key) for key, tool in self._tools.items()] def add_tool_from_fn( self, @@ -63,34 +63,34 @@ class ToolManager: tool = Tool.from_function(fn, name=name, description=description, tags=tags) return self.add_tool(tool) - def add_tool(self, tool: Tool, name: str | None = None) -> Tool: + def add_tool(self, tool: Tool, key: str | None = None) -> Tool: """Register a tool with the server.""" - name = name or tool.name - existing = self._tools.get(name) + key = key or tool.name + existing = self._tools.get(key) if existing: if self.duplicate_behavior == "warn": - logger.warning(f"Tool already exists: {name}") - self._tools[name] = tool + logger.warning(f"Tool already exists: {key}") + self._tools[key] = tool elif self.duplicate_behavior == "replace": - self._tools[name] = tool + self._tools[key] = tool elif self.duplicate_behavior == "error": - raise ValueError(f"Tool already exists: {name}") + raise ValueError(f"Tool already exists: {key}") elif self.duplicate_behavior == "ignore": return existing else: - self._tools[name] = tool + self._tools[key] = tool return tool async def call_tool( self, - name: str, + key: str, arguments: dict[str, Any], context: Context[ServerSessionT, LifespanContextT] | None = None, ) -> Any: """Call a tool by name with arguments.""" - tool = self.get_tool(name) + tool = self.get_tool(key) if not tool: - raise ToolError(f"Unknown tool: {name}") + raise ToolError(f"Unknown tool: {key}") return await tool.run(arguments, context=context) @@ -110,5 +110,5 @@ class ToolManager: """ for name, tool in tool_manager._tools.items(): prefixed_name = f"{prefix}{name}" if prefix else name - self.add_tool(tool, name=prefixed_name) + self.add_tool(tool, key=prefixed_name) logger.debug(f'Imported tool "{tool.name}" as "{prefixed_name}"') diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py index c86e8b0fb..0df04e6fa 100644 --- a/tests/tools/test_tool_manager.py +++ b/tests/tools/test_tool_manager.py @@ -585,7 +585,7 @@ class TestCustomToolNames: tool = Tool.from_function(fn, name="my_tool") manager = ToolManager() # Store it under a different name - manager.add_tool(tool, name="proxy_tool") + manager.add_tool(tool, key="proxy_tool") # The tool is accessible under the storage name stored = manager.get_tool("proxy_tool") assert stored is not None @@ -682,7 +682,7 @@ class TestCustomToolNames: tool = Tool.from_function(fn, name="my_tool") manager = ToolManager() - manager.add_tool(tool, name="proxy_tool") + manager.add_tool(tool, key="proxy_tool") mcp_tools = manager.list_mcp_tools() assert len(mcp_tools) == 1 assert mcp_tools[0].name == "proxy_tool" From 9ec0be8665e19e03952d944894e1860bc1cc87a8 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 15 Apr 2025 09:54:47 -0400 Subject: [PATCH 3/6] Update test_tool_manager.py --- tests/tools/test_tool_manager.py | 33 +++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py index 0df04e6fa..5b8d79e7c 100644 --- a/tests/tools/test_tool_manager.py +++ b/tests/tools/test_tool_manager.py @@ -575,8 +575,8 @@ class TestCustomToolNames: # The tool should not be accessible via its original function name assert manager.get_tool("original_fn") is None - def test_add_tool_object_with_custom_storage_name(self): - """Test adding a Tool object with a custom storage name using add_tool().""" + def test_add_tool_object_with_custom_key(self): + """Test adding a Tool object with a custom key using add_tool().""" def fn(x: int) -> int: return x + 1 @@ -586,7 +586,7 @@ class TestCustomToolNames: manager = ToolManager() # Store it under a different name manager.add_tool(tool, key="proxy_tool") - # The tool is accessible under the storage name + # The tool is accessible under the key stored = manager.get_tool("proxy_tool") assert stored is not None # But the tool's .name is unchanged @@ -612,20 +612,35 @@ class TestCustomToolNames: with pytest.raises(ToolError): await manager.call_tool("multiply", {"a": 5, "b": 3}) - def test_tool_to_mcp_tool_with_custom_name(self): - """Test that to_mcp_tool uses the storage name, not the internal name.""" + def test_tool_to_mcp_tool(self): + """Test that to_mcp_tool uses the key, not the internal name.""" def some_function(x: int) -> int: return x manager = ToolManager() - manager.add_tool_from_fn(some_function, name="api_function") + tool = Tool.from_function(some_function, name="api_function") + manager.add_tool(tool) - # When listing tools for MCP, the custom name should be used mcp_tools = manager.list_mcp_tools() assert len(mcp_tools) == 1 assert mcp_tools[0].name == "api_function" + def test_tool_to_mcp_tool_with_custom_key(self): + """Test that to_mcp_tool uses the key, not the internal name.""" + + def some_function(x: int) -> int: + return x + + manager = ToolManager() + tool = Tool.from_function(some_function, name="api_function") + manager.add_tool(tool, key="custom-key") + + # When listing tools for MCP, the key should be used + mcp_tools = manager.list_mcp_tools() + assert len(mcp_tools) == 1 + assert mcp_tools[0].name == "custom-key" + def test_import_tools_with_custom_names(self): """Test importing tools with custom names.""" @@ -675,7 +690,7 @@ class TestCustomToolNames: assert stored_tool.fn.__name__ == "replacement_fn" def test_mcp_tool_name_for_add_tool(self): - """Test MCPTool name for add_tool (storage name != tool.name).""" + """Test MCPTool name for add_tool (key != tool.name).""" def fn(x: int) -> int: return x + 1 @@ -688,7 +703,7 @@ class TestCustomToolNames: assert mcp_tools[0].name == "proxy_tool" def test_mcp_tool_name_for_add_tool_from_fn(self): - """Test MCPTool name for add_tool_from_fn (storage name == tool.name).""" + """Test MCPTool name for add_tool_from_fn (key == tool.name).""" def fn(x: int) -> int: return x + 1 From 9fdcb8ac93a2f308afa8d0285e77981a123dc652 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 15 Apr 2025 10:04:41 -0400 Subject: [PATCH 4/6] Update matching logic for resources --- src/fastmcp/resources/resource_manager.py | 9 +- src/fastmcp/resources/template.py | 36 +++-- tests/resources/test_resource_manager.py | 161 ++++++++++++++++++++++ 3 files changed, 194 insertions(+), 12 deletions(-) diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py index 6b170ae77..1dc21a45e 100644 --- a/src/fastmcp/resources/resource_manager.py +++ b/src/fastmcp/resources/resource_manager.py @@ -8,7 +8,7 @@ from pydantic import AnyUrl from fastmcp.exceptions import ResourceError from fastmcp.resources import FunctionResource, Resource -from fastmcp.resources.template import ResourceTemplate +from fastmcp.resources.template import ResourceTemplate, match_uri_template from fastmcp.settings import DuplicateBehavior from fastmcp.utilities.logging import get_logger @@ -207,9 +207,10 @@ class ResourceManager: if resource := self._resources.get(uri_str): return resource - # Then check templates - for template in self._templates.values(): - if params := template.matches(uri_str): + # Then check templates - use the utility function to match against storage keys + for storage_key, template in self._templates.items(): + # Try to match against the storage key (which might be a custom key) + if params := match_uri_template(uri_str, storage_key): try: return await template.create_resource(uri_str, params) except Exception as e: diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index aadae3260..7d05aa057 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -7,12 +7,37 @@ import re from collections.abc import Callable from typing import Annotated, Any -from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call +from pydantic import ( + AnyUrl, + BaseModel, + BeforeValidator, + Field, + TypeAdapter, + validate_call, +) from fastmcp.resources.types import FunctionResource, Resource from fastmcp.utilities.types import _convert_set_defaults +def match_uri_template(uri: str, uri_template: str) -> dict[str, Any] | None: + """Match a URI against a template and extract parameters. + + Args: + uri: The URI to match against the template + uri_template: The URI template to match against + + Returns: + A dictionary of extracted parameters if there's a match, or None if no match + """ + # Convert template to regex pattern + pattern = uri_template.replace("{", "(?P<").replace("}", ">[^/]+)") + match = re.match(f"^{pattern}$", uri) + if match: + return match.groupdict() + return None + + class MyModel(BaseModel): key: str value: int @@ -94,12 +119,7 @@ class ResourceTemplate(BaseModel): def matches(self, uri: str) -> dict[str, Any] | None: """Check if URI matches template and extract parameters.""" - # Convert template to regex pattern - pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)") - match = re.match(f"^{pattern}$", uri) - if match: - return match.groupdict() - return None + return match_uri_template(uri, self.uri_template) async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: """Create a resource from the template with the given parameters.""" @@ -110,7 +130,7 @@ class ResourceTemplate(BaseModel): result = await result return FunctionResource( - uri=uri, # type: ignore + uri=AnyUrl(uri), # Explicitly convert to AnyUrl name=self.name, description=self.description, mime_type=self.mime_type, diff --git a/tests/resources/test_resource_manager.py b/tests/resources/test_resource_manager.py index ede30eec4..28e0b3903 100644 --- a/tests/resources/test_resource_manager.py +++ b/tests/resources/test_resource_manager.py @@ -688,3 +688,164 @@ class TestImports: # Verify both resource types were imported with prefixes assert "test+data://resource" in target_manager._resources assert "test+data://template/{id}" in target_manager._templates + + +class TestCustomResourceKeys: + """Test adding resources and templates with custom keys.""" + + def test_add_resource_with_custom_key(self, temp_file: Path): + """Test adding a resource with a custom key different from its URI.""" + manager = ResourceManager() + original_uri = f"file://{temp_file}" + custom_key = "custom://resource/key" + + resource = FileResource( + uri=FileUrl(original_uri), + name="test_resource", + path=temp_file, + ) + + manager.add_resource(resource, key=custom_key) + + # Resource should be accessible via custom key + assert custom_key in manager._resources + # But not via its original URI + assert original_uri not in manager._resources + # The resource's internal URI remains unchanged + assert str(manager._resources[custom_key].uri) == original_uri + + def test_add_template_with_custom_key(self): + """Test adding a template with a custom key different from its URI template.""" + manager = ResourceManager() + + def template_fn(id: str) -> str: + return f"Template {id}" + + original_uri_template = "test://{id}" + custom_key = "custom://{id}/template" + + template = ResourceTemplate.from_function( + fn=template_fn, + uri_template=original_uri_template, + name="test_template", + ) + + manager.add_template(template, key=custom_key) + + # Template should be accessible via custom key + assert custom_key in manager._templates + # But not via its original URI template + assert original_uri_template not in manager._templates + # The template's internal URI template remains unchanged + assert str(manager._templates[custom_key].uri_template) == original_uri_template + + @pytest.mark.anyio + async def test_get_resource_with_custom_key(self, temp_file: Path): + """Test that get_resource works with resources added with custom keys.""" + manager = ResourceManager() + original_uri = f"file://{temp_file}" + custom_key = "custom://resource/path" + + resource = FileResource( + uri=FileUrl(original_uri), + name="test_resource", + path=temp_file, + ) + + manager.add_resource(resource, key=custom_key) + + # Should be retrievable by the custom key + retrieved = await manager.get_resource(custom_key) + assert retrieved is not None + assert str(retrieved.uri) == original_uri + + # Should NOT be retrievable by the original URI + with pytest.raises(ResourceError, match="Unknown resource"): + await manager.get_resource(original_uri) + + @pytest.mark.anyio + async def test_get_resource_from_template_with_custom_key(self): + """Test that templates with custom keys can create resources.""" + manager = ResourceManager() + + def greet(name: str) -> str: + return f"Hello, {name}!" + + original_template = "greet://{name}" + custom_key = "custom://greet/{name}" + + template = ResourceTemplate.from_function( + fn=greet, + uri_template=original_template, + name="custom_greeter", + ) + + manager.add_template(template, key=custom_key) + + # Using a URI that matches the custom key pattern + resource = await manager.get_resource("custom://greet/world") + assert isinstance(resource, FunctionResource) + content = await resource.read() + assert content == "Hello, world!" + + # Shouldn't work with the original template pattern + with pytest.raises(ResourceError, match="Unknown resource"): + await manager.get_resource("greet://world") + + def test_import_resources_with_custom_keys(self): + """Test that import_resources properly uses custom keys.""" + source_manager = ResourceManager() + target_manager = ResourceManager() + + # Add a resource to source manager + async def resource_fn(): + return "Resource data" + + original_uri = "data://original" + resource = FunctionResource( + uri=AnyUrl(original_uri), + name="original_resource", + fn=resource_fn, + ) + source_manager.add_resource(resource) + + # Import with prefix which creates a new key + prefix = "imported+" + target_manager.import_resources(source_manager, prefix) + + # Resource should be in target manager with prefixed URI as key + prefixed_uri = f"{prefix}{original_uri}" + assert prefixed_uri in target_manager._resources + + # The resource's internal URI should remain unchanged + stored_resource = target_manager._resources[prefixed_uri] + assert str(stored_resource.uri) == original_uri + + def test_import_templates_with_custom_keys(self): + """Test that import_templates properly uses custom keys.""" + source_manager = ResourceManager() + target_manager = ResourceManager() + + # Add a template to source manager + async def template_fn(id: str): + return f"Template {id}" + + original_template = "template://{id}" + template = ResourceTemplate.from_function( + fn=template_fn, + uri_template=original_template, + name="original_template", + ) + source_manager.add_template(template) + + # Import with prefix which creates a new key + prefix = "imported+" + target_manager.import_templates(source_manager, prefix) + + # Template should be in target manager with prefixed URI template as key + prefixed_template = f"{prefix}{original_template}" + assert prefixed_template in target_manager._templates + + # The template's internal URI template should remain unchanged + stored_template = target_manager._templates[prefixed_template] + assert str(stored_template.uri_template) == original_template From ccff9f4bd2c839321eceef89310681c02a6374e0 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 15 Apr 2025 10:10:11 -0400 Subject: [PATCH 5/6] update docs --- docs/servers/resources.mdx | 46 +++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/docs/servers/resources.mdx b/docs/servers/resources.mdx index 33fe02bf5..078e47385 100644 --- a/docs/servers/resources.mdx +++ b/docs/servers/resources.mdx @@ -149,7 +149,15 @@ notice_resource = TextResource( ) mcp.add_resource(notice_resource) -# 3. Exposing a directory listing +# 3. Using a custom key different from the URI +special_resource = TextResource( + uri="resource://common-notice", + name="Special Notice", + text="This is a special notice with a custom storage key.", +) +mcp.add_resource(special_resource, key="resource://custom-key") + +# 4. Exposing a directory listing data_dir_path = Path("./app_data").resolve() if data_dir_path.is_dir(): data_listing_resource = DirectoryResource( @@ -173,6 +181,22 @@ if data_dir_path.is_dir(): Use these when the content is static or sourced directly from a file/URL, bypassing the need for a dedicated Python function. +#### Custom Resource Keys + +When adding resources directly with `mcp.add_resource()`, you can optionally provide a custom storage key: + +```python +# Creating a resource with standard URI as the key +resource = TextResource(uri="resource://data") +mcp.add_resource(resource) # Will be stored and accessed using "resource://data" + +# Creating a resource with a custom key +special_resource = TextResource(uri="resource://special-data") +mcp.add_resource(special_resource, key="internal://data-v2") # Will be stored and accessed using "internal://data-v2" +``` + +Note that this parameter is only available when using `add_resource()` directly and not through the `@resource` decorator, as URIs are provided explicitly when using the decorator. + ## Defining Resource Templates Resource Templates allow clients to request resources whose content depends on parameters embedded in the URI. Define a template using the **same `@mcp.resource` decorator**, but include `{parameter_name}` placeholders in the URI string and add corresponding arguments to your function signature. @@ -289,6 +313,26 @@ In this stacked decorator pattern: Templates provide a powerful way to expose parameterized data access points following REST-like principles. +### Custom Template Keys + +Similar to resources, you can provide custom keys when directly adding templates: + +```python +from fastmcp.resources import ResourceTemplate + +# Create a template with a function +template = ResourceTemplate.from_function( + my_function, + uri_template="data://{id}/details", + name="Data Details" +) + +# Register with a custom key +mcp._resource_manager.add_template(template, key="custom://{id}/view") +``` + +This allows accessing the same template implementation through different URI patterns. + ## Server Behavior ### Duplicate Resources From faa8944cfd203ce8b6ea588b872edff865a40687 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 15 Apr 2025 10:11:06 -0400 Subject: [PATCH 6/6] Fix tests --- tests/resources/test_resource_manager.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/tests/resources/test_resource_manager.py b/tests/resources/test_resource_manager.py index 28e0b3903..34f36ff14 100644 --- a/tests/resources/test_resource_manager.py +++ b/tests/resources/test_resource_manager.py @@ -696,13 +696,17 @@ class TestCustomResourceKeys: def test_add_resource_with_custom_key(self, temp_file: Path): """Test adding a resource with a custom key different from its URI.""" manager = ResourceManager() - original_uri = f"file://{temp_file}" + original_uri = "data://test/resource" custom_key = "custom://resource/key" - resource = FileResource( - uri=FileUrl(original_uri), + # Create a function resource instead of file resource to avoid path issues + async def get_data(): + return "Test data" + + resource = FunctionResource( + uri=AnyUrl(original_uri), name="test_resource", - path=temp_file, + fn=get_data, ) manager.add_resource(resource, key=custom_key) @@ -743,13 +747,17 @@ class TestCustomResourceKeys: async def test_get_resource_with_custom_key(self, temp_file: Path): """Test that get_resource works with resources added with custom keys.""" manager = ResourceManager() - original_uri = f"file://{temp_file}" + original_uri = "data://test/resource" custom_key = "custom://resource/path" - resource = FileResource( - uri=FileUrl(original_uri), + # Create a function resource instead of file resource to avoid path issues + async def get_data(): + return "Test data" + + resource = FunctionResource( + uri=AnyUrl(original_uri), name="test_resource", - path=temp_file, + fn=get_data, ) manager.add_resource(resource, key=custom_key)