From 35e13ef9bfa7a97036f8347f36b380dc4e20c35d Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 19 Jun 2025 12:22:14 -0400 Subject: [PATCH] Update resource manager --- src/fastmcp/prompts/prompt_manager.py | 10 +- src/fastmcp/resources/resource_manager.py | 257 ++++++++++++++++++--- src/fastmcp/server/server.py | 168 +++----------- src/fastmcp/tools/tool_manager.py | 10 +- tests/resources/test_resource_manager.py | 8 +- tests/server/middleware/test_middleware.py | 56 +++++ tests/server/openapi/test_openapi.py | 42 ++-- tests/server/test_server.py | 2 +- tests/server/test_tool_annotations.py | 6 +- tests/server/test_tool_exclude_args.py | 4 +- 10 files changed, 349 insertions(+), 214 deletions(-) diff --git a/src/fastmcp/prompts/prompt_manager.py b/src/fastmcp/prompts/prompt_manager.py index bde804a3d..ee1e6f3c9 100644 --- a/src/fastmcp/prompts/prompt_manager.py +++ b/src/fastmcp/prompts/prompt_manager.py @@ -65,14 +65,10 @@ class PromptManager: child_results = await mounted.server._list_prompts() else: # mode == "inventory" # PATH 1: Use the manager-to-manager unfiltered path - child_results = await mounted.server._prompt_manager.get_prompts() + child_results = await mounted.server._prompt_manager._list_prompts() # The combination logic is the same for both paths - child_dict = ( - {p.key: p for p in child_results} - if isinstance(child_results, list) - else child_results - ) + child_dict = {p.key: p for p in child_results} if mounted.prefix: for prompt in child_dict.values(): prefixed_prompt = prompt.with_key( @@ -110,7 +106,7 @@ class PromptManager: """ return await self._load_prompts(mode="inventory") - async def list_prompts(self) -> list[Prompt]: + async def _list_prompts(self) -> list[Prompt]: """ Lists all prompts, applying protocol filtering. """ diff --git a/src/fastmcp/resources/resource_manager.py b/src/fastmcp/resources/resource_manager.py index 64a11aae1..8b307456e 100644 --- a/src/fastmcp/resources/resource_manager.py +++ b/src/fastmcp/resources/resource_manager.py @@ -1,9 +1,11 @@ """Resource manager functionality.""" +from __future__ import annotations + import inspect import warnings from collections.abc import Callable -from typing import Any +from typing import TYPE_CHECKING, Any, Literal from pydantic import AnyUrl @@ -17,6 +19,9 @@ from fastmcp.resources.template import ( from fastmcp.settings import DuplicateBehavior from fastmcp.utilities.logging import get_logger +if TYPE_CHECKING: + from fastmcp.server.server import MountedServer + logger = get_logger(__name__) @@ -38,6 +43,7 @@ class ResourceManager: """ self._resources: dict[str, Resource] = {} self._templates: dict[str, ResourceTemplate] = {} + self._mounted_sources: list[MountedServer] = [] self.mask_error_details = mask_error_details or settings.mask_error_details # Default to "warn" if None is provided @@ -51,6 +57,128 @@ class ResourceManager: ) self.duplicate_behavior = duplicate_behavior + def mount(self, server: MountedServer) -> None: + """Adds a mounted server as a source for resources and templates.""" + self._mounted_sources.append(server) + + async def get_resources(self) -> dict[str, Resource]: + """Get all registered resources, keyed by URI.""" + return await self._load_resources(mode="inventory") + + async def get_resource_templates(self) -> dict[str, ResourceTemplate]: + """Get all registered templates, keyed by URI template.""" + return await self._load_resource_templates(mode="inventory") + + async def _load_resources( + self, *, mode: Literal["inventory", "protocol"] + ) -> dict[str, Resource]: + """ + The single, consolidated recursive method for fetching resources. The 'mode' + parameter determines the communication path. + + - mode="inventory": Manager-to-manager path for complete, unfiltered inventory + - mode="protocol": Server-to-server path for filtered MCP requests + """ + all_resources: dict[str, Resource] = {} + + for mounted in self._mounted_sources: + try: + if mode == "protocol": + # PATH 2: Use the server-to-server filtered path + child_resources_list = await mounted.server._list_resources() + child_resources = { + resource.key: resource for resource in child_resources_list + } + else: # mode == "inventory" + # PATH 1: Use the manager-to-manager unfiltered path + child_resources = ( + await mounted.server._resource_manager.get_resources() + ) + + # Apply prefix if needed + if mounted.prefix: + from fastmcp.server.server import add_resource_prefix + + for uri, resource in child_resources.items(): + prefixed_uri = add_resource_prefix( + uri, mounted.prefix, mounted.resource_prefix_format + ) + # Create a copy of the resource with the prefixed key + prefixed_resource = resource.with_key(prefixed_uri) + all_resources[prefixed_uri] = prefixed_resource + else: + all_resources.update(child_resources) + except Exception as e: + # Skip failed mounts silently, matches existing behavior + logger.warning( + f"Failed to get resources from mounted server '{mounted.prefix}': {e}" + ) + continue + + # Finally, add local resources, which always take precedence + all_resources.update(self._resources) + return all_resources + + async def _load_resource_templates( + self, *, mode: Literal["inventory", "protocol"] + ) -> dict[str, ResourceTemplate]: + """ + The single, consolidated recursive method for fetching templates. The 'mode' + parameter determines the communication path. + + - mode="inventory": Manager-to-manager path for complete, unfiltered inventory + - mode="protocol": Server-to-server path for filtered MCP requests + """ + all_templates: dict[str, ResourceTemplate] = {} + + for mounted in self._mounted_sources: + try: + if mode == "protocol": + # PATH 2: Use the server-to-server filtered path + child_templates = await mounted.server._list_resource_templates() + else: # mode == "inventory" + # PATH 1: Use the manager-to-manager unfiltered path + child_templates = await mounted.server._resource_manager._list_resource_templates() + child_dict = {template.key: template for template in child_templates} + + # Apply prefix if needed + if mounted.prefix: + from fastmcp.server.server import add_resource_prefix + + for uri_template, template in child_dict.items(): + prefixed_uri_template = add_resource_prefix( + uri_template, mounted.prefix, mounted.resource_prefix_format + ) + # Create a copy of the template with the prefixed key + prefixed_template = template.with_key(prefixed_uri_template) + all_templates[prefixed_uri_template] = prefixed_template + else: + all_templates.update(child_dict) + except Exception as e: + # Skip failed mounts silently, matches existing behavior + logger.warning( + f"Failed to get templates from mounted server '{mounted.prefix}': {e}" + ) + continue + + # Finally, add local templates, which always take precedence + all_templates.update(self._templates) + return all_templates + + async def _list_resources(self) -> list[Resource]: + """ + Lists all resources, applying protocol filtering. + """ + resources_dict = await self._load_resources(mode="protocol") + return list(resources_dict.values()) + + async def _list_resource_templates(self) -> list[ResourceTemplate]: + """ + Lists all templates, applying protocol filtering. + """ + templates_dict = await self._load_resource_templates(mode="protocol") + return list(templates_dict.values()) + def add_resource_or_template_from_fn( self, fn: Callable[..., Any], @@ -235,14 +363,21 @@ class ResourceManager: self._templates[storage_key] = template return template - def has_resource(self, uri: AnyUrl | str) -> bool: + async def has_resource(self, uri: AnyUrl | str) -> bool: """Check if a resource exists.""" uri_str = str(uri) - if uri_str in self._resources: + + # First check concrete resources (local and mounted) + resources = await self.get_resources() + if uri_str in resources: return True - for template_key in self._templates.keys(): + + # Then check templates (local and mounted) only if not found in concrete resources + templates = await self.get_resource_templates() + for template_key in templates.keys(): if match_uri_template(uri_str, template_key): return True + return False async def get_resource(self, uri: AnyUrl | str) -> Resource: @@ -257,12 +392,14 @@ class ResourceManager: uri_str = str(uri) logger.debug("Getting resource", extra={"uri": uri_str}) - # First check concrete resources - if resource := self._resources.get(uri_str): + # First check concrete resources (local and mounted) + resources = await self.get_resources() + if resource := resources.get(uri_str): return resource - # Then check templates - use the utility function to match against storage keys - for storage_key, template in self._templates.items(): + # Then check templates (local and mounted) - use the utility function to match against storage keys + templates = await self.get_resource_templates() + for storage_key, template in 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: @@ -289,31 +426,89 @@ class ResourceManager: raise NotFoundError(f"Unknown resource: {uri_str}") async def read_resource(self, uri: AnyUrl | str) -> str | bytes: - """Read a resource contents.""" - resource = await self.get_resource(uri) + """ + Internal API for servers: Finds and reads a resource, respecting the + filtered protocol path. + """ + uri_str = str(uri) - try: - return await resource.read() + # 1. Check local resources first. The server will have already applied its filter. + if uri_str in self._resources: + resource = await self.get_resource(uri_str) + if not resource: + raise NotFoundError(f"Resource {uri_str!r} not found") - # raise ResourceErrors as-is - except ResourceError as e: - logger.error(f"Error reading resource {uri!r}: {e}") - raise e + try: + return await resource.read() - # Handle other exceptions - except Exception as e: - logger.error(f"Error reading resource {uri!r}: {e}") - if self.mask_error_details: - # Mask internal details - raise ResourceError(f"Error reading resource {uri!r}") from e - else: - # Include original error details - raise ResourceError(f"Error reading resource {uri!r}: {e}") from e + # raise ResourceErrors as-is + except ResourceError as e: + logger.exception(f"Error reading resource {uri_str!r}: {e}") + raise e - def get_resources(self) -> dict[str, Resource]: - """Get all registered resources, keyed by URI.""" - return self._resources + # Handle other exceptions + except Exception as e: + logger.exception(f"Error reading resource {uri_str!r}: {e}") + if self.mask_error_details: + # Mask internal details + raise ResourceError(f"Error reading resource {uri_str!r}") from e + else: + # Include original error details + raise ResourceError( + f"Error reading resource {uri_str!r}: {e}" + ) from e - def get_templates(self) -> dict[str, ResourceTemplate]: - """Get all registered templates, keyed by URI template.""" - return self._templates + # 1b. Check local templates if not found in concrete resources + for template in self._templates.values(): + if params := match_uri_template(uri_str, template.uri_template): + try: + resource = await template.create_resource(uri_str, params=params) + return await resource.read() + except ResourceError as e: + logger.exception( + f"Error reading resource from template {uri_str!r}: {e}" + ) + raise e + except Exception as e: + logger.exception( + f"Error reading resource from template {uri_str!r}: {e}" + ) + if self.mask_error_details: + raise ResourceError( + f"Error reading resource from template {uri_str!r}" + ) from e + else: + raise ResourceError( + f"Error reading resource from template {uri_str!r}: {e}" + ) from e + + # 2. Check mounted servers using the filtered protocol path. + from fastmcp.server.server import has_resource_prefix, remove_resource_prefix + + for mounted in reversed(self._mounted_sources): + resource_uri = uri_str + try: + if mounted.prefix: + # If server has a prefix, check if URI matches and strip prefix + if has_resource_prefix( + resource_uri, + mounted.prefix, + mounted.resource_prefix_format, + ): + resource_uri = remove_resource_prefix( + resource_uri, + mounted.prefix, + mounted.resource_prefix_format, + ) + else: + continue + + result = await mounted.server._read_resource(resource_uri) + # Extract content from the first ReadResourceContents + if result and len(result) > 0: + return result[0].content + raise NotFoundError(f"Resource {uri_str!r} returned empty content") + except NotFoundError: + continue + + raise NotFoundError(f"Resource {uri_str!r} not found.") diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 38b5b668b..456572a8b 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -351,8 +351,7 @@ class FastMCP(Generic[LifespanResultT]): async def get_resources(self) -> dict[str, Resource]: """Get all registered resources, indexed by registered key.""" - resources = await self._list_resources(apply_middleware=False) - return {resource.key: resource for resource in resources} + return await self._resource_manager.get_resources() async def get_resource(self, key: str) -> Resource: resources = await self.get_resources() @@ -362,8 +361,7 @@ class FastMCP(Generic[LifespanResultT]): async def get_resource_templates(self) -> dict[str, ResourceTemplate]: """Get all registered resource templates, indexed by registered key.""" - templates = await self._list_resource_templates(apply_middleware=False) - return {template.key: template for template in templates} + return await self._resource_manager.get_resource_templates() async def get_resource_template(self, key: str) -> ResourceTemplate: templates = await self.get_resource_templates() @@ -444,7 +442,7 @@ class FastMCP(Generic[LifespanResultT]): async def _handler( context: MiddlewareContext[mcp.types.ListToolsRequest], ) -> list[Tool]: - tools = await self._tool_manager.list_tools() + tools = await self._tool_manager._list_tools() # type: ignore[reportPrivateUsage] mcp_tools: list[Tool] = [] for tool in tools: @@ -470,12 +468,12 @@ class FastMCP(Generic[LifespanResultT]): logger.debug("Handler called: list_resources") with fastmcp.server.context.Context(fastmcp=self): - resources = await self._middleware_list_resources() + resources = await self._list_resources() return [ resource.to_mcp_resource(uri=resource.key) for resource in resources ] - async def _middleware_list_resources(self) -> list[Resource]: + async def _list_resources(self) -> list[Resource]: """ List all available resources, in the format expected by the low-level MCP server. @@ -485,7 +483,7 @@ class FastMCP(Generic[LifespanResultT]): async def _handler( context: MiddlewareContext[dict[str, Any]], ) -> list[Resource]: - resources = await self._list_resources() + resources = await self._resource_manager._list_resources() # type: ignore[reportPrivateUsage] mcp_resources: list[Resource] = [] for resource in resources: @@ -507,62 +505,17 @@ class FastMCP(Generic[LifespanResultT]): # Apply the middleware chain. return await self._apply_middleware(mw_context, _handler) - async def _list_resources(self, apply_middleware: bool = True) -> list[Resource]: - """ - List all available resources. - """ - - if (resources := self._cache.get("resources")) is self._cache.NOT_FOUND: - resources: dict[str, Resource] = {} - - # iterate such that new mounts overwrite older ones - for mounted_server in self._mounted_servers: - try: - if apply_middleware: - server_resources = ( - await mounted_server.server._middleware_list_resources() - ) - else: - server_resources = await mounted_server.server._list_resources() - # Apply prefix to each resource key if prefix exists - if mounted_server.prefix: - for resource in server_resources: - resource = resource.with_key( - add_resource_prefix( - resource.key, - mounted_server.prefix, - self.resource_prefix_format, - ) - ) - resources[resource.key] = resource - else: - resources.update( - {resource.key: resource for resource in server_resources} - ) - except Exception as e: - logger.warning( - f"Failed to get resources from mounted server '{mounted_server.prefix}': {e}" - ) - continue - ( - local_resources, - _, - ) = await self._resource_manager.get_resources_and_templates() - resources.update(local_resources) - self._cache.set("resources", resources) - return list(resources.values()) - async def _mcp_list_resource_templates(self) -> list[MCPResourceTemplate]: logger.debug("Handler called: list_resource_templates") with fastmcp.server.context.Context(fastmcp=self): - templates = await self._middleware_list_resource_templates() + templates = await self._list_resource_templates() return [ template.to_mcp_template(uriTemplate=template.key) for template in templates ] - async def _middleware_list_resource_templates(self) -> list[ResourceTemplate]: + async def _list_resource_templates(self) -> list[ResourceTemplate]: """ List all available resource templates, in the format expected by the low-level MCP server. @@ -572,7 +525,7 @@ class FastMCP(Generic[LifespanResultT]): async def _handler( context: MiddlewareContext[dict[str, Any]], ) -> list[ResourceTemplate]: - templates = await self._list_resource_templates() + templates = await self._resource_manager._list_resource_templates() mcp_templates: list[ResourceTemplate] = [] for template in templates: @@ -594,56 +547,6 @@ class FastMCP(Generic[LifespanResultT]): # Apply the middleware chain. return await self._apply_middleware(mw_context, _handler) - async def _list_resource_templates( - self, apply_middleware: bool = True - ) -> list[ResourceTemplate]: - """ - List all available resource templates. - """ - - if ( - templates := self._cache.get("resource_templates") - ) is self._cache.NOT_FOUND: - templates: dict[str, ResourceTemplate] = {} - - # iterate such that new mounts overwrite older ones - for mounted_server in self._mounted_servers: - try: - if apply_middleware: - server_templates = await mounted_server.server._middleware_list_resource_templates() - else: - server_templates = ( - await mounted_server.server._list_resource_templates() - ) - # Apply prefix to each template key if prefix exists - if mounted_server.prefix: - for template in server_templates: - template = template.with_key( - add_resource_prefix( - template.key, - mounted_server.prefix, - self.resource_prefix_format, - ) - ) - templates[template.key] = template - else: - templates.update( - {template.key: template for template in server_templates} - ) - except Exception as e: - logger.warning( - "Failed to get resource templates from mounted server " - f"'{mounted_server.prefix}': {e}" - ) - continue - ( - _, - local_templates, - ) = await self._resource_manager.get_resources_and_templates() - templates.update(local_templates) - self._cache.set("resource_templates", templates) - return list(templates.values()) - async def _mcp_list_prompts(self) -> list[MCPPrompt]: logger.debug("Handler called: list_prompts") @@ -661,7 +564,7 @@ class FastMCP(Generic[LifespanResultT]): async def _handler( context: MiddlewareContext[mcp.types.ListPromptsRequest], ) -> list[Prompt]: - prompts = await self._prompt_manager.list_prompts() + prompts = await self._prompt_manager._list_prompts() # type: ignore[reportPrivateUsage] mcp_prompts: list[Prompt] = [] for prompt in prompts: @@ -743,7 +646,7 @@ class FastMCP(Generic[LifespanResultT]): with fastmcp.server.context.Context(fastmcp=self): try: - return await self._middleware_read_resource(uri) + return await self._read_resource(uri) except DisabledError: # convert to NotFoundError to avoid leaking resource presence raise NotFoundError(f"Unknown resource: {str(uri)!r}") @@ -751,25 +654,28 @@ class FastMCP(Generic[LifespanResultT]): # standardize NotFound message raise NotFoundError(f"Unknown resource: {str(uri)!r}") - async def _middleware_read_resource( - self, - uri: AnyUrl | str, - ) -> list[ReadResourceContents]: + async def _read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]: """ - Read a resource with middleware. + Applies this server's middleware and delegates the filtered call to the manager. """ async def _handler( context: MiddlewareContext[mcp.types.ReadResourceRequestParams], ) -> list[ReadResourceContents]: - return await self._read_resource( - uri=context.message.uri, - ) + resource = await self._resource_manager.get_resource(context.message.uri) + if not self._should_enable_component(resource): + raise NotFoundError(f"Unknown resource: {str(context.message.uri)!r}") + + content = await self._resource_manager.read_resource(context.message.uri) + return [ + ReadResourceContents( + content=content, + mime_type=resource.mime_type, + ) + ] # Convert string URI to AnyUrl if needed if isinstance(uri, str): - from pydantic import AnyUrl - uri_param = AnyUrl(uri) else: uri_param = uri @@ -783,25 +689,6 @@ class FastMCP(Generic[LifespanResultT]): ) return await self._apply_middleware(mw_context, _handler) - async def _read_resource(self, uri: AnyUrl | str) -> list[ReadResourceContents]: - """ - Read a resource by URI, in the format expected by the low-level MCP - server. - """ - if await self._resource_manager.has_resource(uri): - resource = await self._resource_manager.get_resource(uri) - if not self._should_enable_component(resource): - raise DisabledError(f"Resource {str(uri)!r} is disabled") - content = await self._resource_manager.read_resource(uri) - return [ - ReadResourceContents( - content=content, - mime_type=resource.mime_type, - ) - ] - else: - raise NotFoundError(f"Unknown resource: {uri}") - async def _mcp_get_prompt( self, name: str, arguments: dict[str, Any] | None = None ) -> GetPromptResult: @@ -1681,7 +1568,11 @@ class FastMCP(Generic[LifespanResultT]): server = FastMCPProxy(Client(transport=FastMCPTransport(server))) # Delegate mounting to all three managers - mounted_server = MountedServer(prefix=prefix, server=server) + mounted_server = MountedServer( + prefix=prefix, + server=server, + resource_prefix_format=self.resource_prefix_format, + ) self._tool_manager.mount(mounted_server) self._resource_manager.mount(mounted_server) self._prompt_manager.mount(mounted_server) @@ -1979,6 +1870,7 @@ class FastMCP(Generic[LifespanResultT]): class MountedServer: prefix: str | None server: FastMCP[Any] + resource_prefix_format: Literal["protocol", "path"] | None = None def add_resource_prefix( diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py index 89696d642..d067f72d7 100644 --- a/src/fastmcp/tools/tool_manager.py +++ b/src/fastmcp/tools/tool_manager.py @@ -66,14 +66,10 @@ class ToolManager: child_results = await mounted.server._list_tools() else: # mode == "inventory" # PATH 1: Use the manager-to-manager unfiltered path - child_results = await mounted.server._tool_manager.get_tools() + child_results = await mounted.server._tool_manager._list_tools() # The combination logic is the same for both paths - child_dict = ( - {t.key: t for t in child_results} - if isinstance(child_results, list) - else child_results - ) + child_dict = {t.key: t for t in child_results} if mounted.prefix: for tool in child_dict.values(): prefixed_tool = tool.with_key(f"{mounted.prefix}_{tool.key}") @@ -109,7 +105,7 @@ class ToolManager: """ return await self._load_tools(mode="inventory") - async def list_tools(self) -> list[Tool]: + async def _list_tools(self) -> list[Tool]: """ Lists all tools, applying protocol filtering. """ diff --git a/tests/resources/test_resource_manager.py b/tests/resources/test_resource_manager.py index 26e45b6e2..a64fded09 100644 --- a/tests/resources/test_resource_manager.py +++ b/tests/resources/test_resource_manager.py @@ -180,7 +180,7 @@ class TestResourceManager: assert "Template already exists" in caplog.text # Should have the template - assert manager.get_templates() == {"test://{id}": template} + assert manager.get_resource_templates() == {"test://{id}": template} def test_error_on_duplicate_templates(self): """Test error on duplicate templates.""" @@ -226,7 +226,7 @@ class TestResourceManager: manager.add_template(template2) # Should have replaced with the new template - templates = list(manager.get_templates().values()) + templates = list(manager.get_resource_templates().values()) assert len(templates) == 1 assert templates[0].name == "replacement" @@ -256,7 +256,7 @@ class TestResourceManager: result = manager.add_template(template2) # Should keep the original - templates = list(manager.get_templates().values()) + templates = list(manager.get_resource_templates().values()) assert len(templates) == 1 assert templates[0].name == "original" # Result should be the original template @@ -379,7 +379,7 @@ class TestResourceTags: ) manager.add_template(template) - templates = list(manager.get_templates().values()) + templates = list(manager.get_resource_templates().values()) assert len(templates) == 1 assert templates[0].tags == {"users", "template", "data"} diff --git a/tests/server/middleware/test_middleware.py b/tests/server/middleware/test_middleware.py index 67f15138c..c789f42be 100644 --- a/tests/server/middleware/test_middleware.py +++ b/tests/server/middleware/test_middleware.py @@ -172,6 +172,18 @@ class TestMiddlewareHooks: assert recording_middleware.assert_called(hook="on_request", times=1) assert recording_middleware.assert_called(hook="on_read_resource", times=1) + async def test_read_resource_template( + self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware + ): + async with Client(mcp_server) as client: + await client.read_resource("resource://test-template/1") + + assert recording_middleware.assert_called(times=3) + assert recording_middleware.assert_called(method="resources/read", times=3) + assert recording_middleware.assert_called(hook="on_message", times=1) + assert recording_middleware.assert_called(hook="on_request", times=1) + assert recording_middleware.assert_called(hook="on_read_resource", times=1) + async def test_get_prompt( self, mcp_server: FastMCP, recording_middleware: RecordingMiddleware ): @@ -367,6 +379,50 @@ class TestNestedMiddlewareHooks: assert nested_middleware.assert_called(hook="on_request", times=1) assert nested_middleware.assert_called(hook="on_read_resource", times=1) + async def test_read_resource_template_on_parent_server( + self, + mcp_server: FastMCP, + nested_mcp_server: FastMCP, + recording_middleware: RecordingMiddleware, + nested_middleware: RecordingMiddleware, + ): + mcp_server.mount(nested_mcp_server, prefix="nested") + + async with Client(mcp_server) as client: + await client.read_resource("resource://test-template/1") + + assert recording_middleware.assert_called(times=3) + assert recording_middleware.assert_called(method="resources/read", times=3) + assert recording_middleware.assert_called(hook="on_message", times=1) + assert recording_middleware.assert_called(hook="on_request", times=1) + assert recording_middleware.assert_called(hook="on_read_resource", times=1) + + assert nested_middleware.assert_called(times=0) + + async def test_read_resource_template_on_nested_server( + self, + mcp_server: FastMCP, + nested_mcp_server: FastMCP, + recording_middleware: RecordingMiddleware, + nested_middleware: RecordingMiddleware, + ): + mcp_server.mount(nested_mcp_server, prefix="nested") + + async with Client(mcp_server) as client: + await client.read_resource("resource://nested/test-template/1") + + assert recording_middleware.assert_called(times=3) + assert recording_middleware.assert_called(method="resources/read", times=3) + assert recording_middleware.assert_called(hook="on_message", times=1) + assert recording_middleware.assert_called(hook="on_request", times=1) + assert recording_middleware.assert_called(hook="on_read_resource", times=1) + + assert nested_middleware.assert_called(times=3) + assert nested_middleware.assert_called(method="resources/read", times=3) + assert nested_middleware.assert_called(hook="on_message", times=1) + assert nested_middleware.assert_called(hook="on_request", times=1) + assert nested_middleware.assert_called(hook="on_read_resource", times=1) + async def test_get_prompt_on_parent_server( self, mcp_server: FastMCP, diff --git a/tests/server/openapi/test_openapi.py b/tests/server/openapi/test_openapi.py index c7a0b74f6..5fc6611ae 100644 --- a/tests/server/openapi/test_openapi.py +++ b/tests/server/openapi/test_openapi.py @@ -478,7 +478,7 @@ class TestTagTransfer: ): """Test that tags from OpenAPI routes are correctly transferred to Tools.""" # Get internal tools directly (not the public API which returns MCP.Content) - tools = fastmcp_openapi_server_with_all_types._tool_manager.list_tools() + tools = fastmcp_openapi_server_with_all_types._tool_manager._list_tools() # Find the create_user and update_user_name tools create_user_tool = next( @@ -528,7 +528,7 @@ class TestTagTransfer: """Test that tags from OpenAPI routes are correctly transferred to ResourceTemplates.""" # Get internal resource templates directly templates = list( - fastmcp_openapi_server_with_all_types._resource_manager.get_templates().values() + fastmcp_openapi_server_with_all_types._resource_manager.get_resource_templates().values() ) # Find the get_user template @@ -549,7 +549,7 @@ class TestTagTransfer: """Test that tags are preserved when creating resources from templates.""" # Get internal resource templates directly templates = list( - fastmcp_openapi_server_with_all_types._resource_manager.get_templates().values() + fastmcp_openapi_server_with_all_types._resource_manager.get_resource_templates().values() ) # Find the get_user template @@ -1537,11 +1537,11 @@ class TestFastAPIDescriptionPropagation: print(f" Resource: {name}, Name attribute: {resource.name}") print("\nDEBUG - Templates created:") - for name, template in server._resource_manager.get_templates().items(): + for name, template in server._resource_manager.get_resource_templates().items(): print(f" Template: {name}, Name attribute: {template.name}") print("\nDEBUG - Tools created:") - for tool in server._tool_manager.list_tools(): + for tool in server._tool_manager._list_tools(): print(f" Tool: {tool.name}") return server @@ -1759,7 +1759,7 @@ class TestReprMethods: self, fastmcp_openapi_server_with_all_types: FastMCPOpenAPI ): """Test that OpenAPITool's __repr__ method works without recursion errors.""" - tools = fastmcp_openapi_server_with_all_types._tool_manager.list_tools() + tools = fastmcp_openapi_server_with_all_types._tool_manager._list_tools() tool = next(iter(tools)) # Verify repr doesn't cause recursion and contains expected elements @@ -1790,7 +1790,7 @@ class TestReprMethods: ): """Test that OpenAPIResourceTemplate's __repr__ method works without recursion errors.""" templates = list( - fastmcp_openapi_server_with_all_types._resource_manager.get_templates().values() + fastmcp_openapi_server_with_all_types._resource_manager.get_resource_templates().values() ) template = next(iter(templates)) @@ -1836,7 +1836,7 @@ class TestEnumHandling: ) # Get the tools from the server - tools = server._tool_manager.list_tools() + tools = server._tool_manager._list_tools() # Find the read_item tool read_item_tool = next((t for t in tools if t.name == "read_item_items"), None) @@ -1929,7 +1929,7 @@ class TestRouteMapWildcard: ) # All operations should be mapped to tools - tools = mcp._tool_manager.list_tools() + tools = mcp._tool_manager._list_tools() tool_names = {tool.name for tool in tools} # Check that all 4 operations became tools @@ -2246,12 +2246,12 @@ class TestMCPNames: ) # Check tools use custom names - tools = server._tool_manager.list_tools() + tools = server._tool_manager._list_tools() tool_names = {tool.name for tool in tools} assert "admin_create_user" in tool_names # Check resource templates use custom names - templates = list(server._resource_manager.get_templates().values()) + templates = list(server._resource_manager.get_resource_templates().values()) template_names = {template.name for template in templates} assert "user_detail" in template_names @@ -2276,10 +2276,10 @@ class TestMCPNames: route_maps=GET_ROUTE_MAPS, ) - tools = server._tool_manager.list_tools() + tools = server._tool_manager._list_tools() tool_names = {tool.name for tool in tools} - templates = list(server._resource_manager.get_templates().values()) + templates = list(server._resource_manager.get_resource_templates().values()) template_names = {template.name for template in templates} resources = list(server._resource_manager.get_resources().values()) @@ -2330,13 +2330,13 @@ class TestMCPNames: # Check all component types all_names = [] - tools = server._tool_manager.list_tools() + tools = server._tool_manager._list_tools() all_names.extend(tool.name for tool in tools) resources = list(server._resource_manager.get_resources().values()) all_names.extend(resource.name for resource in resources) - templates = list(server._resource_manager.get_templates().values()) + templates = list(server._resource_manager.get_resource_templates().values()) all_names.extend(template.name for template in templates) # All names should be 56 characters or less @@ -2363,7 +2363,7 @@ class TestMCPNames: mcp_names=mcp_names, ) - tools = server._tool_manager.list_tools() + tools = server._tool_manager._list_tools() tool_names = {tool.name for tool in tools} assert "openapi_user_list" in tool_names @@ -2395,7 +2395,7 @@ class TestMCPNames: mcp_names=mcp_names, ) - tools = server._tool_manager.list_tools() + tools = server._tool_manager._list_tools() tool_names = {tool.name for tool in tools} assert "fastapi_create_user" in tool_names @@ -2496,7 +2496,7 @@ class TestRouteMapMCPTags: ) # Get the POST tool - tools = server._tool_manager.list_tools() + tools = server._tool_manager._list_tools() create_user_tool = next((t for t in tools if "create_user" in t.name), None) assert create_user_tool is not None, "create_user tool not found" @@ -2564,7 +2564,7 @@ class TestRouteMapMCPTags: ) # Get the resource template - templates = list(server._resource_manager.get_templates().values()) + templates = list(server._resource_manager.get_resource_templates().values()) get_user_template = next((t for t in templates if "get_user" in t.name), None) assert get_user_template is not None, "get_user template not found" @@ -2610,14 +2610,14 @@ class TestRouteMapMCPTags: ) # Check tool tags - tools = server._tool_manager.list_tools() + tools = server._tool_manager._list_tools() create_tool = next((t for t in tools if "create_user" in t.name), None) assert create_tool is not None assert "write-operation" in create_tool.tags assert "mutation" in create_tool.tags # Check resource template tags - templates = list(server._resource_manager.get_templates().values()) + templates = list(server._resource_manager.get_resource_templates().values()) detail_template = next((t for t in templates if "get_user" in t.name), None) assert detail_template is not None assert "detail" in detail_template.tags diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 403c94cae..21b13b841 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -282,7 +282,7 @@ class TestToolDecorator: return x * 2 # Verify the tags were set correctly - tools = mcp._tool_manager.list_tools() + tools = await mcp._tool_manager._list_tools() assert len(tools) == 1 assert tools[0].tags == {"example", "test-tag"} diff --git a/tests/server/test_tool_annotations.py b/tests/server/test_tool_annotations.py index dd569d9e4..84d031df9 100644 --- a/tests/server/test_tool_annotations.py +++ b/tests/server/test_tool_annotations.py @@ -22,7 +22,7 @@ async def test_tool_annotations_in_tool_manager(): return message # Check internal tool objects directly - tools = mcp._tool_manager.list_tools() + tools = mcp._tool_manager._list_tools() assert len(tools) == 1 assert tools[0].annotations is not None assert tools[0].annotations.title == "Echo Tool" @@ -124,7 +124,7 @@ async def test_direct_tool_annotations_in_tool_manager(): return {"modified": True, **data} # Check internal tool objects directly - tools = mcp._tool_manager.list_tools() + tools = mcp._tool_manager._list_tools() assert len(tools) == 1 assert tools[0].annotations is not None assert tools[0].annotations.title == "Direct Tool" @@ -183,7 +183,7 @@ async def test_add_tool_method_annotations(): mcp.add_tool(tool) # Check internal tool objects directly - tools = mcp._tool_manager.list_tools() + tools = mcp._tool_manager._list_tools() assert len(tools) == 1 assert tools[0].annotations is not None assert tools[0].annotations.title == "Create Item" diff --git a/tests/server/test_tool_exclude_args.py b/tests/server/test_tool_exclude_args.py index efe8349a7..cc57bf3c2 100644 --- a/tests/server/test_tool_exclude_args.py +++ b/tests/server/test_tool_exclude_args.py @@ -19,7 +19,7 @@ async def test_tool_exclude_args_in_tool_manager(): pass return message - tools = mcp._tool_manager.list_tools() + tools = mcp._tool_manager._list_tools() assert len(tools) == 1 assert "state" not in echo.parameters["properties"] @@ -60,7 +60,7 @@ async def test_add_tool_method_exclude_args(): mcp.add_tool(tool) # Check internal tool objects directly - tools = mcp._tool_manager.list_tools() + tools = mcp._tool_manager._list_tools() assert len(tools) == 1 assert "state" not in tools[0].parameters["properties"]