From a2bb2314c72c4ff985aa52d432a3c06764b7018f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 18 Jun 2025 21:05:11 -0400 Subject: [PATCH] Add key to component --- src/fastmcp/resources/resource.py | 10 +++++ src/fastmcp/resources/template.py | 10 +++++ src/fastmcp/server/server.py | 68 ++++++++++++++++------------- src/fastmcp/utilities/components.py | 24 +++++++++- test_revert_check.py | 50 --------------------- tests/server/test_mount.py | 4 +- 6 files changed, 81 insertions(+), 85 deletions(-) delete mode 100644 test_revert_check.py diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index 8e7ce307f..b8174fd2a 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -101,6 +101,16 @@ class Resource(FastMCPComponent, abc.ABC): def __repr__(self) -> str: return f"{self.__class__.__name__}(uri={self.uri!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})" + @property + def key(self) -> str: + """ + The key of the component. This is used for internal bookkeeping + and may reflect e.g. prefixes or other identifiers. You should not depend on + keys having a certain value, as the same tool loaded from different + hierarchies of servers may have different keys. + """ + return self._key or str(self.uri) + class FunctionResource(Resource): """A resource that defers data loading by wrapping a function. diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index acbb98eeb..728a12764 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -128,6 +128,16 @@ class ResourceTemplate(FastMCPComponent): } return MCPResourceTemplate(**kwargs | overrides) + @property + def key(self) -> str: + """ + The key of the component. This is used for internal bookkeeping + and may reflect e.g. prefixes or other identifiers. You should not depend on + keys having a certain value, as the same tool loaded from different + hierarchies of servers may have different keys. + """ + return self._key or self.uri_template + class FunctionResourceTemplate(ResourceTemplate): """A template for dynamically creating resources.""" diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 47bd001eb..d289a50ac 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -330,11 +330,11 @@ class FastMCP(Generic[LifespanResultT]): server_tools = await mounted_server.server.get_tools() # Apply prefix to each tool key if prefix exists and is not empty if mounted_server.prefix: - server_tools = { - f"{mounted_server.prefix}_{key}": tool - for key, tool in server_tools.items() - } - tools.update(server_tools) + for tool in server_tools.values(): + tool = tool.with_key(f"{mounted_server.prefix}_{tool.key}") + tools[tool.key] = tool + else: + tools.update(server_tools) except Exception as e: logger.warning( f"Failed to get tools from mounted server '{mounted_server.prefix}': {e}" @@ -361,15 +361,17 @@ class FastMCP(Generic[LifespanResultT]): server_resources = await mounted_server.server.get_resources() # Apply prefix to each resource key if prefix exists if mounted_server.prefix: - server_resources = { - add_resource_prefix( - key, - mounted_server.prefix, - mounted_server.server.resource_prefix_format, - ): resource - for key, resource in server_resources.items() - } - resources.update(server_resources) + for resource in server_resources.values(): + resource = resource.with_key( + add_resource_prefix( + resource.key, + mounted_server.prefix, + self.resource_prefix_format, + ) + ) + resources[resource.key] = resource + else: + resources.update(server_resources) except Exception as e: logger.warning( f"Failed to get resources from mounted server '{mounted_server.prefix}': {e}" @@ -400,15 +402,17 @@ class FastMCP(Generic[LifespanResultT]): ) # Apply prefix to each template key if prefix exists if mounted_server.prefix: - server_templates = { - add_resource_prefix( - key, - mounted_server.prefix, - mounted_server.server.resource_prefix_format, - ): template - for key, template in server_templates.items() - } - templates.update(server_templates) + for template in server_templates.values(): + template = template.with_key( + add_resource_prefix( + template.key, + mounted_server.prefix, + self.resource_prefix_format, + ) + ) + templates[template.key] = template + else: + templates.update(server_templates) except Exception as e: logger.warning( "Failed to get resource templates from mounted server " @@ -429,6 +433,7 @@ class FastMCP(Generic[LifespanResultT]): """ List all available prompts. """ + if (prompts := self._cache.get("prompts")) is self._cache.NOT_FOUND: prompts: dict[str, Prompt] = {} @@ -438,11 +443,13 @@ class FastMCP(Generic[LifespanResultT]): server_prompts = await mounted_server.server.get_prompts() # Apply prefix to each prompt key if prefix exists if mounted_server.prefix: - server_prompts = { - f"{mounted_server.prefix}_{key}": prompt - for key, prompt in server_prompts.items() - } - prompts.update(server_prompts) + for prompt in server_prompts.values(): + prompt = prompt.with_key( + f"{mounted_server.prefix}_{prompt.key}" + ) + prompts[prompt.key] = prompt + else: + prompts.update(server_prompts) except Exception as e: logger.warning( f"Failed to get prompts from mounted server '{mounted_server.prefix}': {e}" @@ -524,6 +531,7 @@ class FastMCP(Generic[LifespanResultT]): """ resources = await self.get_resources() + mcp_resources: list[MCPResource] = [] for key, resource in resources.items(): if self._should_enable_component(resource): @@ -666,12 +674,12 @@ class FastMCP(Generic[LifespanResultT]): if has_resource_prefix( str(resource_uri), mounted_server.prefix, - mounted_server.server.resource_prefix_format, + self.resource_prefix_format, ): resource_uri = remove_resource_prefix( str(resource_uri), mounted_server.prefix, - mounted_server.server.resource_prefix_format, + self.resource_prefix_format, ) else: continue diff --git a/src/fastmcp/utilities/components.py b/src/fastmcp/utilities/components.py index 1c7eb6c06..2c3bbb2e9 100644 --- a/src/fastmcp/utilities/components.py +++ b/src/fastmcp/utilities/components.py @@ -1,7 +1,8 @@ from collections.abc import Sequence -from typing import Annotated, TypeVar +from typing import Annotated, Any, TypeVar -from pydantic import BeforeValidator, Field +from pydantic import BeforeValidator, Field, PrivateAttr +from typing_extensions import Self from fastmcp.utilities.types import FastMCPBaseModel @@ -37,6 +38,25 @@ class FastMCPComponent(FastMCPBaseModel): description="Whether the component is enabled.", ) + _key: str | None = PrivateAttr() + + def __init__(self, *, key: str | None = None, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._key = key + + @property + def key(self) -> str: + """ + The key of the component. This is used for internal bookkeeping + and may reflect e.g. prefixes or other identifiers. You should not depend on + keys having a certain value, as the same tool loaded from different + hierarchies of servers may have different keys. + """ + return self._key or self.name + + def with_key(self, key: str) -> Self: + return self.model_copy(update={"_key": key}) + def __eq__(self, other: object) -> bool: if type(self) is not type(other): return False diff --git a/test_revert_check.py b/test_revert_check.py deleted file mode 100644 index a9e360e1e..000000000 --- a/test_revert_check.py +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env python3 -"""Quick test to verify the revert worked correctly.""" - -import asyncio - -from fastmcp import FastMCP -from fastmcp.client import Client - - -async def test_empty_prefix_behavior(): - """Test that empty prefix correctly adds underscore.""" - - main_app = FastMCP("MainApp") - sub_app = FastMCP("SubApp") - - @sub_app.tool - def sub_tool() -> str: - return "This is from the sub app" - - @sub_app.resource("data://test") - def sub_resource(): - return "Resource data" - - # Mount with empty prefix - main_app.mount("", sub_app) - - # Check that tools have underscore prefix - tools = await main_app.get_tools() - print(f"Tools: {list(tools.keys())}") - assert "_sub_tool" in tools, f"Expected '_sub_tool' in {list(tools.keys())}" - - # Check that resources work correctly - resources = await main_app.get_resources() - print(f"Resources: {list(resources.keys())}") - # Empty prefix for resources should result in no prefix change - assert "data://test" in resources, ( - f"Expected 'data://test' in {list(resources.keys())}" - ) - - # Test calling the tool - async with Client(main_app) as client: - result = await client.call_tool("_sub_tool", {}) - print(f"Tool result: {result[0].text}") - assert "This is from the sub app" in result[0].text - - print("✅ Empty prefix correctly adds underscore for tools!") - - -if __name__ == "__main__": - asyncio.run(test_empty_prefix_behavior()) diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index 6dfb6af05..d32d83c45 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -272,9 +272,7 @@ class TestMultipleServerMount: main_app.mount(working_app, "working") # Use an unreachable port - unreachable_client = Client( - transport=SSETransport("http://127.0.0.1:99999/sse") - ) + unreachable_client = Client(transport=SSETransport("http://127.0.0.1:9999/sse")) # Create a proxy server that will fail to connect unreachable_proxy = FastMCP.as_proxy(unreachable_client)